Skip to content

feat: server and client extensions, request middleware (use), acceptResultType - #2820

Draft
mattzcarey wants to merge 9 commits into
mainfrom
feat/server-extensions
Draft

mattzcarey wants to merge 9 commits into
mainfrom
feat/server-extensions

Conversation

@mattzcarey

@mattzcarey mattzcarey commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Draft, first of a stack. This PR adds the extension hooks; #2782 (rebased on top of it) adds the Tasks extension as its first consumer.

What this adds

A way to package protocol behaviour outside the core spec — an MCP extension such as io.modelcontextprotocol/tasks, or a vendor feature — as one object the server installs:

const server = new McpServer({ name: 'gated', version: '1.0.0' }, { extensions: [tasks] });
  • ServerOptions.extensions takes ServerExtension objects: { id, install(server) }. Each is advertised under capabilities.extensions[id] as {} and installed at construction, in order, after the built-in handlers exist. Everything else an extension does to the protocol, its capability settings included (registerCapabilities), happens in install: the interface is deliberately code, not declaration. Same option on the low-level Server.
  • Protocol.use(method, (request, ctx, next) => …) is request middleware, the new hook an extension uses to intercept a spec method. setRequestHandler is the route handler; use is the middleware around it, Koa-shaped: await next(request, ctx) yields the result and the middleware returns what goes on the wire. Middleware composes at dispatch time, so middleware on tools/call installed at construction still applies even though McpServer registers that handler on the first tool registration. With no underlying handler, next throws MethodNotFound. Middleware runs in one registration order, first installed outermost; the returned function removes it. A thrown ProtocolError becomes the JSON-RPC error response. Method names are exact, no wildcard: what every peer must know about an extension goes through registerCapabilities, not per-request _meta.
  • Protocol.acceptResultType(method, resultType) declares an extension result kind for a method. A raw response carrying that resultType bypasses the era codec's closed vocabulary and is validated against the caller's explicit result schema as-is, discriminator included. This is how a client extension receives shapes such as the Tasks extension's resultType: "task" on tools/call ([v2] Tasks extension: tools/call rejects CreateTaskResult but accepts an omitted discriminator as complete #2637) without the codec rejecting them; typed spec calls keep the closed vocabulary.
  • ClientOptions.extensions is the symmetric client half: ClientExtension objects { id, install(client) }, advertised under the client's capabilities.extensions[id] — in initialize on a legacy connection, in every request's _meta client-capabilities envelope on 2026-07-28, which is where a server extension reads it — and installed with the Client (handlers for server-to-client requests, or middleware around the SDK's own).
  • Custom methods keep using the existing explicit-schema setRequestHandler(method, { params, result }, handler); nothing new there.
  • McpServer tool dispatch re-throws MissingRequiredClientCapabilityError (-32021) as a JSON-RPC error instead of converting it into an isError tool result, the same passthrough UrlElicitationRequiredError already has. Extensions that gate tools/call on a client capability need the client to see the error as an error.

The SDK owns the hooks and the capability advertisement. How an extension stores state or where its work runs is the extension's own.

Tests

  • packages/core-internal/test/shared/requestMiddleware.test.ts — wraps and transforms, answers without next, throws become JSON-RPC errors, applies to a later-registered handler, MethodNotFound with nothing underneath, one registration order across methods with a _meta stamp through next, removal.
  • packages/client/test/client/extensions.test.ts — install at construction, {} default, the extension in initialize on a legacy connection, stamped into every request envelope on 2026-07-28, an accepted extension result kind reaching the caller's schema, and a server-to-client request served by the handler the extension installed.
  • packages/server/test/server/extensions.test.ts — advertised capability with settings set from install, {} when install sets none (through McpServer), extension custom method served, tools/call middleware refusing with -32021 and passing through with the capability, -32021 from inside a tool handler is a JSON-RPC error.
  • Full suites: core-internal 1465, server 524, client 897, all green.

Docs

docs/advanced/extensions.md, linked from the Advanced nav next to Custom methods.

Not in this PR

  • No wildcard or path matching, no plugin registry; kept deliberately small.

@changeset-bot

changeset-bot Bot commented Sep 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: af8889b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@modelcontextprotocol/core-internal Minor
@modelcontextprotocol/client Minor
@modelcontextprotocol/server Minor
@modelcontextprotocol/codemod Minor
@modelcontextprotocol/core Minor
@modelcontextprotocol/server-legacy Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Sep 16, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2820

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@2820

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@2820

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2820

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@2820

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2820

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2820

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2820

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2820

commit: af8889b

@mattzcarey mattzcarey changed the title feat(server): server extensions and Protocol.overrideRequestHandler feat: server and client extensions, Protocol.overrideRequestHandler Sep 17, 2026
@mattzcarey mattzcarey changed the title feat: server and client extensions, Protocol.overrideRequestHandler feat: server and client extensions, overrideRequestHandler, acceptResultType Sep 17, 2026
@mattzcarey mattzcarey changed the title feat: server and client extensions, overrideRequestHandler, acceptResultType feat: server and client extensions, request middleware (use), acceptResultType Sep 17, 2026
@mattzcarey
mattzcarey added this pull request to stack #2826 September 17, 2026 14:18
ServerOptions.extensions takes ServerExtension objects ({ id, capability?,
install(server) }). Each is advertised under capabilities.extensions[id]
and installed at construction, after the built-in handlers exist.

Extensions register custom methods with the explicit-schema
setRequestHandler and intercept spec methods with the new
Protocol.overrideRequestHandler(method, (request, ctx, next) => ...).
Overrides compose around the registered handler at dispatch time, so an
override on tools/call applies even though McpServer registers that
handler lazily; the returned function removes the override.

McpServer tool dispatch re-throws MissingRequiredClientCapabilityError
(-32021) as a JSON-RPC error instead of an isError tool result, matching
the UrlElicitationRequiredError passthrough.
ClientOptions.extensions takes ClientExtension objects ({ id, capability?,
install(client) }), the client half of the server extensions seam. Each is
advertised under the client's capabilities.extensions[id] (in initialize
on a legacy connection, in every request's client-capabilities envelope on
2026-07-28) and installed with the Client at construction.
…inds

A client extension declares that results of a method may carry its own
resultType (the Tasks extension answers tools/call with "task"). A raw
response with that discriminator skips the era codec's closed vocabulary
and is validated against the caller's explicit result schema as-is. Also
replaces the word 'seam' with 'hook' in the text this stack added.
…ddleware in registration order

setRequestHandler is the route handler; use(method, middleware) is the
middleware around it, Koa-shaped. Middleware now composes in registration
order (first installed outermost), matching Hono and Koa. No wildcard.
…ddleware

setRequestHandler is the route handler; use(method, middleware) is the
middleware around it, Koa-shaped. Middleware composes in one registration
order across methods (first installed outermost), matching Hono and Koa,
and use(middleware) / use('*', middleware) runs on every request so an
extension can stamp its own _meta key on every result.
…stall

The optional capability settings field is gone from ServerExtension and
ClientExtension. The constructor advertises {} under the extension id;
an extension with settings calls registerCapabilities in install, where
everything else it does to the protocol already happens.
What every peer must know about an extension goes through
registerCapabilities, not per-request middleware.
@mattzcarey
mattzcarey force-pushed the feat/server-extensions branch from e056cf6 to af8889b Compare September 17, 2026 14:23
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