Design: Vended MCP Endpoints
Context
Switchboard's core object is a durable todo (ADR-0007) that agents claim and complete. Agents are numerous, disposable, and untrusted; humans are few, durable, and accountable. This capability answers who an agent is, who is accountable for it, and how it is granted a scoped slice of switchboard — realizing ADR-0008 and formalized in SPEC-0007.
The MVP is already implemented. The access model is: a human authenticates via OIDC
(SPEC-0008), registers agents (internal/store/agents.go CreateAgent), and
vends each agent a scoped endpoint. Vending mints a credential in internal/cred/cred.go, persists
only its hash via CreateEndpoint, and shows the plaintext once (internal/web/templates/vended.html).
The agent-facing surface is the vended MCP endpoint (internal/mcp, mounted at /mcp/{endpoint};
ADR-0017, SPEC-0014): it authenticates each call by hashing the presented
bearer token and resolving it to an active endpoint (EndpointByCredHash), verifies the credential
matches the slug in the path, then enforces the endpoint's queue+verb scope at the boundary. Routes are
wired in internal/server/server.go (/mcp/{endpoint} for agents, /endpoints/* for the human UI).
The one-time credential reveal emits the type: "http" .mcp.json block via web.buildMCPJSON. The
schema lives in internal/db/migrations/0001_init.sql (agents, endpoints). The original
internal/agentapi REST surface and its stdio adapter are retired.
Goals / Non-Goals
Goals
- Every agent action traces to exactly one accountable human owner.
- The vended endpoint (URL + credential) is the capability grant; scope travels with it.
- Least privilege by construction: an endpoint can do only its allowlisted verbs on its granted queues.
- Credentials stored hashed only; plaintext shown exactly once.
- Revocation is one instant, total operation with no residue.
- No agent identity ever exists in the identity provider.
Non-Goals
- Human authentication itself (OIDC login/session) — owned by SPEC-0008.
- Persona sub-scoping of an agent's vended tools (ADR-0009).
- Cross-agent friending / approval-is-vend (ADR-0010).
- Agent webhook self-management within a ceiling (ADR-0012).
- An external secret manager — credentials live hashed in switchboard's own PostgreSQL.
Decisions
Human principal + per-agent vended endpoints
Choice: Humans are the only principals/tenants; each agent gets a scoped MCP endpoint (URL + credential) that is the capability grant. Rationale: keeps accountability (every action → one human), least privilege, clean revocation, and a humans-only IdP all true at once. Alternatives considered:
- Agents as OIDC identities: creates orphan bot identities, bloats Pocket ID, muddies "who did this"; Pocket ID is passkey-only and humans-oriented — bots don't fit.
- One shared service token: trivial, but no per-agent scoping, revocation, or traceability; one leak compromises everything.
Credential = high-entropy token, stored as SHA-256 hash
Choice: Mint sbk_<base64url(32 bytes)>, store only SHA-256(token) hex + a display prefix,
resolve calls by hashing the presented token.
Rationale: high-entropy tokens don't need a slow password hash; SHA-256 + constant-time lookup is
the correct API-token primitive. Plaintext shown once matches how humans copy API keys.
Alternatives considered:
- bcrypt/argon2: designed for low-entropy human passwords; unnecessary cost per request for a 256-bit random token.
- Storing plaintext or reversible ciphertext: rejected — a database read would leak live credentials.
Immutable scope by default (revoke-and-re-vend)
Choice: default mutability = immutable; change access by revoking and vending anew.
Rationale: a URL+credential that always denotes one fixed power set is far simpler to audit — no
"when/who changed this scope" question. Revocation is always available.
Alternatives considered:
- Mutable-in-place scope: more convenient for incremental tweaks, but reintroduces the audit ambiguity immutability removes. Recorded as an open question (below).
Scope resolved server-side from the credential
Choice: The effective scope is read from the stored endpoint (EndpointByCredHash → queues +
verbs), never from request-supplied values.
Rationale: an agent must not be able to widen its own grant; the credential alone determines
authority.
Architecture
The vending path is human-driven; the enforcement path is credential-driven. The two never cross: the
/agent surface is bearer-authenticated and cookie-free, while /agents and /endpoints are
session-authenticated and ownership-guarded.
Vended-endpoint authentication + scope enforcement, per agent call:
Data model (as implemented in 0001_init.sql):
Revocation flips state to revoked (guarded by owner), which drops the row out of the
EndpointByCredHash active-only lookup — the credential stops resolving immediately, and the URL is
effectively unrouted because no scope can be built for it.
Risks / Trade-offs
- Switchboard runs its own credential mint/store (a mini authorization server) → accepted cost of keeping bots out of the IdP; kept small — one hash column, one active-only lookup, constant-time by DB unique index.
- Immutable scope means re-vend churn on permission changes → mitigated: scope changes should be rare and a re-vend is a single operation; the human copies one new credential.
- A leaked credential is a live bearer token until revoked → mitigated by per-agent revocation, short-lived credentials, and only-hash-at-rest so a DB read never yields a usable token.
- SSE stream is lossy by design (slow subscribers drop events) → acceptable because the durable todo queue is the ledger; the stream is only a doorbell ([ADR-0013]).
Migration Plan
Greenfield — no migration. The agents and endpoints tables ship in 0001_init.sql. The
immutable-vs-mutable default, if changed, would be a data-compatible column-semantics decision, not a
schema migration.
Open Questions
- Immutable vs. mutable scope: confirm with Joe whether to keep immutable-by-default (change =
revoke+re-vend) or allow editing a live endpoint's queues/verbs in place. The schema already carries
a
mutabilitycolumn defaulting toimmutable; mutable is more convenient but reintroduces audit ambiguity (ADR-0008). - Credential lifetime/rotation: ADR-0008 calls credentials "short-lived," but the MVP endpoint record has no explicit expiry column — expiry is currently only via revocation. A TTL/auto-expiry column is a candidate future hardening.
- Native per-credential rate limiting is deferred to a reverse proxy for now; revisit if a single compromised credential can generate abusive load before its owner revokes it.