ptc_runner_mcp is a long-running process that speaks
Model Context Protocol 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 lets PTC-Lisp programs call
configured upstream MCP servers via
(tool/call ...). - Agentic mode adds
lisp_task, a natural-language task tool backed by a planner LLM (requires aggregator mode). - Stateful sessions add
lisp_session_*tools that persist(def ...)bindings, the last three results, and bounded history. lisp_debugexposes in-process telemetry rollups for diagnostics.
lisp_eval and lisp_session_eval are rendered through one of three
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;
for every flag and environment variable, see
docs/mcp-server-configuration.md. For
HTTP deployment, see
docs/mcp-server-http-deployment.md. For
the PTC-Lisp language itself, see
docs/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
rs inraspberry" 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_schemato assert the result is{count :int, sum :int}; on mismatch the response carries avalidation_errorthe 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 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 | 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 | 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:
- 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 /mcpbody scoped byMCP-Session-Id. - The dispatcher routes
tools/callto a per-call worker after passing the concurrency semaphore (max_concurrent_calls, default 8). Excess calls return immediately withreason: "busy"instead of queuing. - The worker validates
program/context/output_schema, builds freshLisp.run/2opts (empty memory, empty tool cache, no journal reuse), and invokesPtcToolProtocol.lisp_run/2. :ptc_runnerruns 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.- The result flows back up:
PtcToolProtocol.render_success_from_step/2builds the R22 success payload, orrender_error/3builds 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.
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_startlisp_session_evallisp_session_inspectlisp_session_listlisp_session_forgetlisp_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, nospit, no file I/O at all. - Network access. No
http-get, no socket primitives. The closed-world assumption is asserted by theopenWorldHint: falsetool 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/callruns 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,programandcontextbodies 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-dirset, 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 fullincludes 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 for
the full set.
Links
mcp_server/README.md— install and client configuration.docs/mcp-server-configuration.md— every flag, environment variable, response profile, catalog mode, tracing setup, and lifecycle command.docs/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—lisp_task, the natural-language planner tool layered on top of aggregator mode.docs/mcp-debug.md— the opt-inlisp_debugdiagnostics tool.- Getting started guide — short walkthrough from install to first schema-validated call.
docs/ptc-lisp-specification.md— the PTC-Lisp language reference.docs/function-reference.md— every built-in function with its signature.docs/signature-syntax.md— internal PTC return-type grammar used behindoutput_schemavalidation.- Model Context Protocol — the upstream protocol spec.