History

mcp: per-user OAuth via stable source secret ids + refresh + DCR hoist

Three connected fixes so MCP OAuth works under per-user scope shadowing, matching the pattern already merged for OpenAPI. 1. Stable per-source secret ids. The UI now mints `mcp_<namespace>_access_token` / `_refresh_token` up front and passes them into `startOAuth`. `completeOAuth` writes token values at the invoker (user) scope under the same id. Innermost-first resolution in `ctx.secrets.get` means each user reads their own tokens while the source record (at source scope) stays shared. 2. Refresh flow. `makeOAuthProvider` now takes `OAuthProviderInputs` with mutable `currentTokens` + `onRefresh` + provided `clientInformation` and `discoveryState`. On refresh the new tokens are persisted back to the invoker scope via `ctx.secrets.set`, so the next call picks them up with no extra round-trip. 3. DCR hoisted to source scope. `McpOAuthSession` + `completeOAuth` response now carry `clientInformation`, `authorizationServerUrl`, `resourceMetadataUrl`. The source record stores them once at the org scope so the second user on the same source reuses the already-registered client instead of re-running DCR.

Rhys Sullivan committed 6 months ago

Commit 108f63b1 parent 4b6f9c17 files · +325 −57Browse files

Unified · Split

7 files
  1. packages/plugins/mcp/src/api/group.ts +16 −0
  2. packages/plugins/mcp/src/api/handlers.ts +5 −0
  3. packages/plugins/mcp/src/react/AddMcpSource.tsx +40 −2
  4. packages/plugins/mcp/src/sdk/invoke.ts +16 −3
  5. packages/plugins/mcp/src/sdk/oauth.ts +29 −2
  6. packages/plugins/mcp/src/sdk/plugin.ts +195 −50
  7. packages/plugins/mcp/src/sdk/types.ts +24 −0
packages/plugins/mcp/src/api/group.ts+16−0
@@ -24,6 +24,8 @@ const namespaceParam = HttpApiSchema.param("namespace", Schema.String); · 23 unmodified lines
2424 // Auth payload (only for remote)
2525 // ---------------------------------------------------------------------------
2626
27+const JsonObject = Schema.Record({ key: Schema.String, value: Schema.Unknown });
28+
2729 const AuthPayload = Schema.Union(
2830 Schema.Struct({ kind: Schema.Literal("none") }),
2931 Schema.Struct({
@@ -39,6 +41,9 @@ const AuthPayload = Schema.Union( · 9 unmodified lines
3941 tokenType: Schema.optional(Schema.String),
4042 expiresAt: Schema.NullOr(Schema.Number),
4143 scope: Schema.NullOr(Schema.String),
44+ clientInformation: Schema.optional(Schema.NullOr(JsonObject)),
45+ authorizationServerUrl: Schema.optional(Schema.NullOr(Schema.String)),
46+ resourceMetadataUrl: Schema.optional(Schema.NullOr(Schema.String)),
4247 }),
4348 );
4449
@@ -108,6 +113,14 @@ const StartOAuthPayload = Schema.Struct({ · 63 unmodified lines
108113 endpoint: Schema.String,
109114 redirectUrl: Schema.String,
110115 queryParams: Schema.optional(Schema.NullOr(StringMap)),
116+ accessTokenSecretId: Schema.String,
117+ refreshTokenSecretId: Schema.optional(Schema.NullOr(Schema.String)),
118+ /** Source-level OAuth state captured by a previous user's flow. When
119+ * passed, DCR is skipped — the same client_id is re-used so the
120+ * source's auth config stays stable across users. */
121+ clientInformation: Schema.optional(Schema.NullOr(JsonObject)),
122+ authorizationServerUrl: Schema.optional(Schema.NullOr(Schema.String)),
123+ resourceMetadataUrl: Schema.optional(Schema.NullOr(Schema.String)),
111124 });
112125
113126 const CompleteOAuthPayload = Schema.Struct({
@@ -153,6 +166,9 @@ const CompleteOAuthResponse = Schema.Struct({ · 39 unmodified lines
153166 tokenType: Schema.String,
154167 expiresAt: Schema.NullOr(Schema.Number),
155168 scope: Schema.NullOr(Schema.String),
169+ clientInformation: Schema.NullOr(JsonObject),
170+ authorizationServerUrl: Schema.NullOr(Schema.String),
171+ resourceMetadataUrl: Schema.NullOr(Schema.String),
156172 });
157173
158174 // ---------------------------------------------------------------------------
packages/plugins/mcp/src/api/handlers.ts+5−0
@@ -204,6 +204,11 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 203 unmodified lines
204204 endpoint: payload.endpoint,
205205 redirectUrl: payload.redirectUrl,
206206 queryParams: payload.queryParams,
207+ accessTokenSecretId: payload.accessTokenSecretId,
208+ refreshTokenSecretId: payload.refreshTokenSecretId,
209+ clientInformation: payload.clientInformation,
210+ authorizationServerUrl: payload.authorizationServerUrl,
211+ resourceMetadataUrl: payload.resourceMetadataUrl,
207212 });
208213 })),
209214 )
packages/plugins/mcp/src/react/AddMcpSource.tsx+40−2
@@ -49,6 +49,22 @@ function findPreset(id: string | undefined): McpPreset | undefined { · 48 unmodified lines
4949 return mcpPresets.find((p) => p.id === id);
5050 }
5151
52+// Stable secret ids for an MCP source's OAuth access/refresh tokens.
53+// The same ids are passed into `startOAuth` AND stored on the source's
54+// auth config, so at invoke time the per-user scope (which actually
55+// holds the value) resolves via shadowing over the source-level id.
56+// Keyed by namespace so reconnecting overwrites the previous token
57+// rather than leaking orphaned secret rows.
58+const mcpOAuthTokenSecretIds = (
59+ namespaceSlug: string,
60+): { readonly accessTokenSecretId: string; readonly refreshTokenSecretId: string } => {
61+ const prefix = `mcp_${namespaceSlug}`;
62+ return {
63+ accessTokenSecretId: `${prefix}_access_token`,
64+ refreshTokenSecretId: `${prefix}_refresh_token`,
65+ };
66+};
67+
5268 // ---------------------------------------------------------------------------
5369 // State machine (remote flow)
5470 // ---------------------------------------------------------------------------
@@ -59,6 +75,12 @@ type OAuthTokens = { · 4 unmodified lines
5975 tokenType: string;
6076 expiresAt: number | null;
6177 scope: string | null;
78+ /** Source-level OAuth state captured during the flow. Persisted on
79+ * the source's auth config so refreshes + future user OAuth flows
80+ * re-use the same DCR client + skip discovery. */
81+ clientInformation: Record<string, unknown> | null;
82+ authorizationServerUrl: string | null;
83+ resourceMetadataUrl: string | null;
6284 };
6385
6486 type ProbeResult = {
@@ -419,9 +441,19 @@ export default function AddMcpSource(props: { · 354 unmodified lines
419441 dispatch({ type: "oauth-start" });
420442 try {
421443 const redirectUrl = `${window.location.origin}/api/mcp/oauth/callback`;
444+ const namespaceSlug =
445+ slugifyNamespace(remoteIdentity.namespace) ||
446+ slugifyNamespace(probe?.namespace ?? "") ||
447+ "mcp";
448+ const tokenIds = mcpOAuthTokenSecretIds(namespaceSlug);
422449 const result = await doStartOAuth({
423450 path: { scopeId },
424− payload: { endpoint: state.url.trim(), redirectUrl },
451+ payload: {
452+ endpoint: state.url.trim(),
453+ redirectUrl,
454+ accessTokenSecretId: tokenIds.accessTokenSecretId,
455+ refreshTokenSecretId: tokenIds.refreshTokenSecretId,
456+ },
425457 });
426458 dispatch({ type: "oauth-waiting", sessionId: result.sessionId });
427459 oauthCleanup.current = openOAuthPopup(
@@ -437,6 +469,9 @@ export default function AddMcpSource(props: { · 9 unmodified lines
437469 tokenType: data.tokenType,
438470 expiresAt: data.expiresAt,
439471 scope: data.scope,
472+ clientInformation: data.clientInformation ?? null,
473+ authorizationServerUrl: data.authorizationServerUrl ?? null,
474+ resourceMetadataUrl: data.resourceMetadataUrl ?? null,
440475 },
441476 });
442477 } else {
@@ -454,7 +489,7 @@ export default function AddMcpSource(props: { · 11 unmodified lines
454489 error: e instanceof Error ? e.message : "Failed to start OAuth",
455490 });
456491 }
457− }, [state.url, scopeId, doStartOAuth]);
492+ }, [state.url, scopeId, doStartOAuth, remoteIdentity.namespace, probe?.namespace]);
458493
459494 const handleCancelOAuth = useCallback(() => {
460495 oauthCleanup.current?.();
@@ -482,6 +517,9 @@ export default function AddMcpSource(props: { · 21 unmodified lines
482517 tokenType: tokens.tokenType,
483518 expiresAt: tokens.expiresAt,
484519 scope: tokens.scope,
520+ clientInformation: tokens.clientInformation,
521+ authorizationServerUrl: tokens.authorizationServerUrl,
522+ resourceMetadataUrl: tokens.resourceMetadataUrl,
485523 }
486524 : { kind: "none" as const };
487525 const headers = Object.fromEntries(
packages/plugins/mcp/src/sdk/invoke.ts+16−3
@@ -34,10 +34,19 @@ const asRecord = (value: unknown): Record<string, unknown> => · 33 unmodified lines
3434 ? (value as Record<string, unknown>)
3535 : {};
3636
37−const connectionCacheKey = (sd: McpStoredSourceData): string =>
37+const connectionCacheKey = (
38+ sd: McpStoredSourceData,
39+ invokerScope: string,
40+): string =>
3841 sd.transport === "stdio"
3942 ? `stdio:${sd.command}`
40− : `remote:${sd.endpoint}`;
43+ : // Remote sources may resolve per-user secrets (OAuth tokens, header
44+ // auth) via scope shadowing, so two users invoking the same source
45+ // get different Authorization headers. The connection caches that
46+ // header in transport state, so the cache key must include the
47+ // invoking scope — otherwise user B re-uses user A's connection
48+ // (and user A's tokens).
49+ `remote:${invokerScope}:${sd.endpoint}`;
4150
4251 // ---------------------------------------------------------------------------
4352 // Elicitation bridge — decode incoming MCP ElicitRequest, route through
@@ -152,6 +161,10 @@ export interface InvokeMcpToolInput { · 108 unmodified lines
152161 readonly toolName: string;
153162 readonly args: unknown;
154163 readonly sourceData: McpStoredSourceData;
164+ /** Innermost executor scope id at invoke time. Mixed into the
165+ * connection cache key so per-user OAuth/secret resolution doesn't
166+ * collapse multiple users onto one shared connection. */
167+ readonly invokerScope: string;
155168 readonly resolveConnector: () => Effect.Effect<McpConnection, McpConnectionError>;
156169 readonly connectionCache: ScopedCache.ScopedCache<
157170 string,
@@ -173,7 +186,7 @@ export const invokeMcpTool = ( · 15 unmodified lines
173186 ? "stdio"
174187 : (input.sourceData.remoteTransport ?? "auto");
175188 return Effect.gen(function* () {
176− const cacheKey = connectionCacheKey(input.sourceData);
189+ const cacheKey = connectionCacheKey(input.sourceData, input.invokerScope);
177190 const args = asRecord(input.args);
178191
179192 // Register the connector for the cache lookup (side-channel pattern
packages/plugins/mcp/src/sdk/oauth.ts+29−2
@@ -37,6 +37,19 @@ export const McpOAuthSession = Schema.Struct({ · 36 unmodified lines
3737 endpoint: Schema.String,
3838 redirectUrl: Schema.String,
3939 codeVerifier: Schema.String,
40+ /**
41+ * Executor scope id where the minted access/refresh tokens will land.
42+ * Pinned at `startOAuth` time so token writes target the same scope
43+ * regardless of who's currently invoking. For per-user OAuth this is
44+ * the innermost (`ctx.scopes[0]`) scope; for org-shared installs it
45+ * can be the org scope.
46+ */
47+ tokenScope: Schema.String,
48+ /** Stable secret ids the minted tokens are written to. Stored once on
49+ * the source's auth config so per-user scope shadowing resolves to
50+ * the calling user's tokens at invoke time. */
51+ accessTokenSecretId: Schema.String,
52+ refreshTokenSecretId: Schema.NullOr(Schema.String),
4053 });
4154 export type McpOAuthSession = typeof McpOAuthSession.Type;
4255
@@ -93,12 +106,26 @@ export const startMcpOAuthAuthorization = (input: { · 50 unmodified lines
93106 endpoint: string;
94107 redirectUrl: string;
95108 state: string;
109+ /** Pre-existing DCR client + discovery URLs for the source. When
110+ * passed, the SDK skips registration and re-uses these values. */
111+ clientInformation?: OAuthClientInformationMixed | null;
112+ authorizationServerUrl?: string | null;
113+ resourceMetadataUrl?: string | null;
96114 }): Effect.Effect<McpOAuthStartResult, McpOAuthError> =>
97115 Effect.gen(function* () {
98116 let authorizationUrl: URL | undefined;
99117 let codeVerifier: string | undefined;
100− let discoveryState: OAuthDiscoveryState | undefined;
101− let clientInformation: OAuthClientInformationMixed | undefined;
118+ let discoveryState: OAuthDiscoveryState | undefined =
119+ input.authorizationServerUrl || input.resourceMetadataUrl
120+ ? {
121+ authorizationServerUrl:
122+ input.authorizationServerUrl ??
123+ new URL("/", input.endpoint).toString(),
124+ resourceMetadataUrl: input.resourceMetadataUrl ?? undefined,
125+ }
126+ : undefined;
127+ let clientInformation: OAuthClientInformationMixed | undefined =
128+ input.clientInformation ?? undefined;
102129
103130 const provider: OAuthClientProvider = {
104131 get redirectUrl() {
packages/plugins/mcp/src/sdk/plugin.ts+195−50
@@ -1,10 +1,17 @@
11 import { Duration, Effect, Exit, Scope, ScopedCache } from "effect";
22
3−import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
4−import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
3+import type {
4+ OAuthClientProvider,
5+ OAuthDiscoveryState,
6+} from "@modelcontextprotocol/sdk/client/auth.js";
7+import type {
8+ OAuthClientInformationMixed,
9+ OAuthTokens,
10+} from "@modelcontextprotocol/sdk/shared/auth.js";
511
612 import {
713 definePlugin,
14+ ScopeId,
815 SecretId,
916 SetSecretInput,
1017 SourceDetectionResult,
@@ -89,6 +96,30 @@ export interface McpOAuthStartInput { · 78 unmodified lines
8996 readonly endpoint: string;
9097 readonly redirectUrl: string;
9198 readonly queryParams?: Record<string, string> | null;
99+ /**
100+ * Executor scope id where the minted access/refresh tokens will land.
101+ * Defaults to `ctx.scopes[0].id` (innermost) — for a per-user stack
102+ * `[user, org]` that pins tokens to the user scope so the source's
103+ * stored `accessTokenSecretId` resolves per-user via shadowing.
104+ */
105+ readonly tokenScope?: string;
106+ /**
107+ * Pre-decided secret ids for the minted tokens. Mint deterministically
108+ * (e.g. `mcp_${namespace}_access_token`) so the source's stored
109+ * OAuth2 auth carries the same id everyone reads, and `ctx.secrets.get`
110+ * resolves per-user via scope fall-through.
111+ */
112+ readonly accessTokenSecretId: string;
113+ readonly refreshTokenSecretId?: string | null;
114+ /**
115+ * Source-level OAuth state captured by a previous user's flow. Pass
116+ * the values stored on the source's auth config to skip Dynamic
117+ * Client Registration — the new user's flow re-uses the same
118+ * client_id and discovery results.
119+ */
120+ readonly clientInformation?: Record<string, unknown> | null;
121+ readonly authorizationServerUrl?: string | null;
122+ readonly resourceMetadataUrl?: string | null;
92123 }
93124
94125 export interface McpOAuthStartResponse {
@@ -108,6 +139,12 @@ export interface McpOAuthCompleteResponse { · 13 unmodified lines
108139 readonly tokenType: string;
109140 readonly expiresAt: number | null;
110141 readonly scope: string | null;
142+ /** DCR client + discovery URLs captured during the flow. The UI
143+ * stores them on the source's auth config so refreshes don't
144+ * re-register or re-discover. */
145+ readonly clientInformation: Record<string, unknown> | null;
146+ readonly authorizationServerUrl: string | null;
147+ readonly resourceMetadataUrl: string | null;
111148 }
112149
113150 export interface McpProbeResult {
@@ -168,41 +205,81 @@ const toBinding = (entry: McpToolManifestEntry): McpToolBinding => · 54 unmodified lines
168205 outputSchema: entry.outputSchema,
169206 });
170207
171−const makeOAuthProvider = (
172− accessToken: string,
173− tokenType: string,
174− refreshToken?: string,
175−): OAuthClientProvider => ({
176− get redirectUrl() {
177− return "http://localhost/oauth/callback";
178− },
179− get clientMetadata() {
180− return {
181− redirect_uris: ["http://localhost/oauth/callback"],
182− grant_types: ["authorization_code", "refresh_token"] as string[],
183− response_types: ["code"] as string[],
184− token_endpoint_auth_method: "none" as const,
185− client_name: "Executor",
186− };
187− },
188− clientInformation: () => undefined,
189− saveClientInformation: () => {},
190− tokens: async (): Promise<OAuthTokens> => ({
191− access_token: accessToken,
192− token_type: tokenType,
193− ...(refreshToken ? { refresh_token: refreshToken } : {}),
194− }),
195− saveTokens: async () => {},
196− redirectToAuthorization: async () => {
197− throw new Error("MCP OAuth re-authorization required");
198− },
199− saveCodeVerifier: () => {},
200− codeVerifier: () => {
201− throw new Error("No active PKCE verifier");
202− },
203− saveDiscoveryState: () => {},
204− discoveryState: () => undefined,
205−});
208+interface OAuthProviderInputs {
209+ readonly accessToken: string;
210+ readonly tokenType: string;
211+ readonly refreshToken?: string;
212+ readonly expiresAt?: number | null;
213+ readonly scope?: string | null;
214+ /** Source-level state — same for every user. */
215+ readonly clientInformation?: OAuthClientInformationMixed | null;
216+ readonly authorizationServerUrl?: string | null;
217+ readonly resourceMetadataUrl?: string | null;
218+ readonly endpoint: string;
219+ /**
220+ * Called when the SDK refreshes tokens (grant_type=refresh_token).
221+ * Persisting new tokens back to per-user secrets is what closes the
222+ * refresh loop — without it the next invocation reads stale values.
223+ */
224+ readonly onRefresh?: (tokens: OAuthTokens) => Promise<void> | void;
225+}
226+
227+const makeOAuthProvider = (inputs: OAuthProviderInputs): OAuthClientProvider => {
228+ let currentTokens: OAuthTokens = {
229+ access_token: inputs.accessToken,
230+ token_type: inputs.tokenType,
231+ ...(inputs.refreshToken ? { refresh_token: inputs.refreshToken } : {}),
232+ ...(inputs.expiresAt
233+ ? { expires_in: Math.max(0, Math.floor((inputs.expiresAt - Date.now()) / 1000)) }
234+ : {}),
235+ ...(inputs.scope ? { scope: inputs.scope } : {}),
236+ };
237+ let clientInformation: OAuthClientInformationMixed | undefined =
238+ inputs.clientInformation ?? undefined;
239+ let discoveryState: OAuthDiscoveryState | undefined =
240+ inputs.authorizationServerUrl || inputs.resourceMetadataUrl
241+ ? {
242+ authorizationServerUrl:
243+ inputs.authorizationServerUrl ?? new URL("/", inputs.endpoint).toString(),
244+ resourceMetadataUrl: inputs.resourceMetadataUrl ?? undefined,
245+ }
246+ : undefined;
247+
248+ return {
249+ get redirectUrl() {
250+ return "http://localhost/oauth/callback";
251+ },
252+ get clientMetadata() {
253+ return {
254+ redirect_uris: ["http://localhost/oauth/callback"],
255+ grant_types: ["authorization_code", "refresh_token"] as string[],
256+ response_types: ["code"] as string[],
257+ token_endpoint_auth_method: "none" as const,
258+ client_name: "Executor",
259+ };
260+ },
261+ clientInformation: () => clientInformation,
262+ saveClientInformation: (ci) => {
263+ clientInformation = ci;
264+ },
265+ tokens: () => currentTokens,
266+ saveTokens: async (t) => {
267+ currentTokens = t;
268+ if (inputs.onRefresh) await inputs.onRefresh(t);
269+ },
270+ redirectToAuthorization: async () => {
271+ throw new Error("MCP OAuth re-authorization required");
272+ },
273+ saveCodeVerifier: () => {},
274+ codeVerifier: () => {
275+ throw new Error("No active PKCE verifier");
276+ },
277+ saveDiscoveryState: (s) => {
278+ discoveryState = s;
279+ },
280+ discoveryState: () => discoveryState,
281+ };
282+};
206283
207284 const remoteConnectionError = (message: string) =>
208285 new McpConnectionError({ transport: "remote", message });
@@ -263,11 +340,58 @@ const resolveConnectorInput = ( · 54 unmodified lines
263340 const refreshToken = auth.refreshTokenSecretId
264341 ? (yield* ctx.secrets.get(auth.refreshTokenSecretId)) ?? undefined
265342 : undefined;
266− authProvider = makeOAuthProvider(
343+ // Capture the resolved owning scope of these secrets so refreshed
344+ // tokens land back at the same per-user scope. `ctx.secrets.get`
345+ // walks the executor scope stack innermost-first, so the existing
346+ // value lives at whichever scope shadowed the source-level id —
347+ // we mirror that with `ctx.scopes[0]!.id`, matching the scope
348+ // chosen at startOAuth time.
349+ const tokenScope = ScopeId.make(ctx.scopes[0]!.id as string);
350+ const accessSecretId = auth.accessTokenSecretId;
351+ const refreshSecretId = auth.refreshTokenSecretId;
352+ authProvider = makeOAuthProvider({
267353 accessToken,
268− auth.tokenType ?? "Bearer",
354+ tokenType: auth.tokenType ?? "Bearer",
269355 refreshToken,
270− );
356+ expiresAt: auth.expiresAt,
357+ scope: auth.scope,
358+ clientInformation: auth.clientInformation as
359+ | OAuthClientInformationMixed
360+ | null
361+ | undefined,
362+ authorizationServerUrl: auth.authorizationServerUrl,
363+ resourceMetadataUrl: auth.resourceMetadataUrl,
364+ endpoint: sd.endpoint,
365+ onRefresh: async (tokens) => {
366+ // Persist refreshed tokens back to the calling user's scope
367+ // so subsequent invocations see the new value rather than
368+ // re-refreshing on every request. Uses runPromise because
369+ // OAuthClientProvider.saveTokens is an async callback, not
370+ // an Effect.
371+ await Effect.runPromise(
372+ ctx.secrets.set(
373+ new SetSecretInput({
374+ id: SecretId.make(accessSecretId),
375+ scope: tokenScope,
376+ name: "MCP OAuth Access Token",
377+ value: tokens.access_token,
378+ }),
379+ ),
380+ );
381+ if (tokens.refresh_token && refreshSecretId) {
382+ await Effect.runPromise(
383+ ctx.secrets.set(
384+ new SetSecretInput({
385+ id: SecretId.make(refreshSecretId),
386+ scope: tokenScope,
387+ name: "MCP OAuth Refresh Token",
388+ value: tokens.refresh_token,
389+ }),
390+ ),
391+ );
392+ }
393+ },
394+ });
271395 }
272396
273397 return {
@@ -736,10 +860,17 @@ export const mcpPlugin = definePlugin( · 462 unmodified lines
736860 }
737861
738862 const sessionId = `mcp_oauth_${crypto.randomUUID()}`;
863+ const tokenScope = input.tokenScope ?? (ctx.scopes[0]!.id as string);
739864 const started = yield* startMcpOAuthAuthorization({
740865 endpoint: fullEndpoint,
741866 redirectUrl: input.redirectUrl,
742867 state: sessionId,
868+ clientInformation: input.clientInformation as
869+ | OAuthClientInformationMixed
870+ | null
871+ | undefined,
872+ authorizationServerUrl: input.authorizationServerUrl,
873+ resourceMetadataUrl: input.resourceMetadataUrl,
743874 }).pipe(
744875 Effect.mapError((e) =>
745876 mcpOAuthError(`OAuth start failed: ${e.message}`),
@@ -748,7 +879,7 @@ export const mcpPlugin = definePlugin( · 2 unmodified lines
748879 );
749880
750881 yield* ctx.storage
751− .putOAuthSession(sessionId, ctx.scopes[0]!.id as string, {
882+ .putOAuthSession(sessionId, tokenScope, {
752883 endpoint: fullEndpoint,
753884 redirectUrl: input.redirectUrl,
754885 codeVerifier: started.codeVerifier,
@@ -757,6 +888,9 @@ export const mcpPlugin = definePlugin( · 2 unmodified lines
757888 resourceMetadata: started.resourceMetadata,
758889 authorizationServerMetadata: started.authorizationServerMetadata,
759890 clientInformation: started.clientInformation,
891+ tokenScope,
892+ accessTokenSecretId: input.accessTokenSecretId,
893+ refreshTokenSecretId: input.refreshTokenSecretId ?? null,
760894 })
761895 .pipe(Effect.withSpan("mcp.plugin.oauth.persist_session"));
762896
@@ -796,13 +930,14 @@ export const mcpPlugin = definePlugin( · 33 unmodified lines
796930 Effect.withSpan("mcp.plugin.oauth.exchange_code"),
797931 );
798932
799− // MCP OAuth tokens are user-specific, so they land at the
800− // innermost scope. On a single-scope executor that's the
801− // only scope; on a per-user stack `[user, org]` it's the
802− // user scope — so tokens shadow org-level fallbacks for
803− // the caller's own invocations.
804− const tokenScope = ctx.scopes[0]!.id;
805− const accessSecretId = `mcp-oauth-access-${input.state}`;
933+ // Token storage is fully driven by the session: scope and
934+ // secret ids were chosen at startOAuth time and pinned to
935+ // the row. That keeps shadowing deterministic — every
936+ // user's OAuth flow on the same source writes to the same
937+ // ids, so the source's stored OAuth2 auth resolves
938+ // per-user via scope fall-through.
939+ const tokenScope = ScopeId.make(session.tokenScope);
940+ const accessSecretId = session.accessTokenSecretId;
806941 yield* ctx.secrets
807942 .set(
808943 new SetSecretInput({
@@ -819,8 +954,8 @@ export const mcpPlugin = definePlugin( · 10 unmodified lines
819954 );
820955
821956 let refreshTokenSecretId: string | null = null;
822− if (exchanged.tokens.refresh_token) {
823− const refreshId = `mcp-oauth-refresh-${input.state}`;
957+ if (exchanged.tokens.refresh_token && session.refreshTokenSecretId) {
958+ const refreshId = session.refreshTokenSecretId;
824959 yield* ctx.secrets
825960 .set(
826961 new SetSecretInput({
@@ -853,6 +988,15 @@ export const mcpPlugin = definePlugin( · 26 unmodified lines
853988 tokenType: exchanged.tokens.token_type ?? "Bearer",
854989 expiresAt,
855990 scope: exchanged.tokens.scope ?? null,
991+ // Echo the source-level OAuth state captured during this
992+ // flow. The UI persists it on the source's auth config so
993+ // refreshes (and any future user's OAuth) re-use the same
994+ // DCR client + skip discovery.
995+ clientInformation: exchanged.clientInformation as
996+ | Record<string, unknown>
997+ | null,
998+ authorizationServerUrl: exchanged.authorizationServerUrl,
999+ resourceMetadataUrl: exchanged.resourceMetadataUrl,
8561000 };
8571001 }).pipe(Effect.withSpan("mcp.plugin.complete_oauth"));
8581002
@@ -946,6 +1090,7 @@ export const mcpPlugin = definePlugin( · 87 unmodified lines
9461090 toolName: entry.binding.toolName,
9471091 args,
9481092 sourceData: sd,
1093+ invokerScope: ctx.scopes[0]!.id as string,
9491094 resolveConnector: () =>
9501095 resolveConnectorInput(sd, ctx, allowStdio).pipe(
9511096 Effect.flatMap((ci) => createMcpConnector(ci)),
packages/plugins/mcp/src/sdk/types.ts+24−0
@@ -15,6 +15,9 @@ export type McpTransport = typeof McpTransport.Type; · 14 unmodified lines
1515 // Connection auth (only applies to remote sources)
1616 // ---------------------------------------------------------------------------
1717
18+/** JSON object loosely typed — used for opaque OAuth state we just round-trip. */
19+const JsonObject = Schema.Record({ key: Schema.String, value: Schema.Unknown });
20+
1821 export const McpConnectionAuth = Schema.Union(
1922 Schema.Struct({ kind: Schema.Literal("none") }),
2023 Schema.Struct({
@@ -30,6 +33,27 @@ export const McpConnectionAuth = Schema.Union( · 9 unmodified lines
3033 tokenType: Schema.optionalWith(Schema.String, { default: () => "Bearer" }),
3134 expiresAt: Schema.NullOr(Schema.Number),
3235 scope: Schema.NullOr(Schema.String),
36+ /**
37+ * Source-level OAuth state shared by every user. Lives on the
38+ * source row (org scope) so DCR runs once per source instead of
39+ * once per user, and so refresh can use the same client_id the
40+ * upstream auth server originally registered.
41+ *
42+ * - `clientInformation`: DCR-issued client credentials (client_id,
43+ * optional client_secret). When present, no DCR happens on
44+ * subsequent OAuth flows or refreshes.
45+ * - `authorizationServerUrl` / `resourceMetadataUrl`: discovery
46+ * URLs captured at first OAuth so refreshes don't re-discover.
47+ */
48+ clientInformation: Schema.optionalWith(Schema.NullOr(JsonObject), {
49+ default: () => null,
50+ }),
51+ authorizationServerUrl: Schema.optionalWith(Schema.NullOr(Schema.String), {
52+ default: () => null,
53+ }),
54+ resourceMetadataUrl: Schema.optionalWith(Schema.NullOr(Schema.String), {
55+ default: () => null,
56+ }),
3357 }),
3458 );
3559 export type McpConnectionAuth = typeof McpConnectionAuth.Type;