feat: tool exposure / discovery-strategy config#21
Merged
kauandotnet merged 3 commits intomainfrom Apr 23, 2026
Merged
Conversation
Introduces McpModuleOptions.exposure so large tool catalogs can avoid bloating the initial prompt. Four kinds: eager (current behavior), search (Anthropic Tool Search Tool annotations), lazy (vendor-neutral on-demand discovery via list_available_tools + get_tool_schema meta- tools), typed-api (reserved for Code Mode). Static strategies or per-client resolver functions are supported; ship preferSearchElseLazy as the common tiering preset. Client capability is detected via the anthropic-beta header (no model regex), so new Claude models pick up search support automatically. All new fields on McpModuleOptions, ToolOptions, and ToolMetadata are optional — existing servers behave as before. No MCP protocol extensions; search emits _meta.defer_loading annotations and lazy exposes normal MCP meta-tools.
|
To view this pull requests documentation preview, visit the following URL: Documentation is deployed and generated using docs.page. |
Replaces ExposureService's `canResolveToLazy` heuristic (always true for any function resolver) with a declarative contract: wrap a resolver with `defineResolver([...kinds], fn)` to tell the service which strategy kinds it can actually produce. - When the declaration is present, the service uses it for exact static analysis — resolvers that only return `search` no longer register the `lazy` meta-tools, eliminating phantom tools that were invokable but hidden from `tools/list`. - When absent, the service falls back to the previous conservative behaviour and logs a one-time warning pointing to `defineResolver`. - `preferSearchElseLazy` auto-applies the declaration, so the common case needs no code change. - `RESOLVER_KINDS` lives as a module-local Symbol; the property is non-enumerable and frozen so it never leaks into `Object.keys` or gets mutated post-construction. Backwards compatible: plain function resolvers still work.
A Matt-Pocock-flavored pass on the feature to eliminate the `as unknown as`
laundering casts and the `Record<string, unknown>` proliferation that was
shipping with the first cut.
## `never[]` as the universal constructor parameter type
`ToolMetadata.target`, `ResourceMetadata.target`, `ResourceTemplateMetadata.target`,
and `PromptMetadata.target` moved from `abstract new (...args: unknown[]) => unknown`
to `abstract new (...args: never[]) => unknown`. In strict function-type mode,
`unknown` in a contravariant position *rejects* specific constructors because
`unknown` isn't assignable to their real parameter types. `never` is the bottom
type, so every concrete constructor is structurally assignable with a single
cast. Drops the `as unknown as` chain from two meta-tool registrations and
collapses the fixture casts in tests to `target: class {}` with no cast at all.
Existing decorator sites still compile unchanged (they widen to `unknown[]`
which is assignable to `never[]` at the callsite).
## `ToolListEntry` replaces `Record<string, unknown>`
Introduces `ToolListEntry` in `@nest-mcp/common` — the MCP-spec shape of a
single `tools/list` entry. Threaded through `McpExecutorService.buildToolEntries`,
`ExposureService.applyStrategy`, and `McpTestApp.listTools`. Removes cast-per-
access patterns like `entry.name as string` and `(entry._meta as Record<string,
unknown> | undefined) ?? {}`.
## `defineResolver` preserves its kinds in the return type
`DeclaredExposureStrategyResolver<K>` carries the declared kinds as a
readonly literal tuple via `const K` type parameter inference. The resolver's
return is narrowed to `Extract<ExposureStrategy, { kind: K }>`, so callers
that introspect the resolver see the exact kind set — not the widened union.
## Exhaustiveness + cleaner validation flow
- `applyStrategy`'s switch ends with `const _exhaustive: never = strategy;`
so adding a new `kind` becomes a compile error instead of a silent no-op.
- `validateSearchReachable` short-circuits at the top for resolver-form
configs (they can't be statically validated) instead of the prior
`canResolveToSearch` + re-check dance.
- `lazyHint()` returns `LazyStrategyOptions | undefined` instead of a
hand-rolled structural subset.
- `applySearch`/`applyLazy` take `ToolSelector | undefined` directly
instead of `Parameters<typeof isEager>[1]`.
- `GetToolSchemaResult.schemas[].annotations` is `ToolAnnotations` instead
of `Record<string, unknown>`.
No runtime behavior change; 1,544 tests still pass.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
McpModuleOptions.exposureso large tool catalogs can avoid bloating the initial prompt.eager(current behavior),search(Anthropic Tool Search Tool),lazy(vendor-neutral on-demand discovery),typed-api(reserved for Code Mode).preferSearchElseLazypreset. Capability detection uses theanthropic-betaheader — no model regex.How it works
kind: 'search'annotates deferred tools with_meta.defer_loading: trueso Anthropic-aware clients route them through the Tool Search Tool beta (advanced-tool-use-2025-11-20). No protocol extensions.kind: 'lazy'ships two synthesized meta-tools —list_available_tools(paginated index, no schemas) andget_tool_schema(batched schema fetch) — that any MCP client can call. Deferred tools are filtered out oftools/listso only the eager subset + the meta-tools appear up front.@Tool({ tags, exposure })accept new optional fields ('eager' | 'deferred' | 'auto'). Decorator override beats module policy.Test plan
pnpm -r test— 1,537 tests pass across common/server/gateway/clientExposureService(strategy resolution, all three runtime kinds, meta-tool registration ordering, bootstrap validators);list_available_tools+get_tool_schemapure functions;selector(isEager,matchesSelector);buildClientContext(header parsing);clientSupports.search(beta header detection);preferSearchElseLazy(tiering)pnpm lint— clean (3 pre-existing complexity warnings unchanged)pnpm build— clean across all packagesFollow-ups not in this PR
kind: 'typed-api'throws"not implemented"at resolve time; Code Mode lands in a later PR once there are clients to target.clientInfo/modelfields onClientContextare defined but not yet populated by the transport — theanthropic-betaheader is the only live signal in this PR.UpstreamManagerServicealready buildsToolMeta[]) but out of scope here.