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 a89df965b..2230797f9 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 is the same in every HEY. `HEY_MCP_CLIENT_ID`, or `mcp_client_ids` +in `~/.config/hey-cli/config.json` keyed by HEY origin +(`{"mcp_client_ids": {"https://app.hey.com": "..."}}`), overrides it. 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`, @@ -103,8 +126,33 @@ 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, +images and the body's first 1,500 characters, with links' targets beside their text and +every control and invisible character escaped so a display name can't forge a line. +Whatever it can't show (the rest of a long body, what attachments hold, images, hidden +parts or formatting the text leaves out) is named on a `Not shown here` line beside the +draft's link in HEY. 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 a `hey mcp` process 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. + +So when you ask your agent to send a draft, it calls `send_draft` straight away: the +confirmation is where you check what goes out, and saying yes in the chat doesn't send +anything. You can still have it show or revise the draft first as you like. + +`hey_send` is served only when the sign-in was granted sending, never under `--read-only`, +and is listed only to an MCP client that can show a confirmation. A client that can't is +told to send each draft from its page in HEY (the draft save's `app_url`) or with +`hey draft send `, and a call to `send_draft` from it answers with that link, as +`edit_url`, and sends nothing. `send_draft` also refuses a draft that is already +scheduled, has no recipients, or has a recipient with no address. Nothing +else a draft holds refuses it: how much your agent sends, and to whom, is yours to decide +once you've granted sending. + +Earlier releases delivered mail when a call omitted `entry.status`; these actions now +save 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 9c47d960e..55bdaf442 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/internal/auth/auth.go b/internal/auth/auth.go index 6c377fbcc..ceff0d33c 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -28,7 +28,28 @@ 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: the tools it serves follow the grant it started on. Guarded + // by mu. + pinnedGrant string + store *Store httpClient *http.Client callbackWait callbackWaiter @@ -63,11 +84,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 +123,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 +173,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 +229,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 +302,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 +326,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 +352,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 +400,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 +427,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 +443,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 +462,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 +472,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 +520,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 +537,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 +557,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 +615,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 +624,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 +752,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..a099f4c1d --- /dev/null +++ b/internal/auth/mcp.go @@ -0,0 +1,199 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "slices" + "strings" + + "github.com/basecamp/hey-cli/internal/config" + "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) + // Keyed by the canonical origin, so https://APP.HEY.COM:443 finds the credential + // https://app.hey.com stored, as the client id lookup does. + origin := m.baseURL + if canonical, err := config.Origin(baseURL); err == nil { + origin = canonical + } + m.storeKey = mcpStoreKeyPrefix + origin + 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..9ec185125 --- /dev/null +++ b/internal/auth/mcp_test.go @@ -0,0 +1,514 @@ +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) + } +} + +// One HEY has one hey mcp credential however its URL is spelled. +func TestMCPCredentialKeyIsTheCanonicalOrigin(t *testing.T) { + a := NewMCPManager("https://app.hey.com/", http.DefaultClient, t.TempDir(), "LocalMcpClient0000000001") + b := NewMCPManager("https://APP.HEY.COM:443", http.DefaultClient, t.TempDir(), "LocalMcpClient0000000001") + if a.CredentialKey() != b.CredentialKey() || a.CredentialKey() != "mcp::https://app.hey.com" { + t.Errorf("keys %q and %q, want both mcp::https://app.hey.com", a.CredentialKey(), b.CredentialKey()) + } +} 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..e580fda94 100644 --- a/internal/auth/store.go +++ b/internal/auth/store.go @@ -67,6 +67,19 @@ 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. A running hey mcp stays on the sign-in it + // started with, and binds each send confirmation to it. + GrantKey string `json:"grant_key,omitempty"` + // GrantedAt is when the sign-in completed, in Unix seconds, for hey auth status. + 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..533b2c0e7 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 != "" || cmd.Flags().Changed("token") || cmd.Flags().Changed("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..198d89e27 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 Override 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..aecc7241f 100644 --- a/internal/cmd/mcp.go +++ b/internal/cmd/mcp.go @@ -3,14 +3,18 @@ package cmd import ( "context" "log/slog" + "net/http" "os" "os/signal" "syscall" + "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 +34,20 @@ 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 naming anything it can't show, and sends exactly\n" + + "what you approved, once. It is served only when the sign-in was granted sending,\n" + + "and listed only to a client that can show a confirmation; any other client is\n" + + "pointed at the draft's page in HEY. --read-only drops every write action, drafts\n" + + "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,78 @@ 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 } - - // 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) + // This process authenticates only as the sign-in it starts on: the tools it serves + // follow that sign-in's grant, and a confirmation is bound to it. + mcpAuthMgr.PinGrant(creds.GrantKey) + + ctx := cmd.Context() + // The tools are chosen once, from the grant's scope, so renew a token about to + // expire first: a renewal can narrow the grant, and the tools should follow what it + // granted. A renewal that fails here (HEY unreachable) leaves the stored grant; the + // first request reports it. + if _, renewErr := mcpAuthMgr.AccessToken(ctx); renewErr == nil { + if creds, err = requireMCPCredentials(); err != nil { + return err + } + } + 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} + canSend := mcpCanSend(creds) + if canSend && !readOnly { + origin, originErr := config.Origin(cfg.BaseURL) + if originErr != nil { + return originErr + } + // 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}, + GrantKey: creds.GrantKey, + 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 +183,47 @@ 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) +} diff --git a/internal/cmd/mcp_auth.go b/internal/cmd/mcp_auth.go new file mode 100644 index 000000000..973a45130 --- /dev/null +++ b/internal/cmd/mcp_auth.go @@ -0,0 +1,190 @@ +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 +} + +// 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 == nil || (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). A +// send confirmation is bound to the sign-in's local key, 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 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 + } + // The same test hey mcp starts on (checkMCPCredentials), so status never reports a + // sign-in hey mcp would refuse. + if creds == nil || (creds.AccessToken == "" && creds.RefreshToken == "") || creds.GrantKey == "" { + 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) { + // A credential that couldn't be read isn't a missing one: signing in again won't + // help, so say what the store answered (checkMCPCredentials reads it the same way). + if storeErr, ok := status["error"].(string); ok && storeErr != "" { + fmt.Fprintf(w, "MCP: Couldn't read hey mcp's sign-in: %s\n", storeErr) + return + } + 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..2d878c5c3 --- /dev/null +++ b/internal/cmd/mcp_auth_test.go @@ -0,0 +1,180 @@ +package cmd + +import ( + "bytes" + "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 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) + } +} + +// A credential the store couldn't read is reported as that, not as signed out: signing +// in again won't help. +func TestStyledMCPStatusReportsAStoreErrorRatherThanSignedOut(t *testing.T) { + var out bytes.Buffer + printMCPStatus(&out, map[string]any{"authenticated": false, "error": "keychain access denied"}) + if got := out.String(); !strings.Contains(got, "Couldn't read hey mcp's sign-in: keychain access denied") || strings.Contains(got, "Not signed in") { + t.Errorf("styled status = %q", got) + } + + out.Reset() + printMCPStatus(&out, map[string]any{"authenticated": false}) + if got := out.String(); !strings.Contains(got, "Not signed in (run: hey auth login --mcp)") { + t.Errorf("styled status = %q", got) + } +} diff --git a/internal/cmd/mcp_test.go b/internal/cmd/mcp_test.go index 03095118b..9b30864ee 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) @@ -102,6 +163,15 @@ func runMCPCommand(t *testing.T, upstream *httptest.Server, args ...string) *mcp return session } +// askingClient is an MCP client that can be asked (it declares elicitation, and +// declines), so hey_send is listed to it wherever it's served: hey_send is listed only +// to a client that can answer its confirmation. +func askingClient() *mcp.ClientOptions { + return &mcp.ClientOptions{ElicitationHandler: func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + return &mcp.ElicitResult{Action: "decline"}, nil + }} +} + func TestMCPCommandServesMCP(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || r.URL.Path != "/boxes.json" { @@ -109,16 +179,16 @@ 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"}]`)) })) t.Cleanup(upstream.Close) - session := runMCPCommand(t, upstream) + session := runMCPCommandWith(t, upstream, auth.MCPScope, askingClient()) if got := session.InitializeResult().ServerInfo.Name; got != "hey-cli" { t.Errorf("server name = %q, want hey-cli", got) @@ -131,8 +201,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 +354,248 @@ 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", askingClient())) + 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", askingClient()) + 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, runMCPCommandWith(t, upstream, auth.MCPScope, askingClient(), "--read-only")); slices.Contains(names, "hey_send") { + t.Errorf("--read-only serves hey_send: %v", names) + } + }) + t.Run("a client that can't be asked", func(t *testing.T) { + if names := toolNames(t, runMCPCommand(t, upstream)); slices.Contains(names, "hey_send") || len(names) != 7 { + t.Errorf("tools = %v, want the 7 domain tools and no hey_send for a client that can't confirm a send", 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.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, `To: "Ann Example" `) || !strings.Contains(prompt, " Hi Ann") { + 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.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.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 refreshes.Load() == 0 { + t.Fatal("the renewal was never tried, so this doesn't test a failed one") + } + // A claimed draft would answer "already sent" from here on; an unclaimed one is + // reviewed again and refused for the token again. + again, 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) + } + for _, r := range []*mcp.CallToolResult{result, again} { + if strings.Contains(r.Content[0].(*mcp.TextContent).Text, "already sent") { + t.Errorf("the draft was claimed: %v", r.Content) + } + } + if puts.Load() != 0 { + t.Errorf("sent (%d PUTs) on a token that couldn't be renewed", puts.Load()) + } +} + +// A token due for renewal is renewed before the tools are chosen, so a renewal that +// narrows the grant is what the tools follow. +func TestMCPCommandChoosesItsToolsAfterRenewing(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && r.URL.Path == "/oauth/tokens" { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"` + mcpTestToken + `","refresh_token":"mcp-refresh-2","token_type":"bearer","expires_in":3600,"scope":"read"}`)) + return + } + 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 + t.Cleanup(func() { mcpTokenExpiresIn = orig }) + names := toolNames(t, runMCPCommandWith(t, upstream, auth.MCPScope, askingClient())) + if slices.Contains(names, "hey_send") { + t.Errorf("tools = %v: hey_send served on a grant renewed down to read", names) + } +} 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..dc37d2d65 --- /dev/null +++ b/internal/config/mcp.go @@ -0,0 +1,87 @@ +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), pinned by haystack's first-party client registry +// (haystack#9215, Oauth::Client::Mcp) to this one id in every HEY environment, as the +// CLI's own id is. HEY_MCP_CLIENT_ID or a configured id (MCPClientID) overrides it. +const DefaultMCPClientID = "vd96nBiRFXz1AjTXbADAtkrg" + +// 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. +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..19a907550 100644 --- a/internal/mcpserver/dispatch.go +++ b/internal/mcpserver/dispatch.go @@ -82,7 +82,7 @@ func (d dispatcher) handle(ctx context.Context, dom gateway.Domain, op gateway.O if !savedAsDraft(resp) { return gateway.ErrorResult("%s", unconfirmedDraft(full.Action, resp)), nil } - return gateway.JSONResult(draftResult(resp)) + return gateway.JSONResult(draftResult(resp, sendable(ctx))) } 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", diff --git a/internal/mcpserver/domains.go b/internal/mcpserver/domains.go index 91e94936e..829c25084 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 `. The usual path: save the draft, show the person its From, To, Cc, Bcc, Subject and Body in the conversation, revise it with update_message as they ask, and send it once they say to.", + 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. The person can have a draft shown or revised as they like. When they ask to send it, call hey_send's send_draft at once where hey_send is listed: its confirmation shows them what goes out, so don't show the draft again or ask them again first. Where hey_send isn't listed, the person sends the draft from its page in HEY (app_url) or with `hey draft send `.", }, { Key: "contacts", diff --git a/internal/mcpserver/drafts.go b/internal/mcpserver/drafts.go index 041d64b9e..5d5777bbf 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. It is served only when hey mcp's +// sign-in can send, and listed only to a client that can be asked. 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 where hey_send is listed, which asks the person to confirm the email; otherwise 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) @@ -150,17 +152,30 @@ func draftLink(location string) string { // draftResult is what a confirmed draft save answers, so the model doesn't // report a send that didn't happen and knows how the draft gets delivered. -func draftResult(resp *hey.Response) map[string]any { +// sendable is whether this client is offered hey_send (server.go): where it is, +// the person's ask to send is answered with send_draft at once, since its +// confirmation shows them what goes out; where it isn't, the note names the +// draft's page in HEY. +func draftResult(resp *hey.Response, sendable bool) map[string]any { location := resp.Headers.Get("Location") result := map[string]any{"status": resp.StatusCode, "location": location, "drafted": true} - if id := draftID(location); id != "" { - result["draft_id"] = id - if link := draftLink(location); link != "" { - result["app_url"] = link - } - 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 + "`." - } else { - result["note"] = "Saved as a draft, not sent. hey mcp doesn't deliver mail: " + howToSend + "." + id := draftID(location) + if id == "" { + result["note"] = "Saved as a draft, not sent. To deliver it, " + howToSend + "." + return result + } + result["draft_id"] = id + link := draftLink(location) + if link != "" { + result["app_url"] = link + } + switch { + case sendable: + result["note"] = "Saved as a draft, not sent. The person can review or revise it as they like. When they ask to send it, call hey_send's send_draft with draft_id " + id + " right away: its confirmation shows them what goes out, so don't show the draft or ask them again first." + case link != "": + result["note"] = "Saved as a draft, not sent. hey mcp can't send it from this client: the person sends it from its page in HEY, " + link + ", or with `hey draft send " + id + "`." + default: + result["note"] = "Saved as a draft, not sent. hey mcp can't send it from this client: the person sends it from HEY or with `hey draft send " + id + "`." } return result } diff --git a/internal/mcpserver/drafts_test.go b/internal/mcpserver/drafts_test.go index 5a514bfb3..a20d1b71a 100644 --- a/internal/mcpserver/drafts_test.go +++ b/internal/mcpserver/drafts_test.go @@ -304,11 +304,11 @@ func TestDraftLinkReadsTheLocation(t *testing.T) { t.Errorf("draftLink(%q) = %q, want %q", location, got, want) } } - result := draftResult(draftSaved("https://app.hey.com/messages/77")) + result := draftResult(draftSaved("https://app.hey.com/messages/77"), false) if result["app_url"] != "https://app.hey.com/messages/77/edit" { t.Errorf("result = %v, want the draft's page as app_url", result) } - if result := draftResult(draftSaved("/messages/77")); result["app_url"] != nil { + if result := draftResult(draftSaved("/messages/77"), false); result["app_url"] != nil { t.Errorf("result = %v, want no app_url for a Location with no origin", result) } } diff --git a/internal/mcpserver/send.go b/internal/mcpserver/send.go new file mode 100644 index 000000000..775637350 --- /dev/null +++ b/internal/mcpserver/send.go @@ -0,0 +1,627 @@ +package mcpserver + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "slices" + "strconv" + "strings" + "sync" + "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/basecamp/mcp/gateway" +) + +// send_draft: delivery, confirmed by the person and single-use (sending-safety D2-D3). +// +// 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, never under --read-only, and is listed +// only to a client that can be asked (server.go): a client that can't answer an +// elicitation isn't offered a tool it can't complete. +// +// A call runs in two rounds. The first fetches the draft and asks the person to confirm +// it through MCP elicitation, with the draft's fingerprint signed into the request +// state. The prompt shows the draft and names whatever its text can't show +// (sendprompt.go). The second, carrying the person's answer and that state, fetches the +// draft again, refuses it if anything the fingerprint covers changed, claims the draft, +// 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 and calls from a cached list gets a draft and a link (D3). +// +// How much a person's agent sends, to whom and when is the person's call once they've +// granted send: sending-safety D4 (caps, new recipients) and D6 (the exposure window) +// were withdrawn on 2026-10-31, and send_draft refuses no draft for what it holds. The +// claim is per process. Across processes (two MCP clients each running hey mcp), HEY's +// own draft lock delivers a draft once (haystack#9163); the claim keeps this process from +// asking HEY twice, or asking the person again about a draft it sent. +// +// 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. 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) +} + +// SendConfig serves send_draft. Nil serves none. +type SendConfig struct { + Drafts Drafts + // GrantKey names hey mcp's sign-in this process started on. A confirmation is bound + // to it, so an answer given under one sign-in isn't redeemed under another. + GrantKey string + // Origin is the HEY this runs against, e.g. https://app.hey.com: links point there. + 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 GrantKey (hey mcp was signed in again or out since this process + // started, or a renewal narrowed the grant below sending), sends are refused until + // hey mcp restarts. 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 + + // claimTTL is how long a claimed draft stays claimed: well past the confirmation's + // TTL and HEY's undo window (D2.3). + claimTTL = 24 * time.Hour + + // 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 signs confirmations. It lives as long as the process: both rounds of a send + // reach the same hey mcp, and a confirmation that outlives a restart is asked again. + key []byte + claims *sendClaims +} + +func newSender(cfg SendConfig) (*sender, error) { + switch { + case cfg.Drafts == nil: + return nil, errors.New("send_draft needs HEY's drafts") + case cfg.GrantKey == "": + return nil, errors.New("send_draft needs the sign-in it sends on") + case cfg.Origin == "": + return nil, errors.New("send_draft needs the HEY origin") + } + if cfg.Now == nil { + cfg.Now = time.Now + } + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return nil, fmt.Errorf("make the confirmation key: %w", err) + } + return &sender{cfg: cfg, key: key, claims: newSendClaims()}, nil +} + +// sendClaims claims each draft id once per process (D2.3). A claim is never released, +// even when HEY refuses the send, so there's no rollback to get wrong. +type sendClaims struct { + mu sync.Mutex + claims map[int64]time.Time +} + +func newSendClaims() *sendClaims { + return &sendClaims{claims: map[int64]time.Time{}} +} + +// claimed reports whether a draft was claimed within claimTTL. +func (c *sendClaims) claimed(id int64, now time.Time) bool { + c.mu.Lock() + defer c.mu.Unlock() + at, ok := c.claims[id] + return ok && now.Sub(at) < claimTTL +} + +// claim takes a draft id, or reports it already taken. +func (c *sendClaims) claim(id int64, now time.Time) bool { + c.mu.Lock() + defer c.mu.Unlock() + for k, at := range c.claims { + if now.Sub(at) >= claimTTL { + delete(c.claims, k) + } + } + if _, taken := c.claims[id]; taken { + return false + } + c.claims[id] = now + return true +} + +const sendDescription = "Send a saved draft: the only way hey mcp delivers mail. " + + "Call it as soon as the person asks to send a draft. It asks them to confirm in this app, showing who it goes to (To, Cc and Bcc), the subject, attachments, links, images and the body, and naming anything it can't show, so don't show the draft again or ask them again first. " + + "Only their answer to that confirmation sends it: a yes in the chat doesn't. " + + "The confirmation is bound to that exact draft, so a draft changed after it was approved isn't sent, and each draft is sent once. " + + "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) { + 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) { + // A draft this process sent isn't put to the person again. + if s.claims.claimed(draftID, s.cfg.Now()) { + return alreadySent(), nil + } + snap, refusal := s.review(ctx, draftID) + if refusal != nil { + return refusal, nil + } + + state := s.sign(confirmState{ + Kind: confirmKind, Method: confirmMethod, + Grant: s.cfg.GrantKey, Origin: s.cfg.Origin, Draft: draftID, + Fingerprint: snap.fingerprint, + Expires: s.cfg.Now().Add(confirmTTL).Unix(), + }) + + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{confirmInputID: confirmElicitation(s.prompt(snap))}, + RequestState: state, + }, nil +} + +func alreadySent() *mcp.CallToolResult { + return gateway.ErrorResult("not sent: hey mcp already sent this draft, or a send of it is in progress or failed; it won't send it again for 24 hours. Check Sent in HEY") +} + +// 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.GrantKey || 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 + } + + snap, refusal := s.review(ctx, draftID) + if refusal != nil { + return refusal, 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 !s.claims.claim(draftID, s.cfg.Now()) { + return alreadySent(), 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()) + 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.GrantKey { + return errors.New("hey mcp was signed in again, or signed out, since it started, or its sign-in can no longer send; restart it") + } + return nil +} + +// 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 +} + +// clientRequest is any request that says what its client can do: a 2026-07-28 request +// carries it in _meta, an older one has it from the session's initialize. +type clientRequest interface { + ClientCapabilities() *mcp.ClientCapabilities +} + +// canElicitForm reports whether the client can be asked: it declared form elicitation. +// A client that declares the capability with neither mode is read as form, as the SDK +// does. +func canElicitForm(req mcp.Request) bool { + r, ok := req.(clientRequest) + if !ok { + return false + } + caps := r.ClientCapabilities() + if caps == nil || caps.Elicitation == nil { + return false + } + return caps.Elicitation.Form != nil || caps.Elicitation.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 (sendbody.go). +func (d *draftSnapshot) content() hey.DraftContent { + return hey.DraftContent{ + Subject: d.subject, + Content: d.body.delivered, + 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 +} + +// review fetches the draft, returning a refusal as the call's result when the draft +// can't be sent here: one HEY no longer serves for editing, one already scheduled, or +// one with no recipients. Nothing the draft holds refuses it; the prompt names what it +// can't show. +func (s *sender) review(ctx context.Context, draftID int64) (*draftSnapshot, *mcp.CallToolResult) { + refuse := func(format string, a ...any) (*draftSnapshot, *mcp.CallToolResult) { + return 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)") + } + for _, r := range recipients { + if normalizedAddress(r.EmailAddress) == "" { + return refuse("not sent: a recipient has no email address") + } + } + return snap, nil +} + +// --- 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 +// process'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"` + 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..41a7b8c45 --- /dev/null +++ b/internal/mcpserver/send_test.go @@ -0,0 +1,900 @@ +package mcpserver + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "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 epoch = 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. +type fakeDrafts struct { + mu sync.Mutex + edit generated.MessageEditState + editErr error + sends []hey.DraftContent + sendErr error + sentAs *generated.SentMessage +} + +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")}}, + }, + } +} + +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: 6001, TopicId: 900}, 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 + clock *clock + api *fakeAPI + cfg SendConfig + server *Server +} + +func newSendRig(t *testing.T) *sendRig { + t.Helper() + rig := &sendRig{drafts: newFakeDrafts(), clock: &clock{now: epoch}, api: &fakeAPI{}} + rig.cfg = SendConfig{Drafts: rig.drafts, GrantKey: "grant-1", Origin: testOrigin, Now: rig.clock.Now} + var err error + 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 +} + +// promptFor runs send_draft on a draft whose body is content, declining, and returns +// the confirmation's text. +func promptFor(t *testing.T, content string) string { + t.Helper() + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { e.Content = content }) + 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) + return prompt +} + +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) + } + } +} + +func listsTool(t *testing.T, session *mcp.ClientSession, name string) bool { + t.Helper() + for tool, err := range session.Tools(context.Background(), nil) { + if err != nil { + t.Fatal(err) + } + if tool.Name == name { + return true + } + } + return false +} + +var eras = []struct{ name, version string }{ + {"2026-07-28 client rounds", ""}, + {"2025-06-18 server elicitation", "2025-06-18"}, +} + +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") + } + }) + } +} + +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": 6001`, `"topic_id": 900`) + mustContain(t, prompt, `Send this email from "Jeremy Daer" ?`, + `To: "Ann Example" `, "Cc: —", "Bcc: —", + "Subject: Re: Q3 plan", "Attached: —", "Images: —", "When: Now (undo window applies)", + " Body:\n Hi Ann, the numbers on page 2 moved since Tuesday.") + if strings.Contains(prompt, "Not shown here") { + t.Errorf("a draft shown whole says something isn't shown:\n%s", prompt) + } + + 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) + } + }) + } +} + +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 = epoch.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 len(first.images) != 0 || first.unshown != "" { + t.Errorf("the attachment's own preview counted as the body's: images %v, unshown %q", first.images, first.unshown) + } +} + +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 process's key": func(_ *sendRig, state string) string { + other := &sender{key: []byte("a different process'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) + } + }) + } +} + +func base64Decode(s string) ([]byte, error) { + return base64.RawURLEncoding.DecodeString(s) +} + +func TestAScheduledDraftIsntSentHere(t *testing.T) { + rig := newSendRig(t) + rig.drafts.update(func(e *generated.MessageEditState) { e.ScheduledDeliveryAt = epoch.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) + } +} + +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) + } + }) + } +} + +func TestASignInChangedUnderTheServerRefusesSends(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") + 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 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") +} + +// 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", " 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 rig.server.sender.claims.claimed(5, rig.clock.Now()) { + t.Error("the draft was claimed anyway") + } +} + +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, " Cx yafter") + mustContain(t, prompt, " A B C\n", " x y\n after") + if strings.Contains(prompt, "Not shown here") { + t.Errorf("a listing counted as formatting the text leaves out:\n%s", prompt) + } +} diff --git a/internal/mcpserver/sendbody.go b/internal/mcpserver/sendbody.go new file mode 100644 index 000000000..020c08116 --- /dev/null +++ b/internal/mcpserver/sendbody.go @@ -0,0 +1,366 @@ +package mcpserver + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "golang.org/x/net/html" + "golang.org/x/net/html/atom" +) + +// What send_draft reads out of a draft's body (sending-safety D2): the text the +// confirmation shows, what that text leaves out, its attachments, links and images, the +// body it delivers, and the hash the confirmation binds. +// +// The body is the Trix editing HTML GetMessageEdit serves. Its attachments are +//
(or ) elements whose +// JSON names the blob's sgid, filename, size and type beside a signed URL, wrapped +// around a preview image and a caption HEY renders from the blob. +// +// A blob attachment is delivered as its identity alone: the element is rebuilt from the +// sgid, filename, size, type and caption, with nothing inside it, which is what HEY +// keeps of one anyway (Action Text resolves an attachment from those attributes and +// renders its markup afresh). So two fetches of an unchanged draft hash alike, whatever +// signed URLs its wrapper carries, and nothing in the wrapper is sent. An attachment +// that isn't a plain blob is delivered as it is and named in the prompt as one it can't +// name. The hash is over exactly the body that is delivered. +// +// Nothing here refuses a draft: what the text can't show is named on the prompt's +// "Not shown here" line, beside the draft's link in HEY. + +// reviewedBody is one draft body, analyzed. +type reviewedBody struct { + // text is the plain-text rendering the confirmation shows, links with their targets. + text string + // attachments are the body's blob attachments, in order. + attachments []attachment + // unnamed counts attachments that aren't plain blobs (no sgid or filename, or + // embedded content, as Trix carries an embedded email body). They go as they are. + unnamed int + // images are the targets of the body's images, in order. + images []string + // unshown names the first markup the text rendering doesn't show, or "". + unshown string + // delivered is the body send_draft sends: the draft's, comments dropped, each blob + // attachment rebuilt from its identity. + delivered string + // hash is the sha256 of delivered. + hash string +} + +type attachment struct { + SGID string `json:"sgid"` + Filename string `json:"filename"` + Size int64 `json:"size"` + ContentType string `json:"content_type"` + Caption string `json:"caption,omitempty"` +} + +// shownTags are the elements the text rendering shows whole: text structure and links, +// nothing that loads, styles or hides. Anything else is named on the prompt's "Not +// shown here" line. It is an allowlist on purpose: it says what the rendering covers, +// so whatever a mail client does with the rest is named without one more selector. +var shownTags = map[atom.Atom]bool{ + atom.Div: true, atom.P: true, atom.Br: true, atom.Span: true, atom.Strong: true, atom.B: true, + atom.Em: true, atom.I: true, atom.U: true, atom.Del: true, atom.S: true, atom.Strike: true, + atom.A: true, atom.Ul: true, atom.Ol: true, atom.Li: true, atom.Blockquote: true, + atom.Pre: true, atom.H1: true, atom.H2: true, atom.H3: true, atom.H4: true, atom.H5: true, + atom.H6: true, atom.Hr: true, atom.Code: true, atom.Listing: true, + // html.Parse wraps a fragment in these. + atom.Html: true, atom.Head: true, atom.Body: true, +} + +// blockTags end a line in the text rendering. +var blockTags = map[atom.Atom]bool{ + atom.Div: true, atom.P: true, atom.Li: true, atom.Blockquote: true, atom.Pre: true, + atom.H1: true, atom.H2: true, atom.H3: true, atom.H4: true, atom.H5: true, atom.H6: true, + atom.Ul: true, atom.Ol: true, atom.Hr: true, atom.Listing: true, +} + +// rawTextTags hold text a mail client doesn't show as text (a stylesheet, a script), +// so the rendering leaves it out rather than showing it as the body's. Elements whose +// text a client may show (textarea, xmp, plaintext, the fallbacks) are walked like any +// other, so their text is in the prompt, and named as formatting it leaves out. +var rawTextTags = map[atom.Atom]bool{ + atom.Style: true, atom.Script: true, atom.Template: true, atom.Title: true, +} + +// analyzeBody reads a draft's raw content. +func analyzeBody(content string) (reviewedBody, error) { + // Parsed with scripting off, as a mail client parses it, so a noscript's contents + // are markup here too. + nodes, err := html.ParseFragmentWithOptions(strings.NewReader(content), + &html.Node{Type: html.ElementNode, Data: "body", DataAtom: atom.Body}, + html.ParseOptionEnableScripting(false)) + if err != nil { + return reviewedBody{}, fmt.Errorf("read the draft's body: %w", err) + } + + w := &bodyWalker{} + for _, n := range nodes { + w.walk(n) + } + + // A comment is never shown, so it isn't sent: a conditional comment is markup + // Outlook renders (). + for _, c := range w.comments { + if c.Parent != nil { + c.Parent.RemoveChild(c) + } + } + var delivered bytes.Buffer + for _, n := range nodes { + if n.Type == html.CommentNode { + continue + } + if err := html.Render(&delivered, n); err != nil { + return reviewedBody{}, fmt.Errorf("render the draft's body: %w", err) + } + } + sum := sha256.Sum256(delivered.Bytes()) + return reviewedBody{ + text: strings.TrimSuffix(strings.ReplaceAll(w.text.String(), "\r\n", "\n"), "\n"), + attachments: w.attachments, + unnamed: w.unnamed, + images: w.images, + unshown: w.unshown, + delivered: delivered.String(), + hash: hex.EncodeToString(sum[:]), + }, nil +} + +type bodyWalker struct { + comments []*html.Node + text strings.Builder + attachments []attachment + unnamed int + images []string + unshown string + // pre counts the whitespace-preserving elements the walk is inside. + pre int +} + +// flow writes text as HTML lays it out outside
: each run of ASCII whitespace is
+// one space, across text nodes too (a run split by inline markup or a comment is still
+// one run), and none follows a line break.
+func (w *bodyWalker) flow(s string) {
+	for _, r := range s {
+		if r == ' ' || r == '\t' || r == '\n' || r == '\r' || r == '\f' {
+			if !w.spaced() {
+				w.text.WriteByte(' ')
+			}
+			continue
+		}
+		w.text.WriteRune(r)
+	}
+}
+
+// spaced reports whether the text so far ends in a space or a line break, or is empty.
+func (w *bodyWalker) spaced() bool {
+	t := w.text.String()
+	return t == "" || strings.HasSuffix(t, " ") || strings.HasSuffix(t, "\n")
+}
+
+// note keeps the first markup the rendering doesn't show.
+func (w *bodyWalker) note(what string) {
+	if w.unshown == "" {
+		w.unshown = what
+	}
+}
+
+func (w *bodyWalker) walk(n *html.Node) {
+	switch n.Type {
+	case html.TextNode:
+		// Shown as a mail client renders it: outside 
, a run of whitespace is one
+		// space.
+		if w.pre > 0 {
+			w.text.WriteString(n.Data)
+		} else {
+			w.flow(n.Data)
+		}
+		return
+	case html.CommentNode:
+		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, plainBlob := attachmentOf(n); isAttachment {
+		if !plainBlob {
+			w.unnamed++
+			w.text.WriteString("\n[attachment]\n")
+			return
+		}
+		rebuildAttachment(n, a)
+		w.attachments = append(w.attachments, a)
+		w.text.WriteString("\n[attachment: " + a.Filename + "]\n")
+		return
+	}
+
+	switch {
+	case n.DataAtom == atom.Img:
+		w.images = append(w.images, strings.TrimSpace(attrValue(n, "src")))
+	case !shownTags[n.DataAtom]:
+		w.note(elementName(n))
+	}
+	for _, attr := range n.Attr {
+		shown := (n.DataAtom == atom.A && attr.Key == "href") || (n.DataAtom == atom.Img && attr.Key == "src")
+		if !shown {
+			w.note(attributeName(attr) + " on " + elementName(n))
+		}
+	}
+	if rawTextTags[n.DataAtom] {
+		return
+	}
+	if n.DataAtom == atom.Pre || n.DataAtom == atom.Listing || n.DataAtom == atom.Textarea || n.DataAtom == atom.Xmp || n.DataAtom == atom.Plaintext {
+		w.pre++
+		defer func() { w.pre-- }()
+	}
+
+	if n.DataAtom == atom.Br {
+		w.text.WriteString("\n")
+	}
+	if n.DataAtom == atom.Hr {
+		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 != "" {
+			w.text.WriteString(" <" + href + ">")
+		}
+	}
+	if blockTags[n.DataAtom] {
+		w.text.WriteString("\n")
+	}
+}
+
+// elementName names n for the prompt. Only an element HTML knows is named: a custom
+// element's name is the draft's own text (), and the
+// prompt's own lines must carry none of it.
+func elementName(n *html.Node) string {
+	if n.DataAtom == 0 {
+		return "a custom element"
+	}
+	return "<" + n.DataAtom.String() + ">"
+}
+
+// attributeName names an attribute HTML knows by its key, and any other as "an
+// attribute", for the same reason.
+func attributeName(attr html.Attribute) string {
+	if attr.Namespace != "" || atom.Lookup([]byte(attr.Key)) == 0 {
+		return "an attribute"
+	}
+	return attr.Key
+}
+
+// attachmentOf reads a Trix attachment element: a figure carrying data-trix-attachment,
+// or an action-text-attachment. It answers whether n is one, and whether it is a plain
+// blob that can be delivered as its identity: one with an sgid and a filename and no
+// 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, bool) {
+	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, false
+		}
+		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, false
+	}
+	return a, true, !embedded && a.SGID != "" && a.Filename != ""
+}
+
+// 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
+}
diff --git a/internal/mcpserver/sendprompt.go b/internal/mcpserver/sendprompt.go
new file mode 100644
index 000000000..4a7a564c7
--- /dev/null
+++ b/internal/mcpserver/sendprompt.go
@@ -0,0 +1,223 @@
+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, and so is every attachment, link and image. 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 and come first in a fixed order; every body
+// line is indented beneath "Body:". Whatever the text can't show is named on a "Not
+// shown here" line beside the draft's link in HEY. This is presentation only: the
+// fingerprint covers the raw values.
+
+func (s *sender) prompt(snap *draftSnapshot) string {
+	// Cut on the draft's own characters, then escape, so an escape is never cut in
+	// half and the counts are of what the draft holds.
+	text := []rune(snap.body.text)
+	total := len(text)
+	body := inertBody(string(text))
+	label := "  Body:\n"
+	if total > bodyPromptCap {
+		body = inertBody(string(text[:bodyPromptCap])) + " …"
+		label = fmt.Sprintf("  Body (the first %s of %s characters):\n", thousands(bodyPromptCap), thousands(total))
+	}
+
+	var b strings.Builder
+	fmt.Fprintf(&b, "Send this email from %s?\n", recipientLine(snap.from))
+	b.WriteString("  When:     Now (undo window applies)\n")
+	fmt.Fprintf(&b, "  To:       %s\n", recipientList(snap.to))
+	fmt.Fprintf(&b, "  Cc:       %s\n", recipientList(snap.cc))
+	fmt.Fprintf(&b, "  Bcc:      %s\n", recipientList(snap.bcc))
+	// Delivered text is shown whole: only display names, which aren't sent, are capped.
+	fmt.Fprintf(&b, "  Subject:  %s\n", inertWhole(snap.subject))
+	fmt.Fprintf(&b, "  Attached: %s\n", attachmentList(snap.body))
+	fmt.Fprintf(&b, "  Images:   %s\n", imageList(snap.body.images))
+	if unshown := unshownOf(snap.body, total); unshown != "" {
+		fmt.Fprintf(&b, "  Not shown here: %s. The whole draft is in HEY: %s\n", unshown, s.editURL(snap.id))
+	}
+	b.WriteString(label)
+	for line := range strings.SplitSeq(body, "\n") {
+		b.WriteString("    " + line + "\n")
+	}
+	return b.String()
+}
+
+// unshownOf names what the prompt can't show of a body, "" when it shows all of it.
+func unshownOf(body reviewedBody, shownLength int) string {
+	var out []string
+	if shownLength > bodyPromptCap {
+		out = append(out, fmt.Sprintf("the last %s characters of the body", thousands(shownLength-bodyPromptCap)))
+	}
+	switch n := len(body.attachments) + body.unnamed; {
+	case n == 1:
+		out = append(out, "what the attachment holds")
+	case n > 1:
+		out = append(out, "what the "+thousands(n)+" attachments hold")
+	}
+	if n := len(body.images); n > 0 {
+		out = append(out, plural(n, "image", "images"))
+	}
+	if body.unshown != "" {
+		out = append(out, "formatting the text leaves out ("+body.unshown+")")
+	}
+	return strings.Join(out, "; ")
+}
+
+func plural(n int, one, many string) string {
+	if n == 1 {
+		return "1 " + one
+	}
+	return thousands(n) + " " + many
+}
+
+// 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) string { + // The address is escaped but never truncated: the person has to see all of where + // the mail goes. + address := "<" + escapeInvisible(c.EmailAddress, false) + ">" + if name := strings.TrimSpace(c.Name); name != "" && !strings.EqualFold(name, c.EmailAddress) { + return `"` + strings.ReplaceAll(inertLine(name), `"`, `\"`) + `" ` + address + } + return address +} + +func recipientList(contacts []generated.Contact) string { + if len(contacts) == 0 { + return "—" + } + lines := make([]string, 0, len(contacts)) + for _, c := range contacts { + lines = append(lines, recipientLine(c)) + } + return strings.Join(lines, ", ") +} + +// imageList shows each image's whole target, which is where a mail client fetches it +// from when the email is opened. +func imageList(images []string) string { + if len(images) == 0 { + return "—" + } + shown := make([]string, 0, len(images)) + for _, src := range images { + if src == "" { + src = "(no source)" + } + shown = append(shown, "<"+inertWhole(src)+">") + } + return strings.Join(shown, ", ") +} + +func attachmentList(body reviewedBody) string { + if len(body.attachments)+body.unnamed == 0 { + return "—" + } + names := make([]string, 0, len(body.attachments)+1) + for _, a := range body.attachments { + name := fmt.Sprintf("%s (%s)", inertWhole(a.Filename), humanSize(a.Size)) + if a.Caption != "" { + name += `, captioned "` + inertWhole(a.Caption) + `"` + } + names = append(names, name) + } + if body.unnamed > 0 { + names = append(names, plural(body.unnamed, "attachment this confirmation can't name", "attachments this confirmation can't 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/server.go b/internal/mcpserver/server.go index a9c7a65ca..adf75cd86 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -1,8 +1,10 @@ package mcpserver import ( + "context" "fmt" "log/slog" + "slices" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -24,12 +26,17 @@ 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. + // Nil serves no way to send. + 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,6 +54,13 @@ func New(api API, cfg Config) (*Server, error) { return nil, fmt.Errorf("hold sending operations to drafts: %w", err) } + var send *sender + if cfg.Send != nil && !cfg.ReadOnly && servesDomain(cfg.Domains, "threads") { + if send, err = newSender(*cfg.Send); err != nil { + return nil, fmt.Errorf("serve send_draft: %w", err) + } + } + gw, err := gateway.New(cat.GatewayDomains(), gateway.Config{ ReadOnly: cfg.ReadOnly, Domains: cfg.Domains, @@ -56,7 +70,17 @@ func New(api API, cfg Config) (*Server, error) { 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. @@ -65,7 +89,42 @@ func (s *Server) Domains() []gateway.Domain { } // BuildMCPServer constructs the SDK MCP server with one gateway tool per -// served domain. +// served domain, and hey_send when send_draft is served. 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) + server.AddReceivingMiddleware(offerSendToClientsThatCanAsk) + } + return server +} + +// offerSendToClientsThatCanAsk lists hey_send only to a client that can be asked, +// since send_draft sends only on the person's answer to its confirmation: a client +// that can't answer one isn't offered a tool it can't complete, and its draft saves +// name the draft's page in HEY instead. It also tells each tool call whether its +// client is offered hey_send (sendable), so a draft save's note says the same. +// +// Only the list changes. A call from a client whose list is cached still runs, and +// gets the draft's link with nothing sent (sending-safety D3). +func offerSendToClientsThatCanAsk(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + asks := canElicitForm(req) + if method == "tools/call" { + ctx = context.WithValue(ctx, sendableKey{}, asks) + } + res, err := next(ctx, method, req) + if list, ok := res.(*mcp.ListToolsResult); ok && err == nil && !asks { + list.Tools = slices.DeleteFunc(slices.Clone(list.Tools), func(t *mcp.Tool) bool { return t.Name == sendToolName }) + } + return res, err + } +} + +type sendableKey struct{} + +// sendable reports whether this tool call's client is offered hey_send. +func sendable(ctx context.Context) bool { + asks, _ := ctx.Value(sendableKey{}).(bool) + return asks }