diff --git a/.changeset/ai-mcp-extension-setup.md b/.changeset/ai-mcp-extension-setup.md new file mode 100644 index 0000000000..59ae4670fc --- /dev/null +++ b/.changeset/ai-mcp-extension-setup.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-mcp': minor +--- + +Serve and call MCP extensions. `createMCPServer` takes a `setup` function. It gets each SDK `McpServer` before it connects, so a server can declare an extension in `capabilities.extensions` and add handlers for its methods. `MCPClient.request` sends any request with the SDK `Client.request`, including the methods of an extension. diff --git a/docs/config.json b/docs/config.json index 3e6326867d..d94d7a08dc 100644 --- a/docs/config.json +++ b/docs/config.json @@ -565,7 +565,7 @@ "label": "Manual MCP", "to": "tools/mcp-manual", "addedAt": "2026-06-05", - "updatedAt": "2026-09-29" + "updatedAt": "2026-10-21" }, { "label": "MCP Type Generation", @@ -588,7 +588,7 @@ "label": "Serve Tools over HTTP", "to": "mcp/server", "addedAt": "2026-09-22", - "updatedAt": "2026-10-06" + "updatedAt": "2026-10-21" }, { "label": "Serve Resources and Prompts", diff --git a/docs/mcp/server.md b/docs/mcp/server.md index b9606a2875..bbb05e1d45 100644 --- a/docs/mcp/server.md +++ b/docs/mcp/server.md @@ -170,6 +170,40 @@ const server = createMCPServer({ }) ``` +## Serve an MCP extension + +An [MCP extension](https://modelcontextprotocol.io/extensions/overview) adds methods that the core spec does not have. For example, the [Skills extension](https://modelcontextprotocol.io/extensions/skills/overview) adds `skills/list` and `skills/get`. A client uses an extension only when the server lists it in `capabilities.extensions`. + +Pass `setup` to get each SDK `McpServer` before it connects. Declare the extension there, then add a handler for each of its methods: + +```ts +import { createMCPServer } from '@tanstack/ai-mcp/server' +import { z } from 'zod' + +const server = createMCPServer({ + name: 'notes', + version: '1.0.0', + setup: (mcp) => { + mcp.server.registerCapabilities({ + extensions: { 'com.example/notes': {} }, + }) + mcp.server.setRequestHandler( + 'notes/count', + { params: z.object({}) }, + () => ({ count: 3 }), + ) + }, +}) +``` + +Rules for `setup`: + +- It runs each time the server builds an `McpServer`. That is each request, or once for each spec 2025 session when `sessions` is `'memory'`. Keep it fast. +- It must be synchronous. The server connects as soon as `setup` returns, so an `async` function fails the request. +- Add new methods only. Do not replace a method the server already handles, such as `tools/call`. + +To call the method from a client, see [Extension methods](../tools/mcp-manual#extension-methods). + ## Shape the result yourself The server parses the tool output with its `outputSchema`. If the output does not match, the call returns a tool error that names the tool. The server sends the output as one text block. An object also goes on `structuredContent`. When you want more than one block, or `isError` without an exception, return an MCP `CallToolResult` from a tool with no `outputSchema`. The server sends it as is. diff --git a/docs/tools/mcp-manual.md b/docs/tools/mcp-manual.md index 0fea893a99..9c6a28d008 100644 --- a/docs/tools/mcp-manual.md +++ b/docs/tools/mcp-manual.md @@ -102,6 +102,41 @@ const stream = chat({ In a [multi-server pool](./mcp#per-server-access), each client has its own value: `pool.clients.github!.instructions`. +## Extension methods + +A server can serve an [MCP extension](https://modelcontextprotocol.io/extensions/overview), with methods that `MCPClient` has no helper for. Call them with `mcp.request`. It takes the same arguments as the SDK `Client.request`, and it uses the client's `requestOptions`. + +1. Declare the extension in `clientOptions.capabilities.extensions`. The spec asks both sides to list the extensions they support, and a server can change what it sends based on this list. +2. After connect, look for the extension in `mcp.capabilities.extensions`. +3. Call the method. For a method that is not in the MCP spec, pass a result schema as the second argument. + +```ts +import { createMCPClient } from '@tanstack/ai-mcp' +import { z } from 'zod' + +const mcp = await createMCPClient({ + transport: { type: 'http', url: process.env.MCP_URL! }, + clientOptions: { + capabilities: { extensions: { 'com.example/notes': {} } }, + }, +}) + +const extensions = mcp.capabilities.extensions +if ( + typeof extensions === 'object' && + extensions !== null && + 'com.example/notes' in extensions +) { + const notes = await mcp.request( + { method: 'notes/count', params: {} }, + z.object({ count: z.number() }), + ) + console.log(notes.count) +} +``` + +To serve an extension, see [Serve an MCP extension](../mcp/server#serve-an-mcp-extension). + ## Resources MCP resources are context documents (files, database records, web pages) the server exposes. Fetch them and inject them into `chat()` as content parts. diff --git a/packages/ai-mcp/skills/ai-mcp/SKILL.md b/packages/ai-mcp/skills/ai-mcp/SKILL.md index d5b580c6b4..feeae587a2 100644 --- a/packages/ai-mcp/skills/ai-mcp/SKILL.md +++ b/packages/ai-mcp/skills/ai-mcp/SKILL.md @@ -236,6 +236,7 @@ Use the MCP names: `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWor A host skips its confirmation for a tool with `readOnlyHint: true`. Set `metadata._meta` to send the MCP tool `_meta`, for example `{ ui: { resourceUri: 'ui://view' } }` for an MCP Apps view. Pass `onerror` to `createMCPServer` to log transport and protocol errors from the SDK. `serveMCPStdio` also sends its transport errors there. +Pass `setup: (server) => {}` to `createMCPServer` to change each SDK `McpServer` before it connects. It must be synchronous. Serve an MCP extension there: `server.server.registerCapabilities({ extensions: { [id]: {} } })`, then `server.server.setRequestHandler(method, { params }, handler)` for each method. A tool with no `outputSchema` can return an MCP `CallToolResult`. The server sends it as is: its content blocks, its `structuredContent`, and its `isError`. @@ -297,6 +298,7 @@ If the server does not support that spec, the client uses the 2025 initialize ha The client keeps negotiation mode `auto`. `client.instructions` holds the server's instructions from the handshake, or `undefined` when the server sends none. Put them in the system prompt: `systemPrompts: client.instructions ? [client.instructions] : []`. +`client.request` takes the same arguments as the SDK `Client.request`, with the client's `requestOptions`. Use it for an MCP extension method: check `client.capabilities.extensions`, then pass the request and a result schema. Two more client options: diff --git a/packages/ai-mcp/src/client.ts b/packages/ai-mcp/src/client.ts index 64726c9c3b..6e6032f84d 100644 --- a/packages/ai-mcp/src/client.ts +++ b/packages/ai-mcp/src/client.ts @@ -41,8 +41,12 @@ import type { McpSubscription, Prompt, ReadResourceResult, + Request as McpRequest, + RequestMethod, + RequestOptions, Resource, ResourceTemplateType, + StandardSchemaV1, Tool as McpToolDef, Transport, } from '@modelcontextprotocol/client' @@ -130,6 +134,14 @@ export interface MCPClient< args?: TServer['tools'][TName]['input'], options?: { signal?: AbortSignal }, ) => Promise> + /** + * Sends one request with the SDK `Client.request`, with the same + * arguments. Use it for a method this client has no helper for, such as + * a method of an MCP extension. Pass a result schema as the second + * argument for a method that is not in the MCP spec. The client's + * `requestOptions` apply under the options you pass. + */ + request: Client['request'] /** * The ORIGINAL connection descriptor this client was created from — the * `transport` input and `prefix` passed to `createMCPClient`. Used by @@ -479,6 +491,28 @@ class MCPClientImpl< return result as TypedCallToolResult } + // The SDK method is overloaded: (request, options?) for a spec method, and + // (request, resultSchema, options?) for any other. This takes both shapes + // and forwards the same one, so the casts to the SDK types are safe. + request = (async ( + request: McpRequest, + schemaOrOptions?: StandardSchemaV1 | RequestOptions, + options?: RequestOptions, + ) => { + if (this.#closed) throw new MCPConnectionError('MCP client is closed') + const defaults = sdkRequestOptions(this.#policy.requestOptions) + if (schemaOrOptions !== undefined && '~standard' in schemaOrOptions) { + return this.#client.request(request, schemaOrOptions, { + ...defaults, + ...options, + }) + } + return this.#client.request( + request as { method: RequestMethod; params?: Record }, + { ...defaults, ...schemaOrOptions }, + ) + }) as Client['request'] + async close(): Promise { if (this.#closed) return this.#closed = true diff --git a/packages/ai-mcp/src/server/create-server.ts b/packages/ai-mcp/src/server/create-server.ts index 54cf6a1ea2..c0790023a4 100644 --- a/packages/ai-mcp/src/server/create-server.ts +++ b/packages/ai-mcp/src/server/create-server.ts @@ -136,6 +136,18 @@ export type MCPServerOptions = { onerror?: (error: Error) => void sample?: (request: SampleRequest) => Promise waitUntil?: (promise: Promise) => void + /** + * Runs on every SDK `McpServer` this server builds, after the tools, + * resources, and prompts are registered and before the server connects. + * Declare more capabilities with `server.server.registerCapabilities` and + * add request handlers with `server.server.setRequestHandler`, for example + * to serve an MCP extension. Leave the methods this server already + * handles alone. + * + * It must be synchronous. The server connects as soon as it returns, so + * an async `setup` throws a `TypeError`. + */ + setup?: (server: McpServer) => void } /** @@ -201,6 +213,8 @@ type LegacySessions = Map * A spec 2025 client gets no session unless `options.sessions` is `'memory'`. * `options.sample` is the model adapter for `ctx.context.sample` on spec 2026. * `options.waitUntil` receives the task promise so a worker can stay alive. + * `options.setup` gets each SDK `McpServer` before it connects, so it can add + * capabilities and request handlers, such as an MCP extension. * * The result has `fetch(request)`, `handle(request, options)`, `tools`, * `resources`, and `prompts`. `handle` takes a token your own middleware @@ -335,6 +349,7 @@ export function createMCPServer< owner, authInfo, context, + onerror: options.onerror, build: (appContext) => buildMcpServer({ options, @@ -481,6 +496,10 @@ function buildMcpServer(input: { if (input.era === '2025') { registerLegacyTaskMethods(server, input.taskStore, input.owner) } + const setupResult: unknown = input.options.setup?.(server) + if (setupResult instanceof Promise) { + throw new TypeError('createMCPServer: setup must be synchronous') + } return server } @@ -983,14 +1002,22 @@ async function openLegacySession( owner: string | undefined authInfo: AuthInfo | undefined context: Record | undefined + onerror: ((error: Error) => void) | undefined build: (appContext: AppContext) => McpServer }, ) { // Before the session exists, the opening request's context applies. let session: LegacySession | undefined - const server = input.build(() => - session === undefined ? input.context : session.context, - ) + let server: McpServer + try { + server = input.build(() => + session === undefined ? input.context : session.context, + ) + } catch (error) { + // `setup` runs in build. Answer the way the SDK does on the other paths. + input.onerror?.(error instanceof Error ? error : new Error(String(error))) + return internalError() + } const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), enableJsonResponse: true, @@ -1020,6 +1047,17 @@ async function openLegacySession( } } +function internalError() { + return Response.json( + { + jsonrpc: '2.0', + id: null, + error: { code: -32603, message: 'Internal server error' }, + }, + { status: 500 }, + ) +} + function sessionNotFound() { return Response.json( { diff --git a/packages/ai-mcp/src/server/index.ts b/packages/ai-mcp/src/server/index.ts index 007550c10e..1b8982d5dc 100644 --- a/packages/ai-mcp/src/server/index.ts +++ b/packages/ai-mcp/src/server/index.ts @@ -24,5 +24,6 @@ export type { AuthInfo, AuthMetadataOptions, BearerAuthOptions, + McpServer, OAuthTokenVerifier, } from '@modelcontextprotocol/server' diff --git a/packages/ai-mcp/tests/apps/call-handler.test.ts b/packages/ai-mcp/tests/apps/call-handler.test.ts index 6abe99216b..5c5e0e0d69 100644 --- a/packages/ai-mcp/tests/apps/call-handler.test.ts +++ b/packages/ai-mcp/tests/apps/call-handler.test.ts @@ -75,6 +75,7 @@ function fakeClient(info: ServerInfo): MCPClient { prompts: unreached('prompts'), getPrompt: unreached('getPrompt'), callTool: unreached('callTool'), + request: unreached('request'), getInfo: () => info, close: unreached('close'), [Symbol.asyncDispose]: unreached('asyncDispose'), diff --git a/packages/ai-mcp/tests/client.test.ts b/packages/ai-mcp/tests/client.test.ts index 8bb42ece2f..7a31a32e74 100644 --- a/packages/ai-mcp/tests/client.test.ts +++ b/packages/ai-mcp/tests/client.test.ts @@ -8,6 +8,7 @@ import { Server } from '@modelcontextprotocol/server' import { toolDefinition } from '@tanstack/ai' import { z } from 'zod' import { createMCPClient, createMCPClientFromTransport } from '../src/client' +import { createMCPServer } from '../src/server/create-server' import { DuplicateToolNameError, MCPConnectionError, @@ -63,6 +64,40 @@ describe('createMCPClient', () => { expect(client.instructions).toBeUndefined() }) + it('sends an extension method with request and refuses it after close', async () => { + const server = createMCPServer({ + name: 'greeter', + version: '1.0.0', + setup: (built) => { + built.server.registerCapabilities({ + extensions: { 'com.example/greetings': {} }, + }) + built.server.setRequestHandler( + 'greetings/get', + { params: z.object({ name: z.string() }) }, + ({ name }) => ({ text: `Hello, ${name}` }), + ) + }, + }) + const client = await createMCPClient({ + transport: { + type: 'http', + url: 'https://mcp.example.com/mcp', + fetch: async (input, init) => server.fetch(new Request(input, init)), + }, + }) + + const greet = () => + client.request( + { method: 'greetings/get', params: { name: 'Ada' } }, + z.object({ text: z.string() }), + ) + expect(await greet()).toEqual({ text: 'Hello, Ada' }) + + await client.close() + await expect(greet()).rejects.toThrow(MCPConnectionError) + }) + it('binds passed toolDefinitions to the server, typed + validated', async () => { const { clientTransport } = await makeServerWithWeatherTool() await using client = await createMCPClientFromTransport(clientTransport) @@ -701,6 +736,13 @@ describe('requestOptions', () => { ) await expect(client.prompts()).rejects.toThrow('Request timed out') await expect(client.getPrompt('brief')).rejects.toThrow('Request timed out') + // Both request shapes: no schema, and a result schema. + await expect(client.request({ method: 'resources/list' })).rejects.toThrow( + 'Request timed out', + ) + await expect( + client.request({ method: 'prompts/list' }, z.object({})), + ).rejects.toThrow('Request timed out') }, 5000) it('sends the timeout with the spec 2025 listTools cache fill', async () => { diff --git a/packages/ai-mcp/tests/server/create-server.test.ts b/packages/ai-mcp/tests/server/create-server.test.ts index 40dffb28c1..38a2df2163 100644 --- a/packages/ai-mcp/tests/server/create-server.test.ts +++ b/packages/ai-mcp/tests/server/create-server.test.ts @@ -1314,6 +1314,72 @@ describe('createMCPServer', () => { ) }) + it('serves an extension that setup registers, on spec 2026 and spec 2025', async () => { + const cases = [ + { era: '2026', sessions: 'stateless' }, + { era: '2025', sessions: 'stateless' }, + { era: '2025', sessions: 'memory' }, + ] as const + for (const { era, sessions } of cases) { + const server = createMCPServer({ + name: 'greeter', + version: '1.0.0', + sessions, + tools: [echoTool()], + setup: (built) => { + built.server.registerCapabilities({ + extensions: { 'com.example/greetings': {} }, + }) + built.server.setRequestHandler( + 'greetings/get', + { params: z.object({ name: z.string() }) }, + ({ name }) => ({ text: `Hello, ${name}` }), + ) + }, + }) + await withClient(server, { era }, async (client) => { + expect(client.getServerCapabilities()?.extensions).toEqual({ + 'com.example/greetings': {}, + }) + const greet = () => + client.request( + { method: 'greetings/get', params: { name: 'Ada' } }, + z.object({ text: z.string() }), + ) + expect(await greet()).toEqual({ text: 'Hello, Ada' }) + // A memory session answers the second call from the same server. + expect(await greet()).toEqual({ text: 'Hello, Ada' }) + const listed = await client.listTools() + expect(listed.tools.map((tool) => tool.name)).toEqual(['echo']) + }) + } + }) + + it('reports a setup that throws or is async to onerror, in every session mode', async () => { + const setups = [ + () => { + throw new Error('setup failed') + }, + async () => {}, + ] + for (const setup of setups) { + for (const sessions of ['stateless', 'memory'] as const) { + const onerror = vi.fn() + const server = createMCPServer({ + name: 'broken', + version: '1.0.0', + sessions, + onerror, + setup, + }) + + const response = await server.fetch(initializeRequest('any')) + expect(response.status).toBe(500) + expect(onerror).toHaveBeenCalledWith(expect.any(Error)) + } + } + }) + it('reports SDK errors to onerror', async () => { const onerror = vi.fn() const server = createMCPServer({ diff --git a/testing/e2e/src/lib/mcp-typed-server.ts b/testing/e2e/src/lib/mcp-typed-server.ts index 882c804fa4..becf3ff785 100644 --- a/testing/e2e/src/lib/mcp-typed-server.ts +++ b/testing/e2e/src/lib/mcp-typed-server.ts @@ -72,6 +72,8 @@ export const typedServer = createMCPServer({ * - `notes://{noteId}` reads the template variable and the `tenant` from * the `handle` context, and lists one concrete note. * - `show_note` links an MCP Apps view through `metadata._meta`. + * - `setup` serves the `com.example/notes` extension with a `notes/count` + * method. */ const note = resourceDefinition({ name: 'note', @@ -99,4 +101,14 @@ export const statelessServer = createMCPServer({ sessions: 'stateless', tools: [showNote], resources: [note], + setup: (server) => { + server.server.registerCapabilities({ + extensions: { 'com.example/notes': {} }, + }) + server.server.setRequestHandler( + 'notes/count', + { params: z.object({}) }, + () => ({ count: 1 }), + ) + }, }) diff --git a/testing/e2e/src/routes/api.mcp-typed-test.ts b/testing/e2e/src/routes/api.mcp-typed-test.ts index d046abd195..05a37f45a5 100644 --- a/testing/e2e/src/routes/api.mcp-typed-test.ts +++ b/testing/e2e/src/routes/api.mcp-typed-test.ts @@ -8,6 +8,7 @@ import { import { createMCPClient } from '@tanstack/ai-mcp' import type { StreamChunk } from '@tanstack/ai' import type { DescriptorFromServer, MCPClient } from '@tanstack/ai-mcp' +import { z } from 'zod' import { createTextAdapter } from '@/lib/providers' import type { typedServer } from '@/lib/mcp-typed-server' @@ -43,12 +44,35 @@ async function* closeMcpOnDrain( * * - GET returns the discovered tools, so a test can read `outputSchema`. * It also calls `forecast` with the typed `callTool`. + * - GET `?extension` calls the `notes/count` extension method of the + * stateless server with `request`. * - POST runs chat() with those tools. */ export const Route = createFileRoute('/api/mcp-typed-test')({ server: { handlers: { GET: async ({ request }) => { + const url = new URL(request.url) + if (url.searchParams.has('extension')) { + const notes = await createMCPClient({ + transport: { + type: 'http', + url: `${url.origin}/api/mcp-typed-server?stateless`, + }, + }) + try { + const counted = await notes.request( + { method: 'notes/count', params: {} }, + z.object({ count: z.number() }), + ) + return Response.json({ + extensions: notes.capabilities.extensions ?? null, + counted, + }) + } finally { + await notes.close() + } + } const mcp = await connect(request, 'alice') try { const tools = await mcp.tools() diff --git a/testing/e2e/tests/mcp-typed.spec.ts b/testing/e2e/tests/mcp-typed.spec.ts index f019e877ce..0ab4654763 100644 --- a/testing/e2e/tests/mcp-typed.spec.ts +++ b/testing/e2e/tests/mcp-typed.spec.ts @@ -224,6 +224,18 @@ test.describe('mcp: createMCPServer with auth, output schema, and a task', () => expect(other.status()).toBe(404) }) + test('a client calls an extension method that setup serves', async ({ + request, + }) => { + const res = await request.get('/api/mcp-typed-test?extension') + const body = await res.text() + expect(res.ok(), body).toBe(true) + expect(JSON.parse(body)).toEqual({ + extensions: { 'com.example/notes': {} }, + counted: { count: 1 }, + }) + }) + test('a stateless server serves spec 2025 with resource context and tool _meta', async ({ request, }) => {