Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ 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
- **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.)_

Expand All @@ -34,7 +37,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:
Expand All @@ -49,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:
Expand Down Expand Up @@ -105,7 +115,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:
Expand Down Expand Up @@ -165,3 +175,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.
2 changes: 1 addition & 1 deletion examples/direct.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
23 changes: 23 additions & 0 deletions mcp_run_python/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,25 @@ 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(
'--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',
Expand All @@ -53,6 +72,10 @@ 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,
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:
Expand Down
12 changes: 12 additions & 0 deletions mcp_run_python/code_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ async def code_sandbox(
dependencies: list[str] | None = None,
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['CodeSandbox']:
"""Create a secure sandbox.

Expand All @@ -64,13 +68,21 @@ 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
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',
dependencies=dependencies,
deps_log_handler=log_handler,
return_mode='json',
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)

Expand Down
2 changes: 2 additions & 0 deletions mcp_run_python/deno/deno.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
18 changes: 18 additions & 0 deletions mcp_run_python/deno/deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

124 changes: 109 additions & 15 deletions mcp_run_python/deno/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,49 @@ 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',
'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,
'pyodide-max-workers': '10',
'pyodide-code-run-timeout-sec': '60',
'pyodide-worker-wait-timeout-sec': '60',
},
})

console.debug(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'],
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'])
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)
Expand All @@ -57,7 +89,14 @@ 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,
pyodideMaxWorkers: number,
pyodideCodeRunTimeoutSec: number,
pyodideWorkerWaitTimeoutSec: number,
): McpServer {
const runCode = new RunCode()
const server = new McpServer(
{
Expand All @@ -72,12 +111,17 @@ 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.

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.
### 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) {
toolDescription += '- To output files or images, save them in the "/output_files" folder.\n'
}

let setLogLevel: LoggingLevel = 'emergency'

Expand Down Expand Up @@ -110,11 +154,31 @@ The code will be executed with Python 3.13.
{ name: 'main.py', content: python_code },
global_variables,
returnMode !== 'xml',
enableFileOutputs,
pyodideMaxWorkers,
pyodideCodeRunTimeoutSec,
pyodideWorkerWaitTimeoutSec,
)
await Promise.all(logPromises)
return {
content: [{ type: 'text', text: returnMode === 'xml' ? asXml(result) : asJson(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
Expand Down Expand Up @@ -167,14 +231,30 @@ 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,
pyodideMaxWorkers: number,
pyodideCodeRunTimeoutSec: number,
pyodideWorkerWaitTimeoutSec: number,
) {
// https://github.com/modelcontextprotocol/typescript-sdk?tab=readme-ov-file#with-session-management
const mcpServer = createServer(deps, returnMode)
const mcpServer = createServer(
deps,
returnMode,
enableFileOutputs,
pyodideMaxWorkers,
pyodideCodeRunTimeoutSec,
pyodideWorkerWaitTimeoutSec,
)
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}

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
Expand Down Expand Up @@ -252,8 +332,22 @@ 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,
pyodideMaxWorkers: number,
pyodideCodeRunTimeoutSec: number,
pyodideWorkerWaitTimeoutSec: number,
) {
const mcpServer = createServer(
deps,
returnMode,
enableFileOutputs,
pyodideMaxWorkers,
pyodideCodeRunTimeoutSec,
pyodideWorkerWaitTimeoutSec,
)
const transport = new StdioServerTransport()
await mcpServer.connect(transport)
}
Expand Down
Loading
Loading