From 9bc9abc68bf2b097b15c76a9673d44fb3ff31d03 Mon Sep 17 00:00:00 2001 From: Anthony Date: Tue, 31 Mar 2026 12:53:52 +0200 Subject: [PATCH 1/4] Fix: Handle error responses in Streamable HTTP SSE streams (#1390) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .../fix-streamable-http-error-response.md | 5 ++ packages/client/src/client/streamableHttp.ts | 4 +- .../client/test/client/streamableHttp.test.ts | 72 +++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-streamable-http-error-response.md diff --git a/.changeset/fix-streamable-http-error-response.md b/.changeset/fix-streamable-http-error-response.md new file mode 100644 index 000000000..1de5839d3 --- /dev/null +++ b/.changeset/fix-streamable-http-error-response.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Fix StreamableHTTPClientTransport to handle error responses in SSE streams diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index b6473357b..56cbb4d98 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -4,6 +4,7 @@ import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol import { createFetchWithInit, isInitializedNotification, + isJSONRPCErrorResponse, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema, @@ -412,7 +413,8 @@ export class StreamableHTTPClientTransport implements Transport { if (!event.event || event.event === 'message') { try { const message = JSONRPCMessageSchema.parse(JSON.parse(event.data)); - if (isJSONRPCResultResponse(message)) { + // Handle both success AND error responses for completion detection and ID remapping + if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { // Mark that we received a response - no need to reconnect for this request receivedResponse = true; if (replayMessageId !== undefined) { diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index ad376c2e0..4a23e6db4 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -1008,6 +1008,78 @@ describe('StreamableHTTPClientTransport', () => { expect(fetchMock.mock.calls[0]![1]?.method).toBe('POST'); }); + it('should NOT reconnect a POST stream when error response was received', async () => { + // ARRANGE + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + reconnectionOptions: { + initialReconnectionDelay: 10, + maxRetries: 1, + maxReconnectionDelay: 1000, + reconnectionDelayGrowFactor: 1 + } + }); + + const messageSpy = vi.fn(); + transport.onmessage = messageSpy; + + // Create a stream that sends: + // 1. Priming event with ID (enables potential reconnection) + // 2. An error response (should also prevent reconnection, just like success) + // 3. Then closes + const streamWithErrorResponse = new ReadableStream({ + start(controller) { + // Priming event with ID + controller.enqueue(new TextEncoder().encode('id: priming-123\ndata: \n\n')); + // An error response to the request (tool not found, for example) + controller.enqueue( + new TextEncoder().encode( + 'id: error-456\ndata: {"jsonrpc":"2.0","error":{"code":-32602,"message":"Tool not found"},"id":"request-1"}\n\n' + ) + ); + // Stream closes normally + controller.close(); + } + }); + + const fetchMock = global.fetch as Mock; + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: streamWithErrorResponse + }); + + const requestMessage: JSONRPCRequest = { + jsonrpc: '2.0', + method: 'tools/call', + id: 'request-1', + params: { name: 'nonexistent-tool' } + }; + + // ACT + await transport.start(); + await transport.send(requestMessage); + await vi.advanceTimersByTimeAsync(50); + + // ASSERT + // THE KEY ASSERTION: Fetch was called ONCE only - no reconnection! + // The error response was received, so no need to reconnect. + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]![1]?.method).toBe('POST'); + + // Verify the error response was delivered to the message handler + expect(messageSpy).toHaveBeenCalledWith( + expect.objectContaining({ + jsonrpc: '2.0', + error: expect.objectContaining({ + code: -32602, + message: 'Tool not found' + }), + id: 'request-1' + }) + ); + }); + it('should not attempt reconnection after close() is called', async () => { // ARRANGE transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { From f73a5af4bca6e0e91142714e94462339c5d2ccfe Mon Sep 17 00:00:00 2001 From: Tapan Chugh Date: Tue, 31 Mar 2026 03:56:30 -0700 Subject: [PATCH 2/4] Add _meta support to registerPrompt (#1629) Co-authored-by: Claude Opus 4.6 Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- packages/server/src/server/mcp.ts | 16 +++- test/integration/test/server/mcp.test.ts | 93 ++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 4 deletions(-) diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 4d9f81c50..6c2699997 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -530,7 +530,8 @@ export class McpServer { name, title: prompt.title, description: prompt.description, - arguments: prompt.argsSchema ? promptArgumentsFromStandardSchema(prompt.argsSchema) : undefined + arguments: prompt.argsSchema ? promptArgumentsFromStandardSchema(prompt.argsSchema) : undefined, + _meta: prompt._meta }; }) }) @@ -699,7 +700,8 @@ export class McpServer { title: string | undefined, description: string | undefined, argsSchema: StandardSchemaWithJSON | undefined, - callback: PromptCallback + callback: PromptCallback, + _meta: Record | undefined ): RegisteredPrompt { // Track current schema and callback for handler regeneration let currentArgsSchema = argsSchema; @@ -709,6 +711,7 @@ export class McpServer { title, description, argsSchema, + _meta, handler: createPromptHandler(name, argsSchema, callback), enabled: true, disable: () => registeredPrompt.update({ enabled: false }), @@ -721,6 +724,7 @@ export class McpServer { } if (updates.title !== undefined) registeredPrompt.title = updates.title; if (updates.description !== undefined) registeredPrompt.description = updates.description; + if (updates._meta !== undefined) registeredPrompt._meta = updates._meta; // Track if we need to regenerate the handler let needsHandlerRegen = false; @@ -921,6 +925,7 @@ export class McpServer { title?: string; description?: string; argsSchema?: Args; + _meta?: Record; }, cb: PromptCallback ): RegisteredPrompt { @@ -928,14 +933,15 @@ export class McpServer { throw new Error(`Prompt ${name} is already registered`); } - const { title, description, argsSchema } = config; + const { title, description, argsSchema, _meta } = config; const registeredPrompt = this._createRegisteredPrompt( name, title, description, argsSchema, - cb as PromptCallback + cb as PromptCallback, + _meta ); this.setPromptRequestHandlers(); @@ -1234,6 +1240,7 @@ export type RegisteredPrompt = { title?: string; description?: string; argsSchema?: StandardSchemaWithJSON; + _meta?: Record; /** @hidden */ handler: PromptHandler; enabled: boolean; @@ -1244,6 +1251,7 @@ export type RegisteredPrompt = { title?: string; description?: string; argsSchema?: Args; + _meta?: Record; callback?: PromptCallback; enabled?: boolean; }): void; diff --git a/test/integration/test/server/mcp.test.ts b/test/integration/test/server/mcp.test.ts index 967435834..92af09744 100644 --- a/test/integration/test/server/mcp.test.ts +++ b/test/integration/test/server/mcp.test.ts @@ -4253,6 +4253,99 @@ describe('Zod v4', () => { } ]); }); + + /*** + * Test: Prompt Registration with _meta field + */ + test('should register prompt with _meta field and include it in list response', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + const metaData = { + author: 'test-author', + version: '1.2.3', + category: 'utility', + tags: ['test', 'example'] + }; + + mcpServer.registerPrompt( + 'test-with-meta', + { + description: 'A prompt with _meta field', + _meta: metaData + }, + async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Test response' + } + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ method: 'prompts/list' }); + + expect(result.prompts).toHaveLength(1); + expect(result.prompts[0]!.name).toBe('test-with-meta'); + expect(result.prompts[0]!.description).toBe('A prompt with _meta field'); + expect(result.prompts[0]!._meta).toEqual(metaData); + }); + + /*** + * Test: Prompt Registration without _meta field should have undefined _meta + */ + test('should register prompt without _meta field and have undefined _meta in response', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerPrompt( + 'test-without-meta', + { + description: 'A prompt without _meta field' + }, + async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Test response' + } + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ method: 'prompts/list' }); + + expect(result.prompts).toHaveLength(1); + expect(result.prompts[0]!.name).toBe('test-without-meta'); + expect(result.prompts[0]!._meta).toBeUndefined(); + }); }); describe('Tool title precedence', () => { From 2fd7f5ff265efc1059ee2bc404260179abcd6234 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Tue, 31 Mar 2026 14:02:37 +0300 Subject: [PATCH 3/4] `v2`: Web standards Request object in ctx (#1822) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- CLAUDE.md | 2 +- docs/migration-SKILL.md | 9 +-- docs/migration.md | 10 +++- packages/core/src/shared/protocol.ts | 7 +-- packages/core/src/shared/transport.ts | 4 +- packages/core/src/types/types.ts | 14 +---- .../node/test/streamableHttp.test.ts | 59 +++++++++++++++++-- packages/server/src/server/server.ts | 5 +- packages/server/src/server/streamableHttp.ts | 13 ++-- 9 files changed, 81 insertions(+), 42 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 472ee06ec..609c920cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -164,7 +164,7 @@ When a request arrives from the remote side: 3. **`Protocol._onrequest()`**: - Looks up handler in `_requestHandlers` map (keyed by method name) - Creates `BaseContext` with `signal`, `sessionId`, `sendNotification`, `sendRequest`, etc. - - Calls `buildContext()` to let subclasses enrich the context (e.g., Server adds `requestInfo`) + - Calls `buildContext()` to let subclasses enrich the context (e.g., Server adds HTTP request info) - Invokes handler, sends JSON-RPC response back via transport 4. **Handler** was registered via `setRequestHandler('method', handler)` diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index fb5ffb693..9d09f49c5 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -298,16 +298,17 @@ Note: the third argument (`metadata`) is required — pass `{}` if no metadata. ## 7. Headers API -Transport constructors and `RequestInfo.headers` now use the Web Standard `Headers` object instead of plain objects. +Transport constructors now use the Web Standard `Headers` object instead of plain objects. The custom `RequestInfo` type has been replaced with the standard Web `Request` object, giving access to headers, URL, query parameters, and method. ```typescript -// v1: plain object, bracket access +// v1: plain object, bracket access, custom RequestInfo headers: { 'Authorization': 'Bearer token' } extra.requestInfo?.headers['mcp-session-id'] -// v2: Headers object, .get() access +// v2: Headers object, .get() access, standard Web Request headers: new Headers({ 'Authorization': 'Bearer token' }) ctx.http?.req?.headers.get('mcp-session-id') +new URL(ctx.http?.req?.url).searchParams.get('debug') ``` ## 8. Removed Server Features @@ -391,7 +392,7 @@ Request/notification params remain fully typed. Remove unused schema imports aft | `extra.sendNotification(...)` | `ctx.mcpReq.notify(...)` | | `extra.authInfo` | `ctx.http?.authInfo` | | `extra.sessionId` | `ctx.sessionId` | -| `extra.requestInfo` | `ctx.http?.req` (only `ServerContext`) | +| `extra.requestInfo` | `ctx.http?.req` (standard Web `Request`, only `ServerContext`) | | `extra.closeSSEStream` | `ctx.http?.closeSSE` (only `ServerContext`) | | `extra.closeStandaloneSSEStream` | `ctx.http?.closeStandaloneSSE` (only `ServerContext`) | | `extra.taskStore` | `ctx.task?.store` | diff --git a/docs/migration.md b/docs/migration.md index 3c00fc185..171c71e8a 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -174,8 +174,12 @@ const transport = new StreamableHTTPClientTransport(url, { } }); -// Reading headers in a request handler +// Reading headers in a request handler (ctx.http.req is the standard Web Request object) const sessionId = ctx.http?.req?.headers.get('mcp-session-id'); + +// Reading query parameters +const url = new URL(ctx.http!.req!.url); +const debug = url.searchParams.get('debug'); ``` ### `McpServer.tool()`, `.prompt()`, `.resource()` removed @@ -512,7 +516,7 @@ The `RequestHandlerExtra` type has been replaced with a structured context type | `extra.sendRequest(...)` | `ctx.mcpReq.send(...)` | | `extra.sendNotification(...)` | `ctx.mcpReq.notify(...)` | | `extra.authInfo` | `ctx.http?.authInfo` | -| `extra.requestInfo` | `ctx.http?.req` (only on `ServerContext`) | +| `extra.requestInfo` | `ctx.http?.req` (standard Web `Request`, only on `ServerContext`) | | `extra.closeSSEStream` | `ctx.http?.closeSSE` (only on `ServerContext`) | | `extra.closeStandaloneSSEStream` | `ctx.http?.closeStandaloneSSE` (only on `ServerContext`) | | `extra.sessionId` | `ctx.sessionId` | @@ -535,7 +539,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { ```typescript server.setRequestHandler('tools/call', async (request, ctx) => { - const headers = ctx.http?.req?.headers; + const headers = ctx.http?.req?.headers; // standard Web Request object const taskStore = ctx.task?.store; await ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: 'abc', progress: 50, total: 100 } }); return { content: [{ type: 'text', text: 'result' }] }; diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index ffa642998..57eab6932 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -24,7 +24,6 @@ import type { RelatedTaskMetadata, Request, RequestId, - RequestInfo, RequestMeta, RequestMethod, RequestTypeMap, @@ -257,9 +256,9 @@ export type ServerContext = BaseContext & { http?: { /** - * The original HTTP request information. + * The original HTTP request. */ - req?: RequestInfo; + req?: globalThis.Request; /** * Closes the SSE stream for this request, triggering client reconnection. @@ -392,7 +391,7 @@ export abstract class Protocol { /** * Builds the context object for request handlers. Subclasses must override - * to return the appropriate context type (e.g., ServerContext adds requestInfo). + * to return the appropriate context type (e.g., ServerContext adds HTTP request info). */ protected abstract buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ContextT; diff --git a/packages/core/src/shared/transport.ts b/packages/core/src/shared/transport.ts index 6f4432ae2..c606e2e3b 100644 --- a/packages/core/src/shared/transport.ts +++ b/packages/core/src/shared/transport.ts @@ -110,9 +110,9 @@ export interface Transport { /** * Callback for when a message (request or response) is received over the connection. * - * Includes the {@linkcode MessageExtraInfo.requestInfo | requestInfo} and {@linkcode MessageExtraInfo.authInfo | authInfo} if the transport is authenticated. + * Includes the {@linkcode MessageExtraInfo.request | request} and {@linkcode MessageExtraInfo.authInfo | authInfo} if the transport is authenticated. * - * The {@linkcode MessageExtraInfo.requestInfo | requestInfo} can be used to get the original request information (headers, etc.) + * The {@linkcode MessageExtraInfo.request | request} can be used to get the original request information (headers, etc.) */ onmessage?: ((message: T, extra?: MessageExtraInfo) => void) | undefined; diff --git a/packages/core/src/types/types.ts b/packages/core/src/types/types.ts index 342542b36..a92deec8e 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -513,24 +513,14 @@ export type ListChangedHandlers = { resources?: ListChangedOptions; }; -/** - * Information about the incoming request. - */ -export interface RequestInfo { - /** - * The headers of the request. - */ - headers: Headers; -} - /** * Extra information about a message. */ export interface MessageExtraInfo { /** - * The request information. + * The original HTTP request. */ - requestInfo?: RequestInfo; + request?: globalThis.Request; /** * The authentication information. diff --git a/packages/middleware/node/test/streamableHttp.test.ts b/packages/middleware/node/test/streamableHttp.test.ts index ab3ba540f..460f72d93 100644 --- a/packages/middleware/node/test/streamableHttp.test.ts +++ b/packages/middleware/node/test/streamableHttp.test.ts @@ -396,7 +396,7 @@ describe('Zod v4', () => { /*** * Test: Tool With Request Info */ - it('should pass request info to tool callback', async () => { + it('should expose the full Request object to tool handlers', async () => { sessionId = await initializeServer(); mcpServer.registerTool( @@ -406,10 +406,11 @@ describe('Zod v4', () => { inputSchema: z.object({ name: z.string().describe('Name to greet') }) }, async ({ name }, ctx): Promise => { - // Convert Headers object to plain object for JSON serialization - // Headers is a Web API class that doesn't serialize with JSON.stringify + const req = ctx.http?.req; const serializedRequestInfo = { - headers: Object.fromEntries(ctx.http?.req?.headers ?? new Headers()) + headers: Object.fromEntries(req?.headers ?? new Headers()), + url: req?.url, + method: req?.method }; return { content: [ @@ -464,10 +465,58 @@ describe('Zod v4', () => { 'user-agent': expect.any(String), 'accept-encoding': expect.any(String), 'content-length': expect.any(String) - } + }, + url: expect.stringContaining(baseUrl.pathname), + method: 'POST' }); }); + it('should expose query parameters via the Request object', async () => { + sessionId = await initializeServer(); + + mcpServer.registerTool( + 'test-query-params', + { + description: 'A tool that reads query params', + inputSchema: z.object({}) + }, + async (_args, ctx): Promise => { + const req = ctx.http?.req; + const url = new URL(req!.url); + const params = Object.fromEntries(url.searchParams); + return { + content: [{ type: 'text', text: JSON.stringify(params) }] + }; + } + ); + + const toolCallMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'test-query-params', + arguments: {} + }, + id: 'call-2' + }; + + // Send to a URL with query parameters + const urlWithParams = new URL(baseUrl.toString()); + urlWithParams.searchParams.set('foo', 'bar'); + urlWithParams.searchParams.set('debug', 'true'); + + const response = await sendPostRequest(urlWithParams, toolCallMessage, sessionId); + expect(response.status).toBe(200); + + const text = await readSSEEvent(response); + const dataLine = text.split('\n').find(line => line.startsWith('data:')); + expect(dataLine).toBeDefined(); + + const eventData = JSON.parse(dataLine!.slice(5)); + const queryParams = JSON.parse(eventData.result.content[0].text); + expect(queryParams).toEqual({ foo: 'bar', debug: 'true' }); + }); + it('should reject requests without a valid session ID', async () => { const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList); diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index f1a1851f4..0f9382691 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -155,8 +155,7 @@ export class Server extends Protocol { protected override buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ServerContext { // Only create http when there's actual HTTP transport info or auth info - const hasHttpInfo = - ctx.http || transportInfo?.requestInfo || transportInfo?.closeSSEStream || transportInfo?.closeStandaloneSSEStream; + const hasHttpInfo = ctx.http || transportInfo?.request || transportInfo?.closeSSEStream || transportInfo?.closeStandaloneSSEStream; return { ...ctx, mcpReq: { @@ -168,7 +167,7 @@ export class Server extends Protocol { http: hasHttpInfo ? { ...ctx.http, - req: transportInfo?.requestInfo, + req: transportInfo?.request, closeSSE: transportInfo?.closeSSEStream, closeStandaloneSSE: transportInfo?.closeStandaloneSSEStream } diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index 31053f35c..edb07b004 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -7,7 +7,7 @@ * For Node.js Express/HTTP compatibility, use {@linkcode @modelcontextprotocol/node!NodeStreamableHTTPServerTransport | NodeStreamableHTTPServerTransport} which wraps this transport. */ -import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestId, RequestInfo, Transport } from '@modelcontextprotocol/core'; +import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestId, Transport } from '@modelcontextprotocol/core'; import { DEFAULT_NEGOTIATED_PROTOCOL_VERSION, isInitializeRequest, @@ -634,10 +634,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { return this.createJsonErrorResponse(415, -32_000, 'Unsupported Media Type: Content-Type must be application/json'); } - // Build request info from headers - const requestInfo: RequestInfo = { - headers: req.headers - }; + const request = req; let rawMessage; if (options?.parsedBody === undefined) { @@ -707,7 +704,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { if (!hasRequests) { // if it only contains notifications or responses, return 202 for (const message of messages) { - this.onmessage?.(message, { authInfo: options?.authInfo, requestInfo }); + this.onmessage?.(message, { authInfo: options?.authInfo, request }); } return new Response(null, { status: 202 }); } @@ -741,7 +738,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { } for (const message of messages) { - this.onmessage?.(message, { authInfo: options?.authInfo, requestInfo }); + this.onmessage?.(message, { authInfo: options?.authInfo, request }); } }); } @@ -811,7 +808,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { }; } - this.onmessage?.(message, { authInfo: options?.authInfo, requestInfo, closeSSEStream, closeStandaloneSSEStream }); + this.onmessage?.(message, { authInfo: options?.authInfo, request, closeSSEStream, closeStandaloneSSEStream }); } // The server SHOULD NOT close the SSE stream before sending all JSON-RPC responses // This will be handled by the send() method when responses are ready From 5f32a90f0d3d98b3508f1454c1a9a285db2d08dc Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Tue, 31 Mar 2026 14:06:08 +0300 Subject: [PATCH 4/4] =?UTF-8?q?fix(core):=20make=20fromJsonSchema()=20use?= =?UTF-8?q?=20runtime-aware=20default=20validator=20=E2=80=A6=20(#1825)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- docs/migration-SKILL.md | 2 +- docs/migration.md | 6 +-- packages/client/src/fromJsonSchema.ts | 9 +++++ packages/client/src/index.ts | 3 ++ packages/core/src/exports/public/index.ts | 3 +- .../core/src/validators/fromJsonSchema.ts | 5 +++ packages/server/src/fromJsonSchema.ts | 9 +++++ packages/server/src/index.ts | 3 ++ test/integration/test/standardSchema.test.ts | 40 ++++++++++++++++++- 9 files changed, 74 insertions(+), 6 deletions(-) create mode 100644 packages/client/src/fromJsonSchema.ts create mode 100644 packages/server/src/fromJsonSchema.ts diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index 9d09f49c5..e200fcf32 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -210,7 +210,7 @@ Zod schemas, all callback return types. Note: `callTool()` and `request()` signa The variadic `.tool()`, `.prompt()`, `.resource()` methods are removed. Use the `register*` methods with a config object. -**IMPORTANT**: v2 requires schema objects implementing [Standard Schema](https://standardschema.dev/) — raw shapes like `{ name: z.string() }` are no longer supported. Wrap with `z.object()` (Zod v4), or use ArkType's `type({...})`, or Valibot. For raw JSON Schema, wrap with `fromJsonSchema(schema, validator)` from `@modelcontextprotocol/server`. Applies to `inputSchema`, `outputSchema`, and `argsSchema`. +**IMPORTANT**: v2 requires schema objects implementing [Standard Schema](https://standardschema.dev/) — raw shapes like `{ name: z.string() }` are no longer supported. Wrap with `z.object()` (Zod v4), or use ArkType's `type({...})`, or Valibot. For raw JSON Schema, wrap with `fromJsonSchema(schema)` from `@modelcontextprotocol/server` (validator defaults automatically; pass an explicit validator for custom configurations). Applies to `inputSchema`, `outputSchema`, and `argsSchema`. ### Tools diff --git a/docs/migration.md b/docs/migration.md index 171c71e8a..6c7339bf6 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -274,10 +274,10 @@ server.registerTool('greet', { inputSchema: type({ name: 'string' }) }, async ({ name }) => { ... }); -// Raw JSON Schema via fromJsonSchema -import { fromJsonSchema, AjvJsonSchemaValidator } from '@modelcontextprotocol/server'; +// Raw JSON Schema via fromJsonSchema (validator defaults to runtime-appropriate choice) +import { fromJsonSchema } from '@modelcontextprotocol/server'; server.registerTool('greet', { - inputSchema: fromJsonSchema({ type: 'object', properties: { name: { type: 'string' } } }, new AjvJsonSchemaValidator()) + inputSchema: fromJsonSchema({ type: 'object', properties: { name: { type: 'string' } } }) }, handler); // For tools with no parameters, use z.object({}) diff --git a/packages/client/src/fromJsonSchema.ts b/packages/client/src/fromJsonSchema.ts new file mode 100644 index 000000000..575db2a8c --- /dev/null +++ b/packages/client/src/fromJsonSchema.ts @@ -0,0 +1,9 @@ +import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/client/_shims'; +import type { JsonSchemaType, jsonSchemaValidator, StandardSchemaWithJSON } from '@modelcontextprotocol/core'; +import { fromJsonSchema as coreFromJsonSchema } from '@modelcontextprotocol/core'; + +let _defaultValidator: jsonSchemaValidator | undefined; + +export function fromJsonSchema(schema: JsonSchemaType, validator?: jsonSchemaValidator): StandardSchemaWithJSON { + return coreFromJsonSchema(schema, validator ?? (_defaultValidator ??= new DefaultJsonSchemaValidator())); +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index a888bf8b7..be30382a7 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -73,5 +73,8 @@ export { StreamableHTTPClientTransport } from './client/streamableHttp.js'; // experimental exports export { ExperimentalClientTasks } from './experimental/tasks/client.js'; +// runtime-aware wrapper (shadows core/public's fromJsonSchema with optional validator) +export { fromJsonSchema } from './fromJsonSchema.js'; + // re-export curated public API from core export * from '@modelcontextprotocol/core/public'; diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index 283470299..7f386ea37 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -138,5 +138,6 @@ export type { StandardSchemaWithJSON } from '../../util/standardSchema.js'; export { AjvJsonSchemaValidator } from '../../validators/ajvProvider.js'; export type { CfWorkerSchemaDraft } from '../../validators/cfWorkerProvider.js'; export { CfWorkerJsonSchemaValidator } from '../../validators/cfWorkerProvider.js'; -export { fromJsonSchema } from '../../validators/fromJsonSchema.js'; +// fromJsonSchema is intentionally NOT exported here — the server and client packages +// provide runtime-aware wrappers that default to the appropriate validator via _shims. export type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from '../../validators/types.js'; diff --git a/packages/core/src/validators/fromJsonSchema.ts b/packages/core/src/validators/fromJsonSchema.ts index ec4ba05d6..73db24e8c 100644 --- a/packages/core/src/validators/fromJsonSchema.ts +++ b/packages/core/src/validators/fromJsonSchema.ts @@ -10,6 +10,11 @@ import type { JsonSchemaType, jsonSchemaValidator } from './types.js'; * The callback arguments will be typed `unknown` (raw JSON Schema has no TypeScript * types attached). Cast at the call site, or use the generic `fromJsonSchema(...)`. * + * @param schema - A JSON Schema object describing the expected shape + * @param validator - A validator provider. When importing `fromJsonSchema` from + * `@modelcontextprotocol/server` or `@modelcontextprotocol/client`, a runtime-appropriate + * default is provided automatically (AJV on Node.js, CfWorker on edge runtimes). + * * @example * ```ts source="./fromJsonSchema.examples.ts#fromJsonSchema_basicUsage" * const inputSchema = fromJsonSchema<{ name: string }>( diff --git a/packages/server/src/fromJsonSchema.ts b/packages/server/src/fromJsonSchema.ts new file mode 100644 index 000000000..180ef2def --- /dev/null +++ b/packages/server/src/fromJsonSchema.ts @@ -0,0 +1,9 @@ +import type { JsonSchemaType, jsonSchemaValidator, StandardSchemaWithJSON } from '@modelcontextprotocol/core'; +import { fromJsonSchema as coreFromJsonSchema } from '@modelcontextprotocol/core'; +import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; + +let _defaultValidator: jsonSchemaValidator | undefined; + +export function fromJsonSchema(schema: JsonSchemaType, validator?: jsonSchemaValidator): StandardSchemaWithJSON { + return coreFromJsonSchema(schema, validator ?? (_defaultValidator ??= new DefaultJsonSchemaValidator())); +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index c680dffe7..6e1bba28d 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -43,5 +43,8 @@ export type { CreateTaskRequestHandler, TaskRequestHandler, ToolTaskHandler } fr export { ExperimentalMcpServerTasks } from './experimental/tasks/mcpServer.js'; export { ExperimentalServerTasks } from './experimental/tasks/server.js'; +// runtime-aware wrapper (shadows core/public's fromJsonSchema with optional validator) +export { fromJsonSchema } from './fromJsonSchema.js'; + // re-export curated public API from core export * from '@modelcontextprotocol/core/public'; diff --git a/test/integration/test/standardSchema.test.ts b/test/integration/test/standardSchema.test.ts index c3817b120..67f16c5fa 100644 --- a/test/integration/test/standardSchema.test.ts +++ b/test/integration/test/standardSchema.test.ts @@ -6,7 +6,7 @@ import { Client } from '@modelcontextprotocol/client'; import type { TextContent } from '@modelcontextprotocol/core'; import { AjvJsonSchemaValidator, fromJsonSchema, InMemoryTransport } from '@modelcontextprotocol/core'; -import { completable, McpServer } from '@modelcontextprotocol/server'; +import { completable, fromJsonSchema as serverFromJsonSchema, McpServer } from '@modelcontextprotocol/server'; import { toStandardJsonSchema } from '@valibot/to-json-schema'; import { type } from 'arktype'; import * as v from 'valibot'; @@ -428,6 +428,44 @@ describe('Standard Schema Support', () => { }); }); + describe('fromJsonSchema with default validator (server wrapper)', () => { + test('should use runtime-appropriate default validator when none is provided', async () => { + const inputSchema = serverFromJsonSchema<{ name: string }>({ + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + }); + + mcpServer.registerTool('greet-default', { inputSchema }, async ({ name }) => ({ + content: [{ type: 'text', text: `Hello, ${name}!` }] + })); + + await connectClientAndServer(); + + const result = await client.request({ method: 'tools/call', params: { name: 'greet-default', arguments: { name: 'World' } } }); + expect((result.content[0] as TextContent).text).toBe('Hello, World!'); + }); + + test('should reject invalid input with default validator', async () => { + const inputSchema = serverFromJsonSchema({ type: 'object', properties: { count: { type: 'number' } }, required: ['count'] }); + + mcpServer.registerTool('double-default', { inputSchema }, async args => { + const { count } = args as { count: number }; + return { content: [{ type: 'text', text: `${count * 2}` }] }; + }); + + await connectClientAndServer(); + + const result = await client.request({ + method: 'tools/call', + params: { name: 'double-default', arguments: { count: 'not a number' } } + }); + expect(result.isError).toBe(true); + const errorText = (result.content[0] as TextContent).text; + expect(errorText).toContain('Input validation error'); + }); + }); + describe('Prompt completions with Zod completable', () => { // Note: completable() is currently Zod-specific // These tests verify that Zod schemas with completable still work