Skip to content
mcpd
no daemon
Source

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.

PathWhat it isKey
/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:

is it there
$ 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 typeBody
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

trace_id Store Search Export

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_key makes 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 …, or X-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-bytes and --max-batch-signals. The signal bound counts the whole request, so splitting the same signals over lines does not get past it. Send content-encoding: gzip for 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 traceparent parsed 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',
})

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

A conformance panel with one row per language and one column per family of check. Every cell passes. The families are: the root span is the agent run; every child hangs off the root; a failed step makes the whole run an error, and only that step; the error carries a message, on the step that failed; the model call records its model; token usage and cost survive the wire; the scores land, with their scale; the feedback lands; ids are OpenTelemetry-shaped. The suite asserts 37 checks in each of the 13 languages.

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.

ToolWhat 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 needsValue
CommandThe 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 read key, not an ingest key. 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 admin list, 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

RouteUse 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