Unified · Split
| @@ -2,6 +2,10 @@ import { httpRouter } from "convex/server"; · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import { controlPlaneHttpHandler } from "./controlPlane"; |
| 4 | 4 | import { mcpHandler } from "./mcp"; |
| 5 | +import { | |
| 6 | + oauthAuthorizationServerHandler, | |
| 7 | + oauthProtectedResourceHandler, | |
| 8 | +} from "./mcp_auth"; | |
| 5 | 9 | import { handleToolCallHttp } from "./runtimeCallbacks"; |
| 6 | 10 | |
| 7 | 11 | const http = httpRouter(); |
| @@ -9,6 +13,16 @@ const http = httpRouter(); · 1 unmodified line | ||
| 9 | 13 | http.route({ path: "/v1/mcp", method: "POST", handler: mcpHandler }); |
| 10 | 14 | http.route({ path: "/v1/mcp", method: "GET", handler: mcpHandler }); |
| 11 | 15 | http.route({ path: "/v1/mcp", method: "DELETE", handler: mcpHandler }); |
| 16 | +http.route({ | |
| 17 | + path: "/.well-known/oauth-protected-resource", | |
| 18 | + method: "GET", | |
| 19 | + handler: oauthProtectedResourceHandler, | |
| 20 | +}); | |
| 21 | +http.route({ | |
| 22 | + path: "/.well-known/oauth-authorization-server", | |
| 23 | + method: "GET", | |
| 24 | + handler: oauthAuthorizationServerHandler, | |
| 25 | +}); | |
| 12 | 26 | http.route({ |
| 13 | 27 | path: "/v1/runtime/tool-call", |
| 14 | 28 | method: "POST", |
| @@ -3,8 +3,13 @@ import { createExecutorRunClient } from "@executor-v2/sdk"; · 2 unmodified lines | ||
| 3 | 3 | import * as Effect from "effect/Effect"; |
| 4 | 4 | |
| 5 | 5 | import { internal } from "./_generated/api"; |
| 6 | −import { httpAction } from "./_generated/server"; | |
| 6 | +import { httpAction, type ActionCtx } from "./_generated/server"; | |
| 7 | 7 | import { executeRunImpl } from "./executor"; |
| 8 | +import { | |
| 9 | + getMcpAuthConfig, | |
| 10 | + unauthorizedMcpResponse, | |
| 11 | + verifyMcpToken, | |
| 12 | +} from "./mcp_auth"; | |
| 8 | 13 | import { createConvexSourceToolRegistry } from "./source_tool_registry"; |
| 9 | 14 | |
| 10 | 15 | const readConfiguredWorkspaceId = (value: string | undefined): string => { |
| @@ -12,10 +17,96 @@ const readConfiguredWorkspaceId = (value: string | undefined): string => { · 1 unmodified line | ||
| 12 | 17 | return normalized && normalized.length > 0 ? normalized : "ws_local"; |
| 13 | 18 | }; |
| 14 | 19 | |
| 15 | −const workspaceId = readConfiguredWorkspaceId(process.env.CONVEX_WORKSPACE_ID); | |
| 20 | +const readWorkspaceIdFromRequest = (request: Request): string | null => { | |
| 21 | + const value = new URL(request.url).searchParams.get("workspaceId")?.trim(); | |
| 22 | + return value && value.length > 0 ? value : null; | |
| 23 | +}; | |
| 24 | + | |
| 25 | +const readRequestedWorkspaceId = (request: Request, fallbackWorkspaceId: string): string => | |
| 26 | + readWorkspaceIdFromRequest(request) ?? fallbackWorkspaceId; | |
| 27 | + | |
| 28 | +const hasWorkspaceAccess = async ( | |
| 29 | + ctx: ActionCtx, | |
| 30 | + workspaceId: string, | |
| 31 | + accountId: string, | |
| 32 | +): Promise<boolean> => { | |
| 33 | + try { | |
| 34 | + const workspace = (await ctx.runQuery( | |
| 35 | + runtimeInternal.control_plane.actor.getWorkspaceForActor, | |
| 36 | + { workspaceId }, | |
| 37 | + )) as { | |
| 38 | + organizationId: string | null; | |
| 39 | + createdByAccountId: string | null; | |
| 40 | + } | null; | |
| 41 | + | |
| 42 | + if (!workspace) { | |
| 43 | + return false; | |
| 44 | + } | |
| 45 | + | |
| 46 | + if (workspace.createdByAccountId === accountId) { | |
| 47 | + return true; | |
| 48 | + } | |
| 49 | + | |
| 50 | + if (!workspace.organizationId) { | |
| 51 | + return false; | |
| 52 | + } | |
| 53 | + | |
| 54 | + const memberships = (await ctx.runQuery( | |
| 55 | + runtimeInternal.control_plane.actor.listOrganizationMembershipsForActor, | |
| 56 | + { accountId }, | |
| 57 | + )) as Array<{ | |
| 58 | + organizationId: string; | |
| 59 | + }>; | |
| 60 | + | |
| 61 | + return memberships.some((membership) => membership.organizationId === workspace.organizationId); | |
| 62 | + } catch { | |
| 63 | + return false; | |
| 64 | + } | |
| 65 | +}; | |
| 66 | + | |
| 67 | +const fallbackWorkspaceId = readConfiguredWorkspaceId(process.env.CONVEX_WORKSPACE_ID); | |
| 16 | 68 | const runtimeInternal = internal as any; |
| 17 | 69 | |
| 18 | 70 | export const mcpHandler = httpAction(async (ctx, request) => { |
| 71 | + const mcpAuthConfig = getMcpAuthConfig(); | |
| 72 | + const requestWorkspaceId = readWorkspaceIdFromRequest(request); | |
| 73 | + const workspaceId = readRequestedWorkspaceId(request, fallbackWorkspaceId); | |
| 74 | + | |
| 75 | + if (!mcpAuthConfig.enabled) { | |
| 76 | + if (mcpAuthConfig.required) { | |
| 77 | + return Response.json( | |
| 78 | + { | |
| 79 | + error: "MCP OAuth must be configured for cloud deployments", | |
| 80 | + }, | |
| 81 | + { status: 503 }, | |
| 82 | + ); | |
| 83 | + } | |
| 84 | + } else { | |
| 85 | + const auth = await verifyMcpToken(request, mcpAuthConfig); | |
| 86 | + if (!auth) { | |
| 87 | + return unauthorizedMcpResponse(request, "No valid bearer token provided."); | |
| 88 | + } | |
| 89 | + | |
| 90 | + if (!requestWorkspaceId) { | |
| 91 | + return Response.json( | |
| 92 | + { | |
| 93 | + error: "workspaceId query parameter is required when MCP OAuth is enabled", | |
| 94 | + }, | |
| 95 | + { status: 400 }, | |
| 96 | + ); | |
| 97 | + } | |
| 98 | + | |
| 99 | + const authorized = await hasWorkspaceAccess(ctx, requestWorkspaceId, auth.subject); | |
| 100 | + if (!authorized) { | |
| 101 | + return Response.json( | |
| 102 | + { | |
| 103 | + error: "Workspace authorization failed", | |
| 104 | + }, | |
| 105 | + { status: 403 }, | |
| 106 | + ); | |
| 107 | + } | |
| 108 | + } | |
| 109 | + | |
| 19 | 110 | const toolRegistry = createConvexSourceToolRegistry(ctx, workspaceId); |
| 20 | 111 | |
| 21 | 112 | const runClient = createExecutorRunClient(async (input) => { |
| @@ -0,0 +1,216 @@ | ||
| 1 | +import { createRemoteJWKSet, jwtVerify } from "jose"; | |
| 2 | + | |
| 3 | +import { httpAction } from "./_generated/server"; | |
| 4 | + | |
| 5 | +export const MCP_PATH = "/v1/mcp"; | |
| 6 | + | |
| 7 | +type McpAuthConfig = { | |
| 8 | + required: boolean; | |
| 9 | + enabled: boolean; | |
| 10 | + authorizationServer: string | null; | |
| 11 | + jwks: ReturnType<typeof createRemoteJWKSet> | null; | |
| 12 | +}; | |
| 13 | + | |
| 14 | +type VerifiedMcpToken = { | |
| 15 | + provider: "workos"; | |
| 16 | + subject: string; | |
| 17 | +}; | |
| 18 | + | |
| 19 | +const isTruthyEnvValue = (value: string | undefined): boolean => { | |
| 20 | + const normalized = value?.trim().toLowerCase(); | |
| 21 | + return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; | |
| 22 | +}; | |
| 23 | + | |
| 24 | +const mcpAuthRequired = (): boolean => { | |
| 25 | + const deploymentMode = (process.env.EXECUTOR_DEPLOYMENT_MODE ?? "").trim().toLowerCase(); | |
| 26 | + if ( | |
| 27 | + deploymentMode === "cloud" | |
| 28 | + || deploymentMode === "hosted" | |
| 29 | + || deploymentMode === "production" | |
| 30 | + || deploymentMode === "prod" | |
| 31 | + ) { | |
| 32 | + return true; | |
| 33 | + } | |
| 34 | + | |
| 35 | + if ( | |
| 36 | + deploymentMode === "self-hosted" | |
| 37 | + || deploymentMode === "self_hosted" | |
| 38 | + || deploymentMode === "selfhosted" | |
| 39 | + ) { | |
| 40 | + return false; | |
| 41 | + } | |
| 42 | + | |
| 43 | + return isTruthyEnvValue(process.env.EXECUTOR_ENFORCE_MCP_AUTH); | |
| 44 | +}; | |
| 45 | + | |
| 46 | +const getMcpAuthorizationServer = (): string | null => | |
| 47 | + process.env.MCP_AUTHORIZATION_SERVER | |
| 48 | + ?? process.env.MCP_AUTHORIZATION_SERVER_URL | |
| 49 | + ?? process.env.WORKOS_AUTHKIT_ISSUER | |
| 50 | + ?? process.env.WORKOS_AUTHKIT_DOMAIN | |
| 51 | + ?? null; | |
| 52 | + | |
| 53 | +export const getMcpAuthConfig = (): McpAuthConfig => { | |
| 54 | + const required = mcpAuthRequired(); | |
| 55 | + const authorizationServer = getMcpAuthorizationServer(); | |
| 56 | + | |
| 57 | + if (!authorizationServer) { | |
| 58 | + return { | |
| 59 | + required, | |
| 60 | + enabled: false, | |
| 61 | + authorizationServer: null, | |
| 62 | + jwks: null, | |
| 63 | + }; | |
| 64 | + } | |
| 65 | + | |
| 66 | + return { | |
| 67 | + required, | |
| 68 | + enabled: true, | |
| 69 | + authorizationServer, | |
| 70 | + jwks: createRemoteJWKSet(new URL("/oauth2/jwks", authorizationServer)), | |
| 71 | + }; | |
| 72 | +}; | |
| 73 | + | |
| 74 | +const parseBearerToken = (request: Request): string | null => { | |
| 75 | + const header = request.headers.get("authorization"); | |
| 76 | + if (!header || !header.startsWith("Bearer ")) { | |
| 77 | + return null; | |
| 78 | + } | |
| 79 | + | |
| 80 | + const token = header.slice("Bearer ".length).trim(); | |
| 81 | + return token.length > 0 ? token : null; | |
| 82 | +}; | |
| 83 | + | |
| 84 | +const resourceMetadataUrl = (request: Request): string => { | |
| 85 | + const url = new URL(request.url); | |
| 86 | + const metadata = new URL("/.well-known/oauth-protected-resource", url.origin); | |
| 87 | + metadata.search = url.search; | |
| 88 | + | |
| 89 | + const resource = new URL(url.pathname, url.origin); | |
| 90 | + resource.search = url.search; | |
| 91 | + metadata.searchParams.set("resource", resource.toString()); | |
| 92 | + | |
| 93 | + return metadata.toString(); | |
| 94 | +}; | |
| 95 | + | |
| 96 | +export const unauthorizedMcpResponse = (request: Request, message: string): Response => { | |
| 97 | + const challenge = [ | |
| 98 | + 'Bearer error="unauthorized"', | |
| 99 | + 'error_description="Authorization needed"', | |
| 100 | + `resource_metadata="${resourceMetadataUrl(request)}"`, | |
| 101 | + ].join(", "); | |
| 102 | + | |
| 103 | + return Response.json( | |
| 104 | + { error: message }, | |
| 105 | + { | |
| 106 | + status: 401, | |
| 107 | + headers: { | |
| 108 | + "WWW-Authenticate": challenge, | |
| 109 | + }, | |
| 110 | + }, | |
| 111 | + ); | |
| 112 | +}; | |
| 113 | + | |
| 114 | +export const verifyMcpToken = async ( | |
| 115 | + request: Request, | |
| 116 | + config: McpAuthConfig, | |
| 117 | +): Promise<VerifiedMcpToken | null> => { | |
| 118 | + if (!config.enabled || !config.jwks || !config.authorizationServer) { | |
| 119 | + return null; | |
| 120 | + } | |
| 121 | + | |
| 122 | + const token = parseBearerToken(request); | |
| 123 | + if (!token) { | |
| 124 | + return null; | |
| 125 | + } | |
| 126 | + | |
| 127 | + try { | |
| 128 | + const { payload } = await jwtVerify(token, config.jwks, { | |
| 129 | + issuer: config.authorizationServer, | |
| 130 | + }); | |
| 131 | + | |
| 132 | + if (typeof payload.sub !== "string" || payload.sub.length === 0) { | |
| 133 | + return null; | |
| 134 | + } | |
| 135 | + | |
| 136 | + const providerClaim = typeof payload.provider === "string" ? payload.provider : undefined; | |
| 137 | + if (providerClaim === "anonymous") { | |
| 138 | + return null; | |
| 139 | + } | |
| 140 | + | |
| 141 | + return { | |
| 142 | + provider: "workos", | |
| 143 | + subject: payload.sub, | |
| 144 | + }; | |
| 145 | + } catch (error) { | |
| 146 | + console.error("MCP token verification failed:", error instanceof Error ? error.message : String(error)); | |
| 147 | + return null; | |
| 148 | + } | |
| 149 | +}; | |
| 150 | + | |
| 151 | +const errorResponseForMissingConfig = (config: McpAuthConfig): Response => | |
| 152 | + Response.json( | |
| 153 | + { | |
| 154 | + error: config.required | |
| 155 | + ? "MCP OAuth must be configured for cloud deployments" | |
| 156 | + : "MCP OAuth is not configured", | |
| 157 | + }, | |
| 158 | + { status: config.required ? 503 : 404 }, | |
| 159 | + ); | |
| 160 | + | |
| 161 | +export const oauthProtectedResourceHandler = httpAction(async (_ctx, request) => { | |
| 162 | + const config = getMcpAuthConfig(); | |
| 163 | + if (!config.enabled || !config.authorizationServer) { | |
| 164 | + return errorResponseForMissingConfig(config); | |
| 165 | + } | |
| 166 | + | |
| 167 | + const url = new URL(request.url); | |
| 168 | + let resource = new URL(MCP_PATH, url.origin); | |
| 169 | + resource.search = url.search; | |
| 170 | + | |
| 171 | + const resourceHint = url.searchParams.get("resource"); | |
| 172 | + if (resourceHint) { | |
| 173 | + try { | |
| 174 | + const parsed = new URL(resourceHint); | |
| 175 | + if (parsed.origin !== url.origin) { | |
| 176 | + return Response.json({ error: "resource hint origin must match this server" }, { status: 400 }); | |
| 177 | + } | |
| 178 | + | |
| 179 | + if (parsed.pathname === MCP_PATH) { | |
| 180 | + resource = new URL(MCP_PATH, url.origin); | |
| 181 | + resource.search = parsed.search; | |
| 182 | + } | |
| 183 | + } catch { | |
| 184 | + return Response.json({ error: "Invalid resource hint" }, { status: 400 }); | |
| 185 | + } | |
| 186 | + } | |
| 187 | + | |
| 188 | + return Response.json({ | |
| 189 | + resource: resource.toString(), | |
| 190 | + authorization_servers: [config.authorizationServer], | |
| 191 | + bearer_methods_supported: ["header"], | |
| 192 | + }); | |
| 193 | +}); | |
| 194 | + | |
| 195 | +export const oauthAuthorizationServerHandler = httpAction(async (_ctx, _request) => { | |
| 196 | + const config = getMcpAuthConfig(); | |
| 197 | + if (!config.enabled || !config.authorizationServer) { | |
| 198 | + return errorResponseForMissingConfig(config); | |
| 199 | + } | |
| 200 | + | |
| 201 | + const upstream = new URL("/.well-known/oauth-authorization-server", config.authorizationServer); | |
| 202 | + const response = await fetch(upstream.toString(), { | |
| 203 | + headers: { | |
| 204 | + accept: "application/json", | |
| 205 | + }, | |
| 206 | + }); | |
| 207 | + | |
| 208 | + const body = await response.text(); | |
| 209 | + | |
| 210 | + return new Response(body, { | |
| 211 | + status: response.status, | |
| 212 | + headers: { | |
| 213 | + "content-type": response.headers.get("content-type") ?? "application/json", | |
| 214 | + }, | |
| 215 | + }); | |
| 216 | +}); | |
| @@ -266,6 +266,139 @@ const safeJsonParse = (value: string): unknown => { · 265 unmodified lines | ||
| 266 | 266 | } |
| 267 | 267 | }; |
| 268 | 268 | |
| 269 | +const normalizeHeaderString = (value: unknown): string | null => { | |
| 270 | + if (typeof value !== "string") { | |
| 271 | + return null; | |
| 272 | + } | |
| 273 | + | |
| 274 | + const trimmed = value.trim(); | |
| 275 | + return trimmed.length > 0 ? trimmed : null; | |
| 276 | +}; | |
| 277 | + | |
| 278 | +const parseSourceConfig = (source: Source): Record<string, unknown> => | |
| 279 | + jsonObjectFromUnknown(safeJsonParse(source.configJson)); | |
| 280 | + | |
| 281 | +const collectConfiguredHeadersFromSourceConfig = ( | |
| 282 | + config: Record<string, unknown>, | |
| 283 | +): Record<string, string> => { | |
| 284 | + const headers = jsonObjectFromUnknown(config.headers); | |
| 285 | + const nextHeaders: Record<string, string> = {}; | |
| 286 | + | |
| 287 | + for (const [rawKey, rawValue] of Object.entries(headers)) { | |
| 288 | + const key = normalizeHeaderString(rawKey); | |
| 289 | + const value = normalizeHeaderString(rawValue); | |
| 290 | + if (key && value) { | |
| 291 | + nextHeaders[key] = value; | |
| 292 | + } | |
| 293 | + } | |
| 294 | + | |
| 295 | + return nextHeaders; | |
| 296 | +}; | |
| 297 | + | |
| 298 | +const collectAuthHeadersFromSourceConfig = ( | |
| 299 | + config: Record<string, unknown>, | |
| 300 | +): Record<string, string> => { | |
| 301 | + const auth = jsonObjectFromUnknown(config.auth); | |
| 302 | + const authType = normalizeHeaderString(auth.type)?.toLowerCase(); | |
| 303 | + const nextHeaders: Record<string, string> = {}; | |
| 304 | + | |
| 305 | + if (authType === "bearer") { | |
| 306 | + const token = normalizeHeaderString(auth.token) ?? normalizeHeaderString(auth.value); | |
| 307 | + if (token) { | |
| 308 | + nextHeaders.Authorization = `Bearer ${token}`; | |
| 309 | + } | |
| 310 | + } | |
| 311 | + | |
| 312 | + if (authType === "apikey" || authType === "api_key") { | |
| 313 | + const token = normalizeHeaderString(auth.value) ?? normalizeHeaderString(auth.token); | |
| 314 | + if (token) { | |
| 315 | + const headerName = normalizeHeaderString(auth.header) ?? "Authorization"; | |
| 316 | + nextHeaders[headerName] = token; | |
| 317 | + } | |
| 318 | + } | |
| 319 | + | |
| 320 | + if (authType === "basic") { | |
| 321 | + const username = normalizeHeaderString(auth.username); | |
| 322 | + const password = normalizeHeaderString(auth.password); | |
| 323 | + if (username && password && typeof btoa === "function") { | |
| 324 | + nextHeaders.Authorization = `Basic ${btoa(`${username}:${password}`)}`; | |
| 325 | + } | |
| 326 | + } | |
| 327 | + | |
| 328 | + return nextHeaders; | |
| 329 | +}; | |
| 330 | + | |
| 331 | +const mergeHeaders = (...sets: ReadonlyArray<Record<string, string>>): Record<string, string> => { | |
| 332 | + const merged: Record<string, string> = {}; | |
| 333 | + const keyByLower = new Map<string, string>(); | |
| 334 | + | |
| 335 | + for (const headers of sets) { | |
| 336 | + for (const [rawKey, rawValue] of Object.entries(headers)) { | |
| 337 | + const key = rawKey.trim(); | |
| 338 | + const value = rawValue.trim(); | |
| 339 | + if (key.length === 0 || value.length === 0) { | |
| 340 | + continue; | |
| 341 | + } | |
| 342 | + | |
| 343 | + const normalizedKey = key.toLowerCase(); | |
| 344 | + const existingKey = keyByLower.get(normalizedKey); | |
| 345 | + if (existingKey && existingKey !== key) { | |
| 346 | + delete merged[existingKey]; | |
| 347 | + } | |
| 348 | + | |
| 349 | + keyByLower.set(normalizedKey, key); | |
| 350 | + merged[key] = value; | |
| 351 | + } | |
| 352 | + } | |
| 353 | + | |
| 354 | + return merged; | |
| 355 | +}; | |
| 356 | + | |
| 357 | +const resolveSourceCredentialHeadersForInvocation = async ( | |
| 358 | + ctx: ActionCtx, | |
| 359 | + source: Source, | |
| 360 | +): Promise<Record<string, string>> => { | |
| 361 | + try { | |
| 362 | + const resolved = (await ctx.runQuery( | |
| 363 | + runtimeInternal.control_plane.credentials.resolveSourceCredentialHeadersForIngest, | |
| 364 | + { | |
| 365 | + workspaceId: source.workspaceId, | |
| 366 | + sourceId: source.id, | |
| 367 | + sourceName: source.name, | |
| 368 | + sourceEndpoint: source.endpoint, | |
| 369 | + }, | |
| 370 | + )) as { headers?: unknown }; | |
| 371 | + | |
| 372 | + const headers = jsonObjectFromUnknown(resolved.headers); | |
| 373 | + const normalized: Record<string, string> = {}; | |
| 374 | + | |
| 375 | + for (const [rawKey, rawValue] of Object.entries(headers)) { | |
| 376 | + const key = normalizeHeaderString(rawKey); | |
| 377 | + const value = normalizeHeaderString(rawValue); | |
| 378 | + if (key && value) { | |
| 379 | + normalized[key] = value; | |
| 380 | + } | |
| 381 | + } | |
| 382 | + | |
| 383 | + return normalized; | |
| 384 | + } catch { | |
| 385 | + return {}; | |
| 386 | + } | |
| 387 | +}; | |
| 388 | + | |
| 389 | +const resolveSourceHeadersForInvocation = async ( | |
| 390 | + ctx: ActionCtx, | |
| 391 | + source: Source, | |
| 392 | +): Promise<Record<string, string>> => { | |
| 393 | + const config = parseSourceConfig(source); | |
| 394 | + | |
| 395 | + return mergeHeaders( | |
| 396 | + collectConfiguredHeadersFromSourceConfig(config), | |
| 397 | + collectAuthHeadersFromSourceConfig(config), | |
| 398 | + await resolveSourceCredentialHeadersForInvocation(ctx, source), | |
| 399 | + ); | |
| 400 | +}; | |
| 401 | + | |
| 269 | 402 | const headersToRecord = (headers: Headers): Record<string, string> => { |
| 270 | 403 | const record: Record<string, string> = {}; |
| 271 | 404 | headers.forEach((value, key) => { |
| @@ -829,11 +962,16 @@ const createConvexGraphqlToolProvider = (ctx: ActionCtx): ToolProvider => ({ · 557 unmodified lines | ||
| 829 | 962 | operationName = payload.fieldName; |
| 830 | 963 | } |
| 831 | 964 | |
| 965 | + const sourceHeaders = await resolveSourceHeadersForInvocation(ctx, input.source); | |
| 966 | + | |
| 832 | 967 | const response = await fetch(payload.endpoint, { |
| 833 | 968 | method: "POST", |
| 834 | − headers: { | |
| 835 | − "content-type": "application/json", | |
| 836 | − }, | |
| 969 | + headers: mergeHeaders( | |
| 970 | + { | |
| 971 | + "content-type": "application/json", | |
| 972 | + }, | |
| 973 | + sourceHeaders, | |
| 974 | + ), | |
| 837 | 975 | body: JSON.stringify({ |
| 838 | 976 | query, |
| 839 | 977 | variables, |
| @@ -878,12 +1016,17 @@ const postMcpJsonRpc = async ( · 38 unmodified lines | ||
| 878 | 1016 | endpoint: URL, |
| 879 | 1017 | body: unknown, |
| 880 | 1018 | sessionId: string | null, |
| 1019 | + sourceHeaders: Record<string, string>, | |
| 881 | 1020 | ): Promise<Response> => { |
| 882 | 1021 | const headers = new Headers({ |
| 883 | 1022 | "content-type": "application/json", |
| 884 | 1023 | accept: "application/json, text/event-stream", |
| 885 | 1024 | }); |
| 886 | 1025 | |
| 1026 | + for (const [key, value] of Object.entries(sourceHeaders)) { | |
| 1027 | + headers.set(key, value); | |
| 1028 | + } | |
| 1029 | + | |
| 887 | 1030 | if (sessionId && sessionId.trim().length > 0) { |
| 888 | 1031 | headers.set("mcp-session-id", sessionId); |
| 889 | 1032 | } |
| @@ -934,8 +1077,13 @@ const createConvexMcpToolProvider = (ctx: ActionCtx): ToolProvider => ({ · 44 unmodified lines | ||
| 934 | 1077 | invoke: (input) => |
| 935 | 1078 | Effect.tryPromise({ |
| 936 | 1079 | try: async () => { |
| 1080 | + if (!input.source) { | |
| 1081 | + throw new Error("MCP provider requires a source"); | |
| 1082 | + } | |
| 1083 | + | |
| 937 | 1084 | const payload = decodeMcpInvocationPayload(input.tool.providerPayload); |
| 938 | 1085 | const args = normalizeGraphqlInvokeInput(input.args); |
| 1086 | + const sourceHeaders = await resolveSourceHeadersForInvocation(ctx, input.source); | |
| 939 | 1087 | |
| 940 | 1088 | if (payload.transport !== "streamable-http") { |
| 941 | 1089 | throw new Error( |
| @@ -960,6 +1108,7 @@ const createConvexMcpToolProvider = (ctx: ActionCtx): ToolProvider => ({ · 18 unmodified lines | ||
| 960 | 1108 | }, |
| 961 | 1109 | }, |
| 962 | 1110 | null, |
| 1111 | + sourceHeaders, | |
| 963 | 1112 | ); |
| 964 | 1113 | const initializeBody = await decodeMcpJsonResponse(initializeResponse); |
| 965 | 1114 | |
| @@ -984,6 +1133,7 @@ const createConvexMcpToolProvider = (ctx: ActionCtx): ToolProvider => ({ · 18 unmodified lines | ||
| 984 | 1133 | params: {}, |
| 985 | 1134 | }, |
| 986 | 1135 | sessionId, |
| 1136 | + sourceHeaders, | |
| 987 | 1137 | ); |
| 988 | 1138 | |
| 989 | 1139 | const callResponse = await postMcpJsonRpc( |
| @@ -998,6 +1148,7 @@ const createConvexMcpToolProvider = (ctx: ActionCtx): ToolProvider => ({ · 8 unmodified lines | ||
| 998 | 1148 | }, |
| 999 | 1149 | }, |
| 1000 | 1150 | sessionId, |
| 1151 | + sourceHeaders, | |
| 1001 | 1152 | ); |
| 1002 | 1153 | const callBody = await decodeMcpJsonResponse(callResponse); |
| 1003 | 1154 | |
| @@ -0,0 +1,169 @@ | ||
| 1 | +import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; | |
| 2 | +import type { NextRequest } from "next/server"; | |
| 3 | +import { NextResponse } from "next/server"; | |
| 4 | + | |
| 5 | +import { fetchMcpOAuth } from "../../../../lib/mcp/oauth-fetch"; | |
| 6 | +import { getExternalOrigin, isExternalHttps } from "../../../../lib/mcp/oauth-request"; | |
| 7 | +import { parseMcpSourceUrl } from "../../../../lib/mcp/oauth-url"; | |
| 8 | +import { | |
| 9 | + buildPendingCookieName, | |
| 10 | + decodePendingCookieValue, | |
| 11 | + encodePopupResultCookieValue, | |
| 12 | + MCP_OAUTH_RESULT_COOKIE, | |
| 13 | + McpPopupOAuthProvider, | |
| 14 | + type McpOAuthPopupResult, | |
| 15 | +} from "../../../../lib/mcp/oauth-provider"; | |
| 16 | + | |
| 17 | +const MCP_OAUTH_CALLBACK_FLOW_TIMEOUT_MS = 75_000; | |
| 18 | +const MCP_OAUTH_CALLBACK_REQUEST_TIMEOUT_MS = 20_000; | |
| 19 | + | |
| 20 | +const withTimeout = async <T>( | |
| 21 | + factory: () => Promise<T>, | |
| 22 | + timeoutMs: number, | |
| 23 | + label: string, | |
| 24 | +): Promise<T> => | |
| 25 | + await new Promise<T>((resolve, reject) => { | |
| 26 | + const timeoutId = setTimeout(() => { | |
| 27 | + reject(new Error(`${label} timed out after ${timeoutMs}ms`)); | |
| 28 | + }, timeoutMs); | |
| 29 | + | |
| 30 | + factory().then( | |
| 31 | + (value) => { | |
| 32 | + clearTimeout(timeoutId); | |
| 33 | + resolve(value); | |
| 34 | + }, | |
| 35 | + (error) => { | |
| 36 | + clearTimeout(timeoutId); | |
| 37 | + reject(error); | |
| 38 | + }, | |
| 39 | + ); | |
| 40 | + }); | |
| 41 | + | |
| 42 | +const popupResultRedirect = ( | |
| 43 | + request: NextRequest, | |
| 44 | + pendingCookieName: string | null, | |
| 45 | + payload: McpOAuthPopupResult, | |
| 46 | +): Response => { | |
| 47 | + const origin = getExternalOrigin(request); | |
| 48 | + const response = NextResponse.redirect(new URL("/mcp/oauth/complete", origin)); | |
| 49 | + | |
| 50 | + response.cookies.set({ | |
| 51 | + name: MCP_OAUTH_RESULT_COOKIE, | |
| 52 | + value: encodePopupResultCookieValue(payload), | |
| 53 | + httpOnly: true, | |
| 54 | + secure: isExternalHttps(request), | |
| 55 | + sameSite: "lax", | |
| 56 | + maxAge: 2 * 60, | |
| 57 | + path: "/", | |
| 58 | + }); | |
| 59 | + | |
| 60 | + if (pendingCookieName) { | |
| 61 | + response.cookies.set({ | |
| 62 | + name: pendingCookieName, | |
| 63 | + value: "", | |
| 64 | + expires: new Date(0), | |
| 65 | + path: "/", | |
| 66 | + }); | |
| 67 | + } | |
| 68 | + | |
| 69 | + return response; | |
| 70 | +}; | |
| 71 | + | |
| 72 | +const toErrorMessage = (error: unknown, fallback: string): string => | |
| 73 | + error instanceof Error ? error.message : fallback; | |
| 74 | + | |
| 75 | +export async function GET(request: NextRequest): Promise<Response> { | |
| 76 | + const code = request.nextUrl.searchParams.get("code")?.trim() ?? ""; | |
| 77 | + const state = request.nextUrl.searchParams.get("state")?.trim() ?? ""; | |
| 78 | + const oauthError = request.nextUrl.searchParams.get("error")?.trim() ?? ""; | |
| 79 | + | |
| 80 | + if (!state) { | |
| 81 | + return popupResultRedirect(request, null, { | |
| 82 | + ok: false, | |
| 83 | + error: "Missing OAuth state", | |
| 84 | + }); | |
| 85 | + } | |
| 86 | + | |
| 87 | + const pendingCookieName = buildPendingCookieName(state); | |
| 88 | + const rawPending = request.cookies.get(pendingCookieName)?.value ?? ""; | |
| 89 | + const pending = rawPending ? decodePendingCookieValue(rawPending) : null; | |
| 90 | + | |
| 91 | + if (!pending) { | |
| 92 | + return popupResultRedirect(request, pendingCookieName, { | |
| 93 | + ok: false, | |
| 94 | + error: "OAuth session expired. Try connecting again.", | |
| 95 | + }); | |
| 96 | + } | |
| 97 | + | |
| 98 | + if (oauthError.length > 0) { | |
| 99 | + return popupResultRedirect(request, pendingCookieName, { | |
| 100 | + ok: false, | |
| 101 | + error: `OAuth error: ${oauthError}`, | |
| 102 | + }); | |
| 103 | + } | |
| 104 | + | |
| 105 | + if (code.length === 0) { | |
| 106 | + return popupResultRedirect(request, pendingCookieName, { | |
| 107 | + ok: false, | |
| 108 | + error: "Missing OAuth authorization code", | |
| 109 | + }); | |
| 110 | + } | |
| 111 | + | |
| 112 | + let sourceUrl: URL; | |
| 113 | + try { | |
| 114 | + sourceUrl = parseMcpSourceUrl(pending.sourceUrl); | |
| 115 | + } catch { | |
| 116 | + return popupResultRedirect(request, pendingCookieName, { | |
| 117 | + ok: false, | |
| 118 | + error: "Invalid MCP source URL", | |
| 119 | + }); | |
| 120 | + } | |
| 121 | + | |
| 122 | + const provider = new McpPopupOAuthProvider({ | |
| 123 | + redirectUrl: pending.redirectUrl, | |
| 124 | + state: pending.state, | |
| 125 | + codeVerifier: pending.codeVerifier, | |
| 126 | + clientInformation: pending.clientInformation, | |
| 127 | + }); | |
| 128 | + | |
| 129 | + try { | |
| 130 | + await withTimeout( | |
| 131 | + () => | |
| 132 | + auth(provider, { | |
| 133 | + serverUrl: sourceUrl, | |
| 134 | + authorizationCode: code, | |
| 135 | + fetchFn: (input, init) => | |
| 136 | + fetchMcpOAuth(input, init ?? {}, { | |
| 137 | + timeoutMs: MCP_OAUTH_CALLBACK_REQUEST_TIMEOUT_MS, | |
| 138 | + label: "OAuth callback request", | |
| 139 | + }), | |
| 140 | + }), | |
| 141 | + MCP_OAUTH_CALLBACK_FLOW_TIMEOUT_MS, | |
| 142 | + "OAuth callback", | |
| 143 | + ); | |
| 144 | + } catch (error) { | |
| 145 | + return popupResultRedirect(request, pendingCookieName, { | |
| 146 | + ok: false, | |
| 147 | + error: toErrorMessage(error, "Failed to finish OAuth"), | |
| 148 | + }); | |
| 149 | + } | |
| 150 | + | |
| 151 | + const tokens = provider.getTokens(); | |
| 152 | + const accessToken = tokens?.access_token?.trim() ?? ""; | |
| 153 | + | |
| 154 | + if (accessToken.length === 0) { | |
| 155 | + return popupResultRedirect(request, pendingCookieName, { | |
| 156 | + ok: false, | |
| 157 | + error: "OAuth completed without an access token", | |
| 158 | + }); | |
| 159 | + } | |
| 160 | + | |
| 161 | + return popupResultRedirect(request, pendingCookieName, { | |
| 162 | + ok: true, | |
| 163 | + sourceUrl: pending.sourceUrl, | |
| 164 | + accessToken, | |
| 165 | + refreshToken: tokens?.refresh_token, | |
| 166 | + scope: tokens?.scope, | |
| 167 | + expiresIn: typeof tokens?.expires_in === "number" ? tokens.expires_in : undefined, | |
| 168 | + }); | |
| 169 | +} | |
| @@ -0,0 +1,86 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useEffect, useRef, useState } from "react"; | |
| 4 | + | |
| 5 | +type PopupMessage = | |
| 6 | + | { | |
| 7 | + type: "executor-v2:mcp-oauth-result"; | |
| 8 | + ok: true; | |
| 9 | + sourceUrl: string; | |
| 10 | + payload: { | |
| 11 | + accessToken: string; | |
| 12 | + refreshToken?: string; | |
| 13 | + scope?: string; | |
| 14 | + expiresIn?: number; | |
| 15 | + }; | |
| 16 | + } | |
| 17 | + | { | |
| 18 | + type: "executor-v2:mcp-oauth-result"; | |
| 19 | + ok: false; | |
| 20 | + error: string; | |
| 21 | + }; | |
| 22 | + | |
| 23 | +const fallbackErrorMessage = (error: unknown): PopupMessage => ({ | |
| 24 | + type: "executor-v2:mcp-oauth-result", | |
| 25 | + ok: false, | |
| 26 | + error: error instanceof Error ? error.message : "Failed to finalize OAuth", | |
| 27 | +}); | |
| 28 | + | |
| 29 | +const fetchPopupResult = async (): Promise<PopupMessage> => { | |
| 30 | + const response = await fetch("/mcp/oauth/result", { | |
| 31 | + method: "GET", | |
| 32 | + cache: "no-store", | |
| 33 | + credentials: "same-origin", | |
| 34 | + }); | |
| 35 | + | |
| 36 | + const data = (await response.json()) as PopupMessage; | |
| 37 | + if (!data || data.type !== "executor-v2:mcp-oauth-result") { | |
| 38 | + throw new Error("Invalid OAuth completion payload"); | |
| 39 | + } | |
| 40 | + | |
| 41 | + return data; | |
| 42 | +}; | |
| 43 | + | |
| 44 | +export default function McpOAuthCompletePage() { | |
| 45 | + const sentRef = useRef(false); | |
| 46 | + const [statusText, setStatusText] = useState("Finishing OAuth"); | |
| 47 | + | |
| 48 | + useEffect(() => { | |
| 49 | + if (sentRef.current) { | |
| 50 | + return; | |
| 51 | + } | |
| 52 | + | |
| 53 | + sentRef.current = true; | |
| 54 | + | |
| 55 | + void fetchPopupResult() | |
| 56 | + .then((message) => { | |
| 57 | + setStatusText("OAuth complete"); | |
| 58 | + | |
| 59 | + if (window.opener) { | |
| 60 | + window.opener.postMessage(message, window.location.origin); | |
| 61 | + } | |
| 62 | + }) | |
| 63 | + .catch((error) => { | |
| 64 | + const message = fallbackErrorMessage(error); | |
| 65 | + setStatusText("OAuth failed"); | |
| 66 | + | |
| 67 | + if (window.opener) { | |
| 68 | + window.opener.postMessage(message, window.location.origin); | |
| 69 | + } | |
| 70 | + }) | |
| 71 | + .finally(() => { | |
| 72 | + window.close(); | |
| 73 | + }); | |
| 74 | + }, []); | |
| 75 | + | |
| 76 | + return ( | |
| 77 | + <main className="min-h-screen flex items-center justify-center bg-background p-6"> | |
| 78 | + <div className="text-center space-y-2"> | |
| 79 | + <h1 className="text-base font-medium">{statusText}</h1> | |
| 80 | + <p className="text-sm text-muted-foreground"> | |
| 81 | + You can close this window if it does not close automatically. | |
| 82 | + </p> | |
| 83 | + </div> | |
| 84 | + </main> | |
| 85 | + ); | |
| 86 | +} | |
| @@ -0,0 +1,178 @@ | ||
| 1 | +import { discoverOAuthProtectedResourceMetadata, extractWWWAuthenticateParams } from "@modelcontextprotocol/sdk/client/auth.js"; | |
| 2 | +import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js"; | |
| 3 | + | |
| 4 | +import { fetchMcpOAuth } from "../../../../lib/mcp/oauth-fetch"; | |
| 5 | +import { parseMcpSourceUrl } from "../../../../lib/mcp/oauth-url"; | |
| 6 | + | |
| 7 | +type DetectResponse = { | |
| 8 | + oauth: boolean; | |
| 9 | + authorizationServers: Array<string>; | |
| 10 | + detail?: string; | |
| 11 | +}; | |
| 12 | + | |
| 13 | +const MCP_OAUTH_CHALLENGE_PROBE_TIMEOUT_MS = 2_500; | |
| 14 | +const MCP_OAUTH_METADATA_TIMEOUT_MS = 12_000; | |
| 15 | + | |
| 16 | +const noStoreDetectJson = (payload: DetectResponse, status = 200): Response => | |
| 17 | + Response.json(payload, { | |
| 18 | + status, | |
| 19 | + headers: { | |
| 20 | + "cache-control": "no-store", | |
| 21 | + }, | |
| 22 | + }); | |
| 23 | + | |
| 24 | +const withTimeout = async <T>( | |
| 25 | + factory: () => Promise<T>, | |
| 26 | + timeoutMs: number, | |
| 27 | + label: string, | |
| 28 | +): Promise<T> => | |
| 29 | + await new Promise<T>((resolve, reject) => { | |
| 30 | + const timeoutId = setTimeout(() => { | |
| 31 | + reject(new Error(`${label} timed out after ${timeoutMs}ms`)); | |
| 32 | + }, timeoutMs); | |
| 33 | + | |
| 34 | + factory().then( | |
| 35 | + (value) => { | |
| 36 | + clearTimeout(timeoutId); | |
| 37 | + resolve(value); | |
| 38 | + }, | |
| 39 | + (error) => { | |
| 40 | + clearTimeout(timeoutId); | |
| 41 | + reject(error); | |
| 42 | + }, | |
| 43 | + ); | |
| 44 | + }); | |
| 45 | + | |
| 46 | +const probeOAuthChallenge = async (sourceUrl: URL): Promise<boolean> => { | |
| 47 | + const response = await fetchMcpOAuth( | |
| 48 | + sourceUrl, | |
| 49 | + { | |
| 50 | + method: "POST", | |
| 51 | + headers: { | |
| 52 | + "content-type": "application/json", | |
| 53 | + accept: "application/json, text/event-stream", | |
| 54 | + "mcp-protocol-version": LATEST_PROTOCOL_VERSION, | |
| 55 | + }, | |
| 56 | + body: JSON.stringify({ | |
| 57 | + jsonrpc: "2.0", | |
| 58 | + id: "oauth-detect", | |
| 59 | + method: "initialize", | |
| 60 | + params: { | |
| 61 | + protocolVersion: LATEST_PROTOCOL_VERSION, | |
| 62 | + capabilities: {}, | |
| 63 | + clientInfo: { | |
| 64 | + name: "Executor v2 OAuth Detector", | |
| 65 | + version: "1.0.0", | |
| 66 | + }, | |
| 67 | + }, | |
| 68 | + }), | |
| 69 | + }, | |
| 70 | + { | |
| 71 | + timeoutMs: MCP_OAUTH_CHALLENGE_PROBE_TIMEOUT_MS, | |
| 72 | + label: "OAuth challenge probe", | |
| 73 | + }, | |
| 74 | + ); | |
| 75 | + | |
| 76 | + const hasBearerChallenge = /^Bearer\s/i.test( | |
| 77 | + response.headers.get("WWW-Authenticate") ?? "", | |
| 78 | + ); | |
| 79 | + const challenge = extractWWWAuthenticateParams(response); | |
| 80 | + | |
| 81 | + await response.body?.cancel(); | |
| 82 | + | |
| 83 | + if ((response.status === 401 || response.status === 403) && hasBearerChallenge) { | |
| 84 | + return true; | |
| 85 | + } | |
| 86 | + | |
| 87 | + return Boolean(challenge.resourceMetadataUrl); | |
| 88 | +}; | |
| 89 | + | |
| 90 | +const toErrorDetail = (error: unknown, fallback: string): string => | |
| 91 | + error instanceof Error ? error.message : fallback; | |
| 92 | + | |
| 93 | +export async function GET(request: Request): Promise<Response> { | |
| 94 | + const requestUrl = new URL(request.url); | |
| 95 | + const sourceUrlRaw = requestUrl.searchParams.get("sourceUrl")?.trim() ?? ""; | |
| 96 | + | |
| 97 | + if (!sourceUrlRaw) { | |
| 98 | + return noStoreDetectJson( | |
| 99 | + { | |
| 100 | + oauth: false, | |
| 101 | + authorizationServers: [], | |
| 102 | + detail: "Missing sourceUrl", | |
| 103 | + }, | |
| 104 | + 400, | |
| 105 | + ); | |
| 106 | + } | |
| 107 | + | |
| 108 | + let sourceUrl: URL; | |
| 109 | + try { | |
| 110 | + sourceUrl = parseMcpSourceUrl(sourceUrlRaw); | |
| 111 | + } catch (error) { | |
| 112 | + return noStoreDetectJson( | |
| 113 | + { | |
| 114 | + oauth: false, | |
| 115 | + authorizationServers: [], | |
| 116 | + detail: toErrorDetail(error, "Invalid sourceUrl"), | |
| 117 | + }, | |
| 118 | + 400, | |
| 119 | + ); | |
| 120 | + } | |
| 121 | + | |
| 122 | + try { | |
| 123 | + const oauthByChallenge = await probeOAuthChallenge(sourceUrl); | |
| 124 | + if (oauthByChallenge) { | |
| 125 | + return noStoreDetectJson({ | |
| 126 | + oauth: true, | |
| 127 | + authorizationServers: [], | |
| 128 | + detail: "OAuth detected from endpoint challenge", | |
| 129 | + }); | |
| 130 | + } | |
| 131 | + } catch { | |
| 132 | + // Continue to metadata discovery. | |
| 133 | + } | |
| 134 | + | |
| 135 | + let metadata: unknown; | |
| 136 | + try { | |
| 137 | + metadata = await withTimeout( | |
| 138 | + () => | |
| 139 | + discoverOAuthProtectedResourceMetadata( | |
| 140 | + sourceUrl, | |
| 141 | + undefined, | |
| 142 | + (input, init) => | |
| 143 | + fetchMcpOAuth(input, init ?? {}, { | |
| 144 | + timeoutMs: MCP_OAUTH_METADATA_TIMEOUT_MS, | |
| 145 | + label: "OAuth metadata lookup", | |
| 146 | + }), | |
| 147 | + ), | |
| 148 | + MCP_OAUTH_METADATA_TIMEOUT_MS, | |
| 149 | + "OAuth metadata lookup", | |
| 150 | + ); | |
| 151 | + } catch (error) { | |
| 152 | + return noStoreDetectJson( | |
| 153 | + { | |
| 154 | + oauth: false, | |
| 155 | + authorizationServers: [], | |
| 156 | + detail: toErrorDetail(error, "OAuth metadata lookup failed"), | |
| 157 | + }, | |
| 158 | + 502, | |
| 159 | + ); | |
| 160 | + } | |
| 161 | + | |
| 162 | + const authorizationServersRaw = | |
| 163 | + metadata && typeof metadata === "object" && !Array.isArray(metadata) | |
| 164 | + ? (metadata as { authorization_servers?: unknown }).authorization_servers | |
| 165 | + : undefined; | |
| 166 | + | |
| 167 | + const authorizationServers = Array.isArray(authorizationServersRaw) | |
| 168 | + ? authorizationServersRaw | |
| 169 | + .filter((entry): entry is string => typeof entry === "string") | |
| 170 | + .map((entry) => entry.trim()) | |
| 171 | + .filter((entry) => entry.length > 0) | |
| 172 | + : []; | |
| 173 | + | |
| 174 | + return noStoreDetectJson({ | |
| 175 | + oauth: authorizationServers.length > 0, | |
| 176 | + authorizationServers, | |
| 177 | + }); | |
| 178 | +} | |
| @@ -0,0 +1,89 @@ | ||
| 1 | +import type { NextRequest } from "next/server"; | |
| 2 | +import { NextResponse } from "next/server"; | |
| 3 | + | |
| 4 | +import { | |
| 5 | + decodePopupResultCookieValue, | |
| 6 | + MCP_OAUTH_RESULT_COOKIE, | |
| 7 | + type McpOAuthPopupResult, | |
| 8 | +} from "../../../../lib/mcp/oauth-provider"; | |
| 9 | + | |
| 10 | +type PopupMessage = | |
| 11 | + | { | |
| 12 | + type: "executor-v2:mcp-oauth-result"; | |
| 13 | + ok: true; | |
| 14 | + sourceUrl: string; | |
| 15 | + payload: { | |
| 16 | + accessToken: string; | |
| 17 | + refreshToken?: string; | |
| 18 | + scope?: string; | |
| 19 | + expiresIn?: number; | |
| 20 | + }; | |
| 21 | + } | |
| 22 | + | { | |
| 23 | + type: "executor-v2:mcp-oauth-result"; | |
| 24 | + ok: false; | |
| 25 | + error: string; | |
| 26 | + }; | |
| 27 | + | |
| 28 | +const toPopupMessage = (result: McpOAuthPopupResult | null): PopupMessage => { | |
| 29 | + if (!result) { | |
| 30 | + return { | |
| 31 | + type: "executor-v2:mcp-oauth-result", | |
| 32 | + ok: false, | |
| 33 | + error: "OAuth result is missing or expired", | |
| 34 | + }; | |
| 35 | + } | |
| 36 | + | |
| 37 | + if (!result.ok) { | |
| 38 | + return { | |
| 39 | + type: "executor-v2:mcp-oauth-result", | |
| 40 | + ok: false, | |
| 41 | + error: result.error ?? "OAuth failed", | |
| 42 | + }; | |
| 43 | + } | |
| 44 | + | |
| 45 | + const accessToken = result.accessToken?.trim() ?? ""; | |
| 46 | + if (accessToken.length === 0) { | |
| 47 | + return { | |
| 48 | + type: "executor-v2:mcp-oauth-result", | |
| 49 | + ok: false, | |
| 50 | + error: "OAuth finished without an access token", | |
| 51 | + }; | |
| 52 | + } | |
| 53 | + | |
| 54 | + return { | |
| 55 | + type: "executor-v2:mcp-oauth-result", | |
| 56 | + ok: true, | |
| 57 | + sourceUrl: result.sourceUrl ?? "", | |
| 58 | + payload: { | |
| 59 | + accessToken, | |
| 60 | + ...(result.refreshToken ? { refreshToken: result.refreshToken } : {}), | |
| 61 | + ...(result.scope ? { scope: result.scope } : {}), | |
| 62 | + ...(typeof result.expiresIn === "number" | |
| 63 | + ? { expiresIn: result.expiresIn } | |
| 64 | + : {}), | |
| 65 | + }, | |
| 66 | + }; | |
| 67 | +}; | |
| 68 | + | |
| 69 | +export async function GET(request: NextRequest): Promise<Response> { | |
| 70 | + const encoded = request.cookies.get(MCP_OAUTH_RESULT_COOKIE)?.value ?? ""; | |
| 71 | + const parsed = encoded ? decodePopupResultCookieValue(encoded) : null; | |
| 72 | + const message = toPopupMessage(parsed); | |
| 73 | + | |
| 74 | + const response = NextResponse.json(message, { | |
| 75 | + status: message.ok ? 200 : 400, | |
| 76 | + headers: { | |
| 77 | + "cache-control": "no-store", | |
| 78 | + }, | |
| 79 | + }); | |
| 80 | + | |
| 81 | + response.cookies.set({ | |
| 82 | + name: MCP_OAUTH_RESULT_COOKIE, | |
| 83 | + value: "", | |
| 84 | + expires: new Date(0), | |
| 85 | + path: "/", | |
| 86 | + }); | |
| 87 | + | |
| 88 | + return response; | |
| 89 | +} | |
| @@ -0,0 +1,152 @@ | ||
| 1 | +import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; | |
| 2 | +import type { NextRequest } from "next/server"; | |
| 3 | +import { NextResponse } from "next/server"; | |
| 4 | + | |
| 5 | +import { fetchMcpOAuth } from "../../../../lib/mcp/oauth-fetch"; | |
| 6 | +import { getExternalOrigin, isExternalHttps } from "../../../../lib/mcp/oauth-request"; | |
| 7 | +import { parseMcpSourceUrl } from "../../../../lib/mcp/oauth-url"; | |
| 8 | +import { | |
| 9 | + buildPendingCookieName, | |
| 10 | + createOAuthState, | |
| 11 | + encodePendingCookieValue, | |
| 12 | + encodePopupResultCookieValue, | |
| 13 | + MCP_OAUTH_RESULT_COOKIE, | |
| 14 | + McpPopupOAuthProvider, | |
| 15 | + type McpOAuthPopupResult, | |
| 16 | +} from "../../../../lib/mcp/oauth-provider"; | |
| 17 | + | |
| 18 | +const MCP_OAUTH_FLOW_TIMEOUT_MS = 75_000; | |
| 19 | +const MCP_OAUTH_REQUEST_TIMEOUT_MS = 20_000; | |
| 20 | + | |
| 21 | +const withTimeout = async <T>( | |
| 22 | + factory: () => Promise<T>, | |
| 23 | + timeoutMs: number, | |
| 24 | + label: string, | |
| 25 | +): Promise<T> => | |
| 26 | + await new Promise<T>((resolve, reject) => { | |
| 27 | + const timeoutId = setTimeout(() => { | |
| 28 | + reject(new Error(`${label} timed out after ${timeoutMs}ms`)); | |
| 29 | + }, timeoutMs); | |
| 30 | + | |
| 31 | + factory().then( | |
| 32 | + (value) => { | |
| 33 | + clearTimeout(timeoutId); | |
| 34 | + resolve(value); | |
| 35 | + }, | |
| 36 | + (error) => { | |
| 37 | + clearTimeout(timeoutId); | |
| 38 | + reject(error); | |
| 39 | + }, | |
| 40 | + ); | |
| 41 | + }); | |
| 42 | + | |
| 43 | +const popupResultRedirect = ( | |
| 44 | + request: NextRequest, | |
| 45 | + payload: McpOAuthPopupResult, | |
| 46 | +): Response => { | |
| 47 | + const origin = getExternalOrigin(request); | |
| 48 | + const response = NextResponse.redirect(new URL("/mcp/oauth/complete", origin)); | |
| 49 | + | |
| 50 | + response.cookies.set({ | |
| 51 | + name: MCP_OAUTH_RESULT_COOKIE, | |
| 52 | + value: encodePopupResultCookieValue(payload), | |
| 53 | + httpOnly: true, | |
| 54 | + secure: isExternalHttps(request), | |
| 55 | + sameSite: "lax", | |
| 56 | + maxAge: 2 * 60, | |
| 57 | + path: "/", | |
| 58 | + }); | |
| 59 | + | |
| 60 | + return response; | |
| 61 | +}; | |
| 62 | + | |
| 63 | +const badPopupResponse = (request: NextRequest, message: string): Response => | |
| 64 | + popupResultRedirect(request, { ok: false, error: message }); | |
| 65 | + | |
| 66 | +const toErrorMessage = (error: unknown, fallback: string): string => | |
| 67 | + error instanceof Error ? error.message : fallback; | |
| 68 | + | |
| 69 | +export async function GET(request: NextRequest): Promise<Response> { | |
| 70 | + const sourceUrlRaw = request.nextUrl.searchParams.get("sourceUrl")?.trim() ?? ""; | |
| 71 | + if (sourceUrlRaw.length === 0) { | |
| 72 | + return badPopupResponse(request, "Missing sourceUrl"); | |
| 73 | + } | |
| 74 | + | |
| 75 | + let sourceUrl: URL; | |
| 76 | + try { | |
| 77 | + sourceUrl = parseMcpSourceUrl(sourceUrlRaw); | |
| 78 | + } catch (error) { | |
| 79 | + return badPopupResponse(request, toErrorMessage(error, "Invalid sourceUrl")); | |
| 80 | + } | |
| 81 | + | |
| 82 | + const state = createOAuthState(); | |
| 83 | + const redirectUrl = `${getExternalOrigin(request)}/mcp/oauth/callback`; | |
| 84 | + const provider = new McpPopupOAuthProvider({ | |
| 85 | + redirectUrl, | |
| 86 | + state, | |
| 87 | + }); | |
| 88 | + | |
| 89 | + let authResult: "AUTHORIZED" | "REDIRECT"; | |
| 90 | + try { | |
| 91 | + authResult = await withTimeout( | |
| 92 | + () => | |
| 93 | + auth(provider, { | |
| 94 | + serverUrl: sourceUrl, | |
| 95 | + fetchFn: (input, init) => | |
| 96 | + fetchMcpOAuth(input, init ?? {}, { | |
| 97 | + timeoutMs: MCP_OAUTH_REQUEST_TIMEOUT_MS, | |
| 98 | + label: "OAuth startup request", | |
| 99 | + }), | |
| 100 | + }), | |
| 101 | + MCP_OAUTH_FLOW_TIMEOUT_MS, | |
| 102 | + "OAuth startup", | |
| 103 | + ); | |
| 104 | + } catch (error) { | |
| 105 | + return badPopupResponse( | |
| 106 | + request, | |
| 107 | + toErrorMessage(error, "Failed to start OAuth flow"), | |
| 108 | + ); | |
| 109 | + } | |
| 110 | + | |
| 111 | + if (authResult === "AUTHORIZED") { | |
| 112 | + const tokens = provider.getTokens(); | |
| 113 | + const accessToken = tokens?.access_token?.trim() ?? ""; | |
| 114 | + if (accessToken.length === 0) { | |
| 115 | + return badPopupResponse( | |
| 116 | + request, | |
| 117 | + "OAuth flow completed without an access token", | |
| 118 | + ); | |
| 119 | + } | |
| 120 | + | |
| 121 | + return popupResultRedirect(request, { | |
| 122 | + ok: true, | |
| 123 | + sourceUrl: sourceUrl.toString(), | |
| 124 | + accessToken, | |
| 125 | + refreshToken: tokens?.refresh_token, | |
| 126 | + scope: tokens?.scope, | |
| 127 | + expiresIn: | |
| 128 | + typeof tokens?.expires_in === "number" ? tokens.expires_in : undefined, | |
| 129 | + }); | |
| 130 | + } | |
| 131 | + | |
| 132 | + const authorizationUrl = provider.getAuthorizationUrl(); | |
| 133 | + if (!authorizationUrl) { | |
| 134 | + return badPopupResponse( | |
| 135 | + request, | |
| 136 | + "Server did not request an OAuth authorization step", | |
| 137 | + ); | |
| 138 | + } | |
| 139 | + | |
| 140 | + const response = NextResponse.redirect(authorizationUrl); | |
| 141 | + response.cookies.set({ | |
| 142 | + name: buildPendingCookieName(state), | |
| 143 | + value: encodePendingCookieValue(provider.toPending(sourceUrl.toString())), | |
| 144 | + httpOnly: true, | |
| 145 | + secure: isExternalHttps(request), | |
| 146 | + sameSite: "lax", | |
| 147 | + maxAge: 10 * 60, | |
| 148 | + path: "/", | |
| 149 | + }); | |
| 150 | + | |
| 151 | + return response; | |
| 152 | +} | |
| @@ -1,18 +1,21 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | 3 | import type { ChangeEvent, FormEvent } from "react"; |
| 4 | −import { useMemo, useState } from "react"; | |
| 4 | +import { useEffect, useMemo, useState } from "react"; | |
| 5 | 5 | import { useAtomSet, useAtomValue } from "@effect-atom/atom-react"; |
| 6 | −import type { SourceId } from "@executor-v2/schema"; | |
| 6 | +import type { SourceCredentialBinding, SourceId } from "@executor-v2/schema"; | |
| 7 | 7 | |
| 8 | 8 | import { useWorkspace } from "../../lib/hooks/use-workspace"; |
| 9 | 9 | import { |
| 10 | + credentialBindingsByWorkspace, | |
| 10 | 11 | optimisticRemoveSources, |
| 11 | 12 | optimisticSourcesByWorkspace, |
| 12 | 13 | optimisticUpsertSources, |
| 13 | 14 | removeSource, |
| 14 | 15 | sourcesByWorkspace, |
| 15 | 16 | sourcesPendingByWorkspace, |
| 17 | + toCredentialBindingUpsertPayload, | |
| 18 | + upsertCredentialBinding, | |
| 16 | 19 | upsertSource, |
| 17 | 20 | } from "../../lib/control-plane/atoms"; |
| 18 | 21 | import { |
| @@ -22,6 +25,10 @@ import { · 3 unmodified lines | ||
| 22 | 25 | type LegacySourceFormState, |
| 23 | 26 | type LegacySourceType, |
| 24 | 27 | } from "../../lib/control-plane/legacy-source"; |
| 28 | +import { | |
| 29 | + startMcpOAuthPopup, | |
| 30 | + type McpOAuthPopupSuccess, | |
| 31 | +} from "../../lib/mcp/oauth-popup"; | |
| 25 | 32 | import { Badge } from "../ui/badge"; |
| 26 | 33 | import { Button } from "../ui/button"; |
| 27 | 34 | import { |
| @@ -177,6 +184,20 @@ const defaultFormState = (): LegacySourceFormState => ({ · 149 unmodified lines | ||
| 177 | 184 | enabled: true, |
| 178 | 185 | }); |
| 179 | 186 | |
| 187 | +type McpOAuthDetectionState = { | |
| 188 | + status: "idle" | "checking" | "oauth" | "none" | "error"; | |
| 189 | + detail: string; | |
| 190 | + authorizationServers: ReadonlyArray<string>; | |
| 191 | +}; | |
| 192 | + | |
| 193 | +const defaultMcpOAuthDetectionState = (): McpOAuthDetectionState => ({ | |
| 194 | + status: "idle", | |
| 195 | + detail: "", | |
| 196 | + authorizationServers: [], | |
| 197 | +}); | |
| 198 | + | |
| 199 | +const normalizeEndpoint = (value: string): string => value.trim(); | |
| 200 | + | |
| 180 | 201 | const statusBadgeVariant = ( |
| 181 | 202 | status: string, |
| 182 | 203 | ): "outline" | "pending" | "approved" | "denied" => { |
| @@ -197,14 +218,25 @@ export default function SourcesView() { · 14 unmodified lines | ||
| 197 | 218 | |
| 198 | 219 | const sources = useAtomValue(sourcesByWorkspace(workspaceId)); |
| 199 | 220 | const sourcesPending = useAtomValue(sourcesPendingByWorkspace(workspaceId)); |
| 221 | + const credentialBindings = useAtomValue(credentialBindingsByWorkspace(workspaceId)); | |
| 200 | 222 | const setOptimisticSources = useAtomSet(optimisticSourcesByWorkspace(workspaceId)); |
| 201 | 223 | const runUpsertSource = useAtomSet(upsertSource, { mode: "promise" }); |
| 224 | + const runUpsertCredentialBinding = useAtomSet(upsertCredentialBinding, { | |
| 225 | + mode: "promise", | |
| 226 | + }); | |
| 202 | 227 | const runRemoveSource = useAtomSet(removeSource, { mode: "promise" }); |
| 203 | 228 | |
| 204 | 229 | const [formState, setFormState] = useState<LegacySourceFormState>(() => defaultFormState()); |
| 205 | 230 | const [searchQuery, setSearchQuery] = useState(""); |
| 206 | 231 | const [templateQuery, setTemplateQuery] = useState(""); |
| 207 | 232 | const [statusText, setStatusText] = useState<string | null>(null); |
| 233 | + const [mcpOAuthDetection, setMcpOAuthDetection] = useState<McpOAuthDetectionState>( | |
| 234 | + () => defaultMcpOAuthDetectionState(), | |
| 235 | + ); | |
| 236 | + const [mcpOAuthBusy, setMcpOAuthBusy] = useState(false); | |
| 237 | + const [mcpOAuthSession, setMcpOAuthSession] = useState<McpOAuthPopupSuccess | null>( | |
| 238 | + null, | |
| 239 | + ); | |
| 208 | 240 | |
| 209 | 241 | const sourceItems = useMemo( |
| 210 | 242 | () => sources.items.map(sourceToLegacyRecord), |
| @@ -246,6 +278,33 @@ export default function SourcesView() { · 35 unmodified lines | ||
| 246 | 278 | |
| 247 | 279 | const isEditing = Boolean(formState.id); |
| 248 | 280 | |
| 281 | + const existingMcpOAuthCredential = useMemo(() => { | |
| 282 | + if (!formState.id) { | |
| 283 | + return null; | |
| 284 | + } | |
| 285 | + | |
| 286 | + const sourceKey = `source:${formState.id}`; | |
| 287 | + return ( | |
| 288 | + credentialBindings.items.find( | |
| 289 | + (binding) => | |
| 290 | + binding.sourceKey === sourceKey && binding.provider === "oauth2", | |
| 291 | + ) ?? null | |
| 292 | + ); | |
| 293 | + }, [credentialBindings.items, formState.id]); | |
| 294 | + | |
| 295 | + const mcpOAuthSessionMatchesEndpoint = Boolean( | |
| 296 | + mcpOAuthSession | |
| 297 | + && normalizeEndpoint(mcpOAuthSession.sourceUrl) | |
| 298 | + === normalizeEndpoint(formState.endpoint), | |
| 299 | + ); | |
| 300 | + | |
| 301 | + const mcpOAuthConnected = | |
| 302 | + formState.type === "mcp" | |
| 303 | + && (mcpOAuthSessionMatchesEndpoint || Boolean(existingMcpOAuthCredential)); | |
| 304 | + | |
| 305 | + const mcpOAuthCanConnect = | |
| 306 | + formState.type === "mcp" && mcpOAuthDetection.status === "oauth"; | |
| 307 | + | |
| 249 | 308 | const setFormField = <K extends keyof LegacySourceFormState>( |
| 250 | 309 | key: K, |
| 251 | 310 | value: LegacySourceFormState[K], |
| @@ -258,9 +317,122 @@ export default function SourcesView() { · 6 unmodified lines | ||
| 258 | 317 | |
| 259 | 318 | const resetForm = () => { |
| 260 | 319 | setFormState(defaultFormState()); |
| 320 | + setMcpOAuthDetection(defaultMcpOAuthDetectionState()); | |
| 321 | + setMcpOAuthSession(null); | |
| 322 | + setMcpOAuthBusy(false); | |
| 323 | + }; | |
| 324 | + | |
| 325 | + useEffect(() => { | |
| 326 | + const endpoint = normalizeEndpoint(formState.endpoint); | |
| 327 | + | |
| 328 | + if (formState.type !== "mcp" || endpoint.length === 0) { | |
| 329 | + setMcpOAuthDetection(defaultMcpOAuthDetectionState()); | |
| 330 | + return; | |
| 331 | + } | |
| 332 | + | |
| 333 | + const controller = new AbortController(); | |
| 334 | + setMcpOAuthDetection({ | |
| 335 | + status: "checking", | |
| 336 | + detail: "Checking for OAuth support...", | |
| 337 | + authorizationServers: [], | |
| 338 | + }); | |
| 339 | + | |
| 340 | + void fetch(`/mcp/oauth/detect?sourceUrl=${encodeURIComponent(endpoint)}`, { | |
| 341 | + signal: controller.signal, | |
| 342 | + cache: "no-store", | |
| 343 | + }) | |
| 344 | + .then(async (response) => { | |
| 345 | + const payload = (await response.json().catch(() => null)) as | |
| 346 | + | { | |
| 347 | + oauth?: unknown; | |
| 348 | + authorizationServers?: unknown; | |
| 349 | + detail?: unknown; | |
| 350 | + } | |
| 351 | + | null; | |
| 352 | + | |
| 353 | + if (controller.signal.aborted) { | |
| 354 | + return; | |
| 355 | + } | |
| 356 | + | |
| 357 | + const oauth = payload?.oauth === true; | |
| 358 | + const authorizationServers = Array.isArray(payload?.authorizationServers) | |
| 359 | + ? payload.authorizationServers | |
| 360 | + .filter((entry): entry is string => typeof entry === "string") | |
| 361 | + .map((entry) => entry.trim()) | |
| 362 | + .filter((entry) => entry.length > 0) | |
| 363 | + : []; | |
| 364 | + const detail = typeof payload?.detail === "string" ? payload.detail.trim() : ""; | |
| 365 | + | |
| 366 | + if (!response.ok) { | |
| 367 | + setMcpOAuthDetection({ | |
| 368 | + status: "error", | |
| 369 | + detail: detail || `OAuth detection failed (${response.status})`, | |
| 370 | + authorizationServers, | |
| 371 | + }); | |
| 372 | + return; | |
| 373 | + } | |
| 374 | + | |
| 375 | + setMcpOAuthDetection({ | |
| 376 | + status: oauth ? "oauth" : "none", | |
| 377 | + detail, | |
| 378 | + authorizationServers, | |
| 379 | + }); | |
| 380 | + }) | |
| 381 | + .catch((error) => { | |
| 382 | + if (controller.signal.aborted) { | |
| 383 | + return; | |
| 384 | + } | |
| 385 | + | |
| 386 | + setMcpOAuthDetection({ | |
| 387 | + status: "error", | |
| 388 | + detail: error instanceof Error ? error.message : "OAuth detection failed", | |
| 389 | + authorizationServers: [], | |
| 390 | + }); | |
| 391 | + }); | |
| 392 | + | |
| 393 | + return () => { | |
| 394 | + controller.abort(); | |
| 395 | + }; | |
| 396 | + }, [formState.endpoint, formState.type]); | |
| 397 | + | |
| 398 | + useEffect(() => { | |
| 399 | + if ( | |
| 400 | + mcpOAuthSession | |
| 401 | + && normalizeEndpoint(mcpOAuthSession.sourceUrl) | |
| 402 | + !== normalizeEndpoint(formState.endpoint) | |
| 403 | + ) { | |
| 404 | + setMcpOAuthSession(null); | |
| 405 | + } | |
| 406 | + }, [formState.endpoint, mcpOAuthSession]); | |
| 407 | + | |
| 408 | + const handleMcpOAuthConnect = () => { | |
| 409 | + const endpoint = normalizeEndpoint(formState.endpoint); | |
| 410 | + if (endpoint.length === 0 || mcpOAuthBusy) { | |
| 411 | + return; | |
| 412 | + } | |
| 413 | + | |
| 414 | + setMcpOAuthBusy(true); | |
| 415 | + | |
| 416 | + void startMcpOAuthPopup(endpoint) | |
| 417 | + .then((result) => { | |
| 418 | + setMcpOAuthSession(result); | |
| 419 | + setFormField("authType", "bearer"); | |
| 420 | + setFormField("authMode", "workspace"); | |
| 421 | + setStatusText("OAuth connected. Save source to persist credentials."); | |
| 422 | + }) | |
| 423 | + .catch((error) => { | |
| 424 | + setStatusText( | |
| 425 | + error instanceof Error ? error.message : "OAuth connection failed.", | |
| 426 | + ); | |
| 427 | + }) | |
| 428 | + .finally(() => { | |
| 429 | + setMcpOAuthBusy(false); | |
| 430 | + }); | |
| 261 | 431 | }; |
| 262 | 432 | |
| 263 | 433 | const handleTemplateUse = (template: CatalogTemplate) => { |
| 434 | + setMcpOAuthSession(null); | |
| 435 | + setMcpOAuthBusy(false); | |
| 264 | 436 | setFormState((current) => ({ |
| 265 | 437 | ...current, |
| 266 | 438 | id: undefined, |
| @@ -279,6 +451,8 @@ export default function SourcesView() { · 12 unmodified lines | ||
| 279 | 451 | return; |
| 280 | 452 | } |
| 281 | 453 | |
| 454 | + setMcpOAuthSession(null); | |
| 455 | + setMcpOAuthBusy(false); | |
| 282 | 456 | setFormState(formStateFromSource(source)); |
| 283 | 457 | setStatusText(`Editing ${source.name}.`); |
| 284 | 458 | }; |
| @@ -305,6 +479,14 @@ export default function SourcesView() { · 20 unmodified lines | ||
| 305 | 479 | const previousSources = sources.items; |
| 306 | 480 | const optimistic = optimisticUpsertSources(previousSources, workspaceId, payload); |
| 307 | 481 | |
| 482 | + const endpoint = normalizeEndpoint(formState.endpoint); | |
| 483 | + const oauthSessionForEndpoint = | |
| 484 | + formState.type === "mcp" | |
| 485 | + && mcpOAuthSession | |
| 486 | + && normalizeEndpoint(mcpOAuthSession.sourceUrl) === endpoint | |
| 487 | + ? mcpOAuthSession | |
| 488 | + : null; | |
| 489 | + | |
| 308 | 490 | setOptimisticSources({ |
| 309 | 491 | items: optimistic.items, |
| 310 | 492 | pendingAck: { |
| @@ -314,8 +496,74 @@ export default function SourcesView() { · 3 unmodified lines | ||
| 314 | 496 | }); |
| 315 | 497 | |
| 316 | 498 | void runUpsertSource({ path: { workspaceId }, payload }) |
| 317 | − .then(() => { | |
| 318 | − setStatusText(isEditing ? `Updated ${formState.name.trim()}.` : `Saved ${formState.name.trim()}.`); | |
| 499 | + .then(async () => { | |
| 500 | + let oauthLinked = false; | |
| 501 | + let oauthLinkNote: string | null = null; | |
| 502 | + | |
| 503 | + if (formState.type === "mcp" && oauthSessionForEndpoint) { | |
| 504 | + const sourceKey = `source:${sourceId}`; | |
| 505 | + const existingBindingForSource = | |
| 506 | + credentialBindings.items.find( | |
| 507 | + (binding) => | |
| 508 | + binding.sourceKey === sourceKey && binding.provider === "oauth2", | |
| 509 | + ) ?? null; | |
| 510 | + | |
| 511 | + const scopeType = | |
| 512 | + formState.authMode === "organization" | |
| 513 | + ? "organization" | |
| 514 | + : "workspace"; | |
| 515 | + | |
| 516 | + if (formState.authMode === "account") { | |
| 517 | + oauthLinkNote = | |
| 518 | + "Account scope is not supported from this flow; OAuth credential was saved as workspace scope."; | |
| 519 | + } | |
| 520 | + | |
| 521 | + try { | |
| 522 | + await runUpsertCredentialBinding({ | |
| 523 | + path: { workspaceId }, | |
| 524 | + payload: toCredentialBindingUpsertPayload({ | |
| 525 | + ...(existingBindingForSource | |
| 526 | + ? { id: existingBindingForSource.id } | |
| 527 | + : {}), | |
| 528 | + credentialId: ( | |
| 529 | + existingBindingForSource?.credentialId | |
| 530 | + ?? createLocalId("cred_") | |
| 531 | + ) as SourceCredentialBinding["credentialId"], | |
| 532 | + scopeType, | |
| 533 | + sourceKey, | |
| 534 | + provider: "oauth2", | |
| 535 | + secretRef: oauthSessionForEndpoint.accessToken, | |
| 536 | + accountId: null, | |
| 537 | + additionalHeadersJson: null, | |
| 538 | + boundAuthFingerprint: null, | |
| 539 | + }), | |
| 540 | + }); | |
| 541 | + | |
| 542 | + oauthLinked = true; | |
| 543 | + setMcpOAuthSession(null); | |
| 544 | + } catch { | |
| 545 | + oauthLinkNote = "Source saved, but OAuth credential linking failed."; | |
| 546 | + } | |
| 547 | + } | |
| 548 | + | |
| 549 | + if (oauthLinked) { | |
| 550 | + setStatusText( | |
| 551 | + `${isEditing ? "Updated" : "Saved"} ${formState.name.trim()} and linked OAuth credentials.${oauthLinkNote ? ` ${oauthLinkNote}` : ""}`, | |
| 552 | + ); | |
| 553 | + } else if (oauthLinkNote) { | |
| 554 | + setStatusText( | |
| 555 | + oauthLinkNote.startsWith("Source saved") | |
| 556 | + ? oauthLinkNote | |
| 557 | + : `${isEditing ? "Updated" : "Saved"} ${formState.name.trim()}. ${oauthLinkNote}`, | |
| 558 | + ); | |
| 559 | + } else { | |
| 560 | + setStatusText( | |
| 561 | + isEditing | |
| 562 | + ? `Updated ${formState.name.trim()}.` | |
| 563 | + : `Saved ${formState.name.trim()}.`, | |
| 564 | + ); | |
| 565 | + } | |
| 566 | + | |
| 319 | 567 | resetForm(); |
| 320 | 568 | }) |
| 321 | 569 | .catch(() => { |
| @@ -498,6 +746,61 @@ export default function SourcesView() { · 176 unmodified lines | ||
| 498 | 746 | </div> |
| 499 | 747 | ) : null} |
| 500 | 748 | |
| 749 | + {formState.type === "mcp" ? ( | |
| 750 | + <div className="space-y-2 rounded-md border border-border/70 bg-muted/20 p-3"> | |
| 751 | + <div className="flex items-start justify-between gap-3"> | |
| 752 | + <div className="space-y-1"> | |
| 753 | + <div className="flex items-center gap-2"> | |
| 754 | + <p className="text-xs font-medium text-foreground">MCP OAuth</p> | |
| 755 | + {mcpOAuthConnected ? ( | |
| 756 | + <Badge variant="approved" className="text-[10px] uppercase tracking-wide"> | |
| 757 | + connected | |
| 758 | + </Badge> | |
| 759 | + ) : null} | |
| 760 | + </div> | |
| 761 | + <p className="text-[11px] text-muted-foreground"> | |
| 762 | + {mcpOAuthDetection.status === "checking" | |
| 763 | + ? "Checking endpoint for OAuth support..." | |
| 764 | + : mcpOAuthDetection.status === "oauth" | |
| 765 | + ? "OAuth is supported for this MCP endpoint." | |
| 766 | + : mcpOAuthDetection.status === "none" | |
| 767 | + ? "OAuth was not detected for this endpoint." | |
| 768 | + : mcpOAuthDetection.status === "error" | |
| 769 | + ? "OAuth detection failed." | |
| 770 | + : "Set an MCP endpoint to detect OAuth support."} | |
| 771 | + </p> | |
| 772 | + {mcpOAuthDetection.detail.length > 0 ? ( | |
| 773 | + <p className="text-[11px] text-muted-foreground"> | |
| 774 | + {mcpOAuthDetection.detail} | |
| 775 | + </p> | |
| 776 | + ) : null} | |
| 777 | + {mcpOAuthDetection.authorizationServers.length > 0 ? ( | |
| 778 | + <p className="text-[11px] text-muted-foreground"> | |
| 779 | + Auth servers: {mcpOAuthDetection.authorizationServers.join(", ")} | |
| 780 | + </p> | |
| 781 | + ) : null} | |
| 782 | + </div> | |
| 783 | + | |
| 784 | + {mcpOAuthCanConnect ? ( | |
| 785 | + <Button | |
| 786 | + type="button" | |
| 787 | + size="sm" | |
| 788 | + variant="outline" | |
| 789 | + className="shrink-0" | |
| 790 | + onClick={handleMcpOAuthConnect} | |
| 791 | + disabled={mcpOAuthBusy} | |
| 792 | + > | |
| 793 | + {mcpOAuthBusy | |
| 794 | + ? "Connecting..." | |
| 795 | + : mcpOAuthConnected | |
| 796 | + ? "Reconnect OAuth" | |
| 797 | + : "Connect OAuth"} | |
| 798 | + </Button> | |
| 799 | + ) : null} | |
| 800 | + </div> | |
| 801 | + </div> | |
| 802 | + ) : null} | |
| 803 | + | |
| 501 | 804 | <div className="grid gap-3 sm:grid-cols-2"> |
| 502 | 805 | <div className="grid gap-1.5"> |
| 503 | 806 | <label className="text-xs text-muted-foreground" htmlFor="source-auth-type">Auth Type</label> |
| @@ -0,0 +1,112 @@ | ||
| 1 | +import { parseMcpSourceUrl } from "./oauth-url"; | |
| 2 | + | |
| 3 | +const REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]); | |
| 4 | +const DEFAULT_MAX_REDIRECTS = 5; | |
| 5 | + | |
| 6 | +type FetchMcpOAuthOptions = { | |
| 7 | + timeoutMs: number; | |
| 8 | + label: string; | |
| 9 | + maxRedirects?: number; | |
| 10 | +}; | |
| 11 | + | |
| 12 | +const toUrl = (input: RequestInfo | URL): URL => { | |
| 13 | + if (input instanceof URL) { | |
| 14 | + return input; | |
| 15 | + } | |
| 16 | + | |
| 17 | + if (typeof input === "string") { | |
| 18 | + return new URL(input); | |
| 19 | + } | |
| 20 | + | |
| 21 | + return new URL(input.url); | |
| 22 | +}; | |
| 23 | + | |
| 24 | +const fetchWithTimeout = async ( | |
| 25 | + input: URL, | |
| 26 | + init: RequestInit, | |
| 27 | + timeoutMs: number, | |
| 28 | + label: string, | |
| 29 | +): Promise<Response> => { | |
| 30 | + const controller = new AbortController(); | |
| 31 | + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); | |
| 32 | + | |
| 33 | + const sourceSignal = init.signal; | |
| 34 | + const abortFromSourceSignal = () => controller.abort(); | |
| 35 | + | |
| 36 | + if (sourceSignal) { | |
| 37 | + if (sourceSignal.aborted) { | |
| 38 | + controller.abort(); | |
| 39 | + } else { | |
| 40 | + sourceSignal.addEventListener("abort", abortFromSourceSignal, { once: true }); | |
| 41 | + } | |
| 42 | + } | |
| 43 | + | |
| 44 | + try { | |
| 45 | + return await fetch(input, { | |
| 46 | + ...init, | |
| 47 | + signal: controller.signal, | |
| 48 | + cache: "no-store", | |
| 49 | + redirect: "manual", | |
| 50 | + }); | |
| 51 | + } catch (error) { | |
| 52 | + if (controller.signal.aborted) { | |
| 53 | + throw new Error(`${label} timed out after ${timeoutMs}ms`); | |
| 54 | + } | |
| 55 | + | |
| 56 | + throw error; | |
| 57 | + } finally { | |
| 58 | + clearTimeout(timeoutId); | |
| 59 | + sourceSignal?.removeEventListener("abort", abortFromSourceSignal); | |
| 60 | + } | |
| 61 | +}; | |
| 62 | + | |
| 63 | +export const fetchMcpOAuth = async ( | |
| 64 | + input: RequestInfo | URL, | |
| 65 | + init: RequestInit, | |
| 66 | + options: FetchMcpOAuthOptions, | |
| 67 | +): Promise<Response> => { | |
| 68 | + const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS; | |
| 69 | + let currentUrl = toUrl(input); | |
| 70 | + let currentInit = { ...init }; | |
| 71 | + | |
| 72 | + for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount += 1) { | |
| 73 | + const response = await fetchWithTimeout( | |
| 74 | + currentUrl, | |
| 75 | + currentInit, | |
| 76 | + options.timeoutMs, | |
| 77 | + options.label, | |
| 78 | + ); | |
| 79 | + | |
| 80 | + if (!REDIRECT_STATUS_CODES.has(response.status)) { | |
| 81 | + return response; | |
| 82 | + } | |
| 83 | + | |
| 84 | + const location = response.headers.get("location")?.trim() ?? ""; | |
| 85 | + if (location.length === 0) { | |
| 86 | + return response; | |
| 87 | + } | |
| 88 | + | |
| 89 | + await response.body?.cancel(); | |
| 90 | + | |
| 91 | + if (redirectCount === maxRedirects) { | |
| 92 | + throw new Error(`Too many redirects while ${options.label.toLowerCase()}`); | |
| 93 | + } | |
| 94 | + | |
| 95 | + const nextUrlRaw = new URL(location, currentUrl).toString(); | |
| 96 | + currentUrl = parseMcpSourceUrl(nextUrlRaw); | |
| 97 | + | |
| 98 | + const method = (currentInit.method ?? "GET").toUpperCase(); | |
| 99 | + if ( | |
| 100 | + response.status === 303 | |
| 101 | + || ((response.status === 301 || response.status === 302) && method === "POST") | |
| 102 | + ) { | |
| 103 | + currentInit = { | |
| 104 | + ...currentInit, | |
| 105 | + method: "GET", | |
| 106 | + body: undefined, | |
| 107 | + }; | |
| 108 | + } | |
| 109 | + } | |
| 110 | + | |
| 111 | + throw new Error(`Too many redirects while ${options.label.toLowerCase()}`); | |
| 112 | +}; | |
| @@ -0,0 +1,128 @@ | ||
| 1 | +export type McpOAuthPopupSuccess = { | |
| 2 | + sourceUrl: string; | |
| 3 | + accessToken: string; | |
| 4 | + refreshToken?: string; | |
| 5 | + scope?: string; | |
| 6 | + expiresIn?: number; | |
| 7 | +}; | |
| 8 | + | |
| 9 | +type McpOAuthPopupMessage = | |
| 10 | + | { | |
| 11 | + type: "executor-v2:mcp-oauth-result"; | |
| 12 | + ok: true; | |
| 13 | + sourceUrl: string; | |
| 14 | + payload: { | |
| 15 | + accessToken: string; | |
| 16 | + refreshToken?: string; | |
| 17 | + scope?: string; | |
| 18 | + expiresIn?: number; | |
| 19 | + }; | |
| 20 | + } | |
| 21 | + | { | |
| 22 | + type: "executor-v2:mcp-oauth-result"; | |
| 23 | + ok: false; | |
| 24 | + error: string; | |
| 25 | + }; | |
| 26 | + | |
| 27 | +const MCP_OAUTH_POPUP_RESULT_TIMEOUT_MS = 2 * 60_000; | |
| 28 | + | |
| 29 | +export const startMcpOAuthPopup = async ( | |
| 30 | + sourceUrl: string, | |
| 31 | +): Promise<McpOAuthPopupSuccess> => { | |
| 32 | + if (typeof window === "undefined") { | |
| 33 | + throw new Error("OAuth popup is only available in a browser context"); | |
| 34 | + } | |
| 35 | + | |
| 36 | + const startUrl = `/mcp/oauth/start?sourceUrl=${encodeURIComponent(sourceUrl)}`; | |
| 37 | + const popup = window.open( | |
| 38 | + startUrl, | |
| 39 | + "executor-v2-mcp-oauth", | |
| 40 | + "popup=yes,width=520,height=720", | |
| 41 | + ); | |
| 42 | + | |
| 43 | + if (!popup) { | |
| 44 | + throw new Error("Popup blocked. Allow popups and try again."); | |
| 45 | + } | |
| 46 | + | |
| 47 | + popup.focus(); | |
| 48 | + | |
| 49 | + return await new Promise<McpOAuthPopupSuccess>((resolve, reject) => { | |
| 50 | + let settled = false; | |
| 51 | + let closedPoll = 0; | |
| 52 | + let resultTimeout = 0; | |
| 53 | + | |
| 54 | + const cleanup = () => { | |
| 55 | + window.removeEventListener("message", onMessage); | |
| 56 | + if (closedPoll) { | |
| 57 | + window.clearInterval(closedPoll); | |
| 58 | + } | |
| 59 | + | |
| 60 | + if (resultTimeout) { | |
| 61 | + window.clearTimeout(resultTimeout); | |
| 62 | + } | |
| 63 | + | |
| 64 | + if (!popup.closed) { | |
| 65 | + popup.close(); | |
| 66 | + } | |
| 67 | + }; | |
| 68 | + | |
| 69 | + const closeAndReject = (message: string) => { | |
| 70 | + if (settled) { | |
| 71 | + return; | |
| 72 | + } | |
| 73 | + | |
| 74 | + settled = true; | |
| 75 | + cleanup(); | |
| 76 | + reject(new Error(message)); | |
| 77 | + }; | |
| 78 | + | |
| 79 | + const onMessage = (event: MessageEvent) => { | |
| 80 | + if (event.origin !== window.location.origin) { | |
| 81 | + return; | |
| 82 | + } | |
| 83 | + | |
| 84 | + const data = event.data as McpOAuthPopupMessage | undefined; | |
| 85 | + if (!data || data.type !== "executor-v2:mcp-oauth-result") { | |
| 86 | + return; | |
| 87 | + } | |
| 88 | + | |
| 89 | + if (!data.ok) { | |
| 90 | + closeAndReject(data.error || "OAuth failed"); | |
| 91 | + return; | |
| 92 | + } | |
| 93 | + | |
| 94 | + if (!data.payload?.accessToken) { | |
| 95 | + closeAndReject("OAuth finished without an access token"); | |
| 96 | + return; | |
| 97 | + } | |
| 98 | + | |
| 99 | + if (settled) { | |
| 100 | + return; | |
| 101 | + } | |
| 102 | + | |
| 103 | + settled = true; | |
| 104 | + cleanup(); | |
| 105 | + resolve({ | |
| 106 | + sourceUrl: data.sourceUrl, | |
| 107 | + accessToken: data.payload.accessToken, | |
| 108 | + ...(data.payload.refreshToken ? { refreshToken: data.payload.refreshToken } : {}), | |
| 109 | + ...(data.payload.scope ? { scope: data.payload.scope } : {}), | |
| 110 | + ...(typeof data.payload.expiresIn === "number" | |
| 111 | + ? { expiresIn: data.payload.expiresIn } | |
| 112 | + : {}), | |
| 113 | + }); | |
| 114 | + }; | |
| 115 | + | |
| 116 | + window.addEventListener("message", onMessage); | |
| 117 | + | |
| 118 | + resultTimeout = window.setTimeout(() => { | |
| 119 | + closeAndReject("OAuth popup timed out before completion. Please try again."); | |
| 120 | + }, MCP_OAUTH_POPUP_RESULT_TIMEOUT_MS); | |
| 121 | + | |
| 122 | + closedPoll = window.setInterval(() => { | |
| 123 | + if (popup.closed) { | |
| 124 | + closeAndReject("OAuth popup was closed before completion"); | |
| 125 | + } | |
| 126 | + }, 300); | |
| 127 | + }); | |
| 128 | +}; | |
| @@ -0,0 +1,249 @@ | ||
| 1 | +import { randomUUID } from "node:crypto"; | |
| 2 | +import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; | |
| 3 | +import type { | |
| 4 | + OAuthClientInformationMixed, | |
| 5 | + OAuthClientMetadata, | |
| 6 | + OAuthTokens, | |
| 7 | +} from "@modelcontextprotocol/sdk/shared/auth.js"; | |
| 8 | + | |
| 9 | +const COOKIE_PREFIX = "executor_v2_mcp_oauth_"; | |
| 10 | +export const MCP_OAUTH_RESULT_COOKIE = "executor_v2_mcp_oauth_result"; | |
| 11 | + | |
| 12 | +export type McpOAuthPending = { | |
| 13 | + state: string; | |
| 14 | + sourceUrl: string; | |
| 15 | + redirectUrl: string; | |
| 16 | + codeVerifier?: string; | |
| 17 | + clientInformation?: OAuthClientInformationMixed; | |
| 18 | +}; | |
| 19 | + | |
| 20 | +export type McpOAuthPopupResult = { | |
| 21 | + ok: boolean; | |
| 22 | + sourceUrl?: string; | |
| 23 | + accessToken?: string; | |
| 24 | + refreshToken?: string; | |
| 25 | + scope?: string; | |
| 26 | + expiresIn?: number; | |
| 27 | + error?: string; | |
| 28 | +}; | |
| 29 | + | |
| 30 | +const asRecord = (value: unknown): Record<string, unknown> | null => { | |
| 31 | + if (!value || typeof value !== "object" || Array.isArray(value)) { | |
| 32 | + return null; | |
| 33 | + } | |
| 34 | + | |
| 35 | + return value as Record<string, unknown>; | |
| 36 | +}; | |
| 37 | + | |
| 38 | +const readString = (record: Record<string, unknown>, key: string): string | null => { | |
| 39 | + const value = record[key]; | |
| 40 | + return typeof value === "string" ? value : null; | |
| 41 | +}; | |
| 42 | + | |
| 43 | +const readOptionalString = (record: Record<string, unknown>, key: string): string | undefined => { | |
| 44 | + const value = record[key]; | |
| 45 | + return typeof value === "string" ? value : undefined; | |
| 46 | +}; | |
| 47 | + | |
| 48 | +const readOptionalNumber = (record: Record<string, unknown>, key: string): number | undefined => { | |
| 49 | + const value = record[key]; | |
| 50 | + return typeof value === "number" ? value : undefined; | |
| 51 | +}; | |
| 52 | + | |
| 53 | +const decodeCookieJson = (raw: string): unknown | null => { | |
| 54 | + try { | |
| 55 | + const decoded = Buffer.from(raw, "base64url").toString("utf8"); | |
| 56 | + return JSON.parse(decoded); | |
| 57 | + } catch { | |
| 58 | + return null; | |
| 59 | + } | |
| 60 | +}; | |
| 61 | + | |
| 62 | +const decodePending = (value: unknown): McpOAuthPending | null => { | |
| 63 | + const record = asRecord(value); | |
| 64 | + if (!record) { | |
| 65 | + return null; | |
| 66 | + } | |
| 67 | + | |
| 68 | + const state = readString(record, "state"); | |
| 69 | + const sourceUrl = readString(record, "sourceUrl"); | |
| 70 | + const redirectUrl = readString(record, "redirectUrl"); | |
| 71 | + | |
| 72 | + if (!state || !sourceUrl || !redirectUrl) { | |
| 73 | + return null; | |
| 74 | + } | |
| 75 | + | |
| 76 | + const codeVerifier = readOptionalString(record, "codeVerifier"); | |
| 77 | + const clientInformationValue = record.clientInformation; | |
| 78 | + const clientInformation = asRecord(clientInformationValue) | |
| 79 | + ? (clientInformationValue as OAuthClientInformationMixed) | |
| 80 | + : undefined; | |
| 81 | + | |
| 82 | + return { | |
| 83 | + state, | |
| 84 | + sourceUrl, | |
| 85 | + redirectUrl, | |
| 86 | + ...(codeVerifier ? { codeVerifier } : {}), | |
| 87 | + ...(clientInformation ? { clientInformation } : {}), | |
| 88 | + }; | |
| 89 | +}; | |
| 90 | + | |
| 91 | +const decodePopupResult = (value: unknown): McpOAuthPopupResult | null => { | |
| 92 | + const record = asRecord(value); | |
| 93 | + if (!record) { | |
| 94 | + return null; | |
| 95 | + } | |
| 96 | + | |
| 97 | + const ok = record.ok; | |
| 98 | + if (typeof ok !== "boolean") { | |
| 99 | + return null; | |
| 100 | + } | |
| 101 | + | |
| 102 | + return { | |
| 103 | + ok, | |
| 104 | + sourceUrl: readOptionalString(record, "sourceUrl"), | |
| 105 | + accessToken: readOptionalString(record, "accessToken"), | |
| 106 | + refreshToken: readOptionalString(record, "refreshToken"), | |
| 107 | + scope: readOptionalString(record, "scope"), | |
| 108 | + expiresIn: readOptionalNumber(record, "expiresIn"), | |
| 109 | + error: readOptionalString(record, "error"), | |
| 110 | + }; | |
| 111 | +}; | |
| 112 | + | |
| 113 | +export const buildPendingCookieName = (state: string): string => | |
| 114 | + `${COOKIE_PREFIX}${state}`; | |
| 115 | + | |
| 116 | +export const createOAuthState = (): string => randomUUID(); | |
| 117 | + | |
| 118 | +export const encodePendingCookieValue = (pending: McpOAuthPending): string => | |
| 119 | + Buffer.from( | |
| 120 | + JSON.stringify({ | |
| 121 | + version: 1, | |
| 122 | + pending, | |
| 123 | + }), | |
| 124 | + "utf8", | |
| 125 | + ).toString("base64url"); | |
| 126 | + | |
| 127 | +export const decodePendingCookieValue = (raw: string): McpOAuthPending | null => { | |
| 128 | + const decoded = decodeCookieJson(raw); | |
| 129 | + const versioned = asRecord(decoded); | |
| 130 | + if (!versioned || versioned.version !== 1) { | |
| 131 | + return null; | |
| 132 | + } | |
| 133 | + | |
| 134 | + return decodePending(versioned.pending); | |
| 135 | +}; | |
| 136 | + | |
| 137 | +export const encodePopupResultCookieValue = (result: McpOAuthPopupResult): string => | |
| 138 | + Buffer.from( | |
| 139 | + JSON.stringify({ | |
| 140 | + version: 1, | |
| 141 | + result, | |
| 142 | + }), | |
| 143 | + "utf8", | |
| 144 | + ).toString("base64url"); | |
| 145 | + | |
| 146 | +export const decodePopupResultCookieValue = ( | |
| 147 | + raw: string, | |
| 148 | +): McpOAuthPopupResult | null => { | |
| 149 | + const decoded = decodeCookieJson(raw); | |
| 150 | + const versioned = asRecord(decoded); | |
| 151 | + if (!versioned || versioned.version !== 1) { | |
| 152 | + return null; | |
| 153 | + } | |
| 154 | + | |
| 155 | + return decodePopupResult(versioned.result); | |
| 156 | +}; | |
| 157 | + | |
| 158 | +export class McpPopupOAuthProvider implements OAuthClientProvider { | |
| 159 | + public clientMetadata: OAuthClientMetadata; | |
| 160 | + | |
| 161 | + private readonly stateValue: string; | |
| 162 | + private readonly redirectTarget: string; | |
| 163 | + private codeVerifierValue?: string; | |
| 164 | + private clientInfo?: OAuthClientInformationMixed; | |
| 165 | + private tokenValue?: OAuthTokens; | |
| 166 | + private authorizationUrl?: string; | |
| 167 | + | |
| 168 | + constructor(input: { | |
| 169 | + redirectUrl: string; | |
| 170 | + state: string; | |
| 171 | + codeVerifier?: string; | |
| 172 | + clientInformation?: OAuthClientInformationMixed; | |
| 173 | + tokens?: OAuthTokens; | |
| 174 | + }) { | |
| 175 | + this.redirectTarget = input.redirectUrl; | |
| 176 | + this.stateValue = input.state; | |
| 177 | + this.codeVerifierValue = input.codeVerifier; | |
| 178 | + this.clientInfo = input.clientInformation; | |
| 179 | + this.tokenValue = input.tokens; | |
| 180 | + | |
| 181 | + this.clientMetadata = { | |
| 182 | + redirect_uris: [input.redirectUrl], | |
| 183 | + grant_types: ["authorization_code", "refresh_token"], | |
| 184 | + response_types: ["code"], | |
| 185 | + token_endpoint_auth_method: "none", | |
| 186 | + client_name: "Executor v2 MCP Connector", | |
| 187 | + }; | |
| 188 | + } | |
| 189 | + | |
| 190 | + get redirectUrl(): string { | |
| 191 | + return this.redirectTarget; | |
| 192 | + } | |
| 193 | + | |
| 194 | + async state(): Promise<string> { | |
| 195 | + return this.stateValue; | |
| 196 | + } | |
| 197 | + | |
| 198 | + clientInformation(): OAuthClientInformationMixed | undefined { | |
| 199 | + return this.clientInfo; | |
| 200 | + } | |
| 201 | + | |
| 202 | + async saveClientInformation( | |
| 203 | + clientInformation: OAuthClientInformationMixed, | |
| 204 | + ): Promise<void> { | |
| 205 | + this.clientInfo = clientInformation; | |
| 206 | + } | |
| 207 | + | |
| 208 | + tokens(): OAuthTokens | undefined { | |
| 209 | + return this.tokenValue; | |
| 210 | + } | |
| 211 | + | |
| 212 | + async saveTokens(tokens: OAuthTokens): Promise<void> { | |
| 213 | + this.tokenValue = tokens; | |
| 214 | + } | |
| 215 | + | |
| 216 | + async redirectToAuthorization(authorizationUrl: URL): Promise<void> { | |
| 217 | + this.authorizationUrl = authorizationUrl.toString(); | |
| 218 | + } | |
| 219 | + | |
| 220 | + async saveCodeVerifier(codeVerifier: string): Promise<void> { | |
| 221 | + this.codeVerifierValue = codeVerifier; | |
| 222 | + } | |
| 223 | + | |
| 224 | + codeVerifier(): string { | |
| 225 | + if (!this.codeVerifierValue) { | |
| 226 | + throw new Error("Missing PKCE code verifier"); | |
| 227 | + } | |
| 228 | + | |
| 229 | + return this.codeVerifierValue; | |
| 230 | + } | |
| 231 | + | |
| 232 | + getAuthorizationUrl(): string | undefined { | |
| 233 | + return this.authorizationUrl; | |
| 234 | + } | |
| 235 | + | |
| 236 | + toPending(sourceUrl: string): McpOAuthPending { | |
| 237 | + return { | |
| 238 | + state: this.stateValue, | |
| 239 | + sourceUrl, | |
| 240 | + redirectUrl: this.redirectTarget, | |
| 241 | + ...(this.codeVerifierValue ? { codeVerifier: this.codeVerifierValue } : {}), | |
| 242 | + ...(this.clientInfo ? { clientInformation: this.clientInfo } : {}), | |
| 243 | + }; | |
| 244 | + } | |
| 245 | + | |
| 246 | + getTokens(): OAuthTokens | undefined { | |
| 247 | + return this.tokenValue; | |
| 248 | + } | |
| 249 | +} | |
| @@ -0,0 +1,59 @@ | ||
| 1 | +const firstHeaderValue = (value: string | null): string => { | |
| 2 | + if (!value) { | |
| 3 | + return ""; | |
| 4 | + } | |
| 5 | + | |
| 6 | + return value.split(",")[0]?.trim() ?? ""; | |
| 7 | +}; | |
| 8 | + | |
| 9 | +const configuredExternalOrigin = (): string | null => { | |
| 10 | + const configured = | |
| 11 | + process.env.EXECUTOR_PUBLIC_ORIGIN | |
| 12 | + ?? process.env.NEXT_PUBLIC_APP_ORIGIN | |
| 13 | + ?? process.env.VERCEL_PROJECT_PRODUCTION_URL | |
| 14 | + ?? process.env.VERCEL_URL | |
| 15 | + ?? ""; | |
| 16 | + | |
| 17 | + if (!configured.trim()) { | |
| 18 | + return null; | |
| 19 | + } | |
| 20 | + | |
| 21 | + try { | |
| 22 | + const normalized = | |
| 23 | + configured.startsWith("http://") || configured.startsWith("https://") | |
| 24 | + ? configured | |
| 25 | + : `https://${configured}`; | |
| 26 | + | |
| 27 | + return new URL(normalized).origin; | |
| 28 | + } catch { | |
| 29 | + return null; | |
| 30 | + } | |
| 31 | +}; | |
| 32 | + | |
| 33 | +export const getExternalOrigin = (request: Request): string => { | |
| 34 | + const configured = configuredExternalOrigin(); | |
| 35 | + if (configured) { | |
| 36 | + return configured; | |
| 37 | + } | |
| 38 | + | |
| 39 | + const host = firstHeaderValue( | |
| 40 | + request.headers.get("x-forwarded-host") ?? request.headers.get("host"), | |
| 41 | + ); | |
| 42 | + const requestUrl = new URL(request.url); | |
| 43 | + const proto = | |
| 44 | + firstHeaderValue(request.headers.get("x-forwarded-proto")) | |
| 45 | + || requestUrl.protocol.replace(":", ""); | |
| 46 | + | |
| 47 | + if (host && proto) { | |
| 48 | + try { | |
| 49 | + return new URL(`${proto}://${host}`).origin; | |
| 50 | + } catch { | |
| 51 | + return requestUrl.origin; | |
| 52 | + } | |
| 53 | + } | |
| 54 | + | |
| 55 | + return requestUrl.origin; | |
| 56 | +}; | |
| 57 | + | |
| 58 | +export const isExternalHttps = (request: Request): boolean => | |
| 59 | + getExternalOrigin(request).startsWith("https://"); | |
| @@ -0,0 +1,30 @@ | ||
| 1 | +const isLocalHost = (hostname: string): boolean => | |
| 2 | + hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; | |
| 3 | + | |
| 4 | +const localHttpAllowed = (): boolean => | |
| 5 | + process.env.NODE_ENV !== "production" | |
| 6 | + || process.env.EXECUTOR_ALLOW_LOCAL_MCP_OAUTH === "1"; | |
| 7 | + | |
| 8 | +export const parseMcpSourceUrl = (raw: string): URL => { | |
| 9 | + let url: URL; | |
| 10 | + | |
| 11 | + try { | |
| 12 | + url = new URL(raw); | |
| 13 | + } catch { | |
| 14 | + throw new Error("Invalid MCP source URL"); | |
| 15 | + } | |
| 16 | + | |
| 17 | + if (url.username || url.password) { | |
| 18 | + throw new Error("Credentials in MCP source URL are not allowed"); | |
| 19 | + } | |
| 20 | + | |
| 21 | + if (url.protocol === "https:") { | |
| 22 | + return url; | |
| 23 | + } | |
| 24 | + | |
| 25 | + if (url.protocol === "http:" && localHttpAllowed() && isLocalHost(url.hostname.toLowerCase())) { | |
| 26 | + return url; | |
| 27 | + } | |
| 28 | + | |
| 29 | + throw new Error("MCP source URL must use https:// (http://localhost allowed in local dev)"); | |
| 30 | +}; | |