Running it
mcpd is one process holding one file. That makes most of operating it short, and it makes the few things that matter worth knowing exactly.
What is on the port
One address serves everything: OTLP ingest at /v1/traces,
/v1/logs and /v1/metrics, native ingest at
/v1/ingest, the query API under /v1/api, the
operator interface at /, and the three operational endpoints
below. There is no sidecar and no reverse proxy to configure, which is most
of why the deployment fits on one line.
The interface is compiled into the binary, so there is no static file server and no second volume mount. Ingest carries the large body limit and no CORS. The query API carries CORS, so an interface served from another origin can read it.
/healthz and /readyz are not the same question
They are separate because they answer to different consequences, and wiring both to the same handler is a common way to build a restart loop.
| Endpoint | Touches storage | Answers |
|---|---|---|
GET /healthz | No | Always 200 while the process is serving |
GET /readyz | Yes, one read of each store | 200 when the signal store and the run store answer, 503 when either does not |
/healthz is liveness. It deliberately never touches storage: a
slow or locked database must not get the process killed and restarted,
because restarting it does not fix a disk and the restart loop costs you the
in-flight batches as well.
/readyz is readiness. It runs one trivial query against a
reader connection, so an instance that cannot serve stops being sent
traffic. When storage is unreachable it answers 503 with a
status of degraded and a detail
naming the failure, rather than a bare status code you then have to
investigate blind.
Both bodies carry the same fields: status,
version, spec_version,
uptime_seconds, storage, and
detail when something is wrong. spec_version is
the data-model version this build implements, which is how a client can
refuse to talk to a server it does not understand.
$ curl -s localhost:4317/healthz{"status":"ok","version":"0.1.0","spec_version":"1.0.0", "uptime_seconds":86400,"storage":"sqlite"}$ curl -s -o /dev/null -w "%{http_code}\n" localhost:4317/readyz200$ curl -s localhost:4317/metrics | head -4# HELP mcpd_signals_accepted_total Signals written to storage.# TYPE mcpd_signals_accepted_total countermcpd_signals_accepted_total 184203# HELP mcpd_signals_rejected_total Signals refused, with a reason returned to the sender.
Neither probe requires a key, and neither does /metrics. If
that is not acceptable in your network, restrict them at your ingress.
kubernetes
livenessProbe:
httpGet: { path: /healthz, port: 4317 }
periodSeconds: 10
readinessProbe:
httpGet: { path: /readyz, port: 4317 }
periodSeconds: 5
# SIGTERM is handled, so give in-flight ingest time to finish.
terminationGracePeriodSeconds: 30 Metrics
mcpd exports its own counters at /metrics in Prometheus text
exposition format. They are not behind a feature flag: an observability tool
that cannot be observed is a liability.
| Metric | Type | Meaning |
|---|---|---|
mcpd_signals_accepted_total | counter | Signals written to storage |
mcpd_signals_rejected_total | counter | Signals refused, with a reason returned to the sender |
mcpd_batches_duplicate_total | counter | Batches recognised as a replay and skipped |
mcpd_traces_sampled_out_total | counter | Traces the sampling policy discarded |
mcpd_signals_over_quota_total | counter | Signals refused for exceeding a project's per-minute ceiling |
mcpd_signals_unbounded_total | counter | Signals that reached the ceiling step for a project no ceiling covers |
mcpd_ingest_requests_total | counter | Ingest requests handled |
mcpd_query_requests_total | counter | Query requests handled |
mcpd_request_errors_total | counter | Requests that failed with a server error |
mcpd_audit_write_failures_total | counter | Audit rows that could not be written; the request they describe still succeeded |
mcpd_uptime_seconds | gauge | Seconds since this process started |
Every counter is process-lifetime and resets to zero on restart, so alert on
a rate rather than a level. The two worth watching are
mcpd_signals_rejected_total, which means senders are being told
no and probably are not reading the reply, and
mcpd_request_errors_total, which is mcpd's own fault by
definition: a 4xx is the caller's and is never counted there.
A rising mcpd_traces_sampled_out_total is not a fault. It is
the number that explains a gap in the interface when somebody asks why their
run is missing.
mcpd_signals_over_quota_total is the same shape of number for
the other reason a signal does not land: a project hit its per-minute
ceiling. Those refusals are also counted in
mcpd_signals_rejected_total, so watch this one to tell a fleet
that is being limited apart from a fleet that is sending malformed data. See
Configuration for what the
ceiling covers.
mcpd_signals_unbounded_total is the opposite number: signals
that were kept because no ceiling covered the project they named. Ceilings
written only under projects bound the projects they name, so a
sender that names anything else walks past them. Those signals are counted
rather than refused, because refusing a project nobody wrote down would drop
the data of a deployment that upgraded without changing its configuration.
Non-zero means set a top-level --quota-per-minute beside the
per-project ones.
The ceiling covers every door a row can come in through: the four ingest
endpoints, the single-record score and feedback endpoints, and
POST /v1/api/score, whose scores are computed and then charged
against the same counter before they are stored. What does not fit under
the ceiling there is reported per score in the response's
skipped list rather than written past it.
mcpd_audit_write_failures_total counts audit rows that could
not be written. The request each row described still succeeded and still
returned its data, which is the point: an audit write never fails the read
it describes. Non-zero therefore means reads of stored payloads happened
that the audit log cannot account for, so treat it as an integrity signal
rather than an availability one.
prometheus
scrape_configs:
- job_name: mcpd
static_configs:
- targets: ['mcpd:4317'] Shutdown
mcpd handles SIGTERM and SIGINT. On either, it
stops accepting new connections and lets in-flight requests finish before
the process exits.
Handling SIGTERM specifically is what makes this work under an
orchestrator. Kubernetes sends SIGTERM and waits out the grace
period before SIGKILL; a container that only listens for
SIGINT is killed rather than stopped, and whatever batch was
mid-transaction is lost.
mcpd 0.1.0 listening on http://0.0.0.0:4317 storage sqlite at /data/mcpd.dbretention removed 12480 rows older than 30 daysreceived termination signalshutting down
What mcpd cannot save on the way out is anything still sitting in a sender's
queue. The SDKs hold spans in memory and export them on a worker, so a
process that exits without calling shutdown() loses whatever
had not gone yet. That is the agent's shutdown to get right, not the
daemon's.
Backups
The whole store is one SQLite file at --data. It runs in WAL
mode, so on disk you will find mcpd.db alongside
mcpd.db-wal and mcpd.db-shm. Copying the
.db on its own while the process is running gives you a file
that is missing everything still in the write-ahead log.
Take the backup through SQLite, which reads a consistent snapshot while mcpd keeps writing:
hot backup
sqlite3 /data/mcpd.db ".backup '/backup/mcpd-$(date +%F).db'"
Stopping the process first and copying all three files is equally valid and
needs no sqlite3 binary in the image. There is no
mcpd backup subcommand in this build, and no scheduled backup:
this is your snapshot tooling's job, and pretending otherwise would put a
half-built one in the critical path.
Retention deletes rows but does not shrink the file. SQLite keeps the freed
pages and reuses them, so disk usage plateaus rather than falling after a
large delete. Run VACUUM against a stopped instance if you
actually need the space back.
Restore
Putting a backup back is four steps, and the order matters because of the write-ahead log.
- Stop the daemon. A restore under a running process is a second writer on the same file.
- Copy the backup over the file at
--data. -
Delete the
-waland-shmfiles beside it. They belong to the database you replaced, and left in place they would be replayed into the one you restored. -
Start the daemon, and confirm it is reading the restored file:
/readyzanswers 200, and a trace list against the project returns the runs the backup held.
$ kill $(cat mcpd.pid) && cp /backup/mcpd-2026-09-01.db /data/mcpd.db$ rm -f /data/mcpd.db-wal /data/mcpd.db-shm$ mcpd --data /data/mcpd.db &$ curl -s -o /dev/null -w "%{http_code}\n" localhost:4317/readyz200$ curl -s "localhost:4317/v1/api/traces?project=default&limit=500" | jq ".traces | length"2 Upgrades
Replace the binary or the image tag and start it again. The schema is
applied at startup, every time, and is written so that running it against an
up-to-date file changes nothing. The applied version is recorded in a
schema_version table inside the same file, and this build is at
version 2.
Two things to keep in mind. Roll forward, not back: an older binary is not
promised to read a newer file, so take the backup first. And check
spec_version from /healthz after the upgrade if
you pin SDK versions, because that is the number a client compares against.
Where one instance stops
Being honest about this is more useful than a number we have not measured. We publish no throughput figures, because none exist that we could stand behind.
- One writer. The SQLite store keeps a single write connection behind a mutex and four read connections. WAL mode means a dashboard query never blocks ingest, which is the failure that would matter, but all writes are serialised through that one connection.
- One instance per file. Two mcpd processes pointed at the same file is not a supported deployment. Run it as a single replica with a volume, not as a horizontally scaled service.
- Both limits above are the embedded store's. They stop applying on ClickHouse, which is compiled in and covered below.
- Payloads are large. Prompts and completions are kilobytes, not bytes, and they are what makes mcpd worth running. Storage grows with text volume far faster than with span count, so measure with your own payloads rather than with a span-per-second figure.
- Retention covers the projects you wrote down. The hourly
pass deletes from every project in the
projectsblock, each on its ownretention_dayswhere it sets one and on the global figure where it does not, plus the project named by--project. A project a sender invented that appears in neither place has no window and is never trimmed, which is worth knowing before you rely on retention as a privacy control.
When volume outgrows the instance, the levers in order of how much they cost
you are: --retention-days, which bounds the file;
--sample, which bounds the inflow and is deterministic so a
kept trace is kept whole; and moving to ClickHouse, which removes the single
writer and the single replica together. See
Sampling and
Redaction and retention.
Postgres, when you already run one
Point mcpd at a Postgres connection string and it stores there instead of the embedded file. The schema is created on start.
store into Postgres
mcpd serve --postgres-url postgres://mcpd:$PGPASSWORD@db:5432/mcpd This removes the same two limits ClickHouse does. Writes no longer pass through a single connection, so mcpd runs as several replicas behind one service, and it does so on a database most teams already operate, back up and monitor.
Migration takes a Postgres advisory lock and is skipped entirely when the schema is already current. Several replicas starting at once is therefore safe, and a rolling deploy does not take an exclusive lock on tables that other replicas are writing to.
Full-text search uses a generated tsvector column with a GIN
index, in the simple configuration: it lower-cases and splits
on word boundaries but does not stem, so searching a prompt for the literal
token you typed does what you expect.
Choose ClickHouse over Postgres when retention is measured in billions of spans. Below that, the database you already run is the better answer.
ClickHouse, when the file is no longer the right shape
Point mcpd at a ClickHouse HTTP endpoint and it stores there instead of the embedded file. The schema is created on start, so there is nothing to apply by hand.
store into ClickHouse
mcpd serve \
--clickhouse-url http://clickhouse:8123 \
--clickhouse-database mcpd \
--clickhouse-user default --clickhouse-password $CH_PASSWORD
Both constraints above lift at once. Writes no longer pass through a single
connection, so mcpd runs as several replicas behind one service, and spans
land in a MergeTree built for the shape this data actually has:
far more writing than reading, and queries that scan a time range.
Choose it when retention is measured in months of production traffic rather than in a laptop's disk. On anything smaller the embedded file is faster to run, faster to back up, and one fewer thing to operate. Keep the file until it hurts.
Traces are a ReplacingMergeTree, because a trace projection is
recomputed as its spans arrive and the last write is the correct one. Spans
arriving out of order, retried, or updated in place therefore converge on
the right answer rather than accumulating duplicates.
One guarantee is weaker here than on the other two backends, and it is the
one you may have to certify. Deletion on ClickHouse is an asynchronous
mutation, so an erasure request is accepted and applied afterwards. SQLite
and Postgres delete inside the transaction that answered. The reply says
which of the two happened, in deletes_pending. See
Redaction and retention.
Checking a deployment
mcpd doctor reads the configuration and the store and reports
what is wrong, naming the flag that fixes each thing. It exists because the
failures that actually bite are quiet ones: nothing crashes, and you find
out weeks later.
check a deployment without contacting anything but the store
mcpd doctor --data /data/mcpd.db --offline
Three flags are the command's own. --offline contacts nothing
but the store, so the forward endpoint is reported as skipped rather than
checked. --read-only writes nothing, so the storage check
proves the store can be read and not that it can be written, and says so.
--window-days is how many days of history the activity, clock
and price-book checks read, seven unless you say otherwise, between 1 and
36525. Everything else it takes is the daemon's own configuration, so it
grades the invocation you would start.
Twelve groups are graded, in this order, and the report ends with a count of each level:
| Group | What it grades |
|---|---|
storage | The store answers a ping, and a probe span survives a write, a read and a delete |
runs | The run store answers, how many definition versions and running runs it holds, and whether the run surface needs a key on an address other people can reach. Read only: the doctor never claims a step or fires a deadline |
keys | Every scope, with its inheritance made explicit, so the case where no admin key is set and every read key therefore also deletes is on the page rather than in the documentation |
identity | The audit subject header, and whether the range permitted to set it is narrow enough to believe |
projects | Configured projects against projects that hold runs. One that has never received a span is almost always a typo in a name |
retention | How far back each project's data goes, and whether anything will ever delete it |
limits | The per-project ingest ceilings and the sampling fractions |
forwarding | The forward endpoint, by POSTing an OTLP batch with no spans in it |
judge | The judge block, as configuration only |
monitors | Monitors and objectives, and the signals they read |
clock | Runs from both ends of each project's history against this machine's clock, because a sender whose clock runs behind sorts underneath every honest run and would never appear on a page of the newest ones |
pricing | Recent model spans against the price book, so a model nothing can price is named |
identity and limits are the two most recently
added, and both were ungraded for the same reason: nothing about them
crashes. An audit log with no subject records what was done and never who
did it. A sampling fraction outside 0.0 to 1.0 is refused for
--sample and was never checked in a project block, so a project
could carry one and the daemon would run it. A ceiling set per project with
no top-level ceiling beside it leaves every project nobody wrote down with
no ceiling at all, and the sender chooses the project name.
Skipped is not ok
A check that was not run says skipped, never ok. That distinction is the point of the command: a subsystem missing from the configuration you pointed it at is not a subsystem in good health, and reporting the two the same way would make doctor most confident exactly when it had been handed the wrong file. The summary line counts the skipped checks separately and says so in words, so a run that verified two of its fourteen findings cannot read as a clean bill of health.
$ mcpd doctor --data /data/mcpd.db | tail -42 ok, 9 skipped, 2 warnings, 1 faultA skipped check proved nothing: it was not run, or there was nothing thereto run it against. Only the 2 ok were verified.A fault is something broken or open. Exiting 2. A busy store is not a broken one
The storage probe is the only check that needs the write lock. Everything below it reads, and readers do not queue behind a writer. A probe write that loses the race for that lock is reported as skipped: it is attempted three times, and if all three wait for the lock and give up, the report says the write was not checked and why.
This matters because the embedded store takes one writer, and a deployment
under real ingest holds that lock almost continuously. Reporting contention
as a fault made the command fail a deploy precisely when the deployment was
healthy and busy. The failures the probe exists to catch each name
themselves and none of them says "locked": a read-only mount, a full disk
and a role without INSERT are still faults, and so is a corrupt
lock, which says "locking protocol" and is real damage.
The exit code
It exits non-zero on a fault, so it can gate a deploy. A warning is not a fault: it exits zero and expects a person to read it. One configuration is a fault rather than a warning, and it is worth knowing before you wire this into a pipeline: an open read scope on a non-loopback address, which hands every stored prompt to anyone who can reach the port. An open ingest scope on the same address stays a warning, because junk rows are what retention removes and disclosure cannot be taken back. A trusted identity range that contains the whole internet is graded the same way, for the same reason: any address could then set the header and be recorded as whoever it names.
What runs without the command
You have to know mcpd doctor exists to run it, and none of the
mistakes it finds announces itself, so the deployment carrying one is
usually the deployment nobody pointed it at. The groups that read only the
configuration therefore run on every start: keys,
identity, limits and judge. They need
no store round trip and no network, so a start pays microseconds for them.
The other eight stay in the command, because each reads the store or
contacts the forward endpoint, and a start must wait on neither.
A fault found at startup stops the daemon; a warning is logged and it
carries on. What separates them is whether the configuration asks for
something that cannot work: a key entry that is empty or has whitespace at
an end, a trusted range holding the whole internet, a project
sample outside 0.0 to 1.0, or a judge that can never answer.
Each of those is a feature you turned on that silently does nothing, which
is worse than one that fails, because nobody goes looking for it. A short
key, a key two projects share, a ceiling smaller than one batch: those work,
and may be what you meant, so they are logged and never fatal.
The openness mcpd ships with is the exception. An open ingest or read scope
on a non-loopback address is a fault to mcpd doctor, which
gates a deploy, and a warning at startup, which does not. Refusing there
would mean a first run that never starts.
mcpd config and mcpd doctor themselves run against
a configuration a start would refuse. They are the two commands that exist
to explain a bad one, so neither can be the command that will not run.
$ mcpd --config-file /etc/mcpd/config.jsonError: the configuration cannot do what it says, so mcpd willnot start: judge: the judge at https://api.example.com/v1 has no criteria, so every judged scorer declines with `is not a configured criterion` and no judged score is ever produced. Add entries under `judge.criteria` in --config-file.Fix the lines above, or run `mcpd doctor` for the whole report. The settings to check before it is reachable
When it binds a non-loopback address, mcpd logs a warning at startup for each surface open to a caller that should not reach it, and each warning names the flags to set. It warns rather than refusing to start, because refusing would break the path from nothing to a first trace. It is still a warning about a real hole: an open read API hands every stored prompt to anyone who can reach the port.
mcpd --data /data/mcpd.db --ingest-key $INGEST_KEY --read-key $READ_KEY --execute-key $EXECUTE_KEY --worker-key $WORKER_KEY --admin-key $ADMIN_KEY --retention-days 30 Without the last three keys, the ingest key and the read key also reach the run surface, and the read key also deletes. Full detail on every key, and on everything else you can set, is in Configuration.