# MCP Getting Started
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`](../mcp-server.md). For full client wiring,
see [`docs/mcp-server-cli.md`](../mcp-server-cli.md).
## 1. Install
Build the Mix release from the repo:
```bash
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:
```bash
_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`](../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`.
```bash
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:
```json
{
"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>`.
```json
{
"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 `value` you 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/foo` when `foo` is absent returns
`reason: "runtime_error"` with a message naming the missing
binding.
- Keys may not contain `/` (would shadow the namespace) or be empty;
either causes `args_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.
```json
{
"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:
```json
{
"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`:
```bash
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:
```bash
ls /tmp/ptc-traces/
# → 2026-05-07T12-34-56_<uuid>.jsonl
```
```bash
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 of `program` / `context` / `result` bytes.
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:
```bash
mix ptc.viewer --trace-dir /tmp/ptc-traces
```
## What's next
- [`docs/mcp-server.md`](../mcp-server.md) — security model, comparison
with Python / JS execution servers, architecture diagram.
- [`docs/ptc-lisp-specification.md`](../ptc-lisp-specification.md) —
the PTC-Lisp language reference (a Clojure subset).
- [`docs/function-reference.md`](../function-reference.md) — every
built-in function with its signature.