Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ai-mcp-param-headers.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 5 additions & 2 deletions packages/ai-mcp/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,8 +368,9 @@ class MCPClientImpl<TServer extends ServerDescriptor> 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
Expand Down Expand Up @@ -433,6 +434,7 @@ class MCPClientImpl<TServer extends ServerDescriptor> implements Omit<
Boolean(def.outputSchema),
requiresTaskExecution(serverTool),
requestOptions,
serverTool.inputSchema,
),
) as ServerTool
// A caller-supplied definition may already carry its own `mcp` block,
Expand Down Expand Up @@ -558,6 +560,7 @@ class MCPClientImpl<TServer extends ServerDescriptor> 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.
Expand Down
195 changes: 33 additions & 162 deletions packages/ai-mcp/src/tools.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
import {
DEFAULT_REQUEST_TIMEOUT_MSEC,
ProtocolError,
ProtocolErrorCode,
SdkError,
SdkErrorCode,
isCallToolResult,
isInputRequiredResult,
} from '@modelcontextprotocol/client'
Expand All @@ -14,7 +9,6 @@ import type {
TaskStatus,
Tool as McpToolDef,
ToolAnnotations,
Transport,
} from '@modelcontextprotocol/client'
import type {
AnyServerTool,
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -419,128 +408,6 @@ async function sdkRequest(
}
}

function rawRequest(
client: Client,
method: string,
params: Record<string, unknown>,
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<unknown>((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<string, unknown>,
envelope: Record<string, unknown> | 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)
}
Expand Down Expand Up @@ -674,13 +541,15 @@ function isRecord(value: unknown): value is Record<string, unknown> {
* 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,
mcpName: string,
preferStructured: boolean,
taskRequired = false,
requestOptions?: MCPClientOptions['requestOptions'],
inputSchema?: McpToolDef['inputSchema'],
) {
return async (
args: unknown,
Expand All @@ -694,6 +563,7 @@ export function makeMcpExecute(
ctx?.abortSignal,
ctx?.inputResponse,
requestOptions,
inputSchema,
)
if (result.isError) {
const text = Array.isArray(result.content)
Expand Down Expand Up @@ -772,6 +642,7 @@ export function toServerTools(
Boolean(def.outputSchema),
requiresTaskExecution(def),
options.requestOptions,
def.inputSchema,
),
}
if (options.needsApproval?.(def)) {
Expand Down
Loading
Loading