MCP Getting Started

Copy Markdown View Source

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 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.

{
  "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 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:

mix ptc.viewer --trace-dir /tmp/ptc-traces

What's next