Skip to content

Commit 7c115ed

Browse files
authored
Merge branch 'main' into main
2 parents 670d988 + 592c91f commit 7c115ed

File tree

7 files changed

+183
-24
lines changed

7 files changed

+183
-24
lines changed

README.md

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ await server.connect(transport);
211211
For remote servers, start a web server with a Server-Sent Events (SSE) endpoint, and a separate endpoint for the client to send its messages to:
212212

213213
```typescript
214-
import express from "express";
214+
import express, { Request, Response } from "express";
215215
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
216216
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
217217

@@ -224,16 +224,27 @@ const server = new McpServer({
224224

225225
const app = express();
226226

227-
app.get("/sse", async (req, res) => {
228-
const transport = new SSEServerTransport("/messages", res);
227+
// to support multiple simultaneous connections we have a lookup object from
228+
// sessionId to transport
229+
const transports: {[sessionId: string]: SSEServerTransport} = {};
230+
231+
app.get("/sse", async (_: Request, res: Response) => {
232+
const transport = new SSEServerTransport('/messages', res);
233+
transports[transport.sessionId] = transport;
234+
res.on("close", () => {
235+
delete transports[transport.sessionId];
236+
});
229237
await server.connect(transport);
230238
});
231239

232-
app.post("/messages", async (req, res) => {
233-
// Note: to support multiple simultaneous connections, these messages will
234-
// need to be routed to a specific matching transport. (This logic isn't
235-
// implemented here, for simplicity.)
236-
await transport.handlePostMessage(req, res);
240+
app.post("/messages", async (req: Request, res: Response) => {
241+
const sessionId = req.query.sessionId as string;
242+
const transport = transports[sessionId];
243+
if (transport) {
244+
await transport.handlePostMessage(req, res);
245+
} else {
246+
res.status(400).send('No transport found for sessionId');
247+
}
237248
});
238249

239250
app.listen(3001);

package-lock.json

Lines changed: 13 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@modelcontextprotocol/sdk",
3-
"version": "1.7.0",
3+
"version": "1.8.0",
44
"description": "Model Context Protocol implementation for TypeScript",
55
"license": "MIT",
66
"author": "Anthropic, PBC (https://anthropic.com)",
@@ -48,6 +48,7 @@
4848
"dependencies": {
4949
"content-type": "^1.0.5",
5050
"cors": "^2.8.5",
51+
"cross-spawn": "^7.0.3",
5152
"eventsource": "^3.0.2",
5253
"express": "^5.0.1",
5354
"express-rate-limit": "^7.5.0",
@@ -61,6 +62,7 @@
6162
"@jest-mock/express": "^3.0.0",
6263
"@types/content-type": "^1.1.8",
6364
"@types/cors": "^2.8.17",
65+
"@types/cross-spawn": "^6.0.6",
6466
"@types/eslint__js": "^8.42.3",
6567
"@types/eventsource": "^1.1.15",
6668
"@types/express": "^5.0.0",

src/client/auth.test.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,11 @@ describe("OAuth Authorization", () => {
4646
it("returns metadata when first fetch fails but second without MCP header succeeds", async () => {
4747
// Set up a counter to control behavior
4848
let callCount = 0;
49-
49+
5050
// Mock implementation that changes behavior based on call count
5151
mockFetch.mockImplementation((_url, _options) => {
5252
callCount++;
53-
53+
5454
if (callCount === 1) {
5555
// First call with MCP header - fail with TypeError (simulating CORS error)
5656
// We need to use TypeError specifically because that's what the implementation checks for
@@ -68,22 +68,22 @@ describe("OAuth Authorization", () => {
6868
// Should succeed with the second call
6969
const metadata = await discoverOAuthMetadata("https://auth.example.com");
7070
expect(metadata).toEqual(validMetadata);
71-
71+
7272
// Verify both calls were made
7373
expect(mockFetch).toHaveBeenCalledTimes(2);
74-
74+
7575
// Verify first call had MCP header
7676
expect(mockFetch.mock.calls[0][1]?.headers).toHaveProperty("MCP-Protocol-Version");
7777
});
7878

7979
it("throws an error when all fetch attempts fail", async () => {
8080
// Set up a counter to control behavior
8181
let callCount = 0;
82-
82+
8383
// Mock implementation that changes behavior based on call count
8484
mockFetch.mockImplementation((_url, _options) => {
8585
callCount++;
86-
86+
8787
if (callCount === 1) {
8888
// First call - fail with TypeError
8989
return Promise.reject(new TypeError("First failure"));
@@ -96,7 +96,7 @@ describe("OAuth Authorization", () => {
9696
// Should fail with the second error
9797
await expect(discoverOAuthMetadata("https://auth.example.com"))
9898
.rejects.toThrow("Second failure");
99-
99+
100100
// Verify both calls were made
101101
expect(mockFetch).toHaveBeenCalledTimes(2);
102102
});
@@ -250,6 +250,7 @@ describe("OAuth Authorization", () => {
250250
clientInformation: validClientInfo,
251251
authorizationCode: "code123",
252252
codeVerifier: "verifier123",
253+
redirectUri: "http://localhost:3000/callback",
253254
});
254255

255256
expect(tokens).toEqual(validTokens);
@@ -271,6 +272,7 @@ describe("OAuth Authorization", () => {
271272
expect(body.get("code_verifier")).toBe("verifier123");
272273
expect(body.get("client_id")).toBe("client123");
273274
expect(body.get("client_secret")).toBe("secret123");
275+
expect(body.get("redirect_uri")).toBe("http://localhost:3000/callback");
274276
});
275277

276278
it("validates token response schema", async () => {
@@ -288,6 +290,7 @@ describe("OAuth Authorization", () => {
288290
clientInformation: validClientInfo,
289291
authorizationCode: "code123",
290292
codeVerifier: "verifier123",
293+
redirectUri: "http://localhost:3000/callback",
291294
})
292295
).rejects.toThrow();
293296
});
@@ -303,6 +306,7 @@ describe("OAuth Authorization", () => {
303306
clientInformation: validClientInfo,
304307
authorizationCode: "code123",
305308
codeVerifier: "verifier123",
309+
redirectUri: "http://localhost:3000/callback",
306310
})
307311
).rejects.toThrow("Token exchange failed");
308312
});

src/client/auth.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ export async function auth(
115115
clientInformation,
116116
authorizationCode,
117117
codeVerifier,
118+
redirectUri: provider.redirectUrl,
118119
});
119120

120121
await provider.saveTokens(tokens);
@@ -259,11 +260,13 @@ export async function exchangeAuthorization(
259260
clientInformation,
260261
authorizationCode,
261262
codeVerifier,
263+
redirectUri,
262264
}: {
263265
metadata?: OAuthMetadata;
264266
clientInformation: OAuthClientInformation;
265267
authorizationCode: string;
266268
codeVerifier: string;
269+
redirectUri: string | URL;
267270
},
268271
): Promise<OAuthTokens> {
269272
const grantType = "authorization_code";
@@ -290,6 +293,7 @@ export async function exchangeAuthorization(
290293
client_id: clientInformation.client_id,
291294
code: authorizationCode,
292295
code_verifier: codeVerifier,
296+
redirect_uri: String(redirectUri),
293297
});
294298

295299
if (clientInformation.client_secret) {

src/client/cross-spawn.test.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { StdioClientTransport } from "./stdio.js";
2+
import spawn from "cross-spawn";
3+
import { JSONRPCMessage } from "../types.js";
4+
import { ChildProcess } from "node:child_process";
5+
6+
// mock cross-spawn
7+
jest.mock("cross-spawn");
8+
const mockSpawn = spawn as jest.MockedFunction<typeof spawn>;
9+
10+
describe("StdioClientTransport using cross-spawn", () => {
11+
beforeEach(() => {
12+
// mock cross-spawn's return value
13+
mockSpawn.mockImplementation(() => {
14+
const mockProcess: {
15+
on: jest.Mock;
16+
stdin?: { on: jest.Mock; write: jest.Mock };
17+
stdout?: { on: jest.Mock };
18+
stderr?: null;
19+
} = {
20+
on: jest.fn((event: string, callback: () => void) => {
21+
if (event === "spawn") {
22+
callback();
23+
}
24+
return mockProcess;
25+
}),
26+
stdin: {
27+
on: jest.fn(),
28+
write: jest.fn().mockReturnValue(true)
29+
},
30+
stdout: {
31+
on: jest.fn()
32+
},
33+
stderr: null
34+
};
35+
return mockProcess as unknown as ChildProcess;
36+
});
37+
});
38+
39+
afterEach(() => {
40+
jest.clearAllMocks();
41+
});
42+
43+
test("should call cross-spawn correctly", async () => {
44+
const transport = new StdioClientTransport({
45+
command: "test-command",
46+
args: ["arg1", "arg2"]
47+
});
48+
49+
await transport.start();
50+
51+
// verify spawn is called correctly
52+
expect(mockSpawn).toHaveBeenCalledWith(
53+
"test-command",
54+
["arg1", "arg2"],
55+
expect.objectContaining({
56+
shell: false
57+
})
58+
);
59+
});
60+
61+
test("should pass environment variables correctly", async () => {
62+
const customEnv = { TEST_VAR: "test-value" };
63+
const transport = new StdioClientTransport({
64+
command: "test-command",
65+
env: customEnv
66+
});
67+
68+
await transport.start();
69+
70+
// verify environment variables are passed correctly
71+
expect(mockSpawn).toHaveBeenCalledWith(
72+
"test-command",
73+
[],
74+
expect.objectContaining({
75+
env: customEnv
76+
})
77+
);
78+
});
79+
80+
test("should send messages correctly", async () => {
81+
const transport = new StdioClientTransport({
82+
command: "test-command"
83+
});
84+
85+
// get the mock process object
86+
const mockProcess: {
87+
on: jest.Mock;
88+
stdin: {
89+
on: jest.Mock;
90+
write: jest.Mock;
91+
once: jest.Mock;
92+
};
93+
stdout: {
94+
on: jest.Mock;
95+
};
96+
stderr: null;
97+
} = {
98+
on: jest.fn((event: string, callback: () => void) => {
99+
if (event === "spawn") {
100+
callback();
101+
}
102+
return mockProcess;
103+
}),
104+
stdin: {
105+
on: jest.fn(),
106+
write: jest.fn().mockReturnValue(true),
107+
once: jest.fn()
108+
},
109+
stdout: {
110+
on: jest.fn()
111+
},
112+
stderr: null
113+
};
114+
115+
mockSpawn.mockReturnValue(mockProcess as unknown as ChildProcess);
116+
117+
await transport.start();
118+
119+
// 关键修复:确保 jsonrpc 是字面量 "2.0"
120+
const message: JSONRPCMessage = {
121+
jsonrpc: "2.0",
122+
id: "test-id",
123+
method: "test-method"
124+
};
125+
126+
await transport.send(message);
127+
128+
// verify message is sent correctly
129+
expect(mockProcess.stdin.write).toHaveBeenCalled();
130+
});
131+
});

0 commit comments

Comments
 (0)