Skip to content
mcpd
no daemon
Source

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.

probing a healthy instance
$ 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.

a clean stop
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.

  1. Stop the daemon. A restore under a running process is a second writer on the same file.
  2. Copy the backup over the file at --data.
  3. Delete the -wal and -shm files beside it. They belong to the database you replaced, and left in place they would be replayed into the one you restored.
  4. Start the daemon, and confirm it is reading the restored file: /readyz answers 200, and a trace list against the project returns the runs the backup held.
restore, run against a demo store
$ 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 projects block, each on its own retention_days where 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:

GroupWhat 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.

how a run against a fresh deployment ends
$ 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.

a judge that could never have produced a score
$ 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.