Install
npx skillscat add agentproto/ts/auth Install via the SkillsCat registry.
When to use
Reach for this whenever an agent (or a child MCP it mounts) must present a
credential to a remote service and you don't want that secret sprayed into the
daemon's global env or hardcoded in a config file. The hallmark: "how do I give
this agent access to <service> without leaking the key to every other agent?"
- A tool/agent calls a Bearer API (agentpush, Stripe, an internal API).
- A hosted/remote MCP server needs an
Authorizationheader. - You want ONE place to store a key, scoped per service, resolved fresh (with
refresh) at use-time instead of a long-lived env var.
Do NOT use for the daemon's own tunnel login (that's agentproto auth login, a
device-code flow already wired) — though it uses the same substrate underneath.
The model — one source of truth, resolved on demand
auth provider (declares WHERE/HOW) ──┐
CredentialStore (holds the secret) ──┤─▶ CredentialBroker.resolveHeaders({ path, audience })
│ → { Authorization: "Bearer …" } (fresh, per call)- Provider — a manifest declaring a service's
id,apiBase, andauth
flow. Config only, never the secret. Registered once (registerAuthProvider). - CredentialStore — where the secret lives:
KeychainStore(macOS CLI),FileStore(AES-256-GCM, headless — key fromAGENTPROTO_STORE_KEY),MemoryStore(tests). Swap the backend, same interface. - Broker —
resolveHeaders({ path, audience?, signal? })turns a providerpathinto ready-to-use headers: serves a fresh stored bearer, else runs the
flow.audience(api/mcp/tunnel) scopes the credential so it can't
be reused cross-plane. - Flows — pick one per service:
pat(static token you store/paste),service-auth(auth.md claim ceremony → short oat + durable assertion),device-code(RFC 8628 → durable daemon token + refresh).
Recipe
1. Declare + register the provider
TS literal (builtin/tests):
import { defineAuthProvider, registerAuthProvider } from "@agentproto/auth"
registerAuthProvider(defineAuthProvider({
id: "agentpush",
description: "AgentPush API — paste a personal API key.",
apiBase: "https://api.agentpush.example",
audience: "mcp", // or "api"
auth: { flow: "pat", tokenStore: { keychain: "agentpush", account: "{server}" } },
}))Or ship a vendor *.auth.md and parseAuthProviderManifest(...) → registerAuthProvider(...) — a host adds providers without editing this package.
2. Store the secret ONCE (out of env, into the store)
import { KeychainStore, resolveStoreRef, getAuthProvider } from "@agentproto/auth"
const p = getAuthProvider("agentpush")!
const store = new KeychainStore() // or FileStore for headless
await store.write(resolveStoreRef(p.auth.tokenStore, p.apiBase, p.audience),
{ value: process.env.AGENTPUSH_API_KEY!, kind: "pat" })Do this once (onboarding / a secrets set step) — then delete the env var.
3. Resolve at use-time
import { CredentialBroker, getAuthProvider } from "@agentproto/auth"
const broker = new CredentialBroker({ store, getProvider: getAuthProvider })
const headers = await broker.resolveHeaders({ path: "agentpush", audience: "mcp" })
// → { Authorization: "Bearer …" } — attach to your fetch / MCP transportFresh every call; for service-auth/device-code it auto-refreshes. No secret
touches env or .mcp.json.
Two consumption paths
A. Agent calls the service in its own code/tool. Works TODAY: resolve via the
broker (step 3) and attach the header. Nothing else to wire.
B. Service exposed as a child MCP the agent mounts. agent_start.mcpServers[]
entries carry { name, transport, ref?, headers?, credentialRef? }
(packages/runtime/src/agent-tools.ts). Set credentialRef to a broker path
and session-spawn.ts's resolveMcpCredentialHeaders resolves it intoheaders at spawn time — merged ON TOP of any static headers on the same
entry, then the credentialRef itself is dropped before the child ever sees
it (the secret never lives in the child's env or config). Runtime stays
auth-free: it calls a resolveMcpCredentialHeaders hook it doesn't implement
(packages/runtime/src/mcp-credential-deps.ts, setMcpCredentialDeps); the
CLI wires that hook to a real CredentialBroker inpackages/cli/src/commands/serve.ts (resolveHeaders({ path: credentialRef, audience: "mcp" })). Resolution failures (missing provider, no keychain,
expired credential) are caught and logged as warnings — they never kill the
spawn, so an agent that can't resolve its credential mounts the MCP without
that header rather than failing to start.
Worked example — agentpush
agentpush uses a static Bearer key. Clean-auth wiring:
- Register
agentpushas apatprovider (step 1),audience: "mcp". - Move
AGENTPUSH_API_KEYfrom the daemon env into the store (step 2). - In-code callers resolve
broker.resolveHeaders({ path: "agentpush" })(path A) —
works now, key out of the global env, scoped to this agent. - For agentpush-as-child-MCP (path B), pass
credentialRef: "agentpush"on theagent_start.mcpServers[]entry —
the daemon resolves it to anAuthorizationheader at spawn time, no
other wiring needed.
Common mistakes
- Storing the key in the daemon's global env — every spawned agent inherits
it. The whole point of the store is per-service, per-spawn scoping. - Skipping
audience— set it (api/mcp/tunnel) so a credential minted
for one plane can't be presented on another (defense-in-depth; the normative
boundary is server-side). - Caching
resolveHeadersoutput — resolve per call; the broker owns
freshness/refresh. A cached header defeatsservice-auth/device-coderefresh. patwhere the service issues short-lived tokens — useservice-auth
(mint + refresh) instead of pasting a token that expires.- Wrong header shape —
resolveHeadersreturns a header map; a service that
wantsX-Api-Key(notAuthorization) needs the provider/flow to emit that
shape, not a Bearer.