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_evalinstead, 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:
--upstreams-config <path>flag.PTC_RUNNER_MCP_UPSTREAMSenv var.~/.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:
bearer→Authorization: Bearer <value>basic→Authorization: Basic base64(user:pass). The binding'svaluemay be eitheruser:passor a JSON{"user":"…","pass":"…"}shape.custom_header→<header>: <value>. The header name MUST match RFC 7230 token grammar and MUST NOT beAuthorization(usebearer/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_callsenvelopes, 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 intools/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
commandwhose 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 hitmax_upstream_calls_per_program. - There is no JSON
faketransport. 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/listfailures fail server startup. Rootmix ptc.repldefaults 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 repoJSON helpers (json/*)
tool/call returns tagged data. Always inspect :ok before using
:value.
| Shape | Meaning |
|---|---|
{: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
| Class | Behavior | When |
|---|---|---|
| World-fault | (tool/call ...) returns {:ok false :reason kw :message text}; entry recorded in upstream_calls; program continues | Upstream couldn't be started, returned a JSON-RPC error, timed out, oversized response, per-program cap exhausted, or returned an MCP isError envelope |
| Programmer-fault | Raises a runtime error; program terminates | Unknown 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 requestReading 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-ishResult<...>payload shape.- Args in
doc::name typefor required,:name type?for optional. The optional?is the LLM's signal to omit the arg or passnil. - 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):enumconstraints render asenum<type>when every listed value shares one primitive type (e.g.enum<string>,enum<integer>), or bareenumfor heterogeneous values. The subscript form is the dominant real-world shape — most enums are uniformly-typed string sets.constrenders asconst<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 renderconst<…>and do not collapse to the primitive type label.- Both override the primitive
typelabel: a schema like{"type": "string", "enum": ["open","closed"]}renders asenum<string>, NOTstring. 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/listand discovery.ptc_runner_mcpuses frozen mode so one server process presents a stable tool surface for its lifetime. If a configured MCP upstream cannot start or cannot answertools/list, MCP server startup fails. - Live defers MCP stdio/http client startup and listing until
discovery or
(tool/call ...)needs the upstream. Rootmix ptc.repl --upstreams-config ...defaults to live mode and accepts--catalog-snapshot-mode frozenwhen 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.
| Form | Signature | Returns |
|---|---|---|
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:
servernot configured,toolnot found on that server, or a bad argument (e.g.:limitout of range,:loadnot a boolean, an emptyquery,server/toolnot 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 message | Cause |
|---|---|
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)
reason | Cause |
|---|---|
upstream_unavailable | The upstream couldn't be started, its initialize handshake failed, or it's in its post-crash recovery window |
upstream_error | The upstream returned a JSON-RPC error to a tools/call |
tool_error | The upstream returned a successful MCP envelope with "isError": true |
timeout | The upstream call exceeded upstream_call_timeout_ms |
response_too_large | The upstream's response exceeded max_upstream_response_bytes before decode |
cap_exhausted | The 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.