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-25 elicitation 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.

Built With

Share this project:

Updates

Submission history