Integrating
A daemon is running somewhere. Here are the three ways anything else reaches it, and how to choose between them.
mcpd has one door and three ways through it. Everything below talks to the same HTTP surface: the SDKs are a convenience over it, and the MCP server is a client of it.
One port, four surfaces
A single process serves all of it on one port, 4317 by default. There is no sidecar, no collector to run beside it and no static-file server for the interface.
| Path | What it is | Key |
|---|---|---|
/v1/traces, /v1/logs, /v1/metrics | OpenTelemetry ingest. Any OTel SDK, with no mcpd-specific code in your application. | Ingest |
/v1/ingest | Native ingest. One batch carries any mix of the six signals and the whole data model, and one request carries a stream of batches. | Ingest |
/v1/api/… | The query API. The interface and the SDKs both read through this and nothing else. | Read, write, admin, execute or worker, declared per endpoint |
/ | The operator interface, compiled into the binary. | None for the markup; its data goes through the query API |
Two more paths, /healthz and /readyz, are
unauthenticated so a probe can reach them. Start there when you are working
out whether an endpoint is reachable at all:
$ curl -s "$MCPD/healthz"{"status":"ok","version":"0.1.0","spec_version":"1.0.0",…}$ curl -s "$MCPD/readyz"storage is not reachable: …# readyz touches the store, so a 503 here means the daemon is up# and the database behind it is not. The HTTP API, with no SDK
If your language has an HTTP client, it can talk to mcpd. There is nothing to register, no project to create up front and no handshake. Every SDK is a convenience over the requests below, and choosing this route costs you batching, retry and id generation, which you then do yourself.
Writing a run
POST /v1/ingest takes one batch. A batch may carry spans, logs,
metrics, scores, feedback and aspect notes together, which is the reason the
native door exists alongside the OTLP one: the six signals share trace and
span ids, so sending them in one request keeps them correlated with no
second lookup.
Three shapes reach that endpoint, and the content type says which. Nothing is worked out from the bytes, so adding a newline inside a string can never change what a body means.
| Content type | Body |
|---|---|
application/json | One batch object, which is what every SDK sends |
application/json | An array of batch objects, for a sender with several to hand |
application/x-ndjson | One batch per line, read as the body arrives rather than held whole. application/jsonl means the same thing |
Each element is the batch it is: it is stored in its own transaction when it
carries an ingest_key, so a resent stream is recognised line by
line rather than as one lump, and elements with no key are stored together.
The whole request is refused, with nothing stored, if any line cannot be
read or if the request as a whole is over either bound. The reply is one
IngestResult covering all of it.
-
Spans
What ran, nested, with timings
-
Logs
Lines the run emitted
-
Metrics
Numbers, with exemplars back to a run
-
Scores
Machine evaluation
-
Feedback
What a person thought
-
Aspect notes
A sentence a model wrote about the run
one batch
# 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. Four things the request has to get right:
- Ids are OpenTelemetry-shaped. 32 lowercase hex characters for a trace, 16 for a span, never all zero. Anything else breaks correlation with every other tool you run.
-
ingest_keymakes a retry safe. It is an idempotency key that is stable for a batch, so a resend is recognised and counted as a duplicate rather than written twice. - The key goes in a header.
Authorization: Bearer …, orX-Mcpd-Key. Ingest needs an ingest key, and only when the daemon has any configured. - Batches have bounds. 32 MiB of body and 10,000 signals by
default, both settable with
--max-body-bytesand--max-batch-signals. The signal bound counts the whole request, so splitting the same signals over lines does not get past it. Sendcontent-encoding: gzipfor anything large.
The response reports what happened per signal:
{"accepted":2,"rejected":0,"duplicate":0}, with an
errors array naming the index and the reason for anything
refused. The OTLP endpoints answer in OTLP's own shape instead, with a
partialSuccess object carrying the rejected count, because that
is what an OpenTelemetry sender reads.
Reading it back
read
# Read it back. Any filter in the API works here.
curl -s "$MCPD/v1/api/traces?project=default&limit=10"
# The whole run, with its logs, scores and feedback in one request.
curl -s "$MCPD/v1/api/traces/$TRACE?include=all"
# Find the run by something the model actually said.
curl -s --get "$MCPD/v1/api/traces" \
--data-urlencode 'project=default' \
--data-urlencode 'q=Leeds depot'
# Find the runs a scorer marked down. This is the query that finds bad answers.
curl -s --get "$MCPD/v1/api/traces" \
--data-urlencode 'project=default' \
--data-urlencode 'has_score=answer_relevance' \
--data-urlencode 'score_below=0.5'
# Where the money went, over the last week, by model.
curl -s --get "$MCPD/v1/api/analytics/cost" \
--data-urlencode 'project=default' \
--data-urlencode 'from=-7d' \
--data-urlencode 'group_by=model'
Filters compose, paging is by cursor, and project defaults to
the daemon's default project when you leave it out. Full-text search over
stored payloads is the q parameter, which is what makes
"find the run where the model said this" a query rather than a grep.
A failure mcpd itself raises comes back as JSON with a stable
machine-readable error code, a message written for
whoever has to fix it, and a field when one parameter is at
fault. Branch on the code, show the message.
One class of failure does not have that shape, and a client that assumes it
does will fail to parse the reply. A request body that cannot be decoded
into the type the endpoint expects is refused by the web framework before
any mcpd code runs, so it answers 422 with
content-type: text/plain and a message like
missing field `scorer_id`. Body syntax that is not JSON at all
answers 400, also as text. There is no error code
to branch on in either case. This covers every endpoint that takes a typed
body:
POST /v1/api/scores, /v1/api/feedback,
/v1/api/score, /v1/api/datasets, its
items and capture routes, and
/v1/api/experiments. The ingest doors read the body themselves,
so /v1/ingest and the OTLP routes do return the JSON shape.
Treat a non-JSON body on a 4xx as a malformed request from your own code,
and read it as text.
The SDKs
Six languages: TypeScript, Python, Rust, Go, Kotlin and Elixir. Each is idiomatic where it can be and identical where it matters. A user who has read the TypeScript quickstart can predict the Kotlin one.
What you get over raw HTTP:
- Batching and a background worker, so recording a span appends to a queue and returns.
- Retry with backoff on 429 and the 5xx codes worth retrying, and never on a 4xx that will stay malformed.
- Id generation, context propagation, and W3C
traceparentparsed on the way in and emitted on the way out, so a trace survives a service boundary. - Types generated from the same specification as the daemon's, so a field cannot mean one thing in the SDK and another in the store.
Three promises hold in every language, and they are the reason an SDK is safe to put in a hot path. Instrumentation never throws: an unreachable daemon is logged at most once an interval and swallowed. It never blocks: network work happens on the worker, so your agent's latency does not depend on mcpd being up. And its queue is bounded: when it fills, the oldest spans are dropped and a counter goes up, because an unbounded buffer turns a network outage into an out-of-memory kill.
connect
import { Mcpd } from '@mcpd/sdk'
// Every field here also reads an environment variable, so the same code runs
// unchanged in development and in production. With no arguments at all this
// talks to a daemon on http://localhost:4317.
const mcpd = new Mcpd({
project: 'support',
environment: 'development',
serviceName: 'support-agent',
serviceVersion: '1.4.0',
}) from mcpd import FeedbackKind, Mcpd, SpanKind
# Everything here has an MCPD_* environment variable behind it, so the same code
# runs in development and in production with no edit. `Mcpd()` with no arguments
# talks to a local daemon.
mcpd = Mcpd(
endpoint=os.environ.get("MCPD_ENDPOINT", "http://localhost:4317"),
project="support",
environment="development",
service_name="support-agent",
service_version="1.4.2",
) // Nothing here is required. `Mcpd::new()` reads MCPD_ENDPOINT, MCPD_API_KEY
// and the rest from the environment and talks to a local mcpd by default.
// Anything set in code wins over the environment.
let mcpd = Mcpd::with_config(
Config::from_env()
.with_project("support")
.with_environment("local")
.with_service("support-agent", Some("1.0.0")),
); m := mcpd.New(
mcpd.WithEndpoint("http://localhost:4317"),
mcpd.WithProject("support"),
mcpd.WithEnvironment("development"),
mcpd.WithService("support-agent", "1.4.2"),
)
// Shutdown flushes what is still queued and stops the background worker.
// A short-lived process that skips it loses whatever had not been sent.
defer m.Shutdown(ctx) // Every field also reads an environment variable (MCPD_ENDPOINT,
// MCPD_PROJECT, MCPD_API_KEY, ...), so `Mcpd()` on its own works too.
val mcpd = Mcpd(
McpdConfig(
endpoint = "http://localhost:4317",
project = "support-desk",
environment = "development",
serviceName = "support-agent",
serviceVersion = "1.4.0",
)
) # Every option also reads an environment variable (MCPD_ENDPOINT, MCPD_PROJECT,
# MCPD_API_KEY, ...), so `Mcpd.start_link()` on its own works too.
{:ok, mcpd} =
Mcpd.start_link(
endpoint: "http://localhost:4317",
project: "support-desk",
environment: "development",
service_name: "support-agent",
service_version: "1.4.0"
) # mcpd needs no registration and no project to be created up front. Check that
# the daemon is up and which spec version it implements.
curl -s "$MCPD/healthz"
# → {"status":"ok","version":"0.1.0","spec_version":"1.0.0", ...}
A client built with no arguments works against a local daemon. Everything is
settable in code or by environment variable, with code winning, and the
variable names are the same in every language so a value copied between two
services keeps working when they are written in different ones:
MCPD_ENDPOINT, MCPD_API_KEY,
MCPD_PROJECT, MCPD_ENVIRONMENT,
MCPD_SERVICE_NAME, MCPD_SERVICE_VERSION,
MCPD_SAMPLE and MCPD_ENABLED. Setting
MCPD_ENABLED=false turns every call into a no-op that still
returns valid objects, so a test suite can switch mcpd off without touching
any code.
They are interchangeable, and that is tested
Unit tests prove each SDK does what its own authors meant. They prove
nothing about whether the six agree. So the conformance suite writes one
canonical run through each language against one live daemon, plus a seventh
channel that sends the same run with nothing but curl, and
asserts that what comes back is structurally identical:
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
If you are choosing a language for a new service, this is the answer to "will the traces look the same as the ones we already have".
If your language is not one of the six
Point an OpenTelemetry SDK at /v1/traces and send no new code.
mcpd is an OTLP receiver, and it maps both the OpenTelemetry
gen_ai.* convention and the OpenInference
llm.* one, so instrumentation you already have lands correctly.
See OpenTelemetry.
From an AI agent, over MCP
The third route is for an agent rather than a service: instead of your code reading traces, a coding or support agent reads them on your behalf and answers a question about them. "Why did the last run fail", "show me the runs a scorer marked down this week", "what did this session cost". mcpd exposes that through the Model Context Protocol.
The three tools
Three, not thirty. They are the three moves of one investigation: which run, what happened inside it, and whether it is one run or all of them.
| Tool | What it answers |
|---|---|
find_runs | Which run. One line each: id, age, status, duration, cost, steps. Filter by status, model, user, session or free text; sort by duration to get the slowest. Defaults to the last 24 hours. |
get_run | What happened. The waterfall, the step that failed and why, the prompts and completions, the logs, the scores. Payloads are capped so one run cannot fill a context window. |
run_stats | One run or all of them. Cost, failure rate, latency and tokens over a window, grouped by model, name, status, environment or user. A 40% failure rate means the run you are reading is not the problem. |
Results come back as text rather than JSON, which is a deliberate choice about whose context this spends. Forty span objects is mostly punctuation; the same run drawn as a waterfall is a few hundred tokens and the agent can see the shape of it.
The shape of it
The MCP server is a subcommand of the same binary rather than a second
service. It runs as mcpd mcp, speaks JSON-RPC over standard
input and output, and holds no data of its own. There is no port to open, no
process to keep alive and nothing to deploy: the agent host starts it as a
child process when a conversation needs it and stops it afterwards.
What it does hold is a connection to a daemon. Every tool call becomes a request to the same query API described above, with the same read key and the same project scoping, so an agent cannot reach a project the key it was given does not reach. The blast radius of handing an agent this server is exactly the blast radius of handing it that key, which is the property to check before you configure one.
The tool set is deliberately small. A large surface makes a model choose
badly, and most questions about an agent's behaviour reduce to listing runs,
opening one, and asking a numeric question over a window. A client discovers
the current set with the protocol's own tools/list call rather
than from a list written down here that could fall behind.
Configuring a client
An MCP host launches a server from a command, some arguments and an environment. The exact file and the exact key are the host's, not mcpd's, so check its documentation; the three values it needs from you are the same everywhere.
| What the host needs | Value |
|---|---|
| Command | The mcpd binary, on a path the host can run. |
| Arguments |
The mcp subcommand. It takes --endpoint, the
daemon to query, and --api-key, a read key for it, for a
host that passes arguments more readily than an environment.
|
| Environment |
The same two values as MCPD_ENDPOINT and
MCPD_API_KEY. The endpoint defaults to
http://127.0.0.1:4317, and the key can be left out when
the daemon has no keys set.
|
what the host runs
MCPD_ENDPOINT=https://mcpd.internal MCPD_API_KEY=$READ mcpd mcp Three things to hold on to when you issue that key.
-
Give the agent a
readkey, not aningestkey. The scopes exist so that a credential for writing spans is not also a credential for every stored prompt. - Scope it to a project. A key configured at the top level reaches every project on the daemon.
-
Know that a read key also deletes traces until you configure an
adminlist, because the admin scope falls back to the read list when it is unset. An agent holding a read key on a daemon that has never been given an admin list can delete a run.
All three are on Security.
Which one to use
| Route | Use it when |
|---|---|
| HTTP | Your language has no SDK, you are sending from a shell or a job, or you want no dependency at all. |
| An SDK | You are instrumenting an application, where batching, retry and never throwing are worth more than the dependency. |
| OpenTelemetry | You already emit OTel and would rather change a URL than add a library. |
| MCP | A person is asking an agent questions about stored runs, rather than code reading them on a schedule. |
They mix. A fleet that emits through the TypeScript SDK, a nightly job that
posts scores with curl, and an agent reading it all back over
MCP is one deployment, not three.
Next
- What is stored and who can read it: Security.
- Pointing an existing OTel setup at mcpd: OpenTelemetry.
- Every endpoint, generated from the specification: HTTP API.
- The queries worth knowing: Finding a run.