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/mcp-client-options.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions packages/ai-mcp/src/apps/call-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions packages/ai-mcp/src/apps/session-store.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ClientOptions } from '@modelcontextprotocol/sdk/client/index.js'
import type { TransportConfig } from '../transport'

export interface McpServerDescriptor {
Expand All @@ -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 {
Expand Down
43 changes: 39 additions & 4 deletions packages/ai-mcp/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import type { ClientOptions } from '@modelcontextprotocol/sdk/client/index.js'
import {
DuplicateToolNameError,
MCPConnectionError,
Expand Down Expand Up @@ -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<void>
[Symbol.asyncDispose]: () => Promise<void>
Expand All @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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<void> {
Expand Down Expand Up @@ -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
Expand All @@ -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<MCPClient<TServer>> {
const impl = new MCPClientImpl<TServer>(prefix)
>(
transport: Transport,
prefix?: string,
clientOptions?: ClientOptions,
): Promise<MCPClient<TServer>> {
const impl = new MCPClientImpl<TServer>(
prefix,
undefined,
undefined,
undefined,
clientOptions,
)
await impl.connect(transport)
return impl
}
3 changes: 3 additions & 0 deletions packages/ai-mcp/src/pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -46,6 +47,7 @@ export interface MCPClients<
{
transport: TransportConfig | undefined
prefix: string | undefined
clientOptions?: ClientOptions
}
>
/** Close every client. */
Expand Down Expand Up @@ -151,6 +153,7 @@ export async function createMCPClients<
{
transport: TransportConfig | undefined
prefix: string | undefined
clientOptions?: ClientOptions
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
> {
// Keyed by config key (serverId / default prefix). Read each underlying
Expand Down
26 changes: 26 additions & 0 deletions packages/ai-mcp/src/types.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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 {
Expand Down
108 changes: 108 additions & 0 deletions packages/ai-mcp/tests/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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<unknown>
provider: jsonSchemaValidator
} {
const schemas: Array<unknown> = []
return {
schemas,
provider: {
getValidator<T>(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<T> => ({
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,
})
})
})
27 changes: 27 additions & 0 deletions packages/ai-mcp/tests/helpers/in-memory-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Loading
Loading