The MCP surface
The single agent MCP surface: connecting an agent, what each scope is advertised, why there is no session id, and what never travels.
One MCP server, one transport. POST /mcp speaks it over HTTP on the same
Decision 0009 token-gated listener the browser surface and
/api/v1 already use — not a second listener, not a second auth story. Design
rationale: agent-interface.md §3.5; recorded in
Decision 0014.
There is no bespoke stdio MCP server, and none is coming back: a process running at the
local operator's ambient authority with no credential and no scope is exactly the shape
the threat model ruled out. A stdio-only MCP
client bridges to POST /mcp with an off-the-shelf http-to-stdio wrapper — see
Bridging a stdio client below. An agent working the local
filesystem alongside exocomp is the sanctioned LOCAL path and drives the
agent-aware CLI directly (--output json, exit codes) — it needs no
MCP hop at all.
Every tool call and resource read routes to the same internal/service verbs the CLI and
the HTTP API call, and marshals the same internal/wire envelope, so a resource's MCP
bytes match its --output json and its /api/v1 bytes. Writes enqueue through the one job
queue and return at once; a running exocomp serve claims and runs the row.
Connecting
Serve the listener with exocomp serve --web-addr <addr> or exocomp web --addr <addr>
(LAN access). The MCP endpoint comes up with it — there is no separate
flag and no separate port. Mint a token at the scope the agent actually needs:
exocomp token new tailnet-agent --scope readonly # or operator, or approve
Then point the client at the endpoint with that token as a bearer:
endpoint: https://<host>:<port>/mcp
header: Authorization: Bearer <token>
The handshake and a first call, by hand:
curl -sS https://<host>:<port>/mcp \
-H "Authorization: Bearer $EXOCOMP_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
curl -sS https://<host>:<port>/mcp \
-H "Authorization: Bearer $EXOCOMP_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
The listener serves a self-signed certificate by default; pin its fingerprint the way
exocomp login does, or front it with a Tailscale/Caddy endpoint that
terminates TLS.
Scopes decide the surface
The three Decision 0009 scopes map 1:1 onto read / write / approve, and tools/list is
filtered by the caller's scope — an agent is never shown a verb its credential cannot
exercise. The same floor is re-checked on every tools/call, before any store read, so a
call to a tool outside the caller's scope is refused rather than executed.
| Scope | tools/list advertises |
Notably absent |
|---|---|---|
readonly |
list_jobs, get_job, get_job_events, list_workspaces, get_workspace, get_drift, list_approvals, list_sources, list_schedules, list_rules, list_events, explain_event, list_secrets (metadata), list_backups, health, list_checks, get_check, list_netgroups, probe_check |
every write, every decision |
operator |
the read tools, plus workspace_plan, workspace_apply, check_add, check_update, check_enable, check_disable, check_rm, check_snooze, check_unsnooze, netgroup_add, netgroup_update, netgroup_rm |
every decision |
approve |
the read tools, plus approve_job and reject_job |
every write |
operator and approve are disjoint on purpose. A token that can raise the approval
gate with workspace_apply must not also hold its release, and a token that can release a
gate must not be able to raise one. On top of that, a decision is refused when the gated
job's ledger records the deciding token as the principal that asked for it. Both rules live
in the one decision path /api/v1 uses, so neither face can drift from the other. See
the threat model.
Resources — exocomp://workspaces, exocomp://jobs/{id}, exocomp://jobs/{id}/log,
exocomp://workspaces/{name}/drift, exocomp://approvals/pending, exocomp://schedules,
exocomp://events/recent, exocomp://health — are reads, available to every scope.
run_playbook is deliberately not exposed on the MCP surface yet: the inventory and
--source path resolvers it needs are still CLI-resident. Use
POST /api/v1/playbooks/run for that.
Checks and netgroups
list_checks and get_check project the same check.list/check.get bytes
check list --output json and /api/v1/checks emit: each target's stored config
plus its last status, stored netgroup, and probe duration. list_netgroups
projects the CIDR set with its !-prefixed excludes.
probe_check runs a stored check's probes now and returns per target
{target, up, duration_ms, netgroup, detail}. It writes nothing — no state row
moves and no event is emitted (D3) — so it needs only the read floor. An optional
target narrows the run to one target by name.
The check and netgroup write tools call the same store verbs the CLI and the web
form use (CreateCheck/UpdateCheck/SetCheckEnabled/DeleteCheck/
SetCheckSnooze, CreateNetgroup/UpdateNetgroup/DeleteNetgroup), so an
agent's write lands on the same rows an operator's would. They are configuration
writes, not queue enqueues: there is no idempotency_key — a repeat check_add
is a duplicate-name refusal, not a second row. check_add and check_update
carry the kind's optional fields (expect for http, min_days for tls,
expect_addr for dns, op+value for metric; both required), and a field that
belongs to another kind is refused with invalid_argument.
Bridging a stdio client
A harness that only speaks the MCP 2024-11-05 stdio shape (no HTTP transport of its own)
bridges to POST /mcp with an off-the-shelf stdio-to-HTTP wrapper — exocomp does not ship
or endorse one; any spec-conformant wrapper works. Two examples, run as a subprocess of the
harness:
npx mcp-remote https://<host>:<port>/mcp --header "Authorization: Bearer $EXOCOMP_TOKEN"
npx supergateway --stdio "https://<host>:<port>/mcp" --header "Authorization: Bearer $EXOCOMP_TOKEN"
The wrapper is the transport; it carries no authority of its own beyond the bearer token you
give it, so mint the token at the scope the agent actually needs (see below) rather than
handing it an operator or approve token by default.
What the transport does, and does not, implement
- One endpoint.
POST /mcpcarries one client→server JSON-RPC message, or a batch of them as a JSON array. The responses come back in that POST's body asapplication/json— an array if the request was an array. - Notifications get
202 Acceptedand an empty body. There is no id to answer on. A side-effecting method (tools/call,resources/read) sent as a notification is refused withinvalid_requestinstead of being executed silently, so a mutation always leaves a frame for an audit wrapper to record. GET /mcpis405. Streamable-HTTP allows a client to hold a GET open for server→client messages; this server initiates none. Job progress already has its own SSE tail atGET /api/v1/jobs/{id}/stream, which re-checks the token as it polls and ends the stream when it is revoked.- No sessions, and no
Mcp-Session-Id. This is a security property, not a gap: every message re-runs the bearer resolution and re-checks the scope, so a revoked or downgraded token stops working on its very next call rather than at the end of a long-lived session. Re-sendinginitializecosts nothing. - Protocol version
2024-11-05, echoed byinitialize. initializereturns aninstructionsstring. It carries one thing an agent cannot get from tool names alone: "event" spans two unrelated id-spaces —get_job_eventsreads a job's own execution log;list_events/explain_eventread inbound reactive trigger events. Do not conflate the two.
Errors come at three levels
| Level | Looks like | Examples |
|---|---|---|
| Transport | an HTTP status + a wire error envelope |
401 no/invalid bearer, 403 a browser session or an unknown scope, 429 auth backoff (Retry-After), 503 KDF gate saturated |
| Protocol | HTTP 200 + a JSON-RPC error object |
-32700 unparseable body, -32600 invalid request, -32601 unknown method, -32602 unknown tool |
| Tool | HTTP 200 + a result with isError: true, holding a wire error envelope |
forbidden (scope), invalid_argument, not_found, conflict, stale_plan |
An unauthenticated request is never answered with a JSON-RPC frame: it gets the 401,
so an agent cannot mistake a refusal for a session.
Idempotency, attribution, and what never travels
workspace_plan and workspace_apply take an optional idempotency_key argument, so a
retried call dedups to one job. The key is namespaced by face, authenticated token, and
tool before it reaches the queue — one agent's guessable key cannot collide with another
caller's write.
Every MCP write records the token behind it on the job's event ledger (enqueued by token <name> via mcp), so an agent's action never reads back as the operator's own CLI run.
Values never travel, by construction: list_secrets returns metadata only, get_drift
carries resource addresses, actions, and attribute keys but no values, and no tool returns
a secret plaintext or an approval decision link — a decision link is a single-use HMAC
capability, and returning one to an agent would silently promote it to approver. A decision
returns its outcome; the capability is derived and consumed server-side.
Later: OAuth
The MCP specification's own authorization story is OAuth 2.1, and some hosted clients
expect it. exocomp deliberately does not implement it yet: Decision 0014 §3 commits to Decision 0009
bearer tokens and their three scopes across all three faces, and one credential type is the
whole point. The seam is narrow when it is wanted — apiGuard/resolve is the single place
a request becomes an identity, and mcpCaller is the single place an identity becomes an
MCP principal — so an OAuth resolver would slot in there and leave the catalog, the scope
floors, and every handler untouched.
Related: The API surface · Machine-readable output · LAN access.