Skip to content

Commit 067ead8

Browse files
Merge branch 'main' into fix/from-json-schema-validator-optional
2 parents 05464f2 + f73a5af commit 067ead8

5 files changed

Lines changed: 185 additions & 5 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/client': patch
3+
---
4+
5+
Fix StreamableHTTPClientTransport to handle error responses in SSE streams

packages/client/src/client/streamableHttp.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol
44
import {
55
createFetchWithInit,
66
isInitializedNotification,
7+
isJSONRPCErrorResponse,
78
isJSONRPCRequest,
89
isJSONRPCResultResponse,
910
JSONRPCMessageSchema,
@@ -412,7 +413,8 @@ export class StreamableHTTPClientTransport implements Transport {
412413
if (!event.event || event.event === 'message') {
413414
try {
414415
const message = JSONRPCMessageSchema.parse(JSON.parse(event.data));
415-
if (isJSONRPCResultResponse(message)) {
416+
// Handle both success AND error responses for completion detection and ID remapping
417+
if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) {
416418
// Mark that we received a response - no need to reconnect for this request
417419
receivedResponse = true;
418420
if (replayMessageId !== undefined) {

packages/client/test/client/streamableHttp.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1008,6 +1008,78 @@ describe('StreamableHTTPClientTransport', () => {
10081008
expect(fetchMock.mock.calls[0]![1]?.method).toBe('POST');
10091009
});
10101010

1011+
it('should NOT reconnect a POST stream when error response was received', async () => {
1012+
// ARRANGE
1013+
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
1014+
reconnectionOptions: {
1015+
initialReconnectionDelay: 10,
1016+
maxRetries: 1,
1017+
maxReconnectionDelay: 1000,
1018+
reconnectionDelayGrowFactor: 1
1019+
}
1020+
});
1021+
1022+
const messageSpy = vi.fn();
1023+
transport.onmessage = messageSpy;
1024+
1025+
// Create a stream that sends:
1026+
// 1. Priming event with ID (enables potential reconnection)
1027+
// 2. An error response (should also prevent reconnection, just like success)
1028+
// 3. Then closes
1029+
const streamWithErrorResponse = new ReadableStream({
1030+
start(controller) {
1031+
// Priming event with ID
1032+
controller.enqueue(new TextEncoder().encode('id: priming-123\ndata: \n\n'));
1033+
// An error response to the request (tool not found, for example)
1034+
controller.enqueue(
1035+
new TextEncoder().encode(
1036+
'id: error-456\ndata: {"jsonrpc":"2.0","error":{"code":-32602,"message":"Tool not found"},"id":"request-1"}\n\n'
1037+
)
1038+
);
1039+
// Stream closes normally
1040+
controller.close();
1041+
}
1042+
});
1043+
1044+
const fetchMock = global.fetch as Mock;
1045+
fetchMock.mockResolvedValueOnce({
1046+
ok: true,
1047+
status: 200,
1048+
headers: new Headers({ 'content-type': 'text/event-stream' }),
1049+
body: streamWithErrorResponse
1050+
});
1051+
1052+
const requestMessage: JSONRPCRequest = {
1053+
jsonrpc: '2.0',
1054+
method: 'tools/call',
1055+
id: 'request-1',
1056+
params: { name: 'nonexistent-tool' }
1057+
};
1058+
1059+
// ACT
1060+
await transport.start();
1061+
await transport.send(requestMessage);
1062+
await vi.advanceTimersByTimeAsync(50);
1063+
1064+
// ASSERT
1065+
// THE KEY ASSERTION: Fetch was called ONCE only - no reconnection!
1066+
// The error response was received, so no need to reconnect.
1067+
expect(fetchMock).toHaveBeenCalledTimes(1);
1068+
expect(fetchMock.mock.calls[0]![1]?.method).toBe('POST');
1069+
1070+
// Verify the error response was delivered to the message handler
1071+
expect(messageSpy).toHaveBeenCalledWith(
1072+
expect.objectContaining({
1073+
jsonrpc: '2.0',
1074+
error: expect.objectContaining({
1075+
code: -32602,
1076+
message: 'Tool not found'
1077+
}),
1078+
id: 'request-1'
1079+
})
1080+
);
1081+
});
1082+
10111083
it('should not attempt reconnection after close() is called', async () => {
10121084
// ARRANGE
10131085
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {

packages/server/src/server/mcp.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -530,7 +530,8 @@ export class McpServer {
530530
name,
531531
title: prompt.title,
532532
description: prompt.description,
533-
arguments: prompt.argsSchema ? promptArgumentsFromStandardSchema(prompt.argsSchema) : undefined
533+
arguments: prompt.argsSchema ? promptArgumentsFromStandardSchema(prompt.argsSchema) : undefined,
534+
_meta: prompt._meta
534535
};
535536
})
536537
})
@@ -699,7 +700,8 @@ export class McpServer {
699700
title: string | undefined,
700701
description: string | undefined,
701702
argsSchema: StandardSchemaWithJSON | undefined,
702-
callback: PromptCallback<StandardSchemaWithJSON | undefined>
703+
callback: PromptCallback<StandardSchemaWithJSON | undefined>,
704+
_meta: Record<string, unknown> | undefined
703705
): RegisteredPrompt {
704706
// Track current schema and callback for handler regeneration
705707
let currentArgsSchema = argsSchema;
@@ -709,6 +711,7 @@ export class McpServer {
709711
title,
710712
description,
711713
argsSchema,
714+
_meta,
712715
handler: createPromptHandler(name, argsSchema, callback),
713716
enabled: true,
714717
disable: () => registeredPrompt.update({ enabled: false }),
@@ -721,6 +724,7 @@ export class McpServer {
721724
}
722725
if (updates.title !== undefined) registeredPrompt.title = updates.title;
723726
if (updates.description !== undefined) registeredPrompt.description = updates.description;
727+
if (updates._meta !== undefined) registeredPrompt._meta = updates._meta;
724728

725729
// Track if we need to regenerate the handler
726730
let needsHandlerRegen = false;
@@ -921,21 +925,23 @@ export class McpServer {
921925
title?: string;
922926
description?: string;
923927
argsSchema?: Args;
928+
_meta?: Record<string, unknown>;
924929
},
925930
cb: PromptCallback<Args>
926931
): RegisteredPrompt {
927932
if (this._registeredPrompts[name]) {
928933
throw new Error(`Prompt ${name} is already registered`);
929934
}
930935

931-
const { title, description, argsSchema } = config;
936+
const { title, description, argsSchema, _meta } = config;
932937

933938
const registeredPrompt = this._createRegisteredPrompt(
934939
name,
935940
title,
936941
description,
937942
argsSchema,
938-
cb as PromptCallback<StandardSchemaWithJSON | undefined>
943+
cb as PromptCallback<StandardSchemaWithJSON | undefined>,
944+
_meta
939945
);
940946

941947
this.setPromptRequestHandlers();
@@ -1234,6 +1240,7 @@ export type RegisteredPrompt = {
12341240
title?: string;
12351241
description?: string;
12361242
argsSchema?: StandardSchemaWithJSON;
1243+
_meta?: Record<string, unknown>;
12371244
/** @hidden */
12381245
handler: PromptHandler;
12391246
enabled: boolean;
@@ -1244,6 +1251,7 @@ export type RegisteredPrompt = {
12441251
title?: string;
12451252
description?: string;
12461253
argsSchema?: Args;
1254+
_meta?: Record<string, unknown>;
12471255
callback?: PromptCallback<Args>;
12481256
enabled?: boolean;
12491257
}): void;

test/integration/test/server/mcp.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4253,6 +4253,99 @@ describe('Zod v4', () => {
42534253
}
42544254
]);
42554255
});
4256+
4257+
/***
4258+
* Test: Prompt Registration with _meta field
4259+
*/
4260+
test('should register prompt with _meta field and include it in list response', async () => {
4261+
const mcpServer = new McpServer({
4262+
name: 'test server',
4263+
version: '1.0'
4264+
});
4265+
const client = new Client({
4266+
name: 'test client',
4267+
version: '1.0'
4268+
});
4269+
4270+
const metaData = {
4271+
author: 'test-author',
4272+
version: '1.2.3',
4273+
category: 'utility',
4274+
tags: ['test', 'example']
4275+
};
4276+
4277+
mcpServer.registerPrompt(
4278+
'test-with-meta',
4279+
{
4280+
description: 'A prompt with _meta field',
4281+
_meta: metaData
4282+
},
4283+
async () => ({
4284+
messages: [
4285+
{
4286+
role: 'assistant',
4287+
content: {
4288+
type: 'text',
4289+
text: 'Test response'
4290+
}
4291+
}
4292+
]
4293+
})
4294+
);
4295+
4296+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
4297+
4298+
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
4299+
4300+
const result = await client.request({ method: 'prompts/list' });
4301+
4302+
expect(result.prompts).toHaveLength(1);
4303+
expect(result.prompts[0]!.name).toBe('test-with-meta');
4304+
expect(result.prompts[0]!.description).toBe('A prompt with _meta field');
4305+
expect(result.prompts[0]!._meta).toEqual(metaData);
4306+
});
4307+
4308+
/***
4309+
* Test: Prompt Registration without _meta field should have undefined _meta
4310+
*/
4311+
test('should register prompt without _meta field and have undefined _meta in response', async () => {
4312+
const mcpServer = new McpServer({
4313+
name: 'test server',
4314+
version: '1.0'
4315+
});
4316+
const client = new Client({
4317+
name: 'test client',
4318+
version: '1.0'
4319+
});
4320+
4321+
mcpServer.registerPrompt(
4322+
'test-without-meta',
4323+
{
4324+
description: 'A prompt without _meta field'
4325+
},
4326+
async () => ({
4327+
messages: [
4328+
{
4329+
role: 'assistant',
4330+
content: {
4331+
type: 'text',
4332+
text: 'Test response'
4333+
}
4334+
}
4335+
]
4336+
})
4337+
);
4338+
4339+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
4340+
4341+
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
4342+
4343+
const result = await client.request({ method: 'prompts/list' });
4344+
4345+
expect(result.prompts).toHaveLength(1);
4346+
expect(result.prompts[0]!.name).toBe('test-without-meta');
4347+
expect(result.prompts[0]!._meta).toBeUndefined();
4348+
});
42564349
});
42574350

42584351
describe('Tool title precedence', () => {

0 commit comments

Comments
 (0)