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/fix-streamable-http-error-response.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/client': patch
---

Fix StreamableHTTPClientTransport to handle error responses in SSE streams
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`

Expand Down
11 changes: 6 additions & 5 deletions docs/migration-SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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` |
Expand Down
16 changes: 10 additions & 6 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -270,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({})
Expand Down Expand Up @@ -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` |
Expand All @@ -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' }] };
Expand Down
4 changes: 3 additions & 1 deletion packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol
import {
createFetchWithInit,
isInitializedNotification,
isJSONRPCErrorResponse,
isJSONRPCRequest,
isJSONRPCResultResponse,
JSONRPCMessageSchema,
Expand Down Expand Up @@ -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) {
Expand Down
9 changes: 9 additions & 0 deletions packages/client/src/fromJsonSchema.ts
Original file line number Diff line number Diff line change
@@ -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<T = unknown>(schema: JsonSchemaType, validator?: jsonSchemaValidator): StandardSchemaWithJSON<T, T> {
return coreFromJsonSchema<T>(schema, validator ?? (_defaultValidator ??= new DefaultJsonSchemaValidator()));
}
3 changes: 3 additions & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
72 changes: 72 additions & 0 deletions packages/client/test/client/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'), {
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/exports/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
7 changes: 3 additions & 4 deletions packages/core/src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import type {
RelatedTaskMetadata,
Request,
RequestId,
RequestInfo,
RequestMeta,
RequestMethod,
RequestTypeMap,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -392,7 +391,7 @@ export abstract class Protocol<ContextT extends BaseContext> {

/**
* 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;

Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/shared/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?: (<T extends JSONRPCMessage>(message: T, extra?: MessageExtraInfo) => void) | undefined;

Expand Down
14 changes: 2 additions & 12 deletions packages/core/src/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,24 +513,14 @@ export type ListChangedHandlers = {
resources?: ListChangedOptions<Resource>;
};

/**
* 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.
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/validators/fromJsonSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MyType>(...)`.
*
* @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 }>(
Expand Down
59 changes: 54 additions & 5 deletions packages/middleware/node/test/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -406,10 +406,11 @@ describe('Zod v4', () => {
inputSchema: z.object({ name: z.string().describe('Name to greet') })
},
async ({ name }, ctx): Promise<CallToolResult> => {
// 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: [
Expand Down Expand Up @@ -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<CallToolResult> => {
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);

Expand Down
9 changes: 9 additions & 0 deletions packages/server/src/fromJsonSchema.ts
Original file line number Diff line number Diff line change
@@ -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<T = unknown>(schema: JsonSchemaType, validator?: jsonSchemaValidator): StandardSchemaWithJSON<T, T> {
return coreFromJsonSchema<T>(schema, validator ?? (_defaultValidator ??= new DefaultJsonSchemaValidator()));
}
Loading
Loading