Configuration
Every setting is one flag, and every flag has an environment variable with the same meaning. The few that are too structured for a command line live in one JSON file. This page is all of them.
Two ways to say the same thing
Run mcpd with no arguments and it works: it listens on
0.0.0.0:4317, keeps data in ./mcpd.db, redacts
secrets, and stores every trace forever. Everything below changes one of
those decisions.
A flag always wins over its environment variable. Both are global, so they are accepted before or after a subcommand.
Reference
| Flag | Variable | Default |
|---|---|---|
--listen | MCPD_LISTEN | 0.0.0.0:4317 |
--data | MCPD_DATA | ./mcpd.db |
--project | MCPD_PROJECT | default |
--retention-days | MCPD_RETENTION_DAYS | unset: keep everything |
--ingest-key | MCPD_INGEST_KEYS | none: ingest is open |
--read-key | MCPD_READ_KEYS | none: reading is open |
--write-key | MCPD_WRITE_KEYS | none: the ingest keys are accepted |
--admin-key | MCPD_ADMIN_KEYS | none: the read keys are accepted |
--execute-key | MCPD_EXECUTE_KEYS | none: the write keys are accepted |
--worker-key | MCPD_WORKER_KEYS | none: the write keys are accepted |
--runs-data | MCPD_RUNS_DATA | the --data path with .runs before its extension |
--trusted-identity-header | MCPD_TRUSTED_IDENTITY_HEADER | none: no subject is recorded |
--trusted-proxy-cidr | MCPD_TRUSTED_PROXY_CIDRS | none: no address may set that header |
--client-ip-header | MCPD_CLIENT_IP_HEADER | none: the peer address is the client |
--signup | MCPD_SIGNUP | off: no accounts |
--accounts-data | MCPD_ACCOUNTS_DATA | the --data path with .accounts before its extension |
--sample | MCPD_SAMPLE | unset: keep every trace |
--quota-per-minute | MCPD_QUOTA_PER_MINUTE | unset: no ceiling |
--no-redact | MCPD_NO_REDACT | off: redaction is on |
--redact-key | MCPD_REDACT_KEY | a built-in key, which is public |
--redact-key-contains | MCPD_REDACT_KEY_CONTAINS | none: the built-in rules only |
--redact-max-string | MCPD_REDACT_MAX_STRING | 128 KiB |
--cors-origin | MCPD_CORS_ORIGINS | none: same-origin only |
--request-timeout-seconds | MCPD_REQUEST_TIMEOUT_SECONDS | 30 |
--max-body-bytes | MCPD_MAX_BODY_BYTES | 33554432, which is 32 MiB |
--max-batch-signals | MCPD_MAX_BATCH_SIGNALS | 10000 |
--log-format | MCPD_LOG_FORMAT | text |
--clickhouse-url | MCPD_CLICKHOUSE_URL | off: the embedded file is used |
--clickhouse-database | MCPD_CLICKHOUSE_DATABASE | mcpd |
--clickhouse-user | MCPD_CLICKHOUSE_USER | none |
--clickhouse-password | MCPD_CLICKHOUSE_PASSWORD | none |
--postgres-url | MCPD_POSTGRES_URL | off: the embedded file is used |
--config-file | MCPD_CONFIG_FILE | none |
--forward-endpoint | MCPD_FORWARD_ENDPOINT | off: nothing is forwarded |
--forward-header | MCPD_FORWARD_HEADERS | none |
| none | MCPD_LOG | mcpd=info,mcpd_server=info,tower_http=warn |
--listen
The address to bind. One port serves the OTLP endpoints, the native ingest endpoint, the query API, the probes, the metrics and the interface, so this is the only address mcpd has.
Binding a loopback address is the quiet choice: mcpd only warns about open
ingest when the address it bound is not loopback. On
0.0.0.0 with no keys set, it warns twice at startup, once for
ingest and once for reading.
--data
Path to the SQLite file. Parent directories are created if they do not
exist. Pass :memory: for a store that leaves nothing behind,
which is right for a demo or a test and wrong for anything you want to look
at tomorrow.
--project
The project a signal lands in when the sender did not name one. A project is
the tenancy boundary: every query is scoped to one. The retention pass runs
against it and against every project in the projects block; a
project that appears in neither place is never trimmed, which matters if
your senders name their own.
--retention-days
Delete signals older than this many days. Unset keeps everything, which is the correct default for a laptop and a decision worth making deliberately anywhere else.
The window is from 1 to 36500 days, here and in a project's
retention_days. Zero is refused at start-up rather than read as
no retention: it would set the cutoff to now, and the first pass runs at
boot, so it would delete every signal the project holds. To keep everything,
leave the setting out.
The pass runs on an hourly timer rather than at query time, so a large delete never lands inside a user's request. Spans, traces, logs and metrics are cut by when mcpd received them; scores, feedback and aspect notes by their own timestamp. Deleting rows does not shrink the file. See Running it.
--ingest-key, --read-key, --write-key, --admin-key, --execute-key and --worker-key
Six scopes, because they carry genuinely different risk. Every endpoint
declares the one it needs in spec/mcpd.spec.json, its handler is
tagged to match, and make check compares the two, so a scope
documented here is the scope the running server enforces.
| Scope | Set with | What it opens |
|---|---|---|
ingest | --ingest-key | The four ingest endpoints. Reads nothing. |
read | --read-key | The query API: traces, spans, logs, metrics, scores, analytics. |
write | --write-key | Scores, feedback, datasets, experiments, acknowledgements, and capturing a run into a dataset. |
admin | --admin-key | Deleting a trace, reading the audit log, and everything the two scopes below open. |
execute | --execute-key | Starting, cancelling and signalling a run, and publishing an event to one. |
worker | --worker-key | Registering definitions, claiming steps and reporting their outcome. Reads no trace. |
The split that matters most is ingest against everything else.
An ingest key lives in every process that emits a span, including the ones
running code you did not write. Handing that population a credential that
can also read back every stored prompt is a mistake mcpd does not make easy:
an ingest key presented to the query API is refused.
write is separate from ingest for the same reason
at a smaller scale. Capturing a run into a dataset reads that run's input and
output, so it is a read wearing a write's clothes and does not belong on the
key your fleet carries.
admin is separate from read because deleting a run
is not reading one. Until you set --admin-key, a read key
deletes as well as reads, and mcpd says so at startup.
execute and worker are the two scopes of the run
surface. Starting a run makes somebody's worker do work and spend money,
which annotating a trace does not, so it is not write. A worker
key lives in the process that runs step bodies, which is usually the process
holding the provider credential, so it opens the lease protocol and nothing
that reads a stored prompt. Until you set the two flags, the write keys are
accepted for both, and an admin key always is.
With no --write-key either, the write keys are the ingest keys,
so the key in every container that emits a span can also start runs and
claim steps, and a claimed step carries its rendered input. That is the
shape a deployment configured with only an ingest key and a read key has.
mcpd says so in a warning at startup and in mcpd doctor, and
mcpd config prints key required (ingest keys) on
both lines. Set --execute-key and --worker-key to
close it.
Upgrading without an outage
An unset --write-key accepts the ingest keys, and an unset
--admin-key accepts the read keys. A deployment configured
before these two scopes existed therefore keeps exactly the reach it had, and
nothing breaks on upgrade. Set the two flags to take the narrower grants,
and mcpd config tells you which state you are in: it prints
key required when the scope has its own keys and
key required (ingest keys) or
key required (read keys) when it is inheriting them.
Repeat any of the six flags for several keys, which is how you rotate one
without an outage. The environment variables take a comma-separated list. A
caller presents a key as Authorization: Bearer KEY or as
X-Mcpd-Key: KEY, and the comparison is constant-time so
neither the length nor the position of the first difference is observable.
A key mcpd does not know is a 401. A key mcpd knows, used on an
endpoint its scope does not cover, is a 403. The two are worth
keeping apart: a 401 sends you hunting for a typo in a key that is perfectly
correct.
An empty entry beside a real key is dropped before anything reads the list,
so a trailing comma adds nothing. A list that is set but holds no key at
all, such as MCPD_EXECUTE_KEYS= or an empty value in a
Kubernetes Secret, stops the daemon at start, and the refusal names the flag
and the variable. Leave the variable out to leave the scope unset. A request
that sends an empty key header presents no key.
A key that is still a placeholder from one of the example files in
deploy/, any value starting REPLACE_WITH or
replace-me in any case, stops the daemon starting. The refusal
names the flag or the project list it is in and never the value. The same
applies to --redact-key. Generate each key with
openssl rand -hex 32.
With no keys configured at all, every scope is open. That is deliberate, because the first run must not require a credential, and it is why the startup warning exists.
Note what the keys do not cover: /healthz,
/readyz, /metrics and the interface itself are
served without a key in this build. Put them behind your own ingress if
that matters to you.
The audit log
The handlers that hand payloads back record who read what:
GET /v1/api/traces/{id}, the span and log searches,
feedback, dataset items, capture, a trace's aspect notes, a theme and its
examples, delete and erasure.
Security names all eleven
actions. A request answered with 400 leaves no row, because
nothing was read. Each row carries the time, a
short digest of the key that acted, the action, the project, the resource and
whether authorisation allowed it. The key itself is never written down.
Read the log with GET /v1/api/audit, which takes the admin
scope. It is not trimmed by --retention-days: a log that deleted
itself on the same schedule as the data it describes could not say who read
the data that has since gone. Writing a row never fails the read it
describes; a row that cannot be written is logged and counted as
mcpd_audit_write_failures_total on /metrics, which
is worth an alert.
--sample
A fraction between 0.0 and 1.0. A value outside that range is a startup
error, not a silent clamp: --sample must be between 0.0 and 1.0, got
2.
The decision is made by hashing the trace id, so a fleet of mcpd instances behind a load balancer keeps and drops the same traces without talking to each other. It is made once per trace and inherited by every span of that trace in the batch, because a half-recorded trace looks like a bug in the system you are observing.
The hash reads the trailing bytes of the trace id. Some producers derive the leading bytes from a timestamp, and a sampler that looked at those would correlate with time of day and quietly drop whole hours.
The policy type also carries "always keep errors" and "always keep slower than", but those apply to a tail decision, made once a trace has finished. This build samples at the head only, and no flag turns tail sampling on. Sampling covers the trade.
--no-redact, --redact-key, --redact-key-contains and --redact-max-string
Redaction is on by default and runs before anything is written, so a value that should never have been persisted never is. Deleting it afterwards is not the same guarantee. Three rule sets ship built in, matched on lowercase substrings of a key name:
| Rule | Applied when the key contains | What is stored |
|---|---|---|
| Mask | api_key, apikey, api-key,
secret, password, passwd,
access_token, refresh_token,
id_token, bearer,
authorization, auth_token,
private_key, client_secret,
session_token, credential,
signature, x-api-key | [redacted] |
| Partial | email, phone, ssn,
credit_card, card_number,
iban |
The first two and last two characters, ab…yz. A value
of six characters or fewer is masked entirely rather than leaking
most of itself
|
| Hash | user_id, userid | sha256: and 12 bytes of a keyed digest, so you can
still group by a user without storing one
|
A matched key holding an object or an array has everything underneath it
redacted too, so {"credentials": {"token": "..."}}
does not slip through on the nested name alone. Any single string longer
than 128 KiB is truncated with a marker naming the byte count, which bounds
what a runaway prompt does to your disk.
--redact-key is the key for the hashing rule. Equal inputs hash
equally under one key, which is what makes grouping work, and rotating the
key breaks that link deliberately. Set it in any deployment that hashes:
the built-in default is in the source, so unkeyed digests are guessable.
--redact-key-contains adds a masking rule of your own on top
of the built-in three. The built-in rules know the names credentials are
usually stored under; they do not know the field your own tools put a
customer's account number in. The value is matched the same way the
built-in rules are, as a lowercase substring of the key, so
--redact-key-contains ticket masks ticket_id
and supportTicket alike, and everything nested under a
matched key with them. Repeat the flag for several, or set
MCPD_REDACT_KEY_CONTAINS to a comma-separated list.
--redact-max-string lowers the length cap from its built-in
128 KiB. A string longer than the cap is cut there, with a marker naming
how many bytes were dropped, before it is stored.
--no-redact turns the whole pipeline off, including the length
cap. As an environment variable it is
MCPD_NO_REDACT=true. It is only sensible when something
upstream already redacts, and the daemon refuses to start if either
addition above is set beside it, because there is nothing for them to
apply to.
--runs-data
Where the run store lives: the definitions, the journal of step attempts,
every deadline and every wait. It is an embedded file whatever the signal
store is, so on Postgres or ClickHouse the runs still sit in one file beside
the daemon, and the run surface is served by one replica. By default it is
the --data path with .runs before the extension,
./mcpd.runs.db for the default, and :memory: when
--data is. --runs-data off serves no runs at all:
every run endpoint answers 503 naming this flag, and the rest of
the daemon is unchanged.
The store's timings are in the config file under runs, in whole
milliseconds: lease_ttl_ms, reclaim_window_ms,
stall_window_ms, idempotency_window_ms,
park_ttl_ms and max_depth. The defaults, a 30 second
lease and a 30 second reclaim window, are right for one daemon on one
machine and are what a worker reads back when it registers.
The same block holds max_step_repeats, which is a count rather
than a duration: how many times one step key may fail with an identical
rendered input before the run is ended as a repetition. It defaults to 16,
and zero turns it off. It exists because two retry paths spend no retry
budget, so retry.max_attempts does not bound them; see
Runs.
--cors-origin
Which browser origins may call the query API from another site. With none
set, a cross-origin request gets no
access-control-allow-origin header and the browser refuses to
hand the page the response. That is the default, and it is what keeps a
daemon on your laptop with an open read API from being readable by any
web page open in the same browser. The bundled interface is served from the
daemon's own origin and never needs this flag.
Serving the interface from somewhere else, a CDN or an internal portal,
is the case this flag exists for: name that origin, as scheme and host,
such as --cors-origin https://console.example. Repeat it for
several, or set MCPD_CORS_ORIGINS to a comma-separated list. A
value that is not an origin, console.example or one with a
trailing slash, refuses the start with the flag named rather than leaving
you with an interface that cannot connect and no reason why.
--cors-origin '*' allows every origin, which is what earlier
builds did without asking.
/healthz, /readyz and /metrics are
not governed by this flag. They carry no stored data, and the interface
probes /healthz before it does anything else, so they stay
readable from any origin.
--request-timeout-seconds
The longest a request may take, reading the body included, before the
daemon answers for it. A handler that runs past the deadline is answered
with a 503 whose body carries timeout and this
flag's name. The default is 30 seconds, which is long enough for a large
batch to be read and stored, and short enough that a storage backend that
accepts connections and never answers is reported rather than waited on.
It cannot be turned off; raise it if an operation genuinely needs longer.
--max-body-bytes and --max-batch-signals
The two bounds on one request. --max-body-bytes is the largest
body the daemon reads, 32 MiB by default, and it is what decides how much
memory one request can take before any of it has been decoded. A larger
body is refused with 413 naming both numbers, and a compressed
one is held to the same figure after it has been inflated, so a body that
expands a thousandfold is refused rather than expanded.
--max-batch-signals is how many signals one request may carry
once it has been decoded, 10,000 by default. It is counted across the whole
request, so an array of batches and an NDJSON stream are held to the same
number as a single batch. Nothing is stored when a request is over it: the
refusal comes before the first write rather than after half of them.
Both are also config file keys, max_body_bytes and
max_batch_signals, and the flag wins where both are set.
Neither can be turned off, and zero is refused at start-up rather than read
as "no limit": a body cap of zero refuses every request, so the reading
that looks generous is the one that takes ingest down.
--log-format and MCPD_LOG
text for a terminal, json for a log pipeline.
MCPD_LOG takes a tracing filter string and has no
flag, because it is a debugging control rather than a deployment decision.
Set it to mcpd_server=debug when you want to see per-request
detail.
Where the two request limits apply
The body cap applies to every route that reads a body, the query API included. The signal cap applies to the ingest doors, and this is what it counts at each of them.
| Door | What the signal cap counts |
|---|---|
POST /v1/ingest | Every span, log, metric, score, piece of feedback and aspect note in the request, added up across all of it, whether it arrived as one batch, an array of batches or a stream of NDJSON lines |
POST /v1/traces | The spans the sender sent, counted before they are mapped, which is the number the sender can act on |
POST /v1/logs | The log records in the export |
POST /v1/metrics | The data points in the export, across every metric in it |
Subcommands
| Command | What it does |
|---|---|
mcpd serve | Run the daemon. This is what happens with no subcommand at all |
mcpd demo | Write a realistic sample run into the store, then serve, so the interface has something in it the first time you open it |
mcpd config | Print the configuration this invocation would use, then exit |
mcpd mcp | Serve the Model Context Protocol over stdio, so an agent can read its own traces from a running daemon |
mcpd doctor | Grade this deployment and report what is misconfigured. Exits 2 on a fault, so it can gate a deploy. See Running it |
mcpd config exists for the argument you are about to have with
a container. Flags, environment variables and defaults all end up in one
place, and this prints that place without starting a server or touching the
data file:
$ MCPD_INGEST_KEYS=k1,k2 mcpd --retention-days 30 --sample 0.1 configlisten 0.0.0.0:4317storage sqlite at ./mcpd.dbconfig file nonelog format textproject defaultprojects noneretention 30 daysingest auth key requiredread auth openwrite auth key required (ingest keys)admin auth openexecute auth key required (ingest keys)worker auth key required (ingest keys)runs sqlite at ./mcpd.runs.dbrun settings lease_ttl_ms 30000, reclaim_window_ms 30000, stall_window_ms 300000, idempotency_window_ms 86400000, park_ttl_ms 86400000, max_depth 8, max_step_repeats 16identity offsampling deterministic 0.1quota unlimitedredaction onredact key built in, which is public; set --redact-keycors same-origin onlytimeout 30s per requestlimits 32 MiB per request, 10000 signals per batchderived duration and token metricsaspects offmonitors noneobjectives nonejudge offforwarding off
Read the six auth lines before you deploy anything. open means
open to whoever can reach the port. A line in brackets names the wider list
that scope is inheriting from, which is the state a deployment upgraded from
an older release starts in. Every flag and every config file key has a line
here, with the value the daemon runs rather than the one written: an
interval under its floor is printed raised, with the number it was raised
from. Nothing secret is printed. Keys are counted, never listed, a password
is set or unset, a forward header is printed by
name, and a URL or a connection string is printed without its credentials.
A configured deployment
Nothing here is mcpd-specific: the variables are ordinary environment, so they work the same in Compose, in a Kubernetes manifest and in a systemd unit.
compose
services:
mcpd:
image: ghcr.io/mcpd/mcpd
ports: ['4317:4317']
volumes: ['mcpd-data:/data']
environment:
MCPD_LISTEN: 0.0.0.0:4317
MCPD_DATA: /data/mcpd.db
MCPD_PROJECT: checkout
MCPD_RETENTION_DAYS: '30'
MCPD_INGEST_KEYS: write-key-one,write-key-two
MCPD_READ_KEYS: read-key
MCPD_REDACT_KEY: a-long-random-string
MCPD_LOG_FORMAT: json
MCPD_LOG: mcpd=info,mcpd_server=warn
volumes:
mcpd-data: The same settings as flags, for a shell or a unit file:
mcpd --listen 0.0.0.0:4317 --data /data/mcpd.db --retention-days 30 --ingest-key $KEY --log-format json What the SDKs read
These variables are read by your agent's process, not by the daemon. They are listed here because the two sets share names, and a container running both an agent and mcpd is configured by one environment.
| Variable | Default |
|---|---|
MCPD_ENDPOINT | http://localhost:4317 |
MCPD_API_KEY | unset |
MCPD_PROJECT | default |
MCPD_ENVIRONMENT | unset |
MCPD_SERVICE_NAME | unset |
MCPD_SERVICE_VERSION | unset |
MCPD_SAMPLE | 1.0 |
MCPD_ENABLED | true |
MCPD_CAPTURE_PAYLOADS | true |
MCPD_LOG_LEVEL | debug |
MCPD_PROJECT and MCPD_SAMPLE mean the same thing
on both sides, which is usually what you want: set them once and the agent
and the daemon agree on the project. Sampling is the one to think about.
An SDK drops a trace before it is sent, which saves the network. The daemon
drops it before it is stored, which is the only place the rate can be
enforced on senders you do not control. Set the rate in one place, not
both, or you are filtering an already filtered stream.
MCPD_ENABLED=false makes every SDK call a no-op that still
returns valid objects, so mcpd can be switched off in tests without
changing any code. MCPD_CAPTURE_PAYLOADS=false keeps prompts
and completions in the process: spans still arrive with their timing,
usage, model and errors, but input and output
are dropped before they are queued, so they never cross the network.
Redaction on the daemon is the other end of the same wire, and the wrong
end for a team that must not send the bytes at all. Per-language detail
is on each SDK page.
--config-file
A JSON file holding the settings that do not fit on a command line. It
takes eleven keys: monitors, slos,
monitor_interval_seconds, forward,
judge, projects, runs,
max_body_bytes, max_batch_signals,
derived_metrics and aspects. Every one is
optional. JSON rather than YAML or TOML because mcpd
already parses JSON for everything else, so the file adds no dependency and
no second syntax to learn. It mounts as a Kubernetes ConfigMap unchanged.
max_body_bytes and max_batch_signals are here as
well as on the command line, so a fleet can keep them beside the monitors
they are deployed with. A flag beats the file, which is how one instance
takes a different bound without the shared file being edited for everyone.
monitor_interval_seconds is how often the monitors and the
objectives are evaluated. It defaults to 60, and a value under 5 is raised
to 5.
An unknown key fails the start rather than being ignored, because a typo that silently watches nothing is the worst outcome available.
So does a value a background timer cannot run with: an
objective that is not at least 0 and below 1, an objective's
window_days outside 1 to 365,
monitor_interval_seconds over 86400,
aspects.window_seconds outside 1 to 31536000,
aspects.interval_seconds over 604800, and
judge.timeout_seconds outside 1 to 300. Each refusal names the
key and the range.
{
"max_body_bytes": 33554432,
"max_batch_signals": 10000,
"monitor_interval_seconds": 60,
"monitors": [
{
"id": "error-rate",
"name": "Error rate above 5%",
"project": "checkout",
"watch": "error_rate",
"compare": "above",
"threshold": 0.05,
"window_seconds": 300,
"min_samples": 20,
"webhook": "https://hooks.example.com/mcpd"
}
],
"forward": {
"endpoint": "https://otlp.example.com",
"headers": [["Authorization", "Bearer sk-..."]],
"batch_size": 256,
"enabled": true
}
} Projects, and the keys that reach them
A project is the tenancy key. Listing keys under one is what makes that true rather than decorative: a key in a project's block reaches that project and no other, so one daemon serves several teams without any of them being able to read the others' prompts.
{
"projects": {
"checkout": {
"ingest_keys": ["ck-write-..."],
"read_keys": ["ck-read-..."],
"admin_keys": ["ck-admin-..."],
"retention_days": 30,
"sample": 0.25
},
"support": {
"ingest_keys": ["sp-write-..."],
"read_keys": ["sp-read-..."],
"retention_days": 90
}
}
}
All six lists exist per project, as ingest_keys,
read_keys, write_keys, admin_keys,
execute_keys and worker_keys, and they fall back
the same way: a project with no write_keys accepts its own
ingest keys, one with no admin_keys accepts its own read keys,
and execute_keys and worker_keys accept the
project's write keys when they are unset. The
support block above sets neither, so it behaves exactly as it
did before the two scopes existed.
A key set with any of the six top-level flags reaches every project. That is the single-tenant path, and it stays the simple one: if you never open this block you never meet any of it.
retention_days and sample fall back to the global
flags when a project does not set them. Both are per project for the same
reason the keys are: one team's ninety-day requirement should not force
everybody else's data to be kept, and one team's traffic should not decide
how another team is sampled.
A batch may name several projects. A scoped ingest key writes the signals it reaches and refuses the rest, reporting the refusal in the response rather than dropping them silently, so a fleet pointed at the wrong project is visible from the reply it is already reading.
A read request for a project your key does not reach is refused without saying whether that project exists. On a shared daemon the project names are often the names of teams or customers.
Monitors and service level objectives are covered in
Monitors and alerts; objectives go
in an slos array beside monitors and are evaluated
on the same timer. The
judge block turns on model-graded scoring and is covered in
Regression testing. It is off until
you set it, and a judged scorer never runs unless a request names it,
because grading spends money on every run it reads.
{
"judge": {
"endpoint": "http://localhost:11434/v1",
"model": "qwen2.5:14b",
"api_key": "",
"criteria": {
"answer_relevance": "Does the answer address the question that was asked?"
}
}
} derived_metrics
Which metrics the daemon derives from the spans it stores. Both halves are
on when the block is absent. durations writes one duration
point for every finished agent, tool, workflow, model-generation or
processor span. tokens writes up to thirteen token counters for
every model-generation span that reported usage, and only the lines the
provider reported. Set either to false to stop writing it; a
field left out stays on, so {"tokens": false} keeps the
durations. There is no flag for this.
Derived points are added after the per-project ceiling, so they never cost
a sender a signal it sent, and they are not counted in the
accepted number an ingest response reports.
{
"derived_metrics": { "durations": true, "tokens": false }
} aspects
Whether the daemon clusters aspect notes into themes, and the bounds it does
it under. Off when the block is absent: a daemon nobody has configured
starts no analysis timer and writes none of the analysis tables.
enabled turns the pass on, and needs no model and no
credential, because the daemon writes the outcome aspect itself
from facts it already holds. interval_seconds is how often a
pass runs, 300 by default, and a value under 30 is raised to 30.
window_seconds is how far back one pass reads notes, 86400 by
default. disabled names aspects no pass clusters; notes already
recorded under one stay readable. A storage backend that does not hold the
analysis tables logs a warning at start and clusters nothing. See
Themes and snapshots.
{
"aspects": {
"enabled": true,
"interval_seconds": 300,
"window_seconds": 86400,
"disabled": ["sentiment"]
}
} --forward-endpoint
Copy every stored span on to another OTLP/HTTP endpoint. Give the base URL;
/v1/traces is appended. The local write always happens first,
so a destination that is down costs you nothing.
This flag beats the forward block in the config file, so one
instance can be pointed elsewhere without editing a shared ConfigMap. See
Forwarding.
--forward-header
A header sent with every forwarded batch, written as
Name: value. Repeat the flag for more than one. The environment
variable takes a comma-separated list.
A value with no colon is refused at start-up, with the form that works,
rather than dropped. An OpenTelemetry-style key=value header is
written key: value here.
--clickhouse-url
The HTTP endpoint of a ClickHouse server, such as
http://clickhouse:8123. Setting it switches the store from the
embedded file to ClickHouse, and --data stops being read. The
schema is created on start.
This is the setting that removes the single-writer and single-replica limits on the embedded store. Reach for it when retention is measured in months of production traffic, and not before: Running it covers the trade.
--clickhouse-database, --clickhouse-user, --clickhouse-password
The database to create tables in, and the credentials to reach it with. A user with no password is sent without one, which is right for a container on a private network and wrong anywhere else.
--postgres-url
A Postgres connection string, such as
postgres://mcpd:secret@db:5432/mcpd. Setting it switches the
store from the embedded file to Postgres, and --data stops
being read. The schema is created on start.
This is the middle option. It removes the same single-writer and
single-replica limits ClickHouse does, without asking you to run a database
you do not already have. Naming both this and
--clickhouse-url fails the start rather than picking one.
Migration takes an advisory lock, so several replicas starting together is safe, and a start with nothing to apply touches no schema at all.
For TLS, add sslmode=require to the connection string. mcpd then
encrypts the connection and always verifies the server's certificate. It
checks it against the public certificate authorities, or against the one in
sslrootcert=/path/to/ca.pem, which is what a managed service with
its own authority needs. verify-ca and verify-full
are read as require. With prefer, the default, or
disable, the connection is not encrypted.
Recording who, not just which key
The audit log records a digest of the key that acted. That answers "an admin key deleted this run" and not "which person did". mcpd has no identity provider, so the identity has to come from the ingress already in front of it.
Give it the header your proxy sets, and the addresses allowed to set it:
take the identity from an ingress you trust
mcpd serve \
--trusted-identity-header x-forwarded-user \
--trusted-proxy-cidr 10.42.0.0/16 Both halves are required, and the address list is the reason this is safe. A header is a string that anyone able to open a socket to the port can send, so mcpd reads it only from an address you have named. A request from anywhere else is served normally and its audit row carries no subject, which is the honest answer rather than a guess. The value is also length-capped and stripped of control characters, because a header is attacker-influenced even when the proxy is not.
With neither flag set, nothing changes and no subject is ever recorded. That is the default.
Open sign-up
--signup lets anyone create an account, from the interface or
with POST /v1/api/account/signup. This is how the hosted mcpd
runs. Each account gets a new project with a random name, and makes its own
API keys for it, one scope per key. A session from logging in can do
everything in that project; a key can do what its scope says. Neither
reaches any other project, and a span an account key sends is stored in the
account's project whatever project the span names.
Turning it on changes one rule for everybody: a request with no key is
refused, even where no key is configured. Only /healthz,
/readyz, /metrics, sign-up and log-in stay open.
Keys you configure keep the reach they have, and are tried first.
Accounts live in their own SQLite file, set with
--accounts-data, whatever stores the signals. The file holds
argon2id password hashes and SHA-256 digests of tokens and keys, never the
secrets. Back it up with the rest of the volume. There is no email
verification.
Sign-up is limited to 5 per hour and log-in to 20 attempts per 15 minutes for each client address, and failed log-ins to 10 per 15 minutes for each email. Behind a proxy every connection comes from the proxy, so name the header it puts the client's address in, and the addresses the proxy connects from:
open sign-up behind Cloudflare and a proxy on a private network
mcpd serve --signup \
--client-ip-header cf-connecting-ip \
--trusted-proxy-cidr 172.16.0.0/12 \
--cors-origin https://mcpd.sh
The header is read only from an address in --trusted-proxy-cidr.
From a trusted proxy that did not set it, the first address in
X-Forwarded-For is used. From any other address, the peer
address is the client, whatever the headers say. Make sure only your proxy
can reach the daemon: a client that reaches it from inside a trusted range
can name any address it likes. --cors-origin is needed when the
interface is served from another origin than the daemon.
One project cannot spend the whole daemon
A project that floods mcpd costs every other project on it: the disk fills,
retention cannot keep up, and the team that did nothing wrong is the one
that loses runs. --quota-per-minute is the ceiling that stops
that.
a ceiling for every project, and a larger one for one of them
mcpd serve --quota-per-minute 10000
Unset means no ceiling, so a deployment that never sets it behaves exactly
as it did before quotas existed. Set, it covers three things: every project
in the projects block that does not name its own
quota_per_minute, the project named by --project,
and every project the configuration does not list at all.
That last one is the part that makes it a ceiling rather than a suggestion. The project name arrives inside the request body, so a sender that picks a name no configuration file lists used to be charged to nothing and could store without bound. A limit the sender can leave by inventing a string is not a limit.
Every unlisted project shares one counter rather than getting one each. A
counter per name would be an allocation any sender could drive by inventing
names, which is the same hole in a different shape. Sharing means a flood
spread over a thousand invented names is bounded in total by this number,
and it means a project worth its own allowance should be named in
projects, where it gets one.
What the ceiling actually bounds
The counter is the two-window weighted kind: this minute's count, plus the minute before it weighted by how much of it the window ending now still covers. It holds the sustained rate to the ceiling and permits a burst of up to a whole ceiling on top of that, because an SDK flushing a full queue arrives as one burst and a counter that refused it would refuse ordinary traffic.
Those two together have an arithmetic consequence worth stating rather than hiding. A project may store a shade under twice its ceiling inside one 60-second span: a full ceiling at the very end of one window, and a full ceiling again at the very end of the next. That is not an artefact of the approximation. Any limiter that lets a project spend its ceiling at once and its ceiling again per minute afterwards permits that across a boundary, and the only way to remove it is to refuse the burst.
The window is one minute and is not configurable. The flag names the unit, so a second setting that changed what "per minute" meant would make the first number unreadable.
What happens to the excess
Signals over the ceiling are refused and reported, never dropped in silence. The batch is not refused whole: mcpd stores what fits and tells you what did not.
- Spans go by whole trace. Half a run has spans whose parent nobody stored, which reads as a broken run rather than a missing one. A trace too large for what is left is skipped and the next one tried, so one big run does not spend the remainder of the allowance on nothing.
- Logs, metrics, scores and feedback stand alone, so they fill whatever is left in arrival order.
- Only what is stored is charged. The trim keeps whole traces and so can store fewer signals than it was permitted, and the difference is handed back. Without that, a project whose runs are larger than its remaining allowance would pay its way to zero while storing nothing.
- Other projects in the same request are untouched. One project over its ceiling does not cost another the signals it sent alongside.
The reply carries the count in rejected and a line in
errors naming the project and its ceiling. A project the
configuration never listed gets a different sentence, because being told
that project analytics-v2 is over its ceiling sends you looking
for a setting that does not exist when the answer is to add the project:
$ curl -sX POST "$MCPD/v1/ingest" -H content-type:application/json -d @batch.json | jq{ "accepted": 5, "rejected": 3, "duplicate": 0, "errors": [ { "signal": "span", "index": 0, "message": "3 signal(s) named project analytics-v2, which no configuration lists. Every unlisted project shares one ceiling of 5 per minute and it was full, so they were not stored. Add analytics-v2 to the `projects` block to give it an allowance of its own, or raise --quota-per-minute." } ]}
A refusal is counted in mcpd_signals_over_quota_total as well
as in mcpd_signals_rejected_total, so a fleet being limited is
distinguishable from a fleet sending malformed data. See
Running it.
Two things to check when you set it
The ceiling covers every door that stores a signal: the batch ones,
/v1/ingest, /v1/traces, /v1/logs and
/v1/metrics, and the single-record ones,
POST /v1/api/scores and POST /v1/api/feedback.
The two answer a project over its ceiling differently, on purpose. A batch
door stores what fits and reports the rest as rejected with a
reason, because a batch may name several projects and refusing the whole
request would punish the ones inside their ceiling. A single-record door
has no partial answer to give, so it refuses with 429 and a
Retry-After header carrying the seconds until that project's
counter admits another signal. The number comes from the counter itself, so
a client that honours it comes back when there is room rather than when a
fixed delay is up.
One write door is not charged to it: POST /v1/api/score, which
runs scorers over stored runs. It hands the scores it produced straight to
storage, so a pass lands on disk for a project whose ceiling is already
spent, and those rows are counted by neither
mcpd_signals_over_quota_total nor
mcpd_signals_unbounded_total. It is a door you open yourself
rather than one a fleet points at, so treat the ceiling as a bound on what
senders can push in, not on everything that can reach the disk.
A ceiling below --max-batch-signals, 10,000 by default, means
one legal batch can exceed a whole minute's allowance and is trimmed
however well behaved the sender is. mcpd doctor grades this under
limits and names the projects it applies to. It also warns when
a ceiling is set per project and no top-level one is set beside it, which is
the configuration that leaves every unlisted project unbounded.
mcpd config prints the limited projects by name, so you can
read back what the setting covers rather than inferring it. It prints the
named ceilings only, so confirm the shared one from the flag itself.