Skip to content

Commit 4096f0d

Browse files
authored
Sandbox (#2)
1 parent b017833 commit 4096f0d

File tree

15 files changed

+508
-119
lines changed

15 files changed

+508
-119
lines changed

README.md

Lines changed: 29 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ where:
4747
- `example` will run a minimal Python script using `numpy`, useful for checking that the package is working, for the code
4848
to run successfully, you'll need to install `numpy` using `uvx mcp-run-python --deps numpy example`
4949

50-
## Usage in codes
50+
## Usage in codes as an MCP server
5151

5252
```bash
5353
pip install mcp-run-python
@@ -87,14 +87,40 @@ if __name__ == '__main__':
8787
As well as returning the args needed to run `mcp_run_python`, `deno_args_prepare` installs the dependencies
8888
so they can be used by the server.
8989

90+
## Usage in code with `code_sandbox`
91+
92+
`mcp-run-python` includes a helper function `code_sandbox` to allow you to easily run code in a sandbox.
93+
94+
```py
95+
from mcp_run_python import code_sandbox
96+
97+
code = """
98+
import numpy
99+
a = numpy.array([1, 2, 3])
100+
print(a)
101+
a
102+
"""
103+
104+
async def main():
105+
async with code_sandbox(dependencies=['numpy']) as sandbox:
106+
result = await sandbox.eval(code)
107+
print(result)
108+
109+
110+
if __name__ == '__main__':
111+
import asyncio
112+
113+
asyncio.run(main())
114+
```
115+
116+
Under the hood, `code_sandbox` runs an MCP server using `stdio`. You can run multiple code blocks with a single sandbox.
117+
90118
## Logging
91119

92120
MCP Run Python supports emitting stdout and stderr from the python execution as [MCP logging messages](https://github.com/modelcontextprotocol/specification/blob/eb4abdf2bb91e0d5afd94510741eadd416982350/docs/specification/draft/server/utilities/logging.md?plain=1).
93121

94122
For logs to be emitted you must set the logging level when connecting to the server. By default, the log level is set to the highest level, `emergency`.
95123

96-
Currently, it's not possible to demonstrate this due to a bug in the Python MCP Client, see [modelcontextprotocol/python-sdk#201](https://github.com/modelcontextprotocol/python-sdk/issues/201#issuecomment-2727663121).
97-
98124
## Dependencies
99125

100126
`mcp_run_python` uses a two step process to install dependencies while avoiding any risk that sandboxed code can
@@ -104,54 +130,3 @@ edit the filesystem.
104130
* `deno` is then run with read-only permissions to the `node_modules` directory to run untrusted code.
105131

106132
Dependencies must be provided when initializing the server so they can be installed in the first step.
107-
108-
Here's an example of manually running code with `mcp-run-python`:
109-
110-
```python
111-
from mcp import ClientSession, StdioServerParameters
112-
from mcp.client.stdio import stdio_client
113-
114-
from mcp_run_python import deno_args_prepare
115-
116-
code = """
117-
import numpy
118-
a = numpy.array([1, 2, 3])
119-
print(a)
120-
a
121-
"""
122-
server_params = StdioServerParameters(
123-
command='deno',
124-
args=deno_args_prepare('stdio', deps=['numpy'])
125-
)
126-
127-
128-
async def main():
129-
async with stdio_client(server_params) as (read, write):
130-
async with ClientSession(read, write) as session:
131-
await session.initialize()
132-
tools = await session.list_tools()
133-
print(len(tools.tools))
134-
#> 1
135-
print(repr(tools.tools[0].name))
136-
#> 'run_python_code'
137-
print(repr(tools.tools[0].inputSchema))
138-
"""
139-
{'type': 'object', 'properties': {'python_code': {'type': 'string', 'description': 'Python code to run'}}, 'required': ['python_code'], 'additionalProperties': False, '$schema': 'http://json-schema.org/draft-07/schema#'}
140-
"""
141-
result = await session.call_tool('run_python_code', {'python_code': code})
142-
print(result.content[0].text)
143-
"""
144-
<status>success</status>
145-
<dependencies>["numpy"]</dependencies>
146-
<output>
147-
[1 2 3]
148-
</output>
149-
<return_value>
150-
[
151-
1,
152-
2,
153-
3
154-
]
155-
</return_value>
156-
"""
157-
```

examples/direct.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ async def main():
2121
print(repr(tools.tools[0].name))
2222
print(repr(tools.tools[0].inputSchema))
2323
result = await session.call_tool('run_python_code', {'python_code': code})
24-
print(result.content[0].text)
24+
content_block = result.content[0]
25+
assert content_block.type == 'text'
26+
print(content_block.text)
2527

2628

2729
if __name__ == '__main__':

examples/pydantic_ai_ex.py

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,8 @@
1-
# /// script
2-
# requires-python = ">=3.13"
3-
# dependencies = [
4-
# "logfire",
5-
# "mcp-run-python",
6-
# "pydantic-ai-slim[mcp,anthropic]",
7-
# ]
8-
#
9-
# [tool.uv.sources]
10-
# mcp-run-python = { path = "." }
11-
# ///
12-
import logfire
131
from pydantic_ai import Agent
142
from pydantic_ai.mcp import MCPServerStdio
153

164
from mcp_run_python import deno_args_prepare
175

18-
logfire.configure()
19-
logfire.instrument_mcp()
20-
logfire.instrument_pydantic_ai()
21-
226
server = MCPServerStdio('deno', args=deno_args_prepare('stdio'))
237
agent_with_python = Agent('claude-3-5-haiku-latest', toolsets=[server])
248

examples/sandbox.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from mcp_run_python import code_sandbox
2+
3+
4+
async def print_wait(level: str, message: str):
5+
await asyncio.sleep(1)
6+
print(f'[{level}] {message}')
7+
8+
9+
async def main():
10+
async with code_sandbox(dependencies=['numpy'], print_handler=print_wait) as sandbox:
11+
for i in range(10):
12+
result = await sandbox.eval(f'import numpy as np\na = np.array([1, 2, {i}])\nprint(a)\na')
13+
print(result)
14+
15+
16+
if __name__ == '__main__':
17+
import asyncio
18+
19+
asyncio.run(main())

mcp_run_python/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
from importlib.metadata import version as _metadata_version
44

5+
from .code_sandbox import code_sandbox
56
from .main import deno_args_prepare, deno_run_server
67

78
__version__ = _metadata_version('mcp_run_python')
8-
__all__ = '__version__', 'deno_args_prepare', 'deno_run_server'
9+
__all__ = '__version__', 'deno_args_prepare', 'deno_run_server', 'code_sandbox'

mcp_run_python/_cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,6 @@ def cli(args_list: Sequence[str] | None = None): # pragma: no cover
3030
print(f'mcp-run-python {__version__}')
3131
elif args.mode:
3232
deps: list[str] = args.deps.split(',') if args.deps else []
33-
deno_run_server(args.mode.replace('-', '_'), port=args.port, deps=deps, install_log_handler=print)
33+
deno_run_server(args.mode.replace('-', '_'), port=args.port, deps=deps, prep_log_handler=print)
3434
else:
3535
parser.error('Mode is required')

mcp_run_python/code_sandbox.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import inspect
2+
import json
3+
from collections.abc import AsyncIterator, Awaitable, Callable
4+
from contextlib import asynccontextmanager
5+
from dataclasses import dataclass
6+
from typing import Literal, TypeAlias, TypedDict
7+
8+
from mcp import ClientSession, StdioServerParameters, types as mcp_types
9+
from mcp.client.stdio import stdio_client
10+
11+
from .main import deno_args_prepare
12+
13+
JsonData: TypeAlias = 'str| bool | int | float | None | list[JsonData] | dict[str, JsonData]'
14+
15+
16+
class RunSuccess(TypedDict):
17+
status: Literal['success']
18+
output: list[str]
19+
returnValueJson: JsonData
20+
21+
22+
class RunError(TypedDict):
23+
status: Literal['install-error', 'run-error']
24+
output: list[str]
25+
error: str
26+
27+
28+
@dataclass
29+
class CodeSandbox:
30+
_session: ClientSession
31+
32+
async def eval(self, code: str) -> RunSuccess | RunError:
33+
result = await self._session.call_tool('run_python_code', {'python_code': code})
34+
content_block = result.content[0]
35+
if content_block.type == 'text':
36+
return json.loads(content_block.text)
37+
else:
38+
raise ValueError(f'Unexpected content type: {content_block.type}')
39+
40+
41+
@asynccontextmanager
42+
async def code_sandbox(
43+
*,
44+
dependencies: list[str] | None = None,
45+
print_handler: Callable[[mcp_types.LoggingLevel, str], None | Awaitable[None]] | None = None,
46+
logging_level: mcp_types.LoggingLevel | None = None,
47+
prep_log_handler: Callable[[str], None] | None = None,
48+
) -> AsyncIterator['CodeSandbox']:
49+
"""Run code in a secure sandbox.
50+
51+
Args:
52+
dependencies: A list of dependencies to be installed.
53+
print_handler: A callback function to handle print statements when code is running.
54+
logging_level: The logging level to use for the print handler, defaults to `info` if `print_handler` is provided.
55+
prep_log_handler: A callback function to run on log statements during initial install of dependencies.
56+
"""
57+
args = deno_args_prepare('stdio', deps=dependencies, prep_log_handler=prep_log_handler, return_mode='json')
58+
server_params = StdioServerParameters(command='deno', args=args)
59+
60+
logging_callback: Callable[[mcp_types.LoggingMessageNotificationParams], Awaitable[None]] | None = None
61+
62+
if print_handler:
63+
64+
async def logging_callback_(params: mcp_types.LoggingMessageNotificationParams) -> None:
65+
if inspect.iscoroutinefunction(print_handler):
66+
await print_handler(params.level, params.data)
67+
else:
68+
print_handler(params.level, params.data)
69+
70+
logging_callback = logging_callback_
71+
72+
async with stdio_client(server_params) as (read, write):
73+
async with ClientSession(read, write, logging_callback=logging_callback) as session:
74+
if print_handler:
75+
await session.set_logging_level(logging_level or 'info')
76+
yield CodeSandbox(session)

mcp_run_python/deno/main.ts

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,28 +11,25 @@ import { type LoggingLevel, SetLevelRequestSchema } from '@modelcontextprotocol/
1111
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
1212
import { z } from 'zod'
1313

14-
import { asXml, runCode } from './runCode.ts'
14+
import { asJson, asXml, runCode } from './runCode.ts'
1515
import { Buffer } from 'node:buffer'
1616

1717
const VERSION = '0.0.13'
1818

1919
export async function main() {
2020
const { args } = Deno
2121
const flags = parseArgs(Deno.args, {
22-
string: ['deps'],
22+
string: ['deps', 'return-mode', 'port'],
23+
default: { port: '3001', 'return-mode': 'xml' },
2324
})
2425
const deps = flags.deps?.split(',') ?? []
2526
if (args.length >= 1) {
2627
if (args[0] === 'stdio') {
27-
await runStdio(deps)
28+
await runStdio(deps, flags['return-mode'])
2829
return
2930
} else if (args[0] === 'streamable_http') {
30-
const flags = parseArgs(Deno.args, {
31-
string: ['port'],
32-
default: { port: '3001' },
33-
})
3431
const port = parseInt(flags.port)
35-
runStreamableHttp(port, deps)
32+
runStreamableHttp(port, deps, flags['return-mode'])
3633
return
3734
} else if (args[0] === 'example') {
3835
await example(deps)
@@ -49,16 +46,17 @@ Invalid arguments: ${args.join(' ')}
4946
Usage: deno ... deno/main.ts [stdio|streamable_http|install_deps|noop]
5047
5148
options:
52-
--port <port> Port to run the HTTP server on (default: 3001)
53-
--deps <deps> Comma separated list of dependencies to install`,
49+
--port <port> Port to run the HTTP server on (default: 3001)
50+
--deps <deps> Comma separated list of dependencies to install
51+
--return-mode <xml/json> Return mode for output data (default: xml)`,
5452
)
5553
Deno.exit(1)
5654
}
5755

5856
/*
5957
* Create an MCP server with the `run_python_code` tool registered.
6058
*/
61-
function createServer(deps: string[]): McpServer {
59+
function createServer(deps: string[], returnMode: string): McpServer {
6260
const server = new McpServer(
6361
{
6462
name: 'MCP Run Python',
@@ -103,7 +101,7 @@ The code will be executed with Python 3.12.
103101
)
104102
await Promise.all(logPromises)
105103
return {
106-
content: [{ type: 'text', text: asXml(result) }],
104+
content: [{ type: 'text', text: returnMode === 'xml' ? asXml(result) : asJson(result) }],
107105
}
108106
},
109107
)
@@ -158,9 +156,9 @@ function httpSetJsonResponse(res: http.ServerResponse, status: number, text: str
158156
/*
159157
* Run the MCP server using the Streamable HTTP transport
160158
*/
161-
function runStreamableHttp(port: number, deps: string[]) {
159+
function runStreamableHttp(port: number, deps: string[], returnMode: string) {
162160
// https://github.com/modelcontextprotocol/typescript-sdk?tab=readme-ov-file#with-session-management
163-
const mcpServer = createServer(deps)
161+
const mcpServer = createServer(deps, returnMode)
164162
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}
165163

166164
const server = http.createServer(async (req, res) => {
@@ -245,8 +243,8 @@ function runStreamableHttp(port: number, deps: string[]) {
245243
/*
246244
* Run the MCP server using the Stdio transport.
247245
*/
248-
async function runStdio(deps: string[]) {
249-
const mcpServer = createServer(deps)
246+
async function runStdio(deps: string[], returnMode: string) {
247+
const mcpServer = createServer(deps, returnMode)
250248
const transport = new StdioServerTransport()
251249
await mcpServer.connect(transport)
252250
}

mcp_run_python/deno/runCode.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/* eslint @typescript-eslint/no-explicit-any: off */
1+
// deno-lint-ignore-file no-explicit-any
22
import { loadPyodide } from 'pyodide'
33
import { preparePythonCode } from './prepareEnvCode.ts'
44
import type { LoggingLevel } from '@modelcontextprotocol/sdk/types.js'
@@ -15,7 +15,6 @@ export async function runCode(
1515
): Promise<RunSuccess | RunError> {
1616
// remove once we can upgrade to pyodide 0.27.7 and console.log is no longer used.
1717
const realConsoleLog = console.log
18-
// deno-lint-ignore no-explicit-any
1918
console.log = (...args: any[]) => log('debug', args.join(' '))
2019

2120
const output: string[] = []
@@ -130,6 +129,17 @@ export function asXml(runResult: RunSuccess | RunError): string {
130129
return xml.join('\n')
131130
}
132131

132+
export function asJson(runResult: RunSuccess | RunError): string {
133+
const { status, output } = runResult
134+
const json: Record<string, any> = { status, output }
135+
if (runResult.status == 'success') {
136+
json.return_value = JSON.parse(runResult.returnValueJson || 'null')
137+
} else {
138+
json.error = runResult.error
139+
}
140+
return JSON.stringify(json)
141+
}
142+
133143
function escapeClosing(closingTag: string): (str: string) => string {
134144
const regex = new RegExp(`</?\\s*${closingTag}(?:.*?>)?`, 'gi')
135145
const onMatch = (match: string) => {
@@ -138,7 +148,6 @@ function escapeClosing(closingTag: string): (str: string) => string {
138148
return (str) => str.replace(regex, onMatch)
139149
}
140150

141-
// deno-lint-ignore no-explicit-any
142151
function formatError(err: any): string {
143152
let errStr = err.toString()
144153
errStr = errStr.replace(/^PythonError: +/, '')
@@ -160,6 +169,5 @@ interface PrepareError {
160169
}
161170
interface PreparePyEnv {
162171
prepare_env: (files: CodeFile[]) => Promise<PrepareSuccess | PrepareError>
163-
// deno-lint-ignore no-explicit-any
164172
dump_json: (value: any) => string | null
165173
}

0 commit comments

Comments
 (0)