PtcRunner MCP Aggregator Mode

Copy Markdown View Source

Reference for operating PtcRunner's MCP server as a programmatic tool-calling aggregator over configured upstream MCP servers.

Overview

Aggregator mode does not advertise upstream tools individually. It adds (tool/call ...) to the PTC-Lisp sandbox — a programmatic primitive that calls configured upstream MCP servers and composes their results deterministically inside the sandbox. In an aggregator-only configuration, the MCP server advertises lisp_eval; optional features such as sessions, diagnostics, or agentic tools may add their own top-level tools. One LLM-authored, sandboxed PTC-Lisp program replaces N round-trip tools/call invocations against the calling client.

Best fit:

  • Ad-hoc cross-server joins (search server A, filter on facts from server B).
  • Filtering / aggregating large upstream tool outputs before they reach the calling LLM.
  • Reducing context pressure: only the program's final value crosses back to the client.
  • Deterministic transforms over upstream results.

Poor fit:

  • Workflows requiring model judgment between tool calls (use multi-turn lisp_eval instead, or hand-written application code).
  • Mature repeated workflows that should be hand-written application code.
  • Setups needing broad MCP gateway features from day one — the aggregator is a primitive, not a gateway. See §15 Positioning.

The aggregator is not an agent framework. It is one deterministic step.

Configuration

Aggregator mode is opt-in. The MCP server resolves the upstreams config from the first match in:

  1. --upstreams-config <path> flag.
  2. PTC_RUNNER_MCP_UPSTREAMS env var.
  3. ~/.config/ptc_runner_mcp/upstreams.json (XDG default).

If none is found, the server runs in MCP v1 (:mcp_no_tools) mode and (tool/call ...) is unavailable.

Format — MCP stdio upstream

{
  "upstreams": {
    "fs": {
      "transport": "mcp_stdio",
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp/sandbox"]
    },
    "github": {
      "transport": "mcp_stdio",
      "command": "github-mcp",
      "args": [],
      "env": {
        "GITHUB_TOKEN": "${GITHUB_TOKEN}"
      }
    }
  }
}

${VAR} placeholders inside stdio env values are resolved from the parent-process environment at startup. Unset variables abort startup with a clear error. Note: the ${VAR} resolver is narrowed to stdio env only — credentials, MCP HTTP url, static_headers, proxy, and other fields are parsed literally.

Format — MCP HTTP upstream + credentials

Aggregator mode also supports Streamable HTTP upstreams (MCP rev 2025-06-18) alongside stdio, with a credentials registry:

{
  "credentials": {
    "github-pat": {
      "source": "env",
      "var": "GITHUB_PAT"
    }
  },
  "upstreams": {
    "github": {
      "transport": "mcp_http",
      "url": "https://api.githubcopilot.com/mcp/",
      "auth": [
        { "scheme": "bearer", "binding": "github-pat" }
      ],
      "static_headers": {
        "X-MCP-Readonly": "true"
      }
    },
    "fs": {
      "transport": "mcp_stdio",
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp/sandbox"]
    }
  }
}

Mixed transports are fully supported. From the program's perspective, an MCP HTTP upstream and an MCP stdio upstream are indistinguishable.

Format — OpenAPI upstream

Read-only JSON OpenAPI upstreams can be configured beside MCP stdio and MCP HTTP upstreams. The v1 OpenAPI adapter is intentionally narrow: only explicitly included GET operations are compiled, request bodies are rejected, header/cookie parameters are rejected, and successful responses must be JSON or empty 204 responses.

Prefer schema_file for production so boot does not depend on the schema host. schema_url is supported for controlled environments; it uses the same static_headers / auth emitters as HTTP upstreams and is fetched at upstream start with schema_max_bytes enforced before decode.

