Skip to content

Commit 5fc4ef5

Browse files
authored
Merge branch 'main' into anthonjn-fix-sse-initial-connection
2 parents 68c8f8d + 7d7896f commit 5fc4ef5

File tree

9 files changed

+262
-20
lines changed

9 files changed

+262
-20
lines changed

README.md

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -570,20 +570,31 @@ app.listen(3000);
570570
```
571571

572572
> [!TIP]
573-
> When using this in a remote environment, make sure to allow the header parameter `mcp-session-id` in CORS. Otherwise, it may result in a `Bad Request: No valid session ID provided` error.
574-
>
575-
> For example, in Node.js you can configure it like this:
576-
>
577-
> ```ts
578-
> app.use(
579-
> cors({
580-
> origin: ['https://your-remote-domain.com, https://your-other-remote-domain.com'],
581-
> exposedHeaders: ['mcp-session-id'],
582-
> allowedHeaders: ['Content-Type', 'mcp-session-id'],
583-
> })
584-
> );
573+
> When using this in a remote environment, make sure to allow the header parameter `mcp-session-id` in CORS. Otherwise, it may result in a `Bad Request: No valid session ID provided` error. Read the following section for examples.
585574
> ```
586575
576+
577+
#### CORS Configuration for Browser-Based Clients
578+
579+
If you'd like your server to be accessible by browser-based MCP clients, you'll need to configure CORS headers. The `Mcp-Session-Id` header must be exposed for browser clients to access it:
580+
581+
```typescript
582+
import cors from 'cors';
583+
584+
// Add CORS middleware before your MCP routes
585+
app.use(cors({
586+
origin: '*', // Configure appropriately for production, for example:
587+
// origin: ['https://your-remote-domain.com, https://your-other-remote-domain.com'],
588+
exposedHeaders: ['Mcp-Session-Id']
589+
allowedHeaders: ['Content-Type', 'mcp-session-id'],
590+
}));
591+
```
592+
593+
This configuration is necessary because:
594+
- The MCP streamable HTTP transport uses the `Mcp-Session-Id` header for session management
595+
- Browsers restrict access to response headers unless explicitly exposed via CORS
596+
- Without this configuration, browser-based clients won't be able to read the session ID from initialization responses
597+
587598
#### Without Session Management (Stateless)
588599

589600
For simpler use cases where session management isn't needed:

src/client/cross-spawn.test.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { StdioClientTransport } from "./stdio.js";
1+
import { StdioClientTransport, getDefaultEnvironment } from "./stdio.js";
22
import spawn from "cross-spawn";
33
import { JSONRPCMessage } from "../types.js";
44
import { ChildProcess } from "node:child_process";
@@ -67,12 +67,33 @@ describe("StdioClientTransport using cross-spawn", () => {
6767

6868
await transport.start();
6969

70-
// verify environment variables are passed correctly
70+
// verify environment variables are merged correctly
7171
expect(mockSpawn).toHaveBeenCalledWith(
7272
"test-command",
7373
[],
7474
expect.objectContaining({
75-
env: customEnv
75+
env: {
76+
...getDefaultEnvironment(),
77+
...customEnv
78+
}
79+
})
80+
);
81+
});
82+
83+
test("should use default environment when env is undefined", async () => {
84+
const transport = new StdioClientTransport({
85+
command: "test-command",
86+
env: undefined
87+
});
88+
89+
await transport.start();
90+
91+
// verify default environment is used
92+
expect(mockSpawn).toHaveBeenCalledWith(
93+
"test-command",
94+
[],
95+
expect.objectContaining({
96+
env: getDefaultEnvironment()
7697
})
7798
);
7899
});

src/client/stdio.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,11 @@ export class StdioClientTransport implements Transport {
122122
this._serverParams.command,
123123
this._serverParams.args ?? [],
124124
{
125-
env: this._serverParams.env ?? getDefaultEnvironment(),
125+
// merge default env with server env because mcp server needs some env vars
126+
env: {
127+
...getDefaultEnvironment(),
128+
...this._serverParams.env,
129+
},
126130
stdio: ["pipe", "pipe", this._serverParams.stderr ?? "inherit"],
127131
shell: false,
128132
signal: this._abortController.signal,

src/examples/server/jsonResponseStreamableHttp.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { McpServer } from '../../server/mcp.js';
44
import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js';
55
import { z } from 'zod';
66
import { CallToolResult, isInitializeRequest } from '../../types.js';
7+
import cors from 'cors';
78

89

910
// Create an MCP server with implementation details
@@ -81,6 +82,12 @@ const getServer = () => {
8182
const app = express();
8283
app.use(express.json());
8384

85+
// Configure CORS to expose Mcp-Session-Id header for browser-based clients
86+
app.use(cors({
87+
origin: '*', // Allow all origins - adjust as needed for production
88+
exposedHeaders: ['Mcp-Session-Id']
89+
}));
90+
8491
// Map to store transports by session ID
8592
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
8693

src/examples/server/simpleStatelessStreamableHttp.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { McpServer } from '../../server/mcp.js';
33
import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js';
44
import { z } from 'zod';
55
import { CallToolResult, GetPromptResult, ReadResourceResult } from '../../types.js';
6+
import cors from 'cors';
67

78
const getServer = () => {
89
// Create an MCP server with implementation details
@@ -96,6 +97,12 @@ const getServer = () => {
9697
const app = express();
9798
app.use(express.json());
9899

100+
// Configure CORS to expose Mcp-Session-Id header for browser-based clients
101+
app.use(cors({
102+
origin: '*', // Allow all origins - adjust as needed for production
103+
exposedHeaders: ['Mcp-Session-Id']
104+
}));
105+
99106
app.post('/mcp', async (req: Request, res: Response) => {
100107
const server = getServer();
101108
try {

src/examples/server/simpleStreamableHttp.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import { setupAuthServer } from './demoInMemoryOAuthProvider.js';
1111
import { OAuthMetadata } from 'src/shared/auth.js';
1212
import { checkResourceAllowed } from 'src/shared/auth-utils.js';
1313

14+
import cors from 'cors';
15+
1416
// Check for OAuth flag
1517
const useOAuth = process.argv.includes('--oauth');
1618
const strictOAuth = process.argv.includes('--oauth-strict');
@@ -420,12 +422,18 @@ const getServer = () => {
420422
return server;
421423
};
422424

423-
const MCP_PORT = 3000;
424-
const AUTH_PORT = 3001;
425+
const MCP_PORT = process.env.MCP_PORT ? parseInt(process.env.MCP_PORT, 10) : 3000;
426+
const AUTH_PORT = process.env.MCP_AUTH_PORT ? parseInt(process.env.MCP_AUTH_PORT, 10) : 3001;
425427

426428
const app = express();
427429
app.use(express.json());
428430

431+
// Allow CORS all domains, expose the Mcp-Session-Id header
432+
app.use(cors({
433+
origin: '*', // Allow all origins
434+
exposedHeaders: ["Mcp-Session-Id"]
435+
}));
436+
429437
// Set up OAuth if enabled
430438
let authMiddleware = null;
431439
if (useOAuth) {

src/examples/server/sseAndStreamableHttpCompatibleServer.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { SSEServerTransport } from '../../server/sse.js';
66
import { z } from 'zod';
77
import { CallToolResult, isInitializeRequest } from '../../types.js';
88
import { InMemoryEventStore } from '../shared/inMemoryEventStore.js';
9+
import cors from 'cors';
910

1011
/**
1112
* This example server demonstrates backwards compatibility with both:
@@ -71,6 +72,12 @@ const getServer = () => {
7172
const app = express();
7273
app.use(express.json());
7374

75+
// Configure CORS to expose Mcp-Session-Id header for browser-based clients
76+
app.use(cors({
77+
origin: '*', // Allow all origins - adjust as needed for production
78+
exposedHeaders: ['Mcp-Session-Id']
79+
}));
80+
7481
// Store transports by session ID
7582
const transports: Record<string, StreamableHTTPServerTransport | SSEServerTransport> = {};
7683

src/server/streamableHttp.test.ts

Lines changed: 164 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ interface TestServerConfig {
2929
enableJsonResponse?: boolean;
3030
customRequestHandler?: (req: IncomingMessage, res: ServerResponse, parsedBody?: unknown) => Promise<void>;
3131
eventStore?: EventStore;
32+
onsessionclosed?: (sessionId: string) => void;
3233
}
3334

3435
/**
@@ -57,7 +58,8 @@ async function createTestServer(config: TestServerConfig = { sessionIdGenerator:
5758
const transport = new StreamableHTTPServerTransport({
5859
sessionIdGenerator: config.sessionIdGenerator,
5960
enableJsonResponse: config.enableJsonResponse ?? false,
60-
eventStore: config.eventStore
61+
eventStore: config.eventStore,
62+
onsessionclosed: config.onsessionclosed
6163
});
6264

6365
await mcpServer.connect(transport);
@@ -111,7 +113,8 @@ async function createTestAuthServer(config: TestServerConfig = { sessionIdGenera
111113
const transport = new StreamableHTTPServerTransport({
112114
sessionIdGenerator: config.sessionIdGenerator,
113115
enableJsonResponse: config.enableJsonResponse ?? false,
114-
eventStore: config.eventStore
116+
eventStore: config.eventStore,
117+
onsessionclosed: config.onsessionclosed
115118
});
116119

117120
await mcpServer.connect(transport);
@@ -1504,6 +1507,165 @@ describe("StreamableHTTPServerTransport in stateless mode", () => {
15041507
});
15051508
});
15061509

1510+
// Test onsessionclosed callback
1511+
describe("StreamableHTTPServerTransport onsessionclosed callback", () => {
1512+
it("should call onsessionclosed callback when session is closed via DELETE", async () => {
1513+
const mockCallback = jest.fn();
1514+
1515+
// Create server with onsessionclosed callback
1516+
const result = await createTestServer({
1517+
sessionIdGenerator: () => randomUUID(),
1518+
onsessionclosed: mockCallback,
1519+
});
1520+
1521+
const tempServer = result.server;
1522+
const tempUrl = result.baseUrl;
1523+
1524+
// Initialize to get a session ID
1525+
const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize);
1526+
const tempSessionId = initResponse.headers.get("mcp-session-id");
1527+
expect(tempSessionId).toBeDefined();
1528+
1529+
// DELETE the session
1530+
const deleteResponse = await fetch(tempUrl, {
1531+
method: "DELETE",
1532+
headers: {
1533+
"mcp-session-id": tempSessionId || "",
1534+
"mcp-protocol-version": "2025-03-26",
1535+
},
1536+
});
1537+
1538+
expect(deleteResponse.status).toBe(200);
1539+
expect(mockCallback).toHaveBeenCalledWith(tempSessionId);
1540+
expect(mockCallback).toHaveBeenCalledTimes(1);
1541+
1542+
// Clean up
1543+
tempServer.close();
1544+
});
1545+
1546+
it("should not call onsessionclosed callback when not provided", async () => {
1547+
// Create server without onsessionclosed callback
1548+
const result = await createTestServer({
1549+
sessionIdGenerator: () => randomUUID(),
1550+
});
1551+
1552+
const tempServer = result.server;
1553+
const tempUrl = result.baseUrl;
1554+
1555+
// Initialize to get a session ID
1556+
const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize);
1557+
const tempSessionId = initResponse.headers.get("mcp-session-id");
1558+
1559+
// DELETE the session - should not throw error
1560+
const deleteResponse = await fetch(tempUrl, {
1561+
method: "DELETE",
1562+
headers: {
1563+
"mcp-session-id": tempSessionId || "",
1564+
"mcp-protocol-version": "2025-03-26",
1565+
},
1566+
});
1567+
1568+
expect(deleteResponse.status).toBe(200);
1569+
1570+
// Clean up
1571+
tempServer.close();
1572+
});
1573+
1574+
it("should not call onsessionclosed callback for invalid session DELETE", async () => {
1575+
const mockCallback = jest.fn();
1576+
1577+
// Create server with onsessionclosed callback
1578+
const result = await createTestServer({
1579+
sessionIdGenerator: () => randomUUID(),
1580+
onsessionclosed: mockCallback,
1581+
});
1582+
1583+
const tempServer = result.server;
1584+
const tempUrl = result.baseUrl;
1585+
1586+
// Initialize to get a valid session
1587+
await sendPostRequest(tempUrl, TEST_MESSAGES.initialize);
1588+
1589+
// Try to DELETE with invalid session ID
1590+
const deleteResponse = await fetch(tempUrl, {
1591+
method: "DELETE",
1592+
headers: {
1593+
"mcp-session-id": "invalid-session-id",
1594+
"mcp-protocol-version": "2025-03-26",
1595+
},
1596+
});
1597+
1598+
expect(deleteResponse.status).toBe(404);
1599+
expect(mockCallback).not.toHaveBeenCalled();
1600+
1601+
// Clean up
1602+
tempServer.close();
1603+
});
1604+
1605+
it("should call onsessionclosed callback with correct session ID when multiple sessions exist", async () => {
1606+
const mockCallback = jest.fn();
1607+
1608+
// Create first server
1609+
const result1 = await createTestServer({
1610+
sessionIdGenerator: () => randomUUID(),
1611+
onsessionclosed: mockCallback,
1612+
});
1613+
1614+
const server1 = result1.server;
1615+
const url1 = result1.baseUrl;
1616+
1617+
// Create second server
1618+
const result2 = await createTestServer({
1619+
sessionIdGenerator: () => randomUUID(),
1620+
onsessionclosed: mockCallback,
1621+
});
1622+
1623+
const server2 = result2.server;
1624+
const url2 = result2.baseUrl;
1625+
1626+
// Initialize both servers
1627+
const initResponse1 = await sendPostRequest(url1, TEST_MESSAGES.initialize);
1628+
const sessionId1 = initResponse1.headers.get("mcp-session-id");
1629+
1630+
const initResponse2 = await sendPostRequest(url2, TEST_MESSAGES.initialize);
1631+
const sessionId2 = initResponse2.headers.get("mcp-session-id");
1632+
1633+
expect(sessionId1).toBeDefined();
1634+
expect(sessionId2).toBeDefined();
1635+
expect(sessionId1).not.toBe(sessionId2);
1636+
1637+
// DELETE first session
1638+
const deleteResponse1 = await fetch(url1, {
1639+
method: "DELETE",
1640+
headers: {
1641+
"mcp-session-id": sessionId1 || "",
1642+
"mcp-protocol-version": "2025-03-26",
1643+
},
1644+
});
1645+
1646+
expect(deleteResponse1.status).toBe(200);
1647+
expect(mockCallback).toHaveBeenCalledWith(sessionId1);
1648+
expect(mockCallback).toHaveBeenCalledTimes(1);
1649+
1650+
// DELETE second session
1651+
const deleteResponse2 = await fetch(url2, {
1652+
method: "DELETE",
1653+
headers: {
1654+
"mcp-session-id": sessionId2 || "",
1655+
"mcp-protocol-version": "2025-03-26",
1656+
},
1657+
});
1658+
1659+
expect(deleteResponse2.status).toBe(200);
1660+
expect(mockCallback).toHaveBeenCalledWith(sessionId2);
1661+
expect(mockCallback).toHaveBeenCalledTimes(2);
1662+
1663+
// Clean up
1664+
server1.close();
1665+
server2.close();
1666+
});
1667+
});
1668+
15071669
// Test DNS rebinding protection
15081670
describe("StreamableHTTPServerTransport DNS rebinding protection", () => {
15091671
let server: Server;

0 commit comments

Comments
 (0)