Skip to content

Commit 81d29d1

Browse files
committed
fix(ai-mcp): keep clientOptions across the MCP Apps reconnect
`createMcpAppCallHandler` rebuilds a client per call from `getInfo()`, and that descriptor carried only `transport` and `prefix`. So a client created with `clientOptions` served widget tool calls through a REBUILT client that had none — back on the SDK's AJV validator, which is the failure the option exists to avoid, reintroduced for every MCP Apps call on an edge runtime. `getInfo()` now reports the options the client was built with, the descriptor carries them, and the handler forwards them. `MCPClients.getServers()` reports them the same way, since the handler reads pools through it. Optional on the return type rather than required: a hand-rolled `MCPClient` would otherwise stop compiling, and it is omitted entirely when the client was built without options, so a descriptor round-trips unchanged.
1 parent 750daa7 commit 81d29d1

5 files changed

Lines changed: 58 additions & 1 deletion

File tree

packages/ai-mcp/src/apps/call-handler.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,10 +127,12 @@ function buildRegistry(clients: McpAppClientsInput): AppRegistry {
127127
const add = (info: {
128128
transport: McpServerDescriptor['transport']
129129
prefix: string | undefined
130+
clientOptions?: McpServerDescriptor['clientOptions']
130131
}) => {
131132
const descriptor: McpServerDescriptor = {
132133
transport: info.transport,
133134
prefix: info.prefix,
135+
...(info.clientOptions ? { clientOptions: info.clientOptions } : {}),
134136
}
135137
total += 1
136138
const key = info.prefix
@@ -234,6 +236,9 @@ export function createMcpAppCallHandler(opts: McpAppCallHandlerOptions) {
234236
const client = await createMCPClient({
235237
transport: descriptor.transport,
236238
prefix: descriptor.prefix,
239+
...(descriptor.clientOptions
240+
? { clientOptions: descriptor.clientOptions }
241+
: {}),
237242
})
238243

239244
try {

packages/ai-mcp/src/apps/session-store.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { ClientOptions } from '@modelcontextprotocol/sdk/client/index.js'
12
import type { TransportConfig } from '../transport'
23

34
export interface McpServerDescriptor {
@@ -9,6 +10,11 @@ export interface McpServerDescriptor {
910
*/
1011
transport: TransportConfig | undefined
1112
prefix?: string
13+
/**
14+
* Options to rebuild the client with. Carried so a reconnect keeps a custom
15+
* `jsonSchemaValidator` — an edge runtime cannot use the SDK's AJV default.
16+
*/
17+
clientOptions?: ClientOptions
1218
}
1319

1420
export interface McpSessionStore {

packages/ai-mcp/src/client.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,15 @@ export interface MCPClient<
8686
getInfo: () => {
8787
transport: TransportConfig | undefined
8888
prefix: string | undefined
89+
/**
90+
* The options this client was built with, so a caller that reconstructs it
91+
* from this descriptor keeps them. Without it a rebuilt client silently
92+
* reverts to the SDK defaults — including the AJV validator that edge
93+
* runtimes cannot compile.
94+
*
95+
* Optional so an existing hand-rolled `MCPClient` keeps compiling.
96+
*/
97+
clientOptions?: ClientOptions
8998
}
9099
close: () => Promise<void>
91100
[Symbol.asyncDispose]: () => Promise<void>
@@ -101,6 +110,10 @@ class MCPClientImpl<
101110
// The ORIGINAL serializable transport config (undefined for clients built
102111
// from a ready-made Transport instance, which is single-use / not reconnectable).
103112
readonly #transport: TransportConfig | undefined
113+
// Retained for the same reason as #transport: the MCP Apps call handler
114+
// rebuilds a client per call from getInfo(), and a rebuilt client that lost
115+
// `jsonSchemaValidator` falls straight back to AJV.
116+
readonly #clientOptions: ClientOptions | undefined
104117

105118
constructor(
106119
prefix?: string,
@@ -111,6 +124,7 @@ class MCPClientImpl<
111124
) {
112125
this.prefix = prefix
113126
this.#transport = transport
127+
this.#clientOptions = clientOptions
114128
// `clientOptions` is spread rather than passed straight through so an
115129
// omitted option keeps the SDK's default. See MCPClientOptions.clientOptions
116130
// for why edge runtimes need `jsonSchemaValidator` in particular.
@@ -120,8 +134,13 @@ class MCPClientImpl<
120134
getInfo(): {
121135
transport: TransportConfig | undefined
122136
prefix: string | undefined
137+
clientOptions?: ClientOptions
123138
} {
124-
return { transport: this.#transport, prefix: this.prefix }
139+
return {
140+
transport: this.#transport,
141+
prefix: this.prefix,
142+
...(this.#clientOptions ? { clientOptions: this.#clientOptions } : {}),
143+
}
125144
}
126145

127146
async connect(transport: Transport): Promise<void> {

packages/ai-mcp/src/pool.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
ServerDescriptor,
88
ToolsOptions,
99
} from './types'
10+
import type { ClientOptions } from '@modelcontextprotocol/sdk/client/index.js'
1011
import type { TransportConfig } from './transport'
1112
import type { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js'
1213

@@ -151,6 +152,7 @@ export async function createMCPClients<
151152
{
152153
transport: TransportConfig | undefined
153154
prefix: string | undefined
155+
clientOptions?: ClientOptions
154156
}
155157
> {
156158
// Keyed by config key (serverId / default prefix). Read each underlying

packages/ai-mcp/tests/client.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,4 +327,29 @@ describe('clientOptions', () => {
327327

328328
expect(result.structuredContent).toEqual({ id: 'u-1', name: 'Ada' })
329329
})
330+
331+
it('reports clientOptions on getInfo so a rebuilt client keeps them', async () => {
332+
// `createMcpAppCallHandler` reconnects per call from `getInfo()`. A
333+
// descriptor that dropped `clientOptions` would hand the rebuilt client
334+
// back to the SDK's AJV default — the exact failure this option exists to
335+
// avoid, reintroduced for every MCP Apps widget call.
336+
const { clientTransport } = await makeServerWithStructuredTool()
337+
const { provider } = recordingValidator()
338+
await using client = await createMCPClient({
339+
transport: clientTransport,
340+
prefix: 'weather',
341+
clientOptions: { jsonSchemaValidator: provider },
342+
})
343+
344+
expect(client.getInfo().clientOptions).toEqual({
345+
jsonSchemaValidator: provider,
346+
})
347+
})
348+
349+
it('omits clientOptions from getInfo when none were given', async () => {
350+
const { clientTransport } = await makeServerWithStructuredTool()
351+
await using client = await createMCPClientFromTransport(clientTransport)
352+
353+
expect(client.getInfo().clientOptions).toBeUndefined()
354+
})
330355
})

0 commit comments

Comments
 (0)