{
  "credentials": {
    "observatory-token": {
      "source": "file",
      "path": "/run/secrets/observatory-token",
      "scheme_hint": "bearer"
    }
  },
  "upstreams": {
    "observatory": {
      "transport": "openapi",
      "base_url": "https://observatory.example",
      "schema_file": "/absolute/path/to/observatory.openapi.json",
      "auth": [
        { "scheme": "bearer", "binding": "observatory-token" }
      ],
      "include_operations": [
        "list_traces",
        "get_trace",
        "list_trace_steps",
        "get_trace_cost"
      ],
      "operation_overrides": {
        "list_trace_steps": {
          "default_args": { "summary": true }
        }
      }
    }
  }
}

The compiled tool names are the exposed catalog names, normalized to the same server/tool surface as MCP tools. Original OpenAPI operationId values are retained in meta under _ptc.operationId for provenance.

Credentials. Top-level credentials: block holds named bindings. Three sources are supported in v1: env (read from process env during runtime startup), file (read + trim trailing whitespace during runtime startup), and literal (value embedded in config). The reserved source exec is deferred.

Auth emitters. Each HTTP upstream's auth: is an ordered list. Three schemes:

  • bearerAuthorization: Bearer <value>
  • basicAuthorization: Basic base64(user:pass). The binding's value may be either user:pass or a JSON {"user":"…","pass":"…"} shape.
  • custom_header<header>: <value>. The header name MUST match RFC 7230 token grammar and MUST NOT be Authorization (use bearer/basic) or any of the impl-controlled protocol headers (MCP-Protocol-Version, Mcp-Session-Id, User-Agent).

Static headers. static_headers: sets literal non-secret headers (e.g., X-MCP-Readonly, X-Tenant). The ${VAR} resolver does NOT touch these — they are parsed verbatim. Sensitive header names are rejected at config-load: Authorization, Proxy-Authorization, Cookie, Set-Cookie, X-Api-Key, plus the protocol-controlled triple. Use auth: emitters for any secret-bearing header.

HTTPS by default. Plain http:// URLs are rejected unless allow_insecure_http: true is set. Sending auth: over plain HTTP additionally requires allow_insecure_auth: true — two explicit opt-ins.

Optional :req dep. MCP HTTP and OpenAPI transports require the :req package. It's an optional Mix dep — stdio-only operators don't need it. If a transport: "mcp_http" entry is configured but :req is unloaded, boot fails loudly with a clear message.

Resolution semantics.

  • Resolved auth bytes are NEVER stored in upstream config maps, Connection state, trace JSONL, upstream_calls envelopes, or Logger output. Structural isolation is the primary guarantee.
  • The redactor (substring-replaces registered plaintext with [REDACTED] in any formatted string the logger / trace / upstream_calls writers emit) is defense in depth.
  • Catalog and discovery output is scrubbed before it reaches MCP tools/list, REPL discovery forms, traces, debug records, or session history. This protects against a malicious or buggy authenticated upstream echoing a credential in tools/list.
  • The MCP server registers root upstream runtime secrets with its process-wide redaction stack so trace files, lisp_debug, session stores, logs, and agentic planner prompts share the same defense-in-depth scrub set.
  • env / file bindings are resolved once when the root upstream runtime starts. Rotate an env var or replace a credential file by restarting the REPL or MCP server process.

Operational notes

  • Self-as-upstream is rejected at startup (a configured command whose resolved path equals the running PtcRunner release). The check applies to stdio entries; an HTTP URL pointing at this PtcRunner's loopback is technically possible and unsafeguarded — programs that loop will eventually hit max_upstream_calls_per_program.
  • There is no JSON fake transport. Tests use root-runtime fixtures/helpers rather than production config fields.
  • The MCP server runs the shared root upstream runtime in frozen snapshot mode. Config parse failures, credential binding failures, MCP client startup failures, or tools/list failures fail server startup. Root mix ptc.repl defaults to live snapshot mode, where MCP client startup/listing is attempted on discovery or call.

Writing PTC-Lisp programs against tool/call

Call shape

(tool/call {:server "<configured-name>"
                :tool   "<upstream-tool>"
                :args   {<args map>}})

:server and :tool are required keys. :args is optional and defaults to {} when omitted; include it whenever the upstream tool takes arguments.

tool/call is a runtime callable in value position, so direct higher-order use is valid:

;; OK
(map tool/call
     [{:server "github" :tool "get_pr" :args {:number 101}}
      {:server "github" :tool "get_pr" :args {:number 102}}])

;; OK
(pmap tool/call
      [{:server "fs" :tool "read" :args {:path "a.txt"}}
       {:server "fs" :tool "read" :args {:path "b.txt"}}])

;; Also OK when argument construction is needed
(map (fn [n]
       (tool/call {:server "github"
                       :tool "get_pr"
                       :args {:number n}}))
     pr-numbers)

Return-value handling

A successful call returns tagged PTC-Lisp data. The program checks :ok and then treats :value as an ordinary value:

(def repos
  (let [r (tool/call {:server "github"
                          :tool "search_repos"
                          :args {:query "infra" :limit 50}})]
    (if (:ok r)
      (:value r)
      (fail (:message r)))))

(count repos)              ;; just a number
(map :name repos)          ;; pluck a field per repo

JSON helpers (json/*)

tool/call returns tagged data. Always inspect :ok before using :value.

ShapeMeaning
{:ok true :value payload :value_kind :json}payload came from structuredContent or parsed JSON text.
{:ok true :value text :value_kind :text}First MCP text content was not JSON.
{:ok true :value nil :value_kind :none}The call succeeded, but no default payload was selected.
{:ok false :reason kw :message text}Recoverable upstream/tool failure.

Many MCP upstreams wrap their payload in the standard envelope %{"content" => [%{"type" => "text", "text" => "..."}]}, sometimes with typed JSON in "structuredContent". The aggregator unwraps the common domain payload for you:

(let [r (tool/call {:server "issues" :tool "list" :args {}})]
  (if (:ok r)
    (get (:value r) "items")
    (fail (:message r))))

World-fault vs programmer-fault

ClassBehaviorWhen
World-fault(tool/call ...) returns {:ok false :reason kw :message text}; entry recorded in upstream_calls; program continuesUpstream couldn't be started, returned a JSON-RPC error, timed out, oversized response, per-program cap exhausted, or returned an MCP isError envelope
Programmer-faultRaises a runtime error; program terminatesUnknown server, unknown tool on a healthy upstream, malformed args

World-faults are expected runtime conditions — write defensive code: inspect :ok before using :value, keep successful results with (filter :ok batch), and inspect :reason / :message when a call fails.

Programmer-faults are defects in the program — the message identifies the bad call site so the LLM can fix the program and retry on the next turn.

Catalog

In aggregator mode, upstream-capable lisp_eval and lisp_session_eval descriptions start with a short discovery hint: use (apropos ...), (dir ...), and (doc ...), then call the selected upstream with tool/call. In inline catalog mode the dynamic tail also includes a synthetic discovery snapshot:

Configured upstream MCP servers:
- fs: Filesystem MCP server. 2 tools. files.
  Tools:
  - read_text_file - Read the contents of a UTF-8 text file
  - list_directory - List the entries in a directory
- github: GitHub MCP server. 2 tools. issues, pull requests.
  Tools:
  - search_repos - Search repositories
  - get_pr - Get a pull request

Reading the catalog

  • dir / apropos: list tool names and short descriptions only. Use them to choose a tool, not to infer schemas.
  • doc: shows args, required args, the call form, and the Clojure-ish Result<...> payload shape.
  • Args in doc: :name type for required, :name type? for optional. The optional ? is the LLM's signal to omit the arg or pass nil.
  • Argument order: required args first in the JSON Schema's required-array order; optional args alphabetical. This is a rendering-side determinism rule; the upstream itself accepts any order.
  • Types: string, integer, number, boolean, object, array, null. Complex types (object, array) are rendered as the bare type name — the LLM does not see the full nested schema.
  • Constraints (priority over type):
    • enum constraints render as enum<type> when every listed value shares one primitive type (e.g. enum<string>, enum<integer>), or bare enum for heterogeneous values. The subscript form is the dominant real-world shape — most enums are uniformly-typed string sets.
    • const renders as const<json-encoded-value> so the LLM sees both "this argument is a fixed literal" and what the literal is. Strings carry their JSON quotes (const<"fixed">), numbers and booleans render bare (const<42>, const<true>). Falsy consts are detected by key-presence rather than truthiness, so {"const": false} / {"const": null} / {"const": 0} / {"const": ""} all render const<…> and do not collapse to the primitive type label.
    • Both override the primitive type label: a schema like {"type": "string", "enum": ["open","closed"]} renders as enum<string>, NOT string. Constrained args are exactly where the LLM most needs the constraint hint.
  • Description: optional prose is normalized to one line and capped. Auto mode drops descriptions before the renderer falls back to lazy mode.

When the catalog is populated

Catalog population is controlled by the root upstream runtime's snapshot mode:

  • Frozen starts and lists configured MCP stdio/http upstreams during runtime startup, then reuses that scrubbed structured snapshot for MCP tools/list and discovery. ptc_runner_mcp uses frozen mode so one server process presents a stable tool surface for its lifetime. If a configured MCP upstream cannot start or cannot answer tools/list, MCP server startup fails.
  • Live defers MCP stdio/http client startup and listing until discovery or (tool/call ...) needs the upstream. Root mix ptc.repl --upstreams-config ... defaults to live mode and accepts --catalog-snapshot-mode frozen when a fail-fast startup check is preferred.

OpenAPI schemas are still loaded during runtime startup in both modes because the runtime compiles the explicitly included operations before exposing them. Prefer schema_file for production so startup does not depend on a schema host.

REPL discovery from PTC-Lisp

The inline catalog above is a static snapshot baked into the tool description. Lazy mode shows configured server names plus discovery guidance instead of individual tools. PTC-Lisp also has local discovery for executable PTC/Clojure builtins and curated Java interop. Aggregator mode extends the same REPL-style forms so programs can inspect configured upstreams at runtime — enumerate servers, page through a server's tools, search across catalogs, or read a tool's full input schema.

FormSignatureReturns
tool/servers(tool/servers)A list of {"name" "description" "tool_count" "catalog_loaded"} maps, sorted by name.
apropos(apropos query)<br>(apropos query opts)A list of compact discovery strings ranked by lexical relevance to query. Loaded MCP tool matches rank before unloaded MCP server hints, and both rank before local PTC/Clojure/Java matches. opts: :limit (integer 1..50, default 8) and :load (boolean, default false). With :load false an unloaded server contributes a server-level placeholder string with a dir next-step hint instead of triggering a load; with :load true live-mode runtimes attempt to load configured upstreams first and only tool-level matches are returned.
dir(dir ref)<br>(dir ref opts)For known local namespaces/classes, lists executable local members. Otherwise, lists tool - description strings for one MCP server, sorted by tool name. opts: :limit (integer 1..200, default 50) and :offset (integer ≥ 0, default 0) for pagination.
doc(doc ref)One detailed local or MCP description string. Known local refs win; unknown refs fall through to MCP tool refs shaped as server/tool. MCP docs include args, required args, a ready-to-edit (tool/call …) example, and the Result<...> payload shape.
meta(meta ref)Structured local or MCP metadata. Known local refs win; unknown refs fall through to MCP tool refs.
ns-publics(ns-publics ns)Local-only map of public names to compact metadata for PTC/Clojure namespaces. Java classes and MCP servers are not supported.

apropos ranks each candidate with a deterministic lexical score: query tokens are matched against the tokenized server/tool names (boosted) and the tokenized descriptions/arg-keys/annotations (unboosted), scoring 10 for an exact token match, 5 for a prefix match, 2 for a substring match, plus a +2 boost on the name fields. Tokenization splits camelCase, snake_case, and kebab-case. Only positive-scoring entries are returned; ties break on {server, tool} so ordering is stable across runs.

dir, doc, and meta (and apropos when called with :load true) trigger live-mode upstream loading when a target MCP server has not been listed yet. Frozen-mode runtimes read the startup snapshot. Result lists are size-capped at --max-catalog-result-bytes (default 256 KiB) of JSON: an over-cap dir / apropos list is truncated entry-by-entry, an over-cap doc or meta result becomes a world fault.

Error model — identical split to (tool/call ...):

  • World fault → nil: upstream can't be started, the result is too large to cap, or the per-program discovery op budget is exhausted. The program keeps running.
  • Programmer fault → program raises: server not configured, tool not found on that server, or a bad argument (e.g. :limit out of range, :load not a boolean, an empty query, server/tool not a non-empty string).

The discovery op budget is a separate atomics counter from the (tool/call ...) budget — discovery calls never eat into a program's upstream-call quota.

;; List the tools the "github" upstream exposes
(dir 'github {:limit 100})

;; Only describe a tool if its server is actually configured
(when (some (fn [s] (= (:name s) "fs")) (tool/servers))
  (doc 'fs/read_text_file))

;; Search every configured upstream for "read"-related tools,
;; loading any cold catalogs so only tool-level matches come back
(apropos "read" {:limit 20 :load true})

Three example programs

Example 1 — Simple read

Read a single text file via the filesystem MCP and return its contents:

(let [r (tool/call {:server "fs"
                        :tool   "read_text_file"
                        :args   {:path "/tmp/sandbox/notes.md"}})]
  (if (:ok r)
    (:value r)
    (fail (:message r))))

The program is one expression; its value is the unwrapped file body.

Example 2 — Cross-server filter

List GitHub PRs and filter to those mentioning a particular file path discovered from the filesystem upstream:

(def unwrap
  (fn [r]
    (if (:ok r)
      (:value r)
      (fail (:message r)))))

(def open-prs
  (unwrap (tool/call {:server "github"
                          :tool   "list_prs"
                          :args   {:state "open" :limit 50}})))

(def watched-paths
  (unwrap (tool/call {:server "fs"
                          :tool   "read_text_file"
                          :args   {:path "/etc/watched-paths.txt"}})))

(def watch-set
  (set (clojure.string/split-lines watched-paths)))

(def hits
  (filter (fn [pr]
            (some watch-set (:files pr)))
          open-prs))

(map :number hits)

Only the final list of PR numbers — perhaps a handful of integers — crosses back to the calling client. The intermediate 50 PRs and the watched-paths file body never leave the sandbox.

Example 3 — Parallel batch with pmap

Fetch ten upstream items in parallel:

(def ids [101 102 103 104 105 106 107 108 109 110])

(def items
  (pmap (fn [id]
          (tool/call {:server "store"
                          :tool   "get"
                          :args   {:id id}}))
        ids))

;; Drop world-fault failures (e.g. one ID was unknown):
(def good (map :value (filter :ok items)))

(map :name good)

pmap parallelism is bounded by the per-program upstream-call cap (see Limits in §9 of the spec). Filter on :ok before taking :value to handle partial failure.

Error reference

Programmer-fault (program raises, terminates)

Error messageCause
tool/call requires :server (string), got <value>:server key missing or not a non-empty string
tool/call on upstream '<server>' requires :tool (string), got <value>:tool key missing or not a non-empty string
tool '<server>.<tool>' rejected args: :args must be a map, got <value>:args not a map
tool '<server>.<tool>' rejected args: not JSON-encodable (<reason>):args map contains a value Jason can't encode (e.g. a closure)
no upstream '<name>' configured:server value is not in the configured upstreams
no tool '<tool>' in upstream '<server>':tool value is not in the upstream's tools/list (only raised when the upstream is healthy and the cache can prove absence)

World-fault (returns tagged error, recorded in upstream_calls)

reasonCause
upstream_unavailableThe upstream couldn't be started, its initialize handshake failed, or it's in its post-crash recovery window
upstream_errorThe upstream returned a JSON-RPC error to a tools/call
tool_errorThe upstream returned a successful MCP envelope with "isError": true
timeoutThe upstream call exceeded upstream_call_timeout_ms
response_too_largeThe upstream's response exceeded max_upstream_response_bytes before decode
cap_exhaustedThe program made more than max_upstream_calls_per_program calls

Each entry in upstream_calls carries server, tool, status, duration_ms, and on error reason and error (the detail string).

Payload reduction

The whole point of programmatic tool calling is that the program fetches from upstream MCP tools and collapses the results down to a small answer before handing it back. Aggregator-mode responses can carry deterministic accounting for that work: a ptc_metrics block plus result_bytes / oversize on each upstream_calls[] entry. Where those fields appear depends on the response profile: debug exposes them inline, while slim/structured model-facing responses omit them and keep the details for lisp_debug recent / get / stats. For sessions, metrics are per eval — they account for the calls drained in that turn, not the session's cumulative upstream_calls history:

// structuredContent (abridged) — lisp_eval, aggregator mode
{
  "result": "…the program's answer (812 bytes)…",
  "upstream_calls": [ { "server": "github", "tool": "search_issues",
                        "status": "ok", "duration_ms": 142,
                        "result_bytes": 48122, "oversize": false } ],
  "ptc_metrics": {
    "schema_version": 1,
    "final_result_bytes": 812,           // byte size of the `result` field (the answer; not prints/feedback)
    "prints_bytes": 0,
    "upstream_call_count": 3, "upstream_ok_count": 3,
    "upstream_error_count": 0, "upstream_oversize_count": 0,
    "upstream_result_bytes": 48122,      // Σ result_bytes over status==ok, non-oversize calls — the denominator
    "upstream_error_bytes": 0, "upstream_oversize_bytes": 0,
    "payload_reduction_ratio": 59.26,    // round(upstream_result_bytes / max(final_result_bytes, 1), 2); null when either side is 0
    "estimated_final_result_tokens": 203, "estimated_upstream_result_tokens": 12031,
    "token_estimate_method": "utf8_bytes_div_4",
    "baseline": {
      "conservative": { "name": "successful_upstream_results_only", "bytes": 48122, "ratio": 59.26, "note": "…" },
      "optimistic":   { "name": "no_ptc_direct_llm_workflow", "available": false, "note": "…" }
    }
  }
}

Honest framing. payload_reduction_ratio is "how much upstream tool-result payload the program collapsed into its answer" — a real number the server can measure. It is not "tokens saved by PTC" (that needs the no-PTC counterfactual and the server-side LLM usage, neither of which the server can know), and it is not the literal reduction in the MCP response the client receives. In debug, the envelope mirrors the full structured payload (ptc_metrics, upstream_calls, prints, feedback) into content[0].text, so the actual response is larger than final_result_bytes; in slim/structured profiles, those observability fields are omitted from normal eval responses. Bytes are primary and exact; token figures are explicitly estimates (utf8_bytes_div_4) — clients that care tokenize themselves. Only status: "ok", non-oversize upstream calls count toward upstream_result_bytes; failed-call and oversize bytes are reported separately and never inflate the ratio. On an error envelope, final_result_bytes is 0 and the ratio is null (the bytes fetched before the failure are still reported). The optimistic baseline is always { "available": false } — the server never invents it.

lisp_task planner cost. A lisp_task response's ptc_metrics also carries a server_side_llm line item — the planner LLM's prompt/completion byte sizes (always available) and provider token counts (provider_reported: true with real numbers when the LLM adapter surfaces usage, else null + byte estimates). The payload_reduction_ratio for lisp_task is answer/result-payload reduction only; an efficiency_note states verbatim that it excludes the planner cost. See Agentic Mode for the planner contract.

When --debug-tool is enabled, lisp_debug op=stats rolls these per-call blocks up into a payload_reduction aggregate — totals, p50/p95/max/weighted ratio (skipping nulls), the top-N reducers, and (for windows containing lisp_task calls) an agentic_planner sub-block with the summed planner tokens/bytes. lisp_debug recent / get records carry the per-call ptc_metrics. See Diagnostics: lisp_debug.