diff --git a/.changeset/ai-mcp-param-headers.md b/.changeset/ai-mcp-param-headers.md new file mode 100644 index 0000000000..b7a19e21ce --- /dev/null +++ b/.changeset/ai-mcp-param-headers.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-mcp': patch +--- + +Send the `Mcp-Param-*` headers on spec 2026-07-28 tool calls. A tool argument marked with `x-mcp-header` in the input schema is now copied to its header, so strict servers such as GitHub's remote MCP server accept the call. Spec 2026 calls now go through the SDK `Client.callTool`, and use the SDK timeout, abort, and progress handling. diff --git a/packages/ai-mcp/src/client.ts b/packages/ai-mcp/src/client.ts index 83f8c9bfa7..d8fe9baf66 100644 --- a/packages/ai-mcp/src/client.ts +++ b/packages/ai-mcp/src/client.ts @@ -368,8 +368,9 @@ class MCPClientImpl implements Omit< // A raw list does not compile `jsonSchemaValidator`. On spec 2025, SDK // `callTool` then skips output checks. So `tools()` follows the raw walk // with `listTools()` to fill the SDK cache and run the validator. - // Spec 2026 calls use a raw tools/call, which never reads that cache, - // so the second walk is skipped there. + // Spec 2026 calls pass the listed input schema to `callTool` for the + // Mcp-Param-* headers, and do not read that cache. So the second walk is + // skipped there. // `raw: true` is the lazy `callTool` path. It must stay free of that cache. async #listTools(options?: { raw?: boolean }) { const client = this.#client @@ -433,6 +434,7 @@ class MCPClientImpl implements Omit< Boolean(def.outputSchema), requiresTaskExecution(serverTool), requestOptions, + serverTool.inputSchema, ), ) as ServerTool // A caller-supplied definition may already carry its own `mcp` block, @@ -558,6 +560,7 @@ class MCPClientImpl implements Omit< options?.signal, undefined, this.#policy.requestOptions, + definition?.inputSchema, ) // Trust boundary: the server type says what `structuredContent` holds. // The client does not check the wire value against that type. diff --git a/packages/ai-mcp/src/tools.ts b/packages/ai-mcp/src/tools.ts index d0365f7a3d..cab5522f8d 100644 --- a/packages/ai-mcp/src/tools.ts +++ b/packages/ai-mcp/src/tools.ts @@ -1,9 +1,4 @@ import { - DEFAULT_REQUEST_TIMEOUT_MSEC, - ProtocolError, - ProtocolErrorCode, - SdkError, - SdkErrorCode, isCallToolResult, isInputRequiredResult, } from '@modelcontextprotocol/client' @@ -14,7 +9,6 @@ import type { TaskStatus, Tool as McpToolDef, ToolAnnotations, - Transport, } from '@modelcontextprotocol/client' import type { AnyServerTool, @@ -147,6 +141,8 @@ export function mcpContentToTanstack( * @param signal - Stops the wait when the caller aborts * @param inputResponse - The user's answer from an `mcp_input` interrupt * @param requestOptions - The client `requestOptions`, sent with tools/call + * @param inputSchema - The listed tool's input schema. On spec 2026 the SDK + * reads its `x-mcp-header` fields to send the `Mcp-Param-*` headers. */ export async function callMcpTool( client: Client, @@ -156,52 +152,52 @@ export async function callMcpTool( signal?: AbortSignal, inputResponse?: ToolInputResponse, requestOptions?: MCPClientOptions['requestOptions'], + inputSchema?: McpToolDef['inputSchema'], ) { signal?.throwIfAborted() const isModern = client.getProtocolEra() === 'modern' - if (!taskRequired && !isModern) { - const result = await client.callTool( - { name: mcpName, arguments: args }, - { - ...sdkRequestOptions(requestOptions, signal), - allowInputRequired: true, - }, + if (taskRequired && !isModern) { + const raw = await sdkRequest( + client, + 'tools/call', + { name: mcpName, arguments: args, task: {} }, + signal, + requestOptions, ) - throwIfInputRequired(result) - return result + return finishToolCall(client, mcpName, raw, signal) } + const options = { + ...sdkRequestOptions(requestOptions, signal), + allowInputRequired: true, + // Only name and inputSchema. The SDK sends the Mcp-Param-* headers from + // the schema. With no outputSchema, it skips its output check, which + // would reject an input_required result. + ...(isModern && inputSchema !== undefined + ? { toolDefinition: { name: mcpName, inputSchema } } + : {}), + } const params = { name: mcpName, arguments: args } - let raw = isModern - ? await rawRequest(client, 'tools/call', params, signal, requestOptions) - : await sdkRequest( - client, - 'tools/call', - { name: mcpName, arguments: args, task: {} }, - signal, - requestOptions, - ) + let result = await client.callTool(params, options) // ponytail: the answer is sent on the second call, so the server state never // travels through the browser. The cost is one extra tools/call. - if (isModern && inputResponse !== undefined && isInputRequiredResult(raw)) { - raw = await rawRequest( - client, - 'tools/call', - { ...params, ...retryParams(raw, inputResponse) }, - signal, - requestOptions, + if (inputResponse !== undefined && isInputRequiredResult(result)) { + result = await client.callTool( + { ...params, ...retryParams(result, inputResponse) }, + options, ) // ponytail: one input round per call. The next resume starts with no // requestState, so a second pause would ask round 1 again forever. // Carry requestState through the interrupt if servers need more rounds. - if (isInputRequiredResult(raw)) { + if (isInputRequiredResult(result)) { throw new Error( `The MCP tool "${mcpName}" asked for input a second time. ` + 'This client answers one input request per tool call.', ) } } - return finishToolCall(client, mcpName, raw, signal) + throwIfInputRequired(result) + return result } // Answers only the first input request. That is the one the interrupt shows. @@ -270,13 +266,6 @@ type TaskState = { const defaultPollMs = 1000 -let rawRequestId = 0 - -const transportTaps = new WeakMap< - Transport, - Set<(message: unknown) => boolean> ->() - async function finishToolCall( client: Client, mcpName: string, @@ -419,128 +408,6 @@ async function sdkRequest( } } -function rawRequest( - client: Client, - method: string, - params: Record, - signal?: AbortSignal, - requestOptions?: MCPClientOptions['requestOptions'], -) { - signal?.throwIfAborted() - const transport = client.transport - if (transport === undefined) { - throw new Error('The MCP client is not connected.') - } - rawRequestId += 1 - const id = `tanstack-ai-mcp:${rawRequestId}` - const listeners = tapTransport(transport) - const body = withEnvelope(params, readEnvelope(client)) - return new Promise((resolve, reject) => { - let settled = false - // The client `timeout`, else the SDK default, so a lost response cannot - // hang the call. - // ponytail: this request sends no progressToken, so the server sends no - // progress and `resetTimeoutOnProgress` cannot apply. Send a token and - // match notifications/progress in `accept` if a spec 2026 server needs it. - const timeout = requestOptions?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC - const timer = setTimeout(() => { - finish( - new SdkError( - SdkErrorCode.RequestTimeout, - `The MCP request ${method} timed out after ${timeout} ms.`, - ), - ) - }, timeout) - const finish = (error: unknown, result?: unknown) => { - if (settled) return - settled = true - clearTimeout(timer) - listeners.delete(accept) - if (signal !== undefined) { - signal.removeEventListener('abort', onAbort) - } - if (error !== undefined) { - reject(error) - return - } - resolve(result) - } - const accept = (message: unknown) => { - if (!isRecord(message) || message.id !== id) return false - if (isRecord(message.error)) { - const { code, message: text, data } = message.error - finish( - new ProtocolError( - typeof code === 'number' ? code : ProtocolErrorCode.InternalError, - typeof text === 'string' ? text : 'The MCP request failed.', - data, - ), - ) - return true - } - finish(undefined, message.result) - return true - } - const activeSignal = signal - const onAbort = () => { - if (activeSignal === undefined) return - finish(abortReason(activeSignal)) - } - listeners.add(accept) - if (activeSignal !== undefined) { - activeSignal.addEventListener('abort', onAbort, { once: true }) - } - void transport - .send({ - jsonrpc: '2.0', - id, - method, - params: body, - }) - .catch((error: unknown) => { - finish(error) - }) - }) -} - -function tapTransport(transport: Transport) { - const existing = transportTaps.get(transport) - if (existing !== undefined) return existing - const listeners = new Set<(message: unknown) => boolean>() - transportTaps.set(transport, listeners) - const previous = transport.onmessage - transport.onmessage = (message, extra) => { - const pending = [...listeners] - for (const listener of pending) { - if (listener(message)) return - } - previous?.(message, extra) - } - return listeners -} - -function readEnvelope(client: Client) { - const value: unknown = client - if (!isRecord(value)) return undefined - const method = value._outboundMetaEnvelope - if (typeof method !== 'function') return undefined - const called: unknown = method.call(value) - if (!isRecord(called)) return undefined - return called -} - -function withEnvelope( - params: Record, - envelope: Record | undefined, -) { - if (envelope === undefined) return params - const meta = isRecord(params._meta) ? params._meta : {} - return { - ...params, - _meta: { ...envelope, ...meta }, - } -} - function cancelTask(client: Client, taskId: string) { return taskRequest(client, 'tasks/cancel', { taskId }).catch(() => undefined) } @@ -674,6 +541,7 @@ function isRecord(value: unknown): value is Record { * validation in `executeServerTool` validates MCP's typed payload rather than * a JSON-in-text blob. Otherwise normalize `content[]` → string | ContentPart[]. * @param requestOptions - The client `requestOptions`, sent with each call + * @param inputSchema - The listed tool's input schema, for the Mcp-Param-* headers */ export function makeMcpExecute( client: Client, @@ -681,6 +549,7 @@ export function makeMcpExecute( preferStructured: boolean, taskRequired = false, requestOptions?: MCPClientOptions['requestOptions'], + inputSchema?: McpToolDef['inputSchema'], ) { return async ( args: unknown, @@ -694,6 +563,7 @@ export function makeMcpExecute( ctx?.abortSignal, ctx?.inputResponse, requestOptions, + inputSchema, ) if (result.isError) { const text = Array.isArray(result.content) @@ -772,6 +642,7 @@ export function toServerTools( Boolean(def.outputSchema), requiresTaskExecution(def), options.requestOptions, + def.inputSchema, ), } if (options.needsApproval?.(def)) { diff --git a/packages/ai-mcp/tests/client.test.ts b/packages/ai-mcp/tests/client.test.ts index b81645ba8e..54bf9d880b 100644 --- a/packages/ai-mcp/tests/client.test.ts +++ b/packages/ai-mcp/tests/client.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { InMemoryTransport, + StreamableHTTPClientTransport, SUPPORTED_PROTOCOL_VERSIONS, } from '@modelcontextprotocol/client' import { Server } from '@modelcontextprotocol/server' @@ -1072,3 +1073,91 @@ async function makeProgressServer() { await server.connect(serverTransport) return { server, clientTransport, sawProgressToken: () => sawToken } } + +describe('spec 2026 Mcp-Param headers', () => { + // A strict spec 2026 HTTP server, like GitHub's. It rejects a tools/call + // whose Mcp-Param-Region header is missing or differs from the argument. + function strictHeaderServer() { + const regions: Array = [] + const tool = { + name: 'lookup', + inputSchema: { + type: 'object', + properties: { region: { type: 'string', 'x-mcp-header': 'Region' } }, + required: ['region'], + }, + } + const transport = new StreamableHTTPClientTransport( + new URL('https://fixture.example/mcp'), + { + protocolVersion: '2026-07-28', + fetch: async (_url, init) => { + if (typeof init?.body !== 'string') { + return new Response(null, { status: 405 }) + } + const body = JSON.parse(init.body) + if (body.id === undefined) return new Response(null, { status: 202 }) + if (body.method === 'tools/call') { + const region = new Headers(init.headers).get('Mcp-Param-Region') + regions.push(region) + if (region !== body.params.arguments.region) { + return Response.json( + { + jsonrpc: '2.0', + id: body.id, + error: { code: -32020, message: 'Mcp-Param-Region mismatch' }, + }, + { status: 400 }, + ) + } + } + const result = + body.method === 'server/discover' + ? { + supportedVersions: ['2026-07-28'], + serverInfo: { name: 'fixture', version: '1' }, + capabilities: { tools: {} }, + } + : body.method === 'tools/list' + ? { tools: [tool] } + : { content: [{ type: 'text', text: 'ok' }] } + return Response.json({ + jsonrpc: '2.0', + id: body.id, + result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', + ...result, + }, + }) + }, + }, + ) + return { transport, regions } + } + + it('sends the x-mcp-header argument as a header from tools()', async () => { + const { transport, regions } = strictHeaderServer() + const mcp = await createMCPClient({ transport }) + try { + const [tool] = await mcp.tools() + await expect(tool!.execute!({ region: 'eu' })).resolves.toBe('ok') + expect(regions).toEqual(['eu']) + } finally { + await mcp.close() + } + }) + + it('sends the x-mcp-header argument as a header from callTool()', async () => { + const { transport, regions } = strictHeaderServer() + const mcp = await createMCPClient({ transport }) + try { + const result = await mcp.callTool('lookup', { region: 'eu' }) + expect(result.content).toEqual([{ type: 'text', text: 'ok' }]) + expect(regions).toEqual(['eu']) + } finally { + await mcp.close() + } + }) +}) diff --git a/packages/ai-mcp/tests/tools.test.ts b/packages/ai-mcp/tests/tools.test.ts index 25948de8be..0eb9954fe7 100644 --- a/packages/ai-mcp/tests/tools.test.ts +++ b/packages/ai-mcp/tests/tools.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { Client, - DEFAULT_REQUEST_TIMEOUT_MSEC, InMemoryTransport, SUPPORTED_PROTOCOL_VERSIONS, } from '@modelcontextprotocol/client' @@ -896,38 +895,25 @@ describe('toServerTools', () => { }) }) -type RawMessage = { id: string; params: Record } +type ToolCall = { + params: Record + options: Record +} /** - * A spec 2026 client whose transport answers each raw request at once with - * the messages `reply` returns. Like `fakeMcpClient`, the partial needs the - * `unknown` bridge. + * A spec 2026 client. `reply` answers each SDK `callTool` call, counted + * from 1. Like `fakeMcpClient`, the partial needs the `unknown` bridge. */ -function scriptedModernClient( - reply: (message: RawMessage) => Array, - extra: Record = {}, -) { - const sent: Array = [] - const transport: { - onmessage?: (message: unknown) => void - send: (message: RawMessage) => Promise - } = { - async send(message) { - sent.push(message) - for (const answer of reply(message)) transport.onmessage?.(answer) - if (extra.sendError instanceof Error) throw extra.sendError - }, - } +function scriptedModernClient(reply: (call: number) => unknown) { + const calls: Array = [] const client = { getProtocolEra: () => 'modern', - transport, - ...extra, + async callTool(params: ToolCall['params'], options: ToolCall['options']) { + calls.push({ params, options }) + return reply(calls.length) + }, } as unknown as Client - return { client, sent } -} - -function answerWith(result: unknown) { - return (message: RawMessage) => [{ jsonrpc: '2.0', id: message.id, result }] + return { client, calls } } /** A spec 2025 client. Each SDK request is answered by `answers[method]`. */ @@ -951,142 +937,40 @@ function scriptedLegacyClient( const okResult = { content: [{ type: 'text', text: 'ok' }] } const missingResult = 'ended without a result or error' -describe('callMcpTool — spec 2026 raw requests', () => { - it('fails when the reply has no tool result, and sends the envelope', async () => { - const { client, sent } = scriptedModernClient(answerWith(null), { - _outboundMetaEnvelope: () => ({ version: '2026-07-28' }), - }) - - await expect(callMcpTool(client, 'ask', {}, false)).rejects.toThrow( - missingResult, - ) - expect(sent[0]?.params._meta).toEqual({ version: '2026-07-28' }) - }) - - it('sends no envelope when the client gives none', async () => { - for (const extra of [{}, { _outboundMetaEnvelope: () => 'x' }]) { - const { client, sent } = scriptedModernClient(answerWith(okResult), extra) - await expect(callMcpTool(client, 'ask', {}, false)).resolves.toEqual( - okResult, - ) - expect(sent[0]?.params._meta).toBeUndefined() +describe('callMcpTool — spec 2026 calls', () => { + it('passes the input schema and the signal to the SDK callTool', async () => { + const inputSchema = { + type: 'object' as const, + properties: { region: { type: 'string', 'x-mcp-header': 'Region' } }, } - }) + const signal = new AbortController().signal + const { client, calls } = scriptedModernClient(() => okResult) - it('uses defaults for a JSON-RPC error without code or message', async () => { - const { client } = scriptedModernClient((message) => [ - { jsonrpc: '2.0', id: message.id, error: {} }, - ]) - - await expect(callMcpTool(client, 'ask', {}, false)).rejects.toMatchObject({ - code: ProtocolErrorCode.InternalError, - message: 'The MCP request failed.', - }) - }) - - it('ignores messages for other requests', async () => { - const { client } = scriptedModernClient((message) => [ - { jsonrpc: '2.0', id: 'other', result: {} }, - { jsonrpc: '2.0', id: message.id, result: okResult }, - ]) - - await expect(callMcpTool(client, 'ask', {}, false)).resolves.toEqual( - okResult, - ) - }) - - it('keeps the answer when send fails after it, and fails without one', async () => { - const sendError = new Error('send failed') - const answered = scriptedModernClient(answerWith(okResult), { sendError }) - await expect( - callMcpTool(answered.client, 'ask', {}, false), - ).resolves.toEqual(okResult) - - const silent = scriptedModernClient(() => [], { sendError }) - await expect(callMcpTool(silent.client, 'ask', {}, false)).rejects.toBe( - sendError, - ) - }) - - it('fails when the client has no transport', async () => { - const client = { getProtocolEra: () => 'modern' } as unknown as Client - - await expect(callMcpTool(client, 'ask', {}, false)).rejects.toThrow( - 'The MCP client is not connected.', - ) - }) - - it('stops on abort and resolves with a signal that never fires', async () => { - const answered = scriptedModernClient(answerWith(okResult)) await expect( callMcpTool( - answered.client, - 'ask', - {}, - false, - new AbortController().signal, - ), - ).resolves.toEqual(okResult) - - const silent = scriptedModernClient(() => []) - const controller = new AbortController() - const pending = callMcpTool( - silent.client, - 'ask', - {}, - false, - controller.signal, - ) - controller.abort(new Error('stop')) - await expect(pending).rejects.toThrow('stop') - }) - - it('times out when the reply never comes', async () => { - vi.useFakeTimers() - try { - const { client } = scriptedModernClient(() => []) - const pending = expect( - callMcpTool(client, 'ask', {}, false), - ).rejects.toThrow(/timed out/) - await vi.advanceTimersByTimeAsync(DEFAULT_REQUEST_TIMEOUT_MSEC) - await pending - } finally { - vi.useRealTimers() - } - }) - - it('uses the requestOptions timeout', async () => { - vi.useFakeTimers() - try { - const { client } = scriptedModernClient(() => []) - const outcome = callMcpTool( client, - 'ask', - {}, + 'lookup', + { region: 'eu' }, false, + signal, undefined, undefined, - { - timeout: 1000, - }, - ).catch((error: unknown) => error) - await vi.advanceTimersByTimeAsync(1000) - const error = await outcome - expect(error).toBeInstanceOf(Error) - expect(String(error)).toContain('timed out after 1000 ms') - } finally { - vi.useRealTimers() - } + inputSchema, + ), + ).resolves.toEqual(okResult) + expect(calls[0]).toEqual({ + params: { name: 'lookup', arguments: { region: 'eu' } }, + options: { + signal, + allowInputRequired: true, + toolDefinition: { name: 'lookup', inputSchema }, + }, + }) }) it('reads every input request shape', async () => { const call = (result: unknown) => - callMcpTool( - scriptedModernClient(answerWith(result)).client, - 'ask', - {}, - false, - ) + callMcpTool(scriptedModernClient(() => result).client, 'ask', {}, false) await expect( call({ @@ -1110,10 +994,6 @@ describe('callMcpTool — spec 2026 raw requests', () => { expect((await inputRequiredFrom(() => call(invalid))).request).toEqual( invalid, ) - - await expect( - call({ status: 'input_required', inputRequests: {} }), - ).rejects.toThrow(missingResult) }) async function retrySent( @@ -1121,18 +1001,13 @@ describe('callMcpTool — spec 2026 raw requests', () => { inputResponse: Parameters[5], requestState?: string, ) { - const { client, sent } = scriptedModernClient((message) => [ - { - jsonrpc: '2.0', - id: message.id, - result: - sent.length === 1 - ? { resultType: 'input_required', inputRequests, requestState } - : okResult, - }, - ]) + const { client, calls } = scriptedModernClient((call) => + call === 1 + ? { resultType: 'input_required', inputRequests, requestState } + : okResult, + ) await callMcpTool(client, 'draft', {}, false, undefined, inputResponse) - return sent[1]?.params + return calls[1]?.params } it('answers a sampling request with text, a result object, or a cancel', async () => {