Skip to content

About

Run model-written Python against any MCP server's tools in a locked-down Docker sandbox, through a host-side bridge that checks every call.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

mcp-code-mode

Code mode for any MCP server, in Python. Instead of calling a server's tools one at a time, the model writes a short Python program that calls them as async functions. The program runs in a locked-down Docker container, and every call it makes goes through a host-side bridge that checks it before the server sees it.

MCP client --run_python(code)--> mcp-code-mode --docker run--> sandbox: runner.py + the code
                                   |      ^                            |
                                   |      +-- bridge checks each <-----+  one JSON line per call
                                   v
                       wrapped MCP server (stdio)

It takes any server's tools from tools/list, turns each into an async Python function inside the sandbox and a stub the model can read, and serves them as a single MCP tool, run_python. The package runs no model and needs no API key.

Where it comes from

This is the code-mode machinery of bank-reconciliation-agent, without anything specific to bank reconciliation. There it ran in a pre-registered experiment (design, results): containment held in every run and every probe, and code mode was cheaper on average, but the pre-registered efficiency hypothesis failed for both models tested. Read the numbers there; this package carries no measurements of its own.

Related work

Code mode is not new. These do the same job in other places, as their documentation described them on 8 November 2026:

The model writes The code runs in
Cloudflare Code Mode (September 2025) TypeScript A fresh V8 isolate on Workers, made per run through the Worker Loader API. fetch() and connect() throw; tools are bindings, and the keys stay with the supervisor
Claude programmatic tool calling Python Anthropic's code execution container. A tool opts in with allowed_callers
OpenAI programmatic tool calling JavaScript A V8 runtime hosted by OpenAI. A tool opts in with allowed_callers
goose Code Mode JavaScript A Boa context inside goose
pi codemode JavaScript A QuickJS worker inside pi, with searchTools and describeTool to find tools

What this package does differently: the model writes Python, the code runs in a container on your own machine, any MCP client can use it as an ordinary MCP server, and every call passes a bridge on the host whose checks each have a test (see Threat model). It claims no stronger isolation than the hosted runtimes; What it does not protect says where it ends.

Quick start

Needs Python 3.13, uv and Docker.

git clone https://github.com/NecmiKural/mcp-code-mode && cd mcp-code-mode
uv sync
uv run mcp-code-mode build-image

Point an MCP client at mcp-code-mode serve. Everything after -- is the wrapped server's command; here it is the toy server from the tests. For example, in a .mcp.json:

{
  "mcpServers": {
    "code-mode": {
      "command": "uv",
      "args": ["--directory", "/path/to/mcp-code-mode", "run", "mcp-code-mode", "serve",
               "--allow", "add", "--allow", "echo", "--", "uv", "run", "python", "tests/toy_server.py"],
      "env": { "DOCKER_HOST": "unix:///path/to/docker.sock" }
    }
  }
}

env is only needed when Docker is not at its default socket (Rancher Desktop, Colima). The client then lists one tool, run_python, whose description carries a stub per wrapped tool (while they fit; see Finding tools):

async def add(params: dict) -> str:
    """
    Add two integers.

    params: a: int, b: int
    """

Code the model might send:

import asyncio, json

sums = await asyncio.gather(*(add({"a": n, "b": 10}) for n in range(3)))
print([json.loads(s)["sum"] for s in sums])

It comes back as {"stdout": "[10, 11, 12]\n", "error": null, "truncated": false, "stopped": false}.

Options for serve: --allow TOOL (repeatable; default all tools), --private TOOL (repeatable; see Private data), --env NAME (pass that variable from this environment to the wrapped server; repeatable), --config FILE (several servers, instead of the options before it and the command), --image, --deadline in seconds per run, and --max-calls per run.

Finding tools

The description carries every stub while they fit 12,000 characters. Past that it lists one line per tool, by name, while they fit, and says how many it left out:

