millivolt Run it locally
Documentation menu

MCP server

The proxy's own /mcp endpoint exposes millivolt's operator and observability API to an LLM, using the official Model Context Protocol Go SDK. It is a client of a running proxy: it never starts, stops or restarts the proxy process. Its operator tools can reconfigure a running proxy, but only through the same credential and the same routes the dashboard uses.

The endpoint serves 22 tools from one implementation. It is operator-gated and Bearer-only, enabled by default: mcp_enabled: false removes it from the process (the route stays reserved, answering 404 and never inference; restart to apply), and the limits are config fields in the mcp category. It forwards the caller's own Authorization: Bearer credential on its internal calls, so the dashboard session cookie is refused there.

The token length band matches the proxy's own boot check (16 to 512 bytes). A credential the proxy would refuse to arm with is not a credential this server can use.

Redirects are refused outright. No operator route redirects, and net/http strips Authorization only when the hostname changes, so a same-host different-port or subdomain 307/308 would replay both the credential and the full request body to the redirect target.

URL placement

A URL-capable client (an editor agent, a hosted connector, an MCP-capable CLI) needs no binary and no environment: it points at the proxy's /mcp endpoint and presents the operator credential in an Authorization header, the same credential the dashboard uses.

{
  "mcpServers": {
    "millivolt": {
      "url": "http://127.0.0.1:8080/mcp",
      "headers": {
        "Authorization": "Bearer the same value the proxy was started with"
      }
    }
  }
}

A remote client uses the reverse-proxied HTTPS origin from the reverse-proxy guide, with /mcp appended:

{
  "mcpServers": {
    "millivolt": {
      "url": "https://your-hostname/mcp",
      "headers": {
        "Authorization": "Bearer the same value the proxy was started with"
      }
    }
  }
}

The header is the whole setup: the endpoint reads no environment, starts no process, and no session cookie or per-request state outlives the request. Every request is independently authenticated, so a missing or wrong Bearer is denied before any tool runs, and a request presenting only the dashboard session cookie is refused with 403, because the caller's own credential is what the internal calls carry. /mcp/ look-alikes are reserved: an unregistered path answers 404 and is never forwarded to inference. The published Compose port is loopback-only, so it is not a remote origin; remote clients terminate TLS at the ingress as in the reverse-proxy guide.

