Skip to content

Commit e783d17

Browse files
feat: add stateless streamable http mode (#15)
1 parent ec7397e commit e783d17

File tree

5 files changed

+60
-18
lines changed

5 files changed

+60
-18
lines changed

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ To use this server, you must have both Python and [Deno](https://deno.com/) inst
3434
The server can be run with `deno` installed using `uvx`:
3535

3636
```bash
37-
uvx mcp-run-python [-h] [--version] [--port PORT] [--deps DEPS] {stdio,streamable-http,example}
37+
uvx mcp-run-python [-h] [--version] [--port PORT] [--deps DEPS] {stdio,streamable-http,streamable-http-stateless,example}
3838
```
3939

4040
where:
@@ -46,6 +46,8 @@ where:
4646
[Streamable HTTP MCP transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http) -
4747
suitable for running the server as an HTTP server to connect locally or remotely. This supports stateful requests, but
4848
does not require the client to hold a stateful connection like SSE
49+
- `streamable-http-stateless` runs the server with [Streamable HTTP MCP transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http) in stateless mode and does not
50+
support server-to-client notifications
4951
- `example` will run a minimal Python script using `numpy`, useful for checking that the package is working, for the code
5052
to run successfully, you'll need to install `numpy` using `uvx mcp-run-python --deps numpy example`
5153

@@ -91,7 +93,6 @@ uv add mcp-run-python
9193

9294
With `mcp-run-python` installed, you can also run deno directly with `prepare_deno_env` or `async_prepare_deno_env`
9395

94-
9596
```python
9697
from pydantic_ai import Agent
9798
from pydantic_ai.mcp import MCPServerStdio

mcp_run_python/_cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def cli_logic(args_list: Sequence[str] | None = None) -> int:
3030
parser.add_argument('--version', action='store_true', help='Show version and exit')
3131
parser.add_argument(
3232
'mode',
33-
choices=['stdio', 'streamable-http', 'example'],
33+
choices=['stdio', 'streamable-http', 'streamable-http-stateless', 'example'],
3434
nargs='?',
3535
help='Mode to run the server in.',
3636
)

mcp_run_python/deno/src/main.ts

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,11 @@ export async function main() {
3030
return
3131
} else if (args[0] === 'streamable_http') {
3232
const port = parseInt(flags.port)
33-
runStreamableHttp(port, deps, flags['return-mode'])
33+
runStreamableHttp(port, deps, flags['return-mode'], false)
34+
return
35+
} else if (args[0] === 'streamable_http_stateless') {
36+
const port = parseInt(flags.port)
37+
runStreamableHttp(port, deps, flags['return-mode'], true)
3438
return
3539
} else if (args[0] === 'example') {
3640
await example(deps)
@@ -44,7 +48,7 @@ export async function main() {
4448
`\
4549
Invalid arguments: ${args.join(' ')}
4650
47-
Usage: deno ... deno/main.ts [stdio|streamable_http|install_deps|noop]
51+
Usage: deno ... deno/main.ts [stdio|streamable_http|streamable_http_stateless|install_deps|noop]
4852
4953
options:
5054
--port <port> Port to run the HTTP server on (default: 3001)
@@ -167,12 +171,53 @@ function httpSetJsonResponse(res: http.ServerResponse, status: number, text: str
167171
/*
168172
* Run the MCP server using the Streamable HTTP transport
169173
*/
170-
function runStreamableHttp(port: number, deps: string[], returnMode: string) {
174+
function runStreamableHttp(port: number, deps: string[], returnMode: string, stateless: boolean): void {
175+
const server = (stateless ? createStatelessHttpServer : createStatefulHttpServer)(deps, returnMode)
176+
server.listen(port, () => {
177+
console.log(`Listening on port ${port}`)
178+
})
179+
}
180+
181+
function createStatelessHttpServer(deps: string[], returnMode: string): http.Server {
182+
return http.createServer(async (req, res) => {
183+
const url = httpGetUrl(req)
184+
185+
if (url.pathname !== '/mcp') {
186+
httpSetTextResponse(res, 404, 'Page not found')
187+
return
188+
}
189+
190+
try {
191+
const mcpServer = createServer(deps, returnMode)
192+
const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({
193+
sessionIdGenerator: undefined,
194+
})
195+
196+
res.on('close', () => {
197+
transport.close()
198+
mcpServer.close()
199+
})
200+
201+
await mcpServer.connect(transport)
202+
203+
const body = req.method === 'POST' ? await httpGetBody(req) : undefined
204+
await transport.handleRequest(req, res, body)
205+
} catch (error) {
206+
console.error('Error handling MCP request:', error)
207+
if (!res.headersSent) {
208+
httpSetJsonResponse(res, 500, 'Internal server error', -32603)
209+
}
210+
}
211+
})
212+
}
213+
214+
function createStatefulHttpServer(deps: string[], returnMode: string): http.Server {
215+
// Stateful mode with session management
171216
// https://github.com/modelcontextprotocol/typescript-sdk?tab=readme-ov-file#with-session-management
172217
const mcpServer = createServer(deps, returnMode)
173218
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}
174219

175-
const server = http.createServer(async (req, res) => {
220+
return http.createServer(async (req, res) => {
176221
const url = httpGetUrl(req)
177222
let pathMatch = false
178223
function match(method: string, path: string): boolean {
@@ -243,10 +288,6 @@ function runStreamableHttp(port: number, deps: string[], returnMode: string) {
243288
httpSetTextResponse(res, 404, 'Page not found')
244289
}
245290
})
246-
247-
server.listen(port, () => {
248-
console.log(`Listening on port ${port}`)
249-
})
250291
}
251292

252293
/*

mcp_run_python/main.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
logger = logging.getLogger(__name__)
1717
LoggingLevel = Literal['debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency']
18-
Mode = Literal['stdio', 'streamable_http', 'example']
18+
Mode = Literal['stdio', 'streamable_http', 'streamable_http_stateless', 'example']
1919
LogHandler = Callable[[LoggingLevel, str], None]
2020

2121

@@ -53,7 +53,7 @@ def run_mcp_server(
5353
deps_log_handler=deps_log_handler,
5454
allow_networking=allow_networking,
5555
) as env:
56-
if mode == 'streamable_http':
56+
if mode in ('streamable_http', 'streamable_http_stateless'):
5757
logger.info('Running mcp-run-python via %s on port %d...', mode, http_port)
5858
else:
5959
logger.info('Running mcp-run-python via %s...', mode)
@@ -198,10 +198,10 @@ def _deno_run_args(
198198
if dependencies is not None:
199199
args.append(f'--deps={",".join(dependencies)}')
200200
if http_port is not None:
201-
if mode == 'streamable_http':
201+
if mode in ('streamable_http', 'streamable_http_stateless'):
202202
args.append(f'--port={http_port}')
203203
else:
204-
raise ValueError('Port is only supported for `streamable_http` mode')
204+
raise ValueError('Port is only supported for `streamable_http` modes')
205205
return args
206206

207207

tests/test_mcp_servers.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
pytestmark = pytest.mark.anyio
2323

2424

25-
@pytest.fixture(name='run_mcp_session', params=['stdio', 'streamable_http'])
25+
@pytest.fixture(name='run_mcp_session', params=['stdio', 'streamable_http', 'streamable_http_stateless'])
2626
def fixture_run_mcp_session(
2727
request: pytest.FixtureRequest,
2828
) -> Callable[[list[str]], AbstractAsyncContextManager[ClientSession]]:
@@ -35,9 +35,9 @@ async def run_mcp(deps: list[str]) -> AsyncIterator[ClientSession]:
3535
async with ClientSession(read, write) as session:
3636
yield session
3737
else:
38-
assert request.param == 'streamable_http', request.param
38+
assert request.param in ('streamable_http', 'streamable_http_stateless'), request.param
3939
port = 3101
40-
async with async_prepare_deno_env('streamable_http', http_port=port, dependencies=deps) as env:
40+
async with async_prepare_deno_env(request.param, http_port=port, dependencies=deps) as env:
4141
p = subprocess.Popen(['deno', *env.args], cwd=env.cwd)
4242
try:
4343
url = f'http://localhost:{port}/mcp'

0 commit comments

Comments
 (0)