Skip to content

Commit b017833

Browse files
authored
Install dependencies separately (#1)
1 parent 96350fa commit b017833

File tree

16 files changed

+364
-270
lines changed

16 files changed

+364
-270
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ jobs:
5757
- run: make test
5858
env:
5959
COVERAGE_FILE: coverage/.coverage.py${{ matrix.python-version }}
60+
- run: uv run mcp-run-python --deps numpy example
6061
- name: store coverage files
6162
uses: actions/upload-artifact@v4
6263
with:
@@ -78,7 +79,7 @@ jobs:
7879
with:
7980
enable-cache: true
8081
- run: uvx coverage combine coverage
81-
- run: uvx coverage report --fail-under 90
82+
- run: uvx coverage report --fail-under 95
8283

8384
check:
8485
if: always()

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ typecheck: typecheck-ts typecheck-py ## Typecheck all code
5858

5959
.PHONY: test
6060
test: build ## Run tests and collect coverage data
61-
uv run coverage run -m pytest
61+
uv run coverage run -m pytest -v
6262
@uv run coverage report
6363

6464
.PHONY: all

README.md

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

3434
```bash
35-
uvx mcp-run-python [stdio|streamable-http|warmup]
35+
uvx mcp-run-python [-h] [--version] [--port PORT] [--deps DEPS] {stdio,streamable-http,example}
3636
```
3737

3838
where:
@@ -44,8 +44,8 @@ where:
4444
[Streamable HTTP MCP transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http) -
4545
suitable for running the server as an HTTP server to connect locally or remotely. This supports stateful requests, but
4646
does not require the client to hold a stateful connection like SSE
47-
- `warmup` will run a minimal Python script to download and cache the Python standard library. This is also useful to
48-
check the server is running correctly.
47+
- `example` will run a minimal Python script using `numpy`, useful for checking that the package is working, for the code
48+
to run successfully, you'll need to install `numpy` using `uvx mcp-run-python --deps numpy example`
4949

5050
## Usage in codes
5151

@@ -60,15 +60,15 @@ Then you can use `mcp-run-python` with Pydantic AI:
6060
```python
6161
from pydantic_ai import Agent
6262
from pydantic_ai.mcp import MCPServerStdio
63-
from mcp_run_python import deno_args
63+
from mcp_run_python import deno_args_prepare
6464

6565
import logfire
6666

6767
logfire.configure()
6868
logfire.instrument_mcp()
6969
logfire.instrument_pydantic_ai()
7070

71-
server = MCPServerStdio('deno', args=deno_args('stdio'))
71+
server = MCPServerStdio('deno', args=deno_args_prepare('stdio'))
7272
agent = Agent('claude-3-5-haiku-latest', toolsets=[server])
7373

7474

@@ -83,10 +83,75 @@ if __name__ == '__main__':
8383
asyncio.run(main())
8484
```
8585

86+
**Note**: `deno_args_prepare` can take `deps` as a keyword argument to install dependencies.
87+
As well as returning the args needed to run `mcp_run_python`, `deno_args_prepare` installs the dependencies
88+
so they can be used by the server.
89+
8690
## Logging
8791

8892
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).
8993

9094
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`.
9195

9296
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+
98+
## Dependencies
99+
100+
`mcp_run_python` uses a two step process to install dependencies while avoiding any risk that sandboxed code can
101+
edit the filesystem.
102+
103+
* `deno` is first run with write permissions to the `node_modules` directory and dependencies are installed, causing wheels to be written to ``
104+
* `deno` is then run with read-only permissions to the `node_modules` directory to run untrusted code.
105+
106+
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+
```

build/prepare_env.py

Lines changed: 3 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -7,29 +7,18 @@
77

88
import importlib
99
import logging
10-
import re
1110
import sys
1211
import traceback
13-
from collections.abc import Iterable, Iterator
12+
from collections.abc import Iterator
1413
from contextlib import contextmanager
1514
from dataclasses import dataclass
16-
from pathlib import Path
17-
from typing import Any, Literal, TypedDict, cast
15+
from typing import Any, Literal, cast
1816

