mcp-python-exec-sandbox 0.1.2__tar.gz → 0.1.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. mcp_python_exec_sandbox-0.1.4/.github/workflows/ci.yml +61 -0
  2. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/CLAUDE.md +4 -3
  3. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/PKG-INFO +46 -16
  4. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/README.md +45 -15
  5. mcp_python_exec_sandbox-0.1.4/e2e_tests/test_multi_version.py +68 -0
  6. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/e2e_tests/test_sandbox_enforcement.py +0 -36
  7. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/pyproject.toml +1 -1
  8. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/src/mcp_python_exec_sandbox/__main__.py +12 -2
  9. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/src/mcp_python_exec_sandbox/config.py +1 -1
  10. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/src/mcp_python_exec_sandbox/executor.py +29 -3
  11. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/src/mcp_python_exec_sandbox/sandbox.py +26 -25
  12. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/tests/test_config.py +4 -1
  13. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/tests/test_sandbox.py +11 -24
  14. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/uv.lock +1 -1
  15. mcp_python_exec_sandbox-0.1.2/.github/workflows/ci.yml +0 -25
  16. mcp_python_exec_sandbox-0.1.2/profiles/sandbox_macos.sb +0 -39
  17. mcp_python_exec_sandbox-0.1.2/src/mcp_python_exec_sandbox/sandbox_macos.py +0 -92
  18. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/.devcontainer/Dockerfile +0 -0
  19. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/.devcontainer/devcontainer.json +0 -0
  20. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/.gitignore +0 -0
  21. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/LICENSE +0 -0
  22. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/e2e_tests/__init__.py +0 -0
  23. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/e2e_tests/test_data_science.py +0 -0
  24. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/e2e_tests/test_docker_sandbox.py +0 -0
  25. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/e2e_tests/test_mcp_protocol.py +0 -0
  26. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/e2e_tests/test_package_install.py +0 -0
  27. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/e2e_tests/test_real_execution.py +0 -0
  28. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/profiles/Dockerfile +0 -0
  29. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/profiles/warmup_packages.txt +0 -0
  30. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/src/mcp_python_exec_sandbox/__init__.py +0 -0
  31. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/src/mcp_python_exec_sandbox/cache.py +0 -0
  32. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/src/mcp_python_exec_sandbox/errors.py +0 -0
  33. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/src/mcp_python_exec_sandbox/output.py +0 -0
  34. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/src/mcp_python_exec_sandbox/sandbox_docker.py +0 -0
  35. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/src/mcp_python_exec_sandbox/sandbox_linux.py +0 -0
  36. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/src/mcp_python_exec_sandbox/script.py +0 -0
  37. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/src/mcp_python_exec_sandbox/server.py +0 -0
  38. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/tests/__init__.py +0 -0
  39. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/tests/conftest.py +0 -0
  40. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/tests/test_executor.py +0 -0
  41. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/tests/test_integration.py +0 -0
  42. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/tests/test_output.py +0 -0
  43. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/tests/test_script.py +0 -0
  44. {mcp_python_exec_sandbox-0.1.2 → mcp_python_exec_sandbox-0.1.4}/tests/test_server.py +0 -0
@@ -0,0 +1,61 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ lint:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: astral-sh/setup-uv@v4
15
+ with:
16
+ python-version: "3.13"
17
+ - run: uv sync --dev
18
+ - run: uv run ruff check .
19
+ - run: uv run ruff format --check .
20
+
21
+ test:
22
+ runs-on: ubuntu-latest
23
+ steps:
24
+ - uses: actions/checkout@v4
25
+ - uses: astral-sh/setup-uv@v4
26
+ with:
27
+ python-version: "3.13"
28
+ - run: uv sync --dev
29
+ - run: uv run pytest tests/ -v
30
+
31
+ e2e-native:
32
+ runs-on: ubuntu-latest
33
+ steps:
34
+ - uses: actions/checkout@v4
35
+ - uses: astral-sh/setup-uv@v4
36
+ with:
37
+ python-version: "3.13"
38
+ - run: sudo apt-get update && sudo apt-get install -y bubblewrap
39
+ - run: uv sync --dev
40
+ - run: uv run pytest e2e_tests/test_sandbox_enforcement.py -v
41
+
42
+ e2e-docker:
43
+ runs-on: ubuntu-latest
44
+ steps:
45
+ - uses: actions/checkout@v4
46
+ - uses: astral-sh/setup-uv@v4
47
+ with:
48
+ python-version: "3.13"
49
+ - run: docker build -t mcp-python-exec-sandbox profiles/
50
+ - run: uv sync --dev
51
+ - run: uv run pytest e2e_tests/test_docker_sandbox.py -v
52
+
53
+ e2e-multi-version:
54
+ runs-on: ubuntu-latest
55
+ steps:
56
+ - uses: actions/checkout@v4
57
+ - uses: astral-sh/setup-uv@v4
58
+ with:
59
+ python-version: "3.13"
60
+ - run: uv sync --dev
61
+ - run: uv run pytest e2e_tests/test_multi_version.py -v
@@ -18,11 +18,12 @@ src/mcp_python_exec_sandbox/ # Package source
18
18
  executor.py # uv subprocess orchestration
19
19
  script.py # PEP 723 metadata parsing/merging
20
20
  sandbox.py # Sandbox ABC + factory
