# PtcRunner MCP Server
`ptc_runner_mcp` is a long-running process that speaks
[Model Context Protocol](https://modelcontextprotocol.io/) over stdio
JSON-RPC by default, with an opt-in Streamable HTTP listener for
private-network deployments. It advertises the MCP server name
`ptc_lisp` and exposes `lisp_eval` to MCP
clients (Claude Desktop, Cursor, Cline, Claude Code, MCP Inspector,
agentic applications, …). The tool
accepts a PTC-Lisp program plus optional `context` and `output_schema`,
runs it in an isolated BEAM sandbox, and
returns a structured result.
The server has no LLM of its own. The MCP client's LLM does the
reasoning; PtcRunner is invoked only when deterministic computation is
useful — counting, filtering, JSON shape validation, schema-driven
extraction. State does not persist between calls; each `tools/call` is
independent unless the server is started with the optional session
tools enabled.
Several capabilities are opt-in and add their own top-level tools:
- [Aggregator mode](aggregator-mode.md) lets PTC-Lisp programs call
configured upstream MCP servers via `(tool/call ...)`.
- [Agentic mode](agentic-mode.md) adds `lisp_task`, a
natural-language task tool backed by a planner LLM (requires
aggregator mode).
- [Stateful sessions](#stateful-sessions) add `lisp_session_*` tools
that persist `(def ...)` bindings, the last three results, and
bounded history.
- [`lisp_debug`](mcp-debug.md) exposes in-process telemetry rollups
for diagnostics.
`lisp_eval` and `lisp_session_eval` are rendered through one of three
[response profiles](mcp-server-configuration.md#response-profiles)
(`slim` / `structured` / `debug`), defaulting to `slim`.
Those profiles also control client-facing output limits for prints,
feedback, schema-validated values, and final envelope size. Oversized
validated values are exact-or-absent: the server omits `validated` and
returns `validated_preview`, `validated_bytes`, and
`output_truncated` metadata instead of putting a partial value under
`validated`.
This document is the conceptual overview. For install + client
configuration, see [`mcp_server/README.md`](../mcp_server/README.md);
for every flag and environment variable, see
[`docs/mcp-server-configuration.md`](mcp-server-configuration.md). For
HTTP deployment, see
[`docs/mcp-server-http-deployment.md`](mcp-server-http-deployment.md). For
the PTC-Lisp language itself, see
[`docs/ptc-lisp-specification.md`](ptc-lisp-specification.md).
## When to use it
Reach for the MCP server when the calling LLM needs deterministic
compute and you want that capability available across MCP clients —
not just from Elixir.
Concrete cases:
- **Counting that LLMs get wrong.** The classic "how many `r`s in
`raspberry`" failure mode. A two-line PTC-Lisp program counts
reliably; the LLM consumes the result.
- **Arithmetic over data the LLM has already seen.** Sum a list of
order totals, average a column, compute a percentile — without
trusting the model's mental math.
- **JSON shape validation.** Pass an `output_schema` to assert the
result is `{count :int, sum :int}`; on mismatch the response
carries a `validation_error` the model can self-correct from.
- **Schema-validated extraction.** Filter a JSON array down to
records matching some condition and return them as a typed,
programmatic value the calling client can consume directly.
- **Cross-server orchestration.** With [aggregator
mode](aggregator-mode.md) on, one PTC-Lisp program can fan out to
several configured upstream MCP servers, reduce the results, and
return the collapsed answer — typically an order of magnitude fewer
bytes than the LLM doing the same work N round-trips at a time.
- **Filtering or reshaping a structured payload** that already lives
in the conversation. A small PTC-Lisp program is cheaper and more
reliable than a long natural-language transformation.
It is **not** the right fit when the work needs filesystem access or
direct network access. PTC-Lisp programs have none of those, by
construction. If the work needs REPL-like memory across calls, the MCP
server can optionally expose stateful PTC-Lisp session tools.
## Comparison with Python / JS execution servers
Other "code interpreter" MCP servers exist. Most run Python or
JavaScript inside a container or a process sandbox. The trade-offs
look like this:
| Concern | `ptc_runner_mcp` | Python-exec server | JS-exec server |
|---|---|---|---|
| Sandboxing | BEAM process, no I/O, no FS, no net (by construction) | Container or `seccomp` (operator-configured) | Container, VM2, or `vm` module (operator-configured) |
| Authoring overhead for the LLM | Zero — every program is a self-contained expression | Imports, virtualenv awareness, library knowledge | `require`, `import`, package availability |
| Schema validation of the return value | First-class — `output_schema` JSON Schema, structured `validated` payload | None (server returns raw stdout / repr) | None (server returns raw value or `JSON.stringify`) |
| Stable wire format | Yes — same R22/R23 contract as in-process PtcRunner | None standardized | None standardized |
| Network access from the program | None directly — opt-in only via configured upstream MCP servers in [aggregator mode](aggregator-mode.md) | Often available unless the operator sandboxes harder | Often available unless the operator sandboxes harder |
| Filesystem access from the program | None directly — opt-in only via filesystem-MCP upstreams in [aggregator mode](aggregator-mode.md) | Often available unless the operator sandboxes harder | Often available unless the operator sandboxes harder |
| Install footprint | Single Mix release (BEAM + ERTS bundled) | Python + interpreter + libs + sandbox tooling | Node + sandbox tooling |
| Concurrency model | Per-call BEAM process, semaphore-bounded | Process-per-call (typical) or threadpool | Worker-per-call or single-loop |
| Default resource caps | 1 s wall-clock, 10 MB memory, 64 KB program, 4 MB context, 8 concurrent (aggregator mode raises the per-program caps to 10 s / 100 MB) | Operator-defined | Operator-defined |
The PtcRunner pitch in one line: **the sandbox is the language, not
the deployment**. Other servers ship a general-purpose interpreter and
ask the operator to lock it down. PtcRunner ships an interpreter that
cannot leak in the first place.
## Architecture
```
MCP client (Claude Desktop, Cursor, Cline, …)
or trusted private-network HTTP client
│ stdio NDJSON or Streamable HTTP
▼
┌─────────────────────────────────────────┐
│ ptc_runner_mcp │
│ │
│ Stdio transport or HTTP session │
│ │ │
│ ▼ │
│ JSON-RPC dispatcher │
│ (initialize, tools/list, │
│ tools/call, notifications/cancelled, │
│ shutdown, exit) │
│ │ │
│ ▼ │
│ Per-call worker ◄── concurrency │
│ process semaphore │
│ │ │
│ ▼ │
│ PtcToolProtocol.lisp_run/2 │
│ │ │
└──────────────┼──────────────────────────┘
▼
:ptc_runner library
│
▼
PtcRunner.Sandbox
(isolated BEAM process,
1 s wall-clock, 10 MB heap)
│
▼
PtcRunner.Lisp.Eval
(PTC-Lisp interpreter:
Clojure subset + java.time +
clojure.string, clojure.set,
clojure.walk)
```
Each `tools/call` request flows top-to-bottom:
1. The transport frames one JSON-RPC message and hands it to the
dispatcher. In stdio mode that is one NDJSON line; in HTTP mode it
is one `POST /mcp` body scoped by `MCP-Session-Id`.
2. The dispatcher routes `tools/call` to a per-call worker after
passing the concurrency semaphore (`max_concurrent_calls`,
default 8). Excess calls return immediately with `reason: "busy"`
instead of queuing.
3. The worker validates `program` / `context` / `output_schema`, builds fresh `Lisp.run/2` opts (empty memory,
empty tool cache, no journal reuse), and invokes
`PtcToolProtocol.lisp_run/2`.
4. `:ptc_runner` runs the program in an isolated BEAM process with a
1 s wall-clock cap and a 10 MB heap cap (10 s / 100 MB in
aggregator mode). Filesystem and network APIs are not exposed to
the program; aggregator-mode programs reach out only through the
mediated `(tool/call …)` builtin.
5. The result flows back up: `PtcToolProtocol.render_success_from_step/2`
builds the R22 success payload, or `render_error/3` builds the R23
error payload. The MCP envelope (`isError`, `structuredContent`,
`content`) wraps it. The transport writes one response frame back.
`notifications/cancelled` from the client kills the in-flight sandbox
process. stdin EOF cancels every stdio in-flight call and exits
cleanly; HTTP `DELETE /mcp` closes one protocol session and cancels its
in-flight work.
## Streamable HTTP
Start with `--http` to serve MCP over Streamable HTTP at `/mcp`
(`127.0.0.1:7332` by default). `POST /mcp` accepts one JSON-RPC
message. `GET /mcp` returns `405` until SSE/resumability support is
added. `DELETE /mcp` terminates the protocol session named by
`MCP-Session-Id`.
HTTP mode is designed for private-network service deployment behind a
TLS edge or load balancer. It requires a bearer token for non-loopback
binds, validates browser `Origin` headers, exposes unauthenticated
`/health` and `/ready`, and stamps request logs/telemetry/traces with
hashed owner/session ids. See
[`mcp-server-http-deployment.md`](mcp-server-http-deployment.md).
## Stateful Sessions
By default, `lisp_eval` is stateless: every call starts with
empty Lisp memory and an empty tool cache. Starting the server with
`--sessions` or `PTC_RUNNER_MCP_SESSIONS=true` adds explicit session
tools:
- `lisp_session_start`
- `lisp_session_eval`
- `lisp_session_inspect`
- `lisp_session_list`
- `lisp_session_forget`
- `lisp_session_close`
Session evals persist explicit `(def ...)` and `(defn ...)` bindings,
the last three successful eval results (`*1`, `*2`, `*3`), captured
`println` output, and bounded/redacted tool-call history. `let`
bindings and ordinary intermediate values do not persist. Use
`lisp_session_forget` to remove stale or large bindings and clear
bounded histories.
`lisp_session_list` returns metadata-only live sessions for the current
owner. It does not render stored binding values or refresh session idle
timers.
Sessions are in-memory, owner-scoped, disabled by default, TTL/idle
bounded, and allow at most one eval in a given session at a time.
`lisp_session_eval` uses the same global concurrency gate as
`lisp_eval`; a second eval on the same session returns
`session_busy` rather than queueing.
`lisp_session_eval` follows the same response-profile model as
`lisp_eval`. In the default `slim` profile it returns text only and
does not advertise `outputSchema`. In `structured`, it returns compact
`structuredContent` with the result, session summary, and
`memory.changed_keys` / `stored_keys`; it does not echo stored binding
values. Full binding previews, upstream-call ledgers, and
`ptc_metrics` are diagnostics and are available through `lisp_debug`
or session inspection paths rather than normal slim/structured eval
responses.
## Security model
`ptc_runner_mcp` is a **stdio MCP server**. That has implications the
operator must own; the package cannot enforce them.
### Trust boundary
The server runs under the user's auth context — the same as every
other stdio MCP server. Anyone with stdio access can submit arbitrary
PTC-Lisp. The sandbox is the protection against that program; the
operator is responsible for not exposing the server to untrusted
callers.
### What the sandbox protects against
- **Filesystem reads / writes.** PTC-Lisp has no `slurp`, no `spit`,
no file I/O at all.
- **Network access.** No `http-get`, no socket primitives. The
closed-world assumption is asserted by the `openWorldHint: false`
tool annotation.
- **Process exec.** No shell-out, no `:os.cmd`, no port spawn.
- **Resource exhaustion.** Five distinct caps:
`max_frame_bytes` (8 MB), `max_program_bytes` (64 KB),
`max_context_bytes` (4 MB), `max_concurrent_calls` (8 by default),
plus the per-program 1 s wall-clock and 10 MB memory limits.
Crossing a cap returns a structured error result, not a server
crash.
- **Cross-call leakage.** Each `tools/call` runs in a fresh BEAM
process with empty user namespace, empty tool cache, and no journal
reuse. Two sequential calls cannot see each other's `(def …)`
bindings — asserted by an isolation regression test.
### What the sandbox does NOT protect against
- **A malicious operator with stdio access.** The server trusts the
bytes on its stdin. If an attacker controls the calling MCP client
or the spawning process, no in-server check helps.
- **The operator's own log files.** If the operator runs the server
with `--log-level debug`, `program` and `context` bodies land in
stderr logs. That is intentional for debugging but means the
operator owns that data's hygiene. Defaults are conservative:
full payloads are debug-level only.
- **Trace files.** With `--trace-dir` set, per-call JSONL traces are
written to disk. `--trace-payloads summary` (the default when
tracing is on) records sizes and SHA-256 digests only;
`--trace-payloads full` includes verbatim bytes. The choice is the
operator's; the package does not exfiltrate.
- **Side channels in the calling LLM.** The server returns whatever
the program returns. If the program is constructed to encode
caller-supplied secrets in its result, those bytes flow back to
the client. PtcRunner has nothing to say about what the calling
LLM does with its inputs.
### Operator log hygiene
Default log levels are conservative. `info` and above never include
verbatim `program` or `context`. Traces are off unless `--trace-dir`
is set. If you do enable tracing, prefer `--trace-payloads summary`
unless you are actively debugging a specific reproduction.
## Limits
| Limit | Default | Configurable |
|---|---|---|
| `max_frame_bytes` | 8 MiB | `--max-frame-bytes` / env |
| `max_program_bytes` | 64 KiB | `--max-program-bytes` / env |
| `max_context_bytes` | 4 MiB | `--max-context-bytes` / env |
| `max_concurrent_calls` | `min(8, logical_processors)` | `--max-concurrent-calls` / env |
| Program wall-clock | 1 s (10 s in aggregator mode) | `--program-timeout-ms` / env |
| Program memory | 10 MB (100 MB in aggregator mode) | `--program-memory-limit-bytes` / env |
Frame overflow surfaces as a JSON-RPC `-32700`. The other caps surface
as structured tool-result errors (`args_error`, `busy`, `timeout`,
`memory_limit`). Aggregator mode adds its own per-call caps
(`upstream_call_timeout_ms`, `max_upstream_response_bytes`,
`max_upstream_calls_per_program`); see
[`docs/mcp-server-configuration.md`](mcp-server-configuration.md) for
the full set.
## Links
- [`mcp_server/README.md`](../mcp_server/README.md) — install and
client configuration.
- [`docs/mcp-server-configuration.md`](mcp-server-configuration.md) —
every flag, environment variable, response profile, catalog mode,
tracing setup, and lifecycle command.
- [`docs/aggregator-mode.md`](aggregator-mode.md) — calling configured
upstream MCP servers from inside the sandbox via `(tool/call …)`,
plus the payload-reduction metrics emitted on every aggregator
response.
- [`docs/agentic-mode.md`](agentic-mode.md) — `lisp_task`, the
natural-language planner tool layered on top of aggregator mode.
- [`docs/mcp-debug.md`](mcp-debug.md) — the opt-in `lisp_debug`
diagnostics tool.
- [Getting started guide](guides/mcp-getting-started.md) — short
walkthrough from install to first schema-validated call.
- [`docs/ptc-lisp-specification.md`](ptc-lisp-specification.md) — the
PTC-Lisp language reference.
- [`docs/function-reference.md`](function-reference.md) — every
built-in function with its signature.
- [`docs/signature-syntax.md`](signature-syntax.md) — internal PTC
return-type grammar used behind `output_schema` validation.
- [Model Context Protocol](https://modelcontextprotocol.io/) — the
upstream protocol spec.