hush/cmd/hush-mcp/mcp.go
jx12n 4d9a26498e hush: one-time secret links the server cannot read
Paste a secret, get a link, send it. The first person to open it and press
Reveal sees the secret; the link dies at that moment. The recipient needs a
browser and nothing else — no account, no client, no installed tooling.

The server cannot read what it stores. AES-256-GCM happens in the browser and
the key lives in the URL fragment, which browsers never transmit, so hushd
holds ciphertext and no key material. That is a property of where the key sits
rather than a promise about our conduct, which is why there is deliberately no
endpoint accepting a plaintext secret and no server-side-encryption fallback:
two guarantees behind one URL would be worse than one honest guarantee.

Three decisions carry the design:

  * GET /s/{id} touches NO storage, not even to check existence. Slack, Teams,
    WhatsApp, iMessage and Outlook Safe Links all fetch a URL before a human
    sees it, so destroying on GET would destroy most secrets in transit and the
    recipient's "already used" would be indistinguishable from interception.
    Only POST /reveal consumes. Bot user-agent detection is an arms race;
    removing the side effect from GET is not. Pinned by
    TestGettingTheRevealPageNeverConsumesTheSecret.
  * Destruction is one Redis GETDEL, which is atomic. GET-then-DEL has a window
    where two simultaneous readers both win, and for a one-time secret that
    window is the product. The store contract demands atomicity and the same
    concurrency test runs against both implementations.
  * Missing, already-revealed, expired and evicted are ONE indistinguishable
    410. Separating them would confirm to a prober that a given link was real.

The secret id IS the capability, so secret.ID is a struct whose every
accidental path — %v, %s, String(), slog, json.Marshal — emits a redacted
handle or refuses, and the raw value needs an explicit Value(). The first
version tried to prevent leaks by implementing no String() at all; its own test
caught that Go's fmt prints unexported fields anyway, so forbidding the method
had removed the control rather than the leak.

Operationally: structured JSON on stdout in the fleet's wire format, which
Vector already collects with no annotation; six hush_* metrics on the chassis
registry with no id, IP or path in any label; five alert rules wired into
vmalert. The public Ingress enumerates /, /s/ and /api/ so /metrics, /healthz
and /readyz share the port but are unreachable from the internet — no
basic-auth middleware to maintain and get wrong.

Dependencies are vendored because go-chassis is private: the Woodpecker test
step and the in-cluster Kaniko build both run -mod=vendor with GOPROXY=off and
hold no git credential.

cmd/hush-mcp is a stdio MCP server doing the same client-side crypto locally,
so using hush from an agent preserves the same guarantee as using it from a
browser.
2026-09-03 00:08:38 -06:00

196 lines
5.7 KiB
Go

