Every step your agent takes, held to the end and on the record.
mcpd is a daemon, not a framework. It holds a run between steps, the retries, the timers, the week-long wait for an approval, while your own code does the work inside each step, in any language. Every step lands in a trace with what it cost.
Build and run it
git clone https://github.com/mcpd/mcpd && cd mcpd && make build && ./target/release/mcpd demo Then open localhost:4317. No account, no database to stand up, nothing to sign. No release yet: the image and the packages land with the first one. Read the five-minute quickstart.
A run you can read.
An agent that fails does not fail in the log line that mentions the error. It fails three steps earlier, when the model chose the wrong tool and then chose it again.
mcpd reconstructs the whole run as a tree: every model call, every tool call, every retrieval, with the exact prompt and completion attached to each, the latency of each, and what each one cost.
- agent support_agent fail
- model chat
- tool returns_policy
- model chat
- tool returns_policy
- model chat
- tool returns_policy
The model called one tool three times and never answered. That is visible in four seconds here, and invisible in a log file.
Run drum
The same run, wound round a recorder drum. Time runs round it, one track per span. Turn it, and the pen reads the span under it.
A run that survives the process that started it.
The daemon owns the run between steps. Your process owns everything inside one, and hands nothing over: not the model, not the prompt, not a credential.
Lease board
The daemon keeps one tag per step on its board. A worker holds a tag only while its heartbeats arrive. Stop the worker and, after the lease and one reclaim window, the tag goes back on the board for the other worker.
The graph is data, the bodies are yours
A workflow is written once, in code. The graph goes to the daemon as a definition it validates and versions. The step bodies stay in your process, in any of six languages, for a worker to serve.
Retries, timers and waits are rows
A transient failure is retried on the step's own budget. A sleep or a wait for an approval holds the run with no process alive, through a deploy, a crash, or a week. An event answers the wait by name and key, and a repeated event is a duplicate, not a second run.
Every run is a trace
Each step is a span with its input, its output and the usage it reported, priced with the same book that prices every other span. The model call inside a step sits under it, so the search, the cost and the scorers work on a run unchanged.
Instrument in the language you already write.
Six SDKs, one behaviour. Recording a span never blocks, never throws, and never grows without bound: if mcpd is unreachable your agent does not notice, which is the only acceptable way for observability to fail. Each one also carries a worker, so the steps of a durable run are served from the same process that records them.
None of them is required. Point OTEL_EXPORTER_OTLP_ENDPOINT at mcpd and an OpenTelemetry SDK you already run reaches it, with no code of ours in your application. The six are a convenience over an HTTP surface, not a condition of being recorded.
Observability that arrives attached to a framework can only record agents built with that framework. mcpd takes whatever reaches its OTLP endpoint: an agent framework, a loop you wrote yourself, or the services either one calls.
instrument a run
/**
* One ticket, one trace.
*
* `withSpan` opens the span, keeps it active for everything the callback
* starts, and ends it when the callback settles. Nothing inside has to be
* passed a handle to be nested under it.
*/
async function handleTicket(ticket: Ticket): Promise<{ answer: string; traceId: string }> {
let traceId = ''
const answer = await mcpd.withSpan('support_agent', async (run) => {
// Keep the id: a score or a piece of feedback that arrives later attaches
// to this run by it.
traceId = run.traceId
// Tags label the whole run, so the trace list can filter on them.
run.setTags(['support', 'tier-1'])
run.setCorrelation({ sessionId: ticket.id, userId: ticket.customerId })
run.setInput({ question: ticket.question })
const order = await lookUpOrder(ticket)
const answer = await draftAnswer(ticket, order)
run.setOutput({ answer })
return answer
})
return { answer, traceId }
} # The root span is the run. Everything opened inside it becomes a child,
# because the active span lives in a `contextvars.ContextVar`.
with mcpd.trace("support_agent", session_id="sess-8121", user_id=user_id,
tags=["support", "orders"]) as run:
run.set_input({"question": question})
# A model call. `set_usage` is what mcpd prices the run from, so the
# cost on the trace is the cost you were actually billed.
with mcpd.span("plan", kind=SpanKind.MODEL_GENERATION) as generation:
generation.set_model("gpt-4o-mini", provider="openai")
generation.set_input([
{"role": "system", "content": "You are a support agent."},
{"role": "user", "content": question},
])
time.sleep(0.12) # stand-in for the provider round trip
generation.set_output("I should look the order up first.")
generation.set_usage(input_tokens=812, output_tokens=96, cache_read=128)
# A tool call that fails. The `with` block records the exception on the
# span, marks it as an error, and lets the exception through unchanged.
order = None
try:
with mcpd.span("lookup_order", kind=SpanKind.TOOL_CALL) as tool:
tool.set_entity("tool", entity_id="orders.lookup", name="Order lookup")
tool.set_input({"order_id": "A-7741"})
order = lookup_order("A-7741")
tool.set_output(order)
except TimeoutError as exc:
# The span already carries the error. This is the agent deciding
# what to do about it, which is a different question.
mcpd.log("warn", "order lookup failed, answering from context", {"error": str(exc)})
mcpd.metric("support.tool_failures", 1, "counter", labels={"tool": "orders.lookup"})
reply = ("I could not reach the order system just now. Your order A-7741 "
"shipped on Tuesday and tracking should update within the hour.")
run.set_output(reply)
run.set_metadata({"used_fallback": order is None})
return reply, run.trace_id // The root span is the run. The sampling decision is made here, once, and
// every child inherits it.
let run = mcpd.trace("support agent");
run.set_input(question);
run.set_session("session-4f21");
run.set_user("customer-91");
run.set_tags(["support", "orders"]);
// A tool call. `run.child_of` nests it under the run.
let lookup = run.child_of("lookup_order", SpanKind::ToolCall);
lookup.set_input(json!({ "order_id": "5512" }));
let order = json!({ "status": "shipped", "carrier": "DHL", "eta": "Tuesday" });
lookup.set_output(&order);
lookup.end();
// A tool that fails. Recording the error sets the span's status to `error`,
// and a message is required: "something failed" is not actionable.
let notify = run.child_of("notify_customer", SpanKind::ToolCall);
notify.set_input(json!({ "channel": "sms" }));
notify.set_error("sms gateway timed out after 5s");
notify.end();
// The failure did not stop the run: the agent falls back to email, and the
// trace shows both the attempt and the recovery.
run.event_with("fell back to email", json!({ "after": "sms gateway timeout" })); // handleTicket is the agent. It drafts an answer with a model, tries to send
// it with a tool, and fails on the send so the error path is recorded too.
func handleTicket(ctx context.Context, m *mcpd.Client, question string) string {
ctx, run := m.Start(ctx, "support_agent",
mcpd.WithSpanKind(mcpd.SpanKindAgentRun),
mcpd.WithEntity(mcpd.EntityKindAgent, "support-agent", "Support agent"),
mcpd.WithSession("sess-4821"),
mcpd.WithUser("u-317"),
mcpd.WithTags("support", "billing"),
mcpd.WithInput(question),
)
defer run.End()
m.Log(ctx, mcpd.LogLevelInfo, "ticket received", map[string]any{"channel": "email"})
answer := draftAnswer(ctx, m, question)
run.SetOutput(answer)
if err := sendReply(ctx, m, answer); err != nil {
// The span is now an error with a message, which is what makes it
// worth reading. End below records the failed status.
run.RecordError(err, mcpd.ErrorDomain("email"), mcpd.ErrorRetryable(true))
}
return run.TraceID()
} val question = "where is my order 41?"
// `span` starts the operation, ends it, and records anything it throws.
// Inside the block the span is the active one, so the helpers below find
// their parent through the coroutine context rather than being handed it.
val traceId = mcpd.span(
"support_agent",
SpanKind.AGENT_RUN,
input = question,
sessionId = "session-8f21",
userId = "customer-4477",
tags = listOf("support", "email"),
) { run ->
mcpd.log(LogLevel.INFO, "handling a question", mapOf("channel" to "email"))
val plan = planAnswer(mcpd, question)
val order = lookUpOrder(mcpd, 41)
val estimate = estimateDelivery(mcpd, 41)
val answer = buildString {
append(plan).append(' ').append(order)
if (estimate != null) append(" It should arrive ").append(estimate).append('.')
}
run.setOutput(answer)
mcpd.metric("support.answers", 1.0, MetricKind.COUNTER, unit = "{answer}")
// The block's value is the value of `span`.
run.traceId
} question = "where is my order 41?"
# The block's value is the value of `span`. Inside it, this span is the active
# one for the process, so the helpers below find their parent without being
# handed it.
trace_id =
Mcpd.span mcpd, "support_agent",
kind: :agent_run,
input: question,
session_id: "session-8f21",
user_id: "customer-4477",
tags: ["support", "email"] do
Mcpd.log(mcpd, :info, "handling a question", %{"channel" => "email"})
plan = plan_answer.(mcpd, question)
order = look_up_order.(mcpd, 41)
estimate = estimate_delivery.(mcpd, 41)
_answer =
[plan, order, estimate && "It should arrive #{estimate}."]
|> Enum.reject(&is_nil/1)
|> Enum.join(" ")
Mcpd.metric(mcpd, "support.answers", 1, kind: :counter, unit: "{answer}")
Mcpd.Context.current().trace_id
end # One batch carries any mix of the five signals. Send the whole run at once.
curl -s -X POST "$MCPD/v1/ingest" \
-H 'content-type: application/json' \
-H "authorization: Bearer ${MCPD_API_KEY:-}" \
-d @- <<JSON
{
"ingest_key": "run-$TRACE",
"spans": [
{
"trace_id": "$TRACE",
"span_id": "$ROOT",
"name": "support_agent",
"kind": "agent_run",
"status": "ok",
"start_time": "$NOW", "end_time": "$NOW",
"is_root": true, "is_event": false, "received_at": "$NOW",
"input": { "question": "Where is order 4417?" },
"output": { "answer": "It is in the Leeds depot." },
"correlation": { "project": "default", "environment": "production" }
},
{
"trace_id": "$TRACE",
"span_id": "$GEN",
"parent_span_id": "$ROOT",
"name": "chat",
"kind": "model_generation",
"status": "ok",
"start_time": "$NOW", "end_time": "$NOW",
"is_root": false, "is_event": false, "received_at": "$NOW",
"model": "gpt-4o",
"provider": "openai",
"usage": { "input_tokens": 1284, "output_tokens": 210 },
"correlation": { "project": "default" }
}
]
}
JSON
# → {"accepted":2,"rejected":0,"duplicate":0}
#
# mcpd prices the model span from its own price book on the way in, so cost is
# stored with the run rather than recomputed later from rates that may have
# changed since. Six signals, correlated by the run that produced them.
Most tools give you traces here and logs somewhere else, and leave you to match timestamps by eye. mcpd stores all six signals against the same trace and span ids, so a spike on a chart opens the run that caused it and a run shows the log lines it emitted.
Sorting bench
Six kinds of signal arrive mixed on one belt. The reader reads the trace id on each, and every piece of a run lands in that run’s drawer.
13 languages that agree.
Cross-language conformance
tests/e2e/run.mjs
| Channel | root | tree | status | error | model | tokens | score | feedback | ids |
|---|---|---|---|---|---|---|---|---|---|
| TypeScript | |||||||||
| Python | |||||||||
| Go | |||||||||
| Elixir | |||||||||
| Kotlin | |||||||||
| Rust | |||||||||
| cURL | |||||||||
| TypeScript | |||||||||
| Python | |||||||||
| Go | |||||||||
| Elixir | |||||||||
| Kotlin | |||||||||
| Rust |
13 languages, one canonical run, one daemon. Every trace comes back the same shape.
481 checks
Every SDK is tested on its own, which proves each does what its authors meant and nothing about whether they match. So each one writes the same run against one daemon, and the traces are read back and compared to each other.
A behaviour that holds in one language and not another shows up there and nowhere else. It runs in CI on every change. How this compares.
Who read what, including who was turned away.
The question that decides a security review is not whether the data is safe. It is whether you can show, afterwards, who reached it.
Audit log
GET /v1/api/audit
| Time | Principal | Action | Project | Result |
|---|---|---|---|---|
| 14:02:11 | a3f9c2e1b7d4 | get_trace | checkout | ok |
| 14:02:11 | a3f9c2e1b7d4 | list_spans | checkout | ok |
| 14:04:38 | 7c1e08b53a9f | list_dataset_items | billing | refused |
| 14:07:02 | d52a4f8c0e63 | capture_into_dataset | checkout | ok |
| 14:11:55 | d52a4f8c0e63 | delete_trace | checkout | ok |
| 14:19:20 | 7c1e08b53a9f | list_feedback | billing | ok |
A refusal is recorded like a read. The principal is a digest of the key, never the key. Retention never trims this table.
18 actions
Six key scopes
Emitting telemetry, reading prompts, changing stored data, destroying it, starting runs and claiming steps are different privileges. An unset write list accepts the ingest keys, and an unset admin list accepts the read keys. Unset execute and worker lists accept the write keys. Upgrading does not lock you out.
A project is a boundary
Keys, retention and sampling are all per project. A key listed under one reaches that one, and the daemon resolves who owns a dataset before it returns a single case from it.
Nothing calls home
Four outbound request sites exist in the whole daemon, and you configure every one of them: the forwarder, two webhooks and the judge. There is no update check, no telemetry and no licence server.
Which key, and which person
The ledger names the key. Give mcpd the header your ingress already sets, and the addresses allowed to set it, and it names the person as well. Sent from anywhere else, a header is a claim anyone could make, so the row stays empty rather than carry a guess.
A deletion request is one call
One DELETE removes every run recorded against a person, with the spans, logs, metrics, scores and feedback hanging off them, and answers with a count per signal. Rows in a project that key cannot open are neither touched nor counted.
Check it before you trust it
mcpd doctor grades a deployment: whether the store takes a write and gives it back, whether a scope was left open, whether a configured project has ever received a span. Anything it wants you to act on names the flag that fixes it.
What mcpd does not do.
Every tool has a list like this. Most keep it internal, and you find it out in the second week. Here it is, with the reason rather than a date.
- No OTLP over gRPC.
- HTTP carries traces in both encodings, and logs and metrics in JSON. gRPC is not supported at all.
- No vendor exporters by name.
- mcpd forwards OTLP, which reaches most destinations through one implementation and makes mcpd one hop rather than the place your data has to stay. One that speaks only its own protocol is out of reach.
- No SSO or SCIM.
- Keys are scoped to projects, which is a tenancy boundary, not an identity provider. A person's identity is whatever your ingress establishes in front of mcpd.
- No multi-region ingest.
- On Postgres or ClickHouse it runs as several replicas behind one service. That is not regional failover.
The full account, measured against a direct source audit of the incumbent, is in PARITY.md. It is updated when something lands, not when something is planned.
Five minutes to your first trace.
Run the daemon, point your agent at it, open the interface. If it takes longer than that, it is a bug and we would like the report.
Start
docker run -p 4317:4317 ghcr.io/mcpd/mcpd