21
- sandbox_{linux,macos,docker}.py
21
+ sandbox_linux.py # bubblewrap sandbox (Linux)
22
+ sandbox_docker.py # Docker sandbox (macOS/any)
22
23
  config.py, cache.py, output.py, errors.py
23
24
  tests/ # Unit + integration tests (mocked or local uv)
24
25
  e2e_tests/ # End-to-end tests (require uv + network)
25
- profiles/ # Dockerfile, macOS seatbelt profile, warmup packages
26
+ profiles/ # Dockerfile, warmup packages
26
27
  ```
27
28
 
28
29
  ## Commands
@@ -40,7 +41,7 @@ uv run pytest e2e_tests/ -v # E2E tests (slow, needs network)
40
41
  - Lint with `uv run ruff check .` and format with `uv run ruff format --check .` before committing. Fix issues with `--fix` / `ruff format .`.
41
42
  - Tool docstrings in `server.py` are user-facing — they become the MCP tool descriptions that agents see. Write them for an LLM audience: include examples, avoid unexplained jargon, link PEPs.
42
43
  - Always pin versions in examples (e.g. `"pandas>=2.2"` not `"pandas"`).
43
- - Sandbox backends must degrade gracefully: if the tool (bwrap, sandbox-exec, docker) is missing, fall back to `NoopSandbox` with a warning.
44
+ - Sandbox backends must degrade gracefully: if the tool (bwrap, docker) is missing, fall back to `NoopSandbox` with a warning. Native sandbox is Linux-only (bwrap); macOS defaults to Docker.
44
45
 
45
46
  ## Contribution format
46
47
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-python-exec-sandbox
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: MCP server for secure Python script execution with automatic dependency management
5
5
  Project-URL: Homepage, https://github.com/lu-zhengda/mcp-python-exec-sandbox
6
6
  Project-URL: Repository, https://github.com/lu-zhengda/mcp-python-exec-sandbox
@@ -29,7 +29,7 @@ Sandboxed Python execution for AI agents. Scripts run in ephemeral, isolated env
29
29
 
30
30
  Every coding agent can already run Python on your host. The problem is what happens next: packages accumulate, venvs sprawl, and a rogue `pip install` breaks your system. **mcp-python-exec-sandbox** eliminates this:
31
31
 
32
- - Scripts execute in a sandbox (bubblewrap on Linux, sandbox-exec on macOS, Docker everywhere)
32
+ - Scripts execute in a sandbox (bubblewrap on Linux, Docker on macOS/other platforms)
33
33
  - Dependencies are declared inline and resolved ephemerally via `uv`
34
34
  - Nothing touches your host's Python, site-packages, or virtualenvs
35
35
  - Each execution is isolated and disposable
@@ -56,25 +56,24 @@ Additional requirements depend on your chosen sandbox backend:
56
56
  | Setup | Additional requirements | Install |
57
57
  |-------|------------------------|---------|
58
58
  | **Native sandbox (Linux)** | [bubblewrap](https://github.com/containers/bubblewrap) | `sudo apt install bubblewrap` |
59
- | **Native sandbox (macOS)** | None -- `sandbox-exec` is built into macOS | -- |
60
- | **Docker sandbox** | [Docker Engine](https://docs.docker.com/engine/install/) | See Docker docs |
59
+ | **Docker sandbox (macOS, any)** | [Docker Engine](https://docs.docker.com/engine/install/) | See Docker docs |
61
60
  | **No sandbox** | None | -- |
62
61
 
63
62
  > **Host Python vs. execution Python:** These are independent. Python 3.13+ is needed to run the server process itself. The `--python-version` flag controls which Python version your *scripts* execute on -- uv downloads the target version automatically. You do not need to install Python 3.14 or 3.15 on your host to run scripts on those versions.
64
63
 
65
64
  ## Quick start
66
65
 
67
- ### Claude Code (native sandbox -- recommended)
66
+ ### Claude Code (Linux -- native sandbox)
68
67
 
69
68
  ```bash
70
- claude mcp add python-sandbox -- uvx mcp-python-exec-sandbox --sandbox-backend native
69
+ claude mcp add python-sandbox -- uvx mcp-python-exec-sandbox
71
70
  ```
72
71
 
73
- ### Claude Code (Docker sandbox)
72
+ ### Claude Code (macOS -- Docker sandbox, recommended)
74
73
 
75
74
  ```bash
76
75
  docker build -t mcp-python-exec-sandbox profiles/
77
- claude mcp add python-sandbox -- uvx mcp-python-exec-sandbox --sandbox-backend docker
76
+ claude mcp add python-sandbox -- uvx mcp-python-exec-sandbox
78
77
  ```
79
78
 
80
79
  > The Docker image build requires the repo source. Clone it first: `git clone https://github.com/lu-zhengda/mcp-python-exec-sandbox.git`
@@ -85,14 +84,45 @@ claude mcp add python-sandbox -- uvx mcp-python-exec-sandbox --sandbox-backend d
85
84
  claude mcp add python-sandbox -- uvx mcp-python-exec-sandbox --sandbox-backend none
