Repository navigation
Configure chat MCP servers from the dashboard - #179
Merged
Merged
Conversation
Test-only: the channel check, settings and a chat session reaching Attio with the bearer token over HTTP. src/Chat/Attio.ts does not exist yet.
Attio's hosted MCP server, with every tool it lists, for turns in Slack channels no one outside the workspace is in. The MCP transport goes through the Worker's HttpClient, which adds the bearer token. Off until ATTIO_MCP_TOKEN is set; docs/chat.md explains why it must stay unset until a refresh flow exists.
Tests only, for EFF-3443: Attio's MCP server takes short-lived OAuth access tokens, refreshed from one stored refresh token. The credential (AttioCredential.test.ts) is tested with a fake token endpoint, in-memory storage and the test clock; the chat side (Attio.test.ts) with a fake credential. Every credential is synthetic.
Tests first for the dashboard design (EFF-3443), superseding the Attio-only configuration. Implementation follows in a separate run. - test/Chat/McpServers.test.ts (was Attio.test.ts): the 11-row channel check now gates private servers; choosing servers per thread (private only in internal Slack channels, non-private everywhere, disabled never, no usable credential or unreadable list = left out, fail closed); per-request tokens from each server's own credential, auth: none with no Authorization header, one token(rejected) after a 401 and no replay, no token leaks. - test/Chat/McpCredential.test.ts (was AttioCredential.test.ts): Connect replaces the seed; refresh window, single flight, rotation, failure tables kept; needs-consent persists and Connect clears it; wipe is per server. - test/Ingress/McpServers.test.ts: Private on by default, validation, bearer write-only, OAuth start (DCR + PKCE S256 + resource) and callback (unknown, reused, >10 min state refused), delete wipes. - test/support/OAuthServer.ts: fake authorisation server. - apps/web chat-sessions.test.ts: statuses shown, Private checked by default in the add form. Expected to fail until the modules exist: src/Chat/McpCredential.ts, src/Chat/McpServers.ts, src/Chat/McpServerStore.ts, src/Ingress/McpServers.ts, @janitor/domain/Chat/McpServer and the web panel messages.
Replaces the Attio-only integration with MCP servers configured on the chat sessions page (EFF-3443): - Postgres table `chat_mcp_servers` (migration 0060) and an `mcpServers` API group behind Access: list, create, update, remove, set a bearer token, and OAuth Connect (discovery, dynamic client registration, PKCE authorisation, single-use 10-minute callback). - One `McpCredential` Durable Object per server holds its bearer token or OAuth client and refresh token. Access tokens refresh single-flight ahead of a 5-minute window, a rotated refresh token is stored before use, failures back off 30 s, and invalid_grant/invalid_client mark the server as needing consent. - Turns get every enabled server; private ones (the default) only in Slack channels with no external members. An unreadable server list gives no servers. Each MCP request fetches its own token in the Worker; a 401 refreshes once and is never retried. - The web panel lists servers with their status and adds, edits, connects, sets tokens for and deletes them. - docs/chat.md: setup, the Attio recipe, revocation, the short-token refresh cost and the limits.
The routes now read McpServerStore and McpCredentials per request (EFF-3443), so the routes test's request context provides them as unused stubs, like the other chat services.
- clanka 0.18.2 -> 0.22.2. Its executor RPC changed in 0.22.0, so the Worker and the sandbox images must ship together; no janitor code used the removed job and sub-agent functions. - Every Effect package moves to the pkg.pr.new snapshot of eff-698-cloudflare-cluster at 6e155f1c, the branch head with the latest Cloudflare queue, workflow, entity and initializer fixes. - That snapshot's @effect/platform-cloudflare needs @alchemy.run/cloudflare >= 2.0.0-beta.85, so Alchemy and every @alchemy.run package move from the 55e34d0272 preview tarballs to npm 2.0.0-beta.85, the release Effect-TS/website pins. It speaks state store protocol v8. - Both Alchemy patches carry over unchanged in content, re-cut for beta.85; none of their fixes is upstream yet.
Tests first for extracting the MCP panel from chat-sessions.ts into apps/web/src/components/mcp-servers.ts (EFF-3443). The implementation follows in a separate run. - mcp-servers.test.ts (was chat-sessions.test.ts): the same two tests, against the child: each status shown, Private on in the add form. - chat-sessions.test.ts: the page embeds the panel, and forwards the panel's OpenAuthorization as its own out-message, which main.ts reads. Expected to fail until @/components/mcp-servers exists and chat-sessions has GotMcpServersMessage.
Discovery, client registration, PKCE authorisation, the code exchange and refresh now come from @modelcontextprotocol/sdk, which also picks the client authentication method and validates the metadata (it refuses javascript:, data: and vbscript: endpoints). McpCredential keeps what is janitor's: single-flight refresh, the 5-minute window, storing a rotated refresh token before use, the 30-second backoff, needs-consent, the single-use 10-minute state and category-only logs. fetchVia moves to McpFetch.ts and serves both the SDK's transport and its OAuth helpers. Requests without an abort signal, as the helpers' are, have their body read at once and their connection released.
mcp-servers.ts holds the panel's model, messages, commands and view. chat-sessions.ts folds it with Update.foldChild, passes on activation and refresh, embeds its view next to the kill switch and maps its OpenAuthorization out-message to its own. No behaviour change.
Tests first for the three findings on PR #179 at 7d78ca7 (EFF-3443). The fixes follow in a separate run. - McpCredential.test.ts: Connect refuses an http authorisation, token or registration endpoint, and a javascript: authorisation endpoint, before sending anything to it or storing a pending Connect. A refresh held at the token endpoint while the credential is wiped, or Connect runs again, doesn't restore the wiped credential or replace the new consent. - McpServers.test.ts: error bodies that echo the bearer token (a 401 on tools/call, a 500 on tools/call or on initialize) never reach prompts, tool results, the sandbox or logs; a 500 rejects no token and isn't retried, and the 401 still gives one token(rejected) and no replay. - OAuthServer.ts: `endpoints` overrides what the metadata names, and the fake answers on its host over http too, so a request sent there shows. Expected to fail until fixed: the three http endpoint cases, both refresh races, and the three echoed-body cases. The javascript: case passes already, since the SDK's metadata schema refuses it.
…aces - Connect refuses an authorisation server whose URL, authorisation, token or registration endpoint isn't https, right after discovery and before registering, so no code, verifier or secret goes over plain HTTP. - The MCP transport hands failed responses back without their body. The SDK copies an error body into its exception, which reaches logs and the model, and a server can echo the token there. The OAuth helpers keep bodies, since the SDK reads the OAuth error code from them. A 401 still refreshes the token once and is never replayed. - A wipe, a bearer token or a successful Connect starts a new credential generation. A refresh from an older one stores, caches and records nothing, so it can't bring back a wiped credential or overwrite a newer one, and new callers no longer wait on it. A rotated refresh token is saved over the latest state, so a Connect started meanwhile keeps its pending state.
Tests first for the two re-review findings on PR #179 at 9daa574 (EFF-3443). The fixes follow in a separate run. - McpCredential.test.ts, on the real FetchHttpClient: a token endpoint answering 307 to plain http on its host, or to another origin, gets nothing sent there on refresh or on Connect, which fail with McpUnavailable and McpOAuthRefused. A 307 within its origin is still followed. Every request goes through the Fetch the credential was given. - McpCredential.test.ts: a Connect callback held at its code exchange while the credential is wiped, or given a bearer token, is refused, and what replaced it stays, after a restart too. - McpServers.test.ts, on the real FetchHttpClient: crm's tools/call answered 307 to another origin fails once, sends nothing there, and docs still answers. - support/Fetch.ts: the real FetchHttpClient over the fakes, with a fetch that follows redirects as fetch does unless asked for manual, and a globalThis.fetch stand-in that records any request that misses the given fetch and never reaches the network. - support/OAuthServer.ts: tokenRedirect, beforeExchange, and the server as a fetch as well as an HttpClient. Expected to fail until fixed: all six new tests. The own-origin redirect case fails only because the given Fetch isn't used.
- fetchVia passes the SDK's redirect mode to fetch through FetchHttpClient.RequestInit. The SDK asks for redirect: manual so it can follow only same-origin redirects and refuse the rest; fetch used to follow any redirect itself, re-sending a code, verifier, refresh token or tool call wherever it led. - fetchVia runs each request with the services its caller was built with, not the default runtime, so the Fetch and RequestInit a caller provides apply. - finishOAuth notes the credential generation once its state is used up and stores nothing if a wipe, a bearer token or another Connect happened during the code exchange, so a callback can't undo a deletion. startOAuth likewise saves no pending state after a wipe.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes EFF-3443
Chat turns can use MCP servers that anyone with Access configures on the chat sessions page. Attio's hosted server is the first one to add. The existing Attio Slack bot is unchanged.
Nothing is enabled by this PR. The table starts empty, so no turn gets a server until someone adds one and, for OAuth, connects it.
How it works
chat_mcp_servers(migration0060). Each server has a name (themcp.<name>.<tool>prefix), anhttpsURL, an authentication kind (none,beareroroauth), Private (on by default) and Enabled. No secret is stored there.McpCredential, named by its id. It holds either the bearer token or the OAuth client and refresh token.invalid_grantorinvalid_clientshows the server as Needs consent until it's connected again.oauth) runs in the Worker:resourceand the advertised scopes./api/v1/chat/mcp-servers/:id/oauth/callback, which only accepts astatethat hasn't been used and is under 10 minutes old.conversations.infocheck, run once per session. A yes is kept, and a no or a failure is asked again next turn.A reviewer's path through the code
packages/domain/src/Chat/McpServer.ts,packages/domain/src/Api/McpServers.ts: schemas, errors and endpoints.apps/cluster/src/Chat/McpCredential.ts: the credential (refresh, Connect, bearer, wipe), tested byMcpCredential.test.ts. The OAuth protocol itself (discovery, registration, PKCE, client authentication, token requests) is the MCP SDK's helpers; this file keeps janitor's lifecycle: one refresh at a time, the 5-minute window, rotation stored before use, the 30-second backoff, needs-consent, the single-use 10-minutestate, https-only endpoints and the generation fence that keeps a refresh or Connect callback from undoing a wipe or overwriting a newer credential.apps/cluster/src/Chat/McpCredentialObject.ts(the Durable Object) andMcpCredentialClient.ts(the Worker's RPC side, which turns RPC failures intoMcpUnavailable,McpOAuthUnavailableorMcpOAuthRefused).apps/cluster/src/Chat/McpServers.ts: choosing a turn's servers.McpFetch.tsis thefetchover the Worker'sHttpClientthat the SDK's transport and OAuth helpers share: the per-request token, the single refresh after a 401, the SDK'sredirect: manualpassed through so it can refuse cross-origin and https-to-http redirects, and, for the transport, failed responses without their body.McpServerStore.tsis the SQL.apps/cluster/src/Ingress/McpServers.ts: the API handlers.apps/cluster/src/Chat/Session.ts,Worker.ts: the wiring.apps/web/src/components/mcp-servers.ts: the panel, whichchat-sessions.tsembeds next to the kill switch.Refresh window cost
Tokens are refreshed once less than five minutes of them are left, as specified. A token that lives five minutes or less, or an answer without
expires_in, which is treated as five minutes, is refreshed on every MCP request: one token request and one refresh-token rotation per request. Attio's real token lifetime hasn't been checked, because that needs a live token. If a provider issues tokens that short, the window should scale with the token's lifetime.docs/chat.mdcovers this.Limitations (also in
docs/chat.md)Dependency upgrade
eff-698-cloudflare-clusterat6e155f1c.@alchemy.run/*package from the55e34d0272preview tarballs to npm2.0.0-beta.85, which the new snapshot requires and which speaks state store protocol v8.Deploying: drain old sandboxes first
clanka 0.22.0 changed the executor RPC protocol, so a new Worker can't talk to a sandbox still running the old image. A chat session reuses its running sandbox whenever it passes the HTTP health check, for up to the 30-minute warm window (
CHAT_SANDBOX_WARM_SECONDS). New chat sessions and new PR review runs always start on the new image.A guest-version check in the sandbox health endpoint would remove these steps for future protocol changes; it's deferred.
Verification
vp test run: 157 files, 1019 tests pass.vp check: 0 errors.workerd (
workerd1.20261026.1, offline, synthetic credentials). A harness ranMcpCredentialin a native Durable Object with real storage. Its RPC results used alchemy'sencodeRpcError/ErrorTagenvelope and were read back through alchemy'smakeRpcStubwith the declared error classes. Fake authorisation and MCP servers were the Worker's only outbound. All 15 checks pass on the final code:Redactedagain;McpServersconnects over the per-requestfetch;Two probes with unsafe metadata, a
javascript:authorisation endpoint and anhttp://token endpoint, are both refused. A third probe, whose token endpoint answers 307 to another origin, sends nothing there on workerd's realfetch; before the redirect fix it did. The harness didn't go through alchemy'sDurableObject.makebridge or the full Worker bundle, so the first development deploy should still be watched.No live MCP, OAuth or CRM calls, and nothing enabled.