Skip to content

Commit 84971c8

Browse files
committed
Set Mcp-Protocol-Version in client requests after init, and warn when it doesn't match negotiated version (it SHOULD match)
1 parent 1878143 commit 84971c8

File tree

9 files changed

+243
-19
lines changed

9 files changed

+243
-19
lines changed

src/client/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,8 @@ export class Client<
165165

166166
this._serverCapabilities = result.capabilities;
167167
this._serverVersion = result.serverInfo;
168+
// HTTP transports must set the protocol version in each header after initialization.
169+
transport.protocolVersion = result.protocolVersion;
168170

169171
this._instructions = result.instructions;
170172

src/client/sse.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export class SSEClientTransport implements Transport {
6262
private _eventSourceInit?: EventSourceInit;
6363
private _requestInit?: RequestInit;
6464
private _authProvider?: OAuthClientProvider;
65+
protocolVersion?: string;
6566

6667
onclose?: () => void;
6768
onerror?: (error: Error) => void;
@@ -99,13 +100,18 @@ export class SSEClientTransport implements Transport {
99100
}
100101

101102
private async _commonHeaders(): Promise<HeadersInit> {
102-
const headers: HeadersInit = { ...this._requestInit?.headers };
103+
const headers = {
104+
...this._requestInit?.headers,
105+
} as HeadersInit & Record<string, string>;
103106
if (this._authProvider) {
104107
const tokens = await this._authProvider.tokens();
105108
if (tokens) {
106-
(headers as Record<string, string>)["Authorization"] = `Bearer ${tokens.access_token}`;
109+
headers["Authorization"] = `Bearer ${tokens.access_token}`;
107110
}
108111
}
112+
if (this.protocolVersion) {
113+
headers["mcp-protocol-version"] = this.protocolVersion;
114+
}
109115

110116
return headers;
111117
}
@@ -214,7 +220,9 @@ export class SSEClientTransport implements Transport {
214220

215221
try {
216222
const commonHeaders = await this._commonHeaders();
217-
const headers = new Headers({ ...commonHeaders, ...this._requestInit?.headers });
223+
// Note: this._requestInit?.headers already set in _commonHeaders
224+
// const headers = new Headers({ ...commonHeaders, ...this._requestInit?.headers });
225+
const headers = new Headers(commonHeaders);
218226
headers.set("content-type", "application/json");
219227
const init = {
220228
...this._requestInit,

src/client/streamableHttp.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ export class StreamableHTTPClientTransport implements Transport {
124124
private _authProvider?: OAuthClientProvider;
125125
private _sessionId?: string;
126126
private _reconnectionOptions: StreamableHTTPReconnectionOptions;
127+
protocolVersion?: string;
127128

128129
onclose?: () => void;
129130
onerror?: (error: Error) => void;
@@ -162,7 +163,7 @@ export class StreamableHTTPClientTransport implements Transport {
162163
}
163164

164165
private async _commonHeaders(): Promise<Headers> {
165-
const headers: HeadersInit = {};
166+
const headers: HeadersInit & Record<string, string> = {};
166167
if (this._authProvider) {
167168
const tokens = await this._authProvider.tokens();
168169
if (tokens) {
@@ -173,6 +174,9 @@ export class StreamableHTTPClientTransport implements Transport {
173174
if (this._sessionId) {
174175
headers["mcp-session-id"] = this._sessionId;
175176
}
177+
if (this.protocolVersion != null) {
178+
headers["mcp-protocol-version"] = this.protocolVersion;
179+
}
176180

177181
return new Headers(
178182
{ ...headers, ...this._requestInit?.headers }

src/integration-tests/stateManagementStreamableHttp.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,27 @@ describe('Streamable HTTP Transport Session Management', () => {
211211
// Clean up
212212
await transport.close();
213213
});
214+
215+
it('should set protocol version after connecting', async () => {
216+
// Create and connect a client
217+
const client = new Client({
218+
name: 'test-client',
219+
version: '1.0.0'
220+
});
221+
222+
const transport = new StreamableHTTPClientTransport(baseUrl);
223+
224+
// Verify protocol version is not set before connecting
225+
expect(transport.protocolVersion).toBeUndefined();
226+
227+
await client.connect(transport);
228+
229+
// Verify protocol version is set after connecting
230+
expect(transport.protocolVersion).toBe('2025-03-26');
231+
232+
// Clean up
233+
await transport.close();
234+
});
214235
});
215236

216237
describe('Stateful Mode', () => {

src/server/index.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -251,10 +251,15 @@ export class Server<
251251
this._clientCapabilities = request.params.capabilities;
252252
this._clientVersion = request.params.clientInfo;
253253

254-
return {
255-
protocolVersion: SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion)
254+
const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion)
256255
? requestedVersion
257-
: LATEST_PROTOCOL_VERSION,
256+
: LATEST_PROTOCOL_VERSION;
257+
if (this.transport) {
258+
this.transport.protocolVersion = protocolVersion;
259+
}
260+
261+
return {
262+
protocolVersion,
258263
capabilities: this.getCapabilities(),
259264
serverInfo: this._serverInfo,
260265
...(this._instructions && { instructions: this._instructions }),

src/server/sse.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export class SSEServerTransport implements Transport {
1818
private _sseResponse?: ServerResponse;
1919
private _sessionId: string;
2020

21+
protocolVersion?: string;
2122
onclose?: () => void;
2223
onerror?: (error: Error) => void;
2324
onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void;

src/server/streamableHttp.test.ts

Lines changed: 140 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,8 @@ async function sendPostRequest(baseUrl: URL, message: JSONRPCMessage | JSONRPCMe
185185

186186
if (sessionId) {
187187
headers["mcp-session-id"] = sessionId;
188+
// After initialization, include the protocol version header
189+
headers["mcp-protocol-version"] = "2025-03-26";
188190
}
189191

190192
return fetch(baseUrl, {
@@ -277,7 +279,7 @@ describe("StreamableHTTPServerTransport", () => {
277279
expectErrorResponse(errorData, -32600, /Only one initialization request is allowed/);
278280
});
279281

280-
it("should pandle post requests via sse response correctly", async () => {
282+
it("should handle post requests via sse response correctly", async () => {
281283
sessionId = await initializeServer();
282284

283285
const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId);
@@ -376,6 +378,7 @@ describe("StreamableHTTPServerTransport", () => {
376378
headers: {
377379
Accept: "text/event-stream",
378380
"mcp-session-id": sessionId,
381+
"mcp-protocol-version": "2025-03-26",
379382
},
380383
});
381384

@@ -417,6 +420,7 @@ describe("StreamableHTTPServerTransport", () => {
417420
headers: {
418421
Accept: "text/event-stream",
419422
"mcp-session-id": sessionId,
423+
"mcp-protocol-version": "2025-03-26",
420424
},
421425
});
422426

@@ -448,6 +452,7 @@ describe("StreamableHTTPServerTransport", () => {
448452
headers: {
449453
Accept: "text/event-stream",
450454
"mcp-session-id": sessionId,
455+
"mcp-protocol-version": "2025-03-26",
451456
},
452457
});
453458

@@ -459,6 +464,7 @@ describe("StreamableHTTPServerTransport", () => {
459464
headers: {
460465
Accept: "text/event-stream",
461466
"mcp-session-id": sessionId,
467+
"mcp-protocol-version": "2025-03-26",
462468
},
463469
});
464470

@@ -477,6 +483,7 @@ describe("StreamableHTTPServerTransport", () => {
477483
headers: {
478484
Accept: "application/json",
479485
"mcp-session-id": sessionId,
486+
"mcp-protocol-version": "2025-03-26",
480487
},
481488
});
482489

@@ -670,6 +677,7 @@ describe("StreamableHTTPServerTransport", () => {
670677
headers: {
671678
Accept: "text/event-stream",
672679
"mcp-session-id": sessionId,
680+
"mcp-protocol-version": "2025-03-26",
673681
},
674682
});
675683

@@ -705,7 +713,10 @@ describe("StreamableHTTPServerTransport", () => {
705713
// Now DELETE the session
706714
const deleteResponse = await fetch(tempUrl, {
707715
method: "DELETE",
708-
headers: { "mcp-session-id": tempSessionId || "" },
716+
headers: {
717+
"mcp-session-id": tempSessionId || "",
718+
"mcp-protocol-version": "2025-03-26",
719+
},
709720
});
710721

711722
expect(deleteResponse.status).toBe(200);
@@ -721,13 +732,129 @@ describe("StreamableHTTPServerTransport", () => {
721732
// Try to delete with invalid session ID
722733
const response = await fetch(baseUrl, {
723734
method: "DELETE",
724-
headers: { "mcp-session-id": "invalid-session-id" },
735+
headers: {
736+
"mcp-session-id": "invalid-session-id",
737+
"mcp-protocol-version": "2025-03-26",
738+
},
725739
});
726740

727741
expect(response.status).toBe(404);
728742
const errorData = await response.json();
729743
expectErrorResponse(errorData, -32001, /Session not found/);
730744
});
745+
746+
describe("protocol version header validation", () => {
747+
it("should accept requests with matching protocol version", async () => {
748+
sessionId = await initializeServer();
749+
750+
// Send request with matching protocol version
751+
const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId);
752+
753+
expect(response.status).toBe(200);
754+
});
755+
756+
it("should accept requests without protocol version header", async () => {
757+
sessionId = await initializeServer();
758+
759+
// Send request without protocol version header
760+
const response = await fetch(baseUrl, {
761+
method: "POST",
762+
headers: {
763+
"Content-Type": "application/json",
764+
Accept: "application/json, text/event-stream",
765+
"mcp-session-id": sessionId,
766+
// No mcp-protocol-version header
767+
},
768+
body: JSON.stringify(TEST_MESSAGES.toolsList),
769+
});
770+
771+
expect(response.status).toBe(200);
772+
});
773+
774+
it("should reject requests with unsupported protocol version", async () => {
775+
sessionId = await initializeServer();
776+
777+
// Send request with unsupported protocol version
778+
const response = await fetch(baseUrl, {
779+
method: "POST",
780+
headers: {
781+
"Content-Type": "application/json",
782+
Accept: "application/json, text/event-stream",
783+
"mcp-session-id": sessionId,
784+
"mcp-protocol-version": "1999-01-01", // Unsupported version
785+
},
786+
body: JSON.stringify(TEST_MESSAGES.toolsList),
787+
});
788+
789+
expect(response.status).toBe(400);
790+
const errorData = await response.json();
791+
expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version/);
792+
});
793+
794+
it("should accept but warn when protocol version differs from negotiated version", async () => {
795+
sessionId = await initializeServer();
796+
797+
// Spy on console.warn to verify warning is logged
798+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
799+
800+
// Send request with different but supported protocol version
801+
const response = await fetch(baseUrl, {
802+
method: "POST",
803+
headers: {
804+
"Content-Type": "application/json",
805+
Accept: "application/json, text/event-stream",
806+
"mcp-session-id": sessionId,
807+
"mcp-protocol-version": "2024-11-05", // Different but supported version
808+
},
809+
body: JSON.stringify(TEST_MESSAGES.toolsList),
810+
});
811+
812+
// Request should still succeed
813+
expect(response.status).toBe(200);
814+
815+
// But warning should have been logged
816+
expect(warnSpy).toHaveBeenCalledWith(
817+
expect.stringContaining("Request has header with protocol version 2024-11-05, but version previously negotiated is 2025-03-26")
818+
);
819+
820+
warnSpy.mockRestore();
821+
});
822+
823+
it("should handle protocol version validation for GET requests", async () => {
824+
sessionId = await initializeServer();
825+
826+
// GET request with unsupported protocol version
827+
const response = await fetch(baseUrl, {
828+
method: "GET",
829+
headers: {
830+
Accept: "text/event-stream",
831+
"mcp-session-id": sessionId,
832+
"mcp-protocol-version": "invalid-version",
833+
},
834+
});
835+
836+
expect(response.status).toBe(400);
837+
const errorData = await response.json();
838+
expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version/);
839+
});
840+
841+
it("should handle protocol version validation for DELETE requests", async () => {
842+
sessionId = await initializeServer();
843+
844+
// DELETE request with unsupported protocol version
845+
const response = await fetch(baseUrl, {
846+
method: "DELETE",
847+
headers: {
848+
"mcp-session-id": sessionId,
849+
"mcp-protocol-version": "invalid-version",
850+
},
851+
});
852+
853+
expect(response.status).toBe(400);
854+
const errorData = await response.json();
855+
expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version/);
856+
});
857+
});
731858
});
732859

733860
describe("StreamableHTTPServerTransport with AuthInfo", () => {
@@ -1120,6 +1247,7 @@ describe("StreamableHTTPServerTransport with resumability", () => {
11201247
headers: {
11211248
Accept: "text/event-stream",
11221249
"mcp-session-id": sessionId,
1250+
"mcp-protocol-version": "2025-03-26",
11231251
},
11241252
});
11251253

@@ -1196,6 +1324,7 @@ describe("StreamableHTTPServerTransport with resumability", () => {
11961324
headers: {
11971325
Accept: "text/event-stream",
11981326
"mcp-session-id": sessionId,
1327+
"mcp-protocol-version": "2025-03-26",
11991328
"last-event-id": firstEventId
12001329
},
12011330
});
@@ -1282,14 +1411,20 @@ describe("StreamableHTTPServerTransport in stateless mode", () => {
12821411
// Open first SSE stream
12831412
const stream1 = await fetch(baseUrl, {
12841413
method: "GET",
1285-
headers: { Accept: "text/event-stream" },
1414+
headers: {
1415+
Accept: "text/event-stream",
1416+
"mcp-protocol-version": "2025-03-26"
1417+
},
12861418
});
12871419
expect(stream1.status).toBe(200);
12881420

12891421
// Open second SSE stream - should still be rejected, stateless mode still only allows one
12901422
const stream2 = await fetch(baseUrl, {
12911423
method: "GET",
1292-
headers: { Accept: "text/event-stream" },
1424+
headers: {
1425+
Accept: "text/event-stream",
1426+
"mcp-protocol-version": "2025-03-26"
1427+
},
12931428
});
12941429
expect(stream2.status).toBe(409); // Conflict - only one stream allowed
12951430
});

0 commit comments

Comments
 (0)