Skip to content

feat: tool exposure / discovery-strategy config#21

Merged
kauandotnet merged 3 commits intomainfrom
feat/tool-exposure-strategy
Apr 23, 2026
Merged

feat: tool exposure / discovery-strategy config#21
kauandotnet merged 3 commits intomainfrom
feat/tool-exposure-strategy

Conversation

@kauandotnet
Copy link
Copy Markdown
Collaborator

Summary

  • Add McpModuleOptions.exposure so large tool catalogs can avoid bloating the initial prompt.
  • Four strategy kinds: eager (current behavior), search (Anthropic Tool Search Tool), lazy (vendor-neutral on-demand discovery), typed-api (reserved for Code Mode).
  • Per-client tiering via optional resolver function + ship preferSearchElseLazy preset. Capability detection uses the anthropic-beta header — no model regex.

How it works

  • kind: 'search' annotates deferred tools with _meta.defer_loading: true so 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) and get_tool_schema (batched schema fetch) — that any MCP client can call. Deferred tools are filtered out of tools/list so 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.
  • All new fields are optional; existing servers behave unchanged.

Test plan

  • pnpm -r test — 1,537 tests pass across common/server/gateway/client
  • New coverage: ExposureService (strategy resolution, all three runtime kinds, meta-tool registration ordering, bootstrap validators); list_available_tools + get_tool_schema pure 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 packages

Follow-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 / model fields on ClientContext are defined but not yet populated by the transport — the anthropic-beta header is the only live signal in this PR.
  • Gateway-side integration of the exposure transform for aggregated upstream catalogs is straightforward (UpstreamManagerService already builds ToolMeta[]) but out of scope here.

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.
@docs-page
Copy link
Copy Markdown

docs-page Bot commented Apr 23, 2026

To view this pull requests documentation preview, visit the following URL:

docs.page/btwld/nest-mcp~21

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.
@kauandotnet kauandotnet merged commit 58bfe89 into main Apr 23, 2026
2 checks passed
@kauandotnet kauandotnet deleted the feat/tool-exposure-strategy branch April 23, 2026 23:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant