Skip to content

MCP

Tracepad serves the Model Context Protocol so an agent can read traces as tools rather than as HTTP. Twenty-two tools, all reads, each a thin wrapper over a read API endpoint; the table below is the whole list, in the order the server registers them (tools/list itself returns them sorted by name).

The MCP surface cannot modify or delete anything. Not "does not today" — cannot: the only thing a tool can reach the API with is a GET, and the administrative surface of admin.md deliberately added nothing here. An agent should not hold destructive capability at all, so that a hallucinated tool call has nothing to destroy. Deleting a project, moving a retention window and erasing a user's data live in the HTTP API and the CLI, where a human confirms them by name.

The tools call the HTTP API — in process when the server serves them itself, over the network in stdio mode — and never the database. That is what makes a tool result and a curl of the corresponding endpoint the same bytes: budgets, truncation, authentication and JSON shape have exactly one implementation.

Connecting

The running server serves MCP at /mcp on the same port as everything else, over streamable HTTP. Authentication is a project key that holds the read scope — and a key minted with read alone is the one to give an agent: it reads everything the tools show and can change nothing, so the guarantee above holds of the credential too, not only of the tools:

{
  "mcpServers": {
    "tracepad": {
      "type": "http",
      "url": "http://localhost:4318/mcp",
      "headers": {"Authorization": "Bearer tp-sk-…"}
    }
  }
}

A request without a valid key is answered 401 (with WWW-Authenticate: Bearer) before it reaches the MCP layer — initialize included. So are the key of a deleted project and the admin token, which reaches no project's data. A key without read — one minted for ingest or write alone — is refused at the same door, as every route refuses it, with 403, WWW-Authenticate: Bearer error="insufficient_scope", scope="read" and {"error": "this key's scopes are ingest; POST /mcp needs read"}. If the server cannot check the key at that moment, the answer is 503 without the challenge: retry, the key is not the problem. When the server stops, open MCP streams are ended at once rather than kept for the shutdown's drain, and a client reconnects to whatever serves next.

Set TRACEPAD_MCP=off to stop serving it.

For clients that cannot speak remote HTTP, the same binary runs the same tools over stdio against a running server:

{
  "mcpServers": {
    "tracepad": {
      "command": "tracepad",
      "args": ["mcp"],
      "env": {
        "TRACEPAD_URL": "http://localhost:4318",
        "TRACEPAD_API_KEY": "tp-sk-…"
      }
    }
  }
}

--url and --key work as flags too. The key needs read here as well: each tool's request carries it, and a key without read has every tool call come back with the server's 403 message.

Protocol

Tracepad targets protocol revision 2026-07-28, which is stateless: no handshake, no Mcp-Session-Id, the protocol version and the client's capabilities riding in each request's _meta. These tools are stateless read wrappers, so the new core fits exactly — any load balancer works, and there is no session bookkeeping to get wrong. Clients on 2025-11-25 negotiate the older stateful session transparently; tool behaviour is identical.

Deliberately not adopted: roots, sampling and logging (deprecated in 2026-07-28), the tasks extension (every tool here answers in milliseconds), elicitation, and MCP resources or prompts. Authentication is the pre-shared project key, not OAuth: the OAuth framework in the MCP spec targets multi-tenant public servers, and this one is self-hosted.

A traceparent on a request's _meta is recorded in the server log, once per request, when it is well-formed W3C trace context: lowercase hex, neither the trace id nor the parent id all zeros, and a version other than ff. Version 00 must be exactly 55 characters; a later version may be longer, as the W3C spec allows, when a dash follows its first 55 characters, and only those 55 are logged. Anything else is dropped without being logged. Nothing more happens with it — this is a tracing product, so it should at least not be the tool that drops trace context on the floor, but instrumenting tracepad with tracepad is a later question.

The tools

Tool Endpoint Use it when
list_traces GET /api/v1/traces Finding runs by filter
search GET /api/v1/traces with q Finding runs by what was said in them
get_trace GET /api/v1/traces/{id} Reading one run whole
get_last_trace GET /api/v1/traces/last "Why did the last run fail"
get_observation_io GET /api/v1/observations/{id}/io Following a truncation marker
list_sessions GET /api/v1/sessions Finding conversations by filter
get_session GET /api/v1/sessions/{id} Summarizing a conversation
list_users GET /api/v1/users "Who are my heaviest users", by traces, cost or tokens
get_user GET /api/v1/users/{id} "What does this account cost me"
get_prompt GET /api/v1/prompts/{name} "What prompt is in production"
list_scores GET /api/v1/scores Reading eval results; "what did I score" with author: "me"
get_stats GET /api/v1/stats Counts, cost, latency, trends
get_score_trends GET /api/v1/stats/scores "Did hallucination drop after 2.5.0", "which model scores best"
get_facets GET /api/v1/facets "Which environments exist", "what are the traces called" — before guessing at a filter value
list_datasets GET /api/v1/datasets "What test sets are there"
get_dataset_items GET /api/v1/datasets/{name}/items Reading the cases in one
list_runs GET /api/v1/datasets/{name}/runs, or GET /api/v1/runs without dataset "What has been tried"
get_run GET /api/v1/runs/{id} How one eval run went
get_run_items GET /api/v1/runs/{id}/items Which cases failed, and what was said
compare_runs GET /api/v1/runs/{a}/compare/{b} "Did this change make it better"
list_queues GET /api/v1/queues "What is being reviewed, and how far has it got"
get_queue_items GET /api/v1/queues/{name}/items What is left to review, who did what, why something was skipped

