Skip to content
mcpd
no daemon
Source

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 config
$ 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_ENDPOINThttp://localhost:4317
MCPD_API_KEYunset
MCPD_PROJECTdefault
MCPD_ENVIRONMENTunset
MCPD_SERVICE_NAMEunset
MCPD_SERVICE_VERSIONunset
MCPD_SAMPLE1.0
MCPD_ENABLEDtrue
MCPD_CAPTURE_PAYLOADStrue
MCPD_LOG_LEVELdebug

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:

eight signals into a project nobody configured, with the shared ceiling at five
$ 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.