An assistant that can both request a household action and report that it was confirmed can confirm its own requests. hagate is a self-hosted MCP server that splits those two roles apart.
Read-only tools (explain_policy, list_contacts, list_bills, list_devices, get_request_status) answer
directly. Consequential tools — placing a grocery order, messaging a family member, locking or unlocking the front
door, paying a bill — never execute on the assistant's say-so. Each call is first admitted against a household
policy (per-role capabilities, spending caps, quiet hours), then held as a pending request until a human confirms
it — in the client's own elicitation form, or, for the highest-risk tier, on a page hagate serves itself, which
shows the action only after the person enters a confirmation code the assistant never receives:
- In-client elicitation. The MCP client renders the confirmation form itself, using the
2025-11-25elicitation capability. - Out-of-band confirmation page. Tier-3 actions (every bill payment, and anything the household's policy
raises to tier 3) are confirmed on a page hagate serves itself, through URL-mode elicitation. The approving
human therefore reads what the action is from hagate, not from the assistant that asked for it. The tier
decides the mode: a client that cannot show the mode a tier needs is refused with
capability_missing, never switched to an easier one.
The confirmation credential is never sent to the assistant and never appears in any tool result; the prompt that
issues it says so in as many words. Bearer tokens and confirmation credentials are separate secrets, and
presenting one where the other belongs is refused. Every admission, confirmation, refusal and expiry is appended
to a hash-chained audit log: altering a past row breaks hagate audit verify, and comparing the chain head
against a head recorded elsewhere (hagate audit verify --expect-head) also catches a wholesale re-chaining.
The protocol layer is MCP 2025-11-25 over Streamable HTTP, implemented directly against the specification on
Starlette — session establishment and deletion, Origin/Host validation, bearer authentication, per-request
rate limiting and duplicate-request suppression. The store is SQLite in WAL mode, with immediate write
transactions; an fcntl advisory lock keeps a second hagate serve off the same data directory. The server
binds loopback only.
The repository ships a second, independent distribution, hagate-console, which is the scripted MCP client used
in the demo. Its only Python dependency is httpx; it never imports the server package and never opens its
database. One scenario shells out to hagate audit verify as a subprocess — that is the single exception, and
the console's own test suite pins it to exactly that call site. Running
hagate-console demo --scenario all walks ten scenarios end to end — the read-only tools, an elicitation form,
a typed echo of the action's key detail, a denial by role, an idempotent replay, a missing client capability, the out-of-band
confirmation page and its wrong-credential path, a bearer/confirmation credential swap, and the audit chain — and
prints one PASS/FAIL line each.
Stated plainly, because a gate that overclaims is worse than no gate: the adapters are simulated — no real
order is placed, no real message is sent, no real lock is driven. The gate, the audit chain and the confirmation
flow are the product; the devices behind them are stand-ins. There is no TLS and no remote listener. It is POSIX
only: hagate serve takes an fcntl advisory lock on its data directory, and the CLI imports fcntl
unconditionally. The audit chain is unkeyed, so it detects an edited row but not a wholesale re-chaining by
someone who can rewrite the whole table — --expect-head against a head kept elsewhere is what closes that.
And a confirmation code printed to a terminal can be captured by scrollback or a
recording tool: "shown exactly once" is a property of hagate's output, not of where that output ends up.
Log in or sign up for Devpost to join the conversation.