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.
Unified · Split
| @@ -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 | ||
| 27 | const JsonObject = Schema.Record({ key: Schema.String, value: Schema.Unknown }); | ||
| 28 | |||
| 27 | const AuthPayload = Schema.Union( | 29 | const 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 | ||
| 113 | const CompleteOAuthPayload = Schema.Struct({ | 126 | const 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 | // --------------------------------------------------------------------------- |
| @@ -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 | ) |
| @@ -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. | ||
| 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 | |||
| 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 | ||
| 64 | type ProbeResult = { | 86 | type 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( |
| @@ -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 | ||
| 37 | const connectionCacheKey = (sd: McpStoredSourceData): string => | 37 | const 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 through | 52 | // 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 pattern | 192 | // Register the connector for the cache lookup (side-channel pattern |
| @@ -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 | }); |
| 41 | export type McpOAuthSession = typeof McpOAuthSession.Type; | 54 | export 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() { |
| @@ -1,10 +1,17 @@ | |||
| 1 | import { Duration, Effect, Exit, Scope, ScopedCache } from "effect"; | 1 | import { Duration, Effect, Exit, Scope, ScopedCache } from "effect"; |
| 2 | 2 | ||
| 3 | import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; | 3 | import type { |
| 4 | import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; | 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"; | ||
| 5 | 11 | ||
| 6 | import { | 12 | import { |
| 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 | ||
| 94 | export interface McpOAuthStartResponse { | 125 | export 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 | ||
| 113 | export interface McpProbeResult { | 150 | export 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 | ||
| 171 | const makeOAuthProvider = ( | 208 | interface 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> => ({ | 227 | const 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 | ||
| 207 | const remoteConnectionError = (message: string) => | 284 | const 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.refreshTokenSecretId | 340 | const refreshToken = auth.refreshTokenSecretId |
| 264 | ? (yield* ctx.secrets.get(auth.refreshTokenSecretId)) ?? undefined | 341 | ? (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.storage | 881 | 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 the | 933 | // Token storage is fully driven by the session: scope and |
| 800 | // innermost scope. On a single-scope executor that's the | 934 | // secret ids were chosen at startOAuth time and pinned to |
| 801 | // only scope; on a per-user stack `[user, org]` it's the | 935 | // the row. That keeps shadowing deterministic — every |
| 802 | // user scope — so tokens shadow org-level fallbacks for | 936 | // 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.secrets | 941 | 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.secrets | 959 | 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)), |
| @@ -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. */ | ||
| 19 | const JsonObject = Schema.Record({ key: Schema.String, value: Schema.Unknown }); | ||
| 20 | |||
| 18 | export const McpConnectionAuth = Schema.Union( | 21 | export 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 | ); |
| 35 | export type McpConnectionAuth = typeof McpConnectionAuth.Type; | 59 | export type McpConnectionAuth = typeof McpConnectionAuth.Type; |