Skip to content

Make server extension handlers composable with filters and subscriptions #1704

Description

@halter73

Problem

#1693 moves Tasks out of Core. That split is useful only if extension packages can layer behavior onto the normal server pipeline without relying on private implementation details.

The current extension seams have three gaps:

  • CallToolWithAlternateFilters is mutually exclusive with normal CallToolFilters. A task wrapper can therefore displace filters used for validation, telemetry, and ASP.NET authorization.
  • Custom extension RPCs use raw JsonRpcRequest handlers. They do not get typed parameters/results or method-specific typed filters through the same pipeline as built-in methods.
  • subscriptions/listen grants are closed over the built-in tools, prompts, and resources fields. Extensions cannot implement their own subscription fields cleanly.

Subscription delivery has an additional transport constraint. The current Core fan-out model tracks active listeners on one long-lived McpServerImpl, which works for stdio and legacy stateful Streamable HTTP. It does not work for stateless Streamable HTTP, where each request has an independent server instance. Stateless delivery is tracked by #1662.

Proposed changes

Compose alternate tools/call behavior with the normal pipeline

  • Build the ordinary tools/call pipeline first, including primitive matching, CallToolFilters, request scoping, logging, and exception handling.
  • Adapt its normal result to ResultOrAlternate<CallToolResult>, then apply alternate-result filters around that adapter.
  • Keep an explicit CallToolWithAlternateHandler as the low-level full-replacement escape hatch. Reject combinations whose semantics cannot be preserved with an actionable configuration error.
  • Have the Tasks extension wrap the ordinary pipeline rather than replace it. Ordinary filters must run exactly once before a task-backed primitive executes.
  • Document the timing for task-backed calls, including whether an authorization denial is surfaced before or after the task record is created.

Add filterable typed custom RPCs

  • Keep raw custom handlers as an escape hatch and add a global raw custom-handler filter pipeline for coarse authorization, logging, and telemetry.
  • Add a typed custom-handler registration shape carrying the method name, JsonTypeInfo<TParams>, JsonTypeInfo<TResult>, a typed RequestContext<TParams> handler, and optional method-specific typed filters.
  • Preserve per-request DI scopes and source-generated serialization through the typed path.
  • Define and document filter ordering. Global raw filters should wrap typed filters, which should wrap the typed handler before result serialization.
  • Validate collisions with built-in, raw custom, and typed custom methods. Fail clearly when a configured typed filter uses parameter or result types that do not match its handler.
  • Move tasks/get, tasks/update, and tasks/cancel to the typed path, retaining protocol-version and Tasks capability checks.

Make subscriptions/listen extensible without coupling it to one delivery model

  • Allow extension-owned fields in SubscriptionsListenNotifications.
  • Add subscription grant processors that can inspect a request, validate capabilities or authorization, and populate the granted acknowledgement.
  • Do not invoke extension grant processors when the transport cannot deliver any granted subscriptions. Until Support server to client streaming (subscriptions/listen fan-out) over stateless Streamable HTTP #1662 lands, stateless HTTP should acknowledge no grants without task-store lookups, capability errors, or other extension side effects.
  • Use the grant hook for Tasks taskIds. Grant only recognized task IDs and require the Tasks capability.
  • Keep Core responsible for subscription ID metadata and routing, but do not make the current per-McpServerImpl _activeSubscriptions registry part of the public contract.
  • Coordinate the public notification delivery abstraction with Support server to client streaming (subscriptions/listen fan-out) over stateless Streamable HTTP #1662. It must be able to support a held-open stateless listen request or another transport-neutral backend rather than assuming the publisher owns every active listener.
  • SendTaskStatusNotificationAsync can remain the stable Tasks-facing API and delegate to the resulting Core delivery abstraction.
  • If this work lands before Support server to client streaming (subscriptions/listen fan-out) over stateless Streamable HTTP #1662, scope notification fan-out explicitly to listeners owned by the current long-lived server instance, keep the Core seam experimental, and document that stateless Streamable HTTP delivery is not yet supported.

The filtering and typed-handler portions should not be blocked on #1662. The DTO and grant-processing seams can also land independently. Only the final public delivery contract needs coordination.

Tests and documentation

Add coverage for:

  • Normal and alternate tool filter composition and ordering.
  • Task-backed tools running ordinary CallToolFilters exactly once with the matched primitive available.
  • An ASP.NET integration using AddAuthorizationFilters() plus WithTasks(). An unauthorized caller must not execute the tool, and an authorized task-backed call must complete.
  • Global raw and method-specific typed filters around custom extension methods.
  • All three task lifecycle methods participating in typed filters.
  • Handler collisions, mismatched typed filters, request scopes, and serialization behavior.
  • Task subscription grants, capability failures, subscription metadata, and suppression of notifications without a matching grant on long-lived transports.
  • Stateless HTTP acknowledging no extension grants and not invoking extension grant processors until Support server to client streaming (subscriptions/listen fan-out) over stateless Streamable HTTP #1662 provides delivery.

Mark the new Core extensibility APIs experimental and document their ordering, replacement semantics, and transport limitations.

Why

This preserves existing authorization and policy behavior while allowing Tasks and future protocol extensions to remain separate packages. It also gives extensions a reusable, typed composition model instead of requiring each package to duplicate Core's private dispatch, filtering, serialization, and subscription machinery.

Related work

Out of scope

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions