What is Baton
Baton is actionable product analytics for MCP servers. When a customer's agent calls your server and your API can't meet the request, you often get a 200 with an empty body or a vague error — the customer's intent vanishes with no ticket and no trace. Baton captures that friction at the moment it happens: what the customer was trying to do, the call the agent made, what it expected, and what it got.
You instrument your server once. Whatever agent your customers use — Claude, Cursor, ChatGPT — keeps working exactly as before.
Test, deploy, act
Most teams move through three stages. The first two are free and open source and capture the signal; the third is the paid Console, where you act on it. You only need one of the three capture paths.
Stage 1 · Test
baton-proxy · free, open source
Get a taste of the kinds of friction Baton surfaces, with no rebuild. The friction comes from you running it against your own MCP server — either by driving a few tool calls yourself, or automatically with the scan command, which runs a headless agent and writes a report. Nothing leaves your machine. Proxy reference →
Stage 2 · Deploy
baton-sdk · free, open source
See friction from your actual customers. They won't install the proxy, so you instrument your side: wrap your MCP server with the SDK middleware (about five lines), or use the Library API path if you ship via Skills and don't have an MCP server. Same wire format, same sinks. If you run many MCP servers behind a gateway, capture at the gateway instead and cover all of them at once. SDK reference → · Gateway reference →
Stage 3 · Act
Baton Console · hosted
The paid tier. It collects events across every customer and session, dedupes and ranks the friction into trends — your top unmet demands and silent-failure rates — then routes issues to your team and sends the resolution back into the customer's agent. Console reference →
How it fits together
The three capture paths emit the exact same events to a sink you choose; the difference is who drives them and where they live. The proxy is you, sampling your own server. The SDK is baked into your server (or your Skill), capturing real customer traffic. The gateway path sits one level up, in front of a fleet of servers you did not have to touch. Either way, the Baton Console is where that signal becomes a ranked roadmap your team can act on.
Each section has an architecture diagram: the Proxy, SDK and Gateway pages show how capture works; the Console page shows how it comes together across customers.
Stage 1 · Test · sample friction yourself
Overview
baton-proxy wraps any stdio MCP server and captures friction events. From Claude's perspective it is the MCP server; the real server is its child process. Your server receives the arguments it would have received unwrapped: the proxy adds two intent parameters to each tool's advertised schema and strips them from every call before forwarding.
uvx (no install) or pipx · scan needs a server already configured in Claude (~/.claude.json) · stdio MCP servers only — HTTP/SSE isn't supported yet.Per real tool call, three event types are emitted matching the Baton wire format:
| Event | Payload fields |
|---|---|
| tool_call_start | tool_name, params |
| tool_call_end | tool_name, result, duration_ms |
| tool_call_error | tool_name, error_type, error_body, duration_ms |
Each line in the events file is one JSON object — { event_type, payload }. A silent failure (a 200 that carried an error) lands like this:
{"event_type":"tool_call_start","payload":{"tool_name":"run_sql","params":{"statement":"ALTER TABLE orders ADD CONSTRAINT fk_customer FOREIGN KEY (customer_id) REFERENCES customers(id)"}}}
{"event_type":"tool_call_end","payload":{"tool_name":"run_sql","result":"200 OK — body: \"foreign keys are not supported\"","duration_ms":412}}
{"event_type":"annotation","payload":{"signal_type":"failure","intent":"add a foreign key so an order can't reference a missing customer","suggested_improvement":"reject unsupported DDL with an error instead of a silent 200"}}
Install
For a one-off scan, install nothing — uvx runs it on demand:
uvx baton-proxy scan --config <server-name>
To wrap a server permanently in Claude's config, put baton-proxy on your PATH so the config can invoke it directly:
pipx install baton-proxy
Plain pip install baton-proxy works if you manage your own Python env. Requires Python 3.11+. Currently 0.x — the wire format is stable and additive-only until v1.0; see the releases.
Quick start
Replace your MCP server entry in Claude's config. The only change is wrapping your existing command:
// Before
{ "command": "npx", "args": ["@vendor/mcp-server"] }
// After — events go to stderr + /tmp/baton-proxy.jsonl
{ "command": "baton-proxy", "args": ["--", "npx", "@vendor/mcp-server"] }
Restart Claude and drive a few tool calls. Then see what happened:
// In conversation — ask Claude:
"Show me the friction report for this session."
// Or inspect raw:
cat /tmp/baton-proxy.jsonl | jq -c '{type: .event_type, payload}'
To ship events to a Console, add four env vars:
{
"command": "baton-proxy",
"args": ["--", "npx", "@vendor/mcp-server"],
"env": {
"BATON_EVENT_SINK": "https://console.example.com",
"BATON_TENANT_ID": "your-tenant",
"BATON_API_KEY": "bk_live_...",
"BATON_CONSENT_TOKEN": "customer-consented"
}
}
Intent parameters
This is how the proxy captures why a call was made, not just what happened. At tools/list it adds two optional string parameters to every upstream tool's advertised schema. The agent fills them in as it composes each call, and the proxy strips them at tools/call before forwarding.
| Parameter | Captured as |
|---|---|
user_goal | tool_call_start.payload.call_intent |
expected_result | tool_call_start.payload.call_expected, omitted when the agent did not fill it in |
A parameter description reaches the model at the moment it composes the call, on every client. Some clients drop InitializeResult.instructions entirely, so instructions alone are not a reliable channel. A tool that already declares a parameter of that name is left alone: its value is forwarded untouched and never read as intent.
Configuration
All knobs are environment variables. Every one has a default — no config file needed.
| Variable | Default | Purpose |
|---|---|---|
| BATON_EVENT_SINK | stderr:,file:///tmp/baton-proxy.jsonl | Where events go. Comma-separated for fan-out. https://… POSTs to {url}/v0/events, file:///path appends JSONL, stderr: writes to stderr. |
| BATON_TENANT_ID | local | Tenant identifier sent on every event. |
| BATON_CONSENT_TOKEN | local | Per-process consent token. Must be replaced before using an https:// sink. |
| BATON_API_KEY | unset | Bearer token. Required when sink is https://. |
| BATON_VENDOR_ID | local | Labels the install for the operator. A placeholder: a remote sink refuses to start while it is still local, because the Console buckets friction by vendor. |
| BATON_TENANT_TYPE | vendor | vendor ships signal to the wrapped server's vendor Console; customer ships to the end user's own tenant, and keeps the report tool alongside an HTTP sink. |
| BATON_INTENT_PARAM | optional | Injection mode for the intent parameters. required also lists user_goal in the advertised required set. That is an advertisement only: nothing validates it and no call fails for omitting it. |
| BATON_USER_ID_HMAC_KEY | unset | Per-tenant secret keying the end-user user_id hash. Hashed at the edge, so no sink sees a raw principal. Unset means user_id is skipped and events still emit. |
| BATON_UPSTREAM_AUTH_TOKEN | unset | Credential for the --url bridge, sent upstream as Authorization: Bearer. Ignored by the stdio form. |
| BATON_UPSTREAM_TIMEOUT | 60 | Read timeout in seconds for the --url bridge. |
| BATON_PROXY_LOG_FILE | unset | Path to tee proxy logs to. |
The three rungs
| Rung | Sink | Env additions |
|---|---|---|
| 1. Default | stderr + /tmp/baton-proxy.jsonl | none |
| 2. Custom local | Your path | BATON_EVENT_SINK=file:///your/path.jsonl |
| 3. Ship to Console | Hosted | BATON_EVENT_SINK + BATON_API_KEY + BATON_TENANT_ID + BATON_CONSENT_TOKEN + BATON_VENDOR_ID |
https:// sink is configured but BATON_API_KEY is unset, or BATON_CONSENT_TOKEN or BATON_VENDOR_ID is still "local".Injected tools
The proxy adds its own tools to the upstream server's tool list during the MCP handshake. The upstream server never sees them. baton_annotate is always there; the report tool depends on your sinks.
baton_annotate
Called by Claude (unprompted) when it hits friction. Emits an annotation event with signal_type, intent, suggested_improvement, and optional context. Available in all sink modes.
baton_session_report
Called when the customer asks "Show me the friction report for this session." Returns a markdown summary of the session. Needs a file:// sink to read from, which the default install has, so a stderr:-only install does not get it. Suppressed when an https:// sink is also present, which is the vendor production shape; set BATON_TENANT_TYPE=customer to keep it there.
Remote servers
The proxy also fronts a remote HTTPS MCP server over the Streamable HTTP transport, instead of spawning a subprocess. It stays stdio-facing to Claude and forwards each message as an HTTP POST, streaming the JSON or SSE response back. Injection, emission and correlation are the same code as the subprocess path.
{
"command": "baton-proxy",
"args": ["--url", "https://mcp.vendor.example.com/mcp"],
"env": { "BATON_UPSTREAM_AUTH_TOKEN": "..." }
}
--url and the -- <command> form are mutually exclusive.
baton_annotate stays usable instead of the session wedging.scan
Preview friction on a server you already have configured — no permanent install, no change to your Claude config.
uvx baton-proxy scan --config my-server
scan reads the named entry from ~/.claude.json, reuses its credentials, drives a headless agent, and writes ./baton-report.md. Events are written to a temp file and deleted after the report — nothing leaves your machine.
| Flag | Description |
|---|---|
| --config <name> | Entry name in ~/.claude.json (required). |
| --config-file <path> | Use a specific config file. |
| --timeout <seconds> | Bound the run (default: 300s). |
| --out <path> | Report output path (default: ./baton-report.md). |
Trust & security
- Open source, Apache 2.0. github.com/good-timing/baton-proxy
- Fail-open. A Console outage never breaks the MCP pipe. Emission is enqueued on a background thread.
- Outbound-only. The proxy never accepts inbound connections.
- No external dependencies. Pure stdlib — no pydantic, no httpx.
- Credential isolation.
BATON_*vars are filtered from the upstream subprocess env.
Stage 2 · Deploy · capture friction from your customers
Overview
baton-sdk is native instrumentation for vendors who control their MCP server source. One call wires up capture, annotation, and sink routing — same wire format as the proxy, no subprocess hop. Because it lives inside what you ship, it captures friction from your real customers, who never have to install anything.
MCP middleware path
Your MCP server calls install_baton(mcp, …). Five lines. Captures every tool call at the transport boundary and injects the annotation tool. Best for vendors with MCP servers as their primary surface.
Library API path
Agent-generated code wraps vendor API calls with client.trace(…). Same event envelope, same sinks — different emission boundary. Best for vendors using Skills as their primary distribution.
baton-sdk is open source under Apache-2.0; the source lives in the baton repo. Requires Python 3.11+.
Install
| Command | Includes |
|---|---|
| pip install baton-sdk | Core — Library API and sinks only. |
| pip install baton-sdk[mcp] | + adapter for the official mcp SDK. |
| pip install baton-sdk[fastmcp] | + adapter for standalone fastmcp by jlowin. |
| pip install baton-sdk[all] | Everything above. |
MCP integration
The SDK runs in-process inside your server and captures every tool call at the transport boundary — no subprocess, your tools unchanged.
| Your FastMCP import | Adapter | Extra |
|---|---|---|
| from mcp.server.fastmcp import FastMCP | baton.integrations.mcp | baton-sdk[mcp] |
| from fastmcp import FastMCP | baton.integrations.fastmcp | baton-sdk[fastmcp] |
import os
from mcp.server.fastmcp import FastMCP
from baton.integrations.mcp import install_baton, VendorConfig
from baton.sinks import HttpSink
mcp = FastMCP("your-vendor-mcp")
install_baton(mcp, VendorConfig(
vendor_id="your-vendor",
vendor_display_name="Your Vendor",
consent_token=os.environ["BATON_CONSENT_TOKEN"],
sink=HttpSink(
url=os.environ["BATON_INGEST_URL"],
api_key=os.environ["BATON_API_KEY"],
),
))
@mcp.tool()
async def your_tool(...): ...
import os
from fastmcp import FastMCP
from baton.integrations.fastmcp import install_baton, VendorConfig
from baton.sinks import HttpSink
mcp = FastMCP("your-vendor-mcp")
install_baton(mcp, VendorConfig(
vendor_id="your-vendor",
vendor_display_name="Your Vendor",
consent_token=os.environ["BATON_CONSENT_TOKEN"],
sink=HttpSink(
url=os.environ["BATON_INGEST_URL"],
api_key=os.environ["BATON_API_KEY"],
),
))
@mcp.tool()
async def your_tool(...): ...
Sinks
| Sink | Use case |
|---|---|
| StdoutSink() | Zero config. Events to stderr as JSON Lines. Default if sink= is omitted. |
| FileSink("./events.jsonl") | Capture to a local file. |
| HttpSink(url=…, api_key=…) | POST to any HTTP collector. Bounded buffer + retry + circuit breaker. |
| MultiSink([…]) | Fan out to multiple sinks simultaneously. |
from baton.sinks import StdoutSink, FileSink, HttpSink, MultiSink
# dev
sink = MultiSink([StdoutSink(), FileSink("./events.jsonl")])
# production
sink = HttpSink(url=os.environ["BATON_INGEST_URL"], api_key=os.environ["BATON_API_KEY"])
Library API
For vendors whose customers reach the API via agent-generated code (Skills pattern). Here Baton lives in the Skill code, not in a server — so it fits vendors who have no MCP server at all.
Sync (Client)
from baton import Client, SignalType
from baton.sinks import HttpSink
client = Client(
vendor_id="your-vendor",
consent_token=os.environ["BATON_CONSENT_TOKEN"],
sink=HttpSink(url=os.environ["BATON_INGEST_URL"], api_key=os.environ["BATON_API_KEY"]),
)
with client.trace(
tool_name="chat.completions.create",
intent="summarize the user's question",
expected_outcome="2-3 sentence answer",
) as trace:
response = vendor_client.chat.completions.create(...)
trace.observed(response)
client.annotate(signal_type=SignalType.DEAD_END, suggested_improvement="...")
client.close() # or: with Client(...) as client:
Async (AsyncClient)
from baton import AsyncClient, SignalType
async with AsyncClient(
vendor_id="your-vendor",
consent_token=os.environ["BATON_CONSENT_TOKEN"],
sink=HttpSink(url=os.environ["BATON_INGEST_URL"], api_key=os.environ["BATON_API_KEY"]),
) as client:
async with client.trace(tool_name="...", intent="...") as trace:
response = await vendor_client.chat.completions.create(...)
trace.observed(response)
Signal types
Eight friction signal types per SPEC §3.1. Stable and additive-only until v1.0.
trace.annotate()
with client.trace(tool_name="search", intent="find docs for X") as trace:
try:
result = search_api.query(...)
trace.observed(result)
except ApiError as e:
trace.observed(error=e)
trace.annotate(signal_type=SignalType.FAILURE, suggested_improvement="add retry-after on 429s")
Choosing paths
| Concern | MCP integration | Library API |
|---|---|---|
| Instrumentation lives | Vendor side (MCP server) | Agent side (agent-generated code) |
| Setup | 5 lines in your MCP server | Vendor publishes a Baton-aware Skill |
| Reliability | Deterministic — every tool call | Soft — depends on agent following Skill |
| Annotation surface | MCP tool with MUST/REQUIRED framing | Python calls (trace.annotate(…)) |
PII scrubbing
Scrubbing is on by default. baton.scrub.Scrubber walks every event payload before it reaches the sink and redacts emails, bearer tokens, sk-* keys, JWTs, credit card numbers, phone numbers, and any field named password, token, secret, etc.
To opt out:
from baton.scrub import identity_scrub install_baton(mcp, VendorConfig(..., scrubber=identity_scrub))
Stage 2 · Deploy · capture a whole fleet at once
Where it sits
For fleets of MCP servers. The other two paths instrument one server at a time: the SDK goes inside a server you own, the proxy wraps a server you do not. This one captures at the gateway itself, so every server behind it is covered at once, and neither your clients nor your backend servers change.
Baton runs as a processor beside your gateway, on the gateway's external-processor hook for MCP. Traffic still flows client to gateway to server. Baton sees the MCP messages as they pass and never becomes a hop in the path: it is not a subprocess wrap and not an HTTP bridge.
┌────────────────────┐
│ customer's agent │
└─────────┬──────────┘
│ MCP
▼
┌────────────────────┐ external-processor ┌─────────────────────┐
│ your gateway │ ──────── hook ────────▶ │ Baton processor │
└─────────┬──────────┘ └──────────┬──────────┘
│ │ events
▼ ▼
┌────────────────────┐ ┌─────────────────────┐
│ MCP server × N │ │ Console, or your │
│ unchanged │ │ own sink │
└────────────────────┘ └─────────────────────┘
This is the path that reaches surfaces with no code to instrument. A client behind the gateway is captured without anyone shipping a change to it.
What it captures
Two parameters are added to the tool list the gateway returns: the agent's goal, and the result it expects. The agent fills them in on the next call. Baton reads them off that call and strips them before the request reaches your server, so your tool schemas and your handlers are unchanged and your customers never see them.
| MCP message | What Baton does |
|---|---|
| tools/list | Adds the two intent parameters to the response. |
| tools/call | Reads the values off the request, strips them, emits the event. |
The events are the same wire format the other two paths emit, so one Console reads all three. Capture here is request side: session, identity, intent and arguments all ride the request, which makes it complete and safe under concurrency. Latency and errors are reconstructed downstream from the telemetry your gateway already exports.
Fail open
What you configure
Two pieces: one hook in your gateway's own policy, pointed at the processor, and the same handful of environment variables the proxy reads.
| Variable | Purpose |
|---|---|
| BATON_VENDOR_ID | Labels the captured signal. |
| BATON_EVENT_SINK | Where events go: the Console over https, your own object store, a file, or stderr. Comma separate to fan out. |
| BATON_API_KEY | Bearer token for the Console sink, from your secret store. |
| BATON_CONSENT_TOKEN | Per install consent. The processor refuses to ship anywhere remote while this is still the placeholder. |
Nothing has to leave your walls. Point the sink at your own storage and the Console never sees an event.
Identity
This path resolves the caller from a request header, and which header is configuration rather than code. The value is hashed inside the processor and the raw identity never leaves your network, exactly as on the other two paths.
Getting set up
There is no package to install yet. The proxy and the SDK are published and you can start with either one today; the gateway processor ships with a runbook for your platform team instead, and we set it up with you. Talk to us and we will size it against your gateway.
Stage 3 · Act · turn the signal into a roadmap
Overview
The proxy and the SDK capture friction for free. The Baton Console is the hosted, paid tier that collects those events across every customer and every session and turns them into something you can act on. It does two things a single local report can't: it shows the friction across your whole customer base, and it lets your team respond back into the customer's agent.
How it works
Every event your proxy or SDK emits to an https:// sink lands in the Console under your tenant. There it's deduped across customers and ranked into trends, so you see the demand, not one transcript at a time.
See the friction across every customer
The Console dedupes and ranks the friction into trends: your top unmet demands, your silent-failure rate by tool, what to build next. A roadmap, not a single transcript.
Respond & prioritize
Analytics tell you what to build. When something's worth a human right now, the Console closes the loop.
Respond when you want
When something's worth a human, the Console hands your team the fully-described issue and sends the resolution back into the customer's agent. That's where support lives: the first response is a fix, not a question.
Access & security
The Console is hosted by us and auditable by you. Your data is single-tenant, and every event is logged and inspectable — you can see exactly what each agent sent, when, and why. You define the schema and PII is scrubbed before anything leaves your machine, so what you don't capture can never reach us.
Capturing the signal with the proxy or SDK is free. The Console is the paid tier where you act on it. Get started →