Skip to content

MCP transport and authorization

Transports

Transport Interface Authorization
stdio Newline-delimited JSON-RPC 2.0, one session per process Process/user boundary; no bearer token
HTTP POST /mcp, /mcp?kb=<name>, /mcp/<name> or /mcp/routed Optional static bearer token
HTTP (read-only UI API) GET /api/ui/v1/… Same bearer token; never public

HTTP requests return complete JSON-RPC responses. Cartographer does not expose the legacy two-endpoint SSE transport or an HTTP streaming session, and issues no session id: over HTTP every POST stands alone.

Over stdio the connection is the session. The client owns the pipe's lifetime and the server tears the session down when stdin closes, so a request written to a pipe that closes immediately — echo '…' | cartographer serve — races its own response. A script driving the stdio server must hold stdin open until it has read the reply. Requests on one stdio session are served concurrently; a client that needs ordering waits for each response, as the JSON-RPC id is there to allow. A line that is not a valid message never ends the session: stdio answers it with the same errors as HTTP (-32700 for a line that is not JSON, -32600 for an empty batch, a jsonrpc other than 2.0 or an id that is not a string or a number; id is null unless the request carried a valid one) and reads the next line. Only an I/O error on stdin or stdout ends it, and a line over 16 MiB (far above the HTTP body limit), the one frame that is refused by ending the session. One message per line: a message spread over several lines is no longer accepted.

The HTTP listener sets its own connection timeouts: a 15s header deadline, a 60s request-read deadline and a 120s keep-alive idle deadline. There is deliberately no write deadline — it would also cap a legitimately slow tool call, such as a full reindex or a git sync, and those are bounded per operation instead. Read them as protection against connections that never complete, not as a request budget.

GET /health reports service readiness; it stays public (never a 401, an invalid token is read as anonymous), but its body depends on the token (D1096): without a valid one it carries no kbs, with one it lists the KBs that token reads. GET /ready needs a valid token once auth is on, and its 503 body counts and names (degraded) only the KBs that token reads (#1109). GET /.well-known/oauth-protected-resource publishes RFC 9728 Protected Resource Metadata, unauthenticated, so clients can discover the protected resource; resource and authorization_servers both name this server's own externally-visible base URL (scheme + Host, from the request itself), since it validates its own static bearer tokens rather than delegating to a separate authorization server. Cartographer does not implement an OAuth authorization server, dynamic client registration or JWT validation; configured tokens are opaque static bearer values.

/api/ui/v1 is the read-only JSON surface the web UI consumes (→ control-plane.md §Read-only UI API). It is not a public path: it passes through the same origin check and token middleware as /mcp, is filtered with the same principal, and answers 404 — never 403 — for a KB or concept the principal cannot see, so it cannot be used to probe for existence. Only /health, the RFC 9728 metadata and the embedded UI's static bundle (/ui, /ui/*) are exempt from requiring a token (/ready is not); with auth on the middleware still reads one there, and attaches the principal of a valid bearer and no principal otherwise, never the local admin (D1096).

One routed topology (D288)

A multi-KB HTTP server always serves /mcp/routed, and that is the only endpoint cartographer connect/sync write into an agent client. The per-KB endpoints (/mcp?kb=<name>, /mcp/<name>) stay as plumbing: the routed mount dispatches into the per-KB servers, and a client whose entries were written earlier keeps working. The routed mount (D187) is not a choice: mcp.mount_mode, CARTOGRAPHER_MCP_MOUNT_MODE and --mount-mode do not exist (D325; a leftover key in YAML is silently ignored).

Endpoint Role tools/list Selecting the KB
/mcp/routed what agent clients use one copy, shaped per connection a kb tool argument, or the URL
/mcp?kb=<name>, /mcp/<name> plumbing, older clients one full copy per KB in the URL

Why one topology. A client using N KBs on per-KB entries carries N copies of the same tool schemas in its fixed context, on every model round-trip: each per-KB mount advertises a full tools/list, so a provider bound to N KBs pays N times what the one routed copy costs. Everything a per-KB entry gave an agent client is available on the routed mount, except client-side permission rules that match a tool name per KB — those match names, not arguments, and are lost by design. Real authorization is per-KB scopes (D44/D45), and read auto-approval through readOnlyHint (D76) is unaffected.

URL parameters of /mcp/routed. kbs (below), access (D1018, D1184: read, or read-min for the read-only connection with the minimal tool listing; empty is absent, any other value is a 400) and provisioned=1 (D1097: the client has the managed instructions block, so initialize carries no guide; empty is absent, any other value is a 400). Any other parameter is ignored, which is what lets a client always send provisioned=1 to a server that predates it. Only the routed path reads the marker: /mcp?kb=<name>, /mcp/<name> and stdio always send the guide. The marker rides the request context, never the cached routed view.

The binding travels in the URL: /mcp/routed?kbs=kb-a,kb-b. Absent, the connection serves every mounted KB. kbs only narrows, never widens: per-KB authorization still runs at the target KB. It is a different parameter from kb, which stays 400 on this path (D187's one-channel rule is about selecting the KB, not the allowed set). The effective set is kbs intersected with the mounted KBs (D756): a name that is not mounted (a KB renamed or removed since the client was synced) does not take the connection down, it is served without it and reported in kb_status.connection.unknown, in the kb_status hint and in the error of a call naming it, with a pointer to cartographer sync on a provisioned connection (a generic one is told, in words, that the integration's URL needs updating, D1101); the server logs it once per distinct set. If no name resolves the request is a 400 naming every unknown name and the mounted KBs (an empty set would have no tools, and falling back to all KBs would widen the binding). Duplicates are ignored.

A renamed KB keeps its URL working (D772). kb rename records the old name in the server state file, <data>/.cartographer/state.json (kb_aliases, {from, to} items; D906). A name in ?kbs= that is not mounted but is the from of an alias whose to is mounted selects that KB; a mounted name always wins over an alias, so a KB later created under an old name is reached by its own name. The pair is reported, never silent: kb_status.connection.renamed is {old: new}, the hint says the URL is stale and (provisioned connection) to run cartographer sync, and the server logs it once per distinct set. A call that names the old KB in its kb argument (a session that outlived the restart still has it in its schemas) is dispatched to the new one, through that KB's own policy, audit and git wrapper. An alias selects which KB a URL names and never widens a token: scopes are not migrated (D177), so a token scoped to kb:<old> is still refused at the renamed KB. An alias whose target is not mounted stays unknown. Only the routed ?kbs= follows aliases: the per-KB endpoints (/mcp?kb=<old>, /mcp/<old>) keep answering that the KB does not exist.

A KB mounted after a session started reaches it only through cartographer sync (plus client bind for an explicit binding) and a new agent session; a generic connection is told that the integration reconnects or re-synchronizes its tool list (D1101): the tool list is fixed per session and HTTP has no list-changed notification. Routed kb_status reports the difference in connection (D712).

The schema follows the connection. With two or more KBs in the effective set, every tool's input schema carries kb, required, as a plain string with no enum (D1183: the size of tools/list does not grow with the number of KBs; the names are the KB list of the instructions); a call without it, or naming a KB outside it, is refused with an error naming the allowed KBs. With exactly one, kb is omitted from every schema and the call dispatches to that KB. That is not inference: the URL named the KB explicitly, exactly like /mcp/<name>. A call that still passes kb equal to that KB is accepted; any other value is refused. A KB is never guessed when the URL left more than one open: a default would land a write in the wrong archive on a model slip.

The effective set (D1096) is the ?kbs= set (or every mounted KB) narrowed to the KBs the principal reads. A ?kbs= name the token cannot read is unknown, like an unmounted one, and an alias never discloses its target; a token that reads no mounted KB gets a denied view (forbidden: no_kb_access on the metadata calls, no HTTP 403, D118). A principal that writes none of the set gets a read-only token view: no write tool is listed and a write call is refused by the target KB with read_only_token.

The advertised set is the union of the effective set's KBs. A tool that only some of them register is advertised once, and refused at dispatch for a KB that does not, with an error naming the KB and the tool. The alternative, an intersection, would silently hide a tool from a KB that has it because a sibling does not. The metadata gate (initialize, tools/list) asks whether the principal can reach at least one KB of the connection's set; since the set is already what the principal reads, it is defence in depth.

Everything per-KB is resolved after kb: the read/write classification, the per-KB authorization policy, the git lock and commit wrapper, and the audit record's KB: exactly one authorization and one audit record per call, at the target KB. The routed endpoint dispatches into the target KB's own server, so it cannot drift from the per-KB path.

Tools are never prefixed. One entry cannot collide with itself, so kbs[].tool_prefix and mcp.tool_prefix_mode (D102/D153) were removed by D325 the same way (a leftover key in YAML is silently ignored, so an upgrade never fails to start). A KB named routed collides with the endpoint's own path and is skipped with a warning to rename it. A ?kb= on the routed URL is refused with 400.

With auth: false the binding is advisory: a client can edit its own URL. It is exactly as true of the old per-KB entries (a client can add any /mcp/<name> entry). Isolation between providers needs per-provider tokens with per-KB scopes.

GET /health always reports mount_mode: "routed" and routed_path in HTTP mode (a server with no KB mounted yet has nothing to route and omits both), and carries no per-KB tool_prefix. cartographer connect/sync read it and write one entry per provider; against an older server that does not report routed_path the client keeps writing per-KB entries. cartographer sync heals a topology change — it rewrites the entries to the single routed one and removes the per-KB ones; no reconnect is needed, and cartographer status names sync when the configured mount differs from the server's.

Protocol versions: two eras at once

The wire format is implemented by the official MCP Go SDK (D168), not by Cartographer. The server answers every revision the SDK serves — 2024-11-05 through 2026-07-28 — and decides which one a request belongs to from the request itself; nothing is negotiated up front and, over HTTP, nothing is remembered between requests. The two that matter here:

Era Identified by Shape
handshake (2024-11-05) neither marker below — including an MCP-Protocol-Version header naming an earlier revision initialize/ping available; results exactly as before
2026-07-28 params._meta.io.modelcontextprotocol/protocolVersion, or an MCP-Protocol-Version header whose value is exactly 2026-07-28 resultType + _meta server info on every result

The header selects the new era by its value, not by being present (D133). Clients on earlier revisions send MCP-Protocol-Version too — the header predates this revision — and they must keep landing in the handshake era, since they send none of the mirrored headers that era requires.

initialize and ping remain available to the handshake era, and stay available as long as the SDK serves that revision — retiring it is no longer Cartographer's decision to implement.

server/discover reports the supported versions, the capabilities and the server identity, and is the method the CLI client's reachability check uses. It exists only in 2026-07-28: a handshake-era request naming it gets -32601. That is the one place where the older era is visibly poorer, and the reason the CLI client sends the newer era's metadata on every request.

A request naming a version the server does not serve is refused with -32022, whose data lists the versions it does.

A POST's Accept header is not enforced (D200). Streamable HTTP asks a client to send Accept: application/json, text/event-stream, and the SDK refuses a request missing either; this server only ever answers with JSON, so it supplies both media types itself before the SDK checks them. A client that sends only application/json — Antigravity does on its notifications — or no Accept at all is served normally.

Mcp-Protocol-Version is stripped on JSON-RPC notifications (D210). The 2026-07-28 SDK enforces that a body carrying this header must also contain _meta.io.modelcontextprotocol/protocolVersion, but notifications have no _meta. Some clients (Antigravity / go-sdk v1.7.0) set the header on every POST, including notifications; the server removes it before the SDK sees the request so the notification is served as 202 Accepted. Regular requests with _meta are unaffected.

Protocol errors on POST /mcp

A body the server cannot take as a JSON-RPC request is HTTP 400 with a JSON-RPC error body, never plain text: -32700 for a body that is not JSON (or empty), -32600 for JSON that is not a valid request (empty batch, a jsonrpc other than 2.0, an id that is not a string or a number, a body that is not an object), -32601 for an unknown method (carrying the request's id). An unknown tool argument is refused as -32602 (D1279), and so are arguments that is not an object and an unknown tool name (D1303).

What a 2026-07-28 request must carry over HTTP

Three headers mirror the body and are validated against it — a disagreement, or a missing one, is HTTP 400 with JSON-RPC -32020:

Header Must equal
MCP-Protocol-Version params._meta.io.modelcontextprotocol/protocolVersion
Mcp-Method the JSON-RPC method
Mcp-Name (on tools/call) params.name, =?base64?…?= form decoded first

The headers exist for intermediaries that route without parsing a body. They never become an input to an authorization decision: the body stays the only thing the server acts on, which is the point of validating them at all.

Status codes in the 2026-07-28 era: unknown method → 404, header mismatch or unsupported version → 400, GET/DELETE on the MCP endpoint → 405. In the handshake era every JSON-RPC error is still carried inside HTTP 200.

Mcp-Session-Id and Last-Event-ID are ignored if sent, and never issued.

Cartographer-Author is not an MCP header and is not checked against the body: it names the git author of the writes the request makes (D904), it decides nothing about access, and a missing or invalid one falls back to the token (§Identity and audit boundaries).

Origin

The MCP endpoint, and every request that changes state on any other route (any method but GET, HEAD or OPTIONS: /api/usage, /api/client-settings, /api/workers, the writes under /api/ui/v1, /clients), validate the Origin header, and the rule is fixed (D906): a request without one — every non-browser client — is unaffected; with one, its host and port must be the request's own Host, whatever the scheme (a TLS-terminating proxy does not have to be told which one it terminated, and an Atlas behind an SSO proxy, which serves the page and the API from one origin, keeps working, D308). A refused origin gets HTTP 403 before authentication runs, and the CORS allow-origin response header echoes the accepted origin rather than a wildcard. There is no list of other origins to allow (mcp.allowed_origins was removed): a page of another site has no business scripting this server. The JSON-body routes (/api/usage, /api/client-settings, /api/workers) also answer HTTP 415 to a body not sent as application/json (a cross-site form cannot set that type without a preflight), and refuse a body over their cap with HTTP 413 (1 MiB for a usage report, 256 KiB for a client settings report, 64 KiB for a worker heartbeat); the usage store itself is refused past 4 MiB.

The same-origin rule alone does not tell a page served from a rebound DNS name from the real one: Origin and Host then both name that name. A server that listens on a loopback address with authentication off (the local service, D268) therefore hardens the rule: every request on every route (/mcp, /ui/, /api/ui/v1, the health routes) must carry a Host naming this machine (localhost, an address in 127.0.0.0/8, or [::1], with any port), whether or not it carries an Origin; any other Host gets HTTP 403. It is installed only under that condition: with authentication on, the token is the access control, and a listen address beyond loopback with authentication off refuses to start (§Enabling bearer authentication). An Origin that carries userinfo is refused.

Enabling bearer authentication

Tokens can be configured through server YAML or CARTOGRAPHER_TOKENS/--tokens. CARTOGRAPHER_AUTH has these modes:

  • unset: enable authentication when tokens exist;
  • true: require authentication and fail startup if no token is configured;
  • false: disable authentication.

Off-loopback needs a token (D906). After the mode is resolved, a server whose listen address is reachable beyond this machine while authentication is off — false, or unset with no token — does not start, and the message says why. Loopback is a listen host in 127.0.0.0/8, ::1 or localhost; an empty or wildcard host (:39273, 0.0.0.0:39273) listens on every interface. The local service pins auth off and listens on 127.0.0.1 (D268), so it is unaffected; a remote deployment configures tokens, which turns authentication on in the default mode.

Send a token only in Authorization: Bearer <token>, never in a URL.

The client refuses cleartext (D1023). A client never sends the bearer token to a non-loopback http:// URL: every request fails before dialing unless the operator opted in with allow_insecure_http or CARTOGRAPHER_ALLOW_INSECURE_HTTP=1 (see configurator.md §Transport security). Serve a remote deployment over https://. The client also refuses to follow a redirect to a URL that fails the same check, and any https to http downgrade, even to loopback.

What a 401 tells the client

The client reads its token from the variable .cartographer.yaml names in token: ${NAME} (D906), so it names that variable back when the server answers 401, and distinguishes the two cases that need different fixes (D222):

  • $<TOKEN_ENV> is unset or empty, so no bearer token was sent — nothing was sent; the variable is missing from the environment this command ran in;
  • the bearer token from $<TOKEN_ENV> was rejected — a token was sent and the server refused it; the value, its scopes or the server's token list is wrong.

A caller with no variable to name (a probe before any configuration exists) gets the generic unauthorized (401): check the bearer token/env var. All three keep classifying as unauthorized in cartographer status --output json and keep matching the client-side unauthorized check, so the wording can change without breaking a caller.

Validation is strict, on the effective configuration

The auth configuration is validated after YAML, environment and flags are merged — the shape that will actually be served — and an invalid declaration stops startup rather than being reinterpreted (D179). Refused:

  • an empty or whitespace-only token value;
  • any scope that is not exactly kb:<name>:r or kb:<name>:rw, including an unknown access value (kb:docs:write) and a missing access segment (kb:docs) — a partially valid list is refused as a whole, and valid roles on the same token do not excuse a malformed scope;
  • an environment/flag entry with a scope separator but no token (|kb:docs:r);
  • a reference to an undeclared role, a duplicate principal id, an unknown auth mode.

Validation runs for every mode, off included, so a latent typo does not become an exposure the day authentication is enabled. Diagnostics name the offending token by index or id and never contain a token value or a raw scope string.

When authentication is required, enforcement is carried explicitly: a configuration that asked for it and produced no usable credential fails startup, and a store that somehow reached the middleware empty refuses every request instead of serving them as a local admin. /health, the RFC 9728 metadata path and the UI's static bundle stay public either way, so a liveness probe keeps working.

Per-KB scopes

A token may carry kb:<name>:r or kb:<name>:rw scopes. The KB name is its explicit kbs[].name, or the normalized repository/directory basename.

YAML uses a token object:

auth:
  tokens:
    - token: ${CARTOGRAPHER_TOKEN}
      scopes:
        - kb:homelab:rw
        - kb:reference:r

The environment/flag form is token|kb:homelab:rw;kb:reference:r. A legacy token that declares neither scopes nor roles has full access to every mounted KB — that absence is the declaration. A token that declares restrictions and ends up with none grants nothing: unrestricted access is never inferred from an empty policy (D179).

For HTTP requests:

  • protocol methods such as initialize, tools/list and ping require read access;
  • tools marked read-only require r;
  • every other tool requires rw;
  • an unknown tool or unreadable request body fails closed as a write.

The guard restores the request body after inspection so the MCP handler sees the original JSON-RPC payload.

Roles and fine-grained permissions

Scopes authorize a whole KB. Roles narrow that down to maps, journals and concept types (D118). A role is a named set of allow rules; a token references roles by name:

auth:
  roles:
    - name: runbook-editor
      rules:
        - kb: homelab
          access: rw
          maps: [infra]
          types: [Runbook]
        - kb: reference
          access: r
  tokens:
    - token: ${CARTOGRAPHER_TOKEN}
      id: ci
      roles: [runbook-editor]

Within a rule, empty maps, journals and types are wildcards and non-empty selectors are intersected: the rule above allows writing Runbook concepts under infra/ and nothing else. There are no deny rules — permissions are unioned, so evaluation is order-independent and adding a role can only widen a principal's access. Roles and legacy scopes may coexist on one token and are unioned, so a deployment migrates one token at a time.

id is a stable principal identifier for logs. When omitted it is derived from a digest of the token; a plaintext token prefix is never used.

Configuration is validated at startup and the server refuses to start on a duplicate role or principal ID, an unknown role reference, an empty KB, an access other than r/rw, an empty or traversal selector, or a selector declared both as a map and a journal. No diagnostic ever contains a token value.

How a permission is enforced

Every request carries exactly one principal, and authorization happens at a single point in dispatch before any handler runs. Tools are classified by the resource they address:

Class Tools Rule
exact concept concept_read, concept_write, asset_*, service_get, … the concept's map/journal and type must be allowed
collection search, concept_list, atlas_overview, contradiction_report, … results are filtered per element
source/destination concept_move both ends must be allowed, and for an expanded concept so must each satellite (its own type, source and destination); link rewriting additionally requires whole-KB write
curated index index_patch the target Map/Journal must be allowed for write by a rule that names no type: the index lists pages of every type, so a role narrowed by type cannot rewrite it, and its own entry is written with index_entry on the creation, under the page's grant (D714); the root index (no single Map/Journal of its own) requires whole-KB write even under a policy that already grants a write inside one of its Maps (D122)
whole KB snapshot, sync_*, lint, … require whole-KB access, since they have no safe partial semantics

A tool absent from the registry is denied, so a newly added tool fails closed until its resource semantics are chosen deliberately.

Two properties are load-bearing:

  • Non-disclosure. A forbidden exact resource returns the same generic not found as a missing one. Existence of a concept outside the perimeter is not observable. Every other denial reads forbidden: <code>: <hint> (D343: unclassified_tool, bad_arguments, read_only_token, needs_whole_kb, outside_scope, template_unusable, missing_id, no_kb_access, no_principal, and unbound_folder for a read-only routed session, D1018, read_only_profile for access=read-min, D1184); the reason describes the caller's own token and arguments, never hidden content, and a concept_batch denial never names the operation.
  • Filtering before limiting. Collection tools apply the permission predicate before the result limit, in the in-memory index, in SQLite FTS (which reads further ranked pages when hidden candidates would leave a page short) and in the vector store. A caller therefore cannot infer hidden concepts from short pages or shifted pagination.
  • Collections have no type. Whether a map or journal is listed at all (map_list, atlas_overview, the UI's KB list and overview) is decided by the maps/journals selectors alone; the types selector applies to the concepts inside it, and the counts shown are counts of visible concepts. A role narrowed to a map and a type therefore sees that map, holding only its concepts of that type (D228).

Writes are re-authorized under the git lock immediately before mutating, so a concept whose type changes between dispatch and commit cannot be written on the strength of a stale decision. Policies are cloned when handed out: no caller holds a mutable alias of another principal's permissions.

A token with no scopes and no roles keeps full access, and admin bypasses the resolver.

Identity and audit boundaries

Bearer tokens authorize requests. SSH keys and git tokens are files of the credentials directory, a KB's own or a default one, as described in deployment §Credentials directory (D905).

The git author and committer of a write are the writer, not a setting (D904, D950). A client that knows who it acts for sends Cartographer-Author: Name <email> with the request; without a valid one the writer is the identity the operator configured for the KB in the server file (git.identity, git.identities), else <token id> <cartographer@<host>>. Every write carries Cartographer-Host and Cartographer-Token: <token id> trailers that the server appends itself. The configured identity is attribution, never authorization. The author line is therefore a claim by the caller, and the trailer is what the server knows: attribution, not a signature, since whoever holds the token can write under any name. Format, validation and the warning on a rejected header → control plane. A token's author_name and author_email are removed settings: a file that still has them loads with a warning, and they never refuse the start.

Tokens are declared in the YAML auth.tokens list; the flat CARTOGRAPHER_TOKENS form cannot carry the fields of a mapping. A token: written as exactly ${NAME} is read from the environment, so the file never holds the secret; an unset or empty variable refuses startup (#680). CARTOGRAPHER_TOKENS, when set, replaces the whole YAML list, so a deployment moving to YAML tokens drops that variable.

Operational audit

The audit log is always on (D905): every tools/call received over HTTP or stdio records two events (D119), including one an authorization decision denies (D132): an attempt before the tool would run (or before the denial is returned) and a completion after it, carrying the tool name, the KB, the transport, the principal and the outcome (success, application_error, internal_error, unauthorized, unknown_tool, …). An attempt with no matching completion is itself evidence: a crash mid-operation becomes visible rather than silent. The principal is read from the request context, so it is always the identity authorization actually used. Arguments of an unregistered tool are never recorded. Of a registered tool only the fields its allow-list names are recorded (identifiers, never content): a string, a boolean, or an array of strings, joined and bounded in items and bytes (joinAuditList, audit.go). A completion also records result_bytes, the size of the result's text (D301): what the call cost the agent to read, so read amplification per session is measurable from the log without logging content. The field is hashed only when present, so events written before it verify unchanged.

Entries form a JSONL hash chain signed with Ed25519: altering one recorded entry invalidates every entry after it.

The log is <data>/audit/audit.log unless audit.log moves it, and its key is audit.key in the credentials directory, which the first start generates when it is missing; the start logs the path and the public key audit verify needs. A key the server can neither read nor generate (a read-only directory without audit.key) leaves the log unsigned under best_effort, with a warning at every start, and refuses the start under required. A log that cannot be opened does the same: best_effort runs without one and says so, required does not start. There is no setting that turns the log off.

Two failure modes, selected by audit.mode:

  • best_effort (default): a failed append is counted and logged, and the MCP call proceeds. Availability wins; the log may have gaps.
  • required: a failed attempt-phase append rejects the call before the tool runs, so the log can never be missing an operation that actually happened. The sink recovers on its own once writes succeed again.

Segments rotate at audit.max_segment_bytes into audit.archive_dir. A rotated segment is recorded in a signed checkpoint index before retention may delete it, so audit.retention_days never breaks verifiability: the chain stays checkable across segments no longer on disk, which audit verify reports as checkpoint-only.

The operator commands are offline by design — they read the files, not a running server, because an audit trail is most needed when the server is down:

cartographer audit verify --log /var/lib/cartographer/audit.jsonl [--public-key <hex> [--signed-since <RFC3339>]]
cartographer audit export --log /var/lib/cartographer/audit.jsonl --out report.json

verify exits non-zero on a broken chain; export refuses to write a report for a chain it cannot verify, so an exported document is never authoritative-looking without being authoritative. Without --public-key the chain is checked but signatures are not, and the unsigned count is reported so the two situations stay distinguishable.

With a key the log must be signed from a start: --signed-since, else the first checkpoint written with a key loaded, else the first signed entry. A log with entries and none of the three has no signature at all, so a rewrite that recomputed the chain and dropped every signature would pass for the original: verification (and export) fails with no signed entry. --signed-since asserts that every entry at or after that time is signed (it needs --public-key, otherwise exit 2); an operator whose log was never signed passes a time after its last entry, or drops the key.

Stateless behavior

Authorization and optimistic content hashes do not depend on an MCP session. Per-KB conflict and provisioning state is stored outside versioned concept content where required.

Since the 2026-07-28 revision this is the protocol's own model, not just a local choice: sessions, Mcp-Session-Id, the GET stream endpoint and SSE resumability were all removed from the spec. Nothing is minted, stored or echoed per connection — including the protocol era, which is derived from each request and discarded with its response.

Client roster

GET /clients reports which clients are talking to this server and on which protocol era. It requires a bearer token whenever auth is enabled — unlike /health it is deliberately not on the public-path list (internal/auth), because client names and versions describe deployment topology.

One row per distinct (KB, client name, client version, protocol version, era) combination, with a request count and a last-seen timestamp; rows are sorted by KB, then client name, then version, so consecutive polls are diffable. A multi-KB server answers with one flat array across every mounted KB, each row naming its kb. Rows are limited to the KBs the token can read (any read grant on the KB, whatever its map, journal or type selectors; an admin sees all): a token with no grant on a KB gets neither its rows nor its activity, and an empty roster is a 200, not a 403, so the answer does not confirm the KB exists. An overflow counter appears only when it is non-zero and counts visible KBs only.

{"clients":[{"kb":"homelab-wiki","client_name":"claude-code","client_version":"2.1.0",
             "protocol_version":"2026-07-28","era":"2026-07-28","count":42,
             "last_seen":"2026-08-14T21:40:00Z"}]}

Four properties bound what the roster is good for:

  • Identity is self-reported and unverified. It comes from the client's own clientInfo — _meta.io.modelcontextprotocol/clientInfo in the 2026-07-28 era, initialize's params.clientInfo in the handshake era. It is an operational aid and must never become an authorization input.
  • Identity belongs to a session, not to a request. A session agrees it once and every later request inherits it; a client that names itself differently mid-session does not get a second row. Two clients are two sessions.
  • It is process-local and lost on restart. Nothing is written to disk; the audit log remains the durable record. A client that sends no clientInfo is counted under the name unknown, so anonymous traffic stays visible instead of disappearing. Notifications are not counted: the roster counts what clients ask of this server.
  • It is bounded. At most 64 distinct keys are tracked and each identity field is truncated to 64 bytes; further distinct keys increment overflow rather than growing the map. Recording happens only after authorization succeeds, and never affects the /mcp response.

The roster also tells an operator whether every client of a given deployment has moved to the current protocol era.

Tool namespace discovery

A server never prefixes tool names (D288), so there is no namespace to discover: GET /health carries no per-KB tool_prefix, and clients use the bare tool name. Clients must not derive a prefix from the KB name.