ADR-0011: Agent layer — policy engine, audit, skills and the MCP server
- Status: Accepted
- Date: 2026-10-08
- Builds on: ADR-0004 (stateless MCP server, layered approval), ADR-0005 (skills format), ADR-0010 (Better Auth)
- Sources:
@modelcontextprotocol/server2.3.1 /@modelcontextprotocol/client2.3.1 (verified against the published type definitions) · https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr · https://modelcontextprotocol.io/extensions/skills/overview ·@better-auth/api-key1.7.7
Context
Sprint 5 makes Cloudhal usable by agents: an MCP client must be able to create content, see a preview, and ask to publish — with publishing stopping at a human. Three things were undecided: how policy, audit and skills relate to the MCP SDK; how approval works for clients with and without elicitation; and how agents authenticate over HTTP.
Decision
packages/agent-runtimeis SDK-free. It holds thePolicyEngine(actor scopes × tool scopes × annotations × declared skill →allow | deny | approval-required), theSkillLoader(SKILL.md →SkillManifestcontract; Cloudhal data in flatcloudhal.*metadata keys) and theAuditLog(tableaudit, migration0003_agent). It never imports MCP.- Tools are defined once, independently of the SDK (
packages/mcp-server/src/tools.ts): JSON Schema in/out built from the contract schemas, honest annotations, scopes, handler overSiteGraphStore.runToolis the only execution path: policy → audit → handler. Every mutation auto-snapshots (reason: "agentMutation", label = tool name). The 18 tools are listed deterministically. - The MCP server is a thin wrapper (
createCloudhalMcpServer): one instance per principal,fromJsonSchemafor schemas (no zod in our code),structuredContenton every result, skills exposed asskill://<name>/SKILL.md(+references/) resources, and a declared skill via_meta["cloudhal.skill"]narrowing the call to that skill’s scopes. - Approval = multi-round-trip elicitation, with a signed token as fallback.
approval-requiredreturnsinput_requiredcarrying an elicitation (the prompt shows the diff summary) and an HMAC-signedrequestStatebound to the tool, the argument hash and the principal. The client retries with the answer; onlyaccept+confirm: trueruns the handler;decline/cancelis audited as a denial. Clients that cannot elicit (2025-era over stateless HTTP, hosts without elicitation) receive aconfirmTokento pass on a second identical call — same HMAC state, same binding, 10-minute TTL; can be disabled withconfirmTokenFallback: false. - Transports:
cloudhal mcp(stdio, local developer, default scopes*,--scopesto narrow) and/mcpinside the Hono API (createMcpHandler, stateless per request, 2025-era served statelessly). Authentication stays outside the SDK: the API resolves aPrincipal(session cookie → human with all scopes;x-api-key, orAuthorization: Bearer <key>outside/api/admin/*→agentactor with the key’scloudhalpermissions as scopes) and passes it viaauthInfo.extra. - Agent credentials are Better Auth API keys (
@better-auth/api-key, table in migration0003_agent). Keys are minted server-side throughPOST /api/agent-keys {name, scopes}by a signed-in human (permissions are a server-only property in the plugin), listed and revoked under the same route. - Skills extension (SEP-2640) is not implemented yet. The TypeScript SDK has no
skills/list/skills/getsupport in 2.3.1 (PR #2818 open); resources cover discovery for now. Revisit when the SDK ships it.
Alternatives considered
| Alternative | Pros | Cons | Why not |
|---|---|---|---|
| Register tools directly with the SDK (zod schemas) | Less code | Policy/audit/tests tied to SDK types; zod in our tree despite ADR-0001 | Contract schemas already exist; fromJsonSchema reuses them |
Approval only via confirmToken |
Works everywhere | The agent sees the token; relies on the host to involve a human | Elicitation is the first-class path; token is the documented fallback (ADR-0004) |
OAuth 2.1 (@better-auth/mcp + CIMD) now |
Spec-aligned remote auth | Much larger surface; not needed for local/CI agents | Sprint 6; API keys cover agents today |
| Durable Object / sessionful server | Server-initiated elicitation works for legacy clients | Deprecated pattern; session affinity | Stateless per ADR-0004 |
Consequences
- Positive: one tool table drives policy, MCP, docs and tests; the exit criterion is proven by a real client ↔ server test (in-memory, HTTP and stdio); audit answers “who did what, under which skill, with which snapshot”.
- Negative: two approval paths to keep in sync; legacy clients over HTTP never get interactive approval.
- Risks: SDK v2 is young (2.3.x) — pinned exact;
requestStateHMAC key must be shared across instances in multi-process deployments (configuremcp.stateKeyfrom a secret).