py-exec-mcp
by blurxy
README.md
# py-exec-mcp
**Run Python from an MCP client without a shell mangling your code.**
[](https://github.com/blurxy/py-exec-mcp/actions/workflows/ci.yml)
[](https://pypi.org/project/py-exec-mcp/)
[](https://pypi.org/project/py-exec-mcp/)
[](LICENSE)
---
## The problem
Ask an agent to run a one-liner and it reaches for `python -c "..."`. That code passes through
**three parsers** before Python ever sees it — the shell, argv splitting, then Python itself — each
with its own escaping rules.
```bash
python -c "print(f'he said \"hi {name}\"')" # which layer eats which quote?
```
The failure mode is what makes this worth fixing: **you usually get a wrong answer, not an error.**
A backslash vanishes, a quote closes early, an f-string silently becomes a literal — and the output
looks plausible. So the agent retries with different escaping, or falls back to writing a temp file
and running it, every single time.
## The fix
Code arrives as a JSON string in the MCP tool call and is handed to the interpreter over **stdin**
(`python -`). No shell. No argv. Nothing re-parses it.
Write the code exactly as it would appear in a `.py` file — nested quotes, f-strings, backslashes,
regex, triple-quoted blocks — and it arrives verbatim.
## Install
```bash
uvx py-exec-mcp # no install
pip install py-exec-mcp # or the usual way
```
Works on **both MCP SDK majors** — 2.x renamed `FastMCP` to `MCPServer`, and the server binds
whichever one your environment has, so you are not forced to pin the SDK to match it.
## Configure
<details open>
<summary><b>Claude Code</b> — <code>.mcp.json</code> in your project root</summary>
```json
{
"mcpServers": {
"py-exec": {
"command": "uvx",
"args": ["py-exec-mcp"]
}
}
}
```
</details>
<details>
<summary><b>Claude Desktop</b> — <code>claude_desktop_config.json</code></summary>
```json
{
"mcpServers": {
"py-exec": {
"command": "uvx",
"args": ["py-exec-mcp"],
"env": { "PY_EXEC_CWD": "/absolute/path/to/your/project" }
}
}
}
```
</details>
<details>
<summary><b>Any client</b> — stdio transport</summary>
```bash
python -m py_exec_mcp
```
</details>
## The tool
### `run_python(code: str, timeout_s: float = 120) -> str`
Returns stdout, stderr and the exit code as text:
```
42
--- stderr ---
warning: something
--- exit 0 ---
```
Four behaviours worth knowing, because each one is a thing that bit somebody:
| behaviour | why |
|---|---|
| **Truncation is announced** | a silently clipped result is indistinguishable from a short one. You get `--- stdout truncated: 12,043 more chars ---` |
| **A timeout still returns output** | the runs that time out are the ones whose partial output matters most |
| **The workdir is on `PYTHONPATH`** | your project's own packages import without a `sys.path` dance |
| **A missing interpreter says so** | rather than failing as an empty result |
## Configuration
All optional. Everything works with none of them set.
| variable | default | what it does |
|---|---|---|
| `PY_EXEC_CWD` | process cwd | directory code runs in, and the root added to `PYTHONPATH` |
| `PY_EXEC_PYTHON` | project venv, else `sys.executable` | interpreter to run code with |
| `PY_EXEC_MAX_TIMEOUT` | `600` | upper clamp on `timeout_s` |
| `PY_EXEC_MAX_OUTPUT` | `30000` | per-stream character cap before truncation |
**Interpreter resolution**, in order: `PY_EXEC_PYTHON` → `.venv/Scripts/python.exe` (Windows) or
`.venv/bin/python` (everywhere else) under the working directory → the interpreter running the
server. So in a project with a virtualenv, your dependencies are simply there.
## ⚠️ Security
**This server executes arbitrary Python with the full privileges of the process that launched it.**
That is its entire purpose, and it is not sandboxed.
- Code runs as **your user**, with your filesystem access and your network access.
- The child process **inherits the server's environment**, so any secrets already exported into it
are readable by executed code. If that matters, launch the server with a scrubbed environment.
- Timeouts bound how long code runs. They bound nothing else.
Give it the same trust you would give a terminal. If you would not paste a script into your shell
and hit enter, do not ask an agent to run it here. For untrusted code, run this inside a container
or a VM — the isolation has to come from the layer underneath, because this server provides none.
## Development
```bash
git clone https://github.com/blurxy/py-exec-mcp
cd py-exec-mcp
pip install -e ".[dev]"
pytest -q
ruff check . && ruff format --check .
```
Tests assert on **what a caller reads** — the returned string — never on "it did not crash". A test
that asserts exit 0 has tested the process surviving, not the answer being right. CI runs the suite
on Linux, macOS and Windows across Python 3.10–3.13, because cross-platform interpreter resolution
is the part most likely to break, plus one job pinned to `mcp<2` — the matrix always resolves the
newest SDK, so without that job the 1.x import path would never be exercised.
## Prior art
MCP has several Python-execution servers, and most target a different problem: sandboxing
(containers, Pyodide, gVisor), or a persistent REPL/kernel where state survives between calls.
This one is deliberately narrow. **It solves the quoting problem and nothing else** — one tool, no
sandbox, no session state, no notebook. If you need isolation, use a sandboxed server. If you need
variables to persist across calls, use a Jupyter-backed one. If you keep losing an afternoon to
escaping, this is the smaller thing.
## Licence
[Apache-2.0](LICENSE)
TDQS
A4.6/5.0
Scored across 1 tool
Disambiguation5/5
Only one tool exists, so there is no possibility of confusing it with another tool. Its purpose is explicit and unique.
Naming Consistency5/5
The single tool name follows a clear verb_noun snake_case convention. There are no other tool names to create inconsistency.
Tool Count5/5
One tool exactly matches the server's narrow purpose of executing Python code. The count is well-scoped without unnecessary breadth.
Completeness5/5
The tool fully covers the execution domain: it handles stdin, working directory, PYTHONPATH, timeouts, and returns stdout, stderr, and exit code. No obvious gaps exist.
Maintenance
ActivityMaintained
ResponsivenessNo issues