Skip to content

Make the alternate-result seam method-agnostic - #1780

Open
onatozmenn wants to merge 1 commit into
modelcontextprotocol:mainfrom
onatozmenn:feat/method-agnostic-alternate-result-filters
Open

Make the alternate-result seam method-agnostic#1780
onatozmenn wants to merge 1 commit into
modelcontextprotocol:mainfrom
onatozmenn:feat/method-agnostic-alternate-result-filters

Conversation

@onatozmenn

Copy link
Copy Markdown

Closes part of #1704, specifically @jeffhandley's method-agnostic point. @PranavSenthilnathan asked to hold this until after 2.0.0 shipped, and v2.0.0 went out on 2026-07-28, so here it is.

Problem

CallToolWithAlternateFilters is tools/call-shaped. Adding alternate-result support for a second method means a new <Method>WithAlternateHandler/<Method>WithAlternateFilters pair plus new Core wiring every time, which is the thing the original review feedback wanted to avoid.

What this does

Adds a method-keyed store, AlternateResultFilterCollection, plus:

options.AddAlternateResultFilter<GetPromptRequestParams, GetPromptResult>(
    RequestMethods.PromptsGet, filter);

CallToolWithAlternateFilters becomes a typed view over the tools/call entry of that same store. Filters added through either API land in one ordered list, so the Tasks extension and AuthorizationFilterSetup keep working untouched. Nothing in the existing shape changes.

Built-in typed handlers now go through SetHandlerWithAlternateSupport, which is a no-op unless filters were registered for that method. When they were, the ordinary handler is adapted to ResultOrAlternate<TResult> and the filters compose around it.

The design call I had to make

In the issue I flagged that the lifecycle logging and primitive matching inside BuildComposedCallToolHandler are tool-specific, so generalizing means deciding how much of that becomes per-method policy.

I went with the narrow answer: the generic seam only does adaptation and filter composition. Primitive matching, ToolCallCompleted/ToolCallError logging, and ComposedCallToolInvocationState all stay in the tools/call path. A future task-enabled method brings its own policy rather than inheriting the tool one.

Happy to go the other way if you'd rather Core owned a shared lifecycle abstraction, but that felt like it would bake tool semantics into methods that don't want them.

One behavior addition worth calling out: registering filters for a method the server doesn't dispatch now throws at construction. Without that, a typo or a missing capability silently drops the filters, which is a rough thing to debug. Empty registrations don't trip it, so just reading CallToolWithAlternateFilters is still free.

Tests

tests/ModelContextProtocol.Tests/Server/AlternateResultFilterTests.cs, driven through prompts/get and prompts/list rather than tools, to show it isn't tool-specific:

  • a filter on prompts/get returns a different Result subtype and the client sees that shape on the wire
  • the same filter delegating to next still returns the ordinary GetPromptResult
  • two filters on prompts/list run in registration order
  • CallToolWithAlternateFilters and AddAlternateResultFilter(RequestMethods.ToolsCall, ...) resolve to the same list instance
  • registering one method with two different result types throws
  • filters for an undispatched method throw at McpServer.Create, and an empty registration doesn't

I also broke it twice to check the tests actually hold: forcing SetHandlerWithAlternateSupport to always take the ordinary path, and disabling the registration validation. Both were caught.

Local run: 2243 passed, 0 failed on ModelContextProtocol.Tests (net10.0), skipping the integration classes that need external processes. The Server namespace alone is 579 passed.

Implemented with AI assistance.

The alternate-result augmentation seam is tools/call-shaped today, so enabling a
second method to return an alternate result would mean another
<Method>WithAlternateHandler/Filters pair plus new Core wiring each time.

Add a method-keyed store (AlternateResultFilterCollection) and
McpServerOptions.AddAlternateResultFilter<TParams, TResult>(method, filter).
CallToolWithAlternateFilters becomes a typed view over the tools/call entry of
that store, so filters registered through either API share one ordered list and
nothing existing changes shape.

Only adaptation and filter composition are generalized. Primitive matching and
tool-call lifecycle logging stay per-method, so tools/call keeps its own
composition in BuildComposedCallToolHandler.

Registering filters for a method the server does not dispatch throws at
construction rather than silently dropping them.

Signed-off-by: onatozmenn <onatozmen44@gmail.com>
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