Skip to content

Commit 640ae5a

Browse files
committed
initial client implementation
1 parent 693fdc3 commit 640ae5a

File tree

2 files changed

+545
-0
lines changed

2 files changed

+545
-0
lines changed

src/client/streamableHttp.test.ts

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { StreamableHTTPClientTransport } from "./streamableHttp.js";
2+
import { JSONRPCMessage } from "../types.js";
3+
4+
5+
describe("StreamableHTTPClientTransport", () => {
6+
let transport: StreamableHTTPClientTransport;
7+
8+
beforeEach(() => {
9+
transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"));
10+
jest.spyOn(global, "fetch");
11+
});
12+
13+
afterEach(() => {
14+
jest.clearAllMocks();
15+
});
16+
17+
it("should send JSON-RPC messages via POST", async () => {
18+
const message: JSONRPCMessage = {
19+
jsonrpc: "2.0",
20+
method: "test",
21+
params: {},
22+
id: "test-id"
23+
};
24+
25+
(global.fetch as jest.Mock).mockResolvedValueOnce({
26+
ok: true,
27+
status: 202,
28+
headers: new Headers(),
29+
});
30+
31+
await transport.send(message);
32+
33+
expect(global.fetch).toHaveBeenCalledWith(
34+
expect.anything(),
35+
expect.objectContaining({
36+
method: "POST",
37+
headers: expect.any(Headers),
38+
body: JSON.stringify(message)
39+
})
40+
);
41+
});
42+
43+
it("should send batch messages", async () => {
44+
const messages: JSONRPCMessage[] = [
45+
{ jsonrpc: "2.0", method: "test1", params: {}, id: "id1" },
46+
{ jsonrpc: "2.0", method: "test2", params: {}, id: "id2" }
47+
];
48+
49+
(global.fetch as jest.Mock).mockResolvedValueOnce({
50+
ok: true,
51+
status: 200,
52+
headers: new Headers({ "content-type": "text/event-stream" }),
53+
body: null
54+
});
55+
56+
await transport.send(messages);
57+
58+
expect(global.fetch).toHaveBeenCalledWith(
59+
expect.anything(),
60+
expect.objectContaining({
61+
method: "POST",
62+
headers: expect.any(Headers),
63+
body: JSON.stringify(messages)
64+
})
65+
);
66+
});
67+
68+
it("should store session ID received during initialization", async () => {
69+
const message: JSONRPCMessage = {
70+
jsonrpc: "2.0",
71+
method: "initialize",
72+
params: {
73+
clientInfo: { name: "test-client", version: "1.0" },
74+
protocolVersion: "2025-03-26"
75+
},
76+
id: "init-id"
77+
};
78+
79+
(global.fetch as jest.Mock).mockResolvedValueOnce({
80+
ok: true,
81+
status: 200,
82+
headers: new Headers({ "mcp-session-id": "test-session-id" }),
83+
});
84+
85+
await transport.send(message);
86+
87+
// Send a second message that should include the session ID
88+
(global.fetch as jest.Mock).mockResolvedValueOnce({
89+
ok: true,
90+
status: 202,
91+
headers: new Headers()
92+
});
93+
94+
await transport.send({ jsonrpc: "2.0", method: "test", params: {} } as JSONRPCMessage);
95+
96+
// Check that second request included session ID header
97+
const calls = (global.fetch as jest.Mock).mock.calls;
98+
const lastCall = calls[calls.length - 1];
99+
expect(lastCall[1].headers).toBeDefined();
100+
expect(lastCall[1].headers.get("mcp-session-id")).toBe("test-session-id");
101+
});
102+
103+
it("should handle 404 response when session expires", async () => {
104+
const message: JSONRPCMessage = {
105+
jsonrpc: "2.0",
106+
method: "test",
107+
params: {},
108+
id: "test-id"
109+
};
110+
111+
(global.fetch as jest.Mock).mockResolvedValueOnce({
112+
ok: false,
113+
status: 404,
114+
statusText: "Not Found",
115+
text: () => Promise.resolve("Session not found"),
116+
headers: new Headers()
117+
});
118+
119+
const errorSpy = jest.fn();
120+
transport.onerror = errorSpy;
121+
122+
await expect(transport.send(message)).rejects.toThrow("Error POSTing to endpoint (HTTP 404)");
123+
expect(errorSpy).toHaveBeenCalled();
124+
});
125+
126+
it("should handle session termination via DELETE request", async () => {
127+
// First set the session ID by mocking initialization
128+
(global.fetch as jest.Mock).mockResolvedValueOnce({
129+
ok: true,
130+
status: 200,
131+
headers: new Headers({ "mcp-session-id": "session-to-terminate" }),
132+
});
133+
134+
await transport.send({
135+
jsonrpc: "2.0",
136+
method: "initialize",
137+
params: {
138+
clientInfo: { name: "test-client", version: "1.0" },
139+
protocolVersion: "2025-03-26"
140+
},
141+
id: "init-id"
142+
} as JSONRPCMessage);
143+
144+
// Mock DELETE request for session termination
145+
(global.fetch as jest.Mock).mockResolvedValueOnce({
146+
ok: true,
147+
status: 200,
148+
headers: new Headers()
149+
});
150+
151+
const closeSpy = jest.fn();
152+
transport.onclose = closeSpy;
153+
154+
await transport.close();
155+
156+
// Check that DELETE request was sent
157+
const calls = (global.fetch as jest.Mock).mock.calls;
158+
const lastCall = calls[calls.length - 1];
159+
expect(lastCall[1].method).toBe("DELETE");
160+
// The headers may be a plain object in tests
161+
expect(lastCall[1].headers["mcp-session-id"]).toBe("session-to-terminate");
162+
163+
expect(closeSpy).toHaveBeenCalled();
164+
});
165+
166+
it("should handle non-streaming JSON response", async () => {
167+
const message: JSONRPCMessage = {
168+
jsonrpc: "2.0",
169+
method: "test",
170+
params: {},
171+
id: "test-id"
172+
};
173+
174+
const responseMessage: JSONRPCMessage = {
175+
jsonrpc: "2.0",
176+
result: { success: true },
177+
id: "test-id"
178+
};
179+
180+
(global.fetch as jest.Mock).mockResolvedValueOnce({
181+
ok: true,
182+
status: 200,
183+
headers: new Headers({ "content-type": "application/json" }),
184+
json: () => Promise.resolve(responseMessage)
185+
});
186+
187+
const messageSpy = jest.fn();
188+
transport.onmessage = messageSpy;
189+
190+
await transport.send(message);
191+
192+
expect(messageSpy).toHaveBeenCalledWith(responseMessage);
193+
});
194+
});

0 commit comments

Comments
 (0)