You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Give hey mcp its own sign-in, and send a draft only on the person's confirmation - #598
Held for Jeremy as a draft: don't merge, don't arm auto-merge. It can't ship before the production HEY MCP (local) row exists, because its client id is still a TODO (below).
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 D2–D6 for stdio, under the roadmap's implementation requirements.
Without that sign-in, hey mcp exits with Run: hey auth login --mcp. That includes a person who is signed in to the CLI or has HEY_TOKEN set. This breaks existing hey mcp registrations until the person runs the sign-in once, so it goes in the release notes.
hey_send / send_draft delivers a draft after the person confirms that exact email in their MCP client. A client that can't show a confirmation gets the draft's link instead.
1. hey auth login --mcp
Separate storage. The credential lives in its own keychain item, hey / hey::mcp::<origin>, beside the CLI's hey::<origin>. With no keychain it gets its own entry in credentials.json. Its refreshes and revocations name the local client id, never the CLI's. HEY_TOKEN never stands in for it.
Device flow first. It sends POST /oauth/device_authorizations with client_id and scope=read write send, and no resource, as #9085's contract specifies. It falls back to loopback authorization with the same scope and S256 PKCE on:
invalid_request: OAUTH_SCOPED_DEVICE_FLOW is off;
unauthorized_client: #9041 without #9085;
a 404.
Other refusals, such as invalid_client, fail and don't fall back. The poll handles authorization_pending, slow_down (+5 s), access_denied, expired_token and invalid_scope, and sends install_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. If send was unchecked, hey mcp serves no hey_send. If write is missing, it serves reads only.
An omitted scope means exactly what was requested (RFC 6749 §5.1).
A grant naming anything not requested is refused and nothing is stored. That covers full, which would mean the configured id is the CLI's own row. An id equal to the CLI's is refused before HEY is contacted.
hey auth logout --mcp revokes the grant with DELETE /oauth/tokens, sending 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 status reports both credentials, in JSON under mcp and styled as an MCP: line. Neither form ever includes a token.
Client id. It resolves from HEY_MCP_CLIENT_ID, then from mcp_client_ids in the global config, keyed by origin, then from config.DefaultMCPClientID.
The default is "", a marked TODO, until Jeremy provisions production with bin/rails oauth:provision_local_mcp_client. Staging and dev then get the same id with CLIENT_ID=, so one constant serves every environment.
A repo's .hey/config.json can't set the id.
2. No fallback to the CLI credential
hey mcp never falls back, not even for reads. The reasons:
The CLI credential holds full.--read-only is hey mcp's own client-side rule, not HEY's. A read-only fallback still puts a full-scope token in the process the model drives. Then the server-enforced containment that justifies the local_mcp row (#9041's threat model) would rest on hey-cli's gateway filter staying correct. That filter is the long-tail code the row exists to contain.
A fallback would leave the old path working. People would never migrate, and the full token would stay the common case.
The cost is one sign-in. The refusal says exactly which one.
3. send_draft, written in hey-cli
Why it isn't imported.
hey-mcp-server's dispatcher has no send_draft. Its server package is still under internal/, and the move out is hey-mcp-server#14, which is open.
hey-cli's go.mod has no hey-mcp-server dependency.
So the code is in internal/mcpserver, in files the W5.1 move can lift as a unit (send.go, sendstate.go, sendbody.go, sendprompt.go, exposure.go).
How it's wired.
Its own tool.hey_send is a separate tool (D7) with destructiveHint and openWorldHint true. It is served only when all of these hold:
the credential holds read and send (expanded, so write counts as read);
the server isn't --read-only;
the threads domain is served.
Two rounds over MRTR, through go-sdk v1.8.0's input-requests support.
The first round fetches GetMessageEdit, applies the policy, and returns an elicitation. Its HMAC-signed request state carries the draft's fingerprint, the sign-in, the origin, a 5-minute expiry and a method marker.
The second round verifies that state and the person's accept with confirm: true. It then re-fetches the draft and recomputes the fingerprint. Any change fails with "the draft changed since you approved it". It re-runs the policy, then claims the draft and charges the caps in one locked update. Last, it calls SendDraft once, through a client that neither retries nor refreshes-and-resends after a 401. The token is refreshed just before the send instead. An answer with an id and no thread, where the draft's edit page is still served, is reported as not sent, as mail.ConfirmDelivered reads it. Every delivered field is written from the verified snapshot, and HEY replaces the whole recipient set when addressed is present (Entry::Addressed#address).
A 2026-07-28 client carries the rounds itself. An older client gets server-initiated elicitation/create from the SDK shim. Both are tested.
The fingerprint covers:
draft id, sender id and address;
sorted To, Cc and Bcc;
subject;
the attachments (sgid, name, size, type, caption);
a sha256 of the body as delivered, each attachment rebuilt from its identity, so signed URLs don't count;
mode now.
The prompt follows D2's layout. Every recipient field is shown, Bcc included, as "—" when empty. Recipients are shown as "Name" <address>. Controls, line separators and every invisible character (all Unicode format characters, which include bidi, zero-width and tag characters, plus variation selectors and private-use characters) are escaped as \u{…}. Header fields are single-line. Delivered ones (the subject, attachment names and captions) are shown whole, and only display names are capped. The body is indented, and the body shows at most 1,500 characters with a link to the rest. New recipients are marked (new: never emailed before).
Without elicitation (D3), send_draft returns a non-error sent: false result with the draft's edit link. A decline or cancel gets the same result. There is no "I confirm" parameter.
Single use and never retried. The claim key is origin#draft_id and nothing else. It 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, and in each case SendDraft is called once.
D4.
Caps: at most 20 recipients per message, and per sign-in at most 50 sends and 10 unique new addresses in a rolling 24 hours. A send is charged only for new addresses not already in the window.
"Known" means mail this identity sent to the address before the sign-in's GrantedAt. It is looked up through advanced search (refine[to]), and each match is verified locally against the entry's creator (one of the identity's addresses), its delivery time (active_at, not created_at, so a draft made before the sign-in and sent after it doesn't count) and its recipients. So a loose search can't make an address known. A failed lookup counts as new. Contact records never count.
The principal is the sign-in's local GrantKey, minted at login and stored with the credential.
D2's unseen-content and remote-reference rules.
To anyone new, or while exposed, the draft goes as the plain text the confirmation shows. The body sent is built from that text alone (lines escaped, joined with <br>), with trailing whitespace and invisible characters dropped. So no markup choice (hidden text, a list's type, bold against italic, a break against a paragraph) can carry something the person didn't read. Such a draft must fit the 1,500-character prompt whole and have no attachment. The plain/as-is decision is signed into the confirmation, and a change between rounds refuses the send. To known recipients on an unexposed sign-in, the draft goes as it is. This replaced a lossless-markup classifier whose every edge was a new hidden channel.
A body with something a mail client loads by itself is refused for every recipient. That covers img, iframe, link, style, svg and the like, src/srcset/background and similar attributes, and any style with an escape or a (, which Trix never writes.
An attachment is delivered as its identity alone. Each one is rebuilt from its sgid, filename, size, type and caption, with nothing inside it, which is what Action Text keeps of one anyway. The body sent is exactly the body hashed, so nothing in an attachment's wrapper (a preview URL, a caption's markup, an attribute) goes out or needs checking. An attachment with no sgid or filename, or with embedded content (Trix's filename-less embedded HTML bodies), is refused for every recipient. Unverified against a live HEY: that UpdateMessage accepts the rebuilt, childless figure as Trix's own submission.
The body is parsed with scripting off. noscript, template and the raw-text fallbacks are refused as loading markup.
A draft that is already scheduled is refused. send_at isn't implemented.
D6 (exposure).
The gateway serves HEY raw, and GetTopic's screened_in isn't in the hey-sdk 0.33.1 model. So readExposes classifies every served read by what it can return.
Box reads, identity, box groups and calendars don't expose, since postings exist only for screened-in threads.
Search, threads, messages, drafts, reply and forward prefills, contacts, the Screener and calendar recordings do. So does any write that answers with a body (reveal_contact returns the contact).
An unclassified read exposes. A test holds the table total over the served catalog.
The marker is written before the content is returned, and a read whose marker can't be written is refused. It's recorded whenever the sign-in can send, including from a --read-only or narrowed hey mcp, since another process on the same sign-in may send.
A process stays on the sign-in it started with. Its auth manager is pinned to that sign-in's key, so a later hey auth login --mcp is refused rather than adopted. A refresh that narrows the grant below sending refuses sends and exposing reads until the process restarts.
While a sign-in is exposed (10 minutes, restarted by each exposing read), drafts to anyone new are refused. The claim re-reads exposure under the lock, so a marker that lands between the prompt and the approval still refuses.
State on stdio (the roadmap's invariants row) is $XDG_STATE_HOME/hey-cli/mcp-send-state.json. It holds claims, send times, new-address membership and exposure.
It is read, checked and written under an exclusive flock on a separate mcp-send-state.lock that is never renamed, with a 5-second timeout that fails closed.
Each write goes to a temp file that is fsynced, renamed into place, and then the directory is fsynced. A failed fsync fails the claim, and nothing is sent.
The confirmation key, mcp-confirm.key, is generated once per installation under the same lock.
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 one MCP line, whose example no longer ran without the new sign-in. .surface gains --mcp on login and logout.
Tests
make check is green: fmt-check, vet, golangci-lint, the unit tests, sync-skills and tidy-check. The new and changed tests cover the brief's six cases and more:
Asked for
Tests
login stores a separate credential
TestMCPLoginStoresACredentialApartFromTheCLIs (two keychain items, the CLI's untouched), …InThePlaintextFallbackToo, TestAuthLoginMCPKeepsTheCLIsCredentialAndStatusShowsBoth (through the command, with status and logout)
hey mcp refuses with no MCP credential
TestMCPCommandRefusesWithoutItsOwnCredential (CLI signed in andHEY_TOKEN set; nothing reaches HEY), TestMCPManagerNeverUsesHEYToken
Also covered: caps (TestSendDraftCapsRecipientsSendsAndNewAddresses), known recipients (TestSendDraftFlagsANewRecipient, TestMailSentAfterTheSignInDoesntMakeARecipientKnown), exposure (TestAnUnscreenedReadRefusesSendsToNewRecipients, TestABoxReadDoesntExpose, …BetweenPromptAndApproval…, TestTheClaimRereadsExposure, TestAnExposedReadIsRefusedWhenItsMarkerCantBeWritten, TestEveryServedReadIsClassifiedForExposure), unseen content and remote references, inert rendering, durability (TestAFailedFsyncFailsTheSendClosed, TestEitherFailedFsyncFailsTheClaim, TestClaimsAndCapsSurviveARestart), caps charged in the claim itself (TestTheClaimEnforcesTheCaps), client id resolution (TestMCPClientIDResolution), never retried (TestAFailedSendIsNeverRetriedAndStaysClaimed), and an end-to-end run through hey mcp (TestMCPCommandSendsAConfirmedDraftOnce).
Revert proof: in the latest revert-proof comment: 45 of 45 pieces fail their tests when taken out.
Not verified against a live HEY
The known-recipient lookup depends on advanced search returning a match's entries with addressed, creator and created_at. If HEY's summaries leave addressed out, every recipient reads as new. That fails closed, but it's strict: no unseen content goes anywhere, and the 10-new-address cap applies to everyone.
Attachment sgid stability across two GetMessageEdit fetches. If the sgid isn't stable, a draft with an attachment always fails as "changed". That also fails closed.
The device flow against #9085 on staging, and the loopback consent with send unchecked under #9035.
What haystack and hey-mcp-server still need
haystack:
Provision HEY MCP (local) in production, then set config.DefaultMCPClientID, then staging and dev with CLIENT_ID=.
Turn on OAUTH_SCOPED_DEVICE_FLOW per host (until then the login falls back to loopback).
A send-history answer that doesn't depend on search summaries ("has this identity sent to X before T").
screened_in in the SDK model (GetTopic, haystack#9083), so the exposure table can read screening per response rather than per operation.
Its stdio gateway refuses clearance writes other than screen-out (D5); hey-cli's still serves them. That's out of scope here, and it doesn't open sends, because every Screener and thread read exposes regardless.
The scopes.md edits #9085 lists.
hey-sdk: the behavior model still marks UpdateMessage idempotent with retries (D2.4). send_draft goes through hey-cli's no-retry client, as every hey mcp mutation does.
…onfirmation
hey auth login --mcp signs hey mcp in as HEY MCP (local) (haystack#9041) on
read write send: by the scoped device flow where HEY serves it (#9085), and by
loopback otherwise. The credential is stored apart from the CLI's, refreshes
and revokes as the local client, reads the scope HEY granted, and HEY_TOKEN
never stands in for it. hey mcp serves on it alone and refuses without it.
hey_send's send_draft delivers a draft after the person confirms that exact
email through MCP elicitation (sending-safety D2-D6): digest-bound, single
use, never retried, with the recipient caps and the exposure window, and its
state in a locked, fsynced file. A host without elicitation gets the draft's
link instead.
Revert proof on 3f8aa2f. I took out each piece, ran the tests that hold it, and restored the file from HEAD. All 16 of the 16 pieces fail their tests when they are taken out, and make check is green with them in.
This PR gives hey mcp a separate, least-privilege sign-in and adds draft sending through explicit confirmation in the MCP client. Production rollout remains gated on provisioning the local MCP client ID.
Changes:
Adds isolated MCP credentials, device-flow login with browser fallback, revocation, and status reporting.
Introduces hey_send with draft-bound confirmation, persistent send claims, recipient limits, and exposure tracking.
Updates command help, documentation, and tests for the new authentication and sending behavior.
[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.
File
Description
README.md
Updates MCP setup and sending guidance.
internal/mcpserver/server.go
Registers sending and exposure tracking.
internal/mcpserver/sendstate.go
Persists claims, limits, exposure, and confirmation keys.
internal/mcpserver/sendprompt.go
Formats send confirmations and escapes fields.
internal/mcpserver/sendbody.go
Analyzes bodies, attachments, and remote references.
internal/mcpserver/send.go
Implements confirmed draft delivery and policy checks.
internal/mcpserver/send_test.go
Tests confirmation, limits, exposure, and durability.
internal/mcpserver/exposure.go
Classifies reads for exposure tracking.
internal/mcpserver/drafts.go
Updates draft-only messaging to reference confirmed sending.
internal/mcpserver/domains.go
Updates the threads tool description.
internal/mcpserver/dispatch.go
Records exposure before returning read results.
internal/config/mcp.go
Resolves the MCP client ID from trusted configuration.
internal/config/mcp_test.go
Tests client ID resolution and validation.
internal/cmd/sdk.go
Removes the superseded shared-client helper.
internal/cmd/root.go
Initializes MCP authentication separately.
internal/cmd/mcp.go
Uses MCP credentials and configures sending.
internal/cmd/mcp_test.go
Tests credential isolation, tool availability, and sending.
internal/cmd/mcp_auth.go
Implements MCP login, logout, and status handling.
- Record exposure whenever the sign-in can send, including from a read-only
or narrowed hey mcp, since another process on the same sign-in may send.
- Refuse sends and exposing reads once the stored sign-in no longer matches
the one the process started on.
- Measure the body against the cap as the prompt shows it, escapes included.
- Refuse attachments that aren't plain blobs: no sgid or filename, embedded
content, stray attributes or markup inside.
- Show every address in full in the confirmation.
- Keep a new address in the rolling window for 24 hours after its last send.
- Report can_send with the same predicate hey mcp serves hey_send on.
Revert proof on 13c80ff, after round one's fixes. I took out each piece, ran the tests that hold it, and restored the file from HEAD. All 23 of the 23 pieces fail their tests when they are taken out, and make check is green with them in.
A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review
Once complete, Codex will leave suggestions, or a comment if no findings are found.
- Report a delivery HEY kept as a draft as not sent (the redirect to the
edit page that mail.ConfirmDelivered reads for hey draft send).
- Count a recipient known by when the mail went out, not when the draft was
created.
- Treat an escaped or functional style as a loading reference.
- Accept an attachment's preview image only from HEY's own host, with no
attributes but its size and alt text.
- Refuse a send when a refresh narrowed the grant below sending.
- Sanitize the device link and code before they reach the terminal, and open
only a web link.
Revert proof on 47dc338, after round two's fixes. I took out each piece, ran the tests that hold it, and restored the file from HEAD. All 28 of the 28 pieces fail their tests when they are taken out, and make check is green with them in.
Revert proof on b0d07de. I took out each piece, ran the tests that hold it, and restored the file from HEAD. All 36 of the 36 pieces fail their tests when they are taken out, and make check is green with them in. The three whitespace and escape-measurement rows are gone because the classifier they guarded is gone. Three rows for the plain-text path replace them.
…e trimming
- Check the sign-in again after the second round's review, renewing the token
there, so a renewal that drops send refuses before the draft is claimed. The
send itself no longer renews.
- Remove invisible characters from plain text before trimming trailing
whitespace, so none can shield spaces from the trim.
- Name no draft text, such as an attachment's filename, in a refusal.
Revert proof on 9f1e95d. I took out each piece, ran the tests that hold it, and restored the file from HEAD. All 39 of the 39 pieces fail their tests when they are taken out, and make check is green with them in.
A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review
Once complete, Codex will leave suggestions, or a comment if no findings are found.
Propagate token renewal errors before claiming drafts
internal/cmd/mcp.go:160
If the token enters its renewal window after the final review and renewal fails transiently, discarding the AccessToken error lets the stored grant pass checkGrant. The draft is then claimed and its caps charged even if request authentication fails again before issuing a delivery PUT, blocking the draft for 24 hours without a delivery attempt. Propagate the renewal error here so the send stops before claiming.
… drop comments
- A token that can't be renewed right before the claim refuses the send, so a
draft that certainly wasn't sent isn't claimed.
- A write HEY applied is acknowledged as applied, with its answer withheld,
when the exposure marker can't be written, so it isn't retried.
- The plain body keeps the spacing the confirmation showed (non-breaking
spaces for leading and repeated spaces, and tabs).
- HTML comments are never sent; a conditional comment is markup Outlook
renders.
Revert proof on 72f3ccc. I took out each piece, ran the tests that hold it, and restored the file from HEAD. All 42 of the 42 pieces fail their tests when they are taken out, and make check is green with them in.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Production provisioning and live verification of security-sensitive authentication and attachment delivery remain pending, alongside an unresolved mutation-acknowledgement issue.
A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review
Once complete, Codex will leave suggestions, or a comment if no findings are found.
…ts before the marker
- On the plain path, body and subject are NFKC-normalized after invisible
characters go, so a no-break space and a decomposed letter go as what they
look like. The subject sent is the one the confirmation showed.
- A write whose success is one exact status (rename_topic) is checked before
the exposure marker, so a redirect is never reported as applied.
Revert proof on 0c62b58. I took out each piece, ran the tests that hold it, and restored the file from HEAD. All 45 of the 45 pieces fail their tests when they are taken out, and make check is green with them in.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Security-sensitive authentication and delivery changes need Jeremy’s final review, production client provisioning, and the documented live HEY validation before the draft hold is lifted.
Converged on 0c62b58, held as a draft for Jeremy. There is no auto-merge.
Reviews. Codex reports no major issues on 0c62b58. Copilot left no inline findings on it; its overview asks for Jeremy's review, production provisioning and live validation, which are the human steps below.
Threads. All 45 are resolved, and none needs a human decision beyond the ones listed under "Waiting on Jeremy".
Revert proof: all 45 of the 45 pieces fail their tests when taken out (latest comment).
Findings per round: 11, 9, 7, 3, 2, 2, 1, 3, 4, 2, then 0.
Two of those rounds turned out to be the same weakness found again, so I replaced the mechanism instead of adding another check:
The attachment wrapper. It was checked as an opaque subtree, and four rounds each found a new way through. Attachments are now rebuilt from their identity, and nothing in the wrapper is sent.
The lossless-markup classifier. It couldn't be made injective. Sends to someone new, or while exposed, now go as exactly the plain text the confirmation showed.
Declined, with the reasoning in each thread:
The production client id is empty. That's why the PR is held: the id exists only after Jeremy provisions production (haystack#9041).
Lookalike Unicode in a known recipient's send. The plain path (new recipients, or exposed) is fixed with NFKC. Sends to known recipients on an unexposed sign-in, and cross-script confusables, are declined: they sit inside D2's accepted residual for known recipients.
Provision HEY MCP (local) in production, then set config.DefaultMCPClientID.
Validate against a live HEY (listed under "Not verified against a live HEY" in the PR body):
the scoped device flow;
the rebuilt attachment figure;
the known-recipient search;
send-unchecked consent.
Decide on merging and the release note: existing hey mcp setups need hey auth login --mcp once.
This branch has not been deployed
No deployments
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Held for Jeremy as a draft: don't merge, don't arm auto-merge. It can't ship before the production
HEY MCP (local)row exists, because its client id is still a TODO (below).hey mcpgets 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 (thelocal_mcprow) and #9085 (the scoped device flow). It implements sending-safety.md D2–D6 for stdio, under the roadmap's implementation requirements.What changes for a person
hey auth login --mcp # once: "HEY MCP (local)", read write send claude mcp add hey -- hey mcphey mcpexits withRun: hey auth login --mcp. That includes a person who is signed in to the CLI or hasHEY_TOKENset. This breaks existinghey mcpregistrations until the person runs the sign-in once, so it goes in the release notes.hey_send/send_draftdelivers a draft after the person confirms that exact email in their MCP client. A client that can't show a confirmation gets the draft's link instead.1.
hey auth login --mcphey/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.POST /oauth/device_authorizationswithclient_idandscope=read write send, and noresource, as #9085's contract specifies. It falls back to loopback authorization with the same scope and S256 PKCE on:invalid_request:OAUTH_SCOPED_DEVICE_FLOWis off;unauthorized_client: #9041 without #9085;Other refusals, such as
invalid_client, fail and don't fall back. 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.scope.hey mcpserves nohey_send. If write is missing, it serves reads only.scopemeans exactly what was requested (RFC 6749 §5.1).full, which would mean the configured id is the CLI's own row. An id equal to the CLI's is refused before HEY is contacted.hey auth logout --mcprevokes the grant withDELETE /oauth/tokens, sending 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 ever includes a token.HEY_MCP_CLIENT_ID, then frommcp_client_idsin the global config, keyed by origin, then fromconfig.DefaultMCPClientID."", a marked TODO, until Jeremy provisions production withbin/rails oauth:provision_local_mcp_client. Staging and dev then get the same id withCLIENT_ID=, so one constant serves every environment..hey/config.jsoncan't set the id.2. No fallback to the CLI credential
hey mcpnever falls back, not even for reads. The reasons:full.--read-onlyishey mcp's own client-side rule, not HEY's. A read-only fallback still puts a full-scope token in the process the model drives. Then the server-enforced containment that justifies thelocal_mcprow (#9041's threat model) would rest on hey-cli's gateway filter staying correct. That filter is the long-tail code the row exists to contain.3.
send_draft, written in hey-cliWhy it isn't imported.
send_draft. Itsserverpackage is still underinternal/, and the move out is hey-mcp-server#14, which is open.go.modhas no hey-mcp-server dependency.So the code is in
internal/mcpserver, in files the W5.1 move can lift as a unit (send.go,sendstate.go,sendbody.go,sendprompt.go,exposure.go).How it's wired.
hey_sendis a separate tool (D7) withdestructiveHintandopenWorldHinttrue. It is served only when all of these hold:readandsend(expanded, sowritecounts asread);--read-only;GetMessageEdit, applies the policy, and returns an elicitation. Its HMAC-signed request state carries the draft's fingerprint, the sign-in, the origin, a 5-minute expiry and a method marker.acceptwithconfirm: true. It then re-fetches the draft and recomputes the fingerprint. Any change fails with "the draft changed since you approved it". It re-runs the policy, then claims the draft and charges the caps in one locked update. Last, it callsSendDraftonce, through a client that neither retries nor refreshes-and-resends after a 401. The token is refreshed just before the send 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 (Entry::Addressed#address).elicitation/createfrom the SDK shim. Both are tested."Name" <address>. Controls, line separators and every invisible character (all Unicode format characters, which include bidi, zero-width and tag characters, plus variation selectors and private-use characters) are escaped as\u{…}. Header fields are single-line. Delivered ones (the subject, attachment names and captions) are shown whole, and only display names are capped. The body is indented, and the body shows at most 1,500 characters with a link to the rest. New recipients are marked(new: never emailed before).send_draftreturns a non-errorsent: falseresult with the draft's edit link. A decline or cancel gets the same result. There is no "I confirm" parameter.Single use and never retried. The claim key is
origin#draft_idand nothing else. It 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, and in each caseSendDraftis called once.D4.
GrantedAt. It is looked up through advanced search (refine[to]), and each match is verified locally against the entry's creator (one of the identity's addresses), its delivery time (active_at, notcreated_at, so a draft made before the sign-in and sent after it doesn't count) and its recipients. So a loose search can't make an address known. A failed lookup counts as new. Contact records never count.GrantKey, minted at login and stored with the credential.D2's unseen-content and remote-reference rules.
<br>), with trailing whitespace and invisible characters dropped. So no markup choice (hidden text, a list's type, bold against italic, a break against a paragraph) can carry something the person didn't read. Such a draft must fit the 1,500-character prompt whole and have no attachment. The plain/as-is decision is signed into the confirmation, and a change between rounds refuses the send. To known recipients on an unexposed sign-in, the draft goes as it is. This replaced a lossless-markup classifier whose every edge was a new hidden channel.img,iframe,link,style,svgand the like,src/srcset/backgroundand similar attributes, and anystylewith an escape or a(, which Trix never writes.content(Trix's filename-less embedded HTML bodies), is refused for every recipient. Unverified against a live HEY: thatUpdateMessageaccepts the rebuilt, childless figure as Trix's own submission.noscript,templateand the raw-text fallbacks are refused as loading markup.send_atisn't implemented.D6 (exposure).
screened_inisn't in the hey-sdk 0.33.1 model. SoreadExposesclassifies every served read by what it can return.reveal_contactreturns the contact).--read-onlyor narrowedhey mcp, since another process on the same sign-in may send.hey auth login --mcpis refused rather than adopted. A refresh that narrows the grant below sending refuses sends and exposing reads until the process restarts.State on stdio (the roadmap's invariants row) is
$XDG_STATE_HOME/hey-cli/mcp-send-state.json. It holds claims, send times, new-address membership and exposure.flockon a separatemcp-send-state.lockthat is never renamed, with a 5-second timeout that fails closed.mcp-confirm.key, is generated once per installation under the same lock.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 one MCP line, whose example no longer ran without the new sign-in..surfacegains--mcpon login and logout.Tests
make checkis green: fmt-check, vet, golangci-lint, the unit tests, sync-skills and tidy-check. The new and changed tests cover the brief's six cases and more:TestMCPLoginStoresACredentialApartFromTheCLIs(two keychain items, the CLI's untouched),…InThePlaintextFallbackToo,TestAuthLoginMCPKeepsTheCLIsCredentialAndStatusShowsBoth(through the command, with status and logout)hey mcprefuses with no MCP credentialTestMCPCommandRefusesWithoutItsOwnCredential(CLI signed in andHEY_TOKENset; nothing reaches HEY),TestMCPManagerNeverUsesHEYTokenTestMCPLoginHonorsTheScopeHEYGranted(all, send unchecked, omitted),TestMCPLoginRefusesAScopeItDidntAskFor(full),TestMCPCommandServesWhatTheGrantAllows,TestMCPRefreshNamesTheLocalClientAndKeepsTheScopesend_draftneeds a confirmed elicitationTestSendDraftSendsOnlyWhatThePersonConfirmed(both protocol eras),TestSendDraftWithoutApprovalSendsNothing(decline, cancel, unticked)TestSendDraftRefusesADraftChangedAfterApproval(Bcc added, recipient swapped, subject, body, sender, schedule),TestAnAttachmentReplacedWithTheSameNameAndSizeIsAChange,TestAReplayedConfirmationSendsNothing,TestTwoConfirmationsOfOneDraftSendItOnce,TestSendDraftSendsADraftOnce,TestAForgedOrExpiredConfirmationSendsNothingTestSendDraftCantSendFromAHostWithoutElicitation(both eras)Also covered: caps (
TestSendDraftCapsRecipientsSendsAndNewAddresses), known recipients (TestSendDraftFlagsANewRecipient,TestMailSentAfterTheSignInDoesntMakeARecipientKnown), exposure (TestAnUnscreenedReadRefusesSendsToNewRecipients,TestABoxReadDoesntExpose,…BetweenPromptAndApproval…,TestTheClaimRereadsExposure,TestAnExposedReadIsRefusedWhenItsMarkerCantBeWritten,TestEveryServedReadIsClassifiedForExposure), unseen content and remote references, inert rendering, durability (TestAFailedFsyncFailsTheSendClosed,TestEitherFailedFsyncFailsTheClaim,TestClaimsAndCapsSurviveARestart), caps charged in the claim itself (TestTheClaimEnforcesTheCaps), client id resolution (TestMCPClientIDResolution), never retried (TestAFailedSendIsNeverRetriedAndStaysClaimed), and an end-to-end run throughhey mcp(TestMCPCommandSendsAConfirmedDraftOnce).Revert proof: in the latest revert-proof comment: 45 of 45 pieces fail their tests when taken out.
Not verified against a live HEY
addressed,creatorandcreated_at. If HEY's summaries leaveaddressedout, every recipient reads as new. That fails closed, but it's strict: no unseen content goes anywhere, and the 10-new-address cap applies to everyone.GetMessageEditfetches. If the sgid isn't stable, a draft with an attachment always fails as "changed". That also fails closed.What haystack and hey-mcp-server still need
HEY MCP (local)in production, then setconfig.DefaultMCPClientID, then staging and dev withCLIENT_ID=.OAUTH_SCOPED_DEVICE_FLOWper host (until then the login falls back to loopback).screened_inin the SDK model (GetTopic, haystack#9083), so the exposure table can read screening per response rather than per operation.hey mcpimports them.UpdateMessageidempotent with retries (D2.4).send_draftgoes through hey-cli's no-retry client, as everyhey mcpmutation does.