86
85
  ```
87
86
 
88
- ### Manual JSON config
87
+ ### Cursor
88
+
89
+ Add to `.cursor/mcp.json` (project-level) or `~/.cursor/mcp.json` (global):
90
+
91
+ ```json
92
+ {
93
+ "mcpServers": {
94
+ "python-sandbox": {
95
+ "command": "uvx",
96
+ "args": ["mcp-python-exec-sandbox"]
97
+ }
98
+ }
99
+ }
100
+ ```
101
+
102
+ ### OpenAI Codex CLI
103
+
104
+ ```bash
105
+ codex mcp add python-sandbox -- uvx mcp-python-exec-sandbox
106
+ ```
107
+
108
+ Or add to `.codex/config.toml`:
109
+
110
+ ```toml
111
+ [mcp_servers.python-sandbox]
112
+ command = "uvx"
113
+ args = ["mcp-python-exec-sandbox"]
114
+ ```
115
+
116
+ ### Other MCP clients
117
+
118
+ Any client that supports the MCP stdio transport can use this server:
89
119
 
90
120
  ```json
91
121
  {
92
122
  "mcpServers": {
93
123
  "python-sandbox": {
94
124
  "command": "uvx",
95
- "args": ["mcp-python-exec-sandbox", "--sandbox-backend", "native"]
125
+ "args": ["mcp-python-exec-sandbox"]
96
126
  }
97
127
  }
98
128
  }
@@ -166,11 +196,10 @@ Validates a script's PEP 723 metadata and dependencies without executing it.
166
196
  | Backend | Platform | Tool | Notes |
167
197
  |---------|----------|------|-------|
168
198
  | `native` | Linux | bubblewrap | Namespace isolation, network allowed |
169
- | `native` | macOS | sandbox-exec | Seatbelt profiles, network allowed |
170
199
  | `docker` | Any | Docker | Container isolation, resource limits |
171
200
  | `none` | Any | -- | No sandboxing (not recommended) |
172
201
 
173
- If the requested sandbox tool is unavailable, the server falls back to `none` with a warning.
202
+ The default backend is `native` (bubblewrap) on Linux and `docker` on macOS/other platforms. Specifying `--sandbox-backend native` on macOS automatically redirects to Docker. If the sandbox tool is unavailable, the server falls back to `none` with a warning.
174
203
 
175
204
  ### Docker sandbox setup
176
205
 
@@ -185,7 +214,7 @@ mcp-python-exec-sandbox [OPTIONS]
185
214
 
186
215
  Options:
187
216
  --python-version TEXT Python version for execution (default: 3.13)
188
- --sandbox-backend TEXT native | docker | none (default: native)
217
+ --sandbox-backend TEXT native | docker | none (default: native on Linux, docker on macOS)
189
218
  --max-timeout INT Maximum allowed timeout in seconds (default: 300)
190
219
  --default-timeout INT Default timeout in seconds (default: 30)
191
220
  --max-output-bytes INT Maximum output size in bytes (default: 102400)
@@ -211,11 +240,12 @@ src/mcp_python_exec_sandbox/ # Package source
211
240
  executor.py # uv subprocess orchestration
212
241
  script.py # PEP 723 metadata parsing/merging
213
242
  sandbox.py # Sandbox ABC + factory
214
- sandbox_{linux,macos,docker}.py
243
+ sandbox_linux.py # bubblewrap sandbox (Linux)
244
+ sandbox_docker.py # Docker sandbox (macOS/any)
215
245
  config.py, cache.py, output.py, errors.py
216
246
  tests/ # Unit + integration tests (mocked or local uv)
217
247
  e2e_tests/ # End-to-end tests (require uv + network)
218
- profiles/ # Dockerfile, macOS seatbelt profile, warmup packages
248
+ profiles/ # Dockerfile, warmup packages
219
249
  .devcontainer/ # Devcontainer for Linux sandbox testing from macOS
220
250
  ```
221
251
 
@@ -300,7 +330,7 @@ devcontainer exec --workspace-folder . uv run pytest e2e_tests/test_sandbox_enfo
300
330
  - Add tests for new functionality: unit tests in `tests/`, E2E in `e2e_tests/` if it needs real execution.
301
331
  - Keep dependencies minimal. Do not add runtime deps without strong justification.
302
332
  - Tool docstrings in `server.py` are user-facing MCP tool descriptions. Write them for an LLM audience.
303
- - Sandbox backends must degrade gracefully: if the tool is missing, fall back to `NoopSandbox` with a warning.
333
+ - Sandbox backends must degrade gracefully: if the required tool (bwrap, docker) is missing, fall back to `NoopSandbox` with a warning.
304
334
 
305
335
  ## License
306
336
 
@@ -6,7 +6,7 @@ Sandboxed Python execution for AI agents. Scripts run in ephemeral, isolated env
6
6
 
7
7
  Every coding agent can already run Python on your host. The problem is what happens next: packages accumulate, venvs sprawl, and a rogue `pip install` breaks your system. **mcp-python-exec-sandbox** eliminates this:
8
8
 
9
- - Scripts execute in a sandbox (bubblewrap on Linux, sandbox-exec on macOS, Docker everywhere)
9
+ - Scripts execute in a sandbox (bubblewrap on Linux, Docker on macOS/other platforms)
10
10
  - Dependencies are declared inline and resolved ephemerally via `uv`
11
11
  - Nothing touches your host's Python, site-packages, or virtualenvs
12
12
  - Each execution is isolated and disposable
@@ -33,25 +33,24 @@ Additional requirements depend on your chosen sandbox backend:
33
33
  | Setup | Additional requirements | Install |
34
34
  |-------|------------------------|---------|
35
35
  | **Native sandbox (Linux)** | [bubblewrap](https://github.com/containers/bubblewrap) | `sudo apt install bubblewrap` |
36
- | **Native sandbox (macOS)** | None -- `sandbox-exec` is built into macOS | -- |
37
- | **Docker sandbox** | [Docker Engine](https://docs.docker.com/engine/install/) | See Docker docs |
36
+ | **Docker sandbox (macOS, any)** | [Docker Engine](https://docs.docker.com/engine/install/) | See Docker docs |
38
37
  | **No sandbox** | None | -- |
39
38
 
40
39
  > **Host Python vs. execution Python:** These are independent. Python 3.13+ is needed to run the server process itself. The `--python-version` flag controls which Python version your *scripts* execute on -- uv downloads the target version automatically. You do not need to install Python 3.14 or 3.15 on your host to run scripts on those versions.
41
40
 
42
41
  ## Quick start
43
42
 
44
- ### Claude Code (native sandbox -- recommended)
43
+ ### Claude Code (Linux -- native sandbox)
45
44
 
46
45
  ```bash
47
- claude mcp add python-sandbox -- uvx mcp-python-exec-sandbox --sandbox-backend native
46
+ claude mcp add python-sandbox -- uvx mcp-python-exec-sandbox
48
47
  ```
49
48
 
50
- ### Claude Code (Docker sandbox)
49
+ ### Claude Code (macOS -- Docker sandbox, recommended)
51
50
 
52
51
  ```bash
53
52
  docker build -t mcp-python-exec-sandbox profiles/
54
- claude mcp add python-sandbox -- uvx mcp-python-exec-sandbox --sandbox-backend docker
53
+ claude mcp add python-sandbox -- uvx mcp-python-exec-sandbox
55
54
  ```
56
55
 
57
56
  > The Docker image build requires the repo source. Clone it first: `git clone https://github.com/lu-zhengda/mcp-python-exec-sandbox.git`
@@ -62,14 +61,45 @@ claude mcp add python-sandbox -- uvx mcp-python-exec-sandbox --sandbox-backend d
62
61
  claude mcp add python-sandbox -- uvx mcp-python-exec-sandbox --sandbox-backend none
63
62
  ```
64
63
 
65
- ### Manual JSON config
64
+ ### Cursor
65
+
66
+ Add to `.cursor/mcp.json` (project-level) or `~/.cursor/mcp.json` (global):
67
+
68
+ ```json
69
+ {
70
+ "mcpServers": {
71
+ "python-sandbox": {
72
+ "command": "uvx",
73
+ "args": ["mcp-python-exec-sandbox"]
74
+ }
75
+ }
76
+ }
77
+ ```
78
+
79
+ ### OpenAI Codex CLI
80
+
81
+ ```bash
82
+ codex mcp add python-sandbox -- uvx mcp-python-exec-sandbox
83
+ ```
84
+
85
+ Or add to `.codex/config.toml`:
86
+
87
+ ```toml
88
+ [mcp_servers.python-sandbox]
89
+ command = "uvx"
90
+ args = ["mcp-python-exec-sandbox"]
91
+ ```
92
+
93
+ ### Other MCP clients
94
+
95
+ Any client that supports the MCP stdio transport can use this server:
66
96
 
67
97
  ```json
68
98
  {
69
99
  "mcpServers": {
70
100
  "python-sandbox": {
71
101
  "command": "uvx",
72
- "args": ["mcp-python-exec-sandbox", "--sandbox-backend", "native"]
102
+ "args": ["mcp-python-exec-sandbox"]
73
103
  }
74
104
  }
75
105
  }
@@ -143,11 +173,10 @@ Validates a script's PEP 723 metadata and dependencies without executing it.
143
173
  | Backend | Platform | Tool | Notes |
144
174
  |---------|----------|------|-------|
145
175
  | `native` | Linux | bubblewrap | Namespace isolation, network allowed |
146
- | `native` | macOS | sandbox-exec | Seatbelt profiles, network allowed |
147
176
  | `docker` | Any | Docker | Container isolation, resource limits |
148
177
  | `none` | Any | -- | No sandboxing (not recommended) |
149
178
 
150
- If the requested sandbox tool is unavailable, the server falls back to `none` with a warning.
179
+ The default backend is `native` (bubblewrap) on Linux and `docker` on macOS/other platforms. Specifying `--sandbox-backend native` on macOS automatically redirects to Docker. If the sandbox tool is unavailable, the server falls back to `none` with a warning.
151
180
 
152
181
  ### Docker sandbox setup
153
182
 
@@ -162,7 +191,7 @@ mcp-python-exec-sandbox [OPTIONS]
162
191
 
163
192
  Options:
164
193
  --python-version TEXT Python version for execution (default: 3.13)
165
- --sandbox-backend TEXT native | docker | none (default: native)
194
+ --sandbox-backend TEXT native | docker | none (default: native on Linux, docker on macOS)
166
195
  --max-timeout INT Maximum allowed timeout in seconds (default: 300)
167
196
  --default-timeout INT Default timeout in seconds (default: 30)
168
197
  --max-output-bytes INT Maximum output size in bytes (default: 102400)
@@ -188,11 +217,12 @@ src/mcp_python_exec_sandbox/ # Package source
188
217
  executor.py # uv subprocess orchestration
189
218
  script.py # PEP 723 metadata parsing/merging
190
219
  sandbox.py # Sandbox ABC + factory