search and list_traces are the same endpoint under two descriptions, and that is the point: "the user quotes text they saw" is a different question from "the user asks what ran", and a model choosing tools by description is better served by two than by one with a mode. search requires its q, takes every filter list_traces takes, and its rows carry match — which observation and field the hit was in, and a snippet of the text — so the next call can be get_observation_io on that observation. The matching rules are the API's: api.md.

The eval tools read datasets and runs and write nothing — a run is opened by a harness or a person, and creating one from a model's guess would leave a container in the store that nobody meant. compare_runs is the one to reach for when the question is whether a change helped: it returns both runs' aggregates per score name with the delta and how many cases improved, regressed or stayed, then the cases themselves with their verdicts. A name says improved only when its config gives it a direction; otherwise the verdict is changed. get_run_items is the follow-up — what the model actually said for a case — and its payload markers feed get_observation_io like every other.

The two annotation tools read the review queues and, for the same reason, write nothing: reading what is left to review is an agent question, and posting a verdict — or completing an item on the strength of one — is a person's or a script's, with somebody to answer to. What a queue holds is pointers; the verdicts themselves are scores, so list_scores with a trace_id is the follow-up, and a score written from a queue carries metadata.queue and metadata.annotator.

get_score_trends is the quality question list_scores cannot answer: it returns one series per score name over the asked buckets — a mean for a numeric name, the rate of true for a boolean one, the count of each value for a categorical one — where list_scores returns the individual judgements. Its targets field is the honesty unit is on get_stats: grouped by model it counts only the scores that name an observation, because a trace-level score has no model. Scores that name only a session, and text scores, are never on a timeline. It returns the fifty busiest names by default — limit raises that to 500, and omitted says how many are still not there — so a model reading the list never mistakes it for the whole list. See quality.md.

get_facets is what to call before guessing at a value for list_traces. It returns the environments, releases and trace names of a range with the number of traces carrying each, busiest first — and the counts are the point as much as the values: prod: 1 beside production: 4656 is a typo, and nothing but the count says so. It takes the range and nothing else, so the answer does not change with the other filters. At most a hundred values per column, with omitted saying how many were left out.

Its answers go straight back in: environment, release and name each take one value or a comma-separated list, and a trace matches when its column equals any item — {"environment": "production,staging"} keeps both. tag is the exception and stays an AND: a trace must carry every tag listed.

There are no administrative tools, for the reason at the top of this page: not a gap, a guarantee.

Tool inputs mirror their endpoint's query parameters — same names, same meanings — with the constraints stated in the schema: enums for status, type, group_by and data_type, hex patterns for ids, limit bounded at 500. Every tool declares an outputSchema and returns the endpoint's JSON as structuredContent, with a one-line summary in content. A value from a trace inside that summary — a name, an id — has its control characters shown as escapes (\x1b), the way the CLI's tables show them; the JSON is the API's.

list_traces, search and get_last_trace take five filters for what the wire already carries:

Parameter Reach for it when
release, version The user names a deployment, or asks whether a release changed something.
type The user asks about a kind of step — a tool call, a guardrail, a retrieval — and wants the traces that contain one. One of ten values, exact: generation does not match embedding.
prompt The user names a prompt and wants what it produced. "support-answer" for every version, "support-answer@7" for one. A version is a number, so a name that contains an @ is passed as it stands.
min_tokens The user asks about heavy or expensive traces and the traffic reports no cost — most does not. Input plus output tokens at least this many.

The rows they return carry release, version and ttft_ms; the tree from get_trace and get_last_trace carries each observation's kind, completion_start_time, its own ttft_ms, the prompt it ran and the sizes of its payloads — the last of these whether or not expand inlined them, so "is this worth fetching" is answerable without fetching it. get_stats takes release in group_by, minute for a window of at most a day (from required) — the last hour, one run — and total — the whole window as one bucket, whose p95 is merged over every hour rather than averaged, which is the answer to "how much did it cost this week" in one call; last week beside this week is the tool called twice (api.md).

Every trace, session and user row, and every get_stats bucket — with user_id too — carries tokens: five classes, input, output, cache read, reasoning and cache write, each summed on its own and never into another (api.md). list_users sorts by tokens, input plus output, which is the question "who uses the most" when nothing reports a cost.

Every tool is annotated readOnlyHint: true with a display title, so a host can auto-approve reads instead of prompting for them. tools/list is returned in a fixed order with ttlMs: 3600000 and cacheScope: "private" — the list changes only when the binary does, and it is a per-project authenticated surface.

Following a truncated payload

A response spends a bounded byte budget on payloads. A payload that does not fit comes back as a marker:

{
  "truncated": true,
  "size": 3200000,
  "preview": "[{\"role\":\"user\",\"content\":\"here is the whole log…",
  "trace_id": "4f8c1d2e3a5b6c7d8e9f0a1b2c3d4e5f",
  "observation_id": "2b3c4d5e6f7a8b9c",
  "full": "/api/v1/observations/2b3c4d5e6f7a8b9c/io?trace_id=4f8c…"
}

The trace_id/observation_id pair is exactly what get_observation_io takes. That is why the tool exists: without it the markers would be dead ends for a consumer that has tools but no URL fetcher.

A cut payload that holds images or files carries media_count in the same marker, so a missing picture is a reference past the preview and not a lost one (api.md). The tools never fetch the bytes.

A worked example

"Why did the last production run fail?"

  1. get_last_trace with {"status": "error", "environment": "production", "expand": "io"} → the whole failed trace, tree and payloads, in one call.
  2. If a payload came back truncated, get_observation_io with the marker's id pair → that payload whole.
  3. get_stats with {"group_by": "day"} → whether this is new or has been happening all week.