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 two 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. SDK 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 proxy and the SDK 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. 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 and SDK 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. All MCP traffic passes through unchanged.
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"
}
}
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 | unset | Labels the install for the operator. |
| 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 |
https:// sink is configured but BATON_API_KEY is unset, or BATON_CONSENT_TOKEN is still "local".Injected tools
The proxy appends two tools to the upstream server's tool list during the MCP handshake. The upstream server never sees them.
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. Local sink only — not injected when an https:// sink is configured.
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 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. Talk to us about access →