191
- sandbox_{linux,macos,docker}.py
220
+ sandbox_linux.py # bubblewrap sandbox (Linux)
221
+ sandbox_docker.py # Docker sandbox (macOS/any)
192
222
  config.py, cache.py, output.py, errors.py
193
223
  tests/ # Unit + integration tests (mocked or local uv)
194
224
  e2e_tests/ # End-to-end tests (require uv + network)
195
- profiles/ # Dockerfile, macOS seatbelt profile, warmup packages
225
+ profiles/ # Dockerfile, warmup packages
196
226
  .devcontainer/ # Devcontainer for Linux sandbox testing from macOS
197
227
  ```
198
228
 
@@ -277,7 +307,7 @@ devcontainer exec --workspace-folder . uv run pytest e2e_tests/test_sandbox_enfo
277
307
  - Add tests for new functionality: unit tests in `tests/`, E2E in `e2e_tests/` if it needs real execution.
278
308
  - Keep dependencies minimal. Do not add runtime deps without strong justification.
279
309
  - Tool docstrings in `server.py` are user-facing MCP tool descriptions. Write them for an LLM audience.
280
- - Sandbox backends must degrade gracefully: if the tool is missing, fall back to `NoopSandbox` with a warning.
310
+ - Sandbox backends must degrade gracefully: if the required tool (bwrap, docker) is missing, fall back to `NoopSandbox` with a warning.
281
311
 
282
312
  ## License
283
313
 
@@ -0,0 +1,68 @@
1
+ """E2E tests for multi-version Python execution."""
2
+
3
+ import shutil
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from mcp_python_exec_sandbox.executor import execute
10
+
11
+ pytestmark = pytest.mark.skipif(
12
+ shutil.which("uv") is None,
13
+ reason="uv not installed",
14
+ )
15
+
16
+
17
+ @pytest.mark.parametrize("python_version", ["3.13", "3.14"])
18
+ @pytest.mark.asyncio
19
+ async def test_version_matches(python_version: str):
20
+ """Test that the script runs on the requested Python version."""
21
+ script = """\
22
+ import sys
23
+ print(f"{sys.version_info.major}.{sys.version_info.minor}")
24
+ """
25
+
26
+ with tempfile.TemporaryDirectory(prefix="mcp-e2e-") as tmpdir:
27
+ script_path = Path(tmpdir) / "test.py"
28
+ script_path.write_text(script)
29
+
30
+ result = await execute(
31
+ script_path=script_path,
32
+ python_version=python_version,
33
+ timeout=60,
34
+ sandbox=None,
35
+ max_output_bytes=102400,
36
+ )
37
+
38
+ assert result.exit_code == 0, f"stderr: {result.stderr}"
39
+ assert result.stdout.strip() == python_version
40
+
41
+
42
+ @pytest.mark.parametrize("python_version", ["3.13", "3.14"])
43
+ @pytest.mark.asyncio
44
+ async def test_dependency_install_across_versions(python_version: str):
45
+ """Test that PEP 723 dependencies work on each Python version."""
46
+ script = """\
47
+ # /// script
48
+ # dependencies = ["pydantic>=2.0"]
49
+ # ///
50
+
51
+ import pydantic
52
+ print(f"pydantic {pydantic.__version__}")
53
+ """
54
+
55
+ with tempfile.TemporaryDirectory(prefix="mcp-e2e-") as tmpdir:
56
+ script_path = Path(tmpdir) / "test.py"
57
+ script_path.write_text(script)
58
+
59
+ result = await execute(
60
+ script_path=script_path,
61
+ python_version=python_version,
62
+ timeout=120,
63
+ sandbox=None,
64
+ max_output_bytes=102400,
65
+ )
66
+
67
+ assert result.exit_code == 0, f"stderr: {result.stderr}"
68
+ assert "pydantic 2." in result.stdout
@@ -18,42 +18,6 @@ pytestmark = [
18
18
  ]
19
19
 
20
20
 
21
- @pytest.mark.skipif(sys.platform != "darwin", reason="macOS only")
22
- @pytest.mark.skipif(
23
- shutil.which("sandbox-exec") is None,
24
- reason="sandbox-exec not found",
25
- )
26
- @pytest.mark.asyncio
27
- async def test_macos_sandbox_blocks_home_read():
28
- """Test that macOS sandbox blocks reading files outside allowed dirs."""
29
- sandbox = get_sandbox("native")
30
-
31
- script = """\
32
- import os
33
- try:
34
- with open(os.path.expanduser("~/.ssh/id_rsa")) as f:
35
- print(f.read())
36
- print("ACCESS_GRANTED")
37
- except (PermissionError, FileNotFoundError, OSError) as e:
38
- print(f"ACCESS_DENIED: {e}")
39
- """
40
-
41
- with tempfile.TemporaryDirectory(prefix="mcp-e2e-") as tmpdir:
42
- script_path = Path(tmpdir) / "test.py"
43
- script_path.write_text(script)
44
-
45
- result = await execute(
46
- script_path=script_path,
47
- python_version="3.13",
48
- timeout=30,
49
- sandbox=sandbox,
50
- max_output_bytes=102400,
51
- )
52
-
53
- # Should not have been able to read the file
54
- assert "ACCESS_GRANTED" not in result.stdout
55
-
56
-
57
21
  @pytest.mark.skipif(sys.platform != "linux", reason="Linux only")
58
22
  @pytest.mark.skipif(
59
23
  shutil.which("bwrap") is None,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mcp-python-exec-sandbox"
3
- version = "0.1.2"
3
+ version = "0.1.4"
4
4
  description = "MCP server for secure Python script execution with automatic dependency management"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -3,9 +3,16 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import argparse
6
+ import sys
7
+
8
+
9
+ def _default_sandbox_backend() -> str:
10
+ """Return the platform-appropriate default sandbox backend."""
11
+ return "native" if sys.platform == "linux" else "docker"
6
12
 
7
13
 
8
14
  def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
15
+ default_backend = _default_sandbox_backend()
9
16
  parser = argparse.ArgumentParser(
10
17
  prog="mcp-python-exec-sandbox",
11
18
  description="MCP server for secure Python script execution",
@@ -18,8 +25,11 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
18
25
  parser.add_argument(
19
26
  "--sandbox-backend",
20
27
  choices=["native", "docker", "none"],
21
- default="native",
22
- help="Sandbox backend: native (bwrap/sandbox-exec), docker, or none (default: native)",
28
+ default=default_backend,
29
+ help=(
30
+ "Sandbox backend: native (bwrap, Linux only), docker, or none "
31
+ f"(default: {default_backend})"
32
+ ),
23
33
  )
24
34
  parser.add_argument(
25
35
  "--max-timeout",
@@ -10,7 +10,7 @@ class ServerConfig:
10
10
  """Configuration for the MCP Python executor server."""
11
11
 
12
12
  python_version: str = "3.13"
13
- sandbox_backend: str = "native" # "native" | "docker" | "none"
13
+ sandbox_backend: str = "native" # "native" (Linux) | "docker" | "none"
14
14
  max_timeout: int = 300
15
15
  default_timeout: int = 30
16
16
  max_output_bytes: int = 102_400 # 100KB
@@ -4,11 +4,34 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import os
7
+ import shutil
8
+ import subprocess
7
9
  import time
8
10
  from pathlib import Path
9
11
 
10
12
  from mcp_python_exec_sandbox.output import ExecutionResult
11
13
 
14
+ _uv_cache_dir: str | None = None
15
+
16
+
17
+ def _get_uv_cache_dir() -> str | None:
18
+ """Discover uv's cache directory (cached after first call)."""
19
+ global _uv_cache_dir # noqa: PLW0603
20
+ if _uv_cache_dir is not None:
21
+ return _uv_cache_dir
22
+ uv = shutil.which("uv")
23
+ if uv:
24
+ try:
25
+ result = subprocess.run(
26
+ [uv, "cache", "dir"], capture_output=True, text=True, timeout=5
27
+ )
28
+ if result.returncode == 0 and result.stdout.strip():
29
+ _uv_cache_dir = str(Path(result.stdout.strip()).resolve())
30
+ return _uv_cache_dir
31
+ except Exception:
32
+ pass
33
+ return None
34
+
12
35
 
13
36
  def _build_clean_env(uv_cache_dir: str | None = None) -> dict[str, str]:
14
37
  """Build a clean environment dict, stripping secrets from the parent env."""
@@ -48,12 +71,14 @@ async def execute(
48
71
  Returns:
49
72
  ExecutionResult with stdout, stderr, exit_code, duration, and timeout status.
50
73
  """
51
- cmd = [uv_path, "run", "--script", "--python", python_version, str(script_path)]
74
+ # Resolve symlinks so paths match sandbox allow-lists (e.g. /var -> /private/var)
75
+ resolved_path = script_path.resolve()
76
+ cmd = [uv_path, "run", "--script", "--python", python_version, str(resolved_path)]
52
77
 
53
78
  if sandbox is not None:
54
- cmd = sandbox.wrap(cmd, script_path)
79
+ cmd = sandbox.wrap(cmd, resolved_path)
55
80
 
56
- env = _build_clean_env()
81
+ env = _build_clean_env(uv_cache_dir=_get_uv_cache_dir())
57
82
  start = time.monotonic()
58
83
  timed_out = False
59
84
 
@@ -63,6 +88,7 @@ async def execute(
63
88
  stdout=asyncio.subprocess.PIPE,
64
89
  stderr=asyncio.subprocess.PIPE,
65
90
  env=env,
91
+ cwd=resolved_path.parent,
66
92
  )
67
93
  stdout_bytes, stderr_bytes = await asyncio.wait_for(proc.communicate(), timeout=timeout)
68
94
  except TimeoutError:
@@ -47,6 +47,19 @@ class NoopSandbox(Sandbox):
47
47
  return "none (no sandboxing)"
48
48
 
49
49
 
50
+ def _get_docker_sandbox() -> Sandbox:
51
+ """Create a Docker sandbox, falling back to NoopSandbox if unavailable."""
52
+ from mcp_python_exec_sandbox.sandbox_docker import DockerSandbox
53
+
54
+ sb = DockerSandbox()
55
+ if not sb.is_available():
56
+ import logging
57
+
58
+ logging.warning("Docker not available, falling back to no sandbox")
59
+ return NoopSandbox()
60
+ return sb
61
+
62
+
50
63
  def get_sandbox(backend: str) -> Sandbox:
51
64
  """Create a sandbox instance for the given backend.
52
65
 
@@ -61,35 +74,23 @@ def get_sandbox(backend: str) -> Sandbox:
61
74
  from mcp_python_exec_sandbox.sandbox_linux import BubblewrapSandbox
62
75
 
63
76
  sb = BubblewrapSandbox()
64
- elif sys.platform == "darwin":
65
- from mcp_python_exec_sandbox.sandbox_macos import SandboxExecSandbox
66
-
67
- sb = SandboxExecSandbox()
68
- else:
69
- import logging
77
+ if not sb.is_available():
78
+ import logging
70
79
 
71
- logging.warning(
72
- "Native sandbox not supported on %s, falling back to no sandbox",
73
- sys.platform,
74
- )
75
- return NoopSandbox()
80
+ logging.warning("bwrap not found, falling back to no sandbox")
81
+ return NoopSandbox()
82
+ return sb
76
83
 
77
- if not sb.is_available():
78
- import logging
84
+ # Native sandbox is only supported on Linux; use Docker on other platforms.
85
+ import logging
79
86
 
80
- logging.warning("Native sandbox tool not found, falling back to no sandbox")
81
- return NoopSandbox()
82
- return sb
87
+ logging.info(
88
+ "Native sandbox is Linux-only; using Docker sandbox on %s.",
89
+ sys.platform,
90
+ )
91
+ return _get_docker_sandbox()
83
92
 
84
93
  if backend == "docker":
85
- from mcp_python_exec_sandbox.sandbox_docker import DockerSandbox
86
-
87
- sb = DockerSandbox()
88
- if not sb.is_available():
89
- import logging
90
-
91
- logging.warning("Docker not available, falling back to no sandbox")
92
- return NoopSandbox()
93
- return sb
94
+ return _get_docker_sandbox()
94
95
 
95
96
  raise ValueError(f"Unknown sandbox backend: {backend!r}")
@@ -56,9 +56,12 @@ class TestServerConfig:
56
56
 
57
57
  class TestParseArgs:
58
58
  def test_defaults(self):
59
+ import sys
60
+
59
61
  args = parse_args([])
60
62
  assert args.python_version == "3.13"
61
- assert args.sandbox_backend == "native"
63
+ expected_backend = "native" if sys.platform == "linux" else "docker"
64
+ assert args.sandbox_backend == expected_backend
62
65
  assert args.max_timeout == 300
63
66
  assert args.default_timeout == 30
64
67
  assert args.max_output_bytes == 102_400
@@ -40,7 +40,17 @@ class TestGetSandbox:
40
40
  assert isinstance(sb, NoopSandbox)
41
41
 
42
42
  @patch("sys.platform", "darwin")
43
- def test_native_macos_fallback_when_sandbox_exec_missing(self):
43
+ def test_native_macos_uses_docker(self):
44
+ """On macOS, 'native' redirects to Docker sandbox."""
45
+ from mcp_python_exec_sandbox.sandbox_docker import DockerSandbox
46
+
47
+ with patch("shutil.which", return_value="/usr/local/bin/docker"):
48
+ sb = get_sandbox("native")
49
+ assert isinstance(sb, DockerSandbox)
50
+
51
+ @patch("sys.platform", "darwin")
52
+ def test_native_macos_falls_back_when_docker_missing(self):
53
+ """On macOS, 'native' falls back to NoopSandbox if Docker is unavailable."""
44
54
  with patch("shutil.which", return_value=None):
45
55
  sb = get_sandbox("native")
46
56
  assert isinstance(sb, NoopSandbox)
@@ -68,29 +78,6 @@ class TestBubblewrapSandbox:
68
78
  assert wrapped[-6:] == cmd
69
79
 
70
80
 
71
- class TestSandboxExecSandbox:
72
- @pytest.mark.skipif(sys.platform != "darwin", reason="macOS only")
73
- def test_wrap_command(self):
74
- from mcp_python_exec_sandbox.sandbox_macos import SandboxExecSandbox
75
-
76
- with patch("shutil.which", return_value="/usr/bin/sandbox-exec"):
77
- sb = SandboxExecSandbox()
78
- cmd = ["uv", "run", "--script", "--python", "3.13", "/tmp/test.py"]
79
- wrapped = sb.wrap(cmd, Path("/tmp/test.py"))
80
- assert wrapped[0] == "/usr/bin/sandbox-exec"
81
- assert "-p" in wrapped
82
- # Original command should be at the end
83
- assert wrapped[-6:] == cmd
84
-
85
- @pytest.mark.skipif(sys.platform != "darwin", reason="macOS only")
86
- def test_describe(self):
87
- from mcp_python_exec_sandbox.sandbox_macos import SandboxExecSandbox
88
-
89
- with patch("shutil.which", return_value="/usr/bin/sandbox-exec"):
90
- sb = SandboxExecSandbox()
91
- assert "sandbox-exec" in sb.describe()
92
-
93
-
94
81
  class TestDockerSandbox:
95
82
  def test_wrap_command(self):
96
83
  from mcp_python_exec_sandbox.sandbox_docker import DockerSandbox
@@ -639,7 +639,7 @@ wheels = [
639
639
 
640
640
  [[package]]
641
641
  name = "mcp-python-exec-sandbox"
642
- version = "0.1.2"
642
+ version = "0.1.3"
643
643
  source = { editable = "." }
644
644
  dependencies = [
645
645
  { name = "fastmcp" },
@@ -1,25 +0,0 @@
1
- name: CI
2
-
3
- on:
4
- push:
5
- branches: [main]
6
- pull_request:
7
- branches: [main]
8
-
9
- jobs:
10
- lint:
11
- runs-on: ubuntu-latest
12
- steps:
13
- - uses: actions/checkout@v4
14
- - uses: astral-sh/setup-uv@v4
15
- - run: uv sync --dev
16
- - run: uv run ruff check .
17
- - run: uv run ruff format --check .
18
-
19
- test:
20
- runs-on: ubuntu-latest
21
- steps:
22
- - uses: actions/checkout@v4
23
- - uses: astral-sh/setup-uv@v4
24
- - run: uv sync --dev
25
- - run: uv run pytest tests/ -v
@@ -1,39 +0,0 @@
1
- (version 1)
2
- (deny default)
3
-
4
- ;; Allow reading system libraries and frameworks
5
- (allow file-read*
6
- (subpath "/usr/lib")
7
- (subpath "/usr/local")
8
- (subpath "/System")
9
- (subpath "/Library/Frameworks")
10
- (subpath "/opt/homebrew")
11
- (subpath "/private/tmp")
12
- (subpath "/private/var/folders")
13
- (subpath "/dev")
14
- (subpath "{{CACHE_DIR}}")
15
- (subpath "{{SCRIPT_DIR}}"))
16
-
17
- ;; Allow writing to cache and script dir
18
- (allow file-write*
19
- (subpath "{{CACHE_DIR}}")
20
- (subpath "{{SCRIPT_DIR}}")
21
- (subpath "/private/tmp")
22
- (subpath "/private/var/folders"))
23
-
24
- ;; Allow process operations
25
- (allow process-exec*)
26
- (allow process-fork)
27
-
28
- ;; Allow network access (needed for package downloads and data-fetching scripts)
29
- (allow network*)
30
-
31
- ;; Allow sysctl reads (needed by Python runtime)
32
- (allow sysctl-read)
33
-
34
- ;; Allow mach lookups (needed for DNS resolution, etc.)
35
- (allow mach-lookup)
36
-
37
- ;; Allow IPC operations (needed by some Python extensions)
38
- (allow ipc-posix-shm-read-data)
39
- (allow ipc-posix-shm-write-data)
@@ -1,92 +0,0 @@
1
- """macOS sandbox-exec (Seatbelt) sandbox implementation."""
2
-
3
- from __future__ import annotations
4
-
5
- import shutil
6
- from pathlib import Path
7
-
8
- from mcp_python_exec_sandbox.sandbox import Sandbox
9
-
10
- _PROFILE_DIR = Path(__file__).parent.parent.parent / "profiles"
11
-
12
-
13
- class SandboxExecSandbox(Sandbox):
14
- """macOS sandbox using sandbox-exec with Seatbelt profiles."""
15
-
16
- def __init__(self) -> None:
17
- self._sandbox_exec_path = shutil.which("sandbox-exec")
18
- self._uv_cache_dir = Path.home() / "Library" / "Caches" / "uv"
19
- self._profile_path = _PROFILE_DIR / "sandbox_macos.sb"
20
-
21
- def is_available(self) -> bool:
22
- return self._sandbox_exec_path is not None
23
-
24
- def _build_profile(self, script_dir: str) -> str:
25
- """Build a Seatbelt profile string with the given script directory."""
26
- cache_dir = str(self._uv_cache_dir)
27
-
28
- # If the profile file exists, read and substitute parameters
29
- if self._profile_path.exists():
30
- template = self._profile_path.read_text()
31
- return template.replace("{{SCRIPT_DIR}}", script_dir).replace(
32
- "{{CACHE_DIR}}", cache_dir
33
- )
34
-
35
- # Fallback inline profile
36
- return f"""\
37
- (version 1)
38
- (deny default)
39
-
40
- ;; Allow reading system libraries and frameworks
41
- (allow file-read*
42
- (subpath "/usr/lib")
43
- (subpath "/usr/local")
44
- (subpath "/System")
45
- (subpath "/Library/Frameworks")
46
- (subpath "/opt/homebrew")
47
- (subpath "/private/tmp")
48
- (subpath "/private/var/folders")
49
- (subpath "/dev")
50
- (subpath "{cache_dir}")
51
- (subpath "{script_dir}"))
52
-
53
- ;; Allow writing to cache and script dir
54
- (allow file-write*
55
- (subpath "{cache_dir}")
56
- (subpath "{script_dir}")
57
- (subpath "/private/tmp")
58
- (subpath "/private/var/folders"))
59
-
60
- ;; Allow process operations
61
- (allow process-exec*)
62
- (allow process-fork)
63
-
64
- ;; Allow network access
65
- (allow network*)
66
-
67
- ;; Allow sysctl reads (needed by Python)
68
- (allow sysctl-read)
69
-
70
- ;; Allow mach lookups (needed for DNS, etc.)
71
- (allow mach-lookup)
72
-
73
- ;; Allow ipc operations
74
- (allow ipc-posix-shm-read-data)
75
- (allow ipc-posix-shm-write-data)
76
- """
77
-
78
- def wrap(self, cmd: list[str], script_path: Path) -> list[str]:
79
- script_dir = str(script_path.parent)
80
- profile = self._build_profile(script_dir)
81
-
82
- return [
83
- self._sandbox_exec_path or "sandbox-exec",
84
- "-p",
85
- profile,
86
- *cmd,
87
- ]
88
-
89
- def describe(self) -> str:
90
- if self._sandbox_exec_path:
91
- return f"sandbox-exec ({self._sandbox_exec_path})"
92
- return "sandbox-exec (not found)"