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
24// Auth payload (only for remote)24// Auth payload (only for remote)
25// ---------------------------------------------------------------------------25// ---------------------------------------------------------------------------
26 26
27const JsonObject = Schema.Record({ key: Schema.String, value: Schema.Unknown });
28
27const AuthPayload = Schema.Union(29const AuthPayload = Schema.Union(
28 Schema.Struct({ kind: Schema.Literal("none") }),30 Schema.Struct({ kind: Schema.Literal("none") }),
29 Schema.Struct({31 Schema.Struct({
@@ -39,6 +41,9 @@ const AuthPayload = Schema.Union( · 9 unmodified lines
39 tokenType: Schema.optional(Schema.String),41 tokenType: Schema.optional(Schema.String),
40 expiresAt: Schema.NullOr(Schema.Number),42 expiresAt: Schema.NullOr(Schema.Number),
41 scope: Schema.NullOr(Schema.String),43 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)),
42 }),47 }),
43);48);
44 49
@@ -108,6 +113,14 @@ const StartOAuthPayload = Schema.Struct({ · 63 unmodified lines
108 endpoint: Schema.String,113 endpoint: Schema.String,
109 redirectUrl: Schema.String,114 redirectUrl: Schema.String,
110 queryParams: Schema.optional(Schema.NullOr(StringMap)),115 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)),
111});124});
112 125
113const CompleteOAuthPayload = Schema.Struct({126const CompleteOAuthPayload = Schema.Struct({
@@ -153,6 +166,9 @@ const CompleteOAuthResponse = Schema.Struct({ · 39 unmodified lines
153 tokenType: Schema.String,166 tokenType: Schema.String,
154 expiresAt: Schema.NullOr(Schema.Number),167 expiresAt: Schema.NullOr(Schema.Number),
155 scope: Schema.NullOr(Schema.String),168 scope: Schema.NullOr(Schema.String),
169 clientInformation: Schema.NullOr(JsonObject),
170 authorizationServerUrl: Schema.NullOr(Schema.String),
171 resourceMetadataUrl: Schema.NullOr(Schema.String),
156});172});
157 173
158// ---------------------------------------------------------------------------174// ---------------------------------------------------------------------------
packages/plugins/mcp/src/api/handlers.ts+5−0
@@ -204,6 +204,11 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 203 unmodified lines
204 endpoint: payload.endpoint,204 endpoint: payload.endpoint,
205 redirectUrl: payload.redirectUrl,205 redirectUrl: payload.redirectUrl,
206 queryParams: payload.queryParams,206 queryParams: payload.queryParams,
207 accessTokenSecretId: payload.accessTokenSecretId,
208 refreshTokenSecretId: payload.refreshTokenSecretId,
209 clientInformation: payload.clientInformation,
210 authorizationServerUrl: payload.authorizationServerUrl,
211 resourceMetadataUrl: payload.resourceMetadataUrl,
207 });212 });
208 })),213 })),
209 )214 )
packages/plugins/mcp/src/react/AddMcpSource.tsx+40−2
@@ -49,6 +49,22 @@ function findPreset(id: string | undefined): McpPreset | undefined { · 48 unmodified lines
49 return mcpPresets.find((p) => p.id === id);49 return mcpPresets.find((p) => p.id === id);
50}50}
51 51
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.
58const 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
52// ---------------------------------------------------------------------------68// ---------------------------------------------------------------------------
53// State machine (remote flow)69// State machine (remote flow)
54// ---------------------------------------------------------------------------70// ---------------------------------------------------------------------------
@@ -59,6 +75,12 @@ type OAuthTokens = { · 4 unmodified lines
59 tokenType: string;75 tokenType: string;
60 expiresAt: number | null;76 expiresAt: number | null;
61 scope: string | null;77 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;
62};84};
63 85
64type ProbeResult = {86type ProbeResult = {
@@ -419,9 +441,19 @@ export default function AddMcpSource(props: { · 354 unmodified lines
419 dispatch({ type: "oauth-start" });441 dispatch({ type: "oauth-start" });
420 try {442 try {
421 const redirectUrl = `${window.location.origin}/api/mcp/oauth/callback`;443 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);
422 const result = await doStartOAuth({449 const result = await doStartOAuth({
423 path: { scopeId },450 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 },
425 });457 });
426 dispatch({ type: "oauth-waiting", sessionId: result.sessionId });458 dispatch({ type: "oauth-waiting", sessionId: result.sessionId });
427 oauthCleanup.current = openOAuthPopup(459 oauthCleanup.current = openOAuthPopup(
@@ -437,6 +469,9 @@ export default function AddMcpSource(props: { · 9 unmodified lines
437 tokenType: data.tokenType,469 tokenType: data.tokenType,
438 expiresAt: data.expiresAt,470 expiresAt: data.expiresAt,
439 scope: data.scope,471 scope: data.scope,
472 clientInformation: data.clientInformation ?? null,
473 authorizationServerUrl: data.authorizationServerUrl ?? null,
474 resourceMetadataUrl: data.resourceMetadataUrl ?? null,
440 },475 },
441 });476 });
442 } else {477 } else {
@@ -454,7 +489,7 @@ export default function AddMcpSource(props: { · 11 unmodified lines
454 error: e instanceof Error ? e.message : "Failed to start OAuth",489 error: e instanceof Error ? e.message : "Failed to start OAuth",
455 });490 });
456 }491 }
457 }, [state.url, scopeId, doStartOAuth]);492 }, [state.url, scopeId, doStartOAuth, remoteIdentity.namespace, probe?.namespace]);
458 493
459 const handleCancelOAuth = useCallback(() => {494 const handleCancelOAuth = useCallback(() => {
460 oauthCleanup.current?.();495 oauthCleanup.current?.();
@@ -482,6 +517,9 @@ export default function AddMcpSource(props: { · 21 unmodified lines
482 tokenType: tokens.tokenType,517 tokenType: tokens.tokenType,
483 expiresAt: tokens.expiresAt,518 expiresAt: tokens.expiresAt,
484 scope: tokens.scope,519 scope: tokens.scope,
520 clientInformation: tokens.clientInformation,
521 authorizationServerUrl: tokens.authorizationServerUrl,
522 resourceMetadataUrl: tokens.resourceMetadataUrl,
485 }523 }
486 : { kind: "none" as const };524 : { kind: "none" as const };
487 const headers = Object.fromEntries(525 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
34 ? (value as Record<string, unknown>)34 ? (value as Record<string, unknown>)
35 : {};35 : {};
36 36
37const connectionCacheKey = (sd: McpStoredSourceData): string =>37const connectionCacheKey = (
38 sd: McpStoredSourceData,
39 invokerScope: string,
40): string =>
38 sd.transport === "stdio"41 sd.transport === "stdio"
39 ? `stdio:${sd.command}`42 ? `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}`;
41 50
42// ---------------------------------------------------------------------------51// ---------------------------------------------------------------------------
43// Elicitation bridge — decode incoming MCP ElicitRequest, route through52// Elicitation bridge — decode incoming MCP ElicitRequest, route through
@@ -152,6 +161,10 @@ export interface InvokeMcpToolInput { · 108 unmodified lines
152 readonly toolName: string;161 readonly toolName: string;
153 readonly args: unknown;162 readonly args: unknown;
154 readonly sourceData: McpStoredSourceData;163 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;
155 readonly resolveConnector: () => Effect.Effect<McpConnection, McpConnectionError>;168 readonly resolveConnector: () => Effect.Effect<McpConnection, McpConnectionError>;
156 readonly connectionCache: ScopedCache.ScopedCache<169 readonly connectionCache: ScopedCache.ScopedCache<
157 string,170 string,
@@ -173,7 +186,7 @@ export const invokeMcpTool = ( · 15 unmodified lines
173 ? "stdio"186 ? "stdio"
174 : (input.sourceData.remoteTransport ?? "auto");187 : (input.sourceData.remoteTransport ?? "auto");
175 return Effect.gen(function* () {188 return Effect.gen(function* () {
176 const cacheKey = connectionCacheKey(input.sourceData);189 const cacheKey = connectionCacheKey(input.sourceData, input.invokerScope);
177 const args = asRecord(input.args);190 const args = asRecord(input.args);
178 191
179 // Register the connector for the cache lookup (side-channel pattern192 // 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
37 endpoint: Schema.String,37 endpoint: Schema.String,
38 redirectUrl: Schema.String,38 redirectUrl: Schema.String,
39 codeVerifier: Schema.String,39 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),
40});53});
41export type McpOAuthSession = typeof McpOAuthSession.Type;54export type McpOAuthSession = typeof McpOAuthSession.Type;
42 55
@@ -93,12 +106,26 @@ export const startMcpOAuthAuthorization = (input: { · 50 unmodified lines
93 endpoint: string;106 endpoint: string;
94 redirectUrl: string;107 redirectUrl: string;
95 state: string;108 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;
96}): Effect.Effect<McpOAuthStartResult, McpOAuthError> =>114}): Effect.Effect<McpOAuthStartResult, McpOAuthError> =>
97 Effect.gen(function* () {115 Effect.gen(function* () {
98 let authorizationUrl: URL | undefined;116 let authorizationUrl: URL | undefined;
99 let codeVerifier: string | undefined;117 let codeVerifier: string | undefined;
100 let discoveryState: OAuthDiscoveryState | undefined;118 let discoveryState: OAuthDiscoveryState | undefined =
101 let clientInformation: OAuthClientInformationMixed | 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;
102 129
103 const provider: OAuthClientProvider = {130 const provider: OAuthClientProvider = {
104 get redirectUrl() {131 get redirectUrl() {
packages/plugins/mcp/src/sdk/plugin.ts+195−50
@@ -1,10 +1,17 @@
1import { Duration, Effect, Exit, Scope, ScopedCache } from "effect";1import { Duration, Effect, Exit, Scope, ScopedCache } from "effect";
2 2
3import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";3import type {
4import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";4 OAuthClientProvider,
5 OAuthDiscoveryState,
6} from "@modelcontextprotocol/sdk/client/auth.js";
7import type {
8 OAuthClientInformationMixed,
9 OAuthTokens,
10} from "@modelcontextprotocol/sdk/shared/auth.js";
5 11
6import {12import {
7 definePlugin,13 definePlugin,
14 ScopeId,
8 SecretId,15 SecretId,
9 SetSecretInput,16 SetSecretInput,
10 SourceDetectionResult,17 SourceDetectionResult,
@@ -89,6 +96,30 @@ export interface McpOAuthStartInput { · 78 unmodified lines
89 readonly endpoint: string;96 readonly endpoint: string;
90 readonly redirectUrl: string;97 readonly redirectUrl: string;
91 readonly queryParams?: Record<string, string> | null;98 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;
92}123}
93 124
94export interface McpOAuthStartResponse {125export interface McpOAuthStartResponse {
@@ -108,6 +139,12 @@ export interface McpOAuthCompleteResponse { · 13 unmodified lines
108 readonly tokenType: string;139 readonly tokenType: string;
109 readonly expiresAt: number | null;140 readonly expiresAt: number | null;
110 readonly scope: string | null;141 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;
111}148}
112 149
113export interface McpProbeResult {150export interface McpProbeResult {
@@ -168,41 +205,81 @@ const toBinding = (entry: McpToolManifestEntry): McpToolBinding => · 54 unmodified lines
168 outputSchema: entry.outputSchema,205 outputSchema: entry.outputSchema,
169 });206 });
170 207
171const makeOAuthProvider = (208interface OAuthProviderInputs {
172 accessToken: string,209 readonly accessToken: string;
173 tokenType: string,210 readonly tokenType: string;
174 refreshToken?: string,211 readonly refreshToken?: string;
175): OAuthClientProvider => ({212 readonly expiresAt?: number | null;
176 get redirectUrl() {213 readonly scope?: string | null;
177 return "http://localhost/oauth/callback";214 /** Source-level state — same for every user. */
178 },215 readonly clientInformation?: OAuthClientInformationMixed | null;
179 get clientMetadata() {216 readonly authorizationServerUrl?: string | null;
180 return {217 readonly resourceMetadataUrl?: string | null;
181 redirect_uris: ["http://localhost/oauth/callback"],218 readonly endpoint: string;
182 grant_types: ["authorization_code", "refresh_token"] as string[],219 /**
183 response_types: ["code"] as string[],220 * Called when the SDK refreshes tokens (grant_type=refresh_token).
184 token_endpoint_auth_method: "none" as const,221 * Persisting new tokens back to per-user secrets is what closes the
185 client_name: "Executor",222 * refresh loop — without it the next invocation reads stale values.
186 };223 */
187 },224 readonly onRefresh?: (tokens: OAuthTokens) => Promise<void> | void;
188 clientInformation: () => undefined,225}
189 saveClientInformation: () => {},226
190 tokens: async (): Promise<OAuthTokens> => ({227const makeOAuthProvider = (inputs: OAuthProviderInputs): OAuthClientProvider => {
191 access_token: accessToken,228 let currentTokens: OAuthTokens = {
192 token_type: tokenType,229 access_token: inputs.accessToken,
193 ...(refreshToken ? { refresh_token: refreshToken } : {}),230 token_type: inputs.tokenType,
194 }),231 ...(inputs.refreshToken ? { refresh_token: inputs.refreshToken } : {}),
195 saveTokens: async () => {},232 ...(inputs.expiresAt
196 redirectToAuthorization: async () => {233 ? { expires_in: Math.max(0, Math.floor((inputs.expiresAt - Date.now()) / 1000)) }
197 throw new Error("MCP OAuth re-authorization required");234 : {}),
198 },235 ...(inputs.scope ? { scope: inputs.scope } : {}),
199 saveCodeVerifier: () => {},236 };
200 codeVerifier: () => {237 let clientInformation: OAuthClientInformationMixed | undefined =
201 throw new Error("No active PKCE verifier");238 inputs.clientInformation ?? undefined;
202 },239 let discoveryState: OAuthDiscoveryState | undefined =
203 saveDiscoveryState: () => {},240 inputs.authorizationServerUrl || inputs.resourceMetadataUrl
204 discoveryState: () => undefined,241 ? {
205});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};
206 283
207const remoteConnectionError = (message: string) =>284const remoteConnectionError = (message: string) =>
208 new McpConnectionError({ transport: "remote", message });285 new McpConnectionError({ transport: "remote", message });
@@ -263,11 +340,58 @@ const resolveConnectorInput = ( · 54 unmodified lines
263 const refreshToken = auth.refreshTokenSecretId340 const refreshToken = auth.refreshTokenSecretId
264 ? (yield* ctx.secrets.get(auth.refreshTokenSecretId)) ?? undefined341 ? (yield* ctx.secrets.get(auth.refreshTokenSecretId)) ?? undefined
265 : undefined;342 : 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({
267 accessToken,353 accessToken,
268 auth.tokenType ?? "Bearer",354 tokenType: auth.tokenType ?? "Bearer",
269 refreshToken,355 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 });
271 }395 }
272 396
273 return {397 return {
@@ -736,10 +860,17 @@ export const mcpPlugin = definePlugin( · 462 unmodified lines
736 }860 }
737 861
738 const sessionId = `mcp_oauth_${crypto.randomUUID()}`;862 const sessionId = `mcp_oauth_${crypto.randomUUID()}`;
863 const tokenScope = input.tokenScope ?? (ctx.scopes[0]!.id as string);
739 const started = yield* startMcpOAuthAuthorization({864 const started = yield* startMcpOAuthAuthorization({
740 endpoint: fullEndpoint,865 endpoint: fullEndpoint,
741 redirectUrl: input.redirectUrl,866 redirectUrl: input.redirectUrl,
742 state: sessionId,867 state: sessionId,
868 clientInformation: input.clientInformation as
869 | OAuthClientInformationMixed
870 | null
871 | undefined,
872 authorizationServerUrl: input.authorizationServerUrl,
873 resourceMetadataUrl: input.resourceMetadataUrl,
743 }).pipe(874 }).pipe(
744 Effect.mapError((e) =>875 Effect.mapError((e) =>
745 mcpOAuthError(`OAuth start failed: ${e.message}`),876 mcpOAuthError(`OAuth start failed: ${e.message}`),
@@ -748,7 +879,7 @@ export const mcpPlugin = definePlugin( · 2 unmodified lines
748 );879 );
749 880
750 yield* ctx.storage881 yield* ctx.storage
751 .putOAuthSession(sessionId, ctx.scopes[0]!.id as string, {882 .putOAuthSession(sessionId, tokenScope, {
752 endpoint: fullEndpoint,883 endpoint: fullEndpoint,
753 redirectUrl: input.redirectUrl,884 redirectUrl: input.redirectUrl,
754 codeVerifier: started.codeVerifier,885 codeVerifier: started.codeVerifier,
@@ -757,6 +888,9 @@ export const mcpPlugin = definePlugin( · 2 unmodified lines
757 resourceMetadata: started.resourceMetadata,888 resourceMetadata: started.resourceMetadata,
758 authorizationServerMetadata: started.authorizationServerMetadata,889 authorizationServerMetadata: started.authorizationServerMetadata,
759 clientInformation: started.clientInformation,890 clientInformation: started.clientInformation,
891 tokenScope,
892 accessTokenSecretId: input.accessTokenSecretId,
893 refreshTokenSecretId: input.refreshTokenSecretId ?? null,
760 })894 })
761 .pipe(Effect.withSpan("mcp.plugin.oauth.persist_session"));895 .pipe(Effect.withSpan("mcp.plugin.oauth.persist_session"));
762 896
@@ -796,13 +930,14 @@ export const mcpPlugin = definePlugin( · 33 unmodified lines
796 Effect.withSpan("mcp.plugin.oauth.exchange_code"),930 Effect.withSpan("mcp.plugin.oauth.exchange_code"),
797 );931 );
798 932
799 // MCP OAuth tokens are user-specific, so they land at the933 // Token storage is fully driven by the session: scope and
800 // innermost scope. On a single-scope executor that's the934 // secret ids were chosen at startOAuth time and pinned to
801 // only scope; on a per-user stack `[user, org]` it's the935 // the row. That keeps shadowing deterministic — every
802 // user scope — so tokens shadow org-level fallbacks for936 // user's OAuth flow on the same source writes to the same
803 // the caller's own invocations.937 // ids, so the source's stored OAuth2 auth resolves
804 const tokenScope = ctx.scopes[0]!.id;938 // per-user via scope fall-through.
805 const accessSecretId = `mcp-oauth-access-${input.state}`;939 const tokenScope = ScopeId.make(session.tokenScope);
940 const accessSecretId = session.accessTokenSecretId;
806 yield* ctx.secrets941 yield* ctx.secrets
807 .set(942 .set(
808 new SetSecretInput({943 new SetSecretInput({
@@ -819,8 +954,8 @@ export const mcpPlugin = definePlugin( · 10 unmodified lines
819 );954 );
820 955
821 let refreshTokenSecretId: string | null = null;956 let refreshTokenSecretId: string | null = null;
822 if (exchanged.tokens.refresh_token) {957 if (exchanged.tokens.refresh_token && session.refreshTokenSecretId) {
823 const refreshId = `mcp-oauth-refresh-${input.state}`;958 const refreshId = session.refreshTokenSecretId;
824 yield* ctx.secrets959 yield* ctx.secrets
825 .set(960 .set(
826 new SetSecretInput({961 new SetSecretInput({
@@ -853,6 +988,15 @@ export const mcpPlugin = definePlugin( · 26 unmodified lines
853 tokenType: exchanged.tokens.token_type ?? "Bearer",988 tokenType: exchanged.tokens.token_type ?? "Bearer",
854 expiresAt,989 expiresAt,
855 scope: exchanged.tokens.scope ?? null,990 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,
856 };1000 };
857 }).pipe(Effect.withSpan("mcp.plugin.complete_oauth"));1001 }).pipe(Effect.withSpan("mcp.plugin.complete_oauth"));
858 1002
@@ -946,6 +1090,7 @@ export const mcpPlugin = definePlugin( · 87 unmodified lines
946 toolName: entry.binding.toolName,1090 toolName: entry.binding.toolName,
947 args,1091 args,
948 sourceData: sd,1092 sourceData: sd,
1093 invokerScope: ctx.scopes[0]!.id as string,
949 resolveConnector: () =>1094 resolveConnector: () =>
950 resolveConnectorInput(sd, ctx, allowStdio).pipe(1095 resolveConnectorInput(sd, ctx, allowStdio).pipe(
951 Effect.flatMap((ci) => createMcpConnector(ci)),1096 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
15// Connection auth (only applies to remote sources)15// Connection auth (only applies to remote sources)
16// ---------------------------------------------------------------------------16// ---------------------------------------------------------------------------
17 17
18/** JSON object loosely typed — used for opaque OAuth state we just round-trip. */
19const JsonObject = Schema.Record({ key: Schema.String, value: Schema.Unknown });
20
18export const McpConnectionAuth = Schema.Union(21export const McpConnectionAuth = Schema.Union(
19 Schema.Struct({ kind: Schema.Literal("none") }),22 Schema.Struct({ kind: Schema.Literal("none") }),
20 Schema.Struct({23 Schema.Struct({
@@ -30,6 +33,27 @@ export const McpConnectionAuth = Schema.Union( · 9 unmodified lines
30 tokenType: Schema.optionalWith(Schema.String, { default: () => "Bearer" }),33 tokenType: Schema.optionalWith(Schema.String, { default: () => "Bearer" }),
31 expiresAt: Schema.NullOr(Schema.Number),34 expiresAt: Schema.NullOr(Schema.Number),
32 scope: Schema.NullOr(Schema.String),35 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 }),
33 }),57 }),
34);58);
35export type McpConnectionAuth = typeof McpConnectionAuth.Type;59export type McpConnectionAuth = typeof McpConnectionAuth.Type;