package main
import (
"bufio"
"encoding/json"
"fmt"
"io"
"sync"
)
// A minimal, dependency-free MCP server over stdio.
//
// MCP on stdio is newline-delimited JSON-RPC 2.0. The surface this server needs
// is four methods — initialize, notifications/initialized, tools/list,
// tools/call — so it is implemented directly rather than pulling in an SDK
// whose API churn would be a bigger maintenance surface than the protocol.
//
// The one rule that matters for a stdio server: stdout carries protocol frames
// ONLY. Anything diagnostic goes to stderr, because a stray Println on stdout
// corrupts the stream and the host reports an opaque parse failure.
// protocolVersion is the MCP revision this server implements. The host sends
// its own in initialize; the spec has the server answer with the version it
// will actually speak rather than echoing the client's.
const protocolVersion = "2025-06-18"
type request struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type response struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Result any `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// JSON-RPC 2.0 reserved codes. -32602 is the one that matters here: a bad tool
// argument is an invalid-params error, not a transport failure.
const (
codeInvalidParams = -32602
codeMethodMissing = -32601
codeInternal = -32603
)
// Tool is one callable tool. Schema is the raw JSON Schema advertised to the
// host, which is what makes the arguments self-documenting in the client.
type Tool struct {
Name string
Title string
Description string
Schema map[string]any
// Handler returns the text to show the caller. An error is reported as a
// TOOL error (isError on the result) rather than a protocol error, so the
// model sees the message and can act on it instead of the call appearing
// to have failed at the transport level.
Handler func(args json.RawMessage) (string, error)
}
// Server dispatches MCP over a reader/writer pair.
type Server struct {
name string
version string
tools []Tool
mu sync.Mutex // serialises writes: one frame per line, never interleaved
out *json.Encoder
w io.Writer
}
func NewServer(name, version string, out io.Writer, tools []Tool) *Server {
return &Server{name: name, version: version, tools: tools, out: json.NewEncoder(out), w: out}
}
// Serve reads frames until stdin closes, which is how a stdio host signals
// shutdown.
func (s *Server) Serve(in io.Reader) error {
sc := bufio.NewScanner(in)
// A tool result can carry a secret link, which is small, but the buffer is
// raised so a large argument cannot truncate a frame into a parse error.
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
line := sc.Bytes()
if len(line) == 0 {
continue
}
var req request
if err := json.Unmarshal(line, &req); err != nil {
// Malformed frame with no id: nothing to reply to. Report on stderr
// and keep the stream alive.
fmt.Fprintf(stderr, "hush-mcp: unparseable frame: %v\n", err)
continue
}
s.dispatch(req)
}
return sc.Err()
}
func (s *Server) dispatch(req request) {
// A notification has no id and MUST NOT be answered. Replying to one is the
// classic stdio bug: the host sees an unsolicited response and desyncs.
isNotification := len(req.ID) == 0
switch req.Method {
case "initialize":
s.reply(req.ID, map[string]any{
"protocolVersion": protocolVersion,
"capabilities": map[string]any{"tools": map[string]any{}},
"serverInfo": map[string]any{"name": s.name, "version": s.version},
})
case "notifications/initialized":
// Handshake complete. Nothing to send.
case "ping":
s.reply(req.ID, map[string]any{})
case "tools/list":
list := make([]map[string]any, 0, len(s.tools))
for _, t := range s.tools {
list = append(list, map[string]any{
"name": t.Name,
"title": t.Title,
"description": t.Description,
"inputSchema": t.Schema,
})
}
s.reply(req.ID, map[string]any{"tools": list})
case "tools/call":
s.call(req)
default:
if !isNotification {
s.fail(req.ID, codeMethodMissing, "unsupported method: "+req.Method)
}
}
}
func (s *Server) call(req request) {
var p struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
if err := json.Unmarshal(req.Params, &p); err != nil {
s.fail(req.ID, codeInvalidParams, "malformed tools/call params")
return
}
for _, t := range s.tools {
if t.Name != p.Name {
continue
}
text, err := t.Handler(p.Arguments)
if err != nil {
// isError:true keeps this a TOOL failure the model can read and
// react to, rather than a protocol error that looks like the server
// broke.
s.reply(req.ID, map[string]any{
"content": []map[string]any{{"type": "text", "text": err.Error()}},
"isError": true,
})
return
}
s.reply(req.ID, map[string]any{
"content": []map[string]any{{"type": "text", "text": text}},
})
return
}
s.fail(req.ID, codeInvalidParams, "unknown tool: "+p.Name)
}
func (s *Server) reply(id json.RawMessage, result any) {
if len(id) == 0 {
return
}
s.write(response{JSONRPC: "2.0", ID: id, Result: result})
}
func (s *Server) fail(id json.RawMessage, code int, msg string) {
if len(id) == 0 {
return
}
s.write(response{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: msg}})
}
func (s *Server) write(r response) {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.out.Encode(r); err != nil {
fmt.Fprintf(stderr, "hush-mcp: write failed: %v\n", err)
}
}