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

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

Mount modes

A multi-KB HTTP server can expose its tools two ways. mcp.mount_mode (CARTOGRAPHER_MCP_MOUNT_MODE, --mount-mode) selects which, and the two coexist: enabling routing adds an endpoint, it never removes or alters the per-KB ones.

Mode Endpoints tools/list Selecting the KB
per-kb (default) /mcp?kb=<name>, /mcp/<name> one full copy per KB in the URL
routed the above, plus /mcp/routed one copy of the union, on the routed endpoint a kb tool argument

Why routing exists. A client using N KBs writes N MCP server entries and carries N copies of the same tool schemas in its fixed context, on every model round-trip. Measured against a running server with three KBs, tools/list returned 82,341 bytes across the three mounts — more than twice the whole rest of that session's fixed context — and the two KBs never called still paid for themselves on all 204 round-trips. The tool-name prefix (D102) makes multi-KB work on a flat-namespace client and the agent profile (D65/D123) shrinks the set per mount; neither removes the duplication, because the duplication is the topology.

The KB is an argument, never inferred. On the routed endpoint every tool's input schema carries a kb property, required whenever two or more KBs are routed; a call without it is refused with an error naming the mounted KBs. With exactly one KB routed there is nothing to disambiguate, so kb is optional. A default KB would land a write in the wrong archive on a model slip, which is precisely what D102's flat-namespace warning exists to prevent.

The advertised set is the union. A tool one KB gates off — artifact_write under kbs[].allow_artifact_write — is still registered once, and refused at dispatch for the KB that gates it, with an error naming the tool, the KB and the setting. The alternative, an intersection, would silently hide a tool from a KB that allows it because a sibling does not.

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. The routed endpoint dispatches into the target KB's own server, so it cannot drift from the per-KB path.

Prefixes and routing do not combine. A routed mount has no flat namespace left to disambiguate, so the kb-name derived default (D153) is not applied when routing: the operator never asked for it, and it would only re-inflate the names routing exists to shrink. An explicit kbs[].tool_prefix is a different matter — that one was asked for, and it contradicts the request to route, so it is refused at startup naming the KB and the key. A KB named routed is refused too: it would A ?kb= on the routed URL is refused with 400: the KB travels in the tool arguments there, and two channels for one choice are how they get to disagree.

GET /health reports mount_mode: "routed" and routed_path when routing is on, and omits both otherwise — which is what an older client and a per-kb server both see. cartographer connect/sync read it and write one MCP entry for a routed server. Switching an existing deployment between modes changes the shape of every entry, which an incremental sync cannot see: cartographer status reports the mismatch and tells you to run cartographer reconnect. It does not heal it.

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.

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.

Origin

The MCP endpoint validates the Origin header. A request without one — every non-browser client — is unaffected. With one, it must appear in mcp.allowed_origins (CARTOGRAPHER_MCP_ALLOWED_ORIGINS); when that list is empty, the default, only the request's own Host is accepted. "*" in the list disables the check. A refused origin gets HTTP 403 before authentication runs, and the CORS allow-origin response header echoes the accepted origin rather than a wildcard.

Operators exposing Cartographer to a browser should list the origins explicitly: the same-origin default keeps a local browser working, but a rebound DNS name arrives with Origin and Host both naming the attacker, so it matches.

Enabling bearer authentication

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

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

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

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 and the RFC 9728 metadata path stay public either way, so probes keep 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; link rewriting additionally requires whole-KB write
curated index index_patch the target Map/Journal must be allowed for write; 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, pr_finalize, … 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.
  • 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.

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 — pre-D118 deployments are unaffected.

Identity and audit boundaries

Bearer tokens authorize requests; they do not become git signing identities. Git author/committer and SSH settings are configured globally or per KB as described in deployment.

Operational audit

When audit.log is configured, 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.

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

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

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 and /ready 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. An overflow counter appears only when it is non-zero.

{"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"}]}

Three 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 is also the go/no-go evidence for retiring the handshake era: it is what tells an operator whether every client of a given deployment has moved.

Tool namespace discovery

GET /health reports, per mounted KB, the effective tool-name prefix under which that KB's tools are registered (tool_prefix, empty when unprefixed — the default). This is the authoritative source for any client that needs to call a tool by name (D120).

Clients must not re-derive the prefix from the KB name: tool_prefix is an arbitrary operator-chosen string, so a derived value is a guess that produces calls to tools that do not exist. The value is read live per operation and never persisted, so an operator changing a prefix server-side does not require any client-side reconnection.

Prefixing is exact, not additive: on a prefixed KB the bare tool name does not resolve. It applies uniformly to every tool the KB registers, including the ones hidden by the agent tools profile, so the advanced/operator tools stay reachable by name under the same namespace.