1917
import micropip
20-
import pyodide_js
21-
import tomllib
22-
from pyodide.code import find_imports
2318

2419
__all__ = 'prepare_env', 'dump_json'
2520

2621

27-
class File(TypedDict):
28-
name: str
29-
content: str
30-
active: bool
31-
32-
3322
@dataclass
3423
class Success:
3524
dependencies: list[str] | None
@@ -42,22 +31,9 @@ class Error:
4231
kind: Literal['error'] = 'error'
4332

4433

45-
async def prepare_env(files: list[File]) -> Success | Error:
34+
async def prepare_env(dependencies: list[str] | None) -> Success | Error:
4635
sys.setrecursionlimit(400)
4736

48-
cwd = Path.cwd()
49-
for file in files:
50-
(cwd / file['name']).write_text(file['content'])
51-
52-
active: File | None = next((f for f in files if f['active']), None)
53-
54-
dependencies: list[str] | None = None
55-
if active:
56-
python_code = active['content']
57-
dependencies = _find_pep723_dependencies(python_code)
58-
if dependencies is None:
59-
dependencies = await _find_import_dependencies(python_code)
60-
6137
if dependencies:
6238
dependencies = _add_extra_dependencies(dependencies)
6339

@@ -141,60 +117,3 @@ def _micropip_logging() -> Iterator[str]:
141117
yield file_name
142118
finally:
143119
logger.removeHandler(handler)
144-
145-
146-
def _find_pep723_dependencies(code: str) -> list[str] | None:
147-
"""Extract dependencies from a script with PEP 723 metadata."""
148-
metadata = _read_pep723_metadata(code)
149-
dependencies: list[str] | None = metadata.get('dependencies')
150-
if dependencies is None:
151-
return None
152-
else:
153-
assert isinstance(dependencies, list), 'dependencies must be a list'
154-
assert all(isinstance(dep, str) for dep in dependencies), 'dependencies must be a list of strings'
155-
return dependencies
156-
157-
158-
def _read_pep723_metadata(code: str) -> dict[str, Any]:
159-
"""Read PEP 723 script metadata.
160-
161-
Copied from https://packaging.python.org/en/latest/specifications/inline-script-metadata/#reference-implementation
162-
"""
163-
name = 'script'
164-
magic_comment_regex = r'(?m)^# /// (?P<type>[a-zA-Z0-9-]+)$\s(?P<content>(^#(| .*)$\s)+)^# ///$'
165-
matches = list(filter(lambda m: m.group('type') == name, re.finditer(magic_comment_regex, code)))
166-
if len(matches) > 1:
167-
raise ValueError(f'Multiple {name} blocks found')
168-
elif len(matches) == 1:
169-
content = ''.join(
170-
line[2:] if line.startswith('# ') else line[1:]
171-
for line in matches[0].group('content').splitlines(keepends=True)
172-
)
173-
return tomllib.loads(content)
174-
else:
175-
return {}
176-
177-
178-
async def _find_import_dependencies(code: str) -> list[str] | None:
179-
"""Find dependencies in imports."""
180-
try:
181-
imports: list[str] = find_imports(code)
182-
except SyntaxError:
183-
return None
184-
else:
185-
return list(_find_imports_to_install(imports))
186-
187-
188-
TO_PACKAGE_NAME: dict[str, str] = pyodide_js._api._import_name_to_package_name.to_py() # pyright: ignore[reportPrivateUsage]
189-
190-
191-
def _find_imports_to_install(imports: list[str]) -> Iterable[str]:
192-
"""Given a list of module names being imported, return packages that are not installed."""
193-
for module in imports:
194-
try:
195-
importlib.import_module(module)
196-
except ModuleNotFoundError:
197-
if package_name := TO_PACKAGE_NAME.get(module):
198-
yield package_name
199-
elif '.' not in module:
200-
yield module

build/stubs/pyodide/code.pyi

