From d901c1cf660438bde15cc1c8fa2a6bf4a62ffd26 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 25 Oct 2025 18:34:33 +0200 Subject: [PATCH 01/16] feat: add support to output resources --- mcp_run_python/_cli.py | 1 + mcp_run_python/deno/deno.jsonc | 2 ++ mcp_run_python/deno/deno.lock | 18 ++++++++++ mcp_run_python/deno/src/main.ts | 25 ++++++++++++-- mcp_run_python/deno/src/runCode.ts | 52 ++++++++++++++++++++++++++++ mcp_run_python/main.py | 13 +++++-- tests/test_mcp_servers.py | 54 +++++++++++++++++++++++++++++- 7 files changed, 159 insertions(+), 6 deletions(-) diff --git a/mcp_run_python/_cli.py b/mcp_run_python/_cli.py index 89258c5..d2c95d3 100644 --- a/mcp_run_python/_cli.py +++ b/mcp_run_python/_cli.py @@ -53,6 +53,7 @@ def cli_logic(args_list: Sequence[str] | None = None) -> int: http_port=args.port, dependencies=deps, deps_log_handler=deps_log_handler, + verbose=bool(args.verbose) ) return return_code else: diff --git a/mcp_run_python/deno/deno.jsonc b/mcp_run_python/deno/deno.jsonc index 57866be..58e4378 100644 --- a/mcp_run_python/deno/deno.jsonc +++ b/mcp_run_python/deno/deno.jsonc @@ -17,7 +17,9 @@ "imports": { "@modelcontextprotocol/sdk": "npm:@modelcontextprotocol/sdk@^1.17.5", "@std/cli": "jsr:@std/cli@^1.0.15", + "@std/encoding": "jsr:@std/encoding@^1.0.10", "@std/path": "jsr:@std/path@^1.0.8", + "mime-types": "npm:mime-types@^3.0.1", "pyodide": "npm:pyodide@0.28.2", "zod": "npm:zod@^3.24.4" }, diff --git a/mcp_run_python/deno/deno.lock b/mcp_run_python/deno/deno.lock index 4138faa..2ab1021 100644 --- a/mcp_run_python/deno/deno.lock +++ b/mcp_run_python/deno/deno.lock @@ -2,14 +2,30 @@ "version": "5", "specifiers": { "jsr:@std/cli@^1.0.15": "1.0.20", + "jsr:@std/encoding@^1.0.10": "1.0.10", + "jsr:@std/internal@^1.0.10": "1.0.12", + "jsr:@std/path@^1.0.8": "1.1.2", "npm:@modelcontextprotocol/sdk@^1.17.5": "1.17.5_express@5.1.0_zod@3.25.76", "npm:@types/node@22.12.0": "22.12.0", + "npm:mime-types@^3.0.1": "3.0.1", "npm:pyodide@0.28.2": "0.28.2", "npm:zod@^3.24.4": "3.25.76" }, "jsr": { "@std/cli@1.0.20": { "integrity": "a8c384a2c98cec6ec6a2055c273a916e2772485eb784af0db004c5ab8ba52333" + }, + "@std/encoding@1.0.10": { + "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" + }, + "@std/internal@1.0.12": { + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" + }, + "@std/path@1.1.2": { + "integrity": "c0b13b97dfe06546d5e16bf3966b1cadf92e1cc83e56ba5476ad8b498d9e3038", + "dependencies": [ + "jsr:@std/internal" + ] } }, "npm": { @@ -527,8 +543,10 @@ "workspace": { "dependencies": [ "jsr:@std/cli@^1.0.15", + "jsr:@std/encoding@^1.0.10", "jsr:@std/path@^1.0.8", "npm:@modelcontextprotocol/sdk@^1.17.5", + "npm:mime-types@^3.0.1", "npm:pyodide@0.28.2", "npm:zod@^3.24.4" ] diff --git a/mcp_run_python/deno/src/main.ts b/mcp_run_python/deno/src/main.ts index d00d549..e4ea62f 100644 --- a/mcp_run_python/deno/src/main.ts +++ b/mcp_run_python/deno/src/main.ts @@ -75,8 +75,9 @@ function createServer(deps: string[], returnMode: string): McpServer { const toolDescription = `Tool to execute Python code and return stdout, stderr, and return value. The code may be async, and the value on the last line will be returned as the return value. - The code will be executed with Python 3.13. + +To output files or images, save them in the "/output_files" folder. ` let setLogLevel: LoggingLevel = 'emergency' @@ -112,9 +113,26 @@ The code will be executed with Python 3.13. returnMode !== 'xml', ) await Promise.all(logPromises) - return { - content: [{ type: 'text', text: returnMode === 'xml' ? asXml(result) : asJson(result) }], + console.error(result) + const mcpResponse: any[] = [] + mcpResponse.push({ type: 'text', text: returnMode === 'xml' ? asXml(result) : asJson(result) }) + + // Add the Resources - if there are any + if (result.status === 'success') { + for (const entry of result.embeddedResources) { + mcpResponse.push({ + type: 'resource', + resource: { + uri: 'file://_', // not providing a file url, as enabling resources is optional according to MCP spec: https://modelcontextprotocol.io/specification/2025-06-18/server/tools#embedded-resources + name: entry.name, + mimeType: entry.mimeType, + blob: entry.blob, + }, + }) + } } + + return { content: mcpResponse } }, ) return server @@ -175,6 +193,7 @@ function runStreamableHttp(port: number, deps: string[], returnMode: string) { const server = 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 diff --git a/mcp_run_python/deno/src/runCode.ts b/mcp_run_python/deno/src/runCode.ts index 1be8681..e4d7bb3 100644 --- a/mcp_run_python/deno/src/runCode.ts +++ b/mcp_run_python/deno/src/runCode.ts @@ -1,6 +1,9 @@ // deno-lint-ignore-file no-explicit-any import { loadPyodide, type PyodideInterface } from 'pyodide' import { preparePythonCode } from './prepareEnvCode.ts' +import { randomBytes } from 'node:crypto' +import mime from 'mime-types' +import { encodeBase64 } from '@std/encoding/base64' import type { LoggingLevel } from '@modelcontextprotocol/sdk/types.js' export interface CodeFile { @@ -56,16 +59,30 @@ export class RunCode { } } else if (file) { try { + // make the temp file system for pyodide to use + const folderName = randomBytes(20).toString('hex').slice(0, 20) + const folderPath = `./output_files/${folderName}` + await Deno.mkdir(folderPath, { recursive: true }) + pyodide.mountNodeFS('/output_files', folderPath) + + // run the code with pyodide const rawValue = await pyodide.runPythonAsync(file.content, { globals: pyodide.toPy({ ...(globals || {}), __name__: '__main__' }), filename: file.name, }) + + // check files that got saved + pyodide.FS.unmount('/output_files') + const files = await this.readAndDeleteFiles(folderPath) + return { status: 'success', output: this.takeOutput(sys), returnValueJson: preparePyEnv.dump_json(rawValue, alwaysReturnJson), + embeddedResources: files, } } catch (err) { + console.log(err) return { status: 'run-error', output: this.takeOutput(sys), @@ -77,10 +94,38 @@ export class RunCode { status: 'success', output: this.takeOutput(sys), returnValueJson: null, + embeddedResources: [], } } } + async readAndDeleteFiles(folderPath: string): Promise { + const results: Resource[] = [] + for await (const file of Deno.readDir(folderPath)) { + // Skip directories + if (!file.isFile) continue + + const fileName = file.name + const filePath = `${folderPath}/${fileName}` + const mimeType = mime.lookup(fileName) + const fileData = await Deno.readFile(filePath) + + // Convert binary to Base64 + const base64Encoded = encodeBase64(fileData) + + results.push({ + name: fileName, + mimeType: mimeType, + blob: base64Encoded, + }) + } + + // Now delete the file folder - otherwise they add up :) + await Deno.remove(folderPath, { recursive: true }) + + return results + } + async prepEnv( dependencies: string[], log: (level: LoggingLevel, data: string) => void, @@ -140,11 +185,18 @@ export class RunCode { } } +interface Resource { + name: string + mimeType: string + blob: string +} + interface RunSuccess { status: 'success' // we could record stdout and stderr separately, but I suspect simplicity is more important output: string[] returnValueJson: string | null + embeddedResources: Resource[] } interface RunError { diff --git a/mcp_run_python/main.py b/mcp_run_python/main.py index 34d177d..7e15c77 100644 --- a/mcp_run_python/main.py +++ b/mcp_run_python/main.py @@ -2,6 +2,7 @@ import logging import shutil import subprocess +import sys import tempfile from collections.abc import AsyncIterator, Callable, Iterator from contextlib import asynccontextmanager, contextmanager @@ -26,6 +27,7 @@ def run_mcp_server( return_mode: Literal['json', 'xml'] = 'xml', deps_log_handler: LogHandler | None = None, allow_networking: bool = True, + verbose: bool = False, ) -> int: """Install dependencies then run the mcp-run-python server. @@ -36,7 +38,12 @@ def run_mcp_server( return_mode: The mode to return tool results in. deps_log_handler: Optional function to receive logs emitted while installing dependencies. allow_networking: Whether to allow networking when running provided python code. + verbose: Log deno outputs to CLI """ + subprocess_kwargs = {} + if verbose: + subprocess_kwargs = {"stdout":sys.stdout, "stderr":sys.stderr} + with prepare_deno_env( mode, dependencies=dependencies, @@ -51,7 +58,7 @@ def run_mcp_server( logger.info('Running mcp-run-python via %s...', mode) try: - p = subprocess.run(('deno', *env.args), cwd=env.cwd) + p = subprocess.run(('deno', *env.args), cwd=env.cwd, **subprocess_kwargs) except KeyboardInterrupt: # pragma: no cover logger.warning('Server stopped.') return 0 @@ -98,6 +105,7 @@ def prepare_deno_env( src = Path(__file__).parent / 'deno' logger.debug('Copying from %s to %s...', src, cwd) shutil.copytree(src, cwd) + (cwd / 'output_files').mkdir() logger.info('Installing dependencies %s...', dependencies) args = 'deno', *_deno_install_args(dependencies) @@ -181,7 +189,8 @@ def _deno_run_args( if allow_networking: args += ['--allow-net'] args += [ - '--allow-read=./node_modules', + '--allow-read=./node_modules,./output_files', + '--allow-write=./output_files', '--node-modules-dir=auto', 'src/main.ts', mode, diff --git a/tests/test_mcp_servers.py b/tests/test_mcp_servers.py index 889b903..dc17d61 100644 --- a/tests/test_mcp_servers.py +++ b/tests/test_mcp_servers.py @@ -13,6 +13,7 @@ from mcp import ClientSession, StdioServerParameters, types from mcp.client.stdio import stdio_client from mcp.client.streamable_http import streamablehttp_client +from mcp.types import BlobResourceContents, EmbeddedResource from mcp_run_python import async_prepare_deno_env @@ -38,7 +39,7 @@ async def run_mcp(deps: list[str]) -> AsyncIterator[ClientSession]: assert request.param == 'streamable_http', request.param port = 3101 async with async_prepare_deno_env('streamable_http', http_port=port, dependencies=deps) as env: - p = subprocess.Popen(['deno', *env.args], cwd=env.cwd) + p = subprocess.Popen(['deno', *env.args], cwd=env.cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) try: url = f'http://localhost:{port}/mcp' await wait_for_server(url, 8) @@ -192,6 +193,57 @@ async def test_run_python_code( assert content.text == expected_output +@pytest.mark.parametrize( + 'deps,code,expected_output,expected_resources', + [ + pytest.param( + [], + [ + 'from pathlib import Path', + 'Path("./hello.txt").write_text("hello world!")', + ], + snapshot("""\ +success + +12 +\ +"""), + [ + EmbeddedResource( + type="resource", + resource=BlobResourceContents( + uri="file://_", + mimeType="text/plain", + name="hello.txt", + blob="aGVsbG8gd29ybGQh", + ), + ) + ], + id='hello-world-file', + ), + ], +) +async def test_run_python_code_with_output_resource( + run_mcp_session: Callable[[list[str]], AbstractAsyncContextManager[ClientSession]], + deps: list[str], + code: list[str], + expected_output: str, + expected_resources: list[EmbeddedResource], +) -> None: + async with run_mcp_session(deps) as mcp_session: + await mcp_session.initialize() + result = await mcp_session.call_tool('run_python_code', {'python_code': '\n'.join(code)}) + assert len(result.content) >= 2 + text_content = result.content[0] + resource_content = result.content[1:] + assert isinstance(text_content, types.TextContent) + assert text_content.text == expected_output + assert len(resource_content) == len(expected_output) + for got, expected in zip(resource_content, expected_resources): + assert got == expected + + + async def test_install_run_python_code() -> None: logs: list[str] = [] From 4e1e270aa16a00bcd5d60329418fd21132336ce5 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 25 Oct 2025 18:51:04 +0200 Subject: [PATCH 02/16] fix: wrong test path --- mcp_run_python/deno/src/main.ts | 1 - mcp_run_python/deno/src/runCode.ts | 3 ++- tests/test_mcp_servers.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mcp_run_python/deno/src/main.ts b/mcp_run_python/deno/src/main.ts index e4ea62f..cc8dbf2 100644 --- a/mcp_run_python/deno/src/main.ts +++ b/mcp_run_python/deno/src/main.ts @@ -113,7 +113,6 @@ To output files or images, save them in the "/output_files" folder. returnMode !== 'xml', ) await Promise.all(logPromises) - console.error(result) const mcpResponse: any[] = [] mcpResponse.push({ type: 'text', text: returnMode === 'xml' ? asXml(result) : asJson(result) }) diff --git a/mcp_run_python/deno/src/runCode.ts b/mcp_run_python/deno/src/runCode.ts index e4d7bb3..d270feb 100644 --- a/mcp_run_python/deno/src/runCode.ts +++ b/mcp_run_python/deno/src/runCode.ts @@ -72,8 +72,8 @@ export class RunCode { }) // check files that got saved - pyodide.FS.unmount('/output_files') const files = await this.readAndDeleteFiles(folderPath) + pyodide.FS.unmount('/output_files') return { status: 'success', @@ -82,6 +82,7 @@ export class RunCode { embeddedResources: files, } } catch (err) { + pyodide.FS.unmount('/output_files') console.log(err) return { status: 'run-error', diff --git a/tests/test_mcp_servers.py b/tests/test_mcp_servers.py index dc17d61..ab16366 100644 --- a/tests/test_mcp_servers.py +++ b/tests/test_mcp_servers.py @@ -200,7 +200,7 @@ async def test_run_python_code( [], [ 'from pathlib import Path', - 'Path("./hello.txt").write_text("hello world!")', + 'Path("/output_files/hello.txt").write_text("hello world!")', ], snapshot("""\ success @@ -238,7 +238,7 @@ async def test_run_python_code_with_output_resource( resource_content = result.content[1:] assert isinstance(text_content, types.TextContent) assert text_content.text == expected_output - assert len(resource_content) == len(expected_output) + assert len(resource_content) == len(expected_resources) for got, expected in zip(resource_content, expected_resources): assert got == expected From f1eb91dd5c99b6c193386bb9ca1638dffa737ec0 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 26 Oct 2025 21:23:29 +0100 Subject: [PATCH 03/16] fix: linting --- mcp_run_python/_cli.py | 2 +- mcp_run_python/main.py | 2 +- tests/test_mcp_servers.py | 11 +++++------ 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/mcp_run_python/_cli.py b/mcp_run_python/_cli.py index d2c95d3..29165ef 100644 --- a/mcp_run_python/_cli.py +++ b/mcp_run_python/_cli.py @@ -53,7 +53,7 @@ def cli_logic(args_list: Sequence[str] | None = None) -> int: http_port=args.port, dependencies=deps, deps_log_handler=deps_log_handler, - verbose=bool(args.verbose) + verbose=bool(args.verbose), ) return return_code else: diff --git a/mcp_run_python/main.py b/mcp_run_python/main.py index 7e15c77..f4015e8 100644 --- a/mcp_run_python/main.py +++ b/mcp_run_python/main.py @@ -42,7 +42,7 @@ def run_mcp_server( """ subprocess_kwargs = {} if verbose: - subprocess_kwargs = {"stdout":sys.stdout, "stderr":sys.stderr} + subprocess_kwargs = {'stdout': sys.stdout, 'stderr': sys.stderr} with prepare_deno_env( mode, diff --git a/tests/test_mcp_servers.py b/tests/test_mcp_servers.py index ab16366..71ec93f 100644 --- a/tests/test_mcp_servers.py +++ b/tests/test_mcp_servers.py @@ -210,12 +210,12 @@ async def test_run_python_code( """), [ EmbeddedResource( - type="resource", + type='resource', resource=BlobResourceContents( - uri="file://_", - mimeType="text/plain", - name="hello.txt", - blob="aGVsbG8gd29ybGQh", + uri='file://_', + mimeType='text/plain', + name='hello.txt', + blob='aGVsbG8gd29ybGQh', ), ) ], @@ -243,7 +243,6 @@ async def test_run_python_code_with_output_resource( assert got == expected - async def test_install_run_python_code() -> None: logs: list[str] = [] From 072a73fd83cebec935b7ada884122ffb8c988c17 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 28 Oct 2025 15:27:18 +0100 Subject: [PATCH 04/16] chore: remove verbose logging from this PR --- mcp_run_python/_cli.py | 1 - mcp_run_python/main.py | 13 ++----------- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/mcp_run_python/_cli.py b/mcp_run_python/_cli.py index 29165ef..89258c5 100644 --- a/mcp_run_python/_cli.py +++ b/mcp_run_python/_cli.py @@ -53,7 +53,6 @@ def cli_logic(args_list: Sequence[str] | None = None) -> int: http_port=args.port, dependencies=deps, deps_log_handler=deps_log_handler, - verbose=bool(args.verbose), ) return return_code else: diff --git a/mcp_run_python/main.py b/mcp_run_python/main.py index f4015e8..34d177d 100644 --- a/mcp_run_python/main.py +++ b/mcp_run_python/main.py @@ -2,7 +2,6 @@ import logging import shutil import subprocess -import sys import tempfile from collections.abc import AsyncIterator, Callable, Iterator from contextlib import asynccontextmanager, contextmanager @@ -27,7 +26,6 @@ def run_mcp_server( return_mode: Literal['json', 'xml'] = 'xml', deps_log_handler: LogHandler | None = None, allow_networking: bool = True, - verbose: bool = False, ) -> int: """Install dependencies then run the mcp-run-python server. @@ -38,12 +36,7 @@ def run_mcp_server( return_mode: The mode to return tool results in. deps_log_handler: Optional function to receive logs emitted while installing dependencies. allow_networking: Whether to allow networking when running provided python code. - verbose: Log deno outputs to CLI """ - subprocess_kwargs = {} - if verbose: - subprocess_kwargs = {'stdout': sys.stdout, 'stderr': sys.stderr} - with prepare_deno_env( mode, dependencies=dependencies, @@ -58,7 +51,7 @@ def run_mcp_server( logger.info('Running mcp-run-python via %s...', mode) try: - p = subprocess.run(('deno', *env.args), cwd=env.cwd, **subprocess_kwargs) + p = subprocess.run(('deno', *env.args), cwd=env.cwd) except KeyboardInterrupt: # pragma: no cover logger.warning('Server stopped.') return 0 @@ -105,7 +98,6 @@ def prepare_deno_env( src = Path(__file__).parent / 'deno' logger.debug('Copying from %s to %s...', src, cwd) shutil.copytree(src, cwd) - (cwd / 'output_files').mkdir() logger.info('Installing dependencies %s...', dependencies) args = 'deno', *_deno_install_args(dependencies) @@ -189,8 +181,7 @@ def _deno_run_args( if allow_networking: args += ['--allow-net'] args += [ - '--allow-read=./node_modules,./output_files', - '--allow-write=./output_files', + '--allow-read=./node_modules', '--node-modules-dir=auto', 'src/main.ts', mode, From 9afd1a3c846b6892594d505c7c7be7cdeaaf4b39 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 28 Oct 2025 15:35:16 +0100 Subject: [PATCH 05/16] chore: leftovers --- mcp_run_python/main.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mcp_run_python/main.py b/mcp_run_python/main.py index 34d177d..aadc0e2 100644 --- a/mcp_run_python/main.py +++ b/mcp_run_python/main.py @@ -98,6 +98,7 @@ def prepare_deno_env( src = Path(__file__).parent / 'deno' logger.debug('Copying from %s to %s...', src, cwd) shutil.copytree(src, cwd) + (cwd / 'output_files').mkdir() logger.info('Installing dependencies %s...', dependencies) args = 'deno', *_deno_install_args(dependencies) @@ -181,7 +182,8 @@ def _deno_run_args( if allow_networking: args += ['--allow-net'] args += [ - '--allow-read=./node_modules', + '--allow-read=./node_modules,./output_files', + '--allow-write=./output_files', '--node-modules-dir=auto', 'src/main.ts', mode, From ed985ec7381b335692972aba6993507a85e2bfd0 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 28 Oct 2025 15:45:18 +0100 Subject: [PATCH 06/16] chore: linting --- tests/test_mcp_servers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_mcp_servers.py b/tests/test_mcp_servers.py index 71ec93f..959eade 100644 --- a/tests/test_mcp_servers.py +++ b/tests/test_mcp_servers.py @@ -214,7 +214,7 @@ async def test_run_python_code( resource=BlobResourceContents( uri='file://_', mimeType='text/plain', - name='hello.txt', + name='hello.txt', # pyright: ignore[reportCallIssue] blob='aGVsbG8gd29ybGQh', ), ) From 2044bfec0b3ef731dd8e817f6e53093326452402 Mon Sep 17 00:00:00 2001 From: "daniel.jaekel" Date: Tue, 28 Oct 2025 20:19:14 +0100 Subject: [PATCH 07/16] chore: update readme & tool description --- README.md | 1 + mcp_run_python/deno/src/main.ts | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4501212..c597ceb 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ the rest of the operating system. - **Complete Results**: Captures standard output, standard error, and return values - **Asynchronous Support**: Runs async code properly - **Error Handling**: Provides detailed error reports for debugging +- **File Output**: Can output and return files & images. Useful for things like generating graphs _(This code was previously part of [Pydantic AI](https://github.com/pydantic/pydantic-ai) but was moved to a separate repo to make it easier to maintain.)_ diff --git a/mcp_run_python/deno/src/main.ts b/mcp_run_python/deno/src/main.ts index cc8dbf2..6ca0e6b 100644 --- a/mcp_run_python/deno/src/main.ts +++ b/mcp_run_python/deno/src/main.ts @@ -74,10 +74,11 @@ function createServer(deps: string[], returnMode: string): McpServer { const toolDescription = `Tool to execute Python code and return stdout, stderr, and return value. -The code may be async, and the value on the last line will be returned as the return value. -The code will be executed with Python 3.13. - -To output files or images, save them in the "/output_files" folder. +### Guidelines +- The code may be async, and the value on the last line will be returned as the return value. +- The code will be executed with Python 3.13 using pyodide - so adapt your code if needed. +- To output files or images, save them in the "/output_files" folder. +- You have these python packages installed: \`${deps}\` ` let setLogLevel: LoggingLevel = 'emergency' From 36b2d9c8aba8ebc9c64c12c4cd4170aafa8dc337 Mon Sep 17 00:00:00 2001 From: "daniel.jaekel" Date: Thu, 6 Nov 2025 14:01:30 +0100 Subject: [PATCH 08/16] reactor: make opt-in feature --- README.md | 12 ++++++++++-- mcp_run_python/_cli.py | 2 ++ mcp_run_python/deno/src/main.ts | 27 ++++++++++++++++----------- mcp_run_python/deno/src/runCode.ts | 25 +++++++++++++++++-------- mcp_run_python/main.py | 10 ++++++++++ 5 files changed, 55 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index c597ceb..b7a2e9d 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,8 @@ the rest of the operating system. - **Complete Results**: Captures standard output, standard error, and return values - **Asynchronous Support**: Runs async code properly - **Error Handling**: Provides detailed error reports for debugging -- **File Output**: Can output and return files & images. Useful for things like generating graphs +- **File Output**: Can output and return files & images. Useful for things like generating graphs. + - **Important:** Disabled by default for backwards compatibility! _(This code was previously part of [Pydantic AI](https://github.com/pydantic/pydantic-ai) but was moved to a separate repo to make it easier to maintain.)_ @@ -35,7 +36,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] [--enable-file-outputs] {stdio,streamable-http,example} ``` where: @@ -166,3 +167,10 @@ edit the filesystem. * `deno` is then run with read-only permissions to the `node_modules` directory to run untrusted code. Dependencies must be provided when initializing the server so they can be installed in the first step. + +## File Outputs + +`mcp_run_python` supports outputting files using [embedded resources](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#embedded-resources). +This can be very useful when having the LLM do complex calculation to create some csv file, or when the code it writes generates binary blobs like images - for example when interacting with matplotlib. + +To preserve backwards compatibility, this is an **opt-in** feature and needs to be enabled. Pass `--enable-file-outputs` to your run command to enable this. diff --git a/mcp_run_python/_cli.py b/mcp_run_python/_cli.py index 89258c5..e397c7f 100644 --- a/mcp_run_python/_cli.py +++ b/mcp_run_python/_cli.py @@ -27,6 +27,7 @@ def cli_logic(args_list: Sequence[str] | None = None) -> int: '--disable-networking', action='store_true', help='Disable networking during execution of python code' ) parser.add_argument('--verbose', action='store_true', help='Enable verbose logging') + parser.add_argument('--enable-file-outputs', action='store_true', help='Enable file output functionality') parser.add_argument('--version', action='store_true', help='Show version and exit') parser.add_argument( 'mode', @@ -53,6 +54,7 @@ def cli_logic(args_list: Sequence[str] | None = None) -> int: http_port=args.port, dependencies=deps, deps_log_handler=deps_log_handler, + enable_file_outputs=args.enable_file_outputs, ) return return_code else: diff --git a/mcp_run_python/deno/src/main.ts b/mcp_run_python/deno/src/main.ts index 6ca0e6b..f338bbf 100644 --- a/mcp_run_python/deno/src/main.ts +++ b/mcp_run_python/deno/src/main.ts @@ -20,17 +20,19 @@ const VERSION = '0.0.13' export async function main() { const { args } = Deno const flags = parseArgs(Deno.args, { - string: ['deps', 'return-mode', 'port'], - default: { port: '3001', 'return-mode': 'xml' }, + string: ['deps', 'return-mode', 'port', "enable-file-outputs"], + default: { port: '3001', 'return-mode': 'xml', "enable-file-outputs": false }, }) + // todo + console.error(flags) const deps = flags.deps?.split(',') ?? [] if (args.length >= 1) { if (args[0] === 'stdio') { - await runStdio(deps, flags['return-mode']) + await runStdio(deps, flags['return-mode'], flags['enable-file-outputs']) return } else if (args[0] === 'streamable_http') { const port = parseInt(flags.port) - runStreamableHttp(port, deps, flags['return-mode']) + runStreamableHttp(port, deps, flags['return-mode'], flags['enable-file-outputs']) return } else if (args[0] === 'example') { await example(deps) @@ -57,7 +59,7 @@ options: /* * Create an MCP server with the `run_python_code` tool registered. */ -function createServer(deps: string[], returnMode: string): McpServer { +function createServer(deps: string[], returnMode: string, enableFileOutputs: boolean): McpServer { const runCode = new RunCode() const server = new McpServer( { @@ -72,14 +74,16 @@ function createServer(deps: string[], returnMode: string): McpServer { }, ) - const toolDescription = `Tool to execute Python code and return stdout, stderr, and return value. + let toolDescription = `Tool to execute Python code and return stdout, stderr, and return value. ### Guidelines - The code may be async, and the value on the last line will be returned as the return value. - The code will be executed with Python 3.13 using pyodide - so adapt your code if needed. -- To output files or images, save them in the "/output_files" folder. - You have these python packages installed: \`${deps}\` ` + if (enableFileOutputs) { + toolDescription += '- To output files or images, save them in the "/output_files" folder.\n' + } let setLogLevel: LoggingLevel = 'emergency' @@ -112,6 +116,7 @@ function createServer(deps: string[], returnMode: string): McpServer { { name: 'main.py', content: python_code }, global_variables, returnMode !== 'xml', + enableFileOutputs, ) await Promise.all(logPromises) const mcpResponse: any[] = [] @@ -185,9 +190,9 @@ function httpSetJsonResponse(res: http.ServerResponse, status: number, text: str /* * 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, enableFileOutputs: boolean) { // https://github.com/modelcontextprotocol/typescript-sdk?tab=readme-ov-file#with-session-management - const mcpServer = createServer(deps, returnMode) + const mcpServer = createServer(deps, returnMode, enableFileOutputs) const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {} const server = http.createServer(async (req, res) => { @@ -271,8 +276,8 @@ function runStreamableHttp(port: number, deps: string[], returnMode: string) { /* * Run the MCP server using the Stdio transport. */ -async function runStdio(deps: string[], returnMode: string) { - const mcpServer = createServer(deps, returnMode) +async function runStdio(deps: string[], returnMode: string, enableFileOutputs: boolean) { + const mcpServer = createServer(deps, returnMode, enableFileOutputs) const transport = new StdioServerTransport() await mcpServer.connect(transport) } diff --git a/mcp_run_python/deno/src/runCode.ts b/mcp_run_python/deno/src/runCode.ts index d270feb..b95863e 100644 --- a/mcp_run_python/deno/src/runCode.ts +++ b/mcp_run_python/deno/src/runCode.ts @@ -30,6 +30,7 @@ export class RunCode { file?: CodeFile, globals?: Record, alwaysReturnJson: boolean = false, + enableFileOutputs: boolean = false, ): Promise { let pyodide: PyodideInterface let sys: any @@ -59,11 +60,17 @@ export class RunCode { } } else if (file) { try { - // make the temp file system for pyodide to use - const folderName = randomBytes(20).toString('hex').slice(0, 20) - const folderPath = `./output_files/${folderName}` - await Deno.mkdir(folderPath, { recursive: true }) - pyodide.mountNodeFS('/output_files', folderPath) + // defaults in case file output is not enabled + let folderPath = "" + let files = [] + + if (enableFileOutputs) { + // make the temp file system for pyodide to use + const folderName = randomBytes(20).toString('hex').slice(0, 20) + folderPath = `./output_files/${folderName}` + await Deno.mkdir(folderPath, { recursive: true }) + pyodide.mountNodeFS('/output_files', folderPath) + } // run the code with pyodide const rawValue = await pyodide.runPythonAsync(file.content, { @@ -71,9 +78,11 @@ export class RunCode { filename: file.name, }) - // check files that got saved - const files = await this.readAndDeleteFiles(folderPath) - pyodide.FS.unmount('/output_files') + if (enableFileOutputs) { + // check files that got saved + files = await this.readAndDeleteFiles(folderPath) + pyodide.FS.unmount('/output_files') + } return { status: 'success', diff --git a/mcp_run_python/main.py b/mcp_run_python/main.py index aadc0e2..3427352 100644 --- a/mcp_run_python/main.py +++ b/mcp_run_python/main.py @@ -26,6 +26,7 @@ def run_mcp_server( return_mode: Literal['json', 'xml'] = 'xml', deps_log_handler: LogHandler | None = None, allow_networking: bool = True, + enable_file_outputs: bool = False, ) -> int: """Install dependencies then run the mcp-run-python server. @@ -36,6 +37,7 @@ def run_mcp_server( return_mode: The mode to return tool results in. deps_log_handler: Optional function to receive logs emitted while installing dependencies. allow_networking: Whether to allow networking when running provided python code. + enable_file_outputs: Whether to enable output files """ with prepare_deno_env( mode, @@ -44,7 +46,9 @@ def run_mcp_server( return_mode=return_mode, deps_log_handler=deps_log_handler, allow_networking=allow_networking, + enable_file_outputs=enable_file_outputs, ) as env: + logger.info(f'Running with file output support {"enabled" if enable_file_outputs else "disabled"}.') if mode == 'streamable_http': logger.info('Running mcp-run-python via %s on port %d...', mode, http_port) else: @@ -74,6 +78,7 @@ def prepare_deno_env( return_mode: Literal['json', 'xml'] = 'xml', deps_log_handler: LogHandler | None = None, allow_networking: bool = True, + enable_file_outputs: bool = False, ) -> Iterator[DenoEnv]: """Prepare the deno environment for running the mcp-run-python server with Deno. @@ -89,6 +94,7 @@ def prepare_deno_env( deps_log_handler: Optional function to receive logs emitted while installing dependencies. allow_networking: Whether the prepared DenoEnv should allow networking when running code. Note that we always allow networking during environment initialization to install dependencies. + enable_file_outputs: Whether to enable output files Returns: Yields the deno environment details. @@ -122,6 +128,7 @@ def prepare_deno_env( dependencies=dependencies, return_mode=return_mode, allow_networking=allow_networking, + enable_file_outputs=enable_file_outputs, ) yield DenoEnv(cwd, args) @@ -177,6 +184,7 @@ def _deno_run_args( dependencies: list[str] | None = None, return_mode: Literal['json', 'xml'] = 'xml', allow_networking: bool = True, + enable_file_outputs: bool = False, ) -> list[str]: args = ['run'] if allow_networking: @@ -189,6 +197,8 @@ def _deno_run_args( mode, f'--return-mode={return_mode}', ] + if enable_file_outputs: + args += ['--enable-file-outputs'] if dependencies is not None: args.append(f'--deps={",".join(dependencies)}') if http_port is not None: From 34bff7acc11490cdcd1120155136fc0bddefdbe4 Mon Sep 17 00:00:00 2001 From: "daniel.jaekel" Date: Thu, 6 Nov 2025 14:47:25 +0100 Subject: [PATCH 09/16] test: add test to check for parallelism --- README.md | 2 +- examples/direct.py | 2 +- mcp_run_python/code_sandbox.py | 3 ++ mcp_run_python/main.py | 2 ++ tests/test_mcp_servers.py | 64 ++++++++++++++++++++++++++++++++-- 5 files changed, 69 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b7a2e9d..279bb2f 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ logfire.instrument_pydantic_ai() async def main(): - async with async_prepare_deno_env('stdio') as deno_env: + async with async_prepare_deno_env('stdio', enable_file_outputs=True) as deno_env: server = MCPServerStdio('deno', args=deno_env.args, cwd=deno_env.cwd, timeout=10) agent = Agent('claude-3-5-haiku-latest', toolsets=[server]) async with agent: diff --git a/examples/direct.py b/examples/direct.py index 2f9819c..a1d3f28 100644 --- a/examples/direct.py +++ b/examples/direct.py @@ -12,7 +12,7 @@ async def main(): - async with async_prepare_deno_env('stdio', dependencies=['numpy']) as deno_env: + async with async_prepare_deno_env('stdio', dependencies=['numpy'], enable_file_outputs=True) as deno_env: server_params = StdioServerParameters(command='deno', args=deno_env.args, cwd=deno_env.cwd) async with stdio_client(server_params) as (read, write): async with ClientSession(read, write) as session: diff --git a/mcp_run_python/code_sandbox.py b/mcp_run_python/code_sandbox.py index f552861..779c3c2 100644 --- a/mcp_run_python/code_sandbox.py +++ b/mcp_run_python/code_sandbox.py @@ -56,6 +56,7 @@ async def code_sandbox( dependencies: list[str] | None = None, log_handler: LogHandler | None = None, allow_networking: bool = True, + enable_file_outputs: bool = False, ) -> AsyncIterator['CodeSandbox']: """Create a secure sandbox. @@ -64,6 +65,7 @@ async def code_sandbox( log_handler: A callback function to handle print statements when code is running. deps_log_handler: A callback function to run on log statements during initial install of dependencies. allow_networking: Whether to allow networking or not while executing python code. + enable_file_outputs: Whether to enable output files """ async with async_prepare_deno_env( 'stdio', @@ -71,6 +73,7 @@ async def code_sandbox( deps_log_handler=log_handler, return_mode='json', allow_networking=allow_networking, + enable_file_outputs=enable_file_outputs, ) as deno_env: server_params = StdioServerParameters(command='deno', args=deno_env.args, cwd=deno_env.cwd) diff --git a/mcp_run_python/main.py b/mcp_run_python/main.py index 3427352..f8174f2 100644 --- a/mcp_run_python/main.py +++ b/mcp_run_python/main.py @@ -145,6 +145,7 @@ async def async_prepare_deno_env( return_mode: Literal['json', 'xml'] = 'xml', deps_log_handler: LogHandler | None = None, allow_networking: bool = True, + enable_file_outputs: bool = False, ) -> AsyncIterator[DenoEnv]: """Async variant of `prepare_deno_env`.""" ct = await _asyncify( @@ -155,6 +156,7 @@ async def async_prepare_deno_env( return_mode=return_mode, deps_log_handler=deps_log_handler, allow_networking=allow_networking, + enable_file_outputs=enable_file_outputs, ) try: yield await _asyncify(ct.__enter__) diff --git a/tests/test_mcp_servers.py b/tests/test_mcp_servers.py index 959eade..c360ff1 100644 --- a/tests/test_mcp_servers.py +++ b/tests/test_mcp_servers.py @@ -3,6 +3,7 @@ import asyncio import re import subprocess +import time from collections.abc import AsyncIterator, Callable from contextlib import AbstractAsyncContextManager, asynccontextmanager from typing import TYPE_CHECKING @@ -30,7 +31,7 @@ def fixture_run_mcp_session( @asynccontextmanager async def run_mcp(deps: list[str]) -> AsyncIterator[ClientSession]: if request.param == 'stdio': - async with async_prepare_deno_env('stdio', dependencies=deps) as env: + async with async_prepare_deno_env('stdio', dependencies=deps, enable_file_outputs=True) as env: server_params = StdioServerParameters(command='deno', args=env.args, cwd=env.cwd) async with stdio_client(server_params) as (read, write): async with ClientSession(read, write) as session: @@ -38,7 +39,9 @@ async def run_mcp(deps: list[str]) -> AsyncIterator[ClientSession]: else: assert request.param == 'streamable_http', 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( + 'streamable_http', http_port=port, dependencies=deps, enable_file_outputs=True + ) as env: p = subprocess.Popen(['deno', *env.args], cwd=env.cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) try: url = f'http://localhost:{port}/mcp' @@ -277,3 +280,60 @@ def logging_callback(level: str, message: str) -> None: \ """ ) + + +async def test_run_parallel_python_code( + run_mcp_session: Callable[[list[str]], AbstractAsyncContextManager[ClientSession]], +) -> None: + code_list = [ + """ + x=11 + x + """, + """ + import time + time.sleep(5) + x=11 + x + """, + """ + import asyncio + await asyncio.sleep(5) + x=11 + x + """, + ] + # Run this a couple times in parallel + code_list = code_list * 5 + + async with run_mcp_session([]) as mcp_session: + await mcp_session.initialize() + + start = time.perf_counter() + + tasks = set() + async with asyncio.TaskGroup() as tg: + for code in code_list: + task = tg.create_task(mcp_session.call_tool('run_python_code', {'python_code': code})) + tasks.add(task) + + # check parallelism + end = time.perf_counter() + run_time = end - start + assert run_time < 10 + assert run_time > 5 + + # check that all outputs are fine too + for task in tasks: + result: types.CallToolResult = task.result() + + assert len(result.content) == 1 + content = result.content[0] + + assert ( + content.text.strip() + == """success + +11 +""".strip() + ) From 13d5325aacf7d515bc5ee4f99664ea001fb85778 Mon Sep 17 00:00:00 2001 From: "daniel.jaekel" Date: Thu, 6 Nov 2025 15:11:15 +0100 Subject: [PATCH 10/16] chore: fix type issues --- mcp_run_python/deno/src/main.ts | 5 +++-- mcp_run_python/deno/src/runCode.ts | 4 ++-- tests/test_mcp_servers.py | 27 +++++++++++++++++---------- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/mcp_run_python/deno/src/main.ts b/mcp_run_python/deno/src/main.ts index f338bbf..ca4d813 100644 --- a/mcp_run_python/deno/src/main.ts +++ b/mcp_run_python/deno/src/main.ts @@ -20,8 +20,9 @@ const VERSION = '0.0.13' export async function main() { const { args } = Deno const flags = parseArgs(Deno.args, { - string: ['deps', 'return-mode', 'port', "enable-file-outputs"], - default: { port: '3001', 'return-mode': 'xml', "enable-file-outputs": false }, + string: ['deps', 'return-mode', 'port'], + boolean: ['enable-file-outputs'], + default: { port: '3001', 'return-mode': 'xml', 'enable-file-outputs': false }, }) // todo console.error(flags) diff --git a/mcp_run_python/deno/src/runCode.ts b/mcp_run_python/deno/src/runCode.ts index b95863e..99c7e86 100644 --- a/mcp_run_python/deno/src/runCode.ts +++ b/mcp_run_python/deno/src/runCode.ts @@ -61,8 +61,8 @@ export class RunCode { } else if (file) { try { // defaults in case file output is not enabled - let folderPath = "" - let files = [] + let folderPath = '' + let files: Resource[] = [] if (enableFileOutputs) { // make the temp file system for pyodide to use diff --git a/tests/test_mcp_servers.py b/tests/test_mcp_servers.py index c360ff1..0c46cb0 100644 --- a/tests/test_mcp_servers.py +++ b/tests/test_mcp_servers.py @@ -29,9 +29,11 @@ def fixture_run_mcp_session( request: pytest.FixtureRequest, ) -> Callable[[list[str]], AbstractAsyncContextManager[ClientSession]]: @asynccontextmanager - async def run_mcp(deps: list[str]) -> AsyncIterator[ClientSession]: + async def run_mcp(deps: list[str], enable_file_outputs: bool = True) -> AsyncIterator[ClientSession]: if request.param == 'stdio': - async with async_prepare_deno_env('stdio', dependencies=deps, enable_file_outputs=True) as env: + async with async_prepare_deno_env( + 'stdio', dependencies=deps, enable_file_outputs=enable_file_outputs + ) as env: server_params = StdioServerParameters(command='deno', args=env.args, cwd=env.cwd) async with stdio_client(server_params) as (read, write): async with ClientSession(read, write) as session: @@ -40,7 +42,7 @@ async def run_mcp(deps: list[str]) -> AsyncIterator[ClientSession]: assert request.param == 'streamable_http', request.param port = 3101 async with async_prepare_deno_env( - 'streamable_http', http_port=port, dependencies=deps, enable_file_outputs=True + 'streamable_http', http_port=port, dependencies=deps, enable_file_outputs=enable_file_outputs ) as env: p = subprocess.Popen(['deno', *env.args], cwd=env.cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) try: @@ -282,8 +284,10 @@ def logging_callback(level: str, message: str) -> None: ) +@pytest.mark.parametrize('enable_file_outputs', [pytest.param(True), pytest.param(False)]) async def test_run_parallel_python_code( - run_mcp_session: Callable[[list[str]], AbstractAsyncContextManager[ClientSession]], + run_mcp_session: Callable[[list[str], bool], AbstractAsyncContextManager[ClientSession]], + enable_file_outputs: bool, ) -> None: code_list = [ """ @@ -306,16 +310,18 @@ async def test_run_parallel_python_code( # Run this a couple times in parallel code_list = code_list * 5 - async with run_mcp_session([]) as mcp_session: + async with run_mcp_session([], enable_file_outputs) as mcp_session: await mcp_session.initialize() start = time.perf_counter() - tasks = set() - async with asyncio.TaskGroup() as tg: + tasks: set[asyncio.Task[types.CallToolResult]] = set() + async with asyncio.TaskGroup() as tg: # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue, reportUnknownVariableType] for code in code_list: - task = tg.create_task(mcp_session.call_tool('run_python_code', {'python_code': code})) - tasks.add(task) + task: asyncio.Task[types.CallToolResult] = tg.create_task( # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] + mcp_session.call_tool('run_python_code', {'python_code': code}) + ) + tasks.add(task) # pyright: ignore[reportUnknownArgumentType] # check parallelism end = time.perf_counter() @@ -325,11 +331,12 @@ async def test_run_parallel_python_code( # check that all outputs are fine too for task in tasks: - result: types.CallToolResult = task.result() + result = task.result() assert len(result.content) == 1 content = result.content[0] + assert isinstance(content, types.TextContent) assert ( content.text.strip() == """success From cf809a30766440f5a410b55c2ce73c011452612f Mon Sep 17 00:00:00 2001 From: "daniel.jaekel" Date: Thu, 6 Nov 2025 16:39:01 +0100 Subject: [PATCH 11/16] feat: initital work on making multiple pyodide instances --- mcp_run_python/deno/src/runCode.ts | 364 ++++++++++++++++++++--------- tests/test_mcp_servers.py | 66 ++++++ 2 files changed, 318 insertions(+), 112 deletions(-) diff --git a/mcp_run_python/deno/src/runCode.ts b/mcp_run_python/deno/src/runCode.ts index 99c7e86..7ec9282 100644 --- a/mcp_run_python/deno/src/runCode.ts +++ b/mcp_run_python/deno/src/runCode.ts @@ -16,138 +16,106 @@ interface PrepResult { preparePyEnv: PreparePyEnv sys: any prepareStatus: PrepareSuccess | PrepareError + output: string[] } -export class RunCode { - private output: string[] = [] - private pyodide?: PyodideInterface - private preparePyEnv?: PreparePyEnv - private prepPromise?: Promise +interface PyodideWorker { + id: number - async run( + pyodide: PyodideInterface + sys: any + prepareStatus: PrepareSuccess | PrepareError | undefined + preparePyEnv: PreparePyEnv + output: string[] + + inUse: boolean +} + +class PyodideAccess { + // function to get a pyodide instance (with timeout & max members) + public async withPyodideInstance( dependencies: string[], log: (level: LoggingLevel, data: string) => void, - file?: CodeFile, - globals?: Record, - alwaysReturnJson: boolean = false, - enableFileOutputs: boolean = false, - ): Promise { - let pyodide: PyodideInterface - let sys: any - let prepareStatus: PrepareSuccess | PrepareError | undefined - let preparePyEnv: PreparePyEnv - if (this.pyodide && this.preparePyEnv) { - pyodide = this.pyodide - preparePyEnv = this.preparePyEnv - sys = pyodide.pyimport('sys') - } else { - if (!this.prepPromise) { - this.prepPromise = this.prepEnv(dependencies, log) - } - // TODO is this safe if the promise has already been accessed? it seems to work fine - const prep = await this.prepPromise - pyodide = prep.pyodide - preparePyEnv = prep.preparePyEnv - sys = prep.sys - prepareStatus = prep.prepareStatus + maximumInstances: number, + waitTimeoutMs: number, + fn: (w: PyodideWorker) => Promise, + ): Promise { + const w = await this.getPyodideInstance(dependencies, log, maximumInstances, waitTimeoutMs) + try { + return await fn(w) + } finally { + this.releasePyodideInstance(w.id) } + } - if (prepareStatus && prepareStatus.kind == 'error') { - return { - status: 'install-error', - output: this.takeOutput(sys), - error: prepareStatus.message, - } - } else if (file) { - try { - // defaults in case file output is not enabled - let folderPath = '' - let files: Resource[] = [] - - if (enableFileOutputs) { - // make the temp file system for pyodide to use - const folderName = randomBytes(20).toString('hex').slice(0, 20) - folderPath = `./output_files/${folderName}` - await Deno.mkdir(folderPath, { recursive: true }) - pyodide.mountNodeFS('/output_files', folderPath) - } - - // run the code with pyodide - const rawValue = await pyodide.runPythonAsync(file.content, { - globals: pyodide.toPy({ ...(globals || {}), __name__: '__main__' }), - filename: file.name, - }) + private pyodideInstances: { [workerId: number]: PyodideWorker } = {} + private nextWorkerId = 1 + private creatingCount = 0 - if (enableFileOutputs) { - // check files that got saved - files = await this.readAndDeleteFiles(folderPath) - pyodide.FS.unmount('/output_files') - } + private waitQueue: { + resolve: (w: PyodideWorker) => void + reject: (e: unknown) => void + timer: ReturnType + }[] = [] - return { - status: 'success', - output: this.takeOutput(sys), - returnValueJson: preparePyEnv.dump_json(rawValue, alwaysReturnJson), - embeddedResources: files, - } - } catch (err) { - pyodide.FS.unmount('/output_files') - console.log(err) - return { - status: 'run-error', - output: this.takeOutput(sys), - error: formatError(err), - } - } - } else { - return { - status: 'success', - output: this.takeOutput(sys), - returnValueJson: null, - embeddedResources: [], + private tryAcquireFree(): PyodideWorker | undefined { + for (const w of Object.values(this.pyodideInstances)) { + if (!w.inUse) { + w.inUse = true + return w } } + return undefined } - async readAndDeleteFiles(folderPath: string): Promise { - const results: Resource[] = [] - for await (const file of Deno.readDir(folderPath)) { - // Skip directories - if (!file.isFile) continue - - const fileName = file.name - const filePath = `${folderPath}/${fileName}` - const mimeType = mime.lookup(fileName) - const fileData = await Deno.readFile(filePath) - - // Convert binary to Base64 - const base64Encoded = encodeBase64(fileData) + private async createPyodideWorker( + id: number, + dependencies: string[], + log: (level: LoggingLevel, data: string) => void, + ): Promise { + // if (this.pyodide && this.preparePyEnv) { + // pyodide = this.pyodide + // preparePyEnv = this.preparePyEnv + // sys = pyodide.pyimport('sys') + // } else { + // if (!this.prepPromise) { + // this.prepPromise = this.prepEnv(dependencies, log) + // } + // // TODO is this safe if the promise has already been accessed? it seems to work fine + // const prep = await this.prepPromise + // pyodide = prep.pyodide + // preparePyEnv = prep.preparePyEnv + // sys = prep.sys + // prepareStatus = prep.prepareStatus + // } - results.push({ - name: fileName, - mimeType: mimeType, - blob: base64Encoded, - }) + const prepPromise = this.prepEnv(dependencies, log) + const prep = await prepPromise + return { + id, + pyodide: prep.pyodide, + sys: prep.sys, + prepareStatus: prep.prepareStatus, + preparePyEnv: prep.preparePyEnv, + output: prep.output, + inUse: false, } - - // Now delete the file folder - otherwise they add up :) - await Deno.remove(folderPath, { recursive: true }) - - return results } - async prepEnv( + private async prepEnv( dependencies: string[], log: (level: LoggingLevel, data: string) => void, ): Promise { + const output: string[] = [] + const pyodide = await loadPyodide({ stdout: (msg) => { log('info', msg) - this.output.push(msg) + output.push(msg) }, stderr: (msg) => { log('warning', msg) - this.output.push(msg) + output.push(msg) }, }) @@ -159,7 +127,7 @@ export class RunCode { messageCallback: (msg: string) => log('debug', msg), errorCallback: (msg: string) => { log('error', msg) - this.output.push(`install error: ${msg}`) + output.push(`install error: ${msg}`) }, ...options, }) @@ -183,14 +151,184 @@ export class RunCode { preparePyEnv, sys, prepareStatus, + output, + } + } + + private releasePyodideInstance(workerId: number): void { + const worker = this.pyodideInstances[workerId] + if (!worker) return + + // if sb is waiting, take the first worker from queue & keep status as "inUse" + const waiter = this.waitQueue.shift() + if (waiter) { + clearTimeout(waiter.timer) + worker.inUse = true + waiter.resolve(worker) + } else { + worker.inUse = false + } + } + + private async getPyodideInstance( + dependencies: string[], + log: (level: LoggingLevel, data: string) => void, + maximumInstances: number, + waitTimeoutMs: number, + ): Promise { + // 1) if possible, take a free - already inititalised - worker + const free = this.tryAcquireFree() + if (free) return free + + // 2) if none is free, check that we are not over capacity already + const currentCount = Object.keys(this.pyodideInstances).length + if (currentCount + this.creatingCount < maximumInstances) { + this.creatingCount++ + try { + const id = this.nextWorkerId++ + const worker = await this.createPyodideWorker(id, dependencies, log) + + // cool, created a new one so let's use that one + worker.inUse = true + this.pyodideInstances[id] = worker + return worker + } catch (err) { + // Need to make sure the creation gets reduced again, so simply re-throwing + throw err + } finally { + this.creatingCount-- + } } + + // 3) we have the maximum worker, wait until timeout until some is free + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const idx = this.waitQueue.findIndex((q) => q.resolve === resolve) + if (idx >= 0) this.waitQueue.splice(idx, 1) + reject(new Error('Timeout: no free Pyodide worker')) + }, waitTimeoutMs) + + this.waitQueue.push({ resolve, reject, timer }) + }) } +} + +export class RunCode { + private pyodideAccess: PyodideAccess = new PyodideAccess() - private takeOutput(sys: any): string[] { - sys.stdout.flush() - sys.stderr.flush() - const output = this.output - this.output = [] + async run( + dependencies: string[], + log: (level: LoggingLevel, data: string) => void, + file?: CodeFile, + globals?: Record, + alwaysReturnJson: boolean = false, + enableFileOutputs: boolean = false, + pyodideMaximumInstances: number = 5, + pyodideWaitTimeoutMs: number = 60_000, + ): Promise { + // get a pyodide instance for this job + return await this.pyodideAccess.withPyodideInstance( + dependencies, + log, + pyodideMaximumInstances, + pyodideWaitTimeoutMs, + async (pyodideWorker) => { + if (pyodideWorker.prepareStatus && pyodideWorker.prepareStatus.kind == 'error') { + return { + status: 'install-error', + output: this.takeOutput(pyodideWorker), + error: pyodideWorker.prepareStatus.message, + } + } else if (file) { + try { + // defaults in case file output is not enabled + let folderPath = '' + let files: Resource[] = [] + + if (enableFileOutputs) { + // make the temp file system for pyodide to use + const folderName = randomBytes(20).toString('hex').slice(0, 20) + folderPath = `./output_files/${folderName}` + await Deno.mkdir(folderPath, { recursive: true }) + pyodideWorker.pyodide.mountNodeFS('/output_files', folderPath) + } + + // run the code with pyodide + const rawValue = await pyodideWorker.pyodide.runPythonAsync(file.content, { + globals: pyodideWorker.pyodide.toPy({ ...(globals || {}), __name__: '__main__' }), + filename: file.name, + }) + + if (enableFileOutputs) { + // check files that got saved + files = await this.readAndDeleteFiles(folderPath) + pyodideWorker.pyodide.FS.unmount('/output_files') + } + + // label the worker as free again + pyodideWorker.inUse = false + + return { + status: 'success', + output: this.takeOutput(pyodideWorker), + returnValueJson: pyodideWorker.preparePyEnv.dump_json(rawValue, alwaysReturnJson), + embeddedResources: files, + } + } catch (err) { + pyodideWorker.pyodide.FS.unmount('/output_files') + pyodideWorker.inUse = false + console.log(err) + return { + status: 'run-error', + output: this.takeOutput(pyodideWorker), + error: formatError(err), + } + } + } else { + pyodideWorker.inUse = false + return { + status: 'success', + output: this.takeOutput(pyodideWorker), + returnValueJson: null, + embeddedResources: [], + } + } + }, + ) + } + + async readAndDeleteFiles(folderPath: string): Promise { + const results: Resource[] = [] + for await (const file of Deno.readDir(folderPath)) { + // Skip directories + if (!file.isFile) continue + + const fileName = file.name + const filePath = `${folderPath}/${fileName}` + const mimeType = mime.lookup(fileName) + const fileData = await Deno.readFile(filePath) + + // Convert binary to Base64 + const base64Encoded = encodeBase64(fileData) + + results.push({ + name: fileName, + mimeType: mimeType, + blob: base64Encoded, + }) + } + + // Now delete the file folder - otherwise they add up :) + await Deno.remove(folderPath, { recursive: true }) + + return results + } + + private takeOutput(pyodideWorker: PyodideWorker): string[] { + pyodideWorker.sys.stdout.flush() + pyodideWorker.sys.stderr.flush() + const output = pyodideWorker.output + pyodideWorker.output = [] return output } } @@ -271,10 +409,12 @@ interface PrepareSuccess { kind: 'success' dependencies?: string[] } + interface PrepareError { kind: 'error' message: string } + interface PreparePyEnv { prepare_env: (files: CodeFile[]) => Promise dump_json: (value: any, always_return_json: boolean) => string | null diff --git a/tests/test_mcp_servers.py b/tests/test_mcp_servers.py index 0c46cb0..b18368c 100644 --- a/tests/test_mcp_servers.py +++ b/tests/test_mcp_servers.py @@ -344,3 +344,69 @@ async def test_run_parallel_python_code( 11 """.strip() ) + + +async def test_run_parallel_python_code_with_files( + run_mcp_session: Callable[[list[str], bool], AbstractAsyncContextManager[ClientSession]], +) -> None: + """Check that the file system works between runs and keeps files to their runs""" + code_list = [ + """ + import time + from pathlib import Path + for i in range(5): + Path(f"/output_files/run1_file{i}.txt").write_text("hi") + time.sleep(1) + """, + """ + import time + from pathlib import Path + for i in range(5): + time.sleep(1) + Path(f"/output_files/run2_file{i}.txt").write_text("hi") + """, + ] + + async with run_mcp_session([], True) as mcp_session: + await mcp_session.initialize() + + start = time.perf_counter() + + tasks: set[asyncio.Task[types.CallToolResult]] = set() + async with asyncio.TaskGroup() as tg: # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue, reportUnknownVariableType] + for code in code_list: + task: asyncio.Task[types.CallToolResult] = tg.create_task( # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] + mcp_session.call_tool('run_python_code', {'python_code': code}) + ) + tasks.add(task) # pyright: ignore[reportUnknownArgumentType] + + # check parallelism + end = time.perf_counter() + run_time = end - start + assert run_time < 10 + assert run_time > 5 + + # check that all outputs are fine too + for task in tasks: + result = task.result() + + assert len(result.content) == 6 + + run_ids: set[str] = set() + for content in result.content: + match content: + case types.EmbeddedResource(): + # save the run id from the text file name - to make sure its all the same + run_ids.add(content.resource.name.split('_')[0]) # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType, reportAttributeAccessIssue] + assert content.resource.blob == 'aGk=' # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue] + case types.TextContent(): + assert ( + content.text.strip() + == """success + + 11 + """.strip() + ) + case _: + raise AssertionError('Unexpected content type') + assert len(run_ids) == 1 From c9b81020946e2e93435582e32cb0094441548958 Mon Sep 17 00:00:00 2001 From: "daniel.jaekel" Date: Fri, 7 Nov 2025 13:43:24 +0100 Subject: [PATCH 12/16] feat: finalise work on making multiple pyodide instances & add timeouts --- mcp_run_python/_cli.py | 21 ++ mcp_run_python/code_sandbox.py | 3 + mcp_run_python/deno/src/main.ts | 88 +++++++- mcp_run_python/deno/src/runCode.ts | 320 ++++++++++++++++------------- mcp_run_python/main.py | 31 +++ tests/test_mcp_servers.py | 49 +++-- 6 files changed, 342 insertions(+), 170 deletions(-) diff --git a/mcp_run_python/_cli.py b/mcp_run_python/_cli.py index e397c7f..03fea22 100644 --- a/mcp_run_python/_cli.py +++ b/mcp_run_python/_cli.py @@ -28,6 +28,24 @@ def cli_logic(args_list: Sequence[str] | None = None) -> int: ) parser.add_argument('--verbose', action='store_true', help='Enable verbose logging') parser.add_argument('--enable-file-outputs', action='store_true', help='Enable file output functionality') + parser.add_argument( + '--pyodide-max-workers', + help='How many pyodide workers should be spawned at a time max. This is the amount of concurrent function executions you can have. Default: 10', + default=10, + type=int, + ) + parser.add_argument( + '--pyodide-code-run-timeout-sec', + help='How long the code execution is allowed to last. Default: 60 seconds', + default=60, + type=int, + ) + parser.add_argument( + '--pyodide-worker-wait-timeout-sec', + help='How many long pyodide should wait for a free worker. Default: 60 seconds', + default=60, + type=int, + ) parser.add_argument('--version', action='store_true', help='Show version and exit') parser.add_argument( 'mode', @@ -55,6 +73,9 @@ def cli_logic(args_list: Sequence[str] | None = None) -> int: dependencies=deps, deps_log_handler=deps_log_handler, enable_file_outputs=args.enable_file_outputs, + pyodide_max_workers=args.pyodide_max_workers, + pyodide_worker_wait_timeout_sec=args.pyodide_worker_wait_timeout_sec, + pyodide_code_run_timeout_sec=args.pyodide_code_run_timeout_sec, ) return return_code else: diff --git a/mcp_run_python/code_sandbox.py b/mcp_run_python/code_sandbox.py index 779c3c2..5a1bd91 100644 --- a/mcp_run_python/code_sandbox.py +++ b/mcp_run_python/code_sandbox.py @@ -57,6 +57,7 @@ async def code_sandbox( log_handler: LogHandler | None = None, allow_networking: bool = True, enable_file_outputs: bool = False, + pyodide_max_workers: int = 10, ) -> AsyncIterator['CodeSandbox']: """Create a secure sandbox. @@ -66,6 +67,7 @@ async def code_sandbox( deps_log_handler: A callback function to run on log statements during initial install of dependencies. allow_networking: Whether to allow networking or not while executing python code. enable_file_outputs: Whether to enable output files + pyodide_max_workers: How many pyodide workers to max use at the same time """ async with async_prepare_deno_env( 'stdio', @@ -74,6 +76,7 @@ async def code_sandbox( return_mode='json', allow_networking=allow_networking, enable_file_outputs=enable_file_outputs, + pyodide_max_workers=pyodide_max_workers, ) as deno_env: server_params = StdioServerParameters(command='deno', args=deno_env.args, cwd=deno_env.cwd) diff --git a/mcp_run_python/deno/src/main.ts b/mcp_run_python/deno/src/main.ts index ca4d813..f96ae14 100644 --- a/mcp_run_python/deno/src/main.ts +++ b/mcp_run_python/deno/src/main.ts @@ -20,20 +20,48 @@ const VERSION = '0.0.13' export async function main() { const { args } = Deno const flags = parseArgs(Deno.args, { - string: ['deps', 'return-mode', 'port'], + string: [ + 'deps', + 'return-mode', + 'port', + 'pyodide-max-workers', + 'pyodide-code-run-timeout-sec', + 'pyodide-worker-wait-timeout-sec', + ], boolean: ['enable-file-outputs'], - default: { port: '3001', 'return-mode': 'xml', 'enable-file-outputs': false }, + default: { + port: '3001', + 'return-mode': 'xml', + 'enable-file-outputs': false, + 'pyodide-max-workers': '10', + 'pyodide-code-run-timeout-sec': '60', + 'pyodide-worker-wait-timeout-sec': '60', + }, }) - // todo console.error(flags) const deps = flags.deps?.split(',') ?? [] if (args.length >= 1) { if (args[0] === 'stdio') { - await runStdio(deps, flags['return-mode'], flags['enable-file-outputs']) + await runStdio( + deps, + flags['return-mode'], + flags['enable-file-outputs'], + parseInt(flags['pyodide-max-workers']), + parseInt(flags['pyodide-code-run-timeout-sec']), + parseInt(flags['pyodide-worker-wait-timeout-sec']), + ) return } else if (args[0] === 'streamable_http') { const port = parseInt(flags.port) - runStreamableHttp(port, deps, flags['return-mode'], flags['enable-file-outputs']) + runStreamableHttp( + port, + deps, + flags['return-mode'], + flags['enable-file-outputs'], + parseInt(flags['pyodide-max-workers']), + parseInt(flags['pyodide-code-run-timeout-sec']), + parseInt(flags['pyodide-worker-wait-timeout-sec']), + ) return } else if (args[0] === 'example') { await example(deps) @@ -60,7 +88,14 @@ options: /* * Create an MCP server with the `run_python_code` tool registered. */ -function createServer(deps: string[], returnMode: string, enableFileOutputs: boolean): McpServer { +function createServer( + deps: string[], + returnMode: string, + enableFileOutputs: boolean, + pyodideMaxWorkers: number, + pyodideCodeRunTimeoutSec: number, + pyodideWorkerWaitTimeoutSec: number, +): McpServer { const runCode = new RunCode() const server = new McpServer( { @@ -80,6 +115,7 @@ function createServer(deps: string[], returnMode: string, enableFileOutputs: boo ### Guidelines - The code may be async, and the value on the last line will be returned as the return value. - The code will be executed with Python 3.13 using pyodide - so adapt your code if needed. +- You code must be executed within a timeout. You have ${pyodideCodeRunTimeoutSec} seconds before the run is canceled. - You have these python packages installed: \`${deps}\` ` if (enableFileOutputs) { @@ -118,6 +154,9 @@ function createServer(deps: string[], returnMode: string, enableFileOutputs: boo global_variables, returnMode !== 'xml', enableFileOutputs, + pyodideMaxWorkers, + pyodideCodeRunTimeoutSec, + pyodideWorkerWaitTimeoutSec, ) await Promise.all(logPromises) const mcpResponse: any[] = [] @@ -191,9 +230,24 @@ function httpSetJsonResponse(res: http.ServerResponse, status: number, text: str /* * Run the MCP server using the Streamable HTTP transport */ -function runStreamableHttp(port: number, deps: string[], returnMode: string, enableFileOutputs: boolean) { +function runStreamableHttp( + port: number, + deps: string[], + returnMode: string, + enableFileOutputs: boolean, + pyodideMaxWorkers: number, + pyodideCodeRunTimeoutSec: number, + pyodideWorkerWaitTimeoutSec: number, +) { // https://github.com/modelcontextprotocol/typescript-sdk?tab=readme-ov-file#with-session-management - const mcpServer = createServer(deps, returnMode, enableFileOutputs) + const mcpServer = createServer( + deps, + returnMode, + enableFileOutputs, + pyodideMaxWorkers, + pyodideCodeRunTimeoutSec, + pyodideWorkerWaitTimeoutSec, + ) const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {} const server = http.createServer(async (req, res) => { @@ -277,8 +331,22 @@ function runStreamableHttp(port: number, deps: string[], returnMode: string, ena /* * Run the MCP server using the Stdio transport. */ -async function runStdio(deps: string[], returnMode: string, enableFileOutputs: boolean) { - const mcpServer = createServer(deps, returnMode, enableFileOutputs) +async function runStdio( + deps: string[], + returnMode: string, + enableFileOutputs: boolean, + pyodideMaxWorkers: number, + pyodideCodeRunTimeoutSec: number, + pyodideWorkerWaitTimeoutSec: number, +) { + const mcpServer = createServer( + deps, + returnMode, + enableFileOutputs, + pyodideMaxWorkers, + pyodideCodeRunTimeoutSec, + pyodideWorkerWaitTimeoutSec, + ) const transport = new StdioServerTransport() await mcpServer.connect(transport) } diff --git a/mcp_run_python/deno/src/runCode.ts b/mcp_run_python/deno/src/runCode.ts index 7ec9282..a981d60 100644 --- a/mcp_run_python/deno/src/runCode.ts +++ b/mcp_run_python/deno/src/runCode.ts @@ -19,28 +19,27 @@ interface PrepResult { output: string[] } -interface PyodideWorker { +interface PyodideWorker extends PrepResult { id: number - - pyodide: PyodideInterface - sys: any - prepareStatus: PrepareSuccess | PrepareError | undefined - preparePyEnv: PreparePyEnv - output: string[] - + pyodideInterruptBuffer: Uint8Array inUse: boolean } +/* + * Class that instanciates pyodide and keeps multiple instances + * There need to be multiple instances, as file system mounting to a standard directory (which is needed for the LLM), cannot be easily done without mixing files + * Now, every process has their own file system & they get looped around + */ class PyodideAccess { - // function to get a pyodide instance (with timeout & max members) + // context manager to get a pyodide instance (with timeout & max members) public async withPyodideInstance( dependencies: string[], log: (level: LoggingLevel, data: string) => void, maximumInstances: number, - waitTimeoutMs: number, + pyodideWorkerWaitTimeoutSec: number, fn: (w: PyodideWorker) => Promise, ): Promise { - const w = await this.getPyodideInstance(dependencies, log, maximumInstances, waitTimeoutMs) + const w = await this.getPyodideInstance(dependencies, log, maximumInstances, pyodideWorkerWaitTimeoutSec) try { return await fn(w) } finally { @@ -52,6 +51,76 @@ class PyodideAccess { private nextWorkerId = 1 private creatingCount = 0 + // after code is run, this releases the worker again to the pool for other codes to run + private releasePyodideInstance(workerId: number): void { + const worker = this.pyodideInstances[workerId] + + // clear interrupt buffer in case it was used + worker.pyodideInterruptBuffer[0] = 0 + + // if sb is waiting, take the first worker from queue & keep status as "inUse" + const waiter = this.waitQueue.shift() + if (waiter) { + clearTimeout(waiter.timer) + worker.inUse = true + waiter.resolve(worker) + } else { + worker.inUse = false + } + } + + // main logic of getting a pyodide instance. Will re-use if possible, otherwise create (up to limit) + private async getPyodideInstance( + dependencies: string[], + log: (level: LoggingLevel, data: string) => void, + maximumInstances: number, + pyodideWorkerWaitTimeoutSec: number, + ): Promise { + // 1) if possible, take a free - already inititalised - worker + const free = this.tryAcquireFree() + if (free) return free + + // 2) if none is free, check that we are not over capacity already + const currentCount = Object.keys(this.pyodideInstances).length + if (currentCount + this.creatingCount < maximumInstances) { + this.creatingCount++ + try { + const id = this.nextWorkerId++ + const worker = await this.createPyodideWorker(id, dependencies, log) + + // cool, created a new one so let's use that one + worker.inUse = true + this.pyodideInstances[id] = worker + return worker + } catch (err) { + // Need to make sure the creation gets reduced again, so simply re-throwing + throw err + } finally { + this.creatingCount-- + } + } + + // 3) we have the maximum worker, poll periodically until timeout for a free one + return await new Promise((resolve, reject) => { + const start = Date.now() + const poll = () => { + const free = this.tryAcquireFree() + if (free) { + clearTimeout(timer) + resolve(free) + return + } + if (Date.now() - start >= pyodideWorkerWaitTimeoutSec * 1000) { + reject(new Error('Timeout: no free Pyodide worker')) + return + } + timer = setTimeout(poll, 1000) + } + let timer = setTimeout(poll, 1000) + this.waitQueue.push({ resolve, reject, timer }) + }) + } + private waitQueue: { resolve: (w: PyodideWorker) => void reject: (e: unknown) => void @@ -68,32 +137,23 @@ class PyodideAccess { return undefined } + // this creates the pyodide worker from scratch private async createPyodideWorker( id: number, dependencies: string[], log: (level: LoggingLevel, data: string) => void, ): Promise { - // if (this.pyodide && this.preparePyEnv) { - // pyodide = this.pyodide - // preparePyEnv = this.preparePyEnv - // sys = pyodide.pyimport('sys') - // } else { - // if (!this.prepPromise) { - // this.prepPromise = this.prepEnv(dependencies, log) - // } - // // TODO is this safe if the promise has already been accessed? it seems to work fine - // const prep = await this.prepPromise - // pyodide = prep.pyodide - // preparePyEnv = prep.preparePyEnv - // sys = prep.sys - // prepareStatus = prep.prepareStatus - // } - const prepPromise = this.prepEnv(dependencies, log) const prep = await prepPromise + + // setup the interrupt buffer to be able to cancel the task + let interruptBuffer = new Uint8Array(new SharedArrayBuffer(1)) + prep.pyodide.setInterruptBuffer(interruptBuffer) + return { id, pyodide: prep.pyodide, + pyodideInterruptBuffer: interruptBuffer, sys: prep.sys, prepareStatus: prep.prepareStatus, preparePyEnv: prep.preparePyEnv, @@ -102,6 +162,7 @@ class PyodideAccess { } } + // load pyodide and install dependencies private async prepEnv( dependencies: string[], log: (level: LoggingLevel, data: string) => void, @@ -154,63 +215,6 @@ class PyodideAccess { output, } } - - private releasePyodideInstance(workerId: number): void { - const worker = this.pyodideInstances[workerId] - if (!worker) return - - // if sb is waiting, take the first worker from queue & keep status as "inUse" - const waiter = this.waitQueue.shift() - if (waiter) { - clearTimeout(waiter.timer) - worker.inUse = true - waiter.resolve(worker) - } else { - worker.inUse = false - } - } - - private async getPyodideInstance( - dependencies: string[], - log: (level: LoggingLevel, data: string) => void, - maximumInstances: number, - waitTimeoutMs: number, - ): Promise { - // 1) if possible, take a free - already inititalised - worker - const free = this.tryAcquireFree() - if (free) return free - - // 2) if none is free, check that we are not over capacity already - const currentCount = Object.keys(this.pyodideInstances).length - if (currentCount + this.creatingCount < maximumInstances) { - this.creatingCount++ - try { - const id = this.nextWorkerId++ - const worker = await this.createPyodideWorker(id, dependencies, log) - - // cool, created a new one so let's use that one - worker.inUse = true - this.pyodideInstances[id] = worker - return worker - } catch (err) { - // Need to make sure the creation gets reduced again, so simply re-throwing - throw err - } finally { - this.creatingCount-- - } - } - - // 3) we have the maximum worker, wait until timeout until some is free - return await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - const idx = this.waitQueue.findIndex((q) => q.resolve === resolve) - if (idx >= 0) this.waitQueue.splice(idx, 1) - reject(new Error('Timeout: no free Pyodide worker')) - }, waitTimeoutMs) - - this.waitQueue.push({ resolve, reject, timer }) - }) - } } export class RunCode { @@ -223,78 +227,98 @@ export class RunCode { globals?: Record, alwaysReturnJson: boolean = false, enableFileOutputs: boolean = false, - pyodideMaximumInstances: number = 5, - pyodideWaitTimeoutMs: number = 60_000, + pyodideMaxWorkers: number = 10, + pyodideCodeRunTimeoutSec: number = 60, + pyodideWorkerWaitTimeoutSec: number = 60, ): Promise { // get a pyodide instance for this job - return await this.pyodideAccess.withPyodideInstance( - dependencies, - log, - pyodideMaximumInstances, - pyodideWaitTimeoutMs, - async (pyodideWorker) => { - if (pyodideWorker.prepareStatus && pyodideWorker.prepareStatus.kind == 'error') { - return { - status: 'install-error', - output: this.takeOutput(pyodideWorker), - error: pyodideWorker.prepareStatus.message, - } - } else if (file) { - try { - // defaults in case file output is not enabled - let folderPath = '' - let files: Resource[] = [] - - if (enableFileOutputs) { - // make the temp file system for pyodide to use - const folderName = randomBytes(20).toString('hex').slice(0, 20) - folderPath = `./output_files/${folderName}` - await Deno.mkdir(folderPath, { recursive: true }) - pyodideWorker.pyodide.mountNodeFS('/output_files', folderPath) - } - - // run the code with pyodide - const rawValue = await pyodideWorker.pyodide.runPythonAsync(file.content, { - globals: pyodideWorker.pyodide.toPy({ ...(globals || {}), __name__: '__main__' }), - filename: file.name, - }) - - if (enableFileOutputs) { - // check files that got saved - files = await this.readAndDeleteFiles(folderPath) - pyodideWorker.pyodide.FS.unmount('/output_files') - } - - // label the worker as free again - pyodideWorker.inUse = false - + try { + return await this.pyodideAccess.withPyodideInstance( + dependencies, + log, + pyodideMaxWorkers, + pyodideWorkerWaitTimeoutSec, + async (pyodideWorker) => { + if (pyodideWorker.prepareStatus && pyodideWorker.prepareStatus.kind == 'error') { return { - status: 'success', + status: 'install-error', output: this.takeOutput(pyodideWorker), - returnValueJson: pyodideWorker.preparePyEnv.dump_json(rawValue, alwaysReturnJson), - embeddedResources: files, + error: pyodideWorker.prepareStatus.message, + } + } else if (file) { + try { + // defaults in case file output is not enabled + let folderPath = '' + let files: Resource[] = [] + + if (enableFileOutputs) { + // make the temp file system for pyodide to use + const folderName = randomBytes(20).toString('hex').slice(0, 20) + folderPath = `./output_files/${folderName}` + await Deno.mkdir(folderPath, { recursive: true }) + pyodideWorker.pyodide.mountNodeFS('/output_files', folderPath) + } + + // run the code with pyodide including a timeout + let timeoutId: any + const rawValue = await Promise.race([ + pyodideWorker.pyodide.runPythonAsync(file.content, { + globals: pyodideWorker.pyodide.toPy({ ...(globals || {}), __name__: '__main__' }), + filename: file.name, + }), + new Promise((_, reject) => { + timeoutId = setTimeout(() => { + // after the time passes signal SIGINT to stop execution + // 2 stands for SIGINT + pyodideWorker.pyodideInterruptBuffer[0] = 2 + reject(new Error(`Timeout exceeded for python execution (${pyodideCodeRunTimeoutSec} sec)`)) + }, pyodideCodeRunTimeoutSec * 1000) + }), + ]) + clearTimeout(timeoutId) + + if (enableFileOutputs) { + // check files that got saved + files = await this.readAndDeleteFiles(folderPath) + pyodideWorker.pyodide.FS.unmount('/output_files') + } + + return { + status: 'success', + output: this.takeOutput(pyodideWorker), + returnValueJson: pyodideWorker.preparePyEnv.dump_json(rawValue, alwaysReturnJson), + embeddedResources: files, + } + } catch (err) { + try { + pyodideWorker.pyodide.FS.unmount('/output_files') + } catch (_) {} + + console.log(err) + return { + status: 'run-error', + output: this.takeOutput(pyodideWorker), + error: formatError(err), + } } - } catch (err) { - pyodideWorker.pyodide.FS.unmount('/output_files') - pyodideWorker.inUse = false - console.log(err) + } else { return { - status: 'run-error', + status: 'success', output: this.takeOutput(pyodideWorker), - error: formatError(err), + returnValueJson: null, + embeddedResources: [], } } - } else { - pyodideWorker.inUse = false - return { - status: 'success', - output: this.takeOutput(pyodideWorker), - returnValueJson: null, - embeddedResources: [], - } - } - }, - ) + }, + ) + } catch (err) { + console.error(err) + return { + status: 'fatal-runtime-error', + output: [], + error: formatError(err), + } + } } async readAndDeleteFiles(folderPath: string): Promise { @@ -348,7 +372,7 @@ interface RunSuccess { } interface RunError { - status: 'install-error' | 'run-error' + status: 'install-error' | 'run-error' | 'fatal-runtime-error' output: string[] error: string } diff --git a/mcp_run_python/main.py b/mcp_run_python/main.py index f8174f2..98ad3cb 100644 --- a/mcp_run_python/main.py +++ b/mcp_run_python/main.py @@ -27,6 +27,9 @@ def run_mcp_server( deps_log_handler: LogHandler | None = None, allow_networking: bool = True, enable_file_outputs: bool = False, + pyodide_max_workers: int = 10, + pyodide_worker_wait_timeout_sec: int = 60, + pyodide_code_run_timeout_sec: int = 60, ) -> int: """Install dependencies then run the mcp-run-python server. @@ -38,6 +41,9 @@ def run_mcp_server( deps_log_handler: Optional function to receive logs emitted while installing dependencies. allow_networking: Whether to allow networking when running provided python code. enable_file_outputs: Whether to enable output files + pyodide_max_workers: How many pyodide workers to max use at the same time + pyodide_code_run_timeout_sec: How long to wait for pyodide code to run in seconds. + pyodide_worker_wait_timeout_sec: How long to wait for a free pyodide worker in seconds. """ with prepare_deno_env( mode, @@ -47,7 +53,11 @@ def run_mcp_server( deps_log_handler=deps_log_handler, allow_networking=allow_networking, enable_file_outputs=enable_file_outputs, + pyodide_max_workers=pyodide_max_workers, + pyodide_worker_wait_timeout_sec=pyodide_worker_wait_timeout_sec, + pyodide_code_run_timeout_sec=pyodide_code_run_timeout_sec, ) as env: + logger.info(env) logger.info(f'Running with file output support {"enabled" if enable_file_outputs else "disabled"}.') if mode == 'streamable_http': logger.info('Running mcp-run-python via %s on port %d...', mode, http_port) @@ -79,6 +89,9 @@ def prepare_deno_env( deps_log_handler: LogHandler | None = None, allow_networking: bool = True, enable_file_outputs: bool = False, + pyodide_max_workers: int = 10, + pyodide_worker_wait_timeout_sec: int = 60, + pyodide_code_run_timeout_sec: int = 60, ) -> Iterator[DenoEnv]: """Prepare the deno environment for running the mcp-run-python server with Deno. @@ -95,6 +108,9 @@ def prepare_deno_env( allow_networking: Whether the prepared DenoEnv should allow networking when running code. Note that we always allow networking during environment initialization to install dependencies. enable_file_outputs: Whether to enable output files + pyodide_max_workers: How many pyodide workers to max use at the same time + pyodide_code_run_timeout_sec: How long to wait for pyodide code to run in seconds. + pyodide_worker_wait_timeout_sec: How long to wait for a free pyodide worker in seconds. Returns: Yields the deno environment details. @@ -129,6 +145,9 @@ def prepare_deno_env( return_mode=return_mode, allow_networking=allow_networking, enable_file_outputs=enable_file_outputs, + pyodide_max_workers=pyodide_max_workers, + pyodide_worker_wait_timeout_sec=pyodide_worker_wait_timeout_sec, + pyodide_code_run_timeout_sec=pyodide_code_run_timeout_sec, ) yield DenoEnv(cwd, args) @@ -146,6 +165,9 @@ async def async_prepare_deno_env( deps_log_handler: LogHandler | None = None, allow_networking: bool = True, enable_file_outputs: bool = False, + pyodide_max_workers: int = 10, + pyodide_worker_wait_timeout_sec: int = 60, + pyodide_code_run_timeout_sec: int = 60, ) -> AsyncIterator[DenoEnv]: """Async variant of `prepare_deno_env`.""" ct = await _asyncify( @@ -157,6 +179,9 @@ async def async_prepare_deno_env( deps_log_handler=deps_log_handler, allow_networking=allow_networking, enable_file_outputs=enable_file_outputs, + pyodide_max_workers=pyodide_max_workers, + pyodide_worker_wait_timeout_sec=pyodide_worker_wait_timeout_sec, + pyodide_code_run_timeout_sec=pyodide_code_run_timeout_sec, ) try: yield await _asyncify(ct.__enter__) @@ -187,6 +212,9 @@ def _deno_run_args( return_mode: Literal['json', 'xml'] = 'xml', allow_networking: bool = True, enable_file_outputs: bool = False, + pyodide_max_workers: int = 10, + pyodide_worker_wait_timeout_sec: int = 60, + pyodide_code_run_timeout_sec: int = 60, ) -> list[str]: args = ['run'] if allow_networking: @@ -198,6 +226,9 @@ def _deno_run_args( 'src/main.ts', mode, f'--return-mode={return_mode}', + f'--pyodide-max-workers={pyodide_max_workers}', + f'--pyodide-worker-wait-timeout-sec={pyodide_worker_wait_timeout_sec}', + f'--pyodide-code-run-timeout-sec={pyodide_code_run_timeout_sec}', ] if enable_file_outputs: args += ['--enable-file-outputs'] diff --git a/tests/test_mcp_servers.py b/tests/test_mcp_servers.py index b18368c..54484e7 100644 --- a/tests/test_mcp_servers.py +++ b/tests/test_mcp_servers.py @@ -290,10 +290,6 @@ async def test_run_parallel_python_code( enable_file_outputs: bool, ) -> None: code_list = [ - """ - x=11 - x - """, """ import time time.sleep(5) @@ -307,7 +303,8 @@ async def test_run_parallel_python_code( x """, ] - # Run this a couple times in parallel + # Run this a couple times (10) in parallel + # As we have 10 pyodide workers by default, this should finish in over 5, but under 10s (first initialisation takes a bit) code_list = code_list * 5 async with run_mcp_session([], enable_file_outputs) as mcp_session: @@ -400,13 +397,41 @@ async def test_run_parallel_python_code_with_files( run_ids.add(content.resource.name.split('_')[0]) # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType, reportAttributeAccessIssue] assert content.resource.blob == 'aGk=' # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue] case types.TextContent(): - assert ( - content.text.strip() - == """success - - 11 - """.strip() - ) + assert content.text.strip() == 'success' case _: raise AssertionError('Unexpected content type') assert len(run_ids) == 1 + + +async def test_run_python_code_timeout( + run_mcp_session: Callable[[list[str], bool], AbstractAsyncContextManager[ClientSession]], +) -> None: + """Check that the timeout of the run command works (60s)""" + code = """ + import time + time.sleep(90) + """ + + async with run_mcp_session([], True) as mcp_session: + await mcp_session.initialize() + + start = time.perf_counter() + + result = await mcp_session.call_tool('run_python_code', {'python_code': code}) + + # check parallelism + end = time.perf_counter() + run_time = end - start + assert run_time > 60 + assert run_time < 65 + + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, types.TextContent) + assert ( + content.text.strip() + == """run-error + + Error: Timeout exceeded for python execution (60 sec) + """.strip() + ) From 4c2a745814a0a125c6788cbdea9e2e70353c8d21 Mon Sep 17 00:00:00 2001 From: "daniel.jaekel" Date: Fri, 7 Nov 2025 17:03:39 +0100 Subject: [PATCH 13/16] chore: CI --- README.md | 10 +++++++++- mcp_run_python/code_sandbox.py | 6 ++++++ mcp_run_python/deno/src/main.ts | 3 ++- mcp_run_python/deno/src/runCode.ts | 22 +++++++++++++--------- mcp_run_python/main.py | 3 +-- tests/test_mcp_servers.py | 10 +++++----- 6 files changed, 36 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 279bb2f..72d9d72 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,8 @@ the rest of the operating system. - **Complete Results**: Captures standard output, standard error, and return values - **Asynchronous Support**: Runs async code properly - **Error Handling**: Provides detailed error reports for debugging -- **File Output**: Can output and return files & images. Useful for things like generating graphs. +- **Timeouts**: Supports execution time limits to prevent long-running code. Default is 60s, can be customised via CLI +- **File Output**: Can output and return files & images. Useful for things like generating graphs. - **Important:** Disabled by default for backwards compatibility! _(This code was previously part of [Pydantic AI](https://github.com/pydantic/pydantic-ai) but was moved to a separate repo to make it easier to maintain.)_ @@ -51,6 +52,13 @@ where: - `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` +--- + +For all available options, +```bash +uvx mcp-run-python --help +``` + ## Usage with Pydantic AI Then you can use `mcp-run-python` with Pydantic AI: diff --git a/mcp_run_python/code_sandbox.py b/mcp_run_python/code_sandbox.py index 5a1bd91..19a7d89 100644 --- a/mcp_run_python/code_sandbox.py +++ b/mcp_run_python/code_sandbox.py @@ -58,6 +58,8 @@ async def code_sandbox( allow_networking: bool = True, enable_file_outputs: bool = False, pyodide_max_workers: int = 10, + pyodide_worker_wait_timeout_sec: int = 60, + pyodide_code_run_timeout_sec: int = 60, ) -> AsyncIterator['CodeSandbox']: """Create a secure sandbox. @@ -68,6 +70,8 @@ async def code_sandbox( allow_networking: Whether to allow networking or not while executing python code. enable_file_outputs: Whether to enable output files pyodide_max_workers: How many pyodide workers to max use at the same time + pyodide_worker_wait_timeout_sec: How long to wait for a free pyodide worker in seconds. + pyodide_code_run_timeout_sec: How long to wait for pyodide code to run in seconds. """ async with async_prepare_deno_env( 'stdio', @@ -77,6 +81,8 @@ async def code_sandbox( allow_networking=allow_networking, enable_file_outputs=enable_file_outputs, pyodide_max_workers=pyodide_max_workers, + pyodide_code_run_timeout_sec=pyodide_code_run_timeout_sec, + pyodide_worker_wait_timeout_sec=pyodide_worker_wait_timeout_sec, ) as deno_env: server_params = StdioServerParameters(command='deno', args=deno_env.args, cwd=deno_env.cwd) diff --git a/mcp_run_python/deno/src/main.ts b/mcp_run_python/deno/src/main.ts index f96ae14..5b49031 100644 --- a/mcp_run_python/deno/src/main.ts +++ b/mcp_run_python/deno/src/main.ts @@ -38,7 +38,8 @@ export async function main() { 'pyodide-worker-wait-timeout-sec': '60', }, }) - console.error(flags) + + console.debug(flags) const deps = flags.deps?.split(',') ?? [] if (args.length >= 1) { if (args[0] === 'stdio') { diff --git a/mcp_run_python/deno/src/runCode.ts b/mcp_run_python/deno/src/runCode.ts index a981d60..8d2bc13 100644 --- a/mcp_run_python/deno/src/runCode.ts +++ b/mcp_run_python/deno/src/runCode.ts @@ -26,7 +26,7 @@ interface PyodideWorker extends PrepResult { } /* - * Class that instanciates pyodide and keeps multiple instances + * Class that instantiates pyodide and keeps multiple instances * There need to be multiple instances, as file system mounting to a standard directory (which is needed for the LLM), cannot be easily done without mixing files * Now, every process has their own file system & they get looped around */ @@ -54,9 +54,13 @@ class PyodideAccess { // after code is run, this releases the worker again to the pool for other codes to run private releasePyodideInstance(workerId: number): void { const worker = this.pyodideInstances[workerId] + if (!worker) { + throw new Error(`Trying to release unknown pyodide worker ${workerId}`) + } - // clear interrupt buffer in case it was used + // clear interrupt buffer in case it was used & clear output worker.pyodideInterruptBuffer[0] = 0 + worker.output.length = 0 // if sb is waiting, take the first worker from queue & keep status as "inUse" const waiter = this.waitQueue.shift() @@ -69,14 +73,14 @@ class PyodideAccess { } } - // main logic of getting a pyodide instance. Will re-use if possible, otherwise create (up to limit) + // main logic of getting a pyodide instance. Will reuse if possible, otherwise create (up to limit) private async getPyodideInstance( dependencies: string[], log: (level: LoggingLevel, data: string) => void, maximumInstances: number, pyodideWorkerWaitTimeoutSec: number, ): Promise { - // 1) if possible, take a free - already inititalised - worker + // 1) if possible, take a free - already initialised - worker const free = this.tryAcquireFree() if (free) return free @@ -147,7 +151,7 @@ class PyodideAccess { const prep = await prepPromise // setup the interrupt buffer to be able to cancel the task - let interruptBuffer = new Uint8Array(new SharedArrayBuffer(1)) + const interruptBuffer = new Uint8Array(new SharedArrayBuffer(1)) prep.pyodide.setInterruptBuffer(interruptBuffer) return { @@ -292,7 +296,9 @@ export class RunCode { } catch (err) { try { pyodideWorker.pyodide.FS.unmount('/output_files') - } catch (_) {} + } catch (_) { + // we need to make sure unmount is attempted, but ignore errors here + } console.log(err) return { @@ -351,9 +357,7 @@ export class RunCode { private takeOutput(pyodideWorker: PyodideWorker): string[] { pyodideWorker.sys.stdout.flush() pyodideWorker.sys.stderr.flush() - const output = pyodideWorker.output - pyodideWorker.output = [] - return output + return [...pyodideWorker.output] } } diff --git a/mcp_run_python/main.py b/mcp_run_python/main.py index 98ad3cb..9fb5440 100644 --- a/mcp_run_python/main.py +++ b/mcp_run_python/main.py @@ -57,10 +57,9 @@ def run_mcp_server( pyodide_worker_wait_timeout_sec=pyodide_worker_wait_timeout_sec, pyodide_code_run_timeout_sec=pyodide_code_run_timeout_sec, ) as env: - logger.info(env) logger.info(f'Running with file output support {"enabled" if enable_file_outputs else "disabled"}.') if mode == 'streamable_http': - logger.info('Running mcp-run-python via %s on port %d...', mode, http_port) + logger.info('Running mcp-run-python via %s on port %d...', mode, str(http_port)) else: logger.info('Running mcp-run-python via %s...', mode) diff --git a/tests/test_mcp_servers.py b/tests/test_mcp_servers.py index 54484e7..842793c 100644 --- a/tests/test_mcp_servers.py +++ b/tests/test_mcp_servers.py @@ -304,7 +304,7 @@ async def test_run_parallel_python_code( """, ] # Run this a couple times (10) in parallel - # As we have 10 pyodide workers by default, this should finish in over 5, but under 10s (first initialisation takes a bit) + # As we have 10 pyodide workers by default, this should finish in over 5, but under 20s (first initialisation takes a bit) code_list = code_list * 5 async with run_mcp_session([], enable_file_outputs) as mcp_session: @@ -323,7 +323,7 @@ async def test_run_parallel_python_code( # check parallelism end = time.perf_counter() run_time = end - start - assert run_time < 10 + assert run_time < 20 assert run_time > 5 # check that all outputs are fine too @@ -431,7 +431,7 @@ async def test_run_python_code_timeout( assert ( content.text.strip() == """run-error - - Error: Timeout exceeded for python execution (60 sec) - """.strip() + +Error: Timeout exceeded for python execution (60 sec) +""".strip() ) From 87d37a537f385c0f4d927c2d0be3042f7c643040 Mon Sep 17 00:00:00 2001 From: "daniel.jaekel" Date: Sat, 8 Nov 2025 10:38:08 +0100 Subject: [PATCH 14/16] chore: minor test fixes --- tests/test_mcp_servers.py | 65 ++++++++++++++++++++++++++++----------- 1 file changed, 47 insertions(+), 18 deletions(-) diff --git a/tests/test_mcp_servers.py b/tests/test_mcp_servers.py index 842793c..69e52ae 100644 --- a/tests/test_mcp_servers.py +++ b/tests/test_mcp_servers.py @@ -285,27 +285,56 @@ def logging_callback(level: str, message: str) -> None: @pytest.mark.parametrize('enable_file_outputs', [pytest.param(True), pytest.param(False)]) +@pytest.mark.parametrize( + 'code_list,multiplicator,max_time_needed', + [ + pytest.param( + [ + """ + import time + time.sleep(5) + x=11 + x + """, + """ + import asyncio + await asyncio.sleep(5) + x=11 + x + """, + ], + 10, + 30, + ), + pytest.param( + [ + """ + x=11 + x + """, + ], + 500, + 20, + ), + ], +) async def test_run_parallel_python_code( run_mcp_session: Callable[[list[str], bool], AbstractAsyncContextManager[ClientSession]], enable_file_outputs: bool, + code_list: list[str], + multiplicator: int, + max_time_needed: int, ) -> None: - code_list = [ - """ - import time - time.sleep(5) - x=11 - x - """, - """ - import asyncio - await asyncio.sleep(5) - x=11 - x - """, - ] # Run this a couple times (10) in parallel - # As we have 10 pyodide workers by default, this should finish in over 5, but under 20s (first initialisation takes a bit) - code_list = code_list * 5 + # As we have 10 pyodide workers by default, this should finish in under the needed time if you add the tasks itself (first initialisation takes a bit - especially for 10 workers) + code_list = code_list * multiplicator + + concurrency_limiter = asyncio.Semaphore(50) + + async def run_wrapper(code: str): + # limit concurrency to avoid overwhelming the server with 500 tasks at once :D + async with concurrency_limiter: + return await mcp_session.call_tool('run_python_code', {'python_code': code}) async with run_mcp_session([], enable_file_outputs) as mcp_session: await mcp_session.initialize() @@ -316,14 +345,14 @@ async def test_run_parallel_python_code( async with asyncio.TaskGroup() as tg: # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue, reportUnknownVariableType] for code in code_list: task: asyncio.Task[types.CallToolResult] = tg.create_task( # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] - mcp_session.call_tool('run_python_code', {'python_code': code}) + run_wrapper(code) ) tasks.add(task) # pyright: ignore[reportUnknownArgumentType] # check parallelism end = time.perf_counter() run_time = end - start - assert run_time < 20 + assert run_time < max_time_needed assert run_time > 5 # check that all outputs are fine too From fff626a2aa5daa70823482f67175b879feda3d28 Mon Sep 17 00:00:00 2001 From: "daniel.jaekel" Date: Sat, 8 Nov 2025 11:07:06 +0100 Subject: [PATCH 15/16] chore: pytest 3.10 compatibility --- tests/test_mcp_servers.py | 38 ++++++++++++++++---------------------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/tests/test_mcp_servers.py b/tests/test_mcp_servers.py index 69e52ae..aaf70ac 100644 --- a/tests/test_mcp_servers.py +++ b/tests/test_mcp_servers.py @@ -6,7 +6,7 @@ import time from collections.abc import AsyncIterator, Callable from contextlib import AbstractAsyncContextManager, asynccontextmanager -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import pytest from httpx import AsyncClient, HTTPError @@ -314,7 +314,7 @@ def logging_callback(level: str, message: str) -> None: """, ], 500, - 20, + 30, ), ], ) @@ -341,13 +341,12 @@ async def run_wrapper(code: str): start = time.perf_counter() - tasks: set[asyncio.Task[types.CallToolResult]] = set() - async with asyncio.TaskGroup() as tg: # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue, reportUnknownVariableType] - for code in code_list: - task: asyncio.Task[types.CallToolResult] = tg.create_task( # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] - run_wrapper(code) - ) - tasks.add(task) # pyright: ignore[reportUnknownArgumentType] + tasks: set[Any] = set() + for code in code_list: + tasks.add(run_wrapper(code)) + + # await the tasks + results: list[types.CallToolResult] = await asyncio.gather(*tasks) # check parallelism end = time.perf_counter() @@ -356,9 +355,7 @@ async def run_wrapper(code: str): assert run_time > 5 # check that all outputs are fine too - for task in tasks: - result = task.result() - + for result in results: assert len(result.content) == 1 content = result.content[0] @@ -398,13 +395,12 @@ async def test_run_parallel_python_code_with_files( start = time.perf_counter() - tasks: set[asyncio.Task[types.CallToolResult]] = set() - async with asyncio.TaskGroup() as tg: # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue, reportUnknownVariableType] - for code in code_list: - task: asyncio.Task[types.CallToolResult] = tg.create_task( # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] - mcp_session.call_tool('run_python_code', {'python_code': code}) - ) - tasks.add(task) # pyright: ignore[reportUnknownArgumentType] + tasks: set[Any] = set() + for code in code_list: + tasks.add(mcp_session.call_tool('run_python_code', {'python_code': code})) + + # await the tasks + results: list[types.CallToolResult] = await asyncio.gather(*tasks) # check parallelism end = time.perf_counter() @@ -413,9 +409,7 @@ async def test_run_parallel_python_code_with_files( assert run_time > 5 # check that all outputs are fine too - for task in tasks: - result = task.result() - + for result in results: assert len(result.content) == 6 run_ids: set[str] = set() From 6acf9ac8e3829c02e37ab1095bc4bd4058979a1d Mon Sep 17 00:00:00 2001 From: "daniel.jaekel" Date: Sat, 8 Nov 2025 11:13:17 +0100 Subject: [PATCH 16/16] chore: pytest 3.10 compatibility v2 --- tests/test_mcp_servers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_mcp_servers.py b/tests/test_mcp_servers.py index aaf70ac..46c60e5 100644 --- a/tests/test_mcp_servers.py +++ b/tests/test_mcp_servers.py @@ -304,7 +304,7 @@ def logging_callback(level: str, message: str) -> None: """, ], 10, - 30, + 40, ), pytest.param( [ @@ -314,7 +314,7 @@ def logging_callback(level: str, message: str) -> None: """, ], 500, - 30, + 40, ), ], )