diff --git a/.changeset/mcp-client-options.md b/.changeset/mcp-client-options.md new file mode 100644 index 0000000000..1ea9b188a3 --- /dev/null +++ b/.changeset/mcp-client-options.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-mcp': minor +--- + +`createMCPClient` and `createMCPClientFromTransport` now accept `clientOptions`, forwarded verbatim to the MCP SDK's `Client`. The option that motivated this is `jsonSchemaValidator`: the SDK validates a tool's `structuredContent` against its declared `outputSchema`, and its default AJV validator compiles each schema by building JavaScript source and handing it to `new Function`. Edge runtimes forbid that, so on Cloudflare Workers a `tools/list` against any server whose tools declare an `outputSchema` failed with `Error compiling schema` (AJV's wrapper around `Code generation from strings disallowed for this context`) — and because validators are built during discovery rather than on call, that took down the whole run, not one tool. The SDK ships the fix (`CfWorkerJsonSchemaValidator`, backed by the optional peer `@cfworker/json-schema`) but it is only installable through `ClientOptions`, which this package did not expose. diff --git a/packages/ai-mcp/src/apps/call-handler.ts b/packages/ai-mcp/src/apps/call-handler.ts index 9e7f8783b0..60a2b05bd1 100644 --- a/packages/ai-mcp/src/apps/call-handler.ts +++ b/packages/ai-mcp/src/apps/call-handler.ts @@ -127,10 +127,12 @@ function buildRegistry(clients: McpAppClientsInput): AppRegistry { const add = (info: { transport: McpServerDescriptor['transport'] prefix: string | undefined + clientOptions?: McpServerDescriptor['clientOptions'] }) => { const descriptor: McpServerDescriptor = { transport: info.transport, prefix: info.prefix, + ...(info.clientOptions ? { clientOptions: info.clientOptions } : {}), } total += 1 const key = info.prefix @@ -234,6 +236,9 @@ export function createMcpAppCallHandler(opts: McpAppCallHandlerOptions) { const client = await createMCPClient({ transport: descriptor.transport, prefix: descriptor.prefix, + ...(descriptor.clientOptions + ? { clientOptions: descriptor.clientOptions } + : {}), }) try { diff --git a/packages/ai-mcp/src/apps/session-store.ts b/packages/ai-mcp/src/apps/session-store.ts index 9540b11b8c..1945ec9d12 100644 --- a/packages/ai-mcp/src/apps/session-store.ts +++ b/packages/ai-mcp/src/apps/session-store.ts @@ -1,3 +1,4 @@ +import type { ClientOptions } from '@modelcontextprotocol/sdk/client/index.js' import type { TransportConfig } from '../transport' export interface McpServerDescriptor { @@ -9,6 +10,11 @@ export interface McpServerDescriptor { */ transport: TransportConfig | undefined prefix?: string + /** + * Options to rebuild the client with. Carried so a reconnect keeps a custom + * `jsonSchemaValidator` — an edge runtime cannot use the SDK's AJV default. + */ + clientOptions?: ClientOptions } export interface McpSessionStore { diff --git a/packages/ai-mcp/src/client.ts b/packages/ai-mcp/src/client.ts index 3c7db2b11e..274fe9f4f3 100644 --- a/packages/ai-mcp/src/client.ts +++ b/packages/ai-mcp/src/client.ts @@ -1,4 +1,5 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import type { ClientOptions } from '@modelcontextprotocol/sdk/client/index.js' import { DuplicateToolNameError, MCPConnectionError, @@ -85,6 +86,15 @@ export interface MCPClient< getInfo: () => { transport: TransportConfig | undefined prefix: string | undefined + /** + * The options this client was built with, so a caller that reconstructs it + * from this descriptor keeps them. Without it a rebuilt client silently + * reverts to the SDK defaults — including the AJV validator that edge + * runtimes cannot compile. + * + * Optional so an existing hand-rolled `MCPClient` keeps compiling. + */ + clientOptions?: ClientOptions } close: () => Promise [Symbol.asyncDispose]: () => Promise @@ -100,23 +110,37 @@ class MCPClientImpl< // The ORIGINAL serializable transport config (undefined for clients built // from a ready-made Transport instance, which is single-use / not reconnectable). readonly #transport: TransportConfig | undefined + // Retained for the same reason as #transport: the MCP Apps call handler + // rebuilds a client per call from getInfo(), and a rebuilt client that lost + // `jsonSchemaValidator` falls straight back to AJV. + readonly #clientOptions: ClientOptions | undefined constructor( prefix?: string, name = 'tanstack-ai-mcp', version = '0.0.1', transport?: TransportConfig, + clientOptions?: ClientOptions, ) { this.prefix = prefix this.#transport = transport - this.#client = new Client({ name, version }) + this.#clientOptions = clientOptions + // `clientOptions` is spread rather than passed straight through so an + // omitted option keeps the SDK's default. See MCPClientOptions.clientOptions + // for why edge runtimes need `jsonSchemaValidator` in particular. + this.#client = new Client({ name, version }, clientOptions) } getInfo(): { transport: TransportConfig | undefined prefix: string | undefined + clientOptions?: ClientOptions } { - return { transport: this.#transport, prefix: this.prefix } + return { + transport: this.#transport, + prefix: this.prefix, + ...(this.#clientOptions ? { clientOptions: this.#clientOptions } : {}), + } } async connect(transport: Transport): Promise { @@ -260,6 +284,7 @@ export async function createMCPClient< // Only a serializable config is reconnectable; a ready-made Transport // instance is single-use, so it is not retained as a descriptor. isTransportInstance(options.transport) ? undefined : options.transport, + options.clientOptions, ) await impl.connect(transport) return impl @@ -268,8 +293,18 @@ export async function createMCPClient< /** Test-only: connect directly from a transport instance (skips resolveTransport). */ export async function createMCPClientFromTransport< TServer extends ServerDescriptor = AutomaticDescriptor, ->(transport: Transport, prefix?: string): Promise> { - const impl = new MCPClientImpl(prefix) +>( + transport: Transport, + prefix?: string, + clientOptions?: ClientOptions, +): Promise> { + const impl = new MCPClientImpl( + prefix, + undefined, + undefined, + undefined, + clientOptions, + ) await impl.connect(transport) return impl } diff --git a/packages/ai-mcp/src/pool.ts b/packages/ai-mcp/src/pool.ts index a997309d53..3362d9c15f 100644 --- a/packages/ai-mcp/src/pool.ts +++ b/packages/ai-mcp/src/pool.ts @@ -7,6 +7,7 @@ import type { ServerDescriptor, ToolsOptions, } from './types' +import type { ClientOptions } from '@modelcontextprotocol/sdk/client/index.js' import type { TransportConfig } from './transport' import type { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js' @@ -46,6 +47,7 @@ export interface MCPClients< { transport: TransportConfig | undefined prefix: string | undefined + clientOptions?: ClientOptions } > /** Close every client. */ @@ -151,6 +153,7 @@ export async function createMCPClients< { transport: TransportConfig | undefined prefix: string | undefined + clientOptions?: ClientOptions } > { // Keyed by config key (serverId / default prefix). Read each underlying diff --git a/packages/ai-mcp/src/types.ts b/packages/ai-mcp/src/types.ts index bf2fdebc81..d70865e991 100644 --- a/packages/ai-mcp/src/types.ts +++ b/packages/ai-mcp/src/types.ts @@ -1,4 +1,5 @@ import type { ServerTool, ToolDefinition } from '@tanstack/ai' +import type { ClientOptions } from '@modelcontextprotocol/sdk/client/index.js' import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js' import type { TransportInput } from './transport' @@ -86,6 +87,31 @@ export interface MCPClientOptions { /** Client identity sent to the server. */ name?: string version?: string + /** + * Options forwarded verbatim to the MCP SDK's `Client`. + * + * The one that matters in practice is `jsonSchemaValidator`. The SDK + * validates a tool's `structuredContent` against its declared `outputSchema`, + * and its default validator is AJV — which compiles each schema by building + * JavaScript source and passing it to `new Function`. Edge runtimes forbid + * that: on Cloudflare Workers every call to a tool with an `outputSchema` + * fails with `Code generation from strings disallowed for this context`, + * wrapped by AJV as `Error compiling schema`. + * + * The SDK ships the fix (`CfWorkerJsonSchemaValidator`, backed by the + * optional peer `@cfworker/json-schema`) but it can only be installed through + * `ClientOptions`, which this package did not expose. + * + * ```ts + * import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/cfworker' + * + * const mcp = await createMCPClient({ + * transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, + * clientOptions: { jsonSchemaValidator: new CfWorkerJsonSchemaValidator() }, + * }) + * ``` + */ + clientOptions?: ClientOptions } export interface ToolsOptions { diff --git a/packages/ai-mcp/tests/client.test.ts b/packages/ai-mcp/tests/client.test.ts index 9082327a93..77bdf810a3 100644 --- a/packages/ai-mcp/tests/client.test.ts +++ b/packages/ai-mcp/tests/client.test.ts @@ -8,10 +8,15 @@ import { } from '../src/errors' import { makeServerWithAnnotatedTool, + makeServerWithStructuredTool, makeServerWithTaskRequiredTool, makeServerWithWeatherTool, } from './helpers/in-memory-server' import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' +import type { + JsonSchemaValidatorResult, + jsonSchemaValidator, +} from '@modelcontextprotocol/sdk/validation' describe('createMCPClient', () => { it('connects and returns discovered tools', async () => { @@ -250,3 +255,106 @@ describe('createMCPClient', () => { await expect(client.tools()).rejects.toThrow() }) }) + +describe('clientOptions', () => { + /** + * Records every schema it is asked about, and accepts everything. + * + * Standing in for `CfWorkerJsonSchemaValidator`, which exists precisely + * because the SDK's default validator compiles schemas with `new Function` — + * forbidden on Cloudflare Workers, where it fails every call to a tool that + * declares an `outputSchema`. + */ + function recordingValidator(): { + schemas: Array + provider: jsonSchemaValidator + } { + const schemas: Array = [] + return { + schemas, + provider: { + getValidator(schema: unknown) { + schemas.push(schema) + // Annotated rather than inferred: the result type is a union, and + // without it TS widens `data` to `T | undefined` and neither branch + // matches. + return (input: unknown): JsonSchemaValidatorResult => ({ + valid: true, + data: input as T, + errorMessage: undefined, + }) + }, + }, + } + } + + it('forwards a custom jsonSchemaValidator to the SDK client', async () => { + const { clientTransport } = await makeServerWithStructuredTool() + const { schemas, provider } = recordingValidator() + await using client = await createMCPClientFromTransport( + clientTransport, + undefined, + { jsonSchemaValidator: provider }, + ) + + // The SDK builds every output validator during `tools/list`, not on call — + // see `cacheToolMetadata`. This is also why the default AJV provider fails + // an entire discovery on an edge runtime rather than a single tool call. + await client.tools() + + expect(schemas).toEqual([expect.objectContaining({ type: 'object' })]) + }) + + it('accepts clientOptions through createMCPClient', async () => { + const { clientTransport } = await makeServerWithStructuredTool() + const { schemas, provider } = recordingValidator() + await using client = await createMCPClient({ + transport: clientTransport, + clientOptions: { jsonSchemaValidator: provider }, + }) + + await client.tools() + + expect(schemas).toHaveLength(1) + }) + + it('falls back to the SDK default when no clientOptions are given', async () => { + const { clientTransport } = await makeServerWithStructuredTool() + await using client = await createMCPClientFromTransport(clientTransport) + await client.tools() + + const result = await client.callTool('lookup_user', { id: 'u-1' }) + + expect(result.structuredContent).toEqual({ id: 'u-1', name: 'Ada' }) + }) + + it('reports clientOptions on getInfo so a rebuilt client keeps them', async () => { + // `createMcpAppCallHandler` reconnects per call from `getInfo()`. A + // descriptor that dropped `clientOptions` would hand the rebuilt client + // back to the SDK's AJV default — the exact failure this option exists to + // avoid, reintroduced for every MCP Apps widget call. + const { clientTransport } = await makeServerWithStructuredTool() + const { provider } = recordingValidator() + await using client = await createMCPClient({ + transport: clientTransport, + prefix: 'weather', + clientOptions: { jsonSchemaValidator: provider }, + }) + + expect(client.getInfo().clientOptions).toEqual({ + jsonSchemaValidator: provider, + }) + }) + + it('omits clientOptions from getInfo when none were given', async () => { + const { clientTransport } = await makeServerWithStructuredTool() + await using client = await createMCPClientFromTransport(clientTransport) + + // `toStrictEqual` rather than reading `.clientOptions`: the contract is that + // the key is OMITTED, and `toBeUndefined()` passes either way. + expect(client.getInfo()).toStrictEqual({ + transport: undefined, + prefix: undefined, + }) + }) +}) diff --git a/packages/ai-mcp/tests/helpers/in-memory-server.ts b/packages/ai-mcp/tests/helpers/in-memory-server.ts index 133793b82d..d85d1bd673 100644 --- a/packages/ai-mcp/tests/helpers/in-memory-server.ts +++ b/packages/ai-mcp/tests/helpers/in-memory-server.ts @@ -213,3 +213,30 @@ export async function makeFullServer() { await server.connect(serverTransport) return { server, clientTransport } } + +/** + * A tool that declares an `outputSchema` and returns `structuredContent`. + * + * The SDK validates that payload against the schema on every call, which is the + * only path that reaches `ClientOptions.jsonSchemaValidator` — a tool without an + * output schema never builds a validator at all. + */ +export async function makeServerWithStructuredTool() { + const server = new McpServer({ name: 'structured', version: '1.0.0' }) + server.registerTool( + 'lookup_user', + { + description: 'Look a user up by id', + inputSchema: { id: z.string() }, + outputSchema: { id: z.string(), name: z.string() }, + }, + async ({ id }) => ({ + content: [{ type: 'text' as const, text: `user ${id}` }], + structuredContent: { id, name: 'Ada' }, + }), + ) + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair() + await server.connect(serverTransport) + return { server, clientTransport } +} diff --git a/testing/e2e/src/routes/api.mcp-test.ts b/testing/e2e/src/routes/api.mcp-test.ts index a6ea991a7c..57b30c8633 100644 --- a/testing/e2e/src/routes/api.mcp-test.ts +++ b/testing/e2e/src/routes/api.mcp-test.ts @@ -9,6 +9,31 @@ import { createMCPClient } from '@tanstack/ai-mcp' import type { StreamChunk } from '@tanstack/ai' import type { MCPClient } from '@tanstack/ai-mcp' import { createTextAdapter } from '@/lib/providers' +import type { jsonSchemaValidator } from '@modelcontextprotocol/sdk/validation' + +/** + * A JSON Schema validator that refuses everything. + * + * Stands in for `CfWorkerJsonSchemaValidator`, which exists because the SDK's + * default AJV provider compiles schemas with `new Function` — forbidden on edge + * runtimes, where it fails an entire `tools/list` against any server whose + * tools declare an `outputSchema`. + * + * Refusing rather than accepting is what makes the pass-through observable: the + * mock server's `get_guitar_price` returns `structuredContent` that AJV accepts, + * so a `clientOptions` that never reached the SDK would leave the run + * succeeding with the price in the transcript. With it installed, the tool call + * fails instead. + */ +const rejectingJsonSchemaValidator: jsonSchemaValidator = { + getValidator() { + return () => ({ + valid: false, + data: undefined, + errorMessage: 'rejected by the injected validator', + }) + }, +} /** * Wrap the chat stream so the MCP client is closed only AFTER the stream has @@ -74,6 +99,9 @@ export const Route = createFileRoute('/api/mcp-test')({ const testId = typeof fp.testId === 'string' ? fp.testId : undefined const aimockPort = fp.aimockPort != null ? Number(fp.aimockPort) : undefined + // Opt-in: install a custom `jsonSchemaValidator` through + // `clientOptions` so the spec can prove the option reaches the SDK. + const rejectStructuredOutput = fp.rejectStructuredOutput === true // The mock MCP server lives at this same dev server's origin. const origin = new URL(request.url).origin @@ -85,6 +113,13 @@ export const Route = createFileRoute('/api/mcp-test')({ try { mcp = await createMCPClient({ transport: { type: 'http', url: mcpUrl }, + ...(rejectStructuredOutput + ? { + clientOptions: { + jsonSchemaValidator: rejectingJsonSchemaValidator, + }, + } + : {}), }) const tools = await mcp.tools() diff --git a/testing/e2e/tests/mcp.spec.ts b/testing/e2e/tests/mcp.spec.ts index 148662c4ae..6909e12b12 100644 --- a/testing/e2e/tests/mcp.spec.ts +++ b/testing/e2e/tests/mcp.spec.ts @@ -112,4 +112,54 @@ test.describe('mcp — server tool discovery + execution in chat()', () => { expect(events.some((e) => e.type === 'RUN_ERROR')).toBe(false) expect(events.some((e) => e.type === 'RUN_FINISHED')).toBe(true) }) + + test('clientOptions reaches the SDK client — a custom validator changes the outcome', async ({ + request, + testId, + aimockPort, + }) => { + // Same server, same fixture, one flag: the route installs a + // `jsonSchemaValidator` that refuses everything via `clientOptions`. + // + // The mock server's `get_guitar_price` declares an `outputSchema` and + // returns `structuredContent` the SDK's default AJV provider accepts, so a + // `clientOptions` that was dropped on the floor would leave this run + // identical to the test above. It is not: the tool call fails validation. + // + // That option is how an edge runtime installs a validator that does not + // compile schemas with `new Function`, which AJV does and Cloudflare + // Workers forbid. + const res = await request.post('/api/mcp-test', { + headers: { 'Content-Type': 'application/json' }, + data: { + threadId: `mcp-opts-thread-${testId}`, + runId: `mcp-opts-run-${testId}`, + state: {}, + messages: [ + { + id: 'mcp-opts-msg-1', + role: 'user', + content: '[mcp] how much is the strat guitar', + }, + ], + tools: [], + context: [], + forwardedProps: { testId, aimockPort, rejectStructuredOutput: true }, + }, + }) + + const body = await res.text() + const events = parseSse(body) + + const toolResult = events.find((e) => e.type === 'TOOL_CALL_RESULT') + expect(toolResult, 'expected a TOOL_CALL_RESULT event').toBeTruthy() + const resultStr = JSON.stringify(toolResult?.content ?? '') + + expect(resultStr).toContain('rejected by the injected validator') + // The other half of the proof: the payload the default validator accepts + // never reaches the tool result. Asserted on the tool result rather than the + // whole transcript — the aimock fixture's final answer is a recorded script + // that names the price whether or not the tool succeeded. + expect(resultStr).not.toContain('1999') + }) })