This guide walks from installing ptc_runner_mcp to making your first
schema-validated call. We use raw JSON-RPC frames so you can see
exactly what the server consumes and emits; in practice your MCP
client (Claude Desktop, Cursor, Cline, …) hides this layer.
The installable release and binary are still named ptc_runner_mcp;
the MCP initialize response advertises the server name ptc_lisp.
For the conceptual overview, see
docs/mcp-server.md. For full client wiring,
see docs/mcp-server-cli.md.
1. Install
Build the Mix release from the repo:
git clone https://github.com/andreasronge/ptc_runner
cd ptc_runner/mcp_server
mix deps.get
MIX_ENV=prod mix release
The release lives at
_build/prod/rel/ptc_runner_mcp/bin/ptc_runner_mcp. Smoke-test it:
_build/prod/rel/ptc_runner_mcp/bin/ptc_runner_mcp version
# → ptc_runner_mcp 0.1.0
For Claude Desktop / Cursor / Cline / Claude Code wiring, use the
ready-to-paste snippets in
docs/mcp-server-cli.md. The rest of this
guide drives the server with raw JSON-RPC frames piped into the
release; that lets us inspect every byte.
2. Hello world: (+ 1 2)
Pipe a small JSON-RPC session into the server. The session must
include initialize and the notifications/initialized notification
before any tools/call.
cat <<'EOF' | _build/prod/rel/ptc_runner_mcp/bin/ptc_runner_mcp start --response-profile structured
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"hello","version":"0.0.0"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"lisp_eval","arguments":{"program":"(+ 1 2)"}}}
EOF
The third frame is the call. This guide starts the server with
--response-profile structured so the examples can show typed
structuredContent. In the production default slim profile,
successful eval tools return concise text only and do not advertise
outputSchema.
The server's response (one NDJSON line on stdout, formatted here for
readability) wraps the success payload in the standard MCP
tools/call envelope:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"isError": false,
"structuredContent": {
"status": "ok",
"result": "user=> 3"
},
"content": [
{ "type": "text", "text": "user=> 3" }
]
}
}The result field ("user=> 3") is an LLM-facing preview — an
EDN/Clojure rendering of the program's final expression, not a
programmatic value. To get a typed value back, supply an
output_schema (step 4 below).
Note: each MCP tools/call is one-shot — defn'd names do NOT
persist into the next call. The response intentionally omits any
memory field so callers don't infer state from a single program's
local definitions (issue #879).
In the structured profile, the content[0].text block carries
concise human-readable text while structuredContent carries the
machine-readable payload. Use --response-profile debug only when you
need the old verbose mirrored payload for troubleshooting.
3. Add context: bind values under data/
The context field is a JSON object whose keys become bindings under
the data/ namespace inside the program. There is no context
binding — you reference values as data/<key>.
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "lisp_eval",
"arguments": {
"program": "(reduce + (map #(get % \"value\") data/items))",
"context": {
"items": [
{"label": "a", "value": 10},
{"label": "b", "value": 20},
{"label": "c", "value": 12}
]
}
}
}
}The server binds data/items to the JSON array, runs the program in
the sandbox, and returns result: "user=> 42" (same envelope shape as
step 2).
A few points worth knowing up front:
- Map keys stay as strings. To read
valueyou write(get % "value"), not(:value %). PTC-Lisp does not auto-convert string keys to keywords — that is intentional, to keep the JSON ↔ PTC-Lisp boundary honest. - A program that references
data/foowhenfoois absent returnsreason: "runtime_error"with a message naming the missing binding. - Keys may not contain
/(would shadow the namespace) or be empty; either causesargs_error.
4. Add output_schema: get a typed validated value back
The result field is a preview string. To consume the program's
return value programmatically, pass JSON Schema in output_schema. On
match, the response carries a structured validated JSON value
alongside the preview.
{
"jsonrpc": "2.0",
"id": 4,
"method": "tools/call",
"params": {
"name": "lisp_eval",
"arguments": {
"program": "(let [vs (map #(get % \"value\") data/items)] {:total (reduce + vs) :count (count vs)})",
"context": {
"items": [
{"label": "a", "value": 10},
{"label": "b", "value": 20},
{"label": "c", "value": 12}
]
},
"output_schema": {
"type": "object",
"properties": {
"total": { "type": "integer" },
"count": { "type": "integer" }
},
"required": ["total", "count"]
}
}
}
}Successful response:
{
"isError": false,
"structuredContent": {
"status": "ok",
"result": "user=> {:total 42, :count 3}",
"validated": { "total": 42, "count": 3 }
}
}validated.total is a real JSON 42 — your client can read it
directly; no parsing of the preview string. If the program returned a
shape that did not match the schema (e.g. :total was a string),
the response would have isError: true and reason: "validation_error", with a feedback string the calling LLM can use
to self-correct.
output_schema is the path to programmatic data on this surface.
Without it, the response carries the LLM-readable preview only — by
design, to keep the boundary between LLM-facing text and machine-facing
JSON sharp.
5. Inspect a trace file
Tracing is opt-in and off by default. To turn it on, pass
--trace-dir:
mkdir -p /tmp/ptc-traces
_build/prod/rel/ptc_runner_mcp/bin/ptc_runner_mcp start \
--response-profile structured \
--trace-dir /tmp/ptc-traces
(You can append the flag to the args array in any client config —
e.g. "args": ["start", "--trace-dir", "/tmp/ptc-traces"].)
After running a tools/call, one JSONL file appears under the trace
directory, one line per telemetry event:
ls /tmp/ptc-traces/
# → 2026-05-07T12-34-56_<uuid>.jsonl
head -1 /tmp/ptc-traces/*.jsonl
# {"event":"trace.start","trace_kind":"mcp_call","ts":"2026-05-07T12:34:56Z",...}
A typical trace contains, in chronological order:
trace.start— header with the trace UUID.[:ptc_lisp, :call, :start]— the MCP call began.[:ptc_runner, :lisp, :execute, :start]— the sandbox started.[:ptc_runner, :lisp, :execute, :stop]— the sandbox finished.[:ptc_lisp, :call, :stop]— the MCP call returned.trace.stop— footer.
Two flags shape what gets written:
--trace-payloads summary(default when tracing is on) — records sizes and SHA-256 digests ofprogram/context/resultbytes. Safe for shared logs.--trace-payloads full— includes verbatim bytes. Use only when actively debugging a specific reproduction.--trace-max-files 1000(default) — rolling-deletion cap on the trace directory.
To browse traces interactively, point the trace viewer at the directory:
mix ptc.viewer --trace-dir /tmp/ptc-traces
What's next
docs/mcp-server.md— security model, comparison with Python / JS execution servers, architecture diagram.docs/ptc-lisp-specification.md— the PTC-Lisp language reference (a Clojure subset).docs/function-reference.md— every built-in function with its signature.