Skip to content

Commit 4c7c434

Browse files
gylove1994ihrpr
authored andcommitted
✨ feat: try add stateless mod
1 parent 970cf9d commit 4c7c434

File tree

2 files changed

+368
-31
lines changed

2 files changed

+368
-31
lines changed

src/server/streamable-http.test.ts

Lines changed: 259 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,24 @@ describe("StreamableHTTPServerTransport", () => {
5757
});
5858

5959
it("should include session ID in response headers", async () => {
60+
// Use POST with initialize method to avoid session ID requirement
61+
const initializeMessage: JSONRPCMessage = {
62+
jsonrpc: "2.0",
63+
method: "initialize",
64+
params: {
65+
clientInfo: { name: "test-client", version: "1.0" },
66+
protocolVersion: "2025-03-26"
67+
},
68+
id: "init-1",
69+
};
70+
6071
const req = createMockRequest({
61-
method: "GET",
72+
method: "POST",
6273
headers: {
63-
accept: "text/event-stream"
74+
"content-type": "application/json",
75+
"accept": "application/json",
6476
},
77+
body: JSON.stringify(initializeMessage),
6578
});
6679

6780
await transport.handleRequest(req, mockResponse);
@@ -79,6 +92,7 @@ describe("StreamableHTTPServerTransport", () => {
7992
method: "GET",
8093
headers: {
8194
"mcp-session-id": "invalid-session-id",
95+
"accept": "text/event-stream"
8296
},
8397
});
8498

@@ -89,13 +103,241 @@ describe("StreamableHTTPServerTransport", () => {
89103
expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"jsonrpc":"2.0"'));
90104
expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"error"'));
91105
});
106+
107+
it("should reject non-initialization requests without session ID with 400 Bad Request", async () => {
108+
const req = createMockRequest({
109+
method: "GET",
110+
headers: {
111+
accept: "text/event-stream",
112+
// No mcp-session-id header
113+
},
114+
});
115+
116+
await transport.handleRequest(req, mockResponse);
117+
118+
expect(mockResponse.writeHead).toHaveBeenCalledWith(400);
119+
expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"jsonrpc":"2.0"'));
120+
expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"message":"Bad Request: Mcp-Session-Id header is required"'));
121+
});
122+
123+
it("should always include session ID in initialization response even in stateless mode", async () => {
124+
// Create a stateless transport for this test
125+
const statelessTransport = new StreamableHTTPServerTransport(endpoint, { enableSessionManagement: false });
126+
127+
// Create an initialization request
128+
const initializeMessage: JSONRPCMessage = {
129+
jsonrpc: "2.0",
130+
method: "initialize",
131+
params: {
132+
clientInfo: { name: "test-client", version: "1.0" },
133+
protocolVersion: "2025-03-26"
134+
},
135+
id: "init-1",
136+
};
137+
138+
const req = createMockRequest({
139+
method: "POST",
140+
headers: {
141+
"content-type": "application/json",
142+
"accept": "application/json",
143+
},
144+
body: JSON.stringify(initializeMessage),
145+
});
146+
147+
await statelessTransport.handleRequest(req, mockResponse);
148+
149+
// In stateless mode, session ID should also be included for initialize responses
150+
expect(mockResponse.writeHead).toHaveBeenCalledWith(
151+
200,
152+
expect.objectContaining({
153+
"mcp-session-id": statelessTransport.sessionId,
154+
})
155+
);
156+
});
157+
});
158+
159+
describe("Stateless Mode", () => {
160+
let statelessTransport: StreamableHTTPServerTransport;
161+
let mockResponse: jest.Mocked<ServerResponse>;
162+
163+
beforeEach(() => {
164+
statelessTransport = new StreamableHTTPServerTransport(endpoint, { enableSessionManagement: false });
165+
mockResponse = createMockResponse();
166+
});
167+
168+
it("should not include session ID in response headers when in stateless mode", async () => {
169+
// Use a non-initialization request
170+
const message: JSONRPCMessage = {
171+
jsonrpc: "2.0",
172+
method: "test",
173+
params: {},
174+
id: 1,
175+
};
176+
177+
const req = createMockRequest({
178+
method: "POST",
179+
headers: {
180+
"content-type": "application/json",
181+
"accept": "application/json",
182+
},
183+
body: JSON.stringify(message),
184+
});
185+
186+
await statelessTransport.handleRequest(req, mockResponse);
187+
188+
expect(mockResponse.writeHead).toHaveBeenCalled();
189+
// Extract the headers from writeHead call
190+
const headers = mockResponse.writeHead.mock.calls[0][1];
191+
expect(headers).not.toHaveProperty("mcp-session-id");
192+
});
193+
194+
it("should not validate session ID in stateless mode", async () => {
195+
const req = createMockRequest({
196+
method: "GET",
197+
headers: {
198+
accept: "text/event-stream",
199+
"mcp-session-id": "invalid-session-id", // This would cause a 404 in stateful mode
200+
},
201+
});
202+
203+
await statelessTransport.handleRequest(req, mockResponse);
204+
205+
// Should still get 200 OK, not 404 Not Found
206+
expect(mockResponse.writeHead).toHaveBeenCalledWith(
207+
200,
208+
expect.not.objectContaining({
209+
"mcp-session-id": expect.anything(),
210+
})
211+
);
212+
});
213+
214+
it("should handle POST requests without session validation in stateless mode", async () => {
215+
const message: JSONRPCMessage = {
216+
jsonrpc: "2.0",
217+
method: "test",
218+
params: {},
219+
id: 1,
220+
};
221+
222+
const req = createMockRequest({
223+
method: "POST",
224+
headers: {
225+
"content-type": "application/json",
226+
"accept": "application/json",
227+
"mcp-session-id": "non-existent-session-id", // This would be rejected in stateful mode
228+
},
229+
body: JSON.stringify(message),
230+
});
231+
232+
const onMessageMock = jest.fn();
233+
statelessTransport.onmessage = onMessageMock;
234+
235+
await statelessTransport.handleRequest(req, mockResponse);
236+
237+
// Message should be processed despite invalid session ID
238+
expect(onMessageMock).toHaveBeenCalledWith(message);
239+
});
240+
241+
it("should work with a mix of requests with and without session IDs in stateless mode", async () => {
242+
// First request without session ID
243+
const req1 = createMockRequest({
244+
method: "GET",
245+
headers: {
246+
accept: "text/event-stream",
247+
},
248+
});
249+
250+
await statelessTransport.handleRequest(req1, mockResponse);
251+
expect(mockResponse.writeHead).toHaveBeenCalledWith(
252+
200,
253+
expect.objectContaining({
254+
"Content-Type": "text/event-stream",
255+
})
256+
);
257+
258+
// Reset mock for second request
259+
mockResponse.writeHead.mockClear();
260+
261+
// Second request with a session ID (which would be invalid in stateful mode)
262+
const req2 = createMockRequest({
263+
method: "GET",
264+
headers: {
265+
accept: "text/event-stream",
266+
"mcp-session-id": "some-random-session-id",
267+
},
268+
});
269+
270+
await statelessTransport.handleRequest(req2, mockResponse);
271+
272+
// Should still succeed
273+
expect(mockResponse.writeHead).toHaveBeenCalledWith(
274+
200,
275+
expect.objectContaining({
276+
"Content-Type": "text/event-stream",
277+
})
278+
);
279+
});
280+
281+
it("should handle initialization requests properly in both modes", async () => {
282+
// Initialize message that would typically be sent during initialization
283+
const initializeMessage: JSONRPCMessage = {
284+
jsonrpc: "2.0",
285+
method: "initialize",
286+
params: {
287+
clientInfo: { name: "test-client", version: "1.0" },
288+
protocolVersion: "2025-03-26"
289+
},
290+
id: "init-1",
291+
};
292+
293+
// Test stateful transport (default)
294+
const statefulReq = createMockRequest({
295+
method: "POST",
296+
headers: {
297+
"content-type": "application/json",
298+
"accept": "application/json",
299+
},
300+
body: JSON.stringify(initializeMessage),
301+
});
302+
303+
await transport.handleRequest(statefulReq, mockResponse);
304+
305+
// In stateful mode, session ID should be included in the response header
306+
expect(mockResponse.writeHead).toHaveBeenCalledWith(
307+
200,
308+
expect.objectContaining({
309+
"mcp-session-id": transport.sessionId,
310+
})
311+
);
312+
313+
// Reset mocks for stateless test
314+
mockResponse.writeHead.mockClear();
315+
316+
// Test stateless transport
317+
const statelessReq = createMockRequest({
318+
method: "POST",
319+
headers: {
320+
"content-type": "application/json",
321+
"accept": "application/json",
322+
},
323+
body: JSON.stringify(initializeMessage),
324+
});
325+
326+
await statelessTransport.handleRequest(statelessReq, mockResponse);
327+
328+
// In stateless mode, session ID should also be included for initialize responses
329+
const headers = mockResponse.writeHead.mock.calls[0][1];
330+
expect(headers).toHaveProperty("mcp-session-id", statelessTransport.sessionId);
331+
});
92332
});
93333

