Give hey mcp its own sign-in, and send a draft only on the person's confirmation
Ship order:
- haystack #9215 merged and deployed: it pins
HEY MCP (local)to client idvd96nBiRFXz1AjTXbADAtkrgin every environment (Oauth::Client::Mcp::LOCAL_MCP_CLIENT_UID). - The production row is confirmed converged on that id.
- This merges.
hey mcp gets its own least-privilege sign-in, and it can send, with a confirmation for each email. This is the hey-cli lane of haystack #9041 (the local_mcp row) and #9085 (the scoped device flow). It implements sending-safety.md D1–D3 for stdio, as the hosted server does. D4 and D6 were withdrawn on 2026-10-31 and aren't here.
Release notes
hey mcp now has its own sign-in. Run hey auth login --mcp once; until you do, hey mcp exits with Run: hey auth login --mcp, even when the CLI is signed in or HEY_TOKEN is set. It can now send a draft, only after you confirm that email in your MCP client.
What changes for a person
hey auth login --mcp # once: "HEY MCP (local)", read write send
claude mcp add hey -- hey mcp
- Without that sign-in,
hey mcpexits withRun: hey auth login --mcp. That includes a person signed in to the CLI or withHEY_TOKENset. Existinghey mcpregistrations stop working until the person runs the sign-in once (release notes above). TheheyCLI's commands and its own credential are unchanged. hey_send/send_draftdelivers a draft after the person confirms that exact email in their MCP client. It's listed only to a client that can show a confirmation. Any other client is pointed at the draft's page in HEY.
1. hey auth login --mcp
- Separate storage. The credential lives in its own keychain item,
hey/hey::mcp::<origin>, beside the CLI'shey::<origin>. With no keychain it gets its own entry incredentials.json. Its refreshes and revocations name the local client id, never the CLI's.HEY_TOKENnever stands in for it. - Device flow first. It sends
POST /oauth/device_authorizationswithclient_idandscope=read write send, and noresource, per #9085's contract. It falls back to loopback authorization with the same scope and S256 PKCE oninvalid_request(OAUTH_SCOPED_DEVICE_FLOWoff),unauthorized_client(#9041 without #9085) or a 404. Other refusals, such asinvalid_client, fail. The poll handlesauthorization_pending,slow_down(+5 s),access_denied,expired_tokenandinvalid_scope, and sendsinstall_id. The device code is never printed. The link and user code are sanitized before they're shown, and only an http(s) link is opened. - It reads the token response's
scope. A narrower grant is stored as granted: with send unchecked,hey mcpserves nohey_send; without write, reads only. An omittedscopemeans what was requested (RFC 6749 §5.1). A grant naming anything not requested (full, which would mean the id is the CLI's own row) is refused and nothing is stored. An id equal to the CLI's is refused before HEY is contacted. hey auth logout --mcprevokes the grant withDELETE /oauth/tokens(the local client id and the refresh token), then removes the local credential. If the revocation fails, the credential is still removed and a warning says to remove the app in Connected Apps.hey auth statusreports both credentials, in JSON undermcpand styled as anMCP:line. Neither form includes a token.- Client id.
config.DefaultMCPClientIDisvd96nBiRFXz1AjTXbADAtkrg, the id haystack#9215 pins in every environment.HEY_MCP_CLIENT_ID, thenmcp_client_idsin the global config (keyed by origin), override it. A repo's.hey/config.jsoncan't set it.
2. No fallback to the CLI credential
hey mcp never falls back, not even for reads:
- The CLI credential holds
full.--read-onlyishey mcp's own rule, not HEY's. A read-only fallback still puts a full-scope token in the process the model drives, and the containment that justifies thelocal_mcprow (#9041's threat model) would then rest on hey-cli's gateway filter. - A fallback would leave the old path working, so people would never migrate.
- The cost is one sign-in, and the refusal names it.
3. send_draft, written in hey-cli
Why it isn't imported. hey-mcp-server's server package is still under internal/ (hey-mcp-server#14, open), and hey-mcp-server is private while hey-cli is public, so requiring it breaks every outside build. The code is in internal/mcpserver (send.go, sendbody.go, sendprompt.go) so the W5.1 move can lift it as a unit.
How it's wired.
- Its own tool.
hey_send(D7) hasdestructiveHintandopenWorldHinttrue. It's served only when the credential holdsreadandsend(expanded, sowritecounts asread), the server isn't--read-only, and the threads domain is served. - Listed only where the person can be asked, as hey-mcp-server#30 ("One press per send") does: a receiving middleware drops
hey_sendfromtools/listfor a client that declares no form elicitation (read from the request's capabilities, so per-request_metaon 2026-07-28 and the session's initialize before it). A call from a cached list still sends nothing and answers with the draft's link (D3). - The guidance is one press per send. The
hey_threadsblurb,send_draft's description and each draft save's note say: the person can have a draft shown or revised as they like; when they ask to send, callsend_draftat once, because its confirmation shows what goes out, so don't show the draft again or ask again. A draft save's note depends on whether this client is offeredhey_send: if not, it names the draft'sapp_urlandhey draft send <id>. - Two rounds over MRTR, through go-sdk v1.8.0's input requests. The first fetches
GetMessageEditand returns an elicitation whose HMAC-signed request state carries the draft's fingerprint, the sign-in, the origin, a 5-minute expiry and a method marker. The second verifies that state and the person'sacceptwithconfirm: true, re-fetches the draft and recomputes the fingerprint; any change fails with "the draft changed since you approved it". It then claims the draft and callsSendDraftonce, through a client that neither retries nor refreshes-and-resends after a 401 (the token is renewed just before the claim instead). An answer with an id and no thread, where the draft's edit page is still served, is reported as not sent, asmail.ConfirmDeliveredreads it. Every delivered field is written from the verified snapshot, and HEY replaces the whole recipient set whenaddressedis present. A 2026-07-28 client carries the rounds; an older one gets server-initiatedelicitation/createfrom the SDK shim. Both are tested. - The fingerprint covers the draft id, sender id and address, sorted To/Cc/Bcc, the subject, the attachments (sgid, name, size, type, caption), a sha256 of the body as delivered (blob attachments rebuilt from their identity, so signed URLs don't count, and comments dropped), and mode now.
- The prompt follows D2's layout: From, When, To, Cc and Bcc (Bcc included, "—" when empty) as
"Name" <address>, the subject whole, attachments, images by target, then the body indented, links' targets beside their text, with at most 1,500 characters shown and the header saying how many there are. Controls, line separators and every invisible character are escaped as\u{…}. Whatever the text can't show (the rest of the body, what attachments hold, images, markup outside a text-and-links allowlist) is named on aNot shown here:line beside the draft's link in HEY. That line names only elements and attributes HTML knows, never a custom element's or attribute's name, which is the draft's own text. - Refusals: a draft HEY no longer serves for editing, one already scheduled (
send_atisn't implemented), one with no recipients. Nothing a draft holds refuses it (sending-safety status, 2026-10-31).
Single use and never retried. The claim is per process, keyed by draft id, lasts 24 hours and is never released, even when HEY refuses. A replayed state, a second confirmation of the same draft and a resend after a failure are all refused with SendDraft called once, and a draft this process sent isn't put to the person again. Across processes, haystack #9163's draft lock delivers a draft once, which is why the hosted server's claim is per process too. The confirmation key is per process: both rounds reach the same hey mcp.
A process stays on the sign-in it started with. Its auth manager is pinned to that sign-in's key, so the tools it listed match the grant its token carries, and a later hey auth login --mcp is refused rather than adopted until restart. A renewal that narrows the grant below sending refuses the send before the claim.
Help and docs. hey mcp --help, hey auth login/logout --help, docs/agents.md ("Its own sign-in", "Drafts first, sends confirmed"), docs/cli.md, the environment help topic (HEY_MCP_CLIENT_ID), and the README's MCP line. .surface gains --mcp on login and logout. skills/hey/SKILL.md (the CLI's draft, review, send path from #615) is unchanged: the CLI has no server-side confirmation, so its in-chat review stays.
Tests
make check is green (fmt-check, vet, golangci-lint, unit tests, sync-skills, tidy-check, surface, release lockstep).
| Asked for | Tests |
|---|---|
| login stores a separate credential | TestMCPLoginStoresACredentialApartFromTheCLIs, …InThePlaintextFallbackToo, TestAuthLoginMCPKeepsTheCLIsCredentialAndStatusShowsBoth |
hey mcp refuses with no MCP credential | TestMCPCommandRefusesWithoutItsOwnCredential (CLI signed in and HEY_TOKEN set; nothing reaches HEY), TestMCPManagerNeverUsesHEYToken |
| the scope response is honored | TestMCPLoginHonorsTheScopeHEYGranted, TestMCPLoginRefusesAScopeItDidntAskFor, TestMCPCommandServesWhatTheGrantAllows (with a client that can be asked, so hey_send's absence is the grant's doing), TestMCPRefreshNamesTheLocalClientAndKeepsTheScope |
send_draft needs a confirmed elicitation | TestSendDraftSendsOnlyWhatThePersonConfirmed (both eras), TestSendDraftWithoutApprovalSendsNothing |
| listed only where it can be asked | TestHeySendIsListedOnlyToClientsThatCanAsk (both eras; fails with the filter disabled), TestADraftSaveSaysHowThisClientSendsIt, TestSendDraftCantSendFromAHostWithoutElicitation (a cached list) |
| digest mismatch and replay refused | TestSendDraftRefusesADraftChangedAfterApproval, TestAnAttachmentReplacedWithTheSameNameAndSizeIsAChange, TestAReplayedConfirmationSendsNothing, TestTwoConfirmationsOfOneDraftSendItOnce, TestSendDraftSendsADraftOnce, TestASentDraftIsntAskedAboutAgain, TestAForgedOrExpiredConfirmationSendsNothing |
| no refusal for content; the prompt names it | TestNothingADraftHoldsRefusesIt (image, style, stylesheet, hidden text, non-blob and blob attachments), TestTheConfirmationShowsEveryImageAndLink, TestALongBodyIsShownInPartAndSaysHowMuch, TestThePromptsOwnLinesNameNoDraftText, TestAStylesheetsTextIsntShownAsTheBody |
Also: inert rendering, whole subject and address, never retried and stays claimed, HEY keeping the draft, a sign-in changed or narrowed under the server, attachments delivered as their identity, comments never sent, and an end-to-end run through hey mcp (TestMCPCommandSendsAConfirmedDraftOnce, TestMCPCommandNeverResendsADeliveryAfterA401).
Not verified against a live HEY
- Attachment sgid stability across two
GetMessageEditfetches. If it isn't stable, a draft with an attachment always fails as "changed", which fails closed. The same holds for an attachment that isn't a plain blob, which goes as it is and is hashed raw. - That
UpdateMessageaccepts the rebuilt, childless attachment figure as Trix's own submission. - The device flow against #9085 on staging, and loopback consent with send unchecked.
Still to do elsewhere
- haystack: turn on
OAUTH_SCOPED_DEVICE_FLOWper host (until then the login falls back to loopback). - hey-mcp-server: W5.1 (#14) plus a way for a public module to depend on it; then these files move there and
hey mcpimports them. Its stdio gateway refuses clearance writes other than screen-out (D5); hey-cli's still serves them, out of scope here. - hey-sdk: the behavior model still marks
UpdateMessageidempotent with retries (D2.4);send_draftgoes through hey-cli's no-retry client, as everyhey mcpmutation does.