Lines changed: 0 additions & 1 deletion
This file was deleted.

build/stubs/pyodide_js.pyi

Lines changed: 0 additions & 7 deletions
This file was deleted.

examples/direct.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from mcp import ClientSession, StdioServerParameters
2+
from mcp.client.stdio import stdio_client
3+
4+
from mcp_run_python import deno_args_prepare
5+
6+
code = """
7+
import numpy
8+
a = numpy.array([1, 2, 3])
9+
print(a)
10+
a
11+
"""
12+
server_params = StdioServerParameters(command='deno', args=deno_args_prepare('stdio', deps=['numpy']))
13+
14+
15+
async def main():
16+
async with stdio_client(server_params) as (read, write):
17+
async with ClientSession(read, write) as session:
18+
await session.initialize()
19+
tools = await session.list_tools()
20+
print(len(tools.tools))
21+
print(repr(tools.tools[0].name))
22+
print(repr(tools.tools[0].inputSchema))
23+
result = await session.call_tool('run_python_code', {'python_code': code})
24+
print(result.content[0].text)
25+
26+
27+
if __name__ == '__main__':
28+
import asyncio
29+
30+
asyncio.run(main())
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,13 @@
1313
from pydantic_ai import Agent
1414
from pydantic_ai.mcp import MCPServerStdio
1515

16-
from mcp_run_python import deno_args
16+
from mcp_run_python import deno_args_prepare
1717

1818
logfire.configure()
1919
logfire.instrument_mcp()
2020
logfire.instrument_pydantic_ai()
2121

22-
server = MCPServerStdio('deno', args=deno_args('stdio'))
22+
server = MCPServerStdio('deno', args=deno_args_prepare('stdio'))
2323
agent_with_python = Agent('claude-3-5-haiku-latest', toolsets=[server])
2424

2525

mcp_run_python/__init__.py

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

33
from importlib.metadata import version as _metadata_version
44

5-
from .main import deno_args, run_deno_server
5+
from .main import deno_args_prepare, deno_run_server
66

77
__version__ = _metadata_version('mcp_run_python')
8-
__all__ = '__version__', 'deno_args', 'run_deno_server'
8+
__all__ = '__version__', 'deno_args_prepare', 'deno_run_server'

mcp_run_python/_cli.py

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,14 @@
11
from __future__ import annotations as _annotations
22

33
import argparse
4-
import sys
54
from collections.abc import Sequence
65

76
from . import __version__
8-
from .main import run_deno_server
7+
from .main import deno_run_server
98

109

11-
def cli() -> int: # pragma: no cover
10+
def cli(args_list: Sequence[str] | None = None): # pragma: no cover
1211
"""Run the CLI."""
13-
sys.exit(cli_logic())
14-
15-
16-
def cli_logic(args_list: Sequence[str] | None = None) -> int:
1712
parser = argparse.ArgumentParser(
1813
prog='mcp-run-python',
1914
description=f'mcp-run-python CLI v{__version__}\n\nMCP server for running untrusted Python code.\n',
@@ -22,16 +17,19 @@ def cli_logic(args_list: Sequence[str] | None = None) -> int:
2217
parser.add_argument('--version', action='store_true', help='Show version and exit')
2318

2419
parser.add_argument('--port', type=int, help='Port to run the server on, default 3001.')
20+
parser.add_argument('--deps', help='Comma separated list of dependencies to install')
2521
parser.add_argument(
2622
'mode',
27-
choices=['stdio', 'streamable-http', 'warmup'],
23+
choices=['stdio', 'streamable-http', 'example'],
24+
nargs='?',
2825
help='Mode to run the server in.',
2926
)
3027

3128
args = parser.parse_args(args_list)
3229
if args.version:
3330
print(f'mcp-run-python {__version__}')
34-
return 0
31+
elif args.mode:
32+
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)
3534
else:
36-
run_deno_server(args.mode.replace('-', '_'), port=args.port)
37-
return 0
35+
parser.error('Mode is required')

0 commit comments

Comments
 (0)