add(a, b): Add two integers.
create_issue(title, body) [private]: File an issue in the team tracker.
2 of 80 tools shown in one line each; search_tools(query) finds the rest and TOOLS[name] shows a stub.

Inside the code, TOOLS maps each tool's name to its stub, and search_tools(query) lists the tools whose name or stub holds a word of the query, best first (a word scores 2 in the name, 1 in the stub). Both work on the tool list fetched at startup: they never reach a wrapped server and use none of the run's calls. Tool descriptions from the server are cut at 2 KiB.

print(search_tools("issue"))
print(TOOLS["create_issue"])

Several servers

serve --config FILE wraps every server in a file of the mcpServers shape MCP clients already use. Entries are stdio servers: command, args and env as usual (values passed as written; ${VAR} is not expanded), plus allow and private per server. type: "stdio" is accepted; url, headers and any other key stop startup with the entry and the key named, rather than being half supported.

{
  "mcpServers": {
    "notes":   {"command": "uv", "args": ["run", "notes-server"], "allow": ["search"], "private": true},
    "tracker": {"command": "tracker-mcp", "allow": ["create_issue", "find_issue"], "private": ["create_issue"]}
  }
}

Each tool is called by its server's name and its own: await notes__search({"query": "salary"}). One call budget covers a run across every server. Two tools that would get the same name stop startup, and a server that fails to start closes the ones already open.

Private data

Joining servers opens a path one server alone does not have: code reads private data from one tool and hands it to another, whose server then holds it or sends it on. --private TOOL, or private in a config entry (true for every tool of that server), marks the tools that may see private data: their results may hold some, and their arguments may carry some to their server.

From the first call to a private tool on, only private tools can be called, on every server, until mcp-code-mode exits. Any other tool, labelled or not, is refused with a ToolError that says why. The rule lasts for the process, not the run, because what one run printed is in the model's context and the next run can be written from it. Over stdio the process lives as long as the client's connection; restarting a wrapped server does not reset it.

In the config above, the code can read notes and file an issue in the team tracker, but once it has read a note it can no longer call find_issue, whose server could pass on its query. Mark a tool private only if its server may hold the data; the rule is only as good as the labels. Private stubs and summary lines say so, so the model knows before it is refused.

Python API

The bridge takes anything mcp.Client accepts: StdioServerParameters, a URL, or a server object in the same process.

import sys

from mcp import StdioServerParameters

from mcp_code_mode import Bridge, run_code

toy = StdioServerParameters(command=sys.executable, args=["tests/toy_server.py"])
with Bridge(toy, allow=["add", "echo"]) as bridge:
    print(bridge.stubs())  # for your own prompt
    cell = run_code(bridge, "print(await add({'a': 2, 'b': 3}))")
    print(cell.stdout)  # {"sum": 5}

Servers(load_config("servers.json")), or Servers({"name": Entry(server, allow, private)}), offers the same tools, stubs(), call() and budget() over several servers, and run_code and build_server take either.

For your own agent loop, with variables kept between cells, hold one Sandbox and pass bridge.budget(n) as each cell's on_call. build_server(bridge) returns the run_python server without starting it.

Agent skill

skills/mcp-code-mode/SKILL.md tells an agent when to reach for run_python, how to write the code and what the limits are. For Claude Code, copy the folder into ~/.claude/skills/ or into a project's .claude/skills/; agents that read SKILL.md files load it the same way. It is free, like the rest of this repository, and a test keeps its numbers in line with the code.