94334
describe("Request Handling", () => {
95335
it("should reject GET requests without Accept: text/event-stream header", async () => {
96336
const req = createMockRequest({
97337
method: "GET",
98-
headers: {},
338+
headers: {
339+
"mcp-session-id": transport.sessionId,
340+
},
99341
});
100342

101343
await transport.handleRequest(req, mockResponse);
@@ -108,7 +350,8 @@ describe("StreamableHTTPServerTransport", () => {
108350
const req = createMockRequest({
109351
method: "GET",
110352
headers: {
111-
accept: "text/event-stream",
353+
"accept": "text/event-stream",
354+
"mcp-session-id": transport.sessionId,
112355
},
113356
});
114357

@@ -127,7 +370,7 @@ describe("StreamableHTTPServerTransport", () => {
127370
it("should reject POST requests without proper Accept header", async () => {
128371
const message: JSONRPCMessage = {
129372
jsonrpc: "2.0",
130-
method: "test",
373+
method: "initialize", // Use initialize to bypass session ID check
131374
params: {},
132375
id: 1,
133376
};
@@ -148,7 +391,7 @@ describe("StreamableHTTPServerTransport", () => {
148391
it("should properly handle JSON-RPC request messages in POST requests", async () => {
149392
const message: JSONRPCMessage = {
150393
jsonrpc: "2.0",
151-
method: "test",
394+
method: "initialize", // Use initialize to bypass session ID check
152395
params: {},
153396
id: 1,
154397
};
@@ -188,6 +431,7 @@ describe("StreamableHTTPServerTransport", () => {
188431
headers: {
189432
"content-type": "application/json",
190433
"accept": "application/json, text/event-stream",
434+
"mcp-session-id": transport.sessionId,
191435
},
192436
body: JSON.stringify(notification),
193437
});
@@ -212,6 +456,7 @@ describe("StreamableHTTPServerTransport", () => {
212456
headers: {
213457
"content-type": "application/json",
214458
"accept": "application/json",
459+
"mcp-session-id": transport.sessionId,
215460
},
216461
body: JSON.stringify(batchMessages),
217462
});
@@ -231,6 +476,7 @@ describe("StreamableHTTPServerTransport", () => {
231476
headers: {
232477
"content-type": "text/plain",
233478
"accept": "application/json",
479+
"mcp-session-id": transport.sessionId,
234480
},
235481
body: "test",
236482
});
@@ -244,7 +490,9 @@ describe("StreamableHTTPServerTransport", () => {
244490
it("should properly handle DELETE requests and close session", async () => {
245491
const req = createMockRequest({
246492
method: "DELETE",
247-
headers: {},
493+
headers: {
494+
"mcp-session-id": transport.sessionId,
495+
},
248496
});
249497

250498
const onCloseMock = jest.fn();
@@ -259,11 +507,12 @@ describe("StreamableHTTPServerTransport", () => {
259507

260508
describe("Message Replay", () => {
261509
it("should replay messages after specified Last-Event-ID", async () => {
262-
// Establish first connection with Accept header
510+
// Establish first connection with Accept header and session ID
263511
const req1 = createMockRequest({
264512
method: "GET",
265513
headers: {
266-
"accept": "text/event-stream"
514+
"accept": "text/event-stream",
515+
"mcp-session-id": transport.sessionId
267516
},
268517
});
269518
await transport.handleRequest(req1, mockResponse);
@@ -293,6 +542,7 @@ describe("StreamableHTTPServerTransport", () => {
293542
headers: {
294543
"accept": "text/event-stream",
295544
"last-event-id": lastEventId,
545+
"mcp-session-id": transport.sessionId
296546
},
297547
});
298548

0 commit comments

Comments
 (0)