Skip to content

Commit bf7dbab

Browse files
authored
feat: support streamable http transport (#243)
Adds support for the new StreamableHttp transport. I'm not aware of any clients that implement it, but somebody's gotta make the start! Once some clients support it, we can also advertise it in the README.
1 parent 7256ee3 commit bf7dbab

File tree

5 files changed

+181
-109
lines changed

5 files changed

+181
-109
lines changed

package-lock.json

+9-14
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
}
3535
},
3636
"dependencies": {
37-
"@modelcontextprotocol/sdk": "^1.6.1",
37+
"@modelcontextprotocol/sdk": "^1.10.1",
3838
"commander": "^13.1.0",
3939
"playwright": "1.53.0-alpha-1745357020000",
4040
"yaml": "^2.7.1",

src/program.ts

+5-73
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,13 @@
1414
* limitations under the License.
1515
*/
1616

17-
import http from 'http';
18-
1917
import { program } from 'commander';
20-
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
21-
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
22-
2318

2419
import { createServer } from './index';
2520
import { ServerList } from './server';
2621

27-
import assert from 'assert';
2822
import { ToolCapability } from './tools/tool';
23+
import { startHttpTransport, startStdioTransport } from './transport';
2924

3025
const packageJSON = require('../package.json');
3126

@@ -53,12 +48,10 @@ program
5348
}));
5449
setupExitWatchdog(serverList);
5550

56-
if (options.port) {
57-
startSSEServer(+options.port, options.host || 'localhost', serverList);
58-
} else {
59-
const server = await serverList.create();
60-
await server.connect(new StdioServerTransport());
61-
}
51+
if (options.port)
52+
startHttpTransport(+options.port, options.host, serverList);
53+
else
54+
await startStdioTransport(serverList);
6255
});
6356

6457
function setupExitWatchdog(serverList: ServerList) {
@@ -74,64 +67,3 @@ function setupExitWatchdog(serverList: ServerList) {
7467
}
7568

7669
program.parse(process.argv);
77-
78-
function startSSEServer(port: number, host: string, serverList: ServerList) {
79-
const sessions = new Map<string, SSEServerTransport>();
80-
const httpServer = http.createServer(async (req, res) => {
81-
if (req.method === 'POST') {
82-
const searchParams = new URL(`http://localhost${req.url}`).searchParams;
83-
const sessionId = searchParams.get('sessionId');
84-
if (!sessionId) {
85-
res.statusCode = 400;
86-
res.end('Missing sessionId');
87-
return;
88-
}
89-
const transport = sessions.get(sessionId);
90-
if (!transport) {
91-
res.statusCode = 404;
92-
res.end('Session not found');
93-
return;
94-
}
95-
96-
await transport.handlePostMessage(req, res);
97-
return;
98-
} else if (req.method === 'GET') {
99-
const transport = new SSEServerTransport('/sse', res);
100-
sessions.set(transport.sessionId, transport);
101-
const server = await serverList.create();
102-
res.on('close', () => {
103-
sessions.delete(transport.sessionId);
104-
serverList.close(server).catch(e => console.error(e));
105-
});
106-
await server.connect(transport);
107-
return;
108-
} else {
109-
res.statusCode = 405;
110-
res.end('Method not allowed');
111-
}
112-
});
113-
114-
httpServer.listen(port, host, () => {
115-
const address = httpServer.address();
116-
assert(address, 'Could not bind server socket');
117-
let url: string;
118-
if (typeof address === 'string') {
119-
url = address;
120-
} else {
121-
const resolvedPort = address.port;
122-
let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
123-
if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
124-
resolvedHost = host === 'localhost' ? 'localhost' : resolvedHost;
125-
url = `http://${resolvedHost}:${resolvedPort}`;
126-
}
127-
console.log(`Listening on ${url}`);
128-
console.log('Put this in your client config:');
129-
console.log(JSON.stringify({
130-
'mcpServers': {
131-
'playwright': {
132-
'url': `${url}/sse`
133-
}
134-
}
135-
}, undefined, 2));
136-
});
137-
}

src/transport.ts

+127
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/**
2+
* Copyright (c) Microsoft Corporation.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import http from 'node:http';
18+
import assert from 'node:assert';
19+
import crypto from 'node:crypto';
20+
21+
import { ServerList } from './server';
22+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
23+
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
24+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
25+
26+
export async function startStdioTransport(serverList: ServerList) {
27+
const server = await serverList.create();
28+
await server.connect(new StdioServerTransport());
29+
}
30+
31+
async function handleSSE(req: http.IncomingMessage, res: http.ServerResponse, url: URL, serverList: ServerList, sessions: Map<string, SSEServerTransport>) {
32+
if (req.method === 'POST') {
33+
const sessionId = url.searchParams.get('sessionId');
34+
if (!sessionId) {
35+
res.statusCode = 400;
36+
return res.end('Missing sessionId');
37+
}
38+
39+
const transport = sessions.get(sessionId);
40+
if (!transport) {
41+
res.statusCode = 404;
42+
return res.end('Session not found');
43+
}
44+
45+
return await transport.handlePostMessage(req, res);
46+
} else if (req.method === 'GET') {
47+
const transport = new SSEServerTransport('/sse', res);
48+
sessions.set(transport.sessionId, transport);
49+
const server = await serverList.create();
50+
res.on('close', () => {
51+
sessions.delete(transport.sessionId);
52+
serverList.close(server).catch(e => console.error(e));
53+
});
54+
return await server.connect(transport);
55+
}
56+
57+
res.statusCode = 405;
58+
res.end('Method not allowed');
59+
}
60+
61+
async function handleStreamable(req: http.IncomingMessage, res: http.ServerResponse, serverList: ServerList, sessions: Map<string, StreamableHTTPServerTransport>) {
62+
const sessionId = req.headers['mcp-session-id'] as string | undefined;
63+
if (sessionId) {
64+
const transport = sessions.get(sessionId);
65+
if (!transport) {
66+
res.statusCode = 404;
67+
res.end('Session not found');
68+
return;
69+
}
70+
return await transport.handleRequest(req, res);
71+
}
72+
73+
if (req.method === 'POST') {
74+
const transport = new StreamableHTTPServerTransport({
75+
sessionIdGenerator: () => crypto.randomUUID(),
76+
onsessioninitialized: sessionId => {
77+
sessions.set(sessionId, transport);
78+
}
79+
});
80+
transport.onclose = () => {
81+
if (transport.sessionId)
82+
sessions.delete(transport.sessionId);
83+
};
84+
const server = await serverList.create();
85+
await server.connect(transport);
86+
return await transport.handleRequest(req, res);
87+
}
88+
89+
res.statusCode = 400;
90+
res.end('Invalid request');
91+
}
92+
93+
export function startHttpTransport(port: number, hostname: string | undefined, serverList: ServerList) {
94+
const sseSessions = new Map<string, SSEServerTransport>();
95+
const streamableSessions = new Map<string, StreamableHTTPServerTransport>();
96+
const httpServer = http.createServer(async (req, res) => {
97+
const url = new URL(`http://localhost${req.url}`);
98+
if (url.pathname.startsWith('/mcp'))
99+
await handleStreamable(req, res, serverList, streamableSessions);
100+
else
101+
await handleSSE(req, res, url, serverList, sseSessions);
102+
});
103+
httpServer.listen(port, hostname, () => {
104+
const address = httpServer.address();
105+
assert(address, 'Could not bind server socket');
106+
let url: string;
107+
if (typeof address === 'string') {
108+
url = address;
109+
} else {
110+
const resolvedPort = address.port;
111+
let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
112+
if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
113+
resolvedHost = 'localhost';
114+
url = `http://${resolvedHost}:${resolvedPort}`;
115+
}
116+
console.log(`Listening on ${url}`);
117+
console.log('Put this in your client config:');
118+
console.log(JSON.stringify({
119+
'mcpServers': {
120+
'playwright': {
121+
'url': `${url}/sse`
122+
}
123+
}
124+
}, undefined, 2));
125+
console.log('If your client supports streamable HTTP, you can use the /mcp endpoint instead.');
126+
});
127+
}

tests/sse.spec.ts

+39-21
Original file line numberDiff line numberDiff line change
@@ -16,27 +16,45 @@
1616

1717
import { spawn } from 'node:child_process';
1818
import path from 'node:path';
19-
import { test } from './fixtures';
19+
import { test as baseTest } from './fixtures';
20+
import { expect } from 'playwright/test';
2021

21-
test('sse transport', async () => {
22-
const cp = spawn('node', [path.join(__dirname, '../cli.js'), '--port', '0'], { stdio: 'pipe' });
23-
try {
24-
let stdout = '';
25-
const url = await new Promise<string>(resolve => cp.stdout?.on('data', data => {
26-
stdout += data.toString();
27-
const match = stdout.match(/Listening on (http:\/\/.*)/);
28-
if (match)
29-
resolve(match[1]);
30-
}));
22+
const test = baseTest.extend<{ serverEndpoint: string }>({
23+
serverEndpoint: async ({}, use) => {
24+
const cp = spawn('node', [path.join(__dirname, '../cli.js'), '--port', '0'], { stdio: 'pipe' });
25+
try {
26+
let stdout = '';
27+
const url = await new Promise<string>(resolve => cp.stdout?.on('data', data => {
28+
stdout += data.toString();
29+
const match = stdout.match(/Listening on (http:\/\/.*)/);
30+
if (match)
31+
resolve(match[1]);
32+
}));
3133

32-
// need dynamic import b/c of some ESM nonsense
33-
const { SSEClientTransport } = await import('@modelcontextprotocol/sdk/client/sse.js');
34-
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
35-
const transport = new SSEClientTransport(new URL(url));
36-
const client = new Client({ name: 'test', version: '1.0.0' });
37-
await client.connect(transport);
38-
await client.ping();
39-
} finally {
40-
cp.kill();
41-
}
34+
await use(url);
35+
} finally {
36+
cp.kill();
37+
}
38+
},
39+
});
40+
41+
test('sse transport', async ({ serverEndpoint }) => {
42+
// need dynamic import b/c of some ESM nonsense
43+
const { SSEClientTransport } = await import('@modelcontextprotocol/sdk/client/sse.js');
44+
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
45+
const transport = new SSEClientTransport(new URL(serverEndpoint));
46+
const client = new Client({ name: 'test', version: '1.0.0' });
47+
await client.connect(transport);
48+
await client.ping();
49+
});
50+
51+
test('streamable http transport', async ({ serverEndpoint }) => {
52+
// need dynamic import b/c of some ESM nonsense
53+
const { StreamableHTTPClientTransport } = await import('@modelcontextprotocol/sdk/client/streamableHttp.js');
54+
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
55+
const transport = new StreamableHTTPClientTransport(new URL('/mcp', serverEndpoint));
56+
const client = new Client({ name: 'test', version: '1.0.0' });
57+
await client.connect(transport);
58+
await client.ping();
59+
expect(transport.sessionId, 'has session support').toBeDefined();
4260
});

0 commit comments

Comments
 (0)