diff --git a/.surface b/.surface index 2a1da24b5..0a5f89af8 100644 --- a/.surface +++ b/.surface @@ -26,9 +26,11 @@ hey attachment save --output hey auth hey auth login hey auth login --cookie +hey auth login --mcp hey auth login --no-browser hey auth login --token hey auth logout +hey auth logout --mcp hey auth refresh hey auth status hey auth token @@ -281,9 +283,11 @@ hey label view --limit hey label view --page hey login hey login --cookie +hey login --mcp hey login --no-browser hey login --token hey logout +hey logout --mcp hey mcp hey mcp --domains hey mcp --read-only diff --git a/README.md b/README.md index ed8ec70e9..a2ac16dd6 100644 --- a/README.md +++ b/README.md @@ -89,10 +89,10 @@ The setup wizard connects the agents it finds. To do it yourself: ```bash hey setup claude # skill + the hey@37signals plugin for Claude Code hey setup codex # skill for Codex -claude mcp add hey -- hey mcp # HEY as MCP tools, on your signed-in account +hey auth login --mcp && claude mcp add hey -- hey mcp # HEY as MCP tools, on hey mcp's own sign-in ``` -`hey mcp` saves drafts and never sends mail. You send a draft from HEY or with `hey draft send `. +`hey mcp` saves drafts, and sends one only when you confirm that email in your MCP client. ## Docs diff --git a/docs/agents.md b/docs/agents.md index ccd7f1423..0563a352b 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -62,16 +62,39 @@ Codex keeps in its log. ## MCP server `hey mcp` runs an MCP (Model Context Protocol) server on stdin/stdout, serving HEY -boxes, search, threads, contacts, todos, calendars, and your identity as tools -backed by your signed-in account — the same keychain-stored credentials every other command uses. -Register it with any MCP client as a stdio server: +boxes, search, threads, contacts, todos, calendars, and your identity as tools. +Sign it in once, then register it with any MCP client as a stdio server: ```bash +hey auth login --mcp # hey mcp's own sign-in (read, write and send) claude mcp add hey -- hey mcp # Claude Code hey mcp --read-only # serve only read-only actions hey mcp --domains boxes,search # narrow the served tool surface ``` +### Its own sign-in + +`hey mcp` doesn't use the CLI's credential. `hey auth login --mcp` signs it in as its own +app, "HEY MCP (local)" in HEY's Connected Apps, asking to read, write and send but nothing +more: a model driving `hey mcp` can't reach your settings, forwarding, trash or HEY World, +which the CLI's own sign-in can. The credential is kept apart from the CLI's, in its own +keychain item (or its own entry in `credentials.json` when there is no keychain), and it +refreshes as that app. `HEY_TOKEN` never stands in for it. Without it, `hey mcp` exits and +says to run `hey auth login --mcp`; it doesn't fall back to the CLI's credential, even +read-only, because that one holds full access and `--read-only` is hey mcp's own rule, +not HEY's. + +The sign-in uses a device code where HEY offers one (it prints a link and a code to check), +and the browser otherwise. If you uncheck sending on HEY's page, `hey mcp` saves drafts +but serves no way to send them; sign in again to allow it. Without write it serves only +reads. `hey auth status` shows both sign-ins, and `hey auth logout --mcp` revokes hey mcp's +at HEY and removes it, leaving the CLI signed in. + +The app's client id comes from `HEY_MCP_CLIENT_ID`, or from `mcp_client_ids` in +`~/.config/hey-cli/config.json`, keyed by HEY origin +(`{"mcp_client_ids": {"https://app.hey.com": "..."}}`). A repository's `.hey/config.json` +can't set it. + Each domain is one gateway tool (`hey_boxes`, `hey_search`, `hey_threads`, `hey_contacts`, `hey_todos`, `hey_calendar`, `hey_identity`) dispatching actions derived from the HEY SDK's API model; call an action named `describe` for any @@ -85,10 +108,10 @@ the caller rather than risking a duplicate delivery, so retry a failed write yourself once you know it did not land. Logs go to stderr — stdout carries the MCP wire protocol. -### Drafts, never sends +### Drafts first, sends confirmed -`hey mcp` never delivers mail. `create_message`, `create_reply` and `update_message`, -the three actions that can send on HEY's API, always save a draft: the entry's status is +`create_message`, `create_reply` and `update_message`, the three actions that can send +on HEY's API, always save a draft: the entry's status is forced to `drafted`, and the entry takes only its recipients (`entry.addressed`). A call that asks to send (any other `entry.status`), schedule (`entry.scheduled_delivery` and its date and hour) or triage the thread in the same request (`mark_done`, `pop`, @@ -100,8 +123,42 @@ mail may have been sent, so check HEY before trying again. A save that fails wit answer from HEY (a dropped connection, a timeout, a 5xx) may still have been saved, and its error says to check Drafts before retrying. -There is no send action yet. A person reviews the draft and sends it from HEY, or with -`hey draft send `. Earlier releases delivered mail when a call omitted -`entry.status`. An MCP client that relied on that now gets a draft. `describe` for these -actions serves the draft-only schema, and the `hey_threads` tool description says the same. -`--read-only` still drops every write action, drafts included. +Delivery is one action in a tool of its own: `hey_send`'s `send_draft`, with the draft's +id. Each call asks you to confirm the email in your MCP client (an MCP elicitation), +showing the sending address, To, Cc and Bcc (a dash when empty), the subject, attachments +and the body's first 1,500 characters, with every control and invisible character +escaped so a display name can't forge a line. Recipients you hadn't emailed before +`hey mcp` was signed in are marked new. What you approve is what goes: the draft is +fetched again after you answer, and if its recipients, sender, subject, body or +attachments changed it isn't sent. Each draft is sent through `hey mcp` once; another +attempt in the next 24 hours, even after a failure, is refused, and the send is never +retried, so an unclear outcome says to check Sent. + +To someone you hadn't emailed before, or within ten minutes of a read that may have +returned an unscreened sender's mail, the draft goes as the plain text the confirmation +shows: its formatting isn't sent, and invisible characters and trailing spaces are +dropped, so what goes is exactly what you read. To people you've emailed before, it +goes as it is. `send_draft` refuses, pointing at the +draft in HEY: + +- any draft, from a client that can't show a confirmation (it answers with the draft's + link, and saying yes in the chat doesn't send it); +- a draft that is already scheduled, or has no recipients, or more than 20; +- a draft with a reference a mail client loads by itself, such as a remote image; +- a draft with an attachment, or more than 1,500 characters, when it goes to someone + new or within the ten minutes below (otherwise such a draft goes, and the + confirmation says how much it can't show); +- a draft to someone new within ten minutes of a read that may have returned an + unscreened sender's mail (search, threads, messages, drafts, contacts, the Screener, + calendar recordings; box reads don't count); +- past 50 sends, or 10 new addresses, in a day on one sign-in. + +That state (claims, the daily counts and the ten-minute marker) lives in +`~/.local/state/hey-cli/mcp-send-state.json`, under a file lock and fsynced, so two +`hey mcp` processes can't both send one draft. `send_draft` is served only when the +sign-in was granted sending, and never under `--read-only`. + +Earlier releases delivered mail when a call omitted `entry.status`, and then saved +drafts only. `describe` for the draft actions serves the draft-only schema, and the +`hey_threads` tool description says the same. A person can still send any draft from HEY +or with `hey draft send `. diff --git a/docs/cli.md b/docs/cli.md index 845c102e4..c1a834780 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -61,6 +61,16 @@ hey auth logout # clear credentials `hey login` and `hey logout` are top-level shortcuts for `hey auth login` and `hey auth logout`. +`hey mcp` has a sign-in of its own, kept apart from the CLI's and limited to reading, +writing and sending (see [agents.md](agents.md#its-own-sign-in)): + +```bash +hey auth login --mcp # sign hey mcp in +hey auth logout --mcp # revoke hey mcp's sign-in and remove it +``` + +`hey auth status` reports both. + ### Linked accounts One HEY login exposes every mail account linked to that identity. List the available diff --git a/go.mod b/go.mod index 52f669fdc..41026939f 100644 --- a/go.mod +++ b/go.mod @@ -25,10 +25,11 @@ require ( github.com/yuin/goldmark v1.8.6 github.com/zalando/go-keyring v0.2.8 golang.org/x/mod v0.42.0 - golang.org/x/net v0.61.0 + golang.org/x/net v0.62.0 golang.org/x/sync v0.24.0 golang.org/x/sys v0.49.0 golang.org/x/term v0.47.0 + golang.org/x/text v0.43.0 ) require ( @@ -121,7 +122,6 @@ require ( go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/crypto v0.58.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/text v0.43.0 // indirect golang.org/x/time v0.15.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect diff --git a/go.sum b/go.sum index a7aa903f3..81695e29e 100644 --- a/go.sum +++ b/go.sum @@ -429,8 +429,8 @@ golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= -golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= -golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= +golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 6c377fbcc..e243cb61c 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -28,7 +28,27 @@ const callbackListenAddr = "127.0.0.1:0" // Manager handles OAuth authentication. type Manager struct { - baseURL string + baseURL string + // storeKey is where the credential lives in the store: the origin for the CLI's + // own, "mcp::" and the origin for hey mcp's (mcp.go). + storeKey string + // clientID is the OAuth client this manager signs in and refreshes as. + clientID string + // scope is what a sign-in asks for. Empty asks for none, which HEY grants the + // CLI's own row as full. + scope string + // honorEnvToken lets HEY_TOKEN stand in for the stored credential. Only the CLI's + // manager honors it: the variable holds whatever token a person exported, usually + // a full one, and hey mcp must never end up holding that. + honorEnvToken bool + // wait sleeps between device-flow polls; a seam for tests. + wait func(context.Context, time.Duration) error + // pinnedGrant, when set, is the only sign-in this manager authenticates with: a + // credential stored under another grant key (a later `hey auth login --mcp`) is + // refused rather than adopted, so a running hey mcp never moves onto a sign-in it + // didn't start with. Guarded by mu. + pinnedGrant string + store *Store httpClient *http.Client callbackWait callbackWaiter @@ -63,11 +83,28 @@ const defaultRefreshHold = 15 * time.Minute // NewManager creates a new auth manager. func NewManager(baseURL string, httpClient *http.Client, configDir string) *Manager { listenConfig := &net.ListenConfig{} + origin := normalizeBaseURL(baseURL) return &Manager{ - baseURL: normalizeBaseURL(baseURL), - store: NewStore(configDir), - httpClient: httpClient, - listen: listenConfig.Listen, + baseURL: origin, + storeKey: origin, + clientID: oauthClientID, + honorEnvToken: true, + wait: sleepContext, + store: NewStore(configDir), + httpClient: httpClient, + listen: listenConfig.Listen, + } +} + +// sleepContext waits d, or until ctx is done. +func sleepContext(ctx context.Context, d time.Duration) error { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil } } @@ -85,9 +122,9 @@ func cloneCredentials(creds *Credentials) *Credentials { } // AccessToken returns a valid access token, refreshing if needed. -// If HEY_TOKEN env var is set, it's used directly. +// If HEY_TOKEN env var is set, the CLI's manager uses it directly. func (m *Manager) AccessToken(ctx context.Context) (string, error) { - if token := os.Getenv("HEY_TOKEN"); token != "" { + if token := m.envToken(); token != "" { return token, nil } @@ -135,8 +172,17 @@ func (m *Manager) AuthenticateRequestFromStore(ctx context.Context, req *http.Re return m.authenticateRequest(ctx, req, true) } +// envToken is HEY_TOKEN, for a manager that honors it. A nil manager is the CLI's, as +// it always was: HEY_TOKEN alone authenticates. +func (m *Manager) envToken() string { + if m != nil && !m.honorEnvToken { + return "" + } + return os.Getenv("HEY_TOKEN") +} + func (m *Manager) authenticateRequest(ctx context.Context, req *http.Request, reload bool) error { - if token := os.Getenv("HEY_TOKEN"); token != "" { + if token := m.envToken(); token != "" { req.Header.Set("Authorization", "Bearer "+token) return nil } @@ -182,7 +228,7 @@ func (m *Manager) authenticateRequest(ctx context.Context, req *http.Request, re // credential is a known signed-out state; a storage failure leaves the state // unknown and is returned to the caller. func (m *Manager) AuthenticationStatus() (bool, error) { - if os.Getenv("HEY_TOKEN") != "" { + if m.envToken() != "" { return true, nil } @@ -255,7 +301,10 @@ func (m *Manager) Login(ctx context.Context, opts LoginOptions) error { return fmt.Errorf("invalid auth endpoint: %w", err) } q := u.Query() - q.Set("client_id", oauthClientID) + q.Set("client_id", m.clientID) + if m.scope != "" { + q.Set("scope", m.scope) + } q.Set("grant_type", "authorization_code") q.Set("redirect_uri", redirectURI) q.Set("state", state) @@ -276,11 +325,22 @@ func (m *Manager) Login(ctx context.Context, opts LoginOptions) error { } // Exchange code for tokens - token, err := exchangeCode(ctx, m.httpClient, tokenEndpoint, code, redirectURI, oauthClientID, codeVerifier, installID) + token, err := exchangeCode(ctx, m.httpClient, tokenEndpoint, code, redirectURI, m.clientID, codeVerifier, installID) if err != nil { return fmt.Errorf("token exchange failed: %w", err) } + creds, err := m.signedInCredentials(token, tokenEndpoint, installID) + if err != nil { + return err + } + return m.saveCredentials(creds) +} + +// signedInCredentials is what a completed sign-in stores. A scoped sign-in (hey mcp's) +// also records the scope HEY granted, the client its refreshes must name, and when and +// under which local key the grant was made (mcp.go). +func (m *Manager) signedInCredentials(token *OAuthToken, tokenEndpoint, installID string) (*Credentials, error) { creds := &Credentials{ AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, @@ -291,8 +351,19 @@ func (m *Manager) Login(ctx context.Context, opts LoginOptions) error { if !token.ExpiresAt.IsZero() { creds.ExpiresAt = token.ExpiresAt.Unix() } + if m.scope == "" { + return creds, nil + } - return m.saveCredentials(creds) + granted, err := grantedScope(m.scope, token.Scope) + if err != nil { + return nil, err + } + creds.Scope = granted + creds.ClientID = m.clientID + creds.GrantKey = generateState() + creds.GrantedAt = time.Now().Unix() + return creds, nil } // LoginWithToken stores a pre-provided bearer token. @@ -328,7 +399,7 @@ func (m *Manager) Logout() error { m.mu.Lock() defer m.mu.Unlock() - if err := m.store.Delete(m.baseURL); err != nil { + if err := m.store.Delete(m.storeKey); err != nil { return err } m.cachedCredentials = nil @@ -355,7 +426,10 @@ func (m *Manager) loadCredentialsLocked() (*Credentials, error) { } func (m *Manager) reloadCredentialsLocked() (*Credentials, error) { - creds, err := m.store.Load(m.baseURL) + creds, err := m.store.Load(m.storeKey) + if err == nil { + err = m.checkPinnedGrant(creds) + } if err != nil { m.cachedCredentials = nil return nil, err @@ -368,7 +442,7 @@ func (m *Manager) saveCredentials(creds *Credentials) error { m.mu.Lock() defer m.mu.Unlock() - if err := m.store.Save(m.baseURL, creds); err != nil { + if err := m.store.Save(m.storeKey, creds); err != nil { return err } m.cachedCredentials = cloneCredentials(creds) @@ -387,7 +461,7 @@ func (m *Manager) refreshLocked(ctx context.Context, creds *Credentials) error { } defer unlock() - stored, loadErr := m.store.load(m.baseURL) + stored, loadErr := m.store.load(m.storeKey) if errors.Is(loadErr, ErrCredentialsNotFound) { // Another process deleted the credential while we waited for the lock. // Nothing stale may survive a forced read that proves it is gone. @@ -397,6 +471,10 @@ func (m *Manager) refreshLocked(ctx context.Context, creds *Credentials) error { if loadErr != nil { return credentialLoadError(loadErr) } + if pinErr := m.checkPinnedGrant(stored); pinErr != nil { + m.cachedCredentials = nil + return pinErr + } m.cachedCredentials = cloneCredentials(stored) changed := stored.AccessToken != creds.AccessToken || stored.SessionCookie != creds.SessionCookie if changed && (stored.AccessToken != "" || stored.SessionCookie != "") { @@ -441,7 +519,14 @@ func (m *Manager) refreshLocked(ctx context.Context, creds *Credentials) error { return err } - token, err := refreshOAuthToken(ctx, m.httpClient, tokenEndpoint, creds.RefreshToken, oauthClientID, installID) + // A refresh names the client the tokens were issued to. hey mcp's are the local + // MCP client's, and HEY refuses a refresh token presented by another client. + clientID := creds.ClientID + if clientID == "" { + clientID = m.clientID + } + + token, err := refreshOAuthToken(ctx, m.httpClient, tokenEndpoint, creds.RefreshToken, clientID, installID) if err != nil { return m.accountForRefreshFailure(err, creds.RefreshToken) } @@ -451,6 +536,15 @@ func (m *Manager) refreshLocked(ctx context.Context, creds *Credentials) error { if token.AccessToken == "" { return fmt.Errorf("token refresh returned no access token") } + if creds.Scope != "" { + // A refresh keeps the grant's scope. Take HEY's word when it narrows it, and + // refuse an answer that names anything the grant never held. + refreshed, scopeErr := grantedScope(creds.Scope, token.Scope) + if scopeErr != nil { + return scopeErr + } + creds.Scope = refreshed + } creds.AccessToken = token.AccessToken if token.RefreshToken != "" { @@ -462,7 +556,7 @@ func (m *Manager) refreshLocked(ctx context.Context, creds *Credentials) error { creds.ExpiresAt = token.ExpiresAt.Unix() } - if err := m.store.save(m.baseURL, creds); err != nil { + if err := m.store.save(m.storeKey, creds); err != nil { return err } m.cachedCredentials = cloneCredentials(creds) @@ -520,7 +614,7 @@ func (m *Manager) accountForRefreshFailure(err error, sentRefreshToken string) e // Forget it under the lock that already spans this load-refresh-save, so the // next command asks for a login instead of re-sending it. - if delErr := m.store.delete(m.baseURL); delErr != nil { + if delErr := m.store.delete(m.storeKey); delErr != nil { // Keep the process-local guard even if the durable fallback also fails. m.refusedRefreshToken = sentRefreshToken @@ -529,7 +623,7 @@ func (m *Manager) accountForRefreshFailure(err error, sentRefreshToken string) e // process cannot load and submit it again. A later login overwrites this // through the ordinary save path. tombstone := &Credentials{} - if saveErr := m.store.save(m.baseURL, tombstone); saveErr != nil { + if saveErr := m.store.save(m.storeKey, tombstone); saveErr != nil { return errRefusedGrant(errors.Join(delErr, fmt.Errorf("replace refused credential: %w", saveErr))) } m.cachedCredentials = cloneCredentials(tombstone) @@ -657,9 +751,10 @@ func (m *Manager) GetStore() *Store { return m.store } -// CredentialKey returns the base URL used as the credential storage key. +// CredentialKey returns the credential's storage key: the base URL for the CLI's own +// credential, prefixed for hey mcp's (mcp.go). func (m *Manager) CredentialKey() string { - return m.baseURL + return m.storeKey } func (m *Manager) waitForCallback(ctx context.Context, expectedState, authURL string, listener net.Listener, opts LoginOptions) (string, error) { diff --git a/internal/auth/device.go b/internal/auth/device.go new file mode 100644 index 000000000..bf59d0cf8 --- /dev/null +++ b/internal/auth/device.go @@ -0,0 +1,232 @@ +package auth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/basecamp/hey-cli/internal/terminal" + "github.com/basecamp/hey-cli/internal/version" +) + +// The device authorization grant (RFC 8628), as haystack#8707 and #9085 serve it. +// +// A device request names the client and the scope, and no resource: hey mcp's tokens +// are for HEY's own API. Until a HEY host runs #9085 with OAUTH_SCOPED_DEVICE_FLOW on, +// a scoped request is refused with invalid_request (or, on #9041 alone, the row with +// unauthorized_client), and the sign-in falls back to loopback authorization. + +// errDeviceFlowUnavailable means this HEY can't serve a scoped device sign-in, so the +// caller should sign in by loopback instead. +var errDeviceFlowUnavailable = errors.New("this HEY doesn't take scoped device sign-ins yet") + +// deviceGrantType is RFC 8628 §3.4's grant type. +const deviceGrantType = "urn:ietf:params:oauth:grant-type:device_code" + +// defaultDeviceInterval is RFC 8628 §3.2's default polling interval, and the step +// slow_down adds to it (§3.5). +const defaultDeviceInterval = 5 * time.Second + +// deviceAuthorization is a device authorization response (RFC 8628 §3.2). DeviceCode +// is a secret: it is sent back to the token endpoint and never shown. +type deviceAuthorization struct { + DeviceCode string `json:"device_code"` //nolint:gosec // G117: legitimate OAuth field + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + VerificationURIComplete string `json:"verification_uri_complete"` + ExpiresIn int64 `json:"expires_in"` + Interval int64 `json:"interval"` +} + +// LoginDevice signs in with the device authorization grant, asking for the manager's +// scope. It returns errDeviceFlowUnavailable, wrapped with HEY's answer, when this HEY +// can't serve the request, so the caller can fall back to Login. +func (m *Manager) LoginDevice(ctx context.Context, opts LoginOptions) error { + tokenEndpoint := m.baseURL + "/oauth/tokens" + installID, err := m.store.InstallID() + if err != nil { + return fmt.Errorf("install id: %w", err) + } + + authorization, err := m.requestDeviceAuthorization(ctx) + if err != nil { + return err + } + + link := authorization.VerificationURIComplete + if link == "" { + link = authorization.VerificationURI + } + // Both come from HEY's answer, so what reaches the terminal is sanitized, and only + // a web link is handed to the browser. + opts.log(fmt.Sprintf("\nTo sign in, open %s\nand check that HEY shows this code: %s\n", terminal.SanitizeLine(link), terminal.SanitizeLine(authorization.UserCode))) + if !opts.NoBrowser { + if !webLink(link) { + opts.log("HEY's sign-in link isn't a web address, so it wasn't opened.\n") + } else if browserErr := openBrowser(link); browserErr != nil { + opts.log("Couldn't open the browser automatically; open the link above.\n") + } + } + opts.log("\nWaiting for you to approve the sign-in in HEY...\n") + + interval := time.Duration(authorization.Interval) * time.Second + if interval <= 0 { + interval = defaultDeviceInterval + } + expiresAt := time.Now().Add(time.Duration(authorization.ExpiresIn) * time.Second) + + for { + if waitErr := m.wait(ctx, min(interval, max(time.Until(expiresAt), 0))); waitErr != nil { + return waitErr + } + if !time.Now().Before(expiresAt) { + return errors.New("the sign-in code expired before it was approved; run the sign-in again") + } + + token, code, pollErr := m.pollDeviceToken(ctx, tokenEndpoint, authorization.DeviceCode, installID) + if pollErr != nil { + return pollErr + } + switch code { + case "": + creds, credErr := m.signedInCredentials(token, tokenEndpoint, installID) + if credErr != nil { + return credErr + } + return m.saveCredentials(creds) + case "authorization_pending": + case "slow_down": + interval += defaultDeviceInterval + case "access_denied": + return errors.New("the sign-in was denied in HEY") + case "expired_token": + return errors.New("the sign-in code expired before it was approved; run the sign-in again") + case "invalid_scope": + return errors.New("HEY no longer allows this client the scope it asked for") + default: + return fmt.Errorf("device sign-in failed: %s", code) + } + } +} + +// webLink reports whether link is an absolute http or https URL. +func webLink(link string) bool { + u, err := url.Parse(link) + return err == nil && (u.Scheme == "https" || u.Scheme == "http") && u.Host != "" +} + +// requestDeviceAuthorization asks HEY for a device code (POST /oauth/device_authorizations). +func (m *Manager) requestDeviceAuthorization(ctx context.Context) (*deviceAuthorization, error) { + form := url.Values{"client_id": {m.clientID}} + if m.scope != "" { + form.Set("scope", m.scope) + } + status, body, err := m.postForm(ctx, m.baseURL+"/oauth/device_authorizations", form) + if err != nil { + return nil, fmt.Errorf("device authorization request failed: %w", err) + } + + if status != http.StatusOK { + code := oauthErrorCode(body) + if deviceFlowUnavailable(status, code) { + return nil, fmt.Errorf("%w (HTTP %d %s)", errDeviceFlowUnavailable, status, code) + } + return nil, fmt.Errorf("device authorization failed: HTTP %d %s", status, code) + } + + var authorization deviceAuthorization + if err := json.Unmarshal(body, &authorization); err != nil { + return nil, fmt.Errorf("parsing device authorization response: %w", err) + } + if authorization.DeviceCode == "" || authorization.UserCode == "" || authorization.VerificationURI == "" || authorization.ExpiresIn <= 0 { + return nil, errors.New("device authorization response is missing required fields") + } + return &authorization, nil +} + +// deviceFlowUnavailable reports whether a refused device request means this HEY can't +// serve it at all: no endpoint (an older HEY), the rollout switch off (invalid_request +// for a request naming scope, haystack#9085), or a row the device flow refuses +// (unauthorized_client, #9041 without #9085). +func deviceFlowUnavailable(status int, code string) bool { + switch status { + case http.StatusNotFound, http.StatusMethodNotAllowed: + return true + case http.StatusBadRequest, http.StatusUnauthorized: + return code == "invalid_request" || code == "unauthorized_client" || code == "unsupported_grant_type" + default: + return false + } +} + +// pollDeviceToken asks the token endpoint once whether the device code is approved. It +// returns the token, or the RFC 8628 §3.5 error code HEY answered. +func (m *Manager) pollDeviceToken(ctx context.Context, tokenEndpoint, deviceCode, installID string) (*OAuthToken, string, error) { + form := url.Values{ + "grant_type": {deviceGrantType}, + "client_id": {m.clientID}, + "device_code": {deviceCode}, + "install_id": {installID}, + } + status, body, err := m.postForm(ctx, tokenEndpoint, form) + if err != nil { + return nil, "", fmt.Errorf("device token request failed: %w", err) + } + + if status == http.StatusOK { + var token OAuthToken + if err := json.Unmarshal(body, &token); err != nil { + return nil, "", fmt.Errorf("parsing device token response: %w", err) + } + if token.AccessToken == "" { + return nil, "", errors.New("device token response has no access token") + } + if token.ExpiresIn > 0 { + token.ExpiresAt = time.Now().Add(time.Duration(token.ExpiresIn) * time.Second) + } + return &token, "", nil + } + + if code := oauthErrorCode(body); code != "" && status >= 400 && status < 500 { + return nil, code, nil + } + return nil, "", fmt.Errorf("device token request failed: HTTP %d", status) +} + +// postForm posts a form and answers the status and at most 64 KiB of the body. +func (m *Manager) postForm(ctx context.Context, endpoint string, form url.Values) (int, []byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return 0, nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", version.UserAgent()) + + resp, err := m.httpClient.Do(req) + if err != nil { + return 0, nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) + if err != nil { + return 0, nil, err + } + return resp.StatusCode, body, nil +} + +// oauthErrorCode is the RFC 6749 §5.2 error code in an error response, or "". +func oauthErrorCode(body []byte) string { + var payload struct { + Error string `json:"error"` + } + if json.Unmarshal(body, &payload) != nil { + return "" + } + return payload.Error +} diff --git a/internal/auth/mcp.go b/internal/auth/mcp.go new file mode 100644 index 000000000..cccdc14e9 --- /dev/null +++ b/internal/auth/mcp.go @@ -0,0 +1,192 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "slices" + "strings" + + "github.com/basecamp/hey-cli/internal/version" +) + +// hey mcp's credential (haystack#9041, #9085). +// +// `hey mcp` signs in as HEY's local MCP client, "HEY MCP (local)", not as the CLI. The +// row's allowlist is read write send and never full, so a model driving hey mcp can +// reach what those scopes declare and nothing else: no settings, forwarding, trash or +// HEY World. It has its own entry in Connected Apps and can be revoked without signing +// the CLI out. +// +// The credential is stored apart from the CLI's: its own keychain item (and its own +// entry in the plaintext fallback file), under storeKey "mcp::". It refreshes +// as the local client. HEY_TOKEN never stands in for it. + +// MCPScope is what `hey auth login --mcp` asks for. A scoped row that names no scope +// is refused (haystack#9085), so the sign-in always sends it. +const MCPScope = "read write send" + +// mcpStoreKeyPrefix keeps hey mcp's credential out of the CLI's keychain item. +const mcpStoreKeyPrefix = "mcp::" + +// ErrMCPClientUnconfigured means this HEY has no local MCP client id configured yet. +var ErrMCPClientUnconfigured = errors.New("no HEY MCP (local) client id is configured for this HEY") + +// NewMCPManager is the manager for hey mcp's credential, signing in and refreshing as +// the local MCP client clientID. +func NewMCPManager(baseURL string, httpClient *http.Client, configDir, clientID string) *Manager { + m := NewManager(baseURL, httpClient, configDir) + m.storeKey = mcpStoreKeyPrefix + m.baseURL + m.clientID = clientID + m.scope = MCPScope + m.honorEnvToken = false + return m +} + +// ClientID is the OAuth client this manager signs in as. +func (m *Manager) ClientID() string { + return m.clientID +} + +// LoginMCP signs in the local MCP client. It uses the device flow where HEY serves +// scoped device requests, and otherwise loopback authorization with the same scope, +// returning which one it used ("device" or "oauth"). +func (m *Manager) LoginMCP(ctx context.Context, opts LoginOptions) (string, error) { + switch m.clientID { + case "": + return "", ErrMCPClientUnconfigured + case oauthClientID: + // A mistyped HEY_MCP_CLIENT_ID pointing at the CLI's own row would sign hey mcp + // in with full, which is the thing this credential exists to avoid. + return "", fmt.Errorf("the configured MCP client id is the CLI's own; set the HEY MCP (local) client id") + } + + err := m.LoginDevice(ctx, opts) + if !errors.Is(err, errDeviceFlowUnavailable) { + return "device", err + } + opts.log(fmt.Sprintf("\n%v, so signing in through the browser instead.\n", err)) + return "oauth", m.Login(ctx, opts) +} + +// PinGrant makes grantKey the only sign-in this manager authenticates with from now on +// (pinnedGrant). hey mcp pins the sign-in it starts on. +func (m *Manager) PinGrant(grantKey string) { + m.mu.Lock() + defer m.mu.Unlock() + m.pinnedGrant = grantKey +} + +// ErrGrantChanged is a stored credential under a different sign-in than the pinned one. +var ErrGrantChanged = errors.New("hey mcp was signed in again, or signed out, since it started; restart it") + +func (m *Manager) checkPinnedGrant(creds *Credentials) error { + if m.pinnedGrant != "" && creds.GrantKey != m.pinnedGrant { + return errNoCredential(ErrGrantChanged.Error(), ErrGrantChanged) + } + return nil +} + +// StoredCredentials reads this manager's stored credential. +func (m *Manager) StoredCredentials() (*Credentials, error) { + return m.store.Load(m.storeKey) +} + +// Revoke asks HEY to revoke the stored grant (DELETE /oauth/tokens). It sends the +// refresh token when there is one, since revoking it ends the whole grant. +func (m *Manager) Revoke(ctx context.Context) error { + m.mu.Lock() + defer m.mu.Unlock() + + creds, err := m.store.Load(m.storeKey) + if err != nil { + return err + } + token := creds.RefreshToken + if token == "" { + token = creds.AccessToken + } + if token == "" { + return nil + } + clientID := creds.ClientID + if clientID == "" { + clientID = m.clientID + } + + form := url.Values{"client_id": {clientID}, "token": {token}} + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, m.baseURL+"/oauth/tokens", strings.NewReader(form.Encode())) + if err != nil { + return fmt.Errorf("creating revocation request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", version.UserAgent()) + + resp, err := m.httpClient.Do(req) + if err != nil { + return fmt.Errorf("revocation request failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + // The body can echo the request, so only the status is reported. + return fmt.Errorf("HEY answered the revocation with HTTP %d", resp.StatusCode) + } + return nil +} + +// scopeImplies is HEY's scope taxonomy as far as hey mcp reads it: write implies +// read (haystack Oauth::Scope), and full implies everything. +var scopeImplies = map[string][]string{ + "write": {"read"}, +} + +// expandScope returns the scopes granted holds, implications included. +func expandScope(granted string) map[string]bool { + held := map[string]bool{} + for _, s := range strings.Fields(granted) { + held[s] = true + for _, implied := range scopeImplies[s] { + held[implied] = true + } + } + return held +} + +// HasScope reports whether a granted scope string holds every scope in want, by the +// expanded set rather than the literal string. +func HasScope(granted string, want ...string) bool { + held := expandScope(granted) + if held["full"] { + return true + } + for _, w := range want { + if !held[w] { + return false + } + } + return true +} + +// grantedScope reads the scope a token response granted against what was asked for. +// An omitted scope means exactly what was requested (RFC 6749 §5.1). A narrower one is +// taken as it is, since the person may have unchecked send on the consent page. One that +// names anything not asked for, full above all, is refused: it would mean the client id +// isn't the local MCP client's. +func grantedScope(requested, returned string) (string, error) { + if strings.TrimSpace(returned) == "" { + return strings.Join(strings.Fields(requested), " "), nil + } + asked := strings.Fields(requested) + var granted []string + for _, s := range strings.Fields(returned) { + if !slices.Contains(asked, s) { + return "", fmt.Errorf("HEY granted scope %q, which this sign-in didn't ask for; check that the MCP client id is HEY MCP (local)'s", s) + } + if !slices.Contains(granted, s) { + granted = append(granted, s) + } + } + return strings.Join(granted, " "), nil +} diff --git a/internal/auth/mcp_test.go b/internal/auth/mcp_test.go new file mode 100644 index 000000000..cb5ef0099 --- /dev/null +++ b/internal/auth/mcp_test.go @@ -0,0 +1,505 @@ +package auth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" +) + +const testMCPClientID = "LocalMcpClient0000000001" + +// fakeHEYOAuth is HEY's OAuth endpoints as the local MCP client meets them. +type fakeHEYOAuth struct { + t *testing.T + + mu sync.Mutex + // deviceStatus and deviceError answer the device authorization request; zero + // serves a device code. + deviceStatus int + deviceError string + // pending is how many polls answer authorization_pending before the token. + pending int + // pollErrors answer polls in order before pending counts down. + pollErrors []string + // grantedScope is the token response's scope; "-" leaves it out. + grantedScope string + // deviceBody replaces the device authorization response when set. + deviceBody string + + deviceRequests url.Values + polls []url.Values + exchanges []url.Values + refreshes []url.Values + revocations []url.Values +} + +func (f *fakeHEYOAuth) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + // net/http parses a form body only for POST, PUT and PATCH; Rack parses one on any + // method, DELETE /oauth/tokens included, so read it directly. + raw, _ := io.ReadAll(r.Body) + form, err := url.ParseQuery(string(raw)) + if err != nil { + f.t.Errorf("parse form: %v", err) + } + w.Header().Set("Content-Type", "application/json") + + switch { + case r.Method == http.MethodPost && r.URL.Path == "/oauth/device_authorizations": + f.deviceRequests = form + if f.deviceStatus != 0 { + w.WriteHeader(f.deviceStatus) + fmt.Fprintf(w, `{"error":%q}`, f.deviceError) + return + } + if f.deviceBody != "" { + fmt.Fprint(w, f.deviceBody) + return + } + fmt.Fprint(w, `{"device_code":"secret-device-code","user_code":"WDJB-MJHT","verification_uri":"https://hey.example/device","verification_uri_complete":"https://hey.example/device?user_code=WDJB-MJHT","expires_in":600,"interval":5}`) + case r.Method == http.MethodPost && r.URL.Path == "/oauth/tokens" && form.Get("grant_type") == deviceGrantType: + f.polls = append(f.polls, form) + if len(f.pollErrors) > 0 { + code := f.pollErrors[0] + f.pollErrors = f.pollErrors[1:] + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, `{"error":%q}`, code) + return + } + if f.pending > 0 { + f.pending-- + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"error":"authorization_pending"}`) + return + } + f.writeToken(w, "device") + case r.Method == http.MethodPost && r.URL.Path == "/oauth/tokens" && form.Get("grant_type") == "authorization_code": + f.exchanges = append(f.exchanges, form) + f.writeToken(w, "loopback") + case r.Method == http.MethodPost && r.URL.Path == "/oauth/tokens" && form.Get("grant_type") == "refresh_token": + f.refreshes = append(f.refreshes, form) + f.writeToken(w, "refreshed") + case r.Method == http.MethodDelete && r.URL.Path == "/oauth/tokens": + f.revocations = append(f.revocations, form) + fmt.Fprint(w, `{}`) + default: + f.t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } +} + +func (f *fakeHEYOAuth) writeToken(w http.ResponseWriter, kind string) { + body := map[string]any{"access_token": kind + "-access", "refresh_token": kind + "-refresh", "token_type": "bearer", "expires_in": 1209600} + switch f.grantedScope { + case "-": + case "": + body["scope"] = MCPScope + default: + body["scope"] = f.grantedScope + } + _ = json.NewEncoder(w).Encode(body) +} + +// mcpRig is a CLI manager and an MCP manager sharing one keychain, against one HEY. +type mcpRig struct { + hey *fakeHEYOAuth + server *httptest.Server + keyring *fakeKeyring + cli *Manager + mcp *Manager + waits []time.Duration +} + +func newMCPRig(t *testing.T) *mcpRig { + t.Helper() + t.Setenv("HEY_TOKEN", "") + t.Setenv("HEY_NO_KEYRING", "") + rig := &mcpRig{hey: &fakeHEYOAuth{t: t}, keyring: newFakeKeyring()} + rig.server = httptest.NewServer(rig.hey) + t.Cleanup(rig.server.Close) + + configDir := t.TempDir() + rig.cli = NewManager(rig.server.URL, rig.server.Client(), configDir) + rig.mcp = NewMCPManager(rig.server.URL, rig.server.Client(), configDir, testMCPClientID) + for _, m := range []*Manager{rig.cli, rig.mcp} { + m.store.keyring = credentialKeyring{set: rig.keyring.Set, get: rig.keyring.Get, delete: rig.keyring.Delete} + } + rig.mcp.wait = func(_ context.Context, d time.Duration) error { + rig.waits = append(rig.waits, d) + return nil + } + // No test opens a browser: the device link and the loopback URL are only logged. + rig.mcp.callbackWait = func(_ context.Context, state, authURL string, listener net.Listener, _ LoginOptions) (string, error) { + u, err := url.Parse(authURL) + if err != nil { + return "", err + } + rig.hey.mu.Lock() + rig.hey.exchanges = append(rig.hey.exchanges, url.Values{"authorize": {u.RawQuery}}) + rig.hey.mu.Unlock() + if u.Query().Get("state") != state { + return "", errors.New("state mismatch") + } + return "loopback-code", nil + } + return rig +} + +var quiet = LoginOptions{NoBrowser: true, Logger: func(string) {}} + +func TestMCPLoginStoresACredentialApartFromTheCLIs(t *testing.T) { + rig := newMCPRig(t) + if err := rig.cli.LoginWithToken("cli-full-token"); err != nil { + t.Fatal(err) + } + rig.hey.pending = 2 + + method, err := rig.mcp.LoginMCP(context.Background(), quiet) + if err != nil { + t.Fatal(err) + } + if method != "device" { + t.Errorf("method = %q, want device", method) + } + + // The device request names the local client and the scope, and no resource. + got := rig.hey.deviceRequests + if got.Get("client_id") != testMCPClientID || got.Get("scope") != MCPScope || got.Has("resource") { + t.Errorf("device request = %v", got) + } + for _, poll := range rig.hey.polls { + if poll.Get("client_id") != testMCPClientID || poll.Get("device_code") != "secret-device-code" || !isInstallID(poll.Get("install_id")) { + t.Errorf("poll = %v", poll) + } + } + if len(rig.hey.polls) != 3 { + t.Errorf("polled %d times, want 3 (two pending, then the token)", len(rig.hey.polls)) + } + + // Two keychain items: the CLI's, untouched, and hey mcp's. + cli, err := rig.cli.StoredCredentials() + if err != nil || cli.AccessToken != "cli-full-token" { + t.Fatalf("the CLI's credential = %+v, %v", cli, err) + } + mcp, err := rig.mcp.StoredCredentials() + if err != nil { + t.Fatal(err) + } + if mcp.AccessToken != "device-access" || mcp.Scope != MCPScope || mcp.ClientID != testMCPClientID || mcp.GrantKey == "" || mcp.GrantedAt == 0 { + t.Errorf("hey mcp's credential = %+v", mcp) + } + cliItem, mcpItem := serviceName+"/"+key(rig.cli.CredentialKey()), serviceName+"/"+key(rig.mcp.CredentialKey()) + if cliItem == mcpItem { + t.Fatalf("both credentials live in keychain item %s", cliItem) + } + if _, ok := rig.keyring.values[cliItem]; !ok { + t.Errorf("no CLI keychain item %s", cliItem) + } + if _, ok := rig.keyring.values[mcpItem]; !ok { + t.Errorf("no hey mcp keychain item %s", mcpItem) + } +} + +func TestMCPLoginStoresApartInThePlaintextFallbackToo(t *testing.T) { + t.Setenv("HEY_TOKEN", "") + t.Setenv("HEY_NO_KEYRING", "1") + hey := &fakeHEYOAuth{t: t} + server := httptest.NewServer(hey) + t.Cleanup(server.Close) + dir := t.TempDir() + + cli := NewManager(server.URL, server.Client(), dir) + if err := cli.LoginWithToken("cli-full-token"); err != nil { + t.Fatal(err) + } + mcp := NewMCPManager(server.URL, server.Client(), dir, testMCPClientID) + mcp.wait = func(context.Context, time.Duration) error { return nil } + if _, err := mcp.LoginMCP(context.Background(), quiet); err != nil { + t.Fatal(err) + } + + all, err := cli.store.loadAllFromFile() + if err != nil { + t.Fatal(err) + } + if len(all) != 2 || all[cli.CredentialKey()].AccessToken != "cli-full-token" || all[mcp.CredentialKey()].AccessToken != "device-access" { + t.Errorf("credentials.json entries = %v", keysOf(all)) + } +} + +func keysOf(all map[string]*Credentials) []string { + keys := make([]string, 0, len(all)) + for k := range all { + keys = append(keys, k) + } + return keys +} + +func TestMCPLoginFallsBackToLoopbackWithTheSameScope(t *testing.T) { + for name, refusal := range map[string]struct { + status int + code string + }{ + "rollout switch off (haystack#9085)": {http.StatusBadRequest, "invalid_request"}, + "row refused by the device flow (#9041)": {http.StatusBadRequest, "unauthorized_client"}, + "no device endpoint": {http.StatusNotFound, ""}, + } { + t.Run(name, func(t *testing.T) { + rig := newMCPRig(t) + rig.hey.deviceStatus, rig.hey.deviceError = refusal.status, refusal.code + + method, err := rig.mcp.LoginMCP(context.Background(), quiet) + if err != nil { + t.Fatal(err) + } + if method != "oauth" { + t.Errorf("method = %q, want the loopback fallback", method) + } + authorize, _ := url.ParseQuery(rig.hey.exchanges[0].Get("authorize")) + if authorize.Get("client_id") != testMCPClientID || authorize.Get("scope") != MCPScope || authorize.Get("code_challenge_method") != "S256" { + t.Errorf("authorization request = %v", authorize) + } + exchange := rig.hey.exchanges[1] + if exchange.Get("client_id") != testMCPClientID { + t.Errorf("code exchange named client %q", exchange.Get("client_id")) + } + creds, err := rig.mcp.StoredCredentials() + if err != nil || creds.AccessToken != "loopback-access" || creds.Scope != MCPScope { + t.Errorf("stored %+v, %v", creds, err) + } + }) + } +} + +func TestMCPLoginDoesntFallBackOnOtherRefusals(t *testing.T) { + rig := newMCPRig(t) + rig.hey.deviceStatus, rig.hey.deviceError = http.StatusUnauthorized, "invalid_client" + if _, err := rig.mcp.LoginMCP(context.Background(), quiet); err == nil { + t.Fatal("an unknown client id signed in") + } + if len(rig.hey.exchanges) != 0 { + t.Error("fell back to loopback for a refusal that isn't about the device flow") + } +} + +func TestMCPLoginHonorsTheScopeHEYGranted(t *testing.T) { + for name, tc := range map[string]struct { + granted, stored string + canSend bool + }{ + "all of it": {"read write send", "read write send", true}, + "send unchecked": {"read write", "read write", false}, + "left out means what was asked": {"-", "read write send", true}, + } { + t.Run(name, func(t *testing.T) { + rig := newMCPRig(t) + rig.hey.grantedScope = tc.granted + if _, err := rig.mcp.LoginMCP(context.Background(), quiet); err != nil { + t.Fatal(err) + } + creds, _ := rig.mcp.StoredCredentials() + if creds.Scope != tc.stored { + t.Errorf("stored scope %q, want %q", creds.Scope, tc.stored) + } + if got := HasScope(creds.Scope, "read", "send"); got != tc.canSend { + t.Errorf("can send = %v, want %v", got, tc.canSend) + } + }) + } +} + +func TestMCPLoginRefusesAScopeItDidntAskFor(t *testing.T) { + rig := newMCPRig(t) + rig.hey.grantedScope = "full" + if _, err := rig.mcp.LoginMCP(context.Background(), quiet); err == nil || !strings.Contains(err.Error(), "full") { + t.Fatalf("err = %v, want a refusal naming full", err) + } + if _, err := rig.mcp.StoredCredentials(); !errors.Is(err, ErrCredentialsNotFound) { + t.Errorf("a full credential was stored for hey mcp: %v", err) + } +} + +func TestMCPLoginNeedsTheLocalClientID(t *testing.T) { + rig := newMCPRig(t) + rig.mcp.clientID = "" + if _, err := rig.mcp.LoginMCP(context.Background(), quiet); !errors.Is(err, ErrMCPClientUnconfigured) { + t.Errorf("err = %v, want ErrMCPClientUnconfigured", err) + } + rig.mcp.clientID = oauthClientID + if _, err := rig.mcp.LoginMCP(context.Background(), quiet); err == nil { + t.Error("hey mcp signed in as the CLI's own client") + } + if rig.hey.deviceRequests != nil { + t.Error("HEY was asked anyway") + } +} + +func TestMCPDeviceLoginStopsOnDenialAndBacksOffOnSlowDown(t *testing.T) { + rig := newMCPRig(t) + rig.hey.pollErrors = []string{"slow_down", "access_denied"} + _, err := rig.mcp.LoginMCP(context.Background(), quiet) + if err == nil || !strings.Contains(err.Error(), "denied") { + t.Fatalf("err = %v, want denial", err) + } + if len(rig.waits) != 2 || rig.waits[0] != 5*time.Second || rig.waits[1] != 10*time.Second { + t.Errorf("waits = %v, want 5s then 10s after slow_down", rig.waits) + } + if _, err := rig.mcp.StoredCredentials(); !errors.Is(err, ErrCredentialsNotFound) { + t.Error("a denied sign-in stored a credential") + } +} + +func TestMCPRefreshNamesTheLocalClientAndKeepsTheScope(t *testing.T) { + rig := newMCPRig(t) + if err := rig.mcp.store.Save(rig.mcp.storeKey, &Credentials{ + AccessToken: "old", RefreshToken: "old-refresh", ExpiresAt: time.Now().Add(-time.Hour).Unix(), + OAuthType: "oauth", InstallID: generateInstallIDForTest(t), Scope: MCPScope, ClientID: testMCPClientID, GrantKey: "g", GrantedAt: 1, + }); err != nil { + t.Fatal(err) + } + rig.hey.grantedScope = "read write" + + token, err := rig.mcp.AccessToken(context.Background()) + if err != nil { + t.Fatal(err) + } + if token != "refreshed-access" { + t.Errorf("token = %q", token) + } + if got := rig.hey.refreshes[0].Get("client_id"); got != testMCPClientID { + t.Errorf("refresh named client %q, want the local MCP client", got) + } + creds, _ := rig.mcp.StoredCredentials() + if creds.Scope != "read write" || creds.GrantKey != "g" || creds.ClientID != testMCPClientID { + t.Errorf("after refresh: %+v", creds) + } +} + +func generateInstallIDForTest(t *testing.T) string { + t.Helper() + id, err := NewStore(t.TempDir()).InstallID() + if err != nil { + t.Fatal(err) + } + return id +} + +func TestMCPManagerNeverUsesHEYToken(t *testing.T) { + rig := newMCPRig(t) + t.Setenv("HEY_TOKEN", "a-full-token-someone-exported") + + if ok, _ := rig.mcp.AuthenticationStatus(); ok { + t.Error("HEY_TOKEN signed hey mcp in") + } + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/boxes.json", nil) + if err := rig.mcp.AuthenticateRequest(context.Background(), req); err == nil || req.Header.Get("Authorization") != "" { + t.Errorf("HEY_TOKEN authenticated a hey mcp request: %q", req.Header.Get("Authorization")) + } + if ok, _ := rig.cli.AuthenticationStatus(); !ok { + t.Error("the CLI stopped honoring HEY_TOKEN") + } +} + +func TestMCPRevokeNamesTheLocalClientAndTheGrant(t *testing.T) { + rig := newMCPRig(t) + if _, err := rig.mcp.LoginMCP(context.Background(), quiet); err != nil { + t.Fatal(err) + } + if err := rig.mcp.Revoke(context.Background()); err != nil { + t.Fatal(err) + } + if len(rig.hey.revocations) != 1 { + t.Fatalf("revocations = %d", len(rig.hey.revocations)) + } + got := rig.hey.revocations[0] + if got.Get("client_id") != testMCPClientID || got.Get("token") != "device-refresh" { + t.Errorf("revocation = client %q, token is the refresh token: %v", got.Get("client_id"), got.Get("token") == "device-refresh") + } +} + +func TestHasScopeReadsTheExpandedSet(t *testing.T) { + for _, tc := range []struct { + granted string + want []string + ok bool + }{ + {"read write send", []string{"read", "send"}, true}, + {"write send", []string{"read", "send"}, true}, + {"read write", []string{"send"}, false}, + {"send", []string{"read", "send"}, false}, + {"", []string{"read"}, false}, + } { + if got := HasScope(tc.granted, tc.want...); got != tc.ok { + t.Errorf("HasScope(%q, %v) = %v, want %v", tc.granted, tc.want, got, tc.ok) + } + } +} + +// The link and code come from HEY's answer: what reaches the terminal is sanitized, and +// the device code, a secret, never does. +func TestDeviceLoginSanitizesWhatItShows(t *testing.T) { + rig := newMCPRig(t) + rig.hey.deviceBody = `{"device_code":"secret-device-code","user_code":"WDJB\u001b]52;c;cGF5bG9hZA==\u0007-MJHT","verification_uri":"https://hey.example/device\u001b[2J\nFake: approved","expires_in":600,"interval":5}` + var shown strings.Builder + opts := LoginOptions{NoBrowser: true, Logger: func(msg string) { shown.WriteString(msg) }} + if _, err := rig.mcp.LoginMCP(context.Background(), opts); err != nil { + t.Fatal(err) + } + out := shown.String() + if strings.ContainsAny(out, "\x1b\x07") || strings.Contains(out, "\nFake: approved") { + t.Errorf("control sequences reached the terminal: %q", out) + } + if strings.Contains(out, "secret-device-code") { + t.Error("the device code was shown") + } +} + +func TestOnlyAWebLinkIsOpened(t *testing.T) { + for link, want := range map[string]bool{ + "https://app.hey.com/device?user_code=WDJB-MJHT": true, + "file:///etc/passwd": false, + "javascript:alert(1)": false, + "/device": false, + } { + if got := webLink(link); got != want { + t.Errorf("webLink(%q) = %v, want %v", link, got, want) + } + } +} + +// A pinned manager authenticates only as the sign-in it started on: a later +// `hey auth login --mcp` isn't adopted, by a reload or by a refresh. +func TestAPinnedManagerRefusesAnotherSignIn(t *testing.T) { + rig := newMCPRig(t) + if _, err := rig.mcp.LoginMCP(context.Background(), quiet); err != nil { + t.Fatal(err) + } + first, _ := rig.mcp.StoredCredentials() + rig.mcp.PinGrant(first.GrantKey) + + other := NewMCPManager(rig.server.URL, rig.server.Client(), t.TempDir(), testMCPClientID) + other.store = rig.mcp.store + other.wait = rig.mcp.wait + if _, err := other.LoginMCP(context.Background(), quiet); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/boxes.json", nil) + if err := rig.mcp.AuthenticateRequestFromStore(context.Background(), req); !errors.Is(err, ErrGrantChanged) { + t.Errorf("a reload adopted another sign-in: %v (Authorization set: %v)", err, req.Header.Get("Authorization") != "") + } + if err := rig.mcp.Refresh(context.Background()); !errors.Is(err, ErrGrantChanged) { + t.Errorf("a refresh adopted another sign-in: %v", err) + } +} diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go index e307273cb..c6f83bc3d 100644 --- a/internal/auth/oauth.go +++ b/internal/auth/oauth.go @@ -98,11 +98,14 @@ func clampRetryAfter(d time.Duration) time.Duration { // OAuthToken represents the token response from the HEY OAuth server. type OAuthToken struct { - AccessToken string `json:"access_token"` //nolint:gosec // G117: legitimate OAuth field - RefreshToken string `json:"refresh_token"` //nolint:gosec // G117: legitimate OAuth field - TokenType string `json:"token_type"` - ExpiresIn int64 `json:"expires_in"` - ExpiresAt time.Time `json:"-"` + AccessToken string `json:"access_token"` //nolint:gosec // G117: legitimate OAuth field + RefreshToken string `json:"refresh_token"` //nolint:gosec // G117: legitimate OAuth field + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + // Scope is what HEY granted, space-separated. RFC 6749 §5.1 lets a server leave it + // out when it granted exactly what was asked for. + Scope string `json:"scope"` + ExpiresAt time.Time `json:"-"` } // exchangeCode exchanges an authorization code for tokens using PKCE. diff --git a/internal/auth/store.go b/internal/auth/store.go index 86c3eccac..a871d8e62 100644 --- a/internal/auth/store.go +++ b/internal/auth/store.go @@ -67,6 +67,21 @@ type Credentials struct { // travels with the tokens: every config directory that shares this keychain entry // refreshes as the same install. InstallID string `json:"install_id,omitempty"` + + // The fields below are set only by a scoped sign-in, hey mcp's (mcp.go). The CLI's own + // credential asks for no scope, holds full, and leaves them empty. + + // Scope is what HEY granted these tokens, space-separated, e.g. "read write send". + Scope string `json:"scope,omitempty"` + // ClientID is the OAuth client the tokens were issued to, which a refresh names. + ClientID string `json:"client_id,omitempty"` + // GrantKey names this sign-in locally. hey mcp keys its send safety state (caps and + // exposure) on it, so a new sign-in, which took the person through consent again, + // starts a new budget. + GrantKey string `json:"grant_key,omitempty"` + // GrantedAt is when the sign-in completed, in Unix seconds. hey mcp counts a + // recipient as known only from mail sent before it. + GrantedAt int64 `json:"granted_at,omitempty"` } // Store handles credential storage, preferring system keychain. diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index 11bfc1411..94cda236f 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -52,6 +52,7 @@ func buildLoginCommand(path string) *cobra.Command { token string cookie string noBrowser bool + mcp bool ) cmd := &cobra.Command{ @@ -60,14 +61,28 @@ func buildLoginCommand(path string) *cobra.Command { Long: `Authenticate with the HEY server. Opens a browser for OAuth authentication against HEY's own OAuth server, using PKCE. -Use --token or --cookie for non-interactive login.`, +Use --token or --cookie for non-interactive login. + +--mcp signs in hey mcp instead, as its own app ("HEY MCP (local)" in HEY's Connected +Apps), asking to read, write and send but nothing more. Its credential is kept apart +from the CLI's, and hey mcp serves on it alone. It uses a device code where HEY +offers one, and the browser otherwise. If you uncheck sending on HEY's page, hey mcp +saves drafts but can't send them.`, Example: strings.Join([]string{ " " + path, " " + path + " --token YOUR_BEARER_TOKEN", " " + path + " --cookie SESSION_COOKIE_VALUE", " " + path + " --no-browser", + " " + path + " --mcp", }, "\n"), RunE: func(cmd *cobra.Command, args []string) error { + if mcp { + if token != "" || cookie != "" { + return apierr.ErrUsage("--mcp signs in through HEY; it takes no --token or --cookie") + } + return loginMCP(cmd, noBrowser) + } + if token != "" { if err := authMgr.LoginWithToken(token); err != nil { return apierr.ErrAuth(fmt.Sprintf("could not save token: %v", err)) @@ -113,6 +128,7 @@ Use --token or --cookie for non-interactive login.`, cmd.Flags().StringVar(&token, "token", "", "Pre-generated Bearer token") cmd.Flags().StringVar(&cookie, "cookie", "", "Session cookie value from browser (__Host-session_token, or session_token)") cmd.Flags().BoolVar(&noBrowser, "no-browser", false, "Don't open browser, print URL instead") + cmd.Flags().BoolVar(&mcp, "mcp", false, "Sign in hey mcp with its own credential (read, write and send)") return cmd } @@ -126,11 +142,19 @@ func newAuthLogoutCommand() *cobra.Command { // buildLogoutCommand constructs a logout command whose example reads as the // given path. Shared by `hey auth logout` and the top-level `hey logout`. func buildLogoutCommand(path string) *cobra.Command { - return &cobra.Command{ - Use: "logout", - Short: "Clear stored credentials", - Example: " " + path, + var mcp bool + cmd := &cobra.Command{ + Use: "logout", + Short: "Clear stored credentials", + Long: `Clear the CLI's stored credentials. + +--mcp signs hey mcp out instead: it revokes hey mcp's sign-in at HEY and removes its +credential, leaving the CLI's alone.`, + Example: " " + path + "\n " + path + " --mcp", RunE: func(cmd *cobra.Command, args []string) error { + if mcp { + return logoutMCP(cmd) + } if err := authMgr.Logout(); err != nil { return apierr.ErrAuth(fmt.Sprintf("could not clear credentials: %v", err)) } @@ -139,6 +163,8 @@ func buildLogoutCommand(path string) *cobra.Command { return writeMutation(cmd, "Logged out", nil) }, } + cmd.Flags().BoolVar(&mcp, "mcp", false, "Sign hey mcp out: revoke its sign-in and remove its credential") + return cmd } // status subcommand @@ -148,14 +174,16 @@ func newAuthStatusCommand() *cobra.Command { Use: "status", Short: "Show authentication status", Annotations: map[string]string{ - "agent_notes": "Use it when the user asks for authentication status or when an explicit check helps diagnose a failed command, not as a preflight. It reports stored credentials without contacting HEY and changes nothing, so authenticated true does not prove HEY still accepts them. JSON answers authenticated, base_url, mail_account, account_source and install_id; method env_var when HEY_TOKEN is set; otherwise storage (keyring or file), auth_type, and expires_at, expired and refresh_available for a token. An expired token with refresh_available is refreshed by the next data command.", + "agent_notes": "Use it when the user asks for authentication status or when an explicit check helps diagnose a failed command, not as a preflight. It reports stored credentials without contacting HEY and changes nothing, so authenticated true does not prove HEY still accepts them. JSON answers authenticated, base_url, mail_account, account_source and install_id; method env_var when HEY_TOKEN is set; otherwise storage (keyring or file), auth_type, and expires_at, expired and refresh_available for a token. An expired token with refresh_available is refreshed by the next data command. mcp reports hey mcp's own sign-in the same way, with its scope and can_send; HEY_TOKEN never stands in for it.", }, RunE: func(cmd *cobra.Command, args []string) error { + mcpStatus := mcpCredentialStatus() status := map[string]any{ "base_url": cfg.BaseURL, "mail_account": cfg.AccountID, "account_source": cfg.SourceOf("account_id"), "authenticated": false, + "mcp": mcpStatus, } // The install identifier is install-scoped: it survives logout and @@ -180,6 +208,7 @@ func newAuthStatusCommand() *cobra.Command { fmt.Fprintf(w, "Install: %s\n", installID) } fmt.Fprintln(w, "Status: Logged in (via HEY_TOKEN env var)") + printMCPStatus(w, mcpStatus) return nil } return writeOK(status, output.WithSummary("Logged in via HEY_TOKEN")) @@ -199,6 +228,7 @@ func newAuthStatusCommand() *cobra.Command { fmt.Fprintf(w, "Install: %s\n", installID) } fmt.Fprintln(w, "Status: Not logged in") + printMCPStatus(w, mcpStatus) return nil } return writeOK(status, output.WithSummary("Not logged in"), @@ -268,6 +298,7 @@ func newAuthStatusCommand() *cobra.Command { } else { fmt.Fprintln(w, "Storage: file") } + printMCPStatus(w, mcpStatus) return nil } diff --git a/internal/cmd/help_topics.go b/internal/cmd/help_topics.go index a57f2d0d7..0265f81f3 100644 --- a/internal/cmd/help_topics.go +++ b/internal/cmd/help_topics.go @@ -55,7 +55,8 @@ JSON failures also carry a machine-readable error code and an actionable hint wh Long: `Configure hey for the current process with environment variables. CONNECTION & AUTHENTICATION - HEY_TOKEN Use a bearer token instead of stored credentials. + HEY_TOKEN Use a bearer token instead of stored credentials (never for hey mcp). + HEY_MCP_CLIENT_ID The HEY MCP (local) client id hey auth login --mcp signs in as. HEY_BASE_URL Override the HEY server URL. HEY_ACCOUNT_ID Select a linked mail account ID or all. HEY_NO_KEYRING Store credentials in the config directory instead of a keyring. diff --git a/internal/cmd/mcp.go b/internal/cmd/mcp.go index d6de84d11..cf521ad75 100644 --- a/internal/cmd/mcp.go +++ b/internal/cmd/mcp.go @@ -3,14 +3,19 @@ package cmd import ( "context" "log/slog" + "net/http" "os" "os/signal" "syscall" + "time" + "github.com/basecamp/hey-sdk/go/pkg/generated" hey "github.com/basecamp/hey-sdk/go/pkg/hey" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/spf13/cobra" + "github.com/basecamp/hey-cli/internal/auth" + "github.com/basecamp/hey-cli/internal/config" "github.com/basecamp/hey-cli/internal/mcpserver" ) @@ -30,12 +35,19 @@ func newMCPCommand() *mcpCommand { Use: "mcp", Short: "Serve HEY to MCP clients over stdio", Long: "Run an MCP (Model Context Protocol) server on stdin/stdout, serving HEY mail,\n" + - "contacts, and todos as tools backed by your signed-in account.\n\n" + - "It never sends mail. create_message, create_reply and update_message only save\n" + - "drafts, and a request to send, schedule or triage in the same call is refused\n" + - "before HEY is contacted. Updating a scheduled draft clears its schedule. Send a\n" + - "draft yourself from HEY or with `hey draft send `. --read-only drops every\n" + - "write action, drafts included.\n\n" + + "contacts, and todos as tools.\n\n" + + "hey mcp has its own sign-in, separate from the CLI's: run `hey auth login --mcp`\n" + + "first. It signs in as \"HEY MCP (local)\", which can read, write and send but\n" + + "can't reach settings, forwarding, trash or HEY World, and shows in HEY's Connected\n" + + "Apps on its own. hey mcp never uses the CLI's credential or HEY_TOKEN.\n\n" + + "create_message, create_reply and update_message only save drafts, and a request\n" + + "to send, schedule or triage in the same call is refused before HEY is contacted.\n" + + "Updating a scheduled draft clears its schedule. hey_send's send_draft is the one\n" + + "way to deliver: it asks you to confirm each email in your MCP client, showing its\n" + + "recipients, subject and body, and sends exactly what you approved, once. A client\n" + + "that can't show a confirmation gets the draft's link in HEY instead. send_draft\n" + + "is served only when the sign-in was granted sending. --read-only drops every\n" + + "write action, drafts and sending included.\n\n" + "Register it with an MCP client as a stdio server, e.g.:\n\n" + " claude mcp add hey -- hey mcp", Args: cobra.NoArgs, @@ -86,18 +98,84 @@ func (a mcpAPI) Delete(ctx context.Context, path string) (*hey.Response, error) } func (c *mcpCommand) run(cmd *cobra.Command, args []string) error { - if err := requireAuth(); err != nil { + // hey mcp serves on its own credential, never the CLI's (mcp_auth.go). + creds, err := requireMCPCredentials() + if err != nil { return err } + // Whatever it may do, this process authenticates only as the sign-in it starts on: + // one that started without send must not move onto a later send-capable sign-in + // whose exposure it doesn't record. + mcpAuthMgr.PinGrant(creds.GrantKey) - // The same account scoping the shared client got in the root command's - // pre-run, applied to the no-retry twin. - writes, err := clientForAccountSelection(cmd.Context(), newSDKClient(hey.WithMaxRetries(0)), cfg.AccountID) + ctx := cmd.Context() + reads, err := clientForAccountSelection(ctx, newMCPSDKClient(), cfg.AccountID) + if err != nil { + return err + } + writes, err := clientForAccountSelection(ctx, newMCPSDKClient(hey.WithMaxRetries(0)), cfg.AccountID) if err != nil { return err } - srv, err := mcpserver.New(mcpAPI{reads: sdk, writes: writes}, mcpserver.Config{ReadOnly: c.readOnly, Domains: c.domains}) + // The tools follow the grant: without write, nothing that writes is served; without + // send, no hey_send. + readOnly := c.readOnly || !auth.HasScope(creds.Scope, "write") + serverCfg := mcpserver.Config{ReadOnly: readOnly, Domains: c.domains} + // Send safety is configured whenever the sign-in can send, read-only or not: this + // process records exposure for any other hey mcp on the same sign-in that sends. + canSend := mcpCanSend(creds) + if canSend { + // Claims are keyed by origin, so it has one spelling: https://app.hey.com and + // https://app.hey.com:443 are the same HEY. + origin, originErr := config.Origin(cfg.BaseURL) + if originErr != nil { + return originErr + } + state, stateErr := mcpserver.OpenSendState(config.StateDir()) + if stateErr != nil { + return stateErr + } + // An undated sign-in makes no recipient known: only mail sent before it does. + var grantedAt time.Time + if creds.GrantedAt > 0 { + grantedAt = time.Unix(creds.GrantedAt, 0) + } + // The delivery goes through a client of its own that can't resend: the SDK + // answers a 401 by refreshing and resending once, and a send is one request + // (D2.4). The token is refreshed before the send instead. + sends, sendsErr := clientForAccountSelection(ctx, newMCPSDKClient(hey.WithMaxRetries(0), hey.WithAuthStrategy(onceAuthStrategy{mgr: mcpAuthMgr})), cfg.AccountID) + if sendsErr != nil { + return sendsErr + } + serverCfg.Send = &mcpserver.SendConfig{ + Drafts: mcpDrafts{reads: reads, sends: sends}, + State: state, + Grant: mcpserver.Grant{Key: creds.GrantKey, GrantedAt: grantedAt}, + Origin: origin, + CurrentGrant: func(ctx context.Context) (string, error) { + // Renew a token about to expire now, before the claim, rather than + // after it: a renewal can narrow the grant, and the send then must + // not go ahead. A token that can't be made live refuses too, so a + // draft that certainly wasn't sent isn't claimed. + if _, tokenErr := mcpAuthMgr.AccessToken(ctx); tokenErr != nil { + return "", tokenErr + } + stored, loadErr := mcpAuthMgr.StoredCredentials() + if loadErr != nil { + return "", loadErr + } + // A refresh that narrowed the grant below sending is a different + // sign-in as far as sending goes: refuse before claiming. + if !mcpCanSend(stored) { + return "", nil + } + return stored.GrantKey, nil + }, + } + } + + srv, err := mcpserver.New(mcpAPI{reads: reads, writes: writes}, serverCfg) if err != nil { return err } @@ -111,7 +189,55 @@ func (c *mcpCommand) run(cmd *cobra.Command, args []string) error { if err != nil { return err } - logger.Info("MCP server running on stdio", "tools", len(srv.Domains()), "read_only", c.readOnly) + logger.Info("MCP server running on stdio", "tools", len(srv.Domains()), "read_only", readOnly, "send_draft", srv.ServesSend()) + if !canSend { + logger.Info("send_draft isn't served: hey mcp's sign-in wasn't granted sending. To allow it, run `hey auth login --mcp` again and leave sending checked.") + } return session.Wait() } + +// newMCPSDKClient builds a client like the CLI's, authenticated with hey mcp's own +// credential instead. It keeps no response cache: the cache is the CLI credential's, and +// what one credential fetched isn't the other's to read. +func newMCPSDKClient(extra ...hey.ClientOption) *hey.Client { + uncached := *sdkClientCfg + uncached.CacheEnabled = false + uncached.CacheDir = "" + opts := append(append([]hey.ClientOption{}, sdkClientOpts...), hey.WithAuthStrategy(&cliAuthStrategy{mgr: mcpAuthMgr})) + return hey.NewClient(&uncached, nil, append(opts, extra...)...) +} + +// mcpDrafts is send_draft's view of the SDK: reads through the retrying client, the send +// through one that never retries or resends, because a second request can deliver twice. +type mcpDrafts struct { + reads, sends *hey.Client +} + +// onceAuthStrategy authenticates like cliAuthStrategy but offers the SDK no refresh, +// so a 401 surfaces instead of being answered with a resend. +type onceAuthStrategy struct { + mgr *auth.Manager +} + +func (a onceAuthStrategy) Authenticate(ctx context.Context, req *http.Request) error { + return a.mgr.AuthenticateRequest(ctx, req) +} + +func (d mcpDrafts) GetEdit(ctx context.Context, draftID int64) (*generated.MessageEditState, error) { + return d.reads.Messages().GetEdit(ctx, draftID) +} + +func (d mcpDrafts) SendDraft(ctx context.Context, draftID int64, draft hey.DraftContent) (*generated.SentMessage, error) { + // The token was renewed just before the claim (CurrentGrant), so this one request + // goes out on a live one. + return d.sends.Messages().SendDraft(ctx, draftID, draft) +} + +func (d mcpDrafts) Identity(ctx context.Context) (*generated.Identity, error) { + return d.reads.Identity().GetIdentity(ctx) +} + +func (d mcpDrafts) Search(ctx context.Context, params hey.SearchParams) (*generated.AdvancedSearchResult, error) { + return d.reads.Search().Search(ctx, params) +} diff --git a/internal/cmd/mcp_auth.go b/internal/cmd/mcp_auth.go new file mode 100644 index 000000000..7d8f39526 --- /dev/null +++ b/internal/cmd/mcp_auth.go @@ -0,0 +1,191 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/spf13/cobra" + + "github.com/basecamp/hey-cli/internal/apierr" + "github.com/basecamp/hey-cli/internal/auth" + "github.com/basecamp/hey-cli/internal/config" +) + +// hey mcp's own credential (internal/auth/mcp.go): `hey auth login --mcp` signs in the +// HEY MCP (local) client on read write send, stored apart from the CLI's credential, and +// `hey mcp` serves on that alone. + +var ( + // mcpAuthMgr manages hey mcp's credential. Built in the root pre-run beside authMgr. + mcpAuthMgr *auth.Manager + // errMCPClientIDConfig is why the configured local MCP client id was refused, if it was. + errMCPClientIDConfig error +) + +func initMCPAuth(httpClient *http.Client, configDir string) { + clientID, _, err := config.MCPClientID(cfg.BaseURL) + errMCPClientIDConfig = err + mcpAuthMgr = auth.NewMCPManager(cfg.BaseURL, httpClient, configDir, clientID) +} + +const mcpLoginCommand = "hey auth login --mcp" + +// errMCPNotSignedIn is hey mcp run without its credential. +func errMCPNotSignedIn() *apierr.Error { + err := apierr.ErrAuth("hey mcp has its own sign-in, separate from the CLI's, and it isn't signed in") + err.Hint = "Run: " + mcpLoginCommand + return err +} + +func errMCPClientUnconfigured() *apierr.Error { + err := apierr.ErrUsage("hey mcp's sign-in isn't available for this HEY yet: no HEY MCP (local) client id is configured") + err.Hint = "Set HEY_MCP_CLIENT_ID, or mcp_client_ids in ~/.config/hey-cli/config.json, to the client id HEY provisioned" + return err +} + +// requireMCPCredentials loads hey mcp's credential, refusing when there is none. It never +// falls back to the CLI's credential: that one holds full, and serving hey mcp on it would +// undo what the scoped sign-in contains. +func requireMCPCredentials() (*auth.Credentials, error) { + return checkMCPCredentials(mcpAuthMgr.StoredCredentials()) +} + +// checkMCPCredentials is requireMCPCredentials' verdict on what the store returned. A +// store that couldn't be read isn't a missing sign-in: a Keychain refusal says signing in +// again won't help, which matters here since hey mcp is usually started by an MCP +// client's background process, the kind the Keychain refuses. +func checkMCPCredentials(creds *auth.Credentials, err error) (*auth.Credentials, error) { + if errors.Is(err, auth.ErrCredentialsNotFound) { + return nil, errMCPNotSignedIn() + } + if err != nil { + return nil, auth.CredentialStorageError("could not read hey mcp's credentials", err) + } + if creds.AccessToken == "" && creds.RefreshToken == "" { + return nil, errMCPNotSignedIn() + } + if creds.GrantKey == "" { + // Every --mcp sign-in mints one; without it the process couldn't be pinned. + return nil, errMCPNotSignedIn() + } + return creds, nil +} + +// mcpCanSend is whether hey mcp serves send_draft on creds: the grant holds send, and +// write, without which hey mcp serves reads only (and read, which write implies). The +// sign-in's local key is the principal send safety counts against, so a credential +// without one can't send. +func mcpCanSend(creds *auth.Credentials) bool { + return auth.HasScope(creds.Scope, "write", "send") && creds.GrantKey != "" +} + +// loginMCP runs `hey auth login --mcp`. +func loginMCP(cmd *cobra.Command, noBrowser bool) error { + if errMCPClientIDConfig != nil { + return apierr.ErrUsage(errMCPClientIDConfig.Error()) + } + + // A device code lives ten minutes; the loopback fallback waits five. + ctx, cancel := context.WithTimeout(cmd.Context(), 12*time.Minute) + defer cancel() + + method, err := mcpAuthMgr.LoginMCP(ctx, auth.LoginOptions{NoBrowser: noBrowser}) + if errors.Is(err, auth.ErrMCPClientUnconfigured) { + return errMCPClientUnconfigured() + } + if err != nil { + return apierr.ErrAuth(fmt.Sprintf("hey mcp sign-in failed: %v", err)) + } + + creds, err := requireMCPCredentials() + if err != nil { + return err + } + canSend := mcpCanSend(creds) + line := fmt.Sprintf("Signed hey mcp in (%s).", creds.Scope) + if !canSend { + line += " hey mcp can't send on this sign-in (it needs write and send); sign in again and leave both checked to allow it." + } + return writeMutationLine(cmd, line, "hey mcp signed in", + map[string]any{"method": method, "scope": creds.Scope, "can_send": canSend}) +} + +// logoutMCP runs `hey auth logout --mcp`: revoke the grant at HEY, then forget it here. +// The local copy goes even when the revocation can't be confirmed, and the warning says +// where to finish it. +func logoutMCP(cmd *cobra.Command) error { + if _, err := mcpAuthMgr.StoredCredentials(); errors.Is(err, auth.ErrCredentialsNotFound) { + return writeMutation(cmd, "hey mcp wasn't signed in", nil) + } + + ctx, cancel := context.WithTimeout(cmd.Context(), 15*time.Second) + defer cancel() + revokeErr := mcpAuthMgr.Revoke(ctx) + if revokeErr != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "warning: couldn't revoke hey mcp's sign-in at HEY (%v); remove HEY MCP (local) in HEY's Connected Apps\n", revokeErr) + } + if err := mcpAuthMgr.Logout(); err != nil { + return apierr.ErrAuth(fmt.Sprintf("could not clear hey mcp's credentials: %v", err)) + } + return writeMutationLine(cmd, "hey mcp signed out.", "hey mcp signed out", + map[string]any{"revoked": revokeErr == nil}) +} + +// mcpCredentialStatus is hey mcp's half of `hey auth status`. It never includes a token. +func mcpCredentialStatus() map[string]any { + status := map[string]any{"authenticated": false} + if mcpAuthMgr == nil { + return status + } + if clientID := mcpAuthMgr.ClientID(); clientID != "" { + status["client_id"] = clientID + } else { + status["client_configured"] = false + } + + creds, err := mcpAuthMgr.StoredCredentials() + if err != nil { + if !errors.Is(err, auth.ErrCredentialsNotFound) { + status["error"] = err.Error() + } + return status + } + if creds.AccessToken == "" && creds.RefreshToken == "" { + return status + } + status["authenticated"] = true + status["scope"] = creds.Scope + status["can_send"] = mcpCanSend(creds) + if creds.ExpiresAt > 0 { + expiry := time.Unix(creds.ExpiresAt, 0) + status["expires_at"] = expiry.Format(time.RFC3339) + status["expired"] = time.Now().After(expiry) + } + if creds.RefreshToken != "" { + status["refresh_available"] = true + } + if creds.GrantedAt > 0 { + status["signed_in_at"] = time.Unix(creds.GrantedAt, 0).Format(time.RFC3339) + } + return status +} + +// printMCPStatus is the styled line for hey mcp's credential. +func printMCPStatus(w io.Writer, status map[string]any) { + if status["authenticated"] != true { + fmt.Fprintf(w, "MCP: Not signed in (run: %s)\n", mcpLoginCommand) + return + } + line := fmt.Sprintf("MCP: Signed in (%v)", status["scope"]) + if status["can_send"] != true { + line += ", can't send" + } + if status["expired"] == true { + line += ", token expired" + } + fmt.Fprintln(w, line) +} diff --git a/internal/cmd/mcp_auth_test.go b/internal/cmd/mcp_auth_test.go new file mode 100644 index 000000000..e12ce3c06 --- /dev/null +++ b/internal/cmd/mcp_auth_test.go @@ -0,0 +1,177 @@ +package cmd + +import ( + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + + "github.com/basecamp/hey-cli/internal/apierr" + "github.com/basecamp/hey-cli/internal/auth" +) + +// fakeLocalMCPOAuth serves the device flow and revocation for the local MCP client. +type fakeLocalMCPOAuth struct { + t *testing.T + scope string + mu sync.Mutex + revocations []url.Values +} + +func (f *fakeLocalMCPOAuth) ServeHTTP(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + form, _ := url.ParseQuery(string(raw)) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodPost && r.URL.Path == "/oauth/device_authorizations": + if form.Get("client_id") != "LocalMcpClient0000000001" || form.Get("scope") != "read write send" { + f.t.Errorf("device request = %v", form) + } + fmt.Fprint(w, `{"device_code":"secret-device-code","user_code":"WDJB-MJHT","verification_uri":"https://hey.example/device","expires_in":600,"interval":1}`) + case r.Method == http.MethodPost && r.URL.Path == "/oauth/tokens": + fmt.Fprintf(w, `{"access_token":"mcp-access","refresh_token":"mcp-refresh","token_type":"bearer","expires_in":1209600,"scope":%q}`, f.scope) + case r.Method == http.MethodDelete && r.URL.Path == "/oauth/tokens": + f.mu.Lock() + f.revocations = append(f.revocations, form) + f.mu.Unlock() + fmt.Fprint(w, `{}`) + default: + f.t.Errorf("unexpected HTTP request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } +} + +func TestAuthLoginMCPKeepsTheCLIsCredentialAndStatusShowsBoth(t *testing.T) { + hey := &fakeLocalMCPOAuth{t: t, scope: "read write"} + server := httptest.NewServer(hey) + defer server.Close() + configHome := t.TempDir() + t.Setenv("HEY_MCP_CLIENT_ID", "LocalMcpClient0000000001") + + if _, _, err := runAuthCommand(t, configHome, server.URL, "", true, "auth", "login", "--token", "cli-full-token"); err != nil { + t.Fatal(err) + } + _, login, err := runAuthCommand(t, configHome, server.URL, "", true, "auth", "login", "--mcp", "--no-browser") + if err != nil { + t.Fatal(err) + } + data, _ := login.Data.(map[string]any) + if data["method"] != "device" || data["scope"] != "read write" || data["can_send"] != false { + t.Errorf("login data = %v", data) + } + + _, status, err := runAuthCommand(t, configHome, server.URL, "", true, "auth", "status") + if err != nil { + t.Fatal(err) + } + got, _ := status.Data.(map[string]any) + mcpStatus, _ := got["mcp"].(map[string]any) + if got["authenticated"] != true || got["auth_type"] != "token" { + t.Errorf("the CLI's credential changed: %v", got) + } + if mcpStatus["authenticated"] != true || mcpStatus["scope"] != "read write" || mcpStatus["can_send"] != false { + t.Errorf("mcp status = %v", mcpStatus) + } + if text := fmt.Sprint(got); strings.Contains(text, "mcp-access") || strings.Contains(text, "mcp-refresh") || strings.Contains(text, "cli-full-token") { + t.Error("status printed a token") + } + + styled, _, err := runAuthCommand(t, configHome, server.URL, "", false, "auth", "status", "--styled") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(styled, "MCP: Signed in (read write), can't send") { + t.Errorf("styled status = %s", styled) + } + + _, logout, err := runAuthCommand(t, configHome, server.URL, "", true, "auth", "logout", "--mcp") + if err != nil { + t.Fatal(err) + } + if data, _ := logout.Data.(map[string]any); data["revoked"] != true { + t.Errorf("logout data = %v", logout.Data) + } + if len(hey.revocations) != 1 || hey.revocations[0].Get("client_id") != "LocalMcpClient0000000001" || hey.revocations[0].Get("token") != "mcp-refresh" { + t.Errorf("revocations = %d, want one of the MCP refresh token by the local client", len(hey.revocations)) + } + + _, status, err = runAuthCommand(t, configHome, server.URL, "", true, "auth", "status") + if err != nil { + t.Fatal(err) + } + got, _ = status.Data.(map[string]any) + mcpStatus, _ = got["mcp"].(map[string]any) + if got["authenticated"] != true || mcpStatus["authenticated"] != false { + t.Errorf("after logout --mcp: cli %v, mcp %v; want the CLI still signed in and hey mcp out", got["authenticated"], mcpStatus["authenticated"]) + } +} + +func TestAuthLoginMCPNeedsAClientID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected HTTP request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + t.Setenv("HEY_MCP_CLIENT_ID", "") + + _, _, err := runAuthCommand(t, t.TempDir(), server.URL, "", true, "auth", "login", "--mcp") + var usage *apierr.Error + if !errors.As(err, &usage) || !strings.Contains(usage.Hint, "HEY_MCP_CLIENT_ID") { + t.Fatalf("err = %#v, want a usage error naming HEY_MCP_CLIENT_ID", err) + } +} + +func TestAuthLoginMCPTakesNoTokenOrCookie(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected HTTP request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + if _, _, err := runAuthCommand(t, t.TempDir(), server.URL, "", true, "auth", "login", "--mcp", "--token", "x"); err == nil { + t.Fatal("--mcp stored a hand-supplied token") + } +} + +// can_send is the same predicate hey mcp serves hey_send on: send alone, without write, +// leaves hey mcp read-only. +func TestMCPCanSendNeedsWriteAndSend(t *testing.T) { + for scope, want := range map[string]bool{"read write send": true, "write send": true, "read send": false, "read write": false} { + if got := mcpCanSend(&auth.Credentials{Scope: scope, GrantKey: "g"}); got != want { + t.Errorf("mcpCanSend(%q) = %v, want %v", scope, got, want) + } + } + if mcpCanSend(&auth.Credentials{Scope: "read write send"}) { + t.Error("a sign-in without a local key can send") + } +} + +func TestHeyMCPSaysAKeychainRefusalIsntAMissingSignIn(t *testing.T) { + t.Setenv("CODEX_SANDBOX", "") + // What the store returns when macOS's security tool exits 36 for a process outside + // the desktop session, such as an MCP client's background server. + refused := fmt.Errorf("read credentials from system keyring: %w", + errors.Join(errors.New("exit status 36"), auth.ErrKeyringRefused)) + + _, err := checkMCPCredentials(nil, refused) + var classified *apierr.Error + if !errors.As(err, &classified) { + t.Fatalf("err = %v, want a classified error carrying a hint", err) + } + if classified.Code == apierr.CodeAuth { + t.Errorf("code = %q, a refused keyring is not a missing sign-in", classified.Code) + } + if !strings.HasPrefix(classified.Hint, "Signing in again won't help") { + t.Errorf("hint = %q, want it to rule out another hey auth login --mcp", classified.Hint) + } + if !strings.HasPrefix(classified.Message, "could not read hey mcp's credentials: ") { + t.Errorf("message = %q", classified.Message) + } + + _, err = checkMCPCredentials(nil, fmt.Errorf("%w for mcp::https://app.hey.com", auth.ErrCredentialsNotFound)) + if !errors.As(err, &classified) || classified.Code != apierr.CodeAuth || classified.Hint != "Run: "+mcpLoginCommand { + t.Errorf("err = %#v, a missing credential still points at %s", err, mcpLoginCommand) + } +} diff --git a/internal/cmd/mcp_test.go b/internal/cmd/mcp_test.go index 03095118b..148e88fda 100644 --- a/internal/cmd/mcp_test.go +++ b/internal/cmd/mcp_test.go @@ -4,13 +4,20 @@ import ( "bytes" "context" "encoding/json" + "errors" "net/http" "net/http/httptest" + "slices" "strings" "sync/atomic" "testing" + "time" "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/basecamp/hey-cli/internal/apierr" + "github.com/basecamp/hey-cli/internal/auth" + "github.com/basecamp/hey-cli/internal/config" ) func TestMCPCommandRegistration(t *testing.T) { @@ -29,28 +36,52 @@ func TestMCPCommandRegistration(t *testing.T) { t.Error("domains flag missing") } - if !commandUsesAccountScope(command) { - t.Error("mcp must use the configured account scope") + // hey mcp scopes its own clients to the configured account, on its own credential; + // the pre-run's account scoping would ask HEY with the CLI's. + if commandUsesAccountScope(command) { + t.Error("the pre-run must not scope hey mcp's account with the CLI's credential") } } -func TestMCPCommandRequiresAuth(t *testing.T) { +// hey mcp refuses to start without its own credential, even with the CLI signed in +// and HEY_TOKEN set: both hold full, and serving on either would undo the scoped +// sign-in. Nothing reaches HEY. +func TestMCPCommandRefusesWithoutItsOwnCredential(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected HTTP request: %s %s", r.Method, r.URL.Path) + })) + t.Cleanup(upstream.Close) + tmpDir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", tmpDir) t.Setenv("XDG_STATE_HOME", tmpDir) t.Setenv("XDG_CACHE_HOME", tmpDir) - t.Setenv("HEY_TOKEN", "") t.Setenv("HEY_NO_KEYRING", "1") + t.Setenv("HEY_TOKEN", "a-full-token") stubInteractive(t, false) + if err := auth.NewManager(upstream.URL, upstream.Client(), config.ConfigDir()).LoginWithToken("the-clis-full-token"); err != nil { + t.Fatal(err) + } + // If hey mcp started serving instead of refusing, it would wait on this transport. + _ = stubMCPTransport(t) root := newRootCmd() var buf bytes.Buffer root.SetOut(&buf) root.SetErr(&buf) - root.SetArgs([]string{"mcp"}) + root.SetArgs([]string{"mcp", "--base-url", upstream.URL}) - if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "Not logged in") { - t.Fatalf("err = %v, want auth error", err) + done := make(chan error, 1) + go func() { done <- root.Execute() }() + var err error + select { + case err = <-done: + case <-time.After(5 * time.Second): + t.Fatal("hey mcp started serving without its own credential") + } + var refusal *apierr.Error + if !errors.As(err, &refusal) || refusal.Code != apierr.CodeAuth || !strings.Contains(refusal.Hint, "hey auth login --mcp") { + t.Fatalf("err = %#v, want an auth error pointing at hey auth login --mcp", err) } } @@ -65,17 +96,47 @@ func stubMCPTransport(t *testing.T) mcp.Transport { return clientTransport } -// runMCPCommand runs `hey mcp` against a stub HEY server and connects a real -// MCP client to it over the transport seam. The command exits when the -// client session closes. +// mcpTestToken is the access token of the hey mcp credential the tests store. +const mcpTestToken = "mcp-scoped-token" + +// mcpTokenExpiresIn is how long the stored test credential's token lives. +var mcpTokenExpiresIn = time.Hour + +// storeMCPCredential stores a hey mcp credential granted scope, as `hey auth login +// --mcp` would, signed in a week ago. +func storeMCPCredential(t *testing.T, baseURL, scope string) { + t.Helper() + mgr := auth.NewMCPManager(baseURL, http.DefaultClient, config.ConfigDir(), "LocalMcpClient0000000001") + creds := &auth.Credentials{ + AccessToken: mcpTestToken, RefreshToken: "mcp-refresh", OAuthType: "oauth", + ExpiresAt: time.Now().Add(mcpTokenExpiresIn).Unix(), Scope: scope, ClientID: "LocalMcpClient0000000001", + GrantKey: "test-grant", GrantedAt: time.Now().Add(-7 * 24 * time.Hour).Unix(), + } + if err := mgr.GetStore().Save(mgr.CredentialKey(), creds); err != nil { + t.Fatal(err) + } +} + +// runMCPCommand runs `hey mcp` against a stub HEY server, signed in on its own +// credential with read write send, and connects a real MCP client to it over the +// transport seam. The command exits when the client session closes. func runMCPCommand(t *testing.T, upstream *httptest.Server, args ...string) *mcp.ClientSession { t.Helper() - t.Setenv("HEY_TOKEN", "test-token") + return runMCPCommandWith(t, upstream, auth.MCPScope, nil, args...) +} + +// runMCPCommandWith is runMCPCommand with the credential's scope and the client's +// options given. +func runMCPCommandWith(t *testing.T, upstream *httptest.Server, scope string, clientOpts *mcp.ClientOptions, args ...string) *mcp.ClientSession { + t.Helper() + // HEY_TOKEN holds whatever a person exported; hey mcp must never send it. + t.Setenv("HEY_TOKEN", "a-full-token-hey-mcp-must-not-use") t.Setenv("HEY_NO_KEYRING", "1") tmpDir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", tmpDir) t.Setenv("XDG_STATE_HOME", tmpDir) t.Setenv("XDG_CACHE_HOME", tmpDir) + storeMCPCredential(t, upstream.URL, scope) clientTransport := stubMCPTransport(t) @@ -93,7 +154,7 @@ func runMCPCommand(t *testing.T, upstream *httptest.Server, args ...string) *mcp } }) - client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "0.0.0"}, nil) + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "0.0.0"}, clientOpts) session, err := client.Connect(context.Background(), clientTransport, nil) if err != nil { t.Fatalf("MCP initialize failed: %v", err) @@ -109,9 +170,9 @@ func TestMCPCommandServesMCP(t *testing.T) { http.NotFound(w, r) return } - // Tool calls must ride on the CLI's own credentials. - if got := r.Header.Get("Authorization"); got != "Bearer test-token" { - t.Errorf("Authorization = %q, want the CLI's token", got) + // Tool calls ride on hey mcp's own credential, never the CLI's or HEY_TOKEN. + if got := r.Header.Get("Authorization"); got != "Bearer "+mcpTestToken { + t.Errorf("Authorization = %q, want hey mcp's own token", got) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`[{"id":1,"name":"Imbox"}]`)) @@ -131,8 +192,8 @@ func TestMCPCommandServesMCP(t *testing.T) { } names = append(names, tool.Name) } - if len(names) != 7 { - t.Fatalf("tools = %v, want 7 hey_* tools", names) + if len(names) != 8 || !slices.Contains(names, "hey_send") { + t.Fatalf("tools = %v, want the 7 domain tools and hey_send", names) } result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ @@ -284,3 +345,215 @@ func TestMCPCommandSavesDraftsAndNeverSends(t *testing.T) { t.Errorf("result = %s", text) } } + +func toolNames(t *testing.T, session *mcp.ClientSession) []string { + t.Helper() + var names []string + for tool, err := range session.Tools(context.Background(), nil) { + if err != nil { + t.Fatal(err) + } + names = append(names, tool.Name) + } + return names +} + +// The tools follow the scope HEY granted: a sign-in with send unchecked serves no +// hey_send, and one without write serves nothing that writes. +func TestMCPCommandServesWhatTheGrantAllows(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected HTTP request: %s %s", r.Method, r.URL.Path) + })) + t.Cleanup(upstream.Close) + + t.Run("send unchecked", func(t *testing.T) { + names := toolNames(t, runMCPCommandWith(t, upstream, "read write", nil)) + if slices.Contains(names, "hey_send") || len(names) != 7 { + t.Errorf("tools = %v, want the 7 domain tools and no hey_send", names) + } + }) + t.Run("read only", func(t *testing.T) { + session := runMCPCommandWith(t, upstream, "read", nil) + for tool, err := range session.Tools(context.Background(), nil) { + if err != nil { + t.Fatal(err) + } + if tool.Name == "hey_send" || strings.Contains(tool.Description, "- trash_topic") { + t.Errorf("a read-only grant serves a write: %s", tool.Name) + } + } + }) + t.Run("--read-only", func(t *testing.T) { + if names := toolNames(t, runMCPCommand(t, upstream, "--read-only")); slices.Contains(names, "hey_send") { + t.Errorf("--read-only serves hey_send: %v", names) + } + }) +} + +// End to end through the command: send_draft reads the draft, asks the person, and +// sends the approved snapshot once, on hey mcp's own credential, through the client +// that never retries. +func TestMCPCommandSendsAConfirmedDraftOnce(t *testing.T) { + var puts atomic.Int32 + var putBody atomic.Value + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer "+mcpTestToken { + t.Errorf("%s %s Authorization = %q, want hey mcp's own token", r.Method, r.URL.Path, got) + } + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/messages/5/edit.json": + _, _ = w.Write([]byte(`{"id":5,"subject":"Re: Q3 plan","content":"
Hi Ann
","sender":{"id":77,"name":"Jeremy Daer","email_address":"jeremy@hey.example"},"addressed":{"directly":[{"id":1,"name":"Ann Example","email_address":"ann@example.com"}]}}`)) + case r.Method == http.MethodGet && r.URL.Path == "/identity.json": + _, _ = w.Write([]byte(`{"id":1,"primary_contact":{"id":77,"email_address":"jeremy@hey.example"},"senders":[{"id":77,"email_address":"jeremy@hey.example"}]}`)) + case r.Method == http.MethodGet && r.URL.Path == "/advanced_search.json": + _, _ = w.Write([]byte(`{"matches":[]}`)) + case r.Method == http.MethodPut && r.URL.Path == "/messages/5.json": + puts.Add(1) + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + putBody.Store(body) + _, _ = w.Write([]byte(`{"id":5,"topic_id":900}`)) + default: + t.Errorf("unexpected HTTP request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + })) + t.Cleanup(upstream.Close) + + var prompt string + session := runMCPCommandWith(t, upstream, auth.MCPScope, &mcp.ClientOptions{ + ElicitationHandler: func(_ context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + prompt = req.Params.Message + return &mcp.ElicitResult{Action: "accept", Content: map[string]any{"confirm": true}}, nil + }, + }) + call := func() *mcp.CallToolResult { + t.Helper() + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "hey_send", + Arguments: map[string]any{"action": "send_draft", "params": map[string]any{"draft_id": 5}}, + }) + if err != nil { + t.Fatal(err) + } + return result + } + + sent := call() + if sent.IsError { + t.Fatalf("send_draft failed: %v", sent.Content) + } + if !strings.Contains(prompt, `"Ann Example" (new: never emailed before)`) { + t.Errorf("prompt = %s", prompt) + } + body, _ := putBody.Load().(map[string]any) + entry, _ := body["entry"].(map[string]any) + if entry["status"] != nil || entry["addressed"] == nil { + t.Errorf("delivery entry = %v, want recipients and no drafted status", entry) + } + + again := call() + if !again.IsError { + t.Fatalf("a second send_draft of the same draft went through: %v", again.Content) + } + if got := puts.Load(); got != 1 { + t.Errorf("HEY saw %d deliveries, want 1", got) + } +} + +// A delivery answered 401 isn't resent: the SDK would refresh and resend, and a send +// is one request. +func TestMCPCommandNeverResendsADeliveryAfterA401(t *testing.T) { + var puts atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/messages/5/edit.json": + _, _ = w.Write([]byte(`{"id":5,"subject":"Re: Q3 plan","content":"
Hi Ann
","sender":{"id":77,"email_address":"jeremy@hey.example"},"addressed":{"directly":[{"id":1,"email_address":"ann@example.com"}]}}`)) + case r.Method == http.MethodGet && r.URL.Path == "/identity.json": + _, _ = w.Write([]byte(`{"id":1,"senders":[{"id":77,"email_address":"jeremy@hey.example"}]}`)) + case r.Method == http.MethodGet && r.URL.Path == "/advanced_search.json": + _, _ = w.Write([]byte(`{"matches":[]}`)) + case r.Method == http.MethodPut && r.URL.Path == "/messages/5.json": + puts.Add(1) + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) + case r.Method == http.MethodPost && r.URL.Path == "/oauth/tokens": + _, _ = w.Write([]byte(`{"access_token":"fresh","refresh_token":"fresh-refresh","expires_in":3600,"scope":"read write send"}`)) + default: + t.Errorf("unexpected HTTP request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + })) + t.Cleanup(upstream.Close) + + session := runMCPCommandWith(t, upstream, auth.MCPScope, &mcp.ClientOptions{ + ElicitationHandler: func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + return &mcp.ElicitResult{Action: "accept", Content: map[string]any{"confirm": true}}, nil + }, + }) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "hey_send", + Arguments: map[string]any{"action": "send_draft", "params": map[string]any{"draft_id": 5}}, + }) + if err != nil { + t.Fatal(err) + } + if !result.IsError { + t.Errorf("a 401 delivery reported success: %v", result.Content) + } + if got := puts.Load(); got != 1 { + t.Errorf("HEY saw %d delivery requests, want 1", got) + } +} + +// A token that can't be renewed refuses the send without claiming the draft. The reads +// before the claim renew too, so this holds the outcome; the check right before the +// claim is held by mcpserver's TestAGrantNarrowedDuringTheReviewDoesntClaimTheDraft. +func TestMCPCommandDoesntClaimWhenTheTokenCantBeRenewed(t *testing.T) { + var puts, refreshes atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/messages/5/edit.json": + _, _ = w.Write([]byte(`{"id":5,"subject":"Re: Q3 plan","content":"
Hi Ann
","sender":{"id":77,"email_address":"jeremy@hey.example"},"addressed":{"directly":[{"id":1,"email_address":"ann@example.com"}]}}`)) + case r.Method == http.MethodGet && r.URL.Path == "/identity.json": + _, _ = w.Write([]byte(`{"id":1,"senders":[{"id":77,"email_address":"jeremy@hey.example"}]}`)) + case r.Method == http.MethodGet && r.URL.Path == "/advanced_search.json": + _, _ = w.Write([]byte(`{"matches":[]}`)) + case r.Method == http.MethodPost && r.URL.Path == "/oauth/tokens": + refreshes.Add(1) + w.WriteHeader(http.StatusServiceUnavailable) + case r.Method == http.MethodPut: + puts.Add(1) + _, _ = w.Write([]byte(`{"id":5,"topic_id":900}`)) + default: + t.Errorf("unexpected HTTP request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + })) + t.Cleanup(upstream.Close) + + orig := mcpTokenExpiresIn + mcpTokenExpiresIn = -time.Minute // expired: every use asks for a renewal + t.Cleanup(func() { mcpTokenExpiresIn = orig }) + session := runMCPCommandWith(t, upstream, auth.MCPScope, &mcp.ClientOptions{ + ElicitationHandler: func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + return &mcp.ElicitResult{Action: "accept", Content: map[string]any{"confirm": true}}, nil + }, + }) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "hey_send", + Arguments: map[string]any{"action": "send_draft", "params": map[string]any{"draft_id": 5}}, + }) + if err != nil { + t.Fatal(err) + } + if !result.IsError || puts.Load() != 0 { + t.Fatalf("sent (%d PUTs) on a token that couldn't be renewed: %v", puts.Load(), result.Content) + } + if strings.Contains(result.Content[0].(*mcp.TextContent).Text, "already sent") { + t.Error("the draft was claimed") + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 72ed289c3..ab0cbba9e 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -121,6 +121,7 @@ func newRootCmd() *cobra.Command { // that credential any more than it outlives a logout. authMgr.OnCredentialCleared(func() { clearHTTPCache(cmd.ErrOrStderr()) }) initSDK(authMgr, cfg.BaseURL) + initMCPAuth(httpClient, configDir) // The agent-local setup subcommands and skill commands never read // credentials and never rewrite the global config, so they defer @@ -276,6 +277,10 @@ func commandUsesAccountScope(cmd *cobra.Command) bool { switch parts[1] { case "account", "auth", "commands", "config", "doctor", "login", "logout", "setup", "shell-completion", "skill", "upgrade", "version": return false + case "mcp": + // hey mcp scopes its own clients, on its own credential (mcp.go): the shared + // client the pre-run would scope carries the CLI's. + return false default: return true } diff --git a/internal/cmd/sdk.go b/internal/cmd/sdk.go index 8d3637c15..543b0b980 100644 --- a/internal/cmd/sdk.go +++ b/internal/cmd/sdk.go @@ -129,13 +129,6 @@ func clearHTTPCache(errOut io.Writer) { } } -// newSDKClient builds another client sharing the CLI's configuration — auth, -// user agent, hooks, logging — plus any extra options. Valid after initSDK. -func newSDKClient(extra ...hey.ClientOption) *hey.Client { - opts := append(append([]hey.ClientOption{}, sdkClientOpts...), extra...) - return hey.NewClient(sdkClientCfg, nil, opts...) -} - // newUncachedSDKClient builds a sibling of sdk — same configuration, same account — // without the response cache, for a read a 304 must not answer. The cache revalidates // on HEY's ETag, and an ETag can cover less than the body: /boxes.json's is the box diff --git a/internal/config/mcp.go b/internal/config/mcp.go new file mode 100644 index 000000000..a64bfe027 --- /dev/null +++ b/internal/config/mcp.go @@ -0,0 +1,92 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "regexp" +) + +// DefaultMCPClientID is the client id of "HEY MCP (local)", the OAuth row `hey mcp` +// signs in as (haystack#9041), for every HEY environment. +// +// TODO(provision): empty until the production row exists. Jeremy provisions production +// first with `bin/rails oauth:provision_local_mcp_client`, which prints a generated +// id; beta runs on production's database and shares that row, and staging and dev are +// then provisioned with CLIENT_ID=, so this one constant serves them all, as +// the CLI's own id does. Until it is set, `hey auth login --mcp` needs HEY_MCP_CLIENT_ID +// or a configured id (MCPClientID). +const DefaultMCPClientID = "" + +// mcpClientIDPattern is what haystack's provisioning accepts for a client id: letters +// and digits. +var mcpClientIDPattern = regexp.MustCompile(`^[A-Za-z0-9]{1,64}$`) + +// Origin is baseURL's canonical origin (scheme and host lowercased, default port +// dropped), so one HEY has one spelling wherever it keys state. +func Origin(baseURL string) (string, error) { + return serverOrigin(baseURL) +} + +// MCPClientID resolves the local MCP client id for the HEY at baseURL, and where it came +// from: HEY_MCP_CLIENT_ID, then the global config's "mcp_client_ids" entry for that +// origin, then DefaultMCPClientID. It is read from the global config only, never a +// repository's .hey/config.json, so a checkout can't point hey mcp's sign-in at another +// client. An empty id means none is configured. +func MCPClientID(baseURL string) (string, Source, error) { + if env := os.Getenv("HEY_MCP_CLIENT_ID"); env != "" { + if !mcpClientIDPattern.MatchString(env) { + return "", SourceEnv, fmt.Errorf("HEY_MCP_CLIENT_ID must be letters and digits") + } + return env, SourceEnv, nil + } + + origin, err := serverOrigin(baseURL) + if err != nil { + return "", SourceDefault, err + } + ids, err := globalMCPClientIDs() + if err != nil { + return "", SourceDefault, err + } + if id, ok := ids[origin]; ok && id != "" { + if !mcpClientIDPattern.MatchString(id) { + return "", SourceGlobal, fmt.Errorf("mcp_client_ids for %s must be letters and digits", origin) + } + return id, SourceGlobal, nil + } + return DefaultMCPClientID, SourceDefault, nil +} + +// globalMCPClientIDs reads the global config's "mcp_client_ids", keyed by origin. +func globalMCPClientIDs() (map[string]string, error) { + path := globalConfigPath() + if path == "" { + return nil, nil + } + data, err := os.ReadFile(path) // #nosec G304 -- the fixed user configuration file + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("could not read config %s: %w", path, err) + } + var file struct { + MCPClientIDs map[string]string `json:"mcp_client_ids"` + } + if err := json.Unmarshal(data, &file); err != nil { + return nil, fmt.Errorf("could not parse config %s: %w", path, err) + } + ids := make(map[string]string, len(file.MCPClientIDs)) + for origin, id := range file.MCPClientIDs { + normalized, originErr := serverOrigin(origin) + if originErr != nil { + return nil, fmt.Errorf("could not parse mcp_client_ids origin %s in %s: %w", origin, path, originErr) + } + if existing, duplicate := ids[normalized]; duplicate && existing != id { + return nil, fmt.Errorf("conflicting mcp_client_ids for %s in %s", normalized, path) + } + ids[normalized] = id + } + return ids, nil +} diff --git a/internal/config/mcp_test.go b/internal/config/mcp_test.go new file mode 100644 index 000000000..6d3c5ebac --- /dev/null +++ b/internal/config/mcp_test.go @@ -0,0 +1,68 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestMCPClientIDResolution(t *testing.T) { + home := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", home) + t.Setenv("HEY_MCP_CLIENT_ID", "") + + id, source, err := MCPClientID("https://app.hey.com") + if err != nil || id != DefaultMCPClientID || source != SourceDefault { + t.Errorf("no config: %q %q %v, want the default", id, source, err) + } + + dir := filepath.Join(home, configDirName) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + global := `{"base_url":"https://app.hey.com","mcp_client_ids":{"https://app.hey.com/":"ProdLocalMcp000000000001","http://app.hey.localhost:3003":"DevLocalMcp0000000000001"}}` + if err := os.WriteFile(filepath.Join(dir, configFile), []byte(global), 0o600); err != nil { + t.Fatal(err) + } + if id, source, _ := MCPClientID("https://app.hey.com"); id != "ProdLocalMcp000000000001" || source != SourceGlobal { + t.Errorf("production: %q %q", id, source) + } + if id, _, _ := MCPClientID("http://app.hey.localhost:3003"); id != "DevLocalMcp0000000000001" { + t.Errorf("dev: %q, want the id configured for its origin", id) + } + + t.Setenv("HEY_MCP_CLIENT_ID", "EnvLocalMcp0000000000001") + if id, source, _ := MCPClientID("https://app.hey.com"); id != "EnvLocalMcp0000000000001" || source != SourceEnv { + t.Errorf("env: %q %q, want the environment to win", id, source) + } + + t.Setenv("HEY_MCP_CLIENT_ID", "not an id&scope=full") + if _, _, err := MCPClientID("https://app.hey.com"); err == nil { + t.Error("a malformed HEY_MCP_CLIENT_ID was accepted") + } +} + +func TestConflictingSpellingsOfOneOriginAreRefused(t *testing.T) { + home := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", home) + t.Setenv("HEY_MCP_CLIENT_ID", "") + dir := filepath.Join(home, configDirName) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + global := `{"mcp_client_ids":{"https://app.hey.com":"FirstLocalMcp00000000001","https://APP.HEY.COM:443":"OtherLocalMcp00000000001"}}` + if err := os.WriteFile(filepath.Join(dir, configFile), []byte(global), 0o600); err != nil { + t.Fatal(err) + } + if _, _, err := MCPClientID("https://app.hey.com"); err == nil { + t.Error("two ids for one origin resolved to one of them") + } +} + +func TestOriginHasOneSpelling(t *testing.T) { + for _, spelling := range []string{"https://app.hey.com", "https://APP.HEY.COM:443", "https://app.hey.com/"} { + if got, err := Origin(spelling); err != nil || got != "https://app.hey.com" { + t.Errorf("Origin(%q) = %q, %v", spelling, got, err) + } + } +} diff --git a/internal/mcpserver/dispatch.go b/internal/mcpserver/dispatch.go index cb7882f9b..4f66c0735 100644 --- a/internal/mcpserver/dispatch.go +++ b/internal/mcpserver/dispatch.go @@ -42,6 +42,9 @@ var _ API = (*hey.Client)(nil) // three. Failures are in-band isError results per MCP convention. type dispatcher struct { api API + // markExposed records that a read may have returned an unscreened sender's words + // (exposure.go). Nil when nothing is served that exposure could tighten. + markExposed func(ctx context.Context, action string) error } func (d dispatcher) handle(ctx context.Context, dom gateway.Domain, op gateway.Operation, params map[string]any) (*mcp.CallToolResult, error) { @@ -78,16 +81,36 @@ func (d dispatcher) handle(ctx context.Context, dom gateway.Domain, op gateway.O } return gateway.ErrorResult("%v", err), nil } + // A write whose success is one exact status is checked first, so nothing below + // reports a redirect's 200 as applied. + if want, ok := acknowledgedBy[full.ID]; ok && resp.StatusCode != want { + return gateway.ErrorResult("%s was not acknowledged: HEY answered HTTP %d, not %d, so nothing was changed", + full.Action, resp.StatusCode, want), nil + } + // The exposure marker lands before the content leaves (D6). A read not in the + // table exposes: it can't be shown not to. So does a write that answers with a body + // (reveal_contact answers the contact): what HEY returns is content, whatever the + // verb. A draft save's answer is this server's own. + if d.markExposed != nil && !draft && exposingAnswer(full, resp) { + if err := d.markExposed(ctx, full.Action); err != nil { + if !full.ReadOnly { + // HEY already applied the write: say so, so it isn't retried, and + // withhold only the answer. + return gateway.JSONResult(map[string]any{ + "applied": true, + "status": resp.StatusCode, + "note": fmt.Sprintf("HEY applied %s. Its answer is withheld because hey mcp couldn't record that it may include mail from an unscreened sender (%v). Don't retry it.", full.Action, err), + }) + } + return gateway.ErrorResult("hey mcp can't return %s right now: it couldn't record that the answer may include mail from an unscreened sender (%v)", full.Action, err), nil + } + } if draft { if !savedAsDraft(resp) { return gateway.ErrorResult("%s", unconfirmedDraft(full.Action, resp)), nil } return gateway.JSONResult(draftResult(resp)) } - if want, ok := acknowledgedBy[full.ID]; ok && resp.StatusCode != want { - return gateway.ErrorResult("%s was not acknowledged: HEY answered HTTP %d, not %d, so nothing was changed", - full.Action, resp.StatusCode, want), nil - } if len(bytes.TrimSpace(resp.Data)) == 0 { result := map[string]any{"status": resp.StatusCode} // A draft save answers 204 with the saved entry's path in Location; @@ -111,6 +134,17 @@ func (d dispatcher) handle(ctx context.Context, dom gateway.Domain, op gateway.O return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: string(resp.Data)}}}, nil } +// exposingAnswer reports whether an answer may carry an unscreened sender's words +// (exposure.go): a read the table doesn't clear, or a write whose answer has a body. +func exposingAnswer(op *catalog.Operation, resp *hey.Response) bool { + if op.ReadOnly { + exposes, classified := readExposes[op.ID] + return exposes || !classified + } + body := bytes.TrimSpace(resp.Data) + return len(body) > 0 && !bytes.Equal(body, []byte("null")) +} + // acknowledgedBy names the mutations whose success is one exact status, because a // redirect would otherwise pass for it: net/http follows a PATCH's 302 as a GET, and // the read that answers is a 200 the dispatcher would hand back as a result. HEY diff --git a/internal/mcpserver/domains.go b/internal/mcpserver/domains.go index 6dd1e1972..2c4dbb806 100644 --- a/internal/mcpserver/domains.go +++ b/internal/mcpserver/domains.go @@ -26,7 +26,7 @@ var DomainSpecs = []catalog.DomainSpec{ { Key: "threads", Tags: []string{"Topics", "Entries", "Messages"}, - Blurb: "HEY email threads: topics and their entries, renaming a thread, full message content, replies and forwards, drafts, and triage (trash, spam, restore, move). create_message, create_reply and update_message only ever save drafts: they never send or schedule mail, and a request to send, schedule or triage in the same call is refused. Updating a scheduled draft clears its schedule. There is no send action; a person sends a draft from HEY or with `hey draft send `.", + Blurb: "HEY email threads: topics and their entries, renaming a thread, full message content, replies and forwards, drafts, and triage (trash, spam, restore, move). create_message, create_reply and update_message only ever save drafts: they never send or schedule mail, and a request to send, schedule or triage in the same call is refused. Updating a scheduled draft clears its schedule. Delivery is hey_send's send_draft, which asks the person to confirm each email, where it's served; a person can also send a draft from HEY or with `hey draft send `.", }, { Key: "contacts", diff --git a/internal/mcpserver/drafts.go b/internal/mcpserver/drafts.go index fe4d811c0..558a0b127 100644 --- a/internal/mcpserver/drafts.go +++ b/internal/mcpserver/drafts.go @@ -29,8 +29,10 @@ import ( // schedule when an update omits it, so editing a scheduled draft unschedules // it rather than letting it go out later. // -// MCP has no way to deliver mail yet: there is no send_draft action. A person -// sends the draft from HEY or with `hey draft send `. +// These actions never deliver. Delivery is send_draft (send.go), in its own hey_send +// tool, which asks the person to confirm each email, and is served only when hey mcp's +// sign-in can send. A person can also send the draft from HEY or with +// `hey draft send `. // TestEveryServedOpenWorldOperationIsDraftFirst holds this list total: mapping // another operation that can reach people outside the mailbox fails until it // has a rule here. @@ -45,8 +47,8 @@ var draftEntryKeys = map[string]bool{"addressed": true, "status": true} const drafted = "drafted" -// howToSend is how a person delivers a draft this server saved. -const howToSend = "a person sends it from HEY or with `hey draft send `" +// howToSend is how a draft this server saved gets delivered. +const howToSend = "call hey_send's send_draft with its draft_id, which asks the person to confirm the email (where this server serves hey_send), or a person sends it from HEY or with `hey draft send `" // draftOnly returns params with the entry forced to a draft, or an error // naming what would have delivered. params is not modified. @@ -59,12 +61,12 @@ func draftOnly(action string, params map[string]any) (map[string]any, error) { } for _, key := range slices.Sorted(maps.Keys(given)) { if !draftEntryKeys[key] { - return nil, fmt.Errorf("%s saves a draft and doesn't take entry.%s: hey mcp never sends or schedules mail, and triage has its own actions (allowed: entry.%s). Nothing was sent to HEY. To deliver, save the draft, then %s", + return nil, fmt.Errorf("%s saves a draft and doesn't take entry.%s: it never sends or schedules mail, and triage has its own actions (allowed: entry.%s). Nothing was sent to HEY. To deliver, save the draft, then %s", action, key, strings.Join(slices.Sorted(maps.Keys(draftEntryKeys)), ", entry."), howToSend) } } if status, ok := given["status"]; ok && status != drafted { - return nil, fmt.Errorf("%s saves a draft: entry.status can only be %q, because hey mcp never sends mail. Nothing was sent to HEY. To deliver, save the draft, then %s", + return nil, fmt.Errorf("%s saves a draft: entry.status can only be %q, because it never sends mail. Nothing was sent to HEY. To deliver, save the draft, then %s", action, drafted, howToSend) } maps.Copy(entry, given) @@ -137,9 +139,9 @@ func draftResult(resp *hey.Response) map[string]any { result := map[string]any{"status": resp.StatusCode, "location": location, "drafted": true} if id := draftID(location); id != "" { result["draft_id"] = id - result["note"] = "Saved as a draft, not sent. hey mcp doesn't deliver mail: a person reviews and sends it from HEY, or with `hey draft send " + id + "`." + result["note"] = "Saved as a draft, not sent. To deliver it, call hey_send's send_draft with draft_id " + id + ", which asks the person to confirm it (where this server serves hey_send), or a person sends it from HEY or with `hey draft send " + id + "`." } else { - result["note"] = "Saved as a draft, not sent. hey mcp doesn't deliver mail: " + howToSend + "." + result["note"] = "Saved as a draft, not sent. To deliver it, " + howToSend + "." } return result } diff --git a/internal/mcpserver/exposure.go b/internal/mcpserver/exposure.go new file mode 100644 index 000000000..97dddbdb1 --- /dev/null +++ b/internal/mcpserver/exposure.go @@ -0,0 +1,72 @@ +package mcpserver + +// Exposure on the stdio gateway (sending-safety D6). +// +// A grant is exposed for ten minutes after a read returns anything an unscreened sender +// wrote, and while it's exposed send_draft refuses drafts to anyone not known (D4). The +// gateway serves HEY's responses raw, and most of them don't say whether a sender is +// screened in (GetTopic's screened_in, haystack#9083, isn't in the hey-sdk model this +// package embeds), so each served read is classified by what it can return: +// +// - false: it can't carry an unscreened sender's words. A box read lists postings, +// and HEY posts a thread to a box only once the Screener let it through +// (hey-mcp-server's provenance.go reads boxes the same way). Calendars, identity +// and box groups are the account's own. +// - true: it can, or it can't be ruled out. Search, threads, messages, drafts (a reply +// draft quotes what it answers), the Screener, contacts (a contact may be a sender +// waiting in the Screener) and calendar recordings (an invite's title is its +// sender's) all expose. +// +// The marker is written before the content is returned, and a read whose marker can't be +// written is refused, so unscreened content never reaches the model unrecorded. +// TestEveryServedReadIsClassifiedForExposure holds this table total: serving another +// read fails until it has an entry here. +var readExposes = map[string]bool{ + // boxes + "GetAsidebox": false, + "GetBox": false, + "GetBoxGroup": false, + "GetBoxPostingChanges": false, + "GetBubblebox": false, + "GetFeedbox": false, + "GetImbox": false, + "GetImboxSeen": false, + "GetLaterbox": false, + "GetTrailbox": false, + "ListBoxGroups": false, + "ListBoxes": false, + + // search + "AdvancedSearch": true, + "GetAdvancedSearchFilters": true, + + // threads + "GetEverythingTopics": true, + "GetMessage": true, + "GetMessageEdit": true, + "GetSentTopics": true, + "GetSpamTopics": true, + "GetTopic": true, + "GetTopicCommentAudience": true, + "GetTopicEntries": true, + "GetTrashTopics": true, + "ListDrafts": true, + "NewEntryForward": true, + "NewEntryReply": true, + + // contacts and the Screener + "GetClearances": true, + "GetContact": true, + "GetContactNote": true, + "GetMyClearances": true, + "ListAddressableContacts": true, + "ListContacts": true, + + // calendar + "GetCalendarRecordings": true, + "ListCalendars": false, + + // identity + "GetIdentity": false, + "GetNavigation": false, +} diff --git a/internal/mcpserver/send.go b/internal/mcpserver/send.go new file mode 100644 index 000000000..56554edc3 --- /dev/null +++ b/internal/mcpserver/send.go @@ -0,0 +1,738 @@ +package mcpserver + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "slices" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/basecamp/hey-sdk/go/pkg/generated" + hey "github.com/basecamp/hey-sdk/go/pkg/hey" + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/basecamp/mcp/gateway" +) + +// send_draft: delivery, confirmed by the person and single-use (sending-safety D2-D6). +// +// It is served in a tool of its own, hey_send, so its annotations can say what it is +// (destructive, open world) without marking every mail read so (D7). It is served only +// when hey mcp's credential holds send and read, and never under --read-only. +// +// A call runs in two rounds. The first fetches the draft, applies the recipient and +// exposure policy (D4, D6), and asks the person to confirm through MCP elicitation, with +// the draft's fingerprint signed into the request state. The second, carrying the +// person's answer and that state, fetches the draft again, refuses it if anything the +// fingerprint covers changed, applies the policy again, claims the draft and charges the +// caps in one locked update of the state file, and sends once, writing every delivered +// field from the snapshot it just verified. A client on the 2026-07-28 protocol carries +// the rounds itself (SEP-2322); for an older client the SDK elicits and calls back in +// the same request. A client that declares no elicitation gets a draft and a link (D3). +// +// What isn't here yet: send_at (scheduled delivery, which D2 also routes through +// send_draft). A draft that's already scheduled is refused. + +// Drafts is the slice of hey-sdk send_draft drives. GetEdit, Identity and Search read; +// SendDraft must go through a client that never retries, because a retried send can +// deliver twice (D2.4). +type Drafts interface { + GetEdit(ctx context.Context, draftID int64) (*generated.MessageEditState, error) + SendDraft(ctx context.Context, draftID int64, draft hey.DraftContent) (*generated.SentMessage, error) + Identity(ctx context.Context) (*generated.Identity, error) + Search(ctx context.Context, params hey.SearchParams) (*generated.AdvancedSearchResult, error) +} + +// Grant is the security principal on stdio: hey mcp's sign-in (D4). +type Grant struct { + // Key names the sign-in; caps and exposure are counted per key. + Key string + // GrantedAt is when it was made. Only mail sent before it makes a recipient known. + GrantedAt time.Time +} + +// SendConfig serves send_draft. Nil serves none. +type SendConfig struct { + Drafts Drafts + State *SendState + Grant Grant + // Origin is the HEY this runs against, e.g. https://app.hey.com: links point there, + // and claims are keyed by it. + Origin string + // Now is the clock; nil is time.Now. + Now func() time.Time + // CurrentGrant answers the sign-in's key as stored now, renewing a token about to + // expire first, so the send that may follow goes out on a live one. When it no longer matches + // Grant.Key (hey mcp was signed in again or out since this process started, and the + // token the client authenticates with may be the new one), sends and exposing reads + // are refused until hey mcp restarts, so caps and exposure never count against a + // sign-in other than the one the requests ride. Nil skips the check. + CurrentGrant func(context.Context) (string, error) +} + +const ( + sendToolName = "hey_send" + sendDraftName = "send_draft" + confirmInputID = "confirm_send" + + // confirmKind and confirmMethod mark a signed state as a send_draft confirmation, + // so no other validator accepts it (basecamp-mcp-server's confirm.go). + confirmKind = "send_draft" + confirmMethod = "tools/call\x00send_draft" + + // confirmTTL: a send confirmation should not outlive the person's attention (D2). + confirmTTL = 5 * time.Minute + + // D4's caps. Starting values to tune from beta usage. + maxRecipients = 20 + maxSendsPerDay = 50 + maxNewAddressesPerDay = 10 + + // bodyPromptCap is how much of the body the confirmation shows (D2). + bodyPromptCap = 1500 + // headerCap bounds each single-line header field in the confirmation. + headerCap = 200 +) + +type sender struct { + cfg SendConfig + key []byte +} + +func newSender(cfg SendConfig) (*sender, error) { + switch { + case cfg.Drafts == nil || cfg.State == nil: + return nil, errors.New("send_draft needs HEY's drafts and a state file") + case cfg.Grant.Key == "": + return nil, errors.New("send_draft needs a sign-in to key its state on") + case cfg.Origin == "": + return nil, errors.New("send_draft needs the HEY origin") + } + if cfg.Now == nil { + cfg.Now = time.Now + } + key, err := cfg.State.ConfirmKey(context.Background()) + if err != nil { + return nil, err + } + return &sender{cfg: cfg, key: key}, nil +} + +const sendDescription = "Send a saved draft: the only way hey mcp delivers mail. " + + "Each send asks the person to confirm it in this app, showing who it goes to (To, Cc and Bcc), the subject, attachments and the body. " + + "The confirmation is bound to that exact draft, so a draft changed after it was approved isn't sent, and each draft can be sent through hey mcp once. " + + "A client that can't show a confirmation gets the draft's link in HEY instead, and nothing is sent: saying yes in the chat doesn't send it. " + + "Drafts to someone this account hasn't emailed before are capped per day, and refused for a while after reading mail that may be from an unscreened sender. " + + "A draft that is already scheduled isn't sent here. Actions: send_draft (params: draft_id, the id create_message or create_reply returned), describe." + +func (s *sender) tool() *mcp.Tool { + destructive, openWorld := true, true + return &mcp.Tool{ + Name: sendToolName, + Title: "Send a HEY draft", + Description: sendDescription, + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "action": map[string]any{"type": "string", "enum": []any{sendDraftName, gateway.DescribeAction}}, + "params": map[string]any{ + "type": "object", + "properties": map[string]any{ + "draft_id": map[string]any{"type": []any{"integer", "string"}, "description": "The draft's id, from create_message, create_reply or list_drafts."}, + }, + }, + }, + "required": []any{"action"}, + }, + Annotations: &mcp.ToolAnnotations{ + Title: "Send a HEY draft", + ReadOnlyHint: false, + DestructiveHint: &destructive, + IdempotentHint: false, + OpenWorldHint: &openWorld, + }, + } +} + +func (s *sender) handle(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var args struct { + Action string `json:"action"` + Params map[string]any `json:"params"` + } + if len(req.Params.Arguments) > 0 { + if err := json.Unmarshal(req.Params.Arguments, &args); err != nil { + return gateway.ErrorResult("invalid arguments: %v", err), nil + } + } + switch args.Action { + case gateway.DescribeAction: + return gateway.JSONResult(map[string]any{ + "action": sendDraftName, + "description": sendDescription, + "params": map[string]any{"draft_id": "integer (required)"}, + }) + case sendDraftName: + default: + return gateway.ErrorResult("unknown action %q in %s (actions: %s, %s)", args.Action, sendToolName, sendDraftName, gateway.DescribeAction), nil + } + + draftID, err := draftIDParam(args.Params) + if err != nil { + return gateway.ErrorResult("%v", err), nil + } + if err := s.checkGrant(ctx); err != nil { + return gateway.ErrorResult("not sent: %v", err), nil + } + + // A host that can't show the person a confirmation can't send (D3). There is no + // "I confirm" parameter: a value the model supplies is what an injected + // instruction would supply. + if !canElicitForm(req.Session) { + return s.notSent(draftID, "This MCP client can't show a confirmation, so hey mcp can't send from it."), nil + } + + if req.Params.RequestState == "" { + return s.ask(ctx, draftID) + } + return s.confirmAndSend(ctx, draftID, req.Params.RequestState, req.Params.InputResponses) +} + +// ask is the first round: review the draft and ask the person to confirm it. +func (s *sender) ask(ctx context.Context, draftID int64) (*mcp.CallToolResult, error) { + snap, outcome, refusal := s.review(ctx, draftID) + if refusal != nil { + return refusal, nil + } + + state := s.sign(confirmState{ + Kind: confirmKind, Method: confirmMethod, + Grant: s.cfg.Grant.Key, Origin: s.cfg.Origin, Draft: draftID, + Fingerprint: snap.fingerprint, + Plain: outcome.plain, + Expires: s.cfg.Now().Add(confirmTTL).Unix(), + }) + + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{confirmInputID: confirmElicitation(s.prompt(snap, outcome))}, + RequestState: state, + }, nil +} + +// confirmAndSend is the second round: the person's answer, then the checks again, the +// claim, and the one send. +func (s *sender) confirmAndSend(ctx context.Context, draftID int64, rawState string, responses mcp.InputResponseMap) (*mcp.CallToolResult, error) { + state, err := s.verify(rawState) + if err != nil { + return gateway.ErrorResult("the confirmation for this send isn't valid (%v), so nothing was sent; call send_draft again to ask again", err), nil + } + if state.Draft != draftID || state.Grant != s.cfg.Grant.Key || state.Origin != s.cfg.Origin { + return gateway.ErrorResult("the confirmation was for a different draft or sign-in, so nothing was sent; call send_draft again"), nil + } + + answer, _ := responses[confirmInputID].(*mcp.ElicitResult) + if answer == nil || answer.Action != "accept" || answer.Content["confirm"] != true { + return s.notSent(draftID, "The send wasn't approved."), nil + } + + // The approval covers content, never a policy decision: the policy runs again. + snap, outcome, refusal := s.review(ctx, draftID) + if refusal != nil { + return refusal, nil + } + if outcome.plain != state.Plain { + return gateway.ErrorResult("not sent: since you approved it, this conversation read unscreened mail or the recipients' history changed, which changes how the draft would go. Call send_draft again to review it as it would go now"), nil + } + if !hmac.Equal([]byte(snap.fingerprint), []byte(state.Fingerprint)) { + return gateway.ErrorResult("not sent: the draft changed since you approved it (its recipients, sender, subject, body or attachments differ). Call send_draft again to review it as it is now"), nil + } + + // The review's reads may have refreshed the token, and a refresh can narrow the + // grant. Check again just before claiming, so a send HEY would refuse doesn't + // block the draft for a day. + if err = s.checkGrant(ctx); err != nil { + return gateway.ErrorResult("not sent: %v", err), nil + } + if err = s.cfg.State.claim(ctx, s.cfg.Origin, draftID, s.cfg.Grant.Key, outcome.newList, !outcome.plain, s.cfg.Now()); err != nil { + var refused *refusalError + if errors.As(err, &refused) { + return gateway.ErrorResult("%s", refused.reason), nil + } + return gateway.ErrorResult("not sent: hey mcp couldn't record the send (%v), so it didn't risk sending it twice. Send it from HEY", err), nil + } + + // From here the draft is claimed for good: the claim is never released, even when + // HEY refuses, so there's no rollback to get wrong (D2.3). + sent, err := s.cfg.Drafts.SendDraft(ctx, draftID, snap.content(outcome.plain)) + if err != nil { + if refusedByHEY(err) { + return gateway.ErrorResult("HEY refused the send: %v. The draft is still a draft, and hey mcp won't send it again for 24 hours; send it from HEY: %s", err, s.editURL(draftID)), nil + } + return gateway.ErrorResult("the send may have gone out: %v. Check Sent in HEY before doing anything else; hey mcp won't send this draft again", err), nil + } + + // HEY refuses a delivery it won't make (usually the account's sending limit) by + // keeping the draft and redirecting to its edit page, which reads as a message with + // an id and no thread. Only an edit page served says it's still a draft (as + // mail.ConfirmDelivered reads it for hey draft send). + if sent != nil && sent.Id != 0 && sent.TopicId == 0 { + if edit, editErr := s.cfg.Drafts.GetEdit(ctx, sent.Id); editErr == nil && edit != nil { + return gateway.ErrorResult("not sent: HEY kept the draft instead of sending it, usually because the account reached its sending limit. hey mcp won't send this draft again for 24 hours; send it from HEY when the limit allows: %s", s.editURL(draftID)), nil + } + } + + result := map[string]any{"sent": true, "draft_id": draftID, "note": "Sent through HEY's undo window. The person approved it in this app."} + if sent != nil { + if sent.Id != 0 { + result["message_id"] = sent.Id + } + if sent.TopicId != 0 { + result["topic_id"] = sent.TopicId + } + result["delayed"] = sent.Delayed + } + return gateway.JSONResult(result) +} + +// checkGrant refuses when the stored sign-in isn't the one this process started on. +func (s *sender) checkGrant(ctx context.Context) error { + if s.cfg.CurrentGrant == nil { + return nil + } + current, err := s.cfg.CurrentGrant(ctx) + if err != nil { + return fmt.Errorf("hey mcp couldn't read its sign-in (%w)", err) + } + if current != s.cfg.Grant.Key { + return errors.New("hey mcp was signed in again, or signed out, since it started; restart it") + } + return nil +} + +// markExposed records an exposing read against this process's sign-in, refusing when +// the stored sign-in has changed under it. +func (s *sender) markExposed(ctx context.Context, action string) error { + if err := s.checkGrant(ctx); err != nil { + return err + } + return s.cfg.State.MarkExposed(ctx, s.cfg.Grant.Key, action, s.cfg.Now()) +} + +// notSent is D3's answer: not an error, a draft and where to send it. +func (s *sender) notSent(draftID int64, why string) *mcp.CallToolResult { + url := s.editURL(draftID) + result, _ := gateway.JSONResult(map[string]any{ + "sent": false, + "draft_id": draftID, + "edit_url": url, + "note": why + " Draft saved but not sent. Review and send it in HEY: " + url, + }) + return result +} + +func (s *sender) editURL(draftID int64) string { + return fmt.Sprintf("%s/messages/%d/edit", strings.TrimRight(s.cfg.Origin, "/"), draftID) +} + +func draftIDParam(params map[string]any) (int64, error) { + raw, ok := params["draft_id"] + if !ok { + return 0, errors.New("send_draft needs params.draft_id, the id create_message or create_reply returned") + } + var id int64 + switch v := raw.(type) { + case float64: + id = int64(v) + if float64(id) != v { + id = 0 + } + case string: + id, _ = strconv.ParseInt(v, 10, 64) + } + if id <= 0 { + return 0, fmt.Errorf("params.draft_id must be a draft's id, got %v", raw) + } + for key := range params { + if key != "draft_id" { + return 0, fmt.Errorf("send_draft takes only params.draft_id, not %q", key) + } + } + return id, nil +} + +// canElicitForm reports whether the client declared form elicitation. A client that +// declares the capability with neither mode is read as form, as the SDK does. +func canElicitForm(session *mcp.ServerSession) bool { + if session == nil { + return false + } + params := session.InitializeParams() + if params == nil || params.Capabilities == nil || params.Capabilities.Elicitation == nil { + return false + } + caps := params.Capabilities.Elicitation + return caps.Form != nil || caps.URL == nil +} + +func confirmElicitation(message string) *mcp.ElicitParams { + return &mcp.ElicitParams{ + Mode: "form", + Message: message, + RequestedSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "confirm": map[string]any{ + "type": "boolean", + "title": "Send this email", + "description": "Check to send it now. Anything else, or declining, sends nothing.", + }, + }, + "required": []any{"confirm"}, + }, + } +} + +// --- the draft as reviewed --- + +// draftSnapshot is a draft as send_draft fetched it, and what it would deliver. +type draftSnapshot struct { + id int64 + from generated.Contact + actingSenderID int64 + to, cc, bcc []generated.Contact + subject string + body reviewedBody + scheduled time.Time + fingerprint string +} + +// content is the delivery request: every field from the verified snapshot, so an edit +// landing after the final fetch is overwritten by what was approved (D2). HEY replaces +// the whole recipient set when addressed is present (haystack Entry::Addressed#address), +// so a Bcc added concurrently is dropped rather than delivered. The body is the one the +// fingerprint hashed, attachments rebuilt from their identity, or, when plain, the +// plain text the confirmation showed (sendbody.go). +func (d *draftSnapshot) content(plain bool) hey.DraftContent { + body, subject := d.body.delivered, d.subject + if plain { + body, subject = d.body.plainHTML, plainSubject(d.subject) + } + return hey.DraftContent{ + Subject: subject, + Content: body, + To: emails(d.to), + CC: emails(d.cc), + BCC: emails(d.bcc), + ActingSenderID: d.actingSenderID, + } +} + +func (d *draftSnapshot) recipients() []generated.Contact { + return slices.Concat(d.to, d.cc, d.bcc) +} + +func emails(contacts []generated.Contact) []string { + out := make([]string, 0, len(contacts)) + for _, c := range contacts { + out = append(out, c.EmailAddress) + } + return out +} + +func normalizedAddress(address string) string { + return strings.ToLower(strings.TrimSpace(address)) +} + +func snapshotOf(edit *generated.MessageEditState) (*draftSnapshot, error) { + body, err := analyzeBody(edit.Content) + if err != nil { + return nil, err + } + snap := &draftSnapshot{ + id: edit.Id, + from: edit.Sender, + to: edit.Addressed.Directly, + cc: edit.Addressed.Copied, + bcc: edit.Addressed.Blindcopied, + subject: edit.Subject, + body: body, + scheduled: edit.ScheduledDeliveryAt, + } + // HEY omits sender when the draft is sent as its creator contact, and the identity + // the draft was saved as rides the send (cmd's draftContentFrom does the same). + if snap.from.Id == 0 { + snap.from = edit.Creator + } + snap.actingSenderID = snap.from.Id + + sorted := func(contacts []generated.Contact) []string { + addresses := emails(contacts) + for i := range addresses { + addresses[i] = normalizedAddress(addresses[i]) + } + slices.Sort(addresses) + return addresses + } + fingerprint, err := json.Marshal(map[string]any{ + "v": 1, + "draft_id": snap.id, + "from_id": snap.actingSenderID, + "from": normalizedAddress(snap.from.EmailAddress), + "to": sorted(snap.to), + "cc": sorted(snap.cc), + "bcc": sorted(snap.bcc), + "subject": snap.subject, + "attachments": body.attachments, + "body_sha256": body.hash, + "mode": "now", + "scheduled_at": nil, + }) + if err != nil { + return nil, err + } + sum := sha256.Sum256(fingerprint) + snap.fingerprint = hex.EncodeToString(sum[:]) + return snap, nil +} + +// reviewOutcome is what the policy found, for the prompt. +type reviewOutcome struct { + // plain: the draft goes as the plain text the confirmation shows (sendbody.go), + // because it's to someone new or the sign-in is exposed. Otherwise it goes as it is. + plain bool + newList []string + newAddresses map[string]bool + exposed bool + exposedBy string +} + +// review fetches the draft and applies the policy, returning a refusal as the call's +// result when the draft can't be sent here. +func (s *sender) review(ctx context.Context, draftID int64) (*draftSnapshot, *reviewOutcome, *mcp.CallToolResult) { + refuse := func(format string, a ...any) (*draftSnapshot, *reviewOutcome, *mcp.CallToolResult) { + return nil, nil, gateway.ErrorResult(format, a...) + } + + edit, err := s.cfg.Drafts.GetEdit(ctx, draftID) + if err != nil { + var apiErr *hey.Error + if errors.As(err, &apiErr) && (apiErr.HTTPStatus == http.StatusNotFound || apiErr.HTTPStatus == http.StatusUnprocessableEntity) { + return refuse("not sent: %d isn't a draft HEY can send (it may have been sent, trashed or deleted)", draftID) + } + return refuse("not sent: couldn't read draft %d: %v", draftID, err) + } + // HEY serves a message's edit state only while it's a live draft (haystack + // MessagesController#ensure_revisable_entry: drafted and not delayed), so reading it + // is the check that it hasn't been trashed or delivered. + if edit == nil || edit.Id != draftID { + return refuse("not sent: %d isn't a draft HEY can send", draftID) + } + snap, err := snapshotOf(edit) + if err != nil { + return refuse("not sent: %v", err) + } + + recipients := snap.recipients() + switch { + case !snap.scheduled.IsZero(): + return refuse("not sent: this draft is already scheduled; change or send it in HEY: %s", s.editURL(draftID)) + case len(recipients) == 0: + return refuse("not sent: the draft has no recipients; add them with update_message (entry.addressed)") + case len(recipients) > maxRecipients: + return refuse("not sent: hey mcp sends to at most %d recipients across To, Cc and Bcc, and this draft has %d. Send it from HEY: %s", maxRecipients, len(recipients), s.editURL(draftID)) + case snap.body.unverifiable != "": + return refuse("not sent: the draft's body has an attachment hey mcp can't verify (%s), so the confirmation couldn't show what it sends. Send it from HEY: %s", snap.body.unverifiable, s.editURL(draftID)) + case snap.body.remote != "": + return refuse("not sent: the draft's body has a reference a mail client loads by itself (%s), which can carry mailbox data to whoever hosts it. Send it from HEY: %s", snap.body.remote, s.editURL(draftID)) + } + for _, r := range recipients { + if normalizedAddress(r.EmailAddress) == "" { + return refuse("not sent: a recipient has no email address") + } + } + + newAddresses := s.newAddresses(ctx, snap) + exposed, by, err := s.cfg.State.exposure(ctx, s.cfg.Grant.Key, s.cfg.Now()) + if err != nil { + // Without the marker the server can't know, so it treats the grant as exposed + // (D6), and the claim would need the same file anyway. + return refuse("not sent: hey mcp couldn't read its send state (%v). Send it from HEY", err) + } + if exposed && len(newAddresses) > 0 { + return refuse("not sent: this conversation read mail that may be from an unscreened sender (%s) in the last %d minutes, and the draft is addressed to someone you haven't emailed before (%s). Send it from HEY: %s", + by, int(exposureWindow/time.Minute), newAddresses[0], s.editURL(draftID)) + } + + // Measured on the escaped text the prompt shows: escapes lengthen it, and what + // falls past the cut is unseen however short the raw text was. + // To someone new, or while exposed, nothing may go that the person didn't read + // (D2): the draft goes as the plain text the confirmation shows, so it has to fit + // there whole, and an attachment, which a name and a size don't show, can't go. + plain := len(newAddresses) > 0 || exposed + if plain && (len(snap.body.attachments) > 0 || utf8.RuneCountInString(snap.body.plainText) > bodyPromptCap) { + return refuse("not sent: the draft can't be fully reviewed here (it has an attachment, or more than %d characters), and it goes to someone you haven't emailed before or this conversation read unscreened mail. Send it from HEY: %s", + bodyPromptCap, s.editURL(draftID)) + } + + sends, charged, err := s.cfg.State.budget(ctx, s.cfg.Grant.Key, s.cfg.Now()) + if err != nil { + return refuse("not sent: hey mcp couldn't read its send state (%v). Send it from HEY", err) + } + if sends >= maxSendsPerDay { + return refuse("not sent: hey mcp sends at most %d messages a day on one sign-in, and this one has reached it. Send it from HEY: %s", maxSendsPerDay, s.editURL(draftID)) + } + uncharged := 0 + for _, address := range newAddresses { + if !charged[address] { + uncharged++ + } + } + if len(charged)+uncharged > maxNewAddressesPerDay { + return refuse("not sent: hey mcp sends to at most %d new addresses a day on one sign-in, and this draft would go past it. Send it from HEY: %s", maxNewAddressesPerDay, s.editURL(draftID)) + } + + outcome := &reviewOutcome{plain: plain, newList: newAddresses, newAddresses: map[string]bool{}, exposed: exposed, exposedBy: by} + for _, address := range newAddresses { + outcome.newAddresses[address] = true + } + return snap, outcome, nil +} + +// --- known recipients (D4) --- + +// newAddresses returns the draft's recipients that aren't known, normalized and in +// order. Known means history the agent can't manufacture: mail this account sent to the +// address before the sign-in existed. Contact records don't count, since a model could +// create one. Anything send_draft can't prove known, a failed lookup included, is new. +func (s *sender) newAddresses(ctx context.Context, snap *draftSnapshot) []string { + var addresses []string + for _, r := range snap.recipients() { + address := normalizedAddress(r.EmailAddress) + if !slices.Contains(addresses, address) { + addresses = append(addresses, address) + } + } + + known := s.knownAddresses(ctx, addresses) + var fresh []string + for _, address := range addresses { + if !known[address] { + fresh = append(fresh, address) + } + } + return fresh +} + +// knownAddresses looks each address up in HEY's search for a message to it from one of +// this identity's addresses, delivered before the sign-in. The search only narrows: each +// match is checked here against the entry's own creator, time and recipients, so a +// search that matches loosely can't make an address known. +func (s *sender) knownAddresses(ctx context.Context, addresses []string) map[string]bool { + known := map[string]bool{} + if s.cfg.Grant.GrantedAt.IsZero() || len(addresses) == 0 { + return known + } + identity, err := s.cfg.Drafts.Identity(ctx) + if err != nil || identity == nil { + return known + } + mine := map[string]bool{} + if address := normalizedAddress(identity.PrimaryContact.EmailAddress); address != "" { + mine[address] = true + } + for _, sender := range identity.Senders { + if address := normalizedAddress(sender.EmailAddress); address != "" { + mine[address] = true + } + } + + for _, address := range addresses { + result, err := s.cfg.Drafts.Search(ctx, hey.SearchParams{To: address}) + if err != nil || result == nil { + continue + } + known[address] = sentBefore(result, address, mine, s.cfg.Grant.GrantedAt) + } + return known +} + +func sentBefore(result *generated.AdvancedSearchResult, address string, mine map[string]bool, before time.Time) bool { + for _, match := range result.Matches { + for _, entry := range match.Entries { + // ActiveAt is when the entry went out; CreatedAt would count a draft made + // before the sign-in and sent after it. No delivery time, no proof. + if !mine[normalizedAddress(entry.Creator.EmailAddress)] || entry.ActiveAt.IsZero() || !entry.ActiveAt.Before(before) { + continue + } + for _, r := range slices.Concat(entry.Addressed.Directly, entry.Addressed.Copied, entry.Addressed.Blindcopied) { + if normalizedAddress(r.EmailAddress) == address { + return true + } + } + } + } + return false +} + +// --- the signed confirmation state --- + +// confirmState is what the request state carries between the rounds: the draft's +// fingerprint, bound to the sign-in and the HEY, with an expiry. It is signed with the +// installation's key, because a 2026-07-28 client holds it between the rounds. +type confirmState struct { + Kind string `json:"k"` + Method string `json:"m"` + Grant string `json:"g"` + Origin string `json:"o"` + Draft int64 `json:"d"` + Fingerprint string `json:"f"` + Plain bool `json:"p,omitempty"` + Expires int64 `json:"e"` +} + +func (s *sender) sign(state confirmState) string { + // A struct of strings and integers always marshals. + payload, _ := json.Marshal(state) + mac := hmac.New(sha256.New, s.key) + mac.Write(payload) + return base64.RawURLEncoding.EncodeToString(payload) + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +func (s *sender) verify(raw string) (*confirmState, error) { + encoded, signature, ok := strings.Cut(raw, ".") + if !ok { + return nil, errors.New("malformed") + } + payload, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return nil, errors.New("malformed") + } + given, err := base64.RawURLEncoding.DecodeString(signature) + if err != nil { + return nil, errors.New("malformed") + } + mac := hmac.New(sha256.New, s.key) + mac.Write(payload) + if !hmac.Equal(given, mac.Sum(nil)) { + return nil, errors.New("signature doesn't match") + } + var state confirmState + if err := json.Unmarshal(payload, &state); err != nil { + return nil, errors.New("malformed") + } + if state.Kind != confirmKind || state.Method != confirmMethod { + return nil, errors.New("not a send confirmation") + } + if !s.cfg.Now().Before(time.Unix(state.Expires, 0)) { + return nil, fmt.Errorf("it expired; a confirmation lasts %d minutes", int(confirmTTL/time.Minute)) + } + return &state, nil +} diff --git a/internal/mcpserver/send_test.go b/internal/mcpserver/send_test.go new file mode 100644 index 000000000..ab65b765f --- /dev/null +++ b/internal/mcpserver/send_test.go @@ -0,0 +1,1474 @@ +package mcpserver + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "runtime" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/basecamp/hey-sdk/go/pkg/generated" + hey "github.com/basecamp/hey-sdk/go/pkg/hey" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// --- fixtures --- + +var grantedAt = time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) + +const testOrigin = "https://app.hey.example" + +func contact(name, address string) generated.Contact { + return generated.Contact{Id: int64(len(address)), Name: name, EmailAddress: address} +} + +// fakeDrafts is HEY as send_draft sees it: one draft, an identity, and the mail this +// identity sent before the sign-in (what search finds). +type fakeDrafts struct { + mu sync.Mutex + edit generated.MessageEditState + editErr error + sends []hey.DraftContent + sendErr error + sentAs *generated.SentMessage + sentTo map[string]time.Time + searches int +} + +func newFakeDrafts() *fakeDrafts { + return &fakeDrafts{ + edit: generated.MessageEditState{ + Id: 5, + Subject: "Re: Q3 plan", + Content: "
Hi Ann, the numbers on page 2 moved since Tuesday.
", + Sender: generated.Contact{Id: 77, Name: "Jeremy Daer", EmailAddress: "jeremy@hey.example"}, + Addressed: generated.Addressed{Directly: []generated.Contact{contact("Ann Example", "ann@example.com")}}, + }, + sentTo: map[string]time.Time{"ann@example.com": grantedAt.Add(-48 * time.Hour)}, + } +} + +func (f *fakeDrafts) update(fn func(*generated.MessageEditState)) { + f.mu.Lock() + defer f.mu.Unlock() + fn(&f.edit) +} + +func (f *fakeDrafts) sent() []hey.DraftContent { + f.mu.Lock() + defer f.mu.Unlock() + return slices.Clone(f.sends) +} + +func (f *fakeDrafts) GetEdit(_ context.Context, draftID int64) (*generated.MessageEditState, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.editErr != nil { + return nil, f.editErr + } + if draftID != f.edit.Id { + return nil, &hey.Error{HTTPStatus: http.StatusNotFound, Message: "not found"} + } + edit := f.edit + edit.Addressed = generated.Addressed{ + Directly: slices.Clone(f.edit.Addressed.Directly), + Copied: slices.Clone(f.edit.Addressed.Copied), + Blindcopied: slices.Clone(f.edit.Addressed.Blindcopied), + } + return &edit, nil +} + +func (f *fakeDrafts) SendDraft(_ context.Context, _ int64, draft hey.DraftContent) (*generated.SentMessage, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.sends = append(f.sends, draft) + if f.sendErr != nil { + return nil, f.sendErr + } + if f.sentAs != nil { + return f.sentAs, nil + } + return &generated.SentMessage{Id: 5, TopicId: 900}, nil +} + +func (f *fakeDrafts) Identity(context.Context) (*generated.Identity, error) { + return &generated.Identity{ + PrimaryContact: generated.Contact{EmailAddress: "jeremy@hey.example"}, + Senders: []generated.Sender{{Id: 77, EmailAddress: "jeremy@hey.example"}}, + }, nil +} + +func (f *fakeDrafts) Search(_ context.Context, params hey.SearchParams) (*generated.AdvancedSearchResult, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.searches++ + at, ok := f.sentTo[params.To] + if !ok { + return &generated.AdvancedSearchResult{}, nil + } + return &generated.AdvancedSearchResult{Matches: []generated.SearchMatch{{Entries: []generated.Entry{{ + CreatedAt: at.Add(-2 * time.Hour), + ActiveAt: at, + Creator: generated.Contact{EmailAddress: "jeremy@hey.example"}, + Addressed: generated.Addressed{Directly: []generated.Contact{{EmailAddress: params.To}}}, + }}}}}, nil +} + +// clock is a settable time source. +type clock struct { + mu sync.Mutex + now time.Time +} + +func (c *clock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.now +} + +func (c *clock) advance(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.now = c.now.Add(d) +} + +type sendRig struct { + drafts *fakeDrafts + state *SendState + clock *clock + api *fakeAPI + cfg SendConfig + server *Server +} + +func newSendRig(t *testing.T) *sendRig { + t.Helper() + state, err := OpenSendState(t.TempDir()) + if err != nil { + t.Fatal(err) + } + rig := &sendRig{drafts: newFakeDrafts(), state: state, clock: &clock{now: grantedAt.Add(24 * time.Hour)}, api: &fakeAPI{}} + rig.cfg = SendConfig{Drafts: rig.drafts, State: state, Grant: Grant{Key: "grant-1", GrantedAt: grantedAt}, Origin: testOrigin, Now: rig.clock.Now} + rig.server, err = New(rig.api, Config{Send: &rig.cfg}) + if err != nil { + t.Fatal(err) + } + return rig +} + +// answer is how the person answers the confirmation; nil means the client declares no +// elicitation at all. +type answer func(ctx context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) + +func approve(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + return &mcp.ElicitResult{Action: "accept", Content: map[string]any{"confirm": true}}, nil +} + +// connect opens a client session on the rig's server. version "" is the SDK's latest +// (2026-07-28, where the client carries the rounds itself); an older one has the +// server elicit within the call. manual turns the client's own round handling off. +func (r *sendRig) connect(t *testing.T, elicit answer, version string, manual bool) *mcp.ClientSession { + t.Helper() + opts := &mcp.ClientOptions{} + if elicit != nil { + opts.ElicitationHandler = elicit + } + if manual { + opts.MultiRoundTrip = &mcp.MultiRoundTripOptions{Disabled: true} + } + clientTransport, serverTransport := mcp.NewInMemoryTransports() + serverSession, err := r.server.BuildMCPServer(slog.New(slog.DiscardHandler)).Connect(context.Background(), serverTransport, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = serverSession.Close() }) + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "0"}, opts) + session, err := client.Connect(context.Background(), clientTransport, &mcp.ClientSessionOptions{ProtocolVersion: version}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = session.Close() }) + return session +} + +// sendDraft calls send_draft on the rig's draft, 5. +func sendDraft(t *testing.T, session *mcp.ClientSession) *mcp.CallToolResult { + t.Helper() + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: sendToolName, + Arguments: map[string]any{"action": sendDraftName, "params": map[string]any{"draft_id": 5}}, + }) + if err != nil { + t.Fatal(err) + } + return result +} + +func resultText(t *testing.T, result *mcp.CallToolResult) string { + t.Helper() + return textContent(t, result) +} + +func mustContain(t *testing.T, text string, wants ...string) { + t.Helper() + for _, want := range wants { + if !strings.Contains(text, want) { + t.Errorf("missing %q in:\n%s", want, text) + } + } +} + +var eras = []struct{ name, version string }{ + {"2026-07-28 client rounds", ""}, + {"2025-06-18 server elicitation", "2025-06-18"}, +} + +// --- the tool --- + +func TestHeySendIsServedWithHonestHints(t *testing.T) { + rig := newSendRig(t) + session := rig.connect(t, approve, "", false) + + var send *mcp.Tool + for tool, err := range session.Tools(context.Background(), nil) { + if err != nil { + t.Fatal(err) + } + if tool.Name == sendToolName { + send = tool + } + } + if send == nil { + t.Fatal("hey_send isn't served") + } + a := send.Annotations + if a == nil || a.ReadOnlyHint || a.IdempotentHint || a.DestructiveHint == nil || !*a.DestructiveHint || a.OpenWorldHint == nil || !*a.OpenWorldHint { + t.Errorf("annotations = %+v, want destructive, open world, not read-only, not idempotent", a) + } +} + +func TestHeySendIsNotServedReadOnlyOrWithoutSend(t *testing.T) { + rig := newSendRig(t) + for name, cfg := range map[string]Config{ + "no send config": {}, + "read-only": {ReadOnly: true, Send: &rig.cfg}, + "no threads": {Domains: []string{"boxes"}, Send: &rig.cfg}, + } { + t.Run(name, func(t *testing.T) { + srv, err := New(&fakeAPI{}, cfg) + if err != nil { + t.Fatal(err) + } + if srv.ServesSend() { + t.Error("hey_send is served") + } + }) + } +} + +// --- confirmation --- + +func TestSendDraftSendsOnlyWhatThePersonConfirmed(t *testing.T) { + for _, era := range eras { + t.Run(era.name, func(t *testing.T) { + rig := newSendRig(t) + var prompt string + session := rig.connect(t, func(ctx context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + prompt = req.Params.Message + if len(rig.drafts.sent()) != 0 { + t.Error("sent before the person answered") + } + return approve(ctx, req) + }, era.version, false) + + result := sendDraft(t, session) + if result.IsError { + t.Fatalf("send failed: %s", resultText(t, result)) + } + mustContain(t, resultText(t, result), `"sent": true`, `"message_id": 5`) + mustContain(t, prompt, `Send this email from "Jeremy Daer" ?`, + `To: "Ann Example" `, "Cc: —", "Bcc: —", + "Subject: Re: Q3 plan", "Attached: —", "When: Now (undo window applies)", + " Hi Ann, the numbers on page 2 moved since Tuesday.") + + sent := rig.drafts.sent() + if len(sent) != 1 { + t.Fatalf("sent %d times, want once", len(sent)) + } + want := hey.DraftContent{Subject: "Re: Q3 plan", Content: rig.drafts.edit.Content, To: []string{"ann@example.com"}, CC: []string{}, BCC: []string{}, ActingSenderID: 77} + got := sent[0] + if got.Subject != want.Subject || got.Content != want.Content || !slices.Equal(got.To, want.To) || len(got.CC)+len(got.BCC) != 0 || got.ActingSenderID != want.ActingSenderID || got.Schedule != nil { + t.Errorf("sent %+v, want %+v", got, want) + } + }) + } +} + +func TestSendDraftWithoutApprovalSendsNothing(t *testing.T) { + answers := map[string]*mcp.ElicitResult{ + "decline": {Action: "decline"}, + "cancel": {Action: "cancel"}, + "accept, unticked": {Action: "accept", Content: map[string]any{"confirm": false}}, + } + for name, given := range answers { + t.Run(name, func(t *testing.T) { + rig := newSendRig(t) + session := rig.connect(t, func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error) { return given, nil }, "", false) + result := sendDraft(t, session) + if result.IsError { + t.Fatalf("an unapproved send is an error result: %s", resultText(t, result)) + } + mustContain(t, resultText(t, result), `"sent": false`, "Draft saved but not sent", testOrigin+"/messages/5/edit") + if n := len(rig.drafts.sent()); n != 0 { + t.Errorf("sent %d times without approval", n) + } + }) + } +} + +func TestSendDraftCantSendFromAHostWithoutElicitation(t *testing.T) { + for _, era := range eras { + t.Run(era.name, func(t *testing.T) { + rig := newSendRig(t) + session := rig.connect(t, nil, era.version, false) + result := sendDraft(t, session) + if result.IsError { + t.Fatalf("D3 answers a non-error result: %s", resultText(t, result)) + } + mustContain(t, resultText(t, result), `"sent": false`, "can't show a confirmation", "send it in HEY: "+testOrigin+"/messages/5/edit") + if n := len(rig.drafts.sent()); n != 0 { + t.Errorf("sent %d times from a host that can't confirm", n) + } + }) + } +} + +// --- digest binding --- + +func TestSendDraftRefusesADraftChangedAfterApproval(t *testing.T) { + changes := map[string]func(*generated.MessageEditState){ + "a Bcc added": func(e *generated.MessageEditState) { + e.Addressed.Blindcopied = append(e.Addressed.Blindcopied, contact("", "archive@evil.example")) + }, + "a recipient swapped": func(e *generated.MessageEditState) { + e.Addressed.Directly = []generated.Contact{contact("Ann Example", "ann@evil.example")} + }, + "the subject": func(e *generated.MessageEditState) { e.Subject = "Re: Q3 plan (updated)" }, + "the body": func(e *generated.MessageEditState) { e.Content += "
PS: the reset code is 123456
" }, + "the sender": func(e *generated.MessageEditState) { e.Sender = contact("Jeremy", "jeremy@work.example") }, + "a schedule": func(e *generated.MessageEditState) { e.ScheduledDeliveryAt = grantedAt.Add(72 * time.Hour) }, + } + for name, change := range changes { + t.Run(name, func(t *testing.T) { + rig := newSendRig(t) + session := rig.connect(t, func(ctx context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + rig.drafts.update(change) + return approve(ctx, req) + }, "", false) + result := sendDraft(t, session) + if !result.IsError { + t.Fatalf("a changed draft was sent: %s", resultText(t, result)) + } + if n := len(rig.drafts.sent()); n != 0 { + t.Errorf("sent %d times after the draft changed", n) + } + }) + } +} + +func TestSendDraftNamesTheChangeWhenOnlyTheContentMoved(t *testing.T) { + rig := newSendRig(t) + session := rig.connect(t, func(ctx context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + rig.drafts.update(func(e *generated.MessageEditState) { e.Subject = "Something else" }) + return approve(ctx, req) + }, "", false) + mustContain(t, resultText(t, sendDraft(t, session)), "the draft changed since you approved it") +} + +func TestAnAttachmentReplacedWithTheSameNameAndSizeIsAChange(t *testing.T) { + figure := func(sgid, url string) string { + return fmt.Sprintf(`
Plan attached.
plan.pdf
`, sgid, url, url) + } + first, err := analyzeBody(figure("BAh7blob1", "https://app.hey.example/blobs/redirect/aaa?exp=1")) + if err != nil { + t.Fatal(err) + } + refetched, _ := analyzeBody(figure("BAh7blob1", "https://app.hey.example/blobs/redirect/bbb?exp=2")) + replaced, _ := analyzeBody(figure("BAh7blob2", "https://app.hey.example/blobs/redirect/aaa?exp=1")) + + if first.hash != refetched.hash { + t.Error("an unchanged attachment hashed differently on a second fetch (signed URLs must not count)") + } + if first.hash == replaced.hash { + t.Error("a same-name, same-size replacement attachment hashed the same") + } + if len(first.attachments) != 1 || first.attachments[0].Filename != "plan.pdf" || first.attachments[0].Size != 217088 { + t.Errorf("attachments = %+v", first.attachments) + } + if first.lossless { + t.Error("a draft with an attachment counts as fully shown") + } + if first.remote != "" { + t.Errorf("the attachment's own preview counted as a remote reference: %s", first.remote) + } +} + +// --- single use --- + +func TestSendDraftSendsADraftOnce(t *testing.T) { + rig := newSendRig(t) + session := rig.connect(t, approve, "", false) + if result := sendDraft(t, session); result.IsError { + t.Fatalf("first send failed: %s", resultText(t, result)) + } + again := sendDraft(t, session) + if !again.IsError { + t.Fatalf("a second send of the draft went through: %s", resultText(t, again)) + } + mustContain(t, resultText(t, again), "already sent") + if n := len(rig.drafts.sent()); n != 1 { + t.Errorf("sent %d times, want once", n) + } +} + +// roundOne asks for a confirmation without answering it, as a 2026-07-28 client that +// carries the rounds itself would, and returns the signed state. +func roundOne(t *testing.T, session *mcp.ClientSession) *mcp.CallToolResult { + t.Helper() + result := sendDraft(t, session) + if !result.NeedsInput() || result.RequestState == "" || result.InputRequests[confirmInputID] == nil { + t.Fatalf("first round didn't ask for a confirmation: %+v", result) + } + return result +} + +func retry(t *testing.T, session *mcp.ClientSession, state string, answer *mcp.ElicitResult) *mcp.CallToolResult { + t.Helper() + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: sendToolName, + Arguments: map[string]any{"action": sendDraftName, "params": map[string]any{"draft_id": 5}}, + RequestState: state, + InputResponses: mcp.InputResponseMap{confirmInputID: answer}, + }) + if err != nil { + t.Fatal(err) + } + return result +} + +func TestAReplayedConfirmationSendsNothing(t *testing.T) { + rig := newSendRig(t) + session := rig.connect(t, approve, "", true) + accepted := &mcp.ElicitResult{Action: "accept", Content: map[string]any{"confirm": true}} + + state := roundOne(t, session).RequestState + if result := retry(t, session, state, accepted); result.IsError { + t.Fatalf("confirmed send failed: %s", resultText(t, result)) + } + replayed := retry(t, session, state, accepted) + if !replayed.IsError { + t.Fatalf("a replayed confirmation sent again: %s", resultText(t, replayed)) + } + if n := len(rig.drafts.sent()); n != 1 { + t.Errorf("sent %d times, want once", n) + } +} + +func TestTwoConfirmationsOfOneDraftSendItOnce(t *testing.T) { + rig := newSendRig(t) + session := rig.connect(t, approve, "", true) + accepted := &mcp.ElicitResult{Action: "accept", Content: map[string]any{"confirm": true}} + + first := roundOne(t, session).RequestState + rig.clock.advance(time.Second) + second := roundOne(t, session).RequestState + if first == second { + t.Fatal("the two rounds minted the same state, so this wouldn't test two confirmations") + } + if result := retry(t, session, first, accepted); result.IsError { + t.Fatalf("first confirmation failed: %s", resultText(t, result)) + } + if result := retry(t, session, second, accepted); !result.IsError { + t.Fatalf("a second confirmation of the same draft sent it again: %s", resultText(t, result)) + } + if n := len(rig.drafts.sent()); n != 1 { + t.Errorf("sent %d times, want once", n) + } +} + +func TestAForgedOrExpiredConfirmationSendsNothing(t *testing.T) { + accepted := &mcp.ElicitResult{Action: "accept", Content: map[string]any{"confirm": true}} + cases := map[string]func(rig *sendRig, state string) string{ + "tampered payload": func(_ *sendRig, state string) string { + payload, signature, _ := strings.Cut(state, ".") + return payload[:len(payload)-2] + "AA." + signature + }, + "unsigned": func(_ *sendRig, state string) string { + payload, _, _ := strings.Cut(state, ".") + return payload + "." + }, + "made up": func(*sendRig, string) string { return "eyJrIjoic2VuZF9kcmFmdCJ9.c2lnbmF0dXJl" }, + "expired": func(rig *sendRig, state string) string { + rig.clock.advance(confirmTTL + time.Second) + return state + }, + "another sign-in's key": func(_ *sendRig, state string) string { + other := &sender{key: []byte("a different installation's key.."), cfg: SendConfig{Now: time.Now}} + payload, _, _ := strings.Cut(state, ".") + raw, _ := base64Decode(payload) + var st confirmState + _ = json.Unmarshal(raw, &st) + return other.sign(st) + }, + } + for name, forge := range cases { + t.Run(name, func(t *testing.T) { + rig := newSendRig(t) + session := rig.connect(t, approve, "", true) + state := roundOne(t, session).RequestState + result := retry(t, session, forge(rig, state), accepted) + if !result.IsError { + t.Fatalf("sent on a forged confirmation: %s", resultText(t, result)) + } + if n := len(rig.drafts.sent()); n != 0 { + t.Errorf("sent %d times", n) + } + }) + } +} + +// --- recipients, exposure and caps --- + +func TestSendDraftFlagsANewRecipient(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { + e.Addressed.Copied = []generated.Contact{contact("Bob Example", "bob@example.com")} + }) + var prompt string + session := rig.connect(t, func(ctx context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + prompt = req.Params.Message + return approve(ctx, req) + }, "", false) + if result := sendDraft(t, session); result.IsError { + t.Fatalf("send failed: %s", resultText(t, result)) + } + mustContain(t, prompt, `Cc: "Bob Example" (new: never emailed before)`) + if strings.Contains(prompt, " (new") { + t.Error("a recipient emailed before the sign-in was flagged new") + } +} + +func TestMailSentAfterTheSignInDoesntMakeARecipientKnown(t *testing.T) { + rig := newSendRig(t) + rig.drafts.sentTo["ann@example.com"] = grantedAt.Add(time.Hour) + var prompt string + session := rig.connect(t, func(ctx context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + prompt = req.Params.Message + return approve(ctx, req) + }, "", false) + sendDraft(t, session) + mustContain(t, prompt, " (new: never emailed before)") +} + +func TestAnUnscreenedReadRefusesSendsToNewRecipients(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { + e.Addressed.Directly = []generated.Contact{contact("", "archive@evil.example")} + }) + session := rig.connect(t, approve, "", false) + + // Reading a thread may return an unscreened sender's words. + read, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "hey_threads", + Arguments: map[string]any{"action": "get_topic", "params": map[string]any{"topicId": 9}}, + }) + if err != nil || read.IsError { + t.Fatalf("get_topic: %v %v", err, read) + } + + result := sendDraft(t, session) + if !result.IsError { + t.Fatalf("sent to a new recipient right after an unscreened read: %s", resultText(t, result)) + } + mustContain(t, resultText(t, result), "unscreened", "get_topic", "archive@evil.example") + if n := len(rig.drafts.sent()); n != 0 { + t.Errorf("sent %d times", n) + } + + // Once the window lapses, the send is a D4 send again: confirmed and flagged. + rig.clock.advance(exposureWindow + time.Second) + if result := sendDraft(t, session); result.IsError { + t.Fatalf("still refused after the exposure window: %s", resultText(t, result)) + } +} + +func TestABoxReadDoesntExpose(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { + e.Addressed.Directly = []generated.Contact{contact("", "new@example.com")} + }) + session := rig.connect(t, approve, "", false) + if _, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "hey_boxes", + Arguments: map[string]any{"action": "get_imbox", "params": map[string]any{}}, + }); err != nil { + t.Fatal(err) + } + if result := sendDraft(t, session); result.IsError { + t.Fatalf("an Imbox read exposed the grant: %s", resultText(t, result)) + } +} + +func TestAnUnscreenedReadBetweenPromptAndApprovalRefusesTheSend(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { + e.Addressed.Directly = []generated.Contact{contact("", "new@example.com")} + }) + session := rig.connect(t, func(ctx context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + // Another conversation on this sign-in reads the Screener meanwhile. + if err := rig.state.MarkExposed(ctx, "grant-1", "get_clearances", rig.clock.Now()); err != nil { + t.Fatal(err) + } + return approve(ctx, req) + }, "", false) + result := sendDraft(t, session) + if !result.IsError { + t.Fatalf("an approval carried past an exposure: %s", resultText(t, result)) + } + if n := len(rig.drafts.sent()); n != 0 { + t.Errorf("sent %d times", n) + } +} + +func TestAnUnscreenedReadBetweenPromptAndApprovalRefusesUnseenContent(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { + e.Content = `
Plan attached.
` + }) + session := rig.connect(t, func(ctx context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + if err := rig.state.MarkExposed(ctx, "grant-1", "get_message", rig.clock.Now()); err != nil { + t.Fatal(err) + } + return approve(ctx, req) + }, "", false) + if result := sendDraft(t, session); !result.IsError { + t.Fatalf("an attachment went out on an approval an exposure overtook: %s", resultText(t, result)) + } + if n := len(rig.drafts.sent()); n != 0 { + t.Errorf("sent %d times", n) + } +} + +func TestAnExposedReadIsRefusedWhenItsMarkerCantBeWritten(t *testing.T) { + rig := newSendRig(t) + rig.state.fsync = func(*os.File) error { return errors.New("disk full") } + session := rig.connect(t, approve, "", false) + read, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "hey_threads", + Arguments: map[string]any{"action": "get_topic", "params": map[string]any{"topicId": 9}}, + }) + if err != nil { + t.Fatal(err) + } + if !read.IsError || strings.Contains(textContent(t, read), `"ok":true`) { + t.Fatalf("content went out without its exposure marker: %v", read.Content) + } +} + +func TestSendDraftCapsRecipientsSendsAndNewAddresses(t *testing.T) { + t.Run("recipients per message", func(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { + for i := range maxRecipients { + e.Addressed.Copied = append(e.Addressed.Copied, contact("", fmt.Sprintf("colleague%d@example.com", i))) + } + }) + session := rig.connect(t, approve, "", false) + mustContain(t, resultText(t, sendDraft(t, session)), "at most 20 recipients") + }) + + t.Run("messages a day", func(t *testing.T) { + rig := newSendRig(t) + for i := range maxSendsPerDay { + if err := rig.state.claim(context.Background(), testOrigin, int64(1000+i), "grant-1", nil, false, rig.clock.Now()); err != nil { + t.Fatal(err) + } + } + session := rig.connect(t, approve, "", false) + mustContain(t, resultText(t, sendDraft(t, session)), "at most 50 messages a day") + rig.clock.advance(capWindow) + if result := sendDraft(t, session); result.IsError { + t.Fatalf("the cap outlived its window: %s", resultText(t, result)) + } + }) + + t.Run("new addresses count once each", func(t *testing.T) { + rig := newSendRig(t) + for i := range maxSendsPerDay - 1 { + if err := rig.state.claim(context.Background(), testOrigin, int64(1000+i), "grant-1", []string{"bob@example.com"}, false, rig.clock.Now()); err != nil { + t.Fatalf("send %d to one new address: %v", i, err) + } + } + _, charged, _ := rig.state.budget(context.Background(), "grant-1", rig.clock.Now()) + if len(charged) != 1 { + t.Errorf("%d sends to one new address charged %d", maxSendsPerDay-1, len(charged)) + } + }) + + t.Run("an eleventh new address", func(t *testing.T) { + rig := newSendRig(t) + for i := range maxNewAddressesPerDay { + if err := rig.state.claim(context.Background(), testOrigin, int64(1000+i), "grant-1", []string{fmt.Sprintf("new%d@example.com", i)}, false, rig.clock.Now()); err != nil { + t.Fatal(err) + } + } + rig.drafts.update(func(e *generated.MessageEditState) { + e.Addressed.Directly = []generated.Contact{contact("", "eleventh@example.com")} + }) + session := rig.connect(t, approve, "", false) + mustContain(t, resultText(t, sendDraft(t, session)), "at most 10 new addresses") + if n := len(rig.drafts.sent()); n != 0 { + t.Errorf("sent %d times", n) + } + }) + + t.Run("caps are per sign-in", func(t *testing.T) { + rig := newSendRig(t) + for i := range maxSendsPerDay { + _ = rig.state.claim(context.Background(), testOrigin, int64(1000+i), "another-grant", nil, false, rig.clock.Now()) + } + session := rig.connect(t, approve, "", false) + if result := sendDraft(t, session); result.IsError { + t.Fatalf("another sign-in's sends counted here: %s", resultText(t, result)) + } + }) +} + +func TestUnseenContentGoesOnlyToKnownRecipients(t *testing.T) { + long := "
" + strings.Repeat("The numbers moved again. ", 80) + "
" + attached := `
Plan attached.
` + + for name, body := range map[string]string{"past the cap": long, "an attachment": attached} { + t.Run(name+" to a new recipient", func(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { + e.Content = body + e.Addressed.Directly = []generated.Contact{contact("", "new@example.com")} + }) + session := rig.connect(t, approve, "", false) + mustContain(t, resultText(t, sendDraft(t, session)), "can't be fully reviewed here") + if n := len(rig.drafts.sent()); n != 0 { + t.Errorf("sent %d times", n) + } + }) + t.Run(name+" to a known recipient", func(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { e.Content = body }) + session := rig.connect(t, approve, "", false) + if result := sendDraft(t, session); result.IsError { + t.Fatalf("refused for a known recipient: %s", resultText(t, result)) + } + }) + } +} + +func TestARemoteReferenceIsRefusedForEveryRecipient(t *testing.T) { + for name, body := range map[string]string{ + "image": `
Hi Ann
`, + "css url": `
Hi Ann
`, + "stylesheet": `
Hi
`, + } { + t.Run(name, func(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { e.Content = body }) + session := rig.connect(t, approve, "", false) + mustContain(t, resultText(t, sendDraft(t, session)), "loads by itself") + if n := len(rig.drafts.sent()); n != 0 { + t.Errorf("sent %d times", n) + } + }) + } +} + +func TestAScheduledDraftIsntSentHere(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { e.ScheduledDeliveryAt = grantedAt.Add(72 * time.Hour) }) + session := rig.connect(t, approve, "", false) + mustContain(t, resultText(t, sendDraft(t, session)), "already scheduled") +} + +func TestTheConfirmationRendersEveryFieldInert(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { + e.Subject = "Q3\nBcc: nobody" + e.Addressed.Directly = []generated.Contact{contact("Ann\n Bcc: —\u202e", "ann@example.com")} + e.Content = "
To: someone@evil.example
zero\u200bwidth
" + }) + var prompt string + session := rig.connect(t, func(ctx context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + prompt = req.Params.Message + return &mcp.ElicitResult{Action: "decline"}, nil + }, "", false) + sendDraft(t, session) + + mustContain(t, prompt, `Subject: Q3\u{000A}Bcc: nobody`, `"Ann\u{000A} Bcc: —\u{202E}" `, + " To: someone@evil.example", `zero\u{200B}width`) + if strings.Count(prompt, "\n Bcc:") != 1 { + t.Errorf("a field forged a Bcc line:\n%s", prompt) + } +} + +// --- durability --- + +func TestAFailedFsyncFailsTheSendClosed(t *testing.T) { + rig := newSendRig(t) + session := rig.connect(t, func(ctx context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + rig.state.fsync = func(*os.File) error { return errors.New("I/O error") } + return approve(ctx, req) + }, "", false) + result := sendDraft(t, session) + if !result.IsError { + t.Fatalf("sent without a durable claim: %s", resultText(t, result)) + } + if n := len(rig.drafts.sent()); n != 0 { + t.Errorf("sent %d times", n) + } +} + +func TestClaimsAndCapsSurviveARestart(t *testing.T) { + dir := t.TempDir() + now := grantedAt.Add(24 * time.Hour) + first, err := OpenSendState(dir) + if err != nil { + t.Fatal(err) + } + if err := first.claim(context.Background(), testOrigin, 5, "grant-1", []string{"new@example.com"}, false, now); err != nil { + t.Fatal(err) + } + if err := first.MarkExposed(context.Background(), "grant-1", "get_topic", now); err != nil { + t.Fatal(err) + } + + reopened, err := OpenSendState(dir) + if err != nil { + t.Fatal(err) + } + if err := reopened.claim(context.Background(), testOrigin, 5, "grant-2", nil, false, now); err == nil { + t.Error("the same draft was claimed again after a restart, from another sign-in") + } + if exposed, _, _ := reopened.exposure(context.Background(), "grant-1", now); !exposed { + t.Error("the exposure marker didn't survive a restart") + } + if _, charged, _ := reopened.budget(context.Background(), "grant-1", now); !charged["new@example.com"] { + t.Error("the new-address charge didn't survive a restart") + } + + key1, _ := first.ConfirmKey(context.Background()) + key2, _ := reopened.ConfirmKey(context.Background()) + if len(key1) != confirmKeyBytes || string(key1) != string(key2) { + t.Error("the confirmation key isn't one per installation") + } +} + +// --- what the send does on failure --- + +func TestAFailedSendIsNeverRetriedAndStaysClaimed(t *testing.T) { + for name, sendErr := range map[string]error{ + "HEY refused": &hey.Error{HTTPStatus: http.StatusUnprocessableEntity, Message: "sending limit reached"}, + "no answer": errors.New("connection reset"), + } { + t.Run(name, func(t *testing.T) { + rig := newSendRig(t) + rig.drafts.sendErr = sendErr + session := rig.connect(t, approve, "", false) + result := sendDraft(t, session) + if !result.IsError { + t.Fatalf("a failed send reported success: %s", resultText(t, result)) + } + if n := len(rig.drafts.sent()); n != 1 { + t.Errorf("send attempted %d times, want once", n) + } + rig.drafts.sendErr = nil + again := sendDraft(t, session) + mustContain(t, resultText(t, again), "already sent") + if n := len(rig.drafts.sent()); n != 1 { + t.Errorf("a claimed draft was sent again after a failure (%d attempts)", n) + } + }) + } +} + +// --- exposure table --- + +// TestEveryServedReadIsClassifiedForExposure holds readExposes total: a read served +// without an entry exposes by default, but the table should say so on purpose. +func TestEveryServedReadIsClassifiedForExposure(t *testing.T) { + cat := loadForTest(t) + for _, d := range cat.Domains { + for _, op := range d.Operations { + if !op.ReadOnly { + continue + } + if _, ok := readExposes[op.ID]; !ok { + t.Errorf("%s (%s) is served but not classified in readExposes", op.ID, d.Key) + } + } + } +} + +func base64Decode(s string) ([]byte, error) { + return base64.RawURLEncoding.DecodeString(s) +} + +// The claim re-reads exposure itself, so a marker landing after the second round's +// review and before the claim still refuses a draft to someone new or with unseen +// content. +func TestTheClaimRereadsExposure(t *testing.T) { + rig := newSendRig(t) + now := rig.clock.Now() + if err := rig.state.MarkExposed(context.Background(), "grant-1", "get_topic", now); err != nil { + t.Fatal(err) + } + var refused *refusalError + if err := rig.state.claim(context.Background(), testOrigin, 5, "grant-1", []string{"new@example.com"}, false, now); !errors.As(err, &refused) { + t.Errorf("claimed a draft to someone new while exposed: %v", err) + } + if err := rig.state.claim(context.Background(), testOrigin, 5, "grant-1", nil, true, now); !errors.As(err, &refused) { + t.Errorf("claimed unseen content while exposed: %v", err) + } + if err := rig.state.claim(context.Background(), testOrigin, 5, "grant-1", nil, false, now); err != nil { + t.Errorf("refused a fully shown draft to known recipients while exposed: %v", err) + } +} + +// The caps are charged in the claim's own locked update, not only checked by the +// review before it, so two processes can't both spend the last of one. +func TestTheClaimEnforcesTheCaps(t *testing.T) { + ctx := context.Background() + rig := newSendRig(t) + now := rig.clock.Now() + var refused *refusalError + for i := range maxNewAddressesPerDay { + if err := rig.state.claim(ctx, testOrigin, int64(100+i), "grant-1", []string{fmt.Sprintf("new%d@example.com", i)}, false, now); err != nil { + t.Fatal(err) + } + } + if err := rig.state.claim(ctx, testOrigin, 200, "grant-1", []string{"eleventh@example.com"}, false, now); !errors.As(err, &refused) { + t.Errorf("claimed an eleventh new address: %v", err) + } + for i := maxNewAddressesPerDay; i < maxSendsPerDay; i++ { + if err := rig.state.claim(ctx, testOrigin, int64(100+i), "grant-1", nil, false, now); err != nil { + t.Fatal(err) + } + } + if err := rig.state.claim(ctx, testOrigin, 300, "grant-1", nil, false, now); !errors.As(err, &refused) { + t.Errorf("claimed a fifty-first send: %v", err) + } +} + +// Each fsync is load-bearing: the new state file's before the rename, and the +// directory's after it. +func TestEitherFailedFsyncFailsTheClaim(t *testing.T) { + for name, failDir := range map[string]bool{"the file": false, "the directory": true} { + t.Run(name, func(t *testing.T) { + if failDir && runtime.GOOS == "windows" { + t.Skip("Windows can't open a directory to fsync it; syncDir skips it there") + } + rig := newSendRig(t) + rig.state.fsync = func(f *os.File) error { + info, err := f.Stat() + if err != nil { + return err + } + if info.IsDir() == failDir { + return errors.New("I/O error") + } + return f.Sync() + } + if err := rig.state.claim(context.Background(), testOrigin, 5, "grant-1", nil, false, rig.clock.Now()); err == nil { + t.Errorf("a claim whose fsync of %s failed was acknowledged", name) + } + }) + } +} + +// --- review round one --- + +func TestASignInChangedUnderTheServerRefusesSendsAndExposingReads(t *testing.T) { + rig := newSendRig(t) + stored := "grant-1" + rig.cfg.CurrentGrant = func(context.Context) (string, error) { return stored, nil } + srv, err := New(rig.api, Config{Send: &rig.cfg}) + if err != nil { + t.Fatal(err) + } + rig.server = srv + session := rig.connect(t, approve, "", false) + + stored = "grant-2" // hey auth login --mcp ran again while this process served + mustContain(t, resultText(t, sendDraft(t, session)), "signed in again") + read, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "hey_threads", + Arguments: map[string]any{"action": "get_topic", "params": map[string]any{"topicId": 9}}, + }) + if err != nil { + t.Fatal(err) + } + if !read.IsError { + t.Error("an exposing read went out under a sign-in its marker wouldn't be recorded against") + } + if n := len(rig.drafts.sent()); n != 0 { + t.Errorf("sent %d times", n) + } +} + +func TestAnAttachmentThatIsntAPlainBlobIsRefused(t *testing.T) { + for name, body := range map[string]string{ + "no blob identity": `
hidden text
`, + "embedded html": `
`, + "unparseable data": `
`, + "action-text, empty": ``, + } { + t.Run(name, func(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { e.Content = "
Hi Ann
" + body }) + session := rig.connect(t, approve, "", false) + mustContain(t, resultText(t, sendDraft(t, session)), "can't verify") + if n := len(rig.drafts.sent()); n != 0 { + t.Errorf("sent %d times", n) + } + }) + } +} + +func TestTheConfirmationShowsAWholeAddressHoweverLong(t *testing.T) { + long := strings.Repeat("a", 240) + "@evil.example" + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { + e.Addressed.Directly = []generated.Contact{contact("Ann Example", long)} + }) + var prompt string + session := rig.connect(t, func(_ context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + prompt = req.Params.Message + return &mcp.ElicitResult{Action: "decline"}, nil + }, "", false) + sendDraft(t, session) + mustContain(t, prompt, "<"+long+">") +} + +func TestAReadOnlyServerStillRecordsExposure(t *testing.T) { + for name, cfg := range map[string]func(*SendConfig) Config{ + "--read-only": func(send *SendConfig) Config { return Config{ReadOnly: true, Send: send} }, + "--domains search": func(send *SendConfig) Config { + return Config{Domains: []string{"search", "threads"}, ReadOnly: true, Send: send} + }, + } { + t.Run(name, func(t *testing.T) { + rig := newSendRig(t) + srv, err := New(rig.api, cfg(&rig.cfg)) + if err != nil { + t.Fatal(err) + } + if srv.ServesSend() { + t.Fatal("a read-only server serves hey_send") + } + rig.server = srv + session := rig.connect(t, approve, "", false) + if _, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "hey_search", + Arguments: map[string]any{"action": "advanced_search", "params": map[string]any{"q": "reset"}}, + }); err != nil { + t.Fatal(err) + } + if exposed, _, _ := rig.state.exposure(context.Background(), "grant-1", rig.clock.Now()); !exposed { + t.Error("a read on a server that can't send didn't expose the sign-in another hey mcp sends on") + } + }) + } +} + +func TestTheNewAddressWindowRollsWithTheLastSend(t *testing.T) { + ctx := context.Background() + rig := newSendRig(t) + start := rig.clock.Now() + first := make([]string, maxNewAddressesPerDay) + for i := range first { + first[i] = fmt.Sprintf("new%d@example.com", i) + } + if err := rig.state.claim(ctx, testOrigin, 100, "grant-1", first, false, start); err != nil { + t.Fatal(err) + } + if err := rig.state.claim(ctx, testOrigin, 101, "grant-1", first, false, start.Add(23*time.Hour)); err != nil { + t.Fatalf("resending to the same new addresses was charged again: %v", err) + } + var refused *refusalError + if err := rig.state.claim(ctx, testOrigin, 102, "grant-1", []string{"eleventh@example.com"}, false, start.Add(24*time.Hour+time.Minute)); !errors.As(err, &refused) { + t.Errorf("an eleventh new address fit in a window that still held ten: %v", err) + } +} + +// --- review round two --- + +func TestADeliveryHEYKeptAsADraftIsntReportedSent(t *testing.T) { + rig := newSendRig(t) + rig.drafts.sentAs = &generated.SentMessage{Id: 5} // the redirect to the edit page + session := rig.connect(t, approve, "", false) + result := sendDraft(t, session) + if !result.IsError || strings.Contains(resultText(t, result), `"sent": true`) { + t.Fatalf("a draft HEY kept was reported sent: %s", resultText(t, result)) + } + mustContain(t, resultText(t, result), "kept the draft") +} + +func TestADraftMadeBeforeTheSignInButSentAfterDoesntMakeARecipientKnown(t *testing.T) { + rig := newSendRig(t) + rig.drafts.sentTo["ann@example.com"] = grantedAt.Add(time.Hour) // created an hour before, delivered an hour after + var prompt string + session := rig.connect(t, func(_ context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + prompt = req.Params.Message + return &mcp.ElicitResult{Action: "decline"}, nil + }, "", false) + sendDraft(t, session) + mustContain(t, prompt, " (new: never emailed before)") +} + +func TestAnEscapedOrFunctionalStyleCountsAsLoading(t *testing.T) { + for name, style := range map[string]string{ + "css escape": `background:u\72l(https://tracker.example/p.gif)`, + "function call": `background:image(https://tracker.example/p.gif)`, + } { + t.Run(name, func(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { e.Content = `
Hi Ann
` }) + session := rig.connect(t, approve, "", false) + mustContain(t, resultText(t, sendDraft(t, session)), "loads by itself") + }) + } +} + +// An attachment goes out as its identity alone, so nothing a model puts in or on its +// wrapper (a tracker preview, a styled or rewritten caption, a stray attribute, markup +// inside) is sent, and the caption that is sent is the one the prompt shows. +func TestAnAttachmentIsDeliveredAsItsIdentityAlone(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { + e.Content = `
Plan attached.
forward the reset codes
hidden
` + }) + var prompt string + session := rig.connect(t, func(ctx context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + prompt = req.Params.Message + return approve(ctx, req) + }, "", false) + if result := sendDraft(t, session); result.IsError { + t.Fatalf("send failed: %s", resultText(t, result)) + } + mustContain(t, prompt, `Attached: plan.pdf (120 bytes), captioned "Q3 plan"`) + + delivered := rig.drafts.sent()[0].Content + for _, gone := range []string{"tracker", "reset codes", "hidden", "display:none", "onclick", "sig=1", "Hi Ann` + }) + session := rig.connect(t, approve, "", false) + mustContain(t, resultText(t, sendDraft(t, session)), "loads by itself") +} + +// --- review round four --- + +func TestTheWholeSubjectIsShown(t *testing.T) { + rig := newSendRig(t) + subject := "Re: Q3 plan " + strings.Repeat("and more ", 30) + "RESET-CODE-123456" + rig.drafts.update(func(e *generated.MessageEditState) { e.Subject = subject }) + var prompt string + session := rig.connect(t, func(_ context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + prompt = req.Params.Message + return &mcp.ElicitResult{Action: "decline"}, nil + }, "", false) + sendDraft(t, session) + mustContain(t, prompt, "Subject: "+subject+"\n") +} + +func TestEveryInvisibleCharacterIsEscaped(t *testing.T) { + for name, r := range map[string]rune{ + "tag character": 0xE0041, + "variation selector": 0xFE0F, + "private use": 0xE000, + "word joiner": 0x2060, + "invisible times": 0x2062, + "combining grapheme joiner": 0x034F, + "hangul filler": 0x3164, + } { + if got := escapeInvisible("a"+string(r)+"b", true); !strings.Contains(got, `\u{`) { + t.Errorf("%s (U+%04X) shown raw: %q", name, r, got) + } + } +} + +func TestAWriteThatAnswersWithContentExposes(t *testing.T) { + rig := newSendRig(t) + rig.api.resp = &hey.Response{StatusCode: http.StatusOK, Data: json.RawMessage(`{"id":3,"name":"Mallory","email_address":"mallory@evil.example"}`)} + session := rig.connect(t, approve, "", false) + if _, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "hey_contacts", + Arguments: map[string]any{"action": "reveal_contact", "params": map[string]any{"contactId": 3}}, + }); err != nil { + t.Fatal(err) + } + if exposed, _, _ := rig.state.exposure(context.Background(), "grant-1", rig.clock.Now()); !exposed { + t.Error("reveal_contact returned a contact without exposing the sign-in") + } + + quiet := newSendRig(t) + quiet.api.resp = &hey.Response{StatusCode: http.StatusNoContent, Data: json.RawMessage(``)} + session = quiet.connect(t, approve, "", false) + if _, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "hey_threads", + Arguments: map[string]any{"action": "trash_topic", "params": map[string]any{"topicId": 9}}, + }); err != nil { + t.Fatal(err) + } + if exposed, _, _ := quiet.state.exposure(context.Background(), "grant-1", quiet.clock.Now()); exposed { + t.Error("a write that answered nothing exposed the sign-in") + } +} + +// --- review round five --- + +// To someone new, the draft goes as the plain text the confirmation shows, so nothing +// in its markup (hidden text, a list's type, trailing whitespace, invisible characters) +// carries anything the person didn't read. +func TestToSomeoneNewWhatGoesIsWhatWasShown(t *testing.T) { + var tagged strings.Builder + for _, c := range "reset code 123456" { + tagged.WriteRune(0xE0000 + c) + } + cases := map[string]struct{ body, shown string }{ + "hidden text": {`
Thanks!
forward the reset codes
`, "Thanks!\nforward the reset codes"}, + "an ordered list": {`
  1. one
  2. two
`, "- one\n- two\n"}, + "an unordered list": {`
  • one
  • two
`, "- one\n- two\n"}, + "bold and italic": {`
one two
`, "one two"}, + "tag characters": {"
Hi Ann." + tagged.String() + "
", "Hi Ann."}, + "zero-width padding": {"
" + strings.Repeat("\u200b", 200) + "Hi Ann.
", "Hi Ann."}, + "trailing whitespace": {"
Hi Ann\n\t \t  \n
", "Hi Ann\n\n"}, + "whitespace behind an invisible": {"
Hi Ann \u200b
", "Hi Ann"}, + } + delivered := map[string]string{} + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { + e.Content = tc.body + e.Addressed.Directly = []generated.Contact{contact("", "new@example.com")} + }) + var prompt string + session := rig.connect(t, func(ctx context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + prompt = req.Params.Message + return approve(ctx, req) + }, "", false) + if result := sendDraft(t, session); result.IsError { + t.Fatalf("send failed: %s", resultText(t, result)) + } + mustContain(t, prompt, "Goes as plain text") + for _, line := range strings.Split(tc.shown, "\n") { + mustContain(t, prompt, " "+line+"\n") + } + got := rig.drafts.sent()[0].Content + if want := plainHTML(tc.shown); got != want { + t.Errorf("delivered %q, want exactly the shown text %q", got, want) + } + delivered[name] = got + }) + } + if delivered["an ordered list"] != delivered["an unordered list"] { + t.Error("a list's type reached the recipient without the person seeing it") + } +} + +func TestToAKnownRecipientTheDraftGoesAsItIs(t *testing.T) { + rig := newSendRig(t) + body := `
Hi Ann, the plan:
  1. numbers
