Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ To use this server, you must have both Python and [Deno](https://deno.com/) inst
The server can be run with `deno` installed using `uvx`:

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

where:
Expand All @@ -46,6 +46,8 @@ where:
[Streamable HTTP MCP transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http) -
suitable for running the server as an HTTP server to connect locally or remotely. This supports stateful requests, but
does not require the client to hold a stateful connection like SSE
- `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
support server-to-client notifications
- `example` will run a minimal Python script using `numpy`, useful for checking that the package is working, for the code
to run successfully, you'll need to install `numpy` using `uvx mcp-run-python --deps numpy example`

Expand Down Expand Up @@ -91,7 +93,6 @@ uv add mcp-run-python

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


```python
from pydantic_ai import Agent
from pydantic_ai.mcp import MCPServerStdio
Expand Down
2 changes: 1 addition & 1 deletion mcp_run_python/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def cli_logic(args_list: Sequence[str] | None = None) -> int:
parser.add_argument('--version', action='store_true', help='Show version and exit')
parser.add_argument(
'mode',
choices=['stdio', 'streamable-http', 'example'],
choices=['stdio', 'streamable-http', 'streamable-http-stateless', 'example'],
nargs='?',
help='Mode to run the server in.',
)
Expand Down
88 changes: 71 additions & 17 deletions mcp_run_python/deno/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ export async function main() {
return
} else if (args[0] === 'streamable_http') {
const port = parseInt(flags.port)
runStreamableHttp(port, deps, flags['return-mode'])
runStreamableHttp(port, deps, flags['return-mode'], false)
return
} else if (args[0] === 'streamable_http_stateless') {
const port = parseInt(flags.port)
runStreamableHttp(port, deps, flags['return-mode'], true)
return
} else if (args[0] === 'example') {
await example(deps)
Expand All @@ -44,7 +48,7 @@ export async function main() {
`\
Invalid arguments: ${args.join(' ')}

Usage: deno ... deno/main.ts [stdio|streamable_http|install_deps|noop]
Usage: deno ... deno/main.ts [stdio|streamable_http|streamable_http_stateless|install_deps|noop]

options:
--port <port> Port to run the HTTP server on (default: 3001)
Expand Down Expand Up @@ -164,24 +168,78 @@ function httpSetJsonResponse(res: http.ServerResponse, status: number, text: str
res.end()
}

function createPathMatcher(req: http.IncomingMessage, url: URL) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to be a factory?

return (method: string, path: string): boolean => {
if (url.pathname === path) {
return req.method === method
}
return false
}
}

function pathMatch(req: http.IncomingMessage, url: URL) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be inlined as well?

return url.pathname === req.url
}

/*
* Run the MCP server using the Streamable HTTP transport
*/
function runStreamableHttp(port: number, deps: string[], returnMode: string) {
function runStreamableHttp(port: number, deps: string[], returnMode: string, stateless: boolean): void {
const server = (stateless ? createStatelessHttpServer : createStatefulHttpServer)(deps, returnMode)
server.listen(port, () => {
console.log(`Listening on port ${port}`)
})
}

function createStatelessHttpServer(deps: string[], returnMode: string): http.Server {
return http.createServer(async (req, res) => {
const url = httpGetUrl(req)
const match = createPathMatcher(req, url)

if (match('POST', '/mcp')) {
try {
const body = await httpGetBody(req)
const mcpServer = createServer(deps, returnMode)
const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
})

res.on('close', () => {
transport.close()
mcpServer.close()
})

await mcpServer.connect(transport)
await transport.handleRequest(req, res, body)
} catch (error) {
console.error('Error handling MCP request:', error)
if (!res.headersSent) {
httpSetJsonResponse(res, 500, 'Internal server error', -32603)
}
}
} else if (match('GET', '/mcp')) {
// SSE notifications not supported in stateless mode
httpSetJsonResponse(res, 405, 'Method not allowed.', -32000)
} else if (match('DELETE', '/mcp')) {
// Session termination not needed in stateless mode
httpSetJsonResponse(res, 405, 'Method not allowed.', -32000)
} else if (pathMatch(req, url)) {
httpSetTextResponse(res, 405, 'Method not allowed')
} else {
httpSetTextResponse(res, 404, 'Page not found')
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we doing so much manually? Doesn't the MCP package handles those?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, this is already handled by handleRequest() from the MCP SDK. I adapted this part now.

})
}

function createStatefulHttpServer(deps: string[], returnMode: string): http.Server {
// Stateful mode with session management
// https://github.com/modelcontextprotocol/typescript-sdk?tab=readme-ov-file#with-session-management
const mcpServer = createServer(deps, returnMode)
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}

const server = http.createServer(async (req, res) => {
return http.createServer(async (req, res) => {
const url = httpGetUrl(req)
let pathMatch = false
function match(method: string, path: string): boolean {
if (url.pathname === path) {
pathMatch = true
return req.method === method
}
return false
}
const match = createPathMatcher(req, url)

// Reusable handler for GET and DELETE requests
async function handleSessionRequest() {
Expand Down Expand Up @@ -237,16 +295,12 @@ function runStreamableHttp(port: number, deps: string[], returnMode: string) {
} else if (match('DELETE', '/mcp')) {
// Handle requests for session termination
await handleSessionRequest()
} else if (pathMatch) {
} else if (pathMatch(req, url)) {
httpSetTextResponse(res, 405, 'Method not allowed')
} else {
httpSetTextResponse(res, 404, 'Page not found')
}
})

server.listen(port, () => {
console.log(`Listening on port ${port}`)
})
}

/*
Expand Down
8 changes: 4 additions & 4 deletions mcp_run_python/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

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


Expand Down Expand Up @@ -45,7 +45,7 @@ def run_mcp_server(
deps_log_handler=deps_log_handler,
allow_networking=allow_networking,
) as env:
if mode == 'streamable_http':
if mode in ('streamable_http', 'streamable_http_stateless'):
logger.info('Running mcp-run-python via %s on port %d...', mode, http_port)
else:
logger.info('Running mcp-run-python via %s...', mode)
Expand Down Expand Up @@ -190,10 +190,10 @@ def _deno_run_args(
if dependencies is not None:
args.append(f'--deps={",".join(dependencies)}')
if http_port is not None:
if mode == 'streamable_http':
if mode in ('streamable_http', 'streamable_http_stateless'):
args.append(f'--port={http_port}')
else:
raise ValueError('Port is only supported for `streamable_http` mode')
raise ValueError('Port is only supported for `streamable_http` modes')
return args


Expand Down
6 changes: 3 additions & 3 deletions tests/test_mcp_servers.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
pytestmark = pytest.mark.anyio


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