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.
112 lines
4.4 KiB
Markdown
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.
|