` + rig.drafts.update(func(e *generated.MessageEditState) { e.Content = body }) + session := rig.connect(t, approve, "", false) + if result := sendDraft(t, session); result.IsError { + t.Fatalf("send failed: %s", resultText(t, result)) + } + mustContain(t, rig.drafts.sent()[0].Content, "Hi Ann", "
    ") +} + +func TestAPlainPathThatChangesBetweenRoundsRefusesTheSend(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { e.Content = `
    Hi Ann
    ` }) + session := rig.connect(t, func(ctx context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + // Approved as it is, to a known recipient; then an unscreened read lands, and + // the draft would now go as plain text, which isn't what was approved. + if err := rig.state.MarkExposed(ctx, "grant-1", "get_topic", rig.clock.Now()); err != nil { + t.Fatal(err) + } + return approve(ctx, req) + }, "", false) + mustContain(t, resultText(t, sendDraft(t, session)), "changes how the draft would go") + if n := len(rig.drafts.sent()); n != 0 { + t.Errorf("sent %d times", n) + } +} + +// The grant is checked again after the second round's review, right before the claim: +// a renewal there that dropped send refuses the send without claiming the draft. +func TestAGrantNarrowedDuringTheReviewDoesntClaimTheDraft(t *testing.T) { + rig := newSendRig(t) + checks := 0 + rig.cfg.CurrentGrant = func(context.Context) (string, error) { + checks++ + if checks > 2 { // both rounds' opening checks pass; the one before the claim doesn't + return "", nil + } + return "grant-1", nil + } + srv, err := New(rig.api, Config{Send: &rig.cfg}) + if err != nil { + t.Fatal(err) + } + rig.server = srv + session := rig.connect(t, approve, "2025-06-18", false) + if result := sendDraft(t, session); !result.IsError { + t.Fatalf("sent on a grant narrowed below sending: %s", resultText(t, result)) + } + if err := rig.state.claim(context.Background(), testOrigin, 5, "grant-1", nil, true, rig.clock.Now()); err != nil { + t.Errorf("the draft was claimed anyway: %v", err) + } +} + +func TestARefusalNamesNoDraftText(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { + e.Content = `
    ` + }) + session := rig.connect(t, approve, "", false) + text := resultText(t, sendDraft(t, session)) + mustContain(t, text, "can't verify") + if strings.Contains(text, "IGNORE") { + t.Errorf("the refusal carried the draft's filename: %s", text) + } + + // A custom element's name is the draft's text too, and a loading attribute on one + // is refused by naming it. + for _, content := range []string{ + `
    Hi Ann
    `, + `
    Hi Annx
    `, + } { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { e.Content = content }) + session := rig.connect(t, approve, "", false) + text := resultText(t, sendDraft(t, session)) + mustContain(t, text, "loads by itself") + mustContain(t, text, "on a custom element") + if strings.Contains(strings.ToLower(text), "ignore") { + t.Errorf("the refusal carried a custom element's name: %s", text) + } + if len(rig.drafts.sent()) != 0 { + t.Errorf("a draft with a loading custom element was sent") + } + } +} + +// --- review round nine --- + +func TestACommentIsNeverSent(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { + e.Content = `
    Hi Ann
    ` + }) + session := rig.connect(t, approve, "", false) + if result := sendDraft(t, session); result.IsError { + t.Fatalf("send failed: %s", resultText(t, result)) + } + if got := rig.drafts.sent()[0].Content; strings.Contains(got, "). + w.comments = append(w.comments, n) + return + case html.ElementNode: + default: + for c := n.FirstChild; c != nil; c = c.NextSibling { + w.walk(c) + } + return + } + + if a, isAttachment, problem := attachmentOf(n); isAttachment { + if problem != "" { + if w.unverifiable == "" { + w.unverifiable = problem + } + return + } + rebuildAttachment(n, a) + w.attachments = append(w.attachments, a) + w.text.WriteString("\n[attachment: " + a.Filename + "]\n") + return + } + + if !plainTags[n.DataAtom] { + w.lossless = false + } + if loadingTags[n.DataAtom] && w.remote == "" { + w.remote = elementName(n) + } + for _, attr := range n.Attr { + if n.DataAtom != atom.A || attr.Key != "href" { + w.lossless = false + } + if w.remote == "" && loadsByItself(attr) { + w.remote = attr.Key + " on " + elementName(n) + } + } + + if n.DataAtom == atom.Br { + w.text.WriteString("\n") + } + if n.DataAtom == atom.Li { + w.text.WriteString("- ") + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + w.walk(c) + } + if n.DataAtom == atom.A { + if href := attrValue(n, "href"); href != "" { + // A link doesn't load itself, so it's seen content once its target shows. + w.text.WriteString(" <" + href + ">") + } + } + if blockTags[n.DataAtom] { + w.text.WriteString("\n") + } +} + +// elementName names n for a refusal the model reads. Only an element HTML knows is +// named: a custom element's name is the draft's own text (), and the refusal must carry none of it. An attribute that loads is named by +// its key alone, which loadingAttributes or style has already fixed. +func elementName(n *html.Node) string { + if n.DataAtom == 0 { + return "a custom element" + } + return "<" + n.DataAtom.String() + ">" +} + +// loadsByItself reports whether an attribute makes a mail client fetch a URL on open: +// a loading attribute, or a style that names one. +func loadsByItself(attr html.Attribute) bool { + key := strings.ToLower(attr.Key) + if slices.Contains(loadingAttributes, key) { + return true + } + if key == "style" { + // CSS escapes spell url( without its letters (u\72l(...)), so a style with an + // escape, or any function call at all, counts as loading. Trix writes no style. + value := strings.ToLower(attr.Val) + return strings.ContainsAny(value, "\\(") || strings.Contains(value, "@import") + } + return false +} + +// attachmentOf reads a Trix attachment element: a figure carrying data-trix-attachment, +// or an action-text-attachment. It answers whether n is one, and a problem when it is +// one that isn't a plain blob: no sgid or filename, or embedded content (Trix carries +// an embedded email body as a filename-less text/html attachment whose markup is in +// its JSON). +func attachmentOf(n *html.Node) (attachment, bool, string) { + var a attachment + var embedded bool + switch { + case n.DataAtom == atom.Figure && hasAttr(n, "data-trix-attachment"): + var data map[string]any + if json.Unmarshal([]byte(attrValue(n, "data-trix-attachment")), &data) != nil { + return a, true, "an attachment's data doesn't parse" + } + var attributes map[string]any + _ = json.Unmarshal([]byte(attrValue(n, "data-trix-attributes")), &attributes) + _, embedded = data["content"] + a = attachment{SGID: asText(data["sgid"]), Filename: asText(data["filename"]), Size: sizeOf(data["filesize"]), ContentType: asText(data["contentType"]), Caption: asText(attributes["caption"])} + case n.Data == "action-text-attachment": + size, _ := strconv.ParseInt(attrValue(n, "filesize"), 10, 64) + embedded = hasAttr(n, "content") + a = attachment{SGID: attrValue(n, "sgid"), Filename: attrValue(n, "filename"), Size: size, ContentType: attrValue(n, "content-type"), Caption: attrValue(n, "caption")} + default: + return a, false, "" + } + + // The problem names no filename: it reaches the model, and a filename is the + // draft's text, not this server's. + switch { + case embedded: + return a, true, "an attachment carries embedded content" + case a.SGID == "" || a.Filename == "": + return a, true, "an attachment has no blob identity" + } + return a, true, "" +} + +// rebuildAttachment replaces an attachment element's attributes with its identity and +// drops everything inside it, so it's delivered as Trix submits a fresh one. +func rebuildAttachment(n *html.Node, a attachment) { + for c := n.FirstChild; c != nil; { + next := c.NextSibling + n.RemoveChild(c) + c = next + } + if n.DataAtom == atom.Figure { + data, _ := json.Marshal(struct { + ContentType string `json:"contentType,omitempty"` + Filename string `json:"filename"` + Filesize int64 `json:"filesize,omitempty"` + SGID string `json:"sgid"` + }{a.ContentType, a.Filename, a.Size, a.SGID}) + n.Attr = []html.Attribute{{Key: "data-trix-attachment", Val: string(data)}} + if a.Caption != "" { + attributes, _ := json.Marshal(map[string]string{"caption": a.Caption}) + n.Attr = append(n.Attr, html.Attribute{Key: "data-trix-attributes", Val: string(attributes)}) + } + return + } + n.Attr = []html.Attribute{{Key: "sgid", Val: a.SGID}, {Key: "filename", Val: a.Filename}} + if a.ContentType != "" { + n.Attr = append(n.Attr, html.Attribute{Key: "content-type", Val: a.ContentType}) + } + if a.Size > 0 { + n.Attr = append(n.Attr, html.Attribute{Key: "filesize", Val: strconv.FormatInt(a.Size, 10)}) + } + if a.Caption != "" { + n.Attr = append(n.Attr, html.Attribute{Key: "caption", Val: a.Caption}) + } +} + +func sizeOf(v any) int64 { + switch size := v.(type) { + case float64: + return int64(size) + case string: + n, _ := strconv.ParseInt(size, 10, 64) + return n + default: + return 0 + } +} + +func attrValue(n *html.Node, key string) string { + for _, attr := range n.Attr { + if attr.Key == key { + return attr.Val + } + } + return "" +} + +func hasAttr(n *html.Node, key string) bool { + for _, attr := range n.Attr { + if attr.Key == key { + return true + } + } + return false +} + +func asText(v any) string { + s, _ := v.(string) + return s +} + +// plainOf is text as it goes when sent as plain text: every invisible character +// removed, compatibility-normalized (NFKC, so a no-break space is a space and a +// decomposed letter is one letter, and two spellings that look alike are one), and +// trailing whitespace dropped from each line, so nothing in it is unseen. +func plainOf(text string) string { + lines := strings.Split(text, "\n") + for i, line := range lines { + lines[i] = plainLine(line) + } + return strings.Join(lines, "\n") +} + +// plainLine is one line of plainOf. Invisible characters go first, so none can shield +// whitespace from the trim. +func plainLine(line string) string { + visible := strings.Map(func(r rune) rune { + if invisible(r) { + return -1 + } + return r + }, line) + return strings.TrimRightFunc(norm.NFKC.String(visible), unicode.IsSpace) +} + +// plainSubject is a subject as it goes on the plain path: one plain line. +func plainSubject(subject string) string { + return plainLine(strings.ReplaceAll(strings.ReplaceAll(subject, "\r", " "), "\n", " ")) +} + +// plainHTML is the body made from plain text: its lines, escaped, with a break between +// each, in one block. +func plainHTML(text string) string { + var b strings.Builder + b.WriteString("
    ") + for i, line := range strings.Split(text, "\n") { + if i > 0 { + b.WriteString("
    ") + } + b.WriteString(keepSpaces(html.EscapeString(line))) + } + b.WriteString("
    ") + return b.String() +} + +// keepSpaces makes the spaces HTML would collapse hold: a leading space, and every +// space after another, become non-breaking, and a tab becomes four, so the recipient +// sees the spacing the confirmation showed. +func keepSpaces(line string) string { + var b strings.Builder + previous := ' ' // a line's first space is leading + for _, r := range line { + switch { + case r == '\t': + b.WriteString("    ") + r = ' ' + case r == ' ' && previous == ' ': + b.WriteString(" ") + default: + b.WriteRune(r) + } + previous = r + } + return b.String() +} + +// plainText is the text the prompt shows: the rendering with its line endings +// normalized and the one newline its last block ends with dropped, and nothing else changed, +// so every space and blank line the body delivers is in what the prompt shows and is +// measured. hidesWhitespace reports trailing spaces or tabs on a line, which a prompt +// can't show and a
     can carry data in.
    +func plainText(s string) (string, bool) {
    +	s = strings.TrimSuffix(strings.ReplaceAll(s, "\r\n", "\n"), "\n")
    +	for line := range strings.SplitSeq(s, "\n") {
    +		if strings.TrimRight(line, " \t") != line {
    +			return s, true
    +		}
    +	}
    +	return s, false
    +}
    diff --git a/internal/mcpserver/sendprompt.go b/internal/mcpserver/sendprompt.go
    new file mode 100644
    index 000000000..ef8a11298
    --- /dev/null
    +++ b/internal/mcpserver/sendprompt.go
    @@ -0,0 +1,192 @@
    +package mcpserver
    +
    +import (
    +	"fmt"
    +	"strings"
    +	"unicode"
    +	"unicode/utf8"
    +
    +	"github.com/basecamp/hey-sdk/go/pkg/generated"
    +)
    +
    +// The confirmation the person reads (sending-safety D2). Every recipient field is shown,
    +// Bcc included and "—" when empty. Every dynamic field is rendered inert: the subject,
    +// names, attachment names and body were chosen by a model or a sender, and the host shows
    +// the prompt as plain text, so a newline in a display name could forge a Bcc line and a
    +// bidi override could reorder an address. Header fields are single lines, capped, and
    +// come first in a fixed order; every body line is indented beneath "Body:". This is
    +// presentation only: the fingerprint covers the raw values.
    +
    +func (s *sender) prompt(snap *draftSnapshot, outcome *reviewOutcome) string {
    +	var b strings.Builder
    +	if outcome.exposed {
    +		fmt.Fprintf(&b, "This conversation read mail that may be from an unscreened sender (%s) in the last few minutes. Check that you asked for this email.\n\n", inertLine(outcome.exposedBy))
    +	}
    +	fmt.Fprintf(&b, "Send this email from %s?\n", recipientLine(snap.from, nil))
    +	b.WriteString("  When:     Now (undo window applies)\n")
    +	fmt.Fprintf(&b, "  To:       %s\n", recipientList(snap.to, outcome.newAddresses))
    +	fmt.Fprintf(&b, "  Cc:       %s\n", recipientList(snap.cc, outcome.newAddresses))
    +	fmt.Fprintf(&b, "  Bcc:      %s\n", recipientList(snap.bcc, outcome.newAddresses))
    +	// Delivered text is shown whole: only display names, which aren't sent, are capped.
    +	subject := snap.subject
    +	if outcome.plain {
    +		subject = plainSubject(subject)
    +	}
    +	fmt.Fprintf(&b, "  Subject:  %s\n", inertWhole(subject))
    +	fmt.Fprintf(&b, "  Attached: %s\n", attachmentList(snap.body.attachments))
    +	b.WriteString("  Body:\n")
    +
    +	text := snap.body.text
    +	if outcome.plain {
    +		text = snap.body.plainText
    +	}
    +	body := inertBody(text)
    +	total := utf8.RuneCountInString(body)
    +	if total > bodyPromptCap {
    +		body = string([]rune(body)[:bodyPromptCap]) + " …"
    +	}
    +	for line := range strings.SplitSeq(body, "\n") {
    +		b.WriteString("    " + line + "\n")
    +	}
    +	if total > bodyPromptCap {
    +		fmt.Fprintf(&b, "    [first %s characters of %s · open the full draft in HEY: %s]\n", thousands(bodyPromptCap), thousands(total), s.editURL(snap.id))
    +	}
    +	switch {
    +	case outcome.plain:
    +		b.WriteString("  Goes as plain text: the draft's formatting isn't sent, so what's above is all of it.\n")
    +	case !snap.body.lossless:
    +		fmt.Fprintf(&b, "  Some of this draft can't be shown here; open it in HEY to see all of it: %s\n", s.editURL(snap.id))
    +	}
    +	if len(outcome.newAddresses) > 0 {
    +		b.WriteString("\nRecipients marked (new) haven't been emailed from this account before hey mcp was signed in.\n")
    +	}
    +	return b.String()
    +}
    +
    +// recipientLine shows a recipient as "Name" 
    , so the address the mail goes to is +// always visible beside whatever name the draft carries. +func recipientLine(c generated.Contact, fresh map[string]bool) string { + // The address is escaped but never truncated: the person has to see all of where + // the mail goes. + address := "<" + escapeInvisible(c.EmailAddress, false) + ">" + line := address + if name := strings.TrimSpace(c.Name); name != "" && !strings.EqualFold(name, c.EmailAddress) { + line = `"` + strings.ReplaceAll(inertLine(name), `"`, `\"`) + `" ` + address + } + if fresh[normalizedAddress(c.EmailAddress)] { + line += " (new: never emailed before)" + } + return line +} + +func recipientList(contacts []generated.Contact, fresh map[string]bool) string { + if len(contacts) == 0 { + return "—" + } + lines := make([]string, 0, len(contacts)) + for _, c := range contacts { + lines = append(lines, recipientLine(c, fresh)) + } + return strings.Join(lines, ", ") +} + +func attachmentList(attachments []attachment) string { + if len(attachments) == 0 { + return "—" + } + names := make([]string, 0, len(attachments)) + for _, a := range attachments { + name := fmt.Sprintf("%s (%s)", inertWhole(a.Filename), humanSize(a.Size)) + if a.Caption != "" { + name += `, captioned "` + inertWhole(a.Caption) + `"` + } + names = append(names, name) + } + return strings.Join(names, ", ") +} + +func humanSize(n int64) string { + switch { + case n >= 1<<20: + return fmt.Sprintf("%.1f MB", float64(n)/(1<<20)) + case n >= 1<<10: + return fmt.Sprintf("%d KB", n/(1<<10)) + default: + return fmt.Sprintf("%d bytes", n) + } +} + +func thousands(n int) string { + s := fmt.Sprint(n) + for i := len(s) - 3; i > 0; i -= 3 { + s = s[:i] + "," + s[i:] + } + return s +} + +// inertLine renders a header field: one line, every control and invisible character +// escaped, capped at headerCap characters. +func inertLine(s string) string { + out := escapeInvisible(s, false) + if utf8.RuneCountInString(out) > headerCap { + out = string([]rune(out)[:headerCap]) + "…" + } + return out +} + +// inertWhole renders a delivered header field (the subject, an attachment's name and +// caption): one line, every control and invisible character escaped, never truncated, +// since what's cut off would go out unseen. +func inertWhole(s string) string { + return escapeInvisible(s, false) +} + +// inertBody renders the body: plain newlines kept, everything else escaped. +func inertBody(s string) string { + return escapeInvisible(s, true) +} + +// escapeInvisible replaces C0 and C1 controls, bidi embeddings, overrides, isolates and +// marks, zero-width characters and the Unicode line and paragraph separators with a +// visible \u{…} escape. keepNewlines keeps "\n" as a line break. +func escapeInvisible(s string, keepNewlines bool) string { + var b strings.Builder + for _, r := range s { + if r == '\n' && keepNewlines { + b.WriteRune(r) + continue + } + if invisible(r) { + fmt.Fprintf(&b, `\u{%04X}`, r) + continue + } + b.WriteRune(r) + } + return b.String() +} + +// invisible reports a rune that shows as nothing or reorders what's around it: every +// format character (Unicode Cf: bidi controls, zero-width characters, tag characters, +// the soft hyphen), variation selectors, private-use characters, the rest of Unicode's +// default-ignorable set (the combining grapheme joiner, Hangul fillers), and the controls and +// separators below. A class, not a list, so the next invisible character is caught too. +func invisible(r rune) bool { + if unicode.In(r, unicode.Cf, unicode.Co, unicode.Variation_Selector, unicode.Other_Default_Ignorable_Code_Point) { + return true + } + switch { + case r < 0x20, r == 0x7F, r >= 0x80 && r <= 0x9F: + return true + case r >= 0x202A && r <= 0x202E, r >= 0x2066 && r <= 0x2069: + return true + case r == 0x200E, r == 0x200F, r == 0x061C: + return true + case r >= 0x200B && r <= 0x200D, r == 0x2060, r == 0xFEFF, r == 0x00AD, r == 0x180E: + return true + case r == 0x2028, r == 0x2029: + return true + case r == utf8.RuneError: + return true + } + return false +} diff --git a/internal/mcpserver/sendstate.go b/internal/mcpserver/sendstate.go new file mode 100644 index 000000000..dce63f91d --- /dev/null +++ b/internal/mcpserver/sendstate.go @@ -0,0 +1,389 @@ +package mcpserver + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "slices" + "sync" + "time" + + "github.com/gofrs/flock" +) + +// SendState is send_draft's state on stdio (sending-safety D2.3, D4, D6; roadmap +// "Implementation requirements"): the per-draft send claims, each grant's caps and +// new-address membership, and each grant's exposure marker, in one JSON file, with the +// confirmation key beside it. +// +// Every read-check-write runs under an exclusive OS file lock on a separate lock file +// that is never renamed or replaced, held across the whole read, check, write and +// rename: two hey mcp processes can't both claim one draft or both spend the last of a +// cap. The new file is fsynced before the rename and its directory after it, under the +// lock, and a failed fsync fails the operation, so an acknowledged claim survives a +// crash. A lock that can't be taken within lockTimeout fails closed. +type SendState struct { + dir string + // lockTimeout bounds the wait for another process's update. + lockTimeout time.Duration + // fsync syncs a file or directory; a seam so a test can make it fail. + fsync func(*os.File) error + + mu sync.Mutex +} + +const ( + sendStateFile = "mcp-send-state.json" + sendStateLock = "mcp-send-state.lock" + confirmKeyFile = "mcp-confirm.key" + confirmKeyBytes = 32 +) + +// claimTTL is how long a claimed draft stays claimed: well past the confirmation's TTL +// and HEY's undo window (D2.3). +const claimTTL = 24 * time.Hour + +// capWindow is the rolling window the per-grant caps count over (D4). +const capWindow = 24 * time.Hour + +// OpenSendState opens (creating) the send state directory. +func OpenSendState(dir string) (*SendState, error) { + if dir == "" { + return nil, errors.New("no state directory for hey mcp's send safety state") + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, fmt.Errorf("create %s: %w", dir, err) + } + return &SendState{dir: dir, lockTimeout: 5 * time.Second, fsync: (*os.File).Sync}, nil +} + +// sendStateData is the state file's content. +type sendStateData struct { + Version int `json:"version"` + // Claims maps a claimed draft (claimKey) to when it was claimed, Unix seconds. A + // claim is never released early (D2.3). + Claims map[string]int64 `json:"claims"` + // Grants holds each grant's caps and exposure, by grant key. + Grants map[string]*grantState `json:"grants"` +} + +type grantState struct { + // Sends are when each confirmed send was claimed, Unix seconds. + Sends []int64 `json:"sends,omitempty"` + // NewAddresses are the new addresses sent to in the window, each with when it was + // last sent to: a send is charged only for new addresses not already here (D4). + NewAddresses map[string]int64 `json:"new_addresses,omitempty"` + // ExposedUntil is when the exposure window ends, Unix seconds (D6). + ExposedUntil int64 `json:"exposed_until,omitempty"` + // ExposedBy names the read that last exposed the grant. + ExposedBy string `json:"exposed_by,omitempty"` +} + +// claimKey is a draft's claim: the HEY it lives on and its id, and nothing else, so +// every confirmation of that draft contends for one claim whatever its grant, content +// or schedule (D2.3). +func claimKey(origin string, draftID int64) string { + return fmt.Sprintf("%s#%d", origin, draftID) +} + +func (d *sendStateData) grant(key string) *grantState { + g := d.Grants[key] + if g == nil { + g = &grantState{} + d.Grants[key] = g + } + if g.NewAddresses == nil { + g.NewAddresses = map[string]int64{} + } + return g +} + +// prune drops what has aged out of every window. +func (d *sendStateData) prune(now time.Time) { + for key, at := range d.Claims { + if now.Sub(time.Unix(at, 0)) >= claimTTL { + delete(d.Claims, key) + } + } + for key, g := range d.Grants { + g.Sends = slices.DeleteFunc(g.Sends, func(at int64) bool { return now.Sub(time.Unix(at, 0)) >= capWindow }) + for address, at := range g.NewAddresses { + if now.Sub(time.Unix(at, 0)) >= capWindow { + delete(g.NewAddresses, address) + } + } + if g.ExposedUntil != 0 && !now.Before(time.Unix(g.ExposedUntil, 0)) { + g.ExposedUntil, g.ExposedBy = 0, "" + } + if len(g.Sends) == 0 && len(g.NewAddresses) == 0 && g.ExposedUntil == 0 { + delete(d.Grants, key) + } + } +} + +// update runs fn on the state under the lock and writes the result durably. fn's error +// leaves the file as it was. +func (s *SendState) update(ctx context.Context, fn func(*sendStateData) error) error { + return s.locked(ctx, func() error { + data, err := s.read() + if err != nil { + return err + } + if err := fn(data); err != nil { + return err + } + return s.write(data) + }) +} + +// view runs fn on the state under the lock, writing nothing. +func (s *SendState) view(ctx context.Context, fn func(*sendStateData)) error { + return s.locked(ctx, func() error { + data, err := s.read() + if err != nil { + return err + } + fn(data) + return nil + }) +} + +func (s *SendState) locked(ctx context.Context, fn func() error) error { + // A file lock belongs to an open file, not a goroutine, so the mutex is what makes + // it exclusive inside this process. + s.mu.Lock() + defer s.mu.Unlock() + + lock := flock.New(filepath.Join(s.dir, sendStateLock)) + waiting, cancel := context.WithTimeout(ctx, s.lockTimeout) + defer cancel() + locked, err := lock.TryLockContext(waiting, 25*time.Millisecond) + if err != nil { + return fmt.Errorf("could not lock hey mcp's send state: %w", err) + } + if !locked { + return errors.New("hey mcp's send state is locked by another process") + } + defer func() { _ = lock.Unlock() }() + return fn() +} + +func (s *SendState) read() (*sendStateData, error) { + data := &sendStateData{Version: 1, Claims: map[string]int64{}, Grants: map[string]*grantState{}} + raw, err := os.ReadFile(filepath.Join(s.dir, sendStateFile)) + if errors.Is(err, os.ErrNotExist) { + return data, nil + } + if err != nil { + return nil, fmt.Errorf("read hey mcp's send state: %w", err) + } + if err := json.Unmarshal(raw, data); err != nil { + // A state file that doesn't parse can't say what was claimed or charged. + return nil, fmt.Errorf("hey mcp's send state %s is unreadable: %w", filepath.Join(s.dir, sendStateFile), err) + } + if data.Claims == nil { + data.Claims = map[string]int64{} + } + if data.Grants == nil { + data.Grants = map[string]*grantState{} + } + return data, nil +} + +// write replaces the state file: a temporary file fsynced, renamed into place, and the +// directory fsynced. Any failure fails the update. +func (s *SendState) write(data *sendStateData) error { + raw, err := json.Marshal(data) + if err != nil { + return err + } + tmp, err := os.CreateTemp(s.dir, sendStateFile+".*.tmp") + if err != nil { + return fmt.Errorf("write hey mcp's send state: %w", err) + } + tmpPath := tmp.Name() + keep := false + defer func() { + if !keep { + _ = os.Remove(tmpPath) //nolint:gosec // G703: path from os.CreateTemp + } + }() + + if _, err := tmp.Write(raw); err != nil { + _ = tmp.Close() + return fmt.Errorf("write hey mcp's send state: %w", err) + } + if err := s.fsync(tmp); err != nil { + _ = tmp.Close() + return fmt.Errorf("fsync hey mcp's send state: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("write hey mcp's send state: %w", err) + } + if err := os.Rename(tmpPath, filepath.Join(s.dir, sendStateFile)); err != nil { //nolint:gosec // G703: paths built from the state directory + return fmt.Errorf("replace hey mcp's send state: %w", err) + } + keep = true + return s.syncDir() +} + +// syncDir fsyncs the state directory, so the rename itself is durable. Windows can't +// open a directory for syncing; NTFS journals the rename's metadata. +func (s *SendState) syncDir() error { + if runtime.GOOS == "windows" { + return nil + } + dir, err := os.Open(s.dir) + if err != nil { + return fmt.Errorf("fsync hey mcp's state directory: %w", err) + } + defer dir.Close() + if err := s.fsync(dir); err != nil { + return fmt.Errorf("fsync hey mcp's state directory: %w", err) + } + return nil +} + +// ConfirmKey is the installation's confirmation key, generated once under the lock and +// kept beside the state, never regenerated per process (D4). +func (s *SendState) ConfirmKey(ctx context.Context) ([]byte, error) { + var key []byte + err := s.locked(ctx, func() error { + path := filepath.Join(s.dir, confirmKeyFile) + raw, err := os.ReadFile(path) //nolint:gosec // G304: path built from the state directory + if err == nil { + decoded, decodeErr := hex.DecodeString(string(raw)) + if decodeErr != nil || len(decoded) != confirmKeyBytes { + return fmt.Errorf("hey mcp's confirmation key %s is malformed; remove it to make a new one", path) + } + key = decoded + return nil + } + if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("read hey mcp's confirmation key: %w", err) + } + + fresh := make([]byte, confirmKeyBytes) + if _, randErr := rand.Read(fresh); randErr != nil { + return randErr + } + file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) //nolint:gosec // G304: path built from the state directory + if err != nil { + return fmt.Errorf("create hey mcp's confirmation key: %w", err) + } + if _, err := file.WriteString(hex.EncodeToString(fresh)); err != nil { + _ = file.Close() + return err + } + if err := s.fsync(file); err != nil { + _ = file.Close() + return fmt.Errorf("fsync hey mcp's confirmation key: %w", err) + } + if err := file.Close(); err != nil { + return err + } + key = fresh + return s.syncDir() + }) + return key, err +} + +// exposureWindow is how long an unscreened read tightens sends (D6). +const exposureWindow = 10 * time.Minute + +// MarkExposed records that the grant read content that may be an unscreened sender's, +// restarting its exposure window. It runs before that content is returned. +func (s *SendState) MarkExposed(ctx context.Context, grantKey, by string, now time.Time) error { + return s.update(ctx, func(d *sendStateData) error { + d.prune(now) + g := d.grant(grantKey) + g.ExposedUntil = now.Add(exposureWindow).Unix() + g.ExposedBy = by + return nil + }) +} + +// exposure reports whether the grant is exposed now, and by which read. +func (s *SendState) exposure(ctx context.Context, grantKey string, now time.Time) (bool, string, error) { + var exposed bool + var by string + err := s.view(ctx, func(d *sendStateData) { + d.prune(now) + if g := d.Grants[grantKey]; g != nil && g.ExposedUntil != 0 { + exposed, by = true, g.ExposedBy + } + }) + return exposed, by, err +} + +// budget is what a grant has spent in the window: its sends, and the new addresses +// already charged. +func (s *SendState) budget(ctx context.Context, grantKey string, now time.Time) (int, map[string]bool, error) { + var sends int + charged := map[string]bool{} + err := s.view(ctx, func(d *sendStateData) { + d.prune(now) + if g := d.Grants[grantKey]; g != nil { + sends = len(g.Sends) + for address := range g.NewAddresses { + charged[address] = true + } + } + }) + return sends, charged, err +} + +// refusalError is a send the state refused: the reason is the in-band error. +type refusalError struct{ reason string } + +func (r *refusalError) Error() string { return r.reason } + +// claim takes a draft's send claim and charges the grant's caps, in one locked update, +// or refuses. It re-reads exposure there: a draft with a new recipient, or one going as +// it is rather than as the plain text the confirmation showed (unseen), is refused if +// an unscreened read landed since the policy was checked (D2, D6). Nothing is written +// when it refuses. +func (s *SendState) claim(ctx context.Context, origin string, draftID int64, grantKey string, newAddresses []string, unseen bool, now time.Time) error { + return s.update(ctx, func(d *sendStateData) error { + d.prune(now) + key := claimKey(origin, draftID) + if _, taken := d.Claims[key]; taken { + return &refusalError{"this draft was already sent, or a send of it is in progress, through hey mcp in the last 24 hours, so it wasn't sent again. Check Sent in HEY. To send it anyway, send it from HEY"} + } + + g := d.grant(grantKey) + if g.ExposedUntil != 0 && len(newAddresses) > 0 { + return &refusalError{fmt.Sprintf("not sent: this conversation read mail that may be from an unscreened sender (%s) in the last %d minutes, and the draft is addressed to someone you haven't emailed before (%s). Send it from HEY", g.ExposedBy, int(exposureWindow/time.Minute), newAddresses[0])} + } + if g.ExposedUntil != 0 && unseen { + return &refusalError{fmt.Sprintf("not sent: this conversation read mail that may be from an unscreened sender (%s) in the last %d minutes, and the draft can't be fully reviewed in the confirmation. Send it from HEY", g.ExposedBy, int(exposureWindow/time.Minute))} + } + if len(g.Sends) >= maxSendsPerDay { + return &refusalError{fmt.Sprintf("not sent: hey mcp sends at most %d messages a day on one sign-in, and this one has reached it. Send it from HEY", maxSendsPerDay)} + } + var charge []string + for _, address := range newAddresses { + if _, charged := g.NewAddresses[address]; !charged && !slices.Contains(charge, address) { + charge = append(charge, address) + } + } + if len(g.NewAddresses)+len(charge) > maxNewAddressesPerDay { + return &refusalError{fmt.Sprintf("not sent: hey mcp sends to at most %d new addresses a day on one sign-in, and this draft would go past it. Send it from HEY", maxNewAddressesPerDay)} + } + + d.Claims[key] = now.Unix() + g.Sends = append(g.Sends, now.Unix()) + // Each new address counts once, and stays in the window for 24 hours after the + // last send to it, so the window holds every new address sent to within it. + for _, address := range newAddresses { + g.NewAddresses[address] = now.Unix() + } + return nil + }) +} diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index a9c7a65ca..94adaf7f6 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -3,6 +3,7 @@ package mcpserver import ( "fmt" "log/slog" + "slices" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -24,12 +25,18 @@ type Config struct { // Domains narrows the served domains by key ("boxes", "search", ...). // Empty means all. Unknown keys are a startup error — fail closed. Domains []string + // Send is set when the sign-in can send. It serves send_draft in its own hey_send + // tool (send.go) when the threads domain is served and the server isn't read-only, + // and either way records which reads expose the sign-in (exposure.go). Nil serves no + // way to send and records nothing. + Send *SendConfig } // Server wraps the toolkit gateway serving hey's derived catalog, dispatching // through the CLI's authenticated SDK client. type Server struct { - gw *gateway.Server + gw *gateway.Server + sender *sender } // New derives the catalog and hands it to the gateway, which applies the @@ -47,16 +54,41 @@ func New(api API, cfg Config) (*Server, error) { return nil, fmt.Errorf("hold sending operations to drafts: %w", err) } + // Exposure is recorded whenever the sign-in can send, whether or not this process + // serves hey_send: another hey mcp on the same sign-in may, and it reads the same + // state file. + var send *sender + d := dispatcher{api: api} + if cfg.Send != nil { + if send, err = newSender(*cfg.Send); err != nil { + return nil, fmt.Errorf("serve send_draft: %w", err) + } + d = dispatcher{api: api, markExposed: send.markExposed} + } + if cfg.ReadOnly || !servesDomain(cfg.Domains, "threads") { + send = nil + } + gw, err := gateway.New(cat.GatewayDomains(), gateway.Config{ ReadOnly: cfg.ReadOnly, Domains: cfg.Domains, - Handler: dispatcher{api: api}.handle, + Handler: d.handle, }) if err != nil { return nil, err } - return &Server{gw: gw}, nil + return &Server{gw: gw, sender: send}, nil +} + +// servesDomain reports whether a domain selection includes key; empty selects all. +func servesDomain(selected []string, key string) bool { + return len(selected) == 0 || slices.Contains(selected, key) +} + +// ServesSend reports whether send_draft is served. +func (s *Server) ServesSend() bool { + return s.sender != nil } // Domains returns the served domains. @@ -67,5 +99,9 @@ func (s *Server) Domains() []gateway.Domain { // BuildMCPServer constructs the SDK MCP server with one gateway tool per // served domain. func (s *Server) BuildMCPServer(logger *slog.Logger) *mcp.Server { - return s.gw.BuildMCPServer(&mcp.Implementation{Name: Name, Version: version.Version}, logger) + server := s.gw.BuildMCPServer(&mcp.Implementation{Name: Name, Version: version.Version}, logger) + if s.sender != nil { + server.AddTool(s.sender.tool(), s.sender.handle) + } + return server } diff --git a/nix/package.nix b/nix/package.nix index 99d5eced4..fe29cf5d6 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -18,7 +18,7 @@ buildGoModule.override { inherit go; } (finalAttrs: { # To update: run `make update-nix-hash` (Docker). It rewrites this quoted # value in place, so keep it a string literal rather than lib.fakeHash. - vendorHash = "sha256-UNlf35JRcZFPxRoTTXiY+cdLV+7mjKY6pP14uULfD5U="; + vendorHash = "sha256-wZHV8zifrNPyw1KNE2qIxC38A+z+iD9+QhtRNQqYzvY="; subPackages = [ "cmd/hey" ];