How a call works

  1. run_python starts a fresh container holding Python and runner.py, nothing else. Each wrapped tool is an async function in the code's globals. MCP names that are not Python identifiers are renamed (get-weather becomes get_weather), and a name that would shadow a keyword, a builtin, a dunder such as __builtins__ or a name the runner defines (ToolError, TOOLS, search_tools) gets a trailing underscore. With --config, each name starts with its server's.
  2. A tool call writes one JSON line to the runner's private copy of stdout. The host reads it as untrusted: if it is too long, not JSON or not a known shape, the run ends as a protocol violation.
  3. The bridge checks the call: the tool is on the allowlist, the arguments are an object of at most 16 KiB that validates against the tool's input schema (arguments the schema does not name are refused unless it allows them), and the run has calls left. If a private tool has been called before, the tool must be private too. Only then does it call the wrapped server, with a 30 second timeout.
  4. The result goes back to the code as a string: the structured content as JSON, else the text. A result over 64 KiB is refused, not cut. A refused or failed call raises ToolError in the code, with a message of at most 500 characters; a fault on the host side reaches the code only as an exception type name.
  5. The run ends when the code finishes, at the deadline (90 seconds), or on a protocol violation. Only what the code printed comes back, at most 8 KiB, with the last lines of a traceback if it failed.

Threat model

The code is written by a model that may be confused or steered by text it read, so it is treated as hostile.

What is protected

  • The host: its files, environment variables, credentials, network position and resources.
  • The wrapped server: from calls outside the allowlist, malformed or oversized arguments, and call floods.
  • The model's context: from runaway output.
  • Private data: from reaching a tool that is not marked private, once a private tool has been called.

Controls

Threat Control Test
Code reads a key or token from the host The container gets no environment from the host, no mounts and no Docker socket. The docker client itself gets only PATH, HOME, DOCKER_HOST and DOCKER_CONTEXT. The image holds Python and runner.py only: no MCP client, no copy of this package test_no_secret_from_the_host_is_inside, test_neither_this_package_nor_an_mcp_client_is_installed, test_nothing_from_the_hosts_environment_reaches_the_code
Code reaches the network --network none test_there_is_no_network
Code writes or plants files Read-only root; /tmp is a 16 MiB tmpfs mounted noexec,nosuid; user 65534; all capabilities dropped; no-new-privileges test_the_filesystem_is_read_only_and_tmp_cannot_execute
Code exhausts the host 64 processes, 256 MiB of memory and no swap, one CPU, 64 open files, and a deadline after which the container is killed test_runaway_code_ends_within_the_deadline (fork bomb, 1 GiB, endless loop), test_runaway_code_is_stopped_and_the_next_run_starts_fresh
Code floods the model's context 8 KiB of output kept; a run that writes 1 MiB is stopped test_output_is_capped
Code forges a protocol message The runner keeps the protocol pipe on private descriptors and points 0, 1 and 2 at /dev/null; the host checks every line's size, JSON and shape, and ends the run on anything else test_plain_output_cannot_forge_a_protocol_message, test_garbage_on_the_protocol_pipe_ends_the_cell_safely
Code calls a tool it should not Only allowlisted tools exist in the sandbox, and the bridge refuses any other name before the server sees it test_unknown_tools_are_refused_before_the_server_sees_them, test_the_allowlist_keeps_other_tools_out
Code sends malformed or huge arguments JSON Schema validation, unnamed arguments refused, a 16 KiB limit test_arguments_outside_the_schema_are_refused, test_oversized_arguments_are_refused
Code loops on a tool, or a tool hangs 200 calls per run, 30 seconds per call test_a_run_has_a_fixed_budget_of_calls, test_a_slow_tool_times_out_and_the_session_survives, test_refusals_reach_the_code_and_the_budget_holds
A tool returns a huge result Refused over 64 KiB test_oversized_results_are_refused_not_cut
Host details leak through errors Only the bridge's own refusal messages reach the code, capped at 500 characters; any other exception only by its type name test_a_host_fault_raises_in_the_cell_without_its_details
Code carries private data to a tool that can send it on After the first private call, every tool that is not private is refused, on every server, until the process exits; a call refused by its schema starts nothing, one that fails after going out still does test_only_private_tools_after_a_private_call, test_a_refused_private_call_does_not_taint, test_a_failed_private_call_still_taints, test_the_taint_lasts_for_the_process, test_private_on_one_server_refuses_another, test_after_a_private_call_only_private_tools_work_in_any_later_run
A label quietly misses its tool A private label on a tool that was not kept (a typo, a bad schema, a name taken) stops startup test_labels_must_name_kept_tools
A server stuffs the description or the search Descriptions cut at 2 KiB; stubs shown only within 12,000 characters, else one line per tool test_long_descriptions_are_cut, test_the_description_falls_back_to_summaries
Discovery reaches a server or spends calls TOOLS and search_tools run in the sandbox over the catalog sent at start test_discovery_never_reaches_the_server
A config option is half supported Only stdio entries; unknown keys, url and headers stop startup by name; a server name twice, empty or not letters, digits, _ or - stops it too; --config excludes the single-server options test_config_errors_name_the_entry_and_key, test_server_names_must_be_unique_words, test_config_excludes_single_server_options
A server's schema crashes startup or breaks a stub A schema too deep to check leaves that tool out; stub types stop at 5 levels; odd or long parameter names are quoted and cut at 64 characters test_a_schema_too_deep_to_check_is_left_out_not_fatal, test_hostile_schemas_still_render_bounded
Two servers collide, or one fails to start A name both would produce stops startup; a failing server closes the ones already open test_a_name_clash_across_servers_stops_startup, test_a_clash_through_the_separator_stops_startup, test_a_failing_entry_closes_the_others
A config's env reaches the code It goes to that wrapped server's process only test_config_serves_two_servers_and_their_env_stays_on_the_host

