hush/docs/MCP.md
jx12n 62c95f8c2f docs, ops scripts, and the MCP install
Written after the service was live, so every command and every number here was
run against the real deployment rather than assumed:

  * DEPLOY.md records the things a rebuild needs and git does not hold — the
    Redis ACL user (and why +getdel is the one to notice), the GCP Secret
    Manager entry, the DNS record, and the three coordinated edits vmalert
    needs because it has no ConfigMap auto-discovery.
  * It also records two blockers rather than hiding them: Woodpecker is NOT
    activated (the token in rdev-credentials returns 401), so pushes do not
    deploy yet and the Kaniko Job is the interim path; and the host is
    hush.threesix.ai rather than hush.orchard9.ai because orchard9.ai is on
    GoDaddy and no GoDaddy credential exists anywhere I can reach.
  * OPERATIONS.md is one section per alert, plus the failure modes that are not
    alerts — chiefly that "gone" cannot distinguish already-revealed from
    expired from LRU-evicted, on purpose, so the operator's default reading of
    an unexpected "gone" is that the secret is compromised and should be
    rotated.
  * scripts/logs.sh and alerts-check.sh verify rather than assert:
    alerts-check asks vmalert what it actually loaded AND checks each rule's
    series exists, because a rule reading a metric nothing exports can never
    fire and looks exactly like a healthy service.
  * scripts/smoke.sh is a real client — it generates a key, encrypts, posts only
    ciphertext, reveals, decrypts, then asserts the second reveal is 410, that
    three GETs did not consume the secret, that missing and malformed ids are
    indistinguishable, and that a plaintext field is refused.

install-mcp.sh proves the MCP handshake before writing any config, backs up
mcp.json, and rewrites only hush's entry — a config pointing at a broken server
surfaces as an opaque host-side connect failure, which is worth one extra check
to avoid.
2026-09-03 00:18:53 -06:00

112 lines
4.4 KiB
Markdown

# The MCP server
`cmd/hush-mcp` gives an MCP host (omp, Claude Code, any client) two tools:
| Tool | Does |
|---|---|
| `hush_create` | Encrypts a secret locally, stores the ciphertext, returns a one-time link |
| `hush_reveal` | Fetches and decrypts a link, **destroying it** |
## Install
```bash
make mcp
```
That builds the binary to `~/.local/bin/hush-mcp`, proves the MCP handshake
works before wiring anything, and adds a `hush` entry to
`~/.omp/agent/mcp.json` — backing the file up first and leaving every other
server alone. Restart omp to pick it up.
```json
{
"mcpServers": {
"hush": {
"type": "stdio",
"command": "/Users/you/.local/bin/hush-mcp",
"env": { "HUSH_BASE_URL": "https://hush.threesix.ai" },
"timeout": 20000
}
}
}
```
The same file shape works for Claude Code (`~/.claude.json`), Cursor and VS
Code — the stdio transport is the portable part.
## Why it runs locally instead of being an endpoint on hushd
hushd could serve `/mcp` and encrypt on the server. It deliberately does not.
If the server did the encrypting, the server would see every plaintext created
through MCP. hush's guarantee — *we cannot read your secrets* — would then hold
for browser users and quietly not hold for agent users, and no one could tell
which they had by looking at a link. Two guarantees behind one URL is worse
than one honest guarantee.
So `hush-mcp` is a peer of the browser, not of the server: it mints the AES-256
key, encrypts, posts only ciphertext, and assembles the `#fragment` link
itself. hushd sees exactly what it sees from a browser and no more.
The cost is that this is a local binary to install rather than a URL to
configure. That is the right trade for a service whose entire value is where
the key sits.
## Wire compatibility
Three implementations produce and consume one format — the browser
(`internal/web/templates/base.html`), this server, and `scripts/smoke.sh`:
```
AES-256-GCM, 96-bit nonce PREPENDED to the ciphertext,
both ciphertext and key base64url-encoded WITHOUT padding
```
A link minted by any of the three opens in the other two.
`TestWireFormatMatchesAnIndependentImplementation` pins that by round-tripping
Go↔Python in both directions, so a change to one implementation's encoding
fails the build rather than producing links that only work in the client that
made them.
## Configuration
| Variable | Default | Purpose |
|---|---|---|
| `HUSH_BASE_URL` | `https://hush.threesix.ai` | Which deployment `hush_create` posts to |
| `HUSH_CREATE_TOKEN` | unset | Only needed if that deployment has `HUSH_REQUIRE_AUTH=true` |
`hush_reveal` ignores `HUSH_BASE_URL` and reveals against **the link's own
origin**. A link from another hush deployment must not be posted to this one,
where its id would be meaningless — and silently revealing against the wrong
host would report `gone` for a secret that was never touched.
## Behaviour worth knowing before you call it
- **`hush_reveal` is destructive and irreversible.** After it returns, the link
is dead and the intended recipient cannot open it. The tool description says
so, because a model that calls it to "check" a link has burned it.
- **`hush_create` returns the link once.** It cannot be recovered: the key was
never sent to the server, so nothing can rebuild it.
- **A link with no `#fragment` is reported as an error without touching the
secret.** Chat and email clients truncate fragments, and this is the commonest
real failure. The secret is intact and the fix is to ask for the full link —
which the error says, rather than reporting a generic failure.
- **Errors come back as tool errors** (`isError: true`), not protocol errors, so
the model reads the message and can act on it instead of seeing an opaque
transport failure.
## Protocol notes
Implemented directly against the JSON-RPC 2.0 stdio transport rather than via an
SDK: the surface needed is `initialize`, `notifications/initialized`,
`tools/list`, `tools/call` and `ping`, which is less code than an SDK
dependency's API churn would cost.
Two rules the implementation is careful about, both of which produce
hard-to-diagnose host-side failures when broken:
- **stdout carries protocol frames only.** Every diagnostic goes to stderr. A
stray `Println` corrupts the stream and the host reports an opaque parse error.
- **A notification (no `id`) is never answered.** Replying to one desyncs the
host, which then attributes the unsolicited response to the next request.