The transport is stateless and request/response only, and its limits are part of choosing a client:

  • GET and DELETE answer 405 with Allow: POST. There is no standalone SSE stream and no session to delete.
  • There is no session id: the endpoint never sends Mcp-Session-Id, and no per-request state outlives the request. No session cookie is minted here either, unlike /admin, /session, /metrics and /dash, so an MCP client's cookie jar gains no dashboard credential; a client must not expect or send a session id. The admitted credential does key an in-process cache of the SDK server object, so repeated calls do not rebuild all 22 tool registrations; admitting a different credential replaces it. The credential is only the in-memory cache key: it is never logged or persisted.
  • SSE resumption is not offered: no event store exists, so Last-Event-ID replay is not available.
  • The server never sends requests to the client: there is no sampling and no elicitation, so a client that waits for a server-initiated request waits forever.
  • Every request must accept both types, for example Accept: application/json, text/event-stream. A wildcard that covers both (*/*, application/*, text/*) is accepted too; an Accept missing either type, or one absent altogether, answers 400.
  • The accepted protocol versions are the bundled SDK's full set, newest first: 2026-07-28, 2025-11-25, 2025-06-18, 2025-03-26 and 2024-11-05. This build does not narrow them. A classic initialize is answered with the requested version when it is one of the legacy versions (2025-11-25 and older, verified live) and with 2025-11-25 for any other requested version; 2026-07-28 is served only through the SDK's per-request metadata form: the request carries Mcp-Protocol-Version: 2026-07-28, matching _meta keys io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities (io.modelcontextprotocol/clientInfo is optional), and the Mcp-Method header. tools/call additionally requires an Mcp-Name header equal to params.name; a form missing any required piece answers 400.
  • The SDK's localhost DNS-rebinding check is deliberately off. It rejects a loopback connection whose Host is not loopback, and the documented reverse-proxied placement preserves the external Host while dialing the proxy over loopback, so leaving it on would reject that placement. The endpoint is Bearer-only and the operator gate re-validates the credential on every request, which is the stronger check for an authenticated endpoint.

Tools

describe is the one to call first. Without the live schema and the column semantics below, correct SQL is guesswork.

Tool Purpose
describe Live SQLite schema, the complete requests/request_debug/meta column reference, the exact health predicates, a value vocabulary for every filter dimension, the live known client/provider/model and tool names, the indexes, the complete result-limit set (the query row default and explicit max_rows ceiling, query bytes, log page band, default page size, explorer group and chart bucket caps, Prometheus line cap and capture byte budget), and the working SQL idioms for this build.
query One bounded SELECT, clamped on both rows and encoded size, truncating with an explicit marker. The only tool that can express a time range.
values The distinct values one dimension has in durable history, most frequent first, so a filter value is discovered rather than guessed.
explore Faceted breakdown over all matching history by one dimension, with at most 24 groups. No time window.
chart Server-authoritative time series, at most 31 clock-aligned buckets. Cannot group.
records One durable newest-first log page, with the paired before_ms/before_id cursor and the page's model_canon map.
snapshot The dashboard's live snapshot: sequence, feed, counters, storage signal, recent records. With durable storage enabled, a full snapshot is capped at 2 * dash_log_rows; with storage off the ring is the only history and the snapshot is not capped.
prometheus The text exposition, which covers the ring rather than durable since-inception totals.
audit_status Capture sessions, their scope and live capture counts, the scope vocabularies, and the storage prerequisite.
audit_start Start or edit one capture session. Requires confirm: "start capture" and refuses when durable storage is off.
audit_stop Stop one session by id, or every session with stop_all: "stop every capture session".
audit_captures_list Page the requests that have a stored capture, through a bounded SELECT over requests WHERE debug = 1 whose request_debug sidecar exists and has not expired. Ids are canonical: empty and whitespace-padded record ids are excluded because audit_capture_get could not read them back.
audit_capture_get Read one capture document by request record id.
operator_state Pause, limits, quota/storm and restart status in one call.
set_pause Create, edit or resume pause holds.
set_throttle Set or clear one provider's budgets.
resume_quota Close one provider's open retry-mode quota gate.
set_config Revision-checked partial configuration patch.
config_get The live configuration document: values, running overrides (the keys the process pins outside the file, which set_config strips), the canonical usage and model metadata field names, defaults, per-key schema, restart-required set and the revision a patch must echo.
reload_config Re-read the configuration file.
purge_preview Count what a filter would delete, without deleting.
purge Delete matching history permanently.

The routes are a closed set owned beside the HTTP client, so no tool can name an arbitrary path: nothing reaches the proxy's transparent inference catch-all, and no restore-adjacent route is exposed. The HTTP endpoint dispatches those same calls in process to the proxy's own gated handler, so they never leave the process and the gate re-validates the forwarded Bearer.

Filters

explore, chart, records and the explorer facets share one grammar, owned in one place. Filters are dim:id, repeated, split on the FIRST colon, AND-ed across dimensions and OR-ed within one dimension. dim is one of client, provider, model, conversation, key, status, time, tool, error.

status is a single selector: an exact HTTP status code (0 to 9999) or a live in-flight class (streaming, paused, throttled, pending). It is not the explorer's status-class vocabulary, which travels as status:2xx.

An unknown dimension, malformed filter or unknown status is refused before the request is sent.

explore's error_total uses two units. With dim: error it is the union of distinct affected requests represented by the error cards, including recovered quality failures whose final status is 200; for every other dimension it is the canonical error count. A card share is N / error_total and cannot exceed 100%. Do not compare it across the two cases as one metric.

Pagination

records and audit_captures_list page newest-first with a paired (before_ms, before_id) cursor. Both fields or neither.

The proxy scans a bounded number of rows per call, so a page can be short or empty while its more flag is still true. exhausted is true when a page that was given a cursor returns that same cursor, and also when more is false: treat it as "stop paging". A first page has no cursor to repeat, so any returned cursor counts as progress, including (0, ""). A non-advancing cursor means the scan budget cannot reach the next row, and treating it as "keep going" is an endless loop; an empty page with an advanced cursor is instead the normal shape of "the budget ran out before a match", so page again.

Deep history needs this rather than OFFSET, which degrades on every page.

Volume

A model cannot read ten thousand rows. Every listing defaults to a small page, and every tool truncates with an explicit marker (truncation.marker) naming what was withheld and the way to continue for that tool, because only some of them have a cursor.

What is not claimed: the explorer caps groups at 24 and the chart caps buckets at 31 SERVER-SIDE, so those drops happen inside the proxy and those tools' truncation.truncated is false when groups were dropped. A model that trusts that flag alone will read a short breakdown as a complete one.

What each tool cannot do

These are the limits that otherwise send a model down the wrong path. They are also in the tool descriptions, which is where a model reads them.

  • describe first. Without the live schema, the column meanings, the health predicates and the value vocabularies, correct SQL is guesswork.
  • explore has no time window. It always folds all history. It is also single-dimension: it cannot cross-tabulate provider by model. For either, use query with a WHERE on started_at and a GROUP BY.
  • chart is the only windowed tool, and it cannot group. A window broken down by a dimension is a query.
  • query is the only tool that can express a time range. There is no unixnow() in this SQLite build, so a relative window is strftime('%s','now')*1000, for example WHERE started_at >= strftime('%s','now')*1000 - 3600000 for the last hour. Ready-to-run analysis SQL for exactly these time-ranged questions is collected in the operator recipes.
  • An unknown filter value is an empty result, not an error. A misspelled client, provider, model, tool, conversation or key produces a confident zero. Call values before filtering on one of those. Model filters take the canonical name the proxy folds from model_rules (which is what the values tool returns), never a raw stored spelling.
  • values(model) is paged and folded. The tool reads raw spellings in bounded keyset pages, so a vocabulary larger than the proxy's storage_query_max_rows no longer fails with 413, then folds and merges the pages through model_rules. Past its documented raw-spelling, canonical-name, or total page-query bound it fails explicitly rather than returning a partial vocabulary. The ordinary limit still reports its own explicit truncation marker.
  • time is a daypart bucket, not a duration: time:24h matches nothing. The buckets are night, work, evening and weekend.
  • status takes the proxy's status classes, not HTTP shorthand: 2xx, cancel (499), 4xx, 5xx and err (anything outside those, including 3xx and below 200). There is no 3xx class of its own.

Audit semantics

Audit is the proxy's debug-capture subsystem.

Capture requires durable storage. With db_path empty, audit_start is refused by the proxy with 503 durable storage is disabled and creates no session; every capture read-back is a 404 because no capture document can exist. audit_start and audit_captures_list therefore check the storage signal first and refuse with that explanation, and audit_status reports it. Starting a session that can never capture would be a silent no-op, which is worse than a refusal.

Scope is explicit. An empty dimension matches anything; non-empty dimensions are AND-ed and names within one dimension are OR-ed. There is no unrestricted all-traffic scope, and there is no conversation dimension. Model names use the proxy's native model normalization, so use the known_models values rather than guessing spellings. A scope overlapping a live session is rejected (409) rather than silently replacing it.

Edits change only named dimensions. With a session id, an omitted clients, providers or models field preserves that dimension and an explicitly empty array clears it. An omitted duration preserves the deadline and an explicit empty string clears it. A create response also carries created_id, the id its own request installed, so concurrent starts resolve their own sessions. set_pause follows the same presence rules for scope and duration when hold_id targets an edit; without hold_id, an omitted create scope is the documented global hold.

Starting requires a confirmation. audit_start takes confirm: "start capture" because capture stores full request and response bodies.

Stopping does not delete, but a purge does. Stopping a session, or all of them, ends future captures only: a stopped session's documents stay readable by record id until debug_capture_ttl expires. There is no capture-specific delete endpoint, but a purge deletes the stored capture document of every request it matches, in the same transaction as the request row. A filter that reaches a captured request takes its evidence with it. Only a backup taken beforehand restores it.

A scope name must be known when the vocabulary is. audit_start checks every client, provider and model against the vocabulary audit_status reports. When that vocabulary is non-empty, an unknown name is refused with the known values offered, because a session scoped to a name that matches nothing would report enabled: true and capture nothing. An empty vocabulary is the exception: a proxy that has seen nothing knows no names, so refusing every name would make the tool unusable rather than safer and the check is skipped. A vocabulary that cannot be read is not a skip: the start is refused, because the guard cannot be applied at all.

Read-back is by record id. audit_capture_get takes the REQUEST RECORD id (requests.id), not the session id. There is no listing endpoint, so audit_captures_list discovers those ids by querying requests WHERE debug = 1 with an existing, unexpired request_debug sidecar and reports that it does so. The listing also excludes an id that is empty or padded with whitespace: the capture route trims the id before lookup while the sidecar key is stored verbatim, so such an id could be advertised but never fetched. Every listed id is therefore readable. Captured documents are sensitive: credentials are redacted from headers, but body content is not sanitized.

Sessions survive a restart. The proxy restores them from its own state, so a start list may already contain sessions this server did not create.

The destructive call

purge permanently deletes matching request history. The proxy has NO server-side confirmation, NO confirm flag and NO two-step protocol. The operator credential and this tool's own guards are the entire gate.

Three guards apply together, and each alone would be insufficient:

  1. The filter must constrain at least one field. The bodyless request that deletes ALL history is never sent by this tool; an empty filter is refused before any request.
  2. The confirmation must be an exact phrase. confirmation: "permanently delete the matching millivolt history". It is also a required tool argument, so a call that omits it is rejected by the schema before the handler runs.
  3. preview_token must be the token purge_preview issued for THIS filter. It is an opaque HMAC-bound token over the canonicalized filter, the count it authorized and an expiry, so it cannot be forged, cannot be reused for another filter, and expires. A bare count could not do this: it is only a number, so two different filters matching the same number of rows authorized each other, and a count obtained from anywhere - a query, a guess, a preview of a different filter - satisfied the comparison. That was a live bypass, not a theoretical one.

Always run purge_preview first and pass its preview_token back verbatim.

What the guard does not prevent

Stated plainly, because an operator needs to know the size of the remaining window:

  • The re-check and the delete are two HTTP requests with no shared transaction. The count is re-checked immediately before deleting, and a deletion is refused when it no longer matches. But rows committed between that re-check and the delete are still removed without having been previewed. The preview authorizes a filter and a count; it is not a lock on the row set.
  • A filter that reaches a captured request deletes its capture document too. The preview and the deletion both say so in their output.

Deleted history cannot be recovered from this API. The deletion preserves newer completions and in-flight work and does not reset live error-storm protection.

set_config is the other consequential call, and it is a revision-checked partial patch: values carries only the keys to change, the whole result is validated before the file is written, CLI-overridden keys are stripped, and the response names the keys that need a restart. A supplied list or map replaces that whole field, so call config_get first for structured keys such as providers or model_rules, and echo the revision it returns.

A failed reload is reported as a save, not as an error. The proxy writes the file and only then answers 500 with the reload failure in the body, so that response is decoded as the committed document it is: saved is true and error carries the reload message. Reporting it as a tool error would tell a model that a mutation that succeeded had failed, and its retry would then fail with 409.

reload_config re-reads the configuration file. It is the one state-changing route the dashboard never calls. The operator token itself is process-bound and is not re-read.

Errors

Every failure is a tool result the model can read and correct, carrying the proxy's own .error text and the status: 401 (credential wrong or missing), 403 (the proxy's operator plane is unarmed), 404, 409 (overlap or stale revision), 413 (a result limit), and 429 with its Retry-After. An unreachable proxy, a timeout, and a body that is not the expected document are reported the same way. The .error field is read regardless of Content-Type, because the operator routes are inconsistent and some answer text/plain with a JSON body.

The returned text is scrubbed of the operator credential first, because a reflecting endpoint could otherwise put the Authorization value into its own failure message and hand it to the model. The scrub removes the literal token, any contiguous 8-byte fragment of it (so a credential split across two JSON string fields leaks no usable piece), percent escapes in any hex case (including full-byte nested forms), the query + form of a space, and JSON-escaped forms, including backslash-doubled text and surrogate pairs. Each form is decoded to a fixed point before matching: an encoding is either fully seen or the excerpt fails closed. Text that still decodes after three escape applications is not published partly decoded: the whole excerpt is replaced by [redacted] instead. That bound applies regardless of whether the credential appears in the body: a heavily re-encoded failure with no credential in it still loses its diagnostics, because a credential could be hidden past the bound. The scan is bounded to the region that can still reach the excerpt, and the raw bytes it reads are capped; a body whose whitespace collapse does not reach the excerpt target inside that cap fails closed to [redacted] too, for the same reason. The excerpt itself is whitespace-collapsed and truncated to 4 KiB before the [truncated] marker is appended, so the model-visible text runs up to the 12-byte marker past 4 KiB. Redaction runs before the truncation, so a credential straddling the boundary cannot survive as a fragment.

Try it against a disposable instance

Run the endpoint locally against a disposable instance with the repository's lifecycle owner, never the main instance on :8080, and drive it with one stateless POST:

(
  set -e
  trap 'scripts/dev.sh stop' EXIT
  export MILLIVOLT_OPERATOR_TOKEN='dev-instance-credential'
  scripts/dev.sh
  curl -fsS -X POST http://127.0.0.1:8081/mcp \
    -H "Authorization: Bearer $MILLIVOLT_OPERATOR_TOKEN" \
    -H 'Accept: application/json, text/event-stream' \
    -H 'Content-Type: application/json' \
    -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}'
)

The answer carries the server identity. A full client session is the client configuration above pointed at http://127.0.0.1:8081/mcp; a request without the Bearer credential answers 401 from the operator gate.

Security

The endpoint is driven with an operator credential, which is the whole dashboard plane: pause, limits, configuration, restart, purge. Treat the client configuration that carries it as sensitive and keep it out of shared repositories. See Security.

Verification

The unit suite uses a fake proxy that pins the exact method, path, query and Authorization header of every tool, maps canned responses onto typed output, and covers the error shapes, setup validation, truncation markers, the pagination rule and the purge guards. The paging rule has one owner shared with audit_captures_list, so the two listings cannot disagree. One of its tests runs outside that harness on purpose:

  • the redirect test stands up a second listener as the redirect target and asserts it received nothing, so "we refused the redirect" is an observation rather than an assertion about an error string.
  • the capture listing test executes the generated SELECT against a real SQLite schema with empty, space-padded, tab-padded and NBSP-padded sidecars, and asserts they are excluded while every returned id has an unexpired sidecar and is the exact spelling audit_capture_get can fetch.

The live integration test starts a private instance through scripts/dev.sh on a port and scratch database of its own, so concurrent runs do not share a port or database, drives the tools through a real MCP session, and runs a full capture cycle against neutral loopback traffic:

MILLIVOLT_MCP_INTEGRATION=1 scripts/check.sh go test -count=1 ./internal/mcp/...

It runs in CI as part of the isolated browser-fixtures step, which already has a dev instance and an operator token; it is not part of the core source-and-unit job, because that job should not have to start a second proxy.

It drives every registered tool against its own disposable instance. The state-changing tools are set_pause, set_throttle, resume_quota, set_config, reload_config, audit_start, audit_stop and purge. The run exercises set_pause (a hold scoped to the fixture client, resumed at once), set_throttle (a limit set and then cleared), resume_quota, set_config and reload_config against the scratch config copy, a full capture cycle (audit_start and audit_stop), and the purge path: the missing confirmation, the wrong phrase, an unauthorized token and a token issued for a different filter each refuse with the row count unchanged, and then an authorized purge deletes the fixture rows. The read-only tools are describe, query, explore, chart, records, snapshot, prometheus, audit_status, audit_captures_list, audit_capture_get, operator_state, config_get, values and purge_preview; the run drives each of them. A successful purge and the config write are safe here because the instance, its database and its config copy all belong to the test and are discarded.

The same run drives the proxy's own /mcp endpoint with a real streamable HTTP client: initialize, tools/list (22 tools), and describe and records returning the same documents the in-process session returns, plus the auth boundary (401 without a credential, 401 for a wrong Bearer from the shared gate, 403 for a cookie-only request or a wrong Bearer beside an admitted cookie) and the reserved /mcp/ look-alike answering 404, never inference.

search

Search the docs

Search titles, headings, and body text. Press / from anywhere.