The tests that need Docker skip locally without it and fail under CI, where the image is built first.

What it does not protect

  • The container shares the host kernel. Docker's default runtime, runc, narrows the kernel surface with seccomp, no capabilities and no network, but a kernel exploit would cross it. gVisor (--runtime=runsc in docker_argv) is the upgrade path for code or data you do not trust at all; it is not wired in or tested here.
  • The wrapped server is not sandboxed. It runs on the host as an ordinary process, with MCP's default environment plus any --env names. The bridge decides which tools and which arguments, not what a tool does with them: an allowlisted tool that deletes, deletes. Allow only what the task needs, and prefer read-only servers.
  • Valid arguments can still do harm. A schema says a string, not which string.
  • Prompt injection is labelled, not filtered. Tool descriptions (in the run_python description) and tool results (through what the code prints) come from the wrapped server and reach the model. They are marked as data. Nothing is redacted: bank-reconciliation-agent fenced and redacted payer text because it knew which fields were untrusted, and a generic wrapper does not.
  • The deadline is checked between messages. A run can overrun it by up to one call timeout, and calls to the wrapped server are made one at a time.
  • Whoever runs mcp-code-mode can run docker, which on Linux is equivalent to root. The process itself is trusted.
  • The base image is pinned by tag, not by digest, and the resource limits are fixed in sandbox.py.
  • Side channels remain: code can time things and read CPU and kernel details inside the container.
  • The private-data rule is only as good as its labels. A tool marked private whose server sends data on defeats it. Unlabelled tools are refused after a private call, so a missing label costs availability, not data.
  • Private data also arrives without a call. Private servers' descriptions and schemas (enums, examples) are in the description and in TOOLS, and nothing starts the rule on them.
  • The rule assumes run_python is the model's only outlet. What a run prints (up to 8 KiB) reaches the model, which may hold other tools in its client.
  • One private call blocks every other tool until restart, even one an injected description talked the model into. That availability is lost on purpose. Asking the user through MCP elicitation, where the client supports it, is the upgrade path.
  • Confidentiality only. A result from an untrusted tool can still steer a later write.

Development

uv sync
uv run mcp-code-mode build-image
uv run ruff check . && uv run ruff format --check . && uv run pytest

tests/toy_server.py is a small MCP server for the tests: the bridge tests run it in process, and the end-to-end tests start it over stdio behind the CLI.

License

MIT

About

Run model-written Python against any MCP server's tools in a locked-down Docker sandbox, through a host-side bridge that checks every call.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages