feat(bridle,mcpServer): per-user MCP OAuth tokens on a shared channel (CLEAN-80) - #106
Merged
maksymhryzodub-prog merged 2 commits intoSep 24, 2026
Conversation
maksymhryzodub-prog
added this pull request to stack #107
September 23, 2026 12:16
… (CLEAN-80)
Owners and admins chat as one `admin` conversation on purpose — shared
history, shared context. The MCP OAuth token from CLEAN-75 was stored per
agent, so everyone on that channel used the Silpo account of whoever logged
in first. The identity for tokens now travels separately from the channel.
Hub: a console login's JWT `sub` (+ email) is kept on its socket record and
attached to every message that socket sends, as `user` next to `clientId`
(socket and HTTP sync paths alike). Two admins still share `admin`; the
runtime now knows which of them is typing. Share visitors and anonymous
embeds carry no user — their clientId is already theirs alone.
OAuth: start takes a subject (the user id, or a share/anon client id; the
PKCE state remembers it), the callback stores the bundle under
mcpOauth:<serverId>:<subject> and pushes mcp_connected { server, serverId,
subject } so the runtime brings up the client for exactly that person;
without a subject the agent-wide mcpOauth:<serverId> bundle is written as
before, which is what start_mcp_oauth from the Rancher chat still creates.
status answers per subject with the agent-wide bundle as fallback and says
which one it found, plus the login email for "connected as …".
Sweep: bundles of per-browser subjects (anon-*, share-*) unused for 30
days are revoked (RFC 7009, when the provider advertises an endpoint) and
dropped every six hours; a real user's bundle is never swept. The bundle
carries connectedAt/lastUsedAt for that; the runtime bumps lastUsedAt.
Migration adds McpOauthState.subject / subjectEmail, nullable, additive.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KtJu4QscRKbDdj8WLL9aVL
…ndle (CLEAN-80) The S3-backed secret gateway lists entries as `<agentId>/<key>`, the AWS one as the bare key. `status` compared against the bare key only, so on the file provider a bundle stored a minute earlier answered "not connected" — seen live against Silpo. The same comparison would have kept the sweep from ever matching a per-browser bundle. Both now strip the agent's own scope before comparing; deletion keeps the listed name, which either gateway resolves. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KtJu4QscRKbDdj8WLL9aVL
maksymhryzodub-prog
force-pushed
the
feat/CLEAN-80-per-user-mcp-tokens
branch
from
September 24, 2026 11:44
ae466ad to
2a146bb
Compare
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.
Jira: CLEAN-80
Stacked on #105 (
feat/CLEAN-78-mcp-probe). Merge #105 first, then retarget this one tomain. Runtime half: CleanSlice/runtime PR for CLEAN-79 / CLEAN-81.Why
Owners and admins chat as one
adminconversation on purpose: shared history, shared context. The MCP OAuth token from CLEAN-75 was stored per agent, so everyone on that channel used the Silpo account of whoever logged in first. The identity for tokens has to travel separately from the channel.What
Hub — a console login's JWT
sub(+ email) is kept on its socket record and attached to every message that socket sends, asusernext toclientId(socket and HTTP sync paths alike). Two admins still shareadmin; the runtime now knows which of them is typing. Share visitors and anonymous embeds carry no user — theirclientIdis already theirs alone.OAuth —
starttakes asubject(the user id the hub forwarded, or a share/anon client id; the PKCE state remembers it, with the email for display). The callback stores the bundle undermcpOauth:<serverId>:<subject>and pushesmcp_connected { server, serverId, subject }so the runtime brings up the client for exactly that person. Without a subject the agent-widemcpOauth:<serverId>bundle is written as before — whatstart_mcp_oauthfrom the Rancher chat still creates (it gained an optionalsubject).statusanswers per subject with the agent-wide bundle as fallback and says which it found, plus the login email for "connected as …".anon-*,share-*) unused for 30 days are revoked (RFC 7009 when the provider advertisesrevocation_endpoint) and dropped every six hours; a real user's bundle is never swept. Bundles carryconnectedAt/lastUsedAt; the runtime bumps the latter.20260923120000_mcp_oauth_subject:McpOauthState.subject/subjectEmail, nullable, additive.Verification
api: jest onbridle+mcpServer→ 24 suites, 358 tests green (new: identity follows the sending socket, admin sub kept next toclientId: admin, per-subject storage, status order, sweep rules);tsc --noEmitand eslint clean.bun run migrateon deploy (Prisma client regenerated locally).Live test (2026-09-23, local stack, real Silpo)
clientId: admin— the throwaway agent received both messages with distinctuser { id, email }.startwith a subject: PKCE state keeps subject + email, DCR at Silpo succeeded, callback storedmcpOauth:<silpo>:<sub>with refresh token,connectedAt,lastUsedAt,revocationEndpoint.statusper subject answersscope: subjectwith the email; another subject getsconnected: false.<agentId>/<key>, sostatus/sweep never matched a stored bundle (ae466ad).🤖 Generated with Claude Code
https://claude.ai/code/session_01KtJu4QscRKbDdj8WLL9aVL