Skip to content

Commit 09bd7dd

Browse files
Kigstndaniel.jaekel
andauthored
Support streamable HTTP in mcp-run-python (#2230)
Co-authored-by: daniel.jaekel <[email protected]>
1 parent 9ca4bca commit 09bd7dd

File tree

5 files changed

+206
-37
lines changed

5 files changed

+206
-37
lines changed

docs/mcp/run-python.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ The MCP Run Python server is distributed as a [JSR package](https://jsr.io/@pyda
2121
```bash {title="terminal"}
2222
deno run \
2323
-N -R=node_modules -W=node_modules --node-modules-dir=auto \
24-
jsr:@pydantic/mcp-run-python [stdio|sse|warmup]
24+
jsr:@pydantic/mcp-run-python [stdio|streamable_http|sse|warmup]
2525
```
2626

2727
where:
@@ -34,6 +34,10 @@ where:
3434
- `stdio` runs the server with the
3535
[Stdio MCP transport](https://spec.modelcontextprotocol.io/specification/2024-11-05/basic/transports/#stdio)
3636
— suitable for running the process as a subprocess locally
37+
- `streamable_http` runs the server with the
38+
[Streamable HTTP MCP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http)
39+
— running the server as an HTTP server to connect locally or remotely.
40+
This supports stateful requests, but does not require the client to hold a stateful connection like SSE
3741
- `sse` runs the server with the
3842
[SSE MCP transport](https://spec.modelcontextprotocol.io/specification/2024-11-05/basic/transports/#http-with-sse)
3943
— running the server as an HTTP server to connect locally or remotely

mcp-run-python/deno.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"build-publish": "deno task build && deno publish"
1414
},
1515
"imports": {
16-
"@modelcontextprotocol/sdk": "npm:@modelcontextprotocol/sdk@^1.8.0",
16+
"@modelcontextprotocol/sdk": "npm:@modelcontextprotocol/sdk@^1.15.1",
1717
"@std/cli": "jsr:@std/cli@^1.0.15",
1818
"@std/path": "jsr:@std/path@^1.0.8",
1919
// do NOT upgrade above this version until there is a workaround for https://github.com/pyodide/pyodide/pull/5621

mcp-run-python/deno.lock

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

mcp-run-python/src/main.ts

Lines changed: 148 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,32 @@
22

33
import './polyfill.ts'
44
import http from 'node:http'
5+
import { randomUUID } from 'node:crypto'
56
import { parseArgs } from '@std/cli/parse-args'
67
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'
78
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
9+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
10+
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'
811
import { type LoggingLevel, SetLevelRequestSchema } from '@modelcontextprotocol/sdk/types.js'
912
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
1013
import { z } from 'zod'
1114

1215
import { asXml, runCode } from './runCode.ts'
16+
import { Buffer } from 'node:buffer'
1317

1418
const VERSION = '0.0.13'
1519

1620
export async function main() {
1721
const { args } = Deno
1822
if (args.length === 1 && args[0] === 'stdio') {
1923
await runStdio()
24+
} else if (args.length >= 1 && args[0] === 'streamable_http') {
25+
const flags = parseArgs(Deno.args, {
26+
string: ['port'],
27+
default: { port: '3001' },
28+
})
29+
const port = parseInt(flags.port)
30+
runStreamableHttp(port)
2031
} else if (args.length >= 1 && args[0] === 'sse') {
2132
const flags = parseArgs(Deno.args, {
2233
string: ['port'],
@@ -31,7 +42,7 @@ export async function main() {
3142
`\
3243
Invalid arguments.
3344
34-
Usage: deno run -N -R=node_modules -W=node_modules --node-modules-dir=auto jsr:@pydantic/mcp-run-python [stdio|sse|warmup]
45+
Usage: deno run -N -R=node_modules -W=node_modules --node-modules-dir=auto jsr:@pydantic/mcp-run-python [stdio|streamable_http|sse|warmup]
3546
3647
options:
3748
--port <port> Port to run the SSE server on (default: 3001)`,
@@ -103,6 +114,138 @@ print('python code here')
103114
return server
104115
}
105116

117+
/*
118+
* Define some QOL functions for both the SSE and Streamable HTTP server implementation
119+
*/
120+
function httpGetUrl(req: http.IncomingMessage): URL {
121+
return new URL(
122+
req.url ?? '',
123+
`http://${req.headers.host ?? 'unknown'}`,
124+
)
125+
}
126+
127+
function httpGetBody(req: http.IncomingMessage): Promise<JSON> {
128+
// https://nodejs.org/en/learn/modules/anatomy-of-an-http-transaction#request-body
129+
return new Promise((resolve) => {
130+
// deno-lint-ignore no-explicit-any
131+
const bodyParts: any[] = []
132+
let body
133+
req.on('data', (chunk) => {
134+
bodyParts.push(chunk)
135+
}).on('end', () => {
136+
body = Buffer.concat(bodyParts).toString()
137+
resolve(JSON.parse(body))
138+
})
139+
})
140+
}
141+
142+
function httpSetTextResponse(res: http.ServerResponse, status: number, text: string) {
143+
res.setHeader('Content-Type', 'text/plain')
144+
res.statusCode = status
145+
res.end(`${text}\n`)
146+
}
147+
148+
function httpSetJsonResponse(res: http.ServerResponse, status: number, text: string, code: number) {
149+
res.setHeader('Content-Type', 'application/json')
150+
res.statusCode = status
151+
res.write(JSON.stringify({
152+
jsonrpc: '2.0',
153+
error: {
154+
code: code,
155+
message: text,
156+
},
157+
id: null,
158+
}))
159+
res.end()
160+
}
161+
162+
/*
163+
* Run the MCP server using the Streamable HTTP transport
164+
*/
165+
function runStreamableHttp(port: number) {
166+
// https://github.com/modelcontextprotocol/typescript-sdk?tab=readme-ov-file#with-session-management
167+
const mcpServer = createServer()
168+
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}
169+
170+
const server = http.createServer(async (req, res) => {
171+
const url = httpGetUrl(req)
172+
let pathMatch = false
173+
function match(method: string, path: string): boolean {
174+
if (url.pathname === path) {
175+
pathMatch = true
176+
return req.method === method
177+
}
178+
return false
179+
}
180+
181+
// Reusable handler for GET and DELETE requests
182+
async function handleSessionRequest() {
183+
const sessionId = req.headers['mcp-session-id'] as string | undefined
184+
if (!sessionId || !transports[sessionId]) {
185+
httpSetTextResponse(res, 400, 'Invalid or missing session ID')
186+
return
187+
}
188+
189+
const transport = transports[sessionId]
190+
await transport.handleRequest(req, res)
191+
}
192+
193+
// Handle different request methods and paths
194+
if (match('POST', '/mcp')) {
195+
// Check for existing session ID
196+
const sessionId = req.headers['mcp-session-id'] as string | undefined
197+
let transport: StreamableHTTPServerTransport
198+
199+
const body = await httpGetBody(req)
200+
201+
if (sessionId && transports[sessionId]) {
202+
// Reuse existing transport
203+
transport = transports[sessionId]
204+
} else if (!sessionId && isInitializeRequest(body)) {
205+
// New initialization request
206+
transport = new StreamableHTTPServerTransport({
207+
sessionIdGenerator: () => randomUUID(),
208+
onsessioninitialized: (sessionId) => {
209+
// Store the transport by session ID
210+
transports[sessionId] = transport
211+
},
212+
})
213+
214+
// Clean up transport when closed
215+
transport.onclose = () => {
216+
if (transport.sessionId) {
217+
delete transports[transport.sessionId]
218+
}
219+
}
220+
221+
await mcpServer.connect(transport)
222+
} else {
223+
httpSetJsonResponse(res, 400, 'Bad Request: No valid session ID provided', -32000)
224+
return
225+
}
226+
227+
// Handle the request
228+
await transport.handleRequest(req, res, body)
229+
} else if (match('GET', '/mcp')) {
230+
// Handle server-to-client notifications via SSE
231+
await handleSessionRequest()
232+
} else if (match('DELETE', '/mcp')) {
233+
// Handle requests for session termination
234+
await handleSessionRequest()
235+
} else if (pathMatch) {
236+
httpSetTextResponse(res, 405, 'Method not allowed')
237+
} else {
238+
httpSetTextResponse(res, 404, 'Page not found')
239+
}
240+
})
241+
242+
server.listen(port, () => {
243+
console.log(
244+
`Running MCP Run Python version ${VERSION} with Streamable HTTP transport on port ${port}`,
245+
)
246+
})
247+
}
248+
106249
/*
107250
* Run the MCP server using the SSE transport, e.g. over HTTP.
108251
*/
@@ -111,10 +254,7 @@ function runSse(port: number) {
111254
const transports: { [sessionId: string]: SSEServerTransport } = {}
112255

113256
const server = http.createServer(async (req, res) => {
114-
const url = new URL(
115-
req.url ?? '',
116-
`http://${req.headers.host ?? 'unknown'}`,
117-
)
257+
const url = httpGetUrl(req)
118258
let pathMatch = false
119259
function match(method: string, path: string): boolean {
120260
if (url.pathname === path) {
@@ -123,12 +263,6 @@ function runSse(port: number) {
123263
}
124264
return false
125265
}
126-
function textResponse(status: number, text: string) {
127-
res.setHeader('Content-Type', 'text/plain')
128-
res.statusCode = status
129-
res.end(`${text}\n`)
130-
}
131-
// console.log(`${req.method} ${url}`)
132266

133267
if (match('GET', '/sse')) {
134268
const transport = new SSEServerTransport('/messages', res)
@@ -143,12 +277,12 @@ function runSse(port: number) {
143277
if (transport) {
144278
await transport.handlePostMessage(req, res)
145279
} else {
146-
textResponse(400, `No transport found for sessionId '${sessionId}'`)
280+
httpSetTextResponse(res, 400, `No transport found for sessionId '${sessionId}'`)
147281
}
148282
} else if (pathMatch) {
149-
textResponse(405, 'Method not allowed')
283+
httpSetTextResponse(res, 405, 'Method not allowed')
150284
} else {
151-
textResponse(404, 'Page not found')
285+
httpSetTextResponse(res, 404, 'Page not found')
152286
}
153287
})
154288

0 commit comments

Comments
 (0)