Skip to content

Configure chat MCP servers from the dashboard - #179

Merged
tim-smart merged 14 commits into
mainfrom
agent/effect-bot/f02f332cae3f
Nov 6, 2026
Merged

tim-smart merged 14 commits into
mainfrom
agent/effect-bot/f02f332cae3f

Conversation

@effect-bot

@effect-bot effect-bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

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

  • Configuration is in Postgres, in chat_mcp_servers (migration 0060). Each server has a name (the mcp.<name>.<tool> prefix), an https URL, an authentication kind (none, bearer or oauth), Private (on by default) and Enabled. No secret is stored there.
  • Credentials. Each server has its own Durable Object, McpCredential, named by its id. It holds either the bearer token or the OAuth client and refresh token.
    • Access tokens are refreshed one at a time across every session, while five minutes are still left.
    • A rotated refresh token is stored before the new access token is handed out.
    • A failed refresh waits 30 seconds before trying again.
    • invalid_grant or invalid_client shows the server as Needs consent until it's connected again.
  • Connect (oauth) runs in the Worker:
    1. Discovery, using RFC 9728 resource metadata and then RFC 8414 server metadata.
    2. Dynamic client registration.
    3. PKCE S256 authorisation, with resource and the advertised scopes.
    4. The callback, /api/v1/chat/mcp-servers/:id/oauth/callback, which only accepts a state that hasn't been used and is under 10 minutes old.
  • Turns.
    • Each turn reads the server list and gets every enabled server.
    • Private servers are offered only in Slack channels that hold no one from outside the workspace. That's PR Configure chat MCP servers from the dashboard #179's original conversations.info check, run once per session. A yes is kept, and a no or a failure is asked again next turn.
    • Discord and channels that couldn't be classified get only non-private servers. If the list can't be read, the turn gets no servers at all.
  • Requests. Each MCP request gets its token from the server's credential inside the Worker. A 401 reports that token as rejected, so the next request gets a new one. The request that got the 401 is never retried. A server with no usable token doesn't connect for that turn.
  • Dashboard. An MCP servers card next to the kill switch lists each server with its status. From it you can add a server (Private checked), switch Private and Enabled, set a bearer token, Connect, or delete. Deleting also wipes the credential.

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 by McpCredential.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-minute state, 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) and McpCredentialClient.ts (the Worker's RPC side, which turns RPC failures into McpUnavailable, McpOAuthUnavailable or McpOAuthRefused).
  • apps/cluster/src/Chat/McpServers.ts: choosing a turn's servers. McpFetch.ts is the fetch over the Worker's HttpClient that the SDK's transport and OAuth helpers share: the per-request token, the single refresh after a 401, the SDK's redirect: manual passed through so it can refuse cross-origin and https-to-http redirects, and, for the transport, failed responses without their body. McpServerStore.ts is 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, which chat-sessions.ts embeds 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.md covers this.

Limitations (also in docs/chat.md)

  • Every tool is offered, writes and deletes included, with no confirmation from janitor.
  • Turns with MCP servers keep GitHub, Multica and internet access.
  • Tool results show up in transcripts, which everyone with Access can read.
  • The channel is checked once per session or thread, and guests aren't detected.
  • A server that isn't private can be reached from public Discord channels.
  • Anyone with Access can add a server that every thread can use.

Dependency upgrade

  • clanka 0.18.2 to 0.22.2.
  • Every Effect package to the pkg.pr.new snapshot of eff-698-cloudflare-cluster at 6e155f1c.
  • Alchemy and every @alchemy.run/* package from the 55e34d0272 preview tarballs to npm 2.0.0-beta.85, which the new snapshot requires and which speaks state store protocol v8.
  • Both Alchemy patches carried over to beta.85; none of their fixes is upstream yet.

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.

  1. Turn the chat kill switch on for every thread. Running turns stop within seconds, and released sandboxes start their 30-minute warm window.
  2. Check the PR reviews page and deploy when no review is running. A review that starts or is caught mid-run during the deploy may fail and should be requested again.
  3. Deploy.
  4. Keep the kill switch on until Cloudflare confirms that every chat container started before the deploy is gone. Elapsed time is not the gate: 30 minutes after step 1 is when to start checking, not when to turn the switch off. A container still running from before the deploy keeps the switch on.
  5. Turn the kill switch off.

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 (workerd 1.20261026.1, offline, synthetic credentials). A harness ran McpCredential in a native Durable Object with real storage. Its RPC results used alchemy's encodeRpcError/ErrorTag envelope and were read back through alchemy's makeRpcStub with the declared error classes. Fake authorisation and MCP servers were the Worker's only outbound. All 15 checks pass on the final code:

    • tagged errors arrive as real classes;
    • tokens cross RPC as strings and are Redacted again;
    • Connect works end to end, and a replayed callback is refused;
    • storage holds only the client and refresh token;
    • five concurrent rejections over RPC cause one refresh;
    • the rotated refresh token is stored;
    • clanka's McpServers connects over the per-request fetch;
    • a 401 fails that one call with no retry, and the next call uses a new token;
    • wipe and bearer tokens work.

    Two probes with unsafe metadata, a javascript: authorisation endpoint and an http:// token endpoint, are both refused. A third probe, whose token endpoint answers 307 to another origin, sends nothing there on workerd's real fetch; before the redirect fix it did. The harness didn't go through alchemy's DurableObject.make bridge or the full Worker bundle, so the first development deploy should still be watched.

  • No live MCP, OAuth or CRM calls, and nothing enabled.

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.
@effect-bot effect-bot changed the title Offer Attio's MCP server to chat turns in internal Slack channels Configure chat MCP servers from the dashboard Nov 6, 2026
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.
@tim-smart
tim-smart merged commit d873d1d into main Nov 6, 2026
4 checks passed
@tim-smart
tim-smart deleted the agent/effect-bot/f02f332cae3f branch November 6, 2026 12:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants