# PtcRunner MCP Aggregator Mode

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

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

```json
{
  "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.

```json
{
  "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'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

```clojure
(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:

```clojure
;; 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:

```clojure
(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`.

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

```clojure
(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 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.

| 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**: `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.

```clojure
;; 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:

```clojure
(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:

```clojure
(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:

```clojure
(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:

```jsonc
// 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](agentic-mode.md) 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 `null`s), 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](mcp-debug.md).
