Unified · Split
| @@ -27,6 +27,7 @@ MCP install URL generation: · 26 unmodified lines | ||
| 27 | 27 | |
| 28 | 28 | - Derives from existing frontend origin and deployment metadata. |
| 29 | 29 | - In local dev with no DB URL, defaults to local PGlite-backed control-plane in this Next.js app. |
| 30 | +- MCP endpoint is served from this app at `GET/POST/DELETE /v1/mcp`. | |
| 30 | 31 | |
| 31 | 32 | WorkOS auth setup (optional but recommended): |
| 32 | 33 | |
| @@ -36,3 +37,11 @@ WorkOS auth setup (optional but recommended): · 3 unmodified lines | ||
| 36 | 37 | - `WORKOS_REDIRECT_URI` or `NEXT_PUBLIC_WORKOS_REDIRECT_URI` (for example `http://localhost:4312/callback`) |
| 37 | 38 | |
| 38 | 39 | When WorkOS is configured, the app requires sign-in and the server proxy forwards the authenticated WorkOS access token to control-plane as a bearer token (`Authorization: Bearer ...`). |
| 40 | + | |
| 41 | +MCP OAuth setup (for external MCP clients): | |
| 42 | + | |
| 43 | +- `MCP_AUTHORIZATION_SERVER` (or `MCP_AUTHORIZATION_SERVER_URL`, `WORKOS_AUTHKIT_ISSUER`) | |
| 44 | +- `/.well-known/oauth-protected-resource` | |
| 45 | +- `/.well-known/oauth-authorization-server` | |
| 46 | + | |
| 47 | +Unauthenticated requests to `/v1/mcp` return `401` with `WWW-Authenticate` and `resource_metadata` for OAuth discovery. | |
| @@ -0,0 +1,43 @@ | ||
| 1 | +import { getMcpAuthConfig } from "../../../lib/mcp/resource-auth"; | |
| 2 | + | |
| 3 | +export const dynamic = "force-dynamic"; | |
| 4 | +export const runtime = "nodejs"; | |
| 5 | + | |
| 6 | +export async function GET(): Promise<Response> { | |
| 7 | + const authConfig = getMcpAuthConfig(); | |
| 8 | + if (!authConfig.enabled || !authConfig.authorizationServer) { | |
| 9 | + return Response.json( | |
| 10 | + { error: "MCP OAuth is not configured" }, | |
| 11 | + { status: 404 }, | |
| 12 | + ); | |
| 13 | + } | |
| 14 | + | |
| 15 | + const upstream = new URL( | |
| 16 | + "/.well-known/oauth-authorization-server", | |
| 17 | + authConfig.authorizationServer, | |
| 18 | + ); | |
| 19 | + | |
| 20 | + try { | |
| 21 | + const response = await fetch(upstream.toString(), { | |
| 22 | + headers: { accept: "application/json" }, | |
| 23 | + }); | |
| 24 | + | |
| 25 | + const text = await response.text(); | |
| 26 | + return new Response(text, { | |
| 27 | + status: response.status, | |
| 28 | + headers: { | |
| 29 | + "content-type": response.headers.get("content-type") ?? "application/json", | |
| 30 | + }, | |
| 31 | + }); | |
| 32 | + } catch (error) { | |
| 33 | + return Response.json( | |
| 34 | + { | |
| 35 | + error: | |
| 36 | + error instanceof Error | |
| 37 | + ? `Failed to fetch upstream auth metadata: ${error.message}` | |
| 38 | + : "Failed to fetch upstream auth metadata", | |
| 39 | + }, | |
| 40 | + { status: 502 }, | |
| 41 | + ); | |
| 42 | + } | |
| 43 | +} | |
| @@ -0,0 +1,62 @@ | ||
| 1 | +import { externalOriginFromRequest } from "../../../lib/workos"; | |
| 2 | +import { getMcpAuthConfig } from "../../../lib/mcp/resource-auth"; | |
| 3 | + | |
| 4 | +export const dynamic = "force-dynamic"; | |
| 5 | +export const runtime = "nodejs"; | |
| 6 | + | |
| 7 | +const MCP_PATH = "/v1/mcp"; | |
| 8 | + | |
| 9 | +const parseResourceHint = ( | |
| 10 | + requestUrl: URL, | |
| 11 | + origin: string, | |
| 12 | +): { resourceUrl: URL } | { error: string } => { | |
| 13 | + const resourceHint = requestUrl.searchParams.get("resource")?.trim(); | |
| 14 | + if (!resourceHint) { | |
| 15 | + const fallback = new URL(MCP_PATH, origin); | |
| 16 | + for (const [key, value] of requestUrl.searchParams.entries()) { | |
| 17 | + if (key === "resource") continue; | |
| 18 | + fallback.searchParams.set(key, value); | |
| 19 | + } | |
| 20 | + return { resourceUrl: fallback }; | |
| 21 | + } | |
| 22 | + | |
| 23 | + let parsed: URL; | |
| 24 | + try { | |
| 25 | + parsed = new URL(resourceHint); | |
| 26 | + } catch { | |
| 27 | + return { error: "Invalid resource hint" }; | |
| 28 | + } | |
| 29 | + | |
| 30 | + if (parsed.origin !== origin) { | |
| 31 | + return { error: "resource hint origin must match this server" }; | |
| 32 | + } | |
| 33 | + | |
| 34 | + if (parsed.pathname !== MCP_PATH) { | |
| 35 | + return { error: `resource hint path must be ${MCP_PATH}` }; | |
| 36 | + } | |
| 37 | + | |
| 38 | + return { resourceUrl: parsed }; | |
| 39 | +}; | |
| 40 | + | |
| 41 | +export async function GET(request: Request): Promise<Response> { | |
| 42 | + const authConfig = getMcpAuthConfig(); | |
| 43 | + if (!authConfig.enabled || !authConfig.authorizationServer) { | |
| 44 | + return Response.json( | |
| 45 | + { error: "MCP OAuth is not configured" }, | |
| 46 | + { status: 404 }, | |
| 47 | + ); | |
| 48 | + } | |
| 49 | + | |
| 50 | + const origin = externalOriginFromRequest(request); | |
| 51 | + const requestUrl = new URL(request.url); | |
| 52 | + const parsedResource = parseResourceHint(requestUrl, origin); | |
| 53 | + if ("error" in parsedResource) { | |
| 54 | + return Response.json({ error: parsedResource.error }, { status: 400 }); | |
| 55 | + } | |
| 56 | + | |
| 57 | + return Response.json({ | |
| 58 | + resource: parsedResource.resourceUrl.toString(), | |
| 59 | + authorization_servers: [authConfig.authorizationServer], | |
| 60 | + bearer_methods_supported: ["header"], | |
| 61 | + }); | |
| 62 | +} | |
| @@ -0,0 +1,66 @@ | ||
| 1 | +import { | |
| 2 | + createWorkosPrincipal, | |
| 3 | + getControlPlaneRuntime, | |
| 4 | + provisionPrincipal, | |
| 5 | +} from "../../../lib/control-plane/server"; | |
| 6 | +import { | |
| 7 | + getMcpAuthConfig, | |
| 8 | + unauthorizedMcpResponse, | |
| 9 | + verifyMcpBearerToken, | |
| 10 | +} from "../../../lib/mcp/resource-auth"; | |
| 11 | + | |
| 12 | +export const dynamic = "force-dynamic"; | |
| 13 | +export const runtime = "nodejs"; | |
| 14 | + | |
| 15 | +const parseWorkspaceId = (request: Request): string | null => { | |
| 16 | + const value = new URL(request.url).searchParams.get("workspaceId")?.trim(); | |
| 17 | + return value && value.length > 0 ? value : null; | |
| 18 | +}; | |
| 19 | + | |
| 20 | +const handler = async (request: Request): Promise<Response> => { | |
| 21 | + const authConfig = getMcpAuthConfig(); | |
| 22 | + if (!authConfig.enabled) { | |
| 23 | + return Response.json( | |
| 24 | + { | |
| 25 | + error: | |
| 26 | + "MCP OAuth is not configured. Set MCP_AUTHORIZATION_SERVER (or WORKOS_AUTHKIT_ISSUER).", | |
| 27 | + }, | |
| 28 | + { status: 503 }, | |
| 29 | + ); | |
| 30 | + } | |
| 31 | + | |
| 32 | + const verified = await verifyMcpBearerToken(request, authConfig); | |
| 33 | + if (!verified) { | |
| 34 | + return unauthorizedMcpResponse(request, "No valid bearer token provided."); | |
| 35 | + } | |
| 36 | + | |
| 37 | + const workspaceId = parseWorkspaceId(request); | |
| 38 | + if (!workspaceId) { | |
| 39 | + return Response.json( | |
| 40 | + { error: "workspaceId query parameter is required" }, | |
| 41 | + { status: 400 }, | |
| 42 | + ); | |
| 43 | + } | |
| 44 | + | |
| 45 | + const principal = createWorkosPrincipal({ | |
| 46 | + subject: verified.subject, | |
| 47 | + email: verified.email, | |
| 48 | + displayName: verified.displayName, | |
| 49 | + }); | |
| 50 | + | |
| 51 | + if (workspaceId !== principal.workspaceId) { | |
| 52 | + return Response.json( | |
| 53 | + { error: "workspaceId does not match authenticated subject" }, | |
| 54 | + { status: 403 }, | |
| 55 | + ); | |
| 56 | + } | |
| 57 | + | |
| 58 | + const controlPlaneRuntime = await getControlPlaneRuntime(); | |
| 59 | + await provisionPrincipal(controlPlaneRuntime, principal); | |
| 60 | + | |
| 61 | + return controlPlaneRuntime.handleMcp(request, workspaceId); | |
| 62 | +}; | |
| 63 | + | |
| 64 | +export const GET = handler; | |
| 65 | +export const POST = handler; | |
| 66 | +export const DELETE = handler; | |
| @@ -65,13 +65,6 @@ type SourceGroup = { · 64 unmodified lines | ||
| 65 | 65 | toolCount: number; |
| 66 | 66 | }; |
| 67 | 67 | |
| 68 | −const isLocalHost = (hostname: string): boolean => | |
| 69 | − hostname === "localhost" | |
| 70 | − || hostname === "127.0.0.1" | |
| 71 | − || hostname === "0.0.0.0" | |
| 72 | − || hostname === "::1" | |
| 73 | − || hostname === "[::1]"; | |
| 74 | − | |
| 75 | 68 | // --------------------------------------------------------------------------- |
| 76 | 69 | // Search helpers (ported from old executor explorer-derived.ts) |
| 77 | 70 | // --------------------------------------------------------------------------- |
| @@ -407,11 +400,7 @@ export function ToolsView(props: { · 329 unmodified lines | ||
| 407 | 400 | return `http://127.0.0.1:8788/v1/mcp?workspaceId=${encodeURIComponent(workspaceId)}`; |
| 408 | 401 | } |
| 409 | 402 | |
| 410 | − const baseOrigin = isLocalHost(window.location.hostname) | |
| 411 | − ? "http://127.0.0.1:8788" | |
| 412 | − : window.location.origin; | |
| 413 | − | |
| 414 | − const url = new URL("/v1/mcp", baseOrigin); | |
| 403 | + const url = new URL("/v1/mcp", window.location.origin); | |
| 415 | 404 | url.searchParams.set("workspaceId", workspaceId); |
| 416 | 405 | return url.toString(); |
| 417 | 406 | }, [props.mcpBaseUrl, workspaceId]); |
| @@ -8,6 +8,18 @@ import { · 7 unmodified lines | ||
| 8 | 8 | makeSourceCatalogService, |
| 9 | 9 | makeSourceManagerService, |
| 10 | 10 | } from "@executor-v2/management-api"; |
| 11 | +import { | |
| 12 | + createRunExecutor, | |
| 13 | + createSourceToolRegistry, | |
| 14 | + defaultExecuteToolExposureMode, | |
| 15 | + makeGraphqlToolProvider, | |
| 16 | + makeMcpToolProvider, | |
| 17 | + makeOpenApiToolProvider, | |
| 18 | + makeRuntimeAdapterRegistry, | |
| 19 | + makeToolProviderRegistry, | |
| 20 | + parseExecuteToolExposureMode, | |
| 21 | + type ExecuteToolExposureMode, | |
| 22 | +} from "@executor-v2/engine"; | |
| 11 | 23 | import { |
| 12 | 24 | makeSqlControlPlanePersistence, |
| 13 | 25 | type SqlControlPlanePersistence, |
| @@ -22,14 +34,22 @@ import { · 8 unmodified lines | ||
| 22 | 34 | type OrganizationMembership, |
| 23 | 35 | type Workspace, |
| 24 | 36 | } from "@executor-v2/schema"; |
| 37 | +import { makeCloudflareWorkerLoaderRuntimeAdapter } from "@executor-v2/runtime-cloudflare-worker-loader"; | |
| 38 | +import { makeDenoSubprocessRuntimeAdapter } from "@executor-v2/runtime-deno-subprocess"; | |
| 39 | +import { makeLocalInProcessRuntimeAdapter } from "@executor-v2/runtime-local-inproc"; | |
| 25 | 40 | import * as Effect from "effect/Effect"; |
| 26 | 41 | import * as Layer from "effect/Layer"; |
| 27 | 42 | |
| 28 | 43 | import { PmActorLive } from "../../../pm/src/actor"; |
| 29 | −import { createPmApprovalsService } from "../../../pm/src/approvals-service"; | |
| 44 | +import { | |
| 45 | + createPmApprovalsService, | |
| 46 | + createPmPersistentToolApprovalPolicy, | |
| 47 | +} from "../../../pm/src/approvals-service"; | |
| 30 | 48 | import { createPmCredentialsService } from "../../../pm/src/credentials-service"; |
| 49 | +import { createPmMcpHandler } from "../../../pm/src/mcp-handler"; | |
| 31 | 50 | import { createPmOrganizationsService } from "../../../pm/src/organizations-service"; |
| 32 | 51 | import { createPmPoliciesService } from "../../../pm/src/policies-service"; |
| 52 | +import { createPmExecuteRuntimeRun } from "../../../pm/src/runtime-execution-port"; | |
| 33 | 53 | import { createPmStorageService } from "../../../pm/src/storage-service"; |
| 34 | 54 | import { createPmToolsService } from "../../../pm/src/tools-service"; |
| 35 | 55 | import { createPmWorkspacesService } from "../../../pm/src/workspaces-service"; |
| @@ -90,6 +110,7 @@ type ControlPlaneRuntime = { · 54 unmodified lines | ||
| 90 | 110 | toolArtifactStore: ToolArtifactStore; |
| 91 | 111 | fetchOpenApiDocument: typeof fetchOpenApiDocument; |
| 92 | 112 | handleControlPlane: (request: Request) => Promise<Response>; |
| 113 | + handleMcp: (request: Request, workspaceId: string) => Promise<Response>; | |
| 93 | 114 | dispose: () => Promise<void>; |
| 94 | 115 | }; |
| 95 | 116 | |
| @@ -127,6 +148,22 @@ const resolveControlPlaneDataDir = (): string => · 31 unmodified lines | ||
| 127 | 148 | const resolveStateRootDir = (): string => |
| 128 | 149 | defaultControlPlaneStateRootDir; |
| 129 | 150 | |
| 151 | +const readConfiguredRuntimeKind = (value: string | undefined): string | undefined => { | |
| 152 | + const normalized = value?.trim(); | |
| 153 | + return normalized && normalized.length > 0 ? normalized : undefined; | |
| 154 | +}; | |
| 155 | + | |
| 156 | +const readBooleanFlag = (value: string | undefined): boolean => { | |
| 157 | + const normalized = value?.trim().toLowerCase(); | |
| 158 | + return normalized === "1" || normalized === "true" || normalized === "yes"; | |
| 159 | +}; | |
| 160 | + | |
| 161 | +const readConfiguredToolExposureMode = ( | |
| 162 | + value: string | undefined, | |
| 163 | +): ExecuteToolExposureMode => | |
| 164 | + parseExecuteToolExposureMode(value ?? undefined) ?? | |
| 165 | + defaultExecuteToolExposureMode; | |
| 166 | + | |
| 130 | 167 | const openApiSyncRetryDelayMs = 300; |
| 131 | 168 | |
| 132 | 169 | const formatCause = (cause: unknown): string => { |
| @@ -256,6 +293,35 @@ const createControlPlaneRuntime = async (): Promise<ControlPlaneRuntime> => { · 123 unmodified lines | ||
| 256 | 293 | |
| 257 | 294 | const sourceStore = persistence.sourceStore; |
| 258 | 295 | const toolArtifactStore = persistence.toolArtifactStore; |
| 296 | + const runtimeAdapterList = [ | |
| 297 | + makeLocalInProcessRuntimeAdapter(), | |
| 298 | + makeDenoSubprocessRuntimeAdapter(), | |
| 299 | + makeCloudflareWorkerLoaderRuntimeAdapter(), | |
| 300 | + ]; | |
| 301 | + const runtimeAdapters = makeRuntimeAdapterRegistry(runtimeAdapterList); | |
| 302 | + const defaultRuntimeKind = | |
| 303 | + readConfiguredRuntimeKind(process.env.PM_RUNTIME_KIND) | |
| 304 | + ?? runtimeAdapterList[0]?.kind | |
| 305 | + ?? "local-inproc"; | |
| 306 | + const requireToolApprovals = readBooleanFlag(process.env.PM_REQUIRE_TOOL_APPROVALS); | |
| 307 | + const defaultToolExposureMode = readConfiguredToolExposureMode( | |
| 308 | + process.env.PM_TOOL_EXPOSURE_MODE, | |
| 309 | + ); | |
| 310 | + const toolProviderRegistry = makeToolProviderRegistry([ | |
| 311 | + makeOpenApiToolProvider(), | |
| 312 | + makeMcpToolProvider(), | |
| 313 | + makeGraphqlToolProvider(), | |
| 314 | + ]); | |
| 315 | + const persistentApprovalPolicy = createPmPersistentToolApprovalPolicy( | |
| 316 | + persistence.rows, | |
| 317 | + { | |
| 318 | + requireApprovals: requireToolApprovals, | |
| 319 | + }, | |
| 320 | + ); | |
| 321 | + const mcpHandlers = new Map< | |
| 322 | + string, | |
| 323 | + (request: Request) => Promise<Response> | |
| 324 | + >(); | |
| 259 | 325 | |
| 260 | 326 | const sourceCatalog = makeSourceCatalogService(sourceStore); |
| 261 | 327 | const sourceManager = makeSourceManagerService(toolArtifactStore); |
| @@ -373,12 +439,44 @@ const createControlPlaneRuntime = async (): Promise<ControlPlaneRuntime> => { · 111 unmodified lines | ||
| 373 | 439 | PmActorLive(persistence.rows), |
| 374 | 440 | ); |
| 375 | 441 | |
| 442 | + const resolveMcpHandler = (workspaceId: string): ((request: Request) => Promise<Response>) => { | |
| 443 | + const existing = mcpHandlers.get(workspaceId); | |
| 444 | + if (existing) { | |
| 445 | + return existing; | |
| 446 | + } | |
| 447 | + | |
| 448 | + const toolRegistry = createSourceToolRegistry({ | |
| 449 | + workspaceId, | |
| 450 | + sourceStore, | |
| 451 | + toolArtifactStore, | |
| 452 | + toolProviderRegistry, | |
| 453 | + approvalPolicy: persistentApprovalPolicy, | |
| 454 | + }); | |
| 455 | + const executeRuntimeRun = createPmExecuteRuntimeRun({ | |
| 456 | + defaultRuntimeKind, | |
| 457 | + runtimeAdapters, | |
| 458 | + toolRegistry, | |
| 459 | + }); | |
| 460 | + const runExecutor = createRunExecutor(executeRuntimeRun); | |
| 461 | + const next = createPmMcpHandler(runExecutor.executeRun, { | |
| 462 | + toolRegistry, | |
| 463 | + defaultToolExposureMode, | |
| 464 | + }); | |
| 465 | + | |
| 466 | + mcpHandlers.set(workspaceId, next); | |
| 467 | + return next; | |
| 468 | + }; | |
| 469 | + | |
| 376 | 470 | return { |
| 377 | 471 | persistence, |
| 378 | 472 | sourceStore, |
| 379 | 473 | toolArtifactStore, |
| 380 | 474 | fetchOpenApiDocument, |
| 381 | 475 | handleControlPlane: controlPlaneWebHandler.handler, |
| 476 | + handleMcp: async (request, workspaceId) => { | |
| 477 | + const handler = resolveMcpHandler(workspaceId); | |
| 478 | + return handler(request); | |
| 479 | + }, | |
| 382 | 480 | dispose: async () => { |
| 383 | 481 | await controlPlaneWebHandler.dispose(); |
| 384 | 482 | await persistence.close(); |
| @@ -0,0 +1,181 @@ | ||
| 1 | +import { createRemoteJWKSet, jwtVerify } from "jose"; | |
| 2 | + | |
| 3 | +import { externalOriginFromRequest } from "../workos"; | |
| 4 | + | |
| 5 | +type McpAuthConfig = { | |
| 6 | + enabled: boolean; | |
| 7 | + authorizationServer: string | null; | |
| 8 | + jwks: ReturnType<typeof createRemoteJWKSet> | null; | |
| 9 | +}; | |
| 10 | + | |
| 11 | +export type VerifiedMcpToken = { | |
| 12 | + subject: string; | |
| 13 | + email: string | null; | |
| 14 | + displayName: string | null; | |
| 15 | +}; | |
| 16 | + | |
| 17 | +const trim = (value: string | undefined): string | undefined => { | |
| 18 | + const candidate = value?.trim(); | |
| 19 | + return candidate && candidate.length > 0 ? candidate : undefined; | |
| 20 | +}; | |
| 21 | + | |
| 22 | +const toAuthorizationServerUrl = (value: string): string | null => { | |
| 23 | + const candidate = | |
| 24 | + value.startsWith("http://") || value.startsWith("https://") | |
| 25 | + ? value | |
| 26 | + : `https://${value}`; | |
| 27 | + | |
| 28 | + try { | |
| 29 | + return new URL(candidate).origin; | |
| 30 | + } catch { | |
| 31 | + return null; | |
| 32 | + } | |
| 33 | +}; | |
| 34 | + | |
| 35 | +const resolveAuthorizationServer = (): string | null => { | |
| 36 | + const candidates = [ | |
| 37 | + process.env.MCP_AUTHORIZATION_SERVER, | |
| 38 | + process.env.MCP_AUTHORIZATION_SERVER_URL, | |
| 39 | + process.env.WORKOS_AUTHKIT_ISSUER, | |
| 40 | + process.env.WORKOS_AUTHKIT_DOMAIN, | |
| 41 | + ]; | |
| 42 | + | |
| 43 | + for (const candidate of candidates) { | |
| 44 | + const normalized = trim(candidate); | |
| 45 | + if (!normalized) { | |
| 46 | + continue; | |
| 47 | + } | |
| 48 | + | |
| 49 | + const parsed = toAuthorizationServerUrl(normalized); | |
| 50 | + if (parsed) { | |
| 51 | + return parsed; | |
| 52 | + } | |
| 53 | + } | |
| 54 | + | |
| 55 | + return null; | |
| 56 | +}; | |
| 57 | + | |
| 58 | +let cachedAuthConfig: McpAuthConfig | null = null; | |
| 59 | + | |
| 60 | +export const getMcpAuthConfig = (): McpAuthConfig => { | |
| 61 | + const authorizationServer = resolveAuthorizationServer(); | |
| 62 | + | |
| 63 | + if ( | |
| 64 | + cachedAuthConfig | |
| 65 | + && cachedAuthConfig.authorizationServer === authorizationServer | |
| 66 | + ) { | |
| 67 | + return cachedAuthConfig; | |
| 68 | + } | |
| 69 | + | |
| 70 | + if (!authorizationServer) { | |
| 71 | + cachedAuthConfig = { | |
| 72 | + enabled: false, | |
| 73 | + authorizationServer: null, | |
| 74 | + jwks: null, | |
| 75 | + }; | |
| 76 | + return cachedAuthConfig; | |
| 77 | + } | |
| 78 | + | |
| 79 | + cachedAuthConfig = { | |
| 80 | + enabled: true, | |
| 81 | + authorizationServer, | |
| 82 | + jwks: createRemoteJWKSet(new URL("/oauth2/jwks", authorizationServer)), | |
| 83 | + }; | |
| 84 | + | |
| 85 | + return cachedAuthConfig; | |
| 86 | +}; | |
| 87 | + | |
| 88 | +const parseBearerToken = (request: Request): string | null => { | |
| 89 | + const header = request.headers.get("authorization"); | |
| 90 | + if (!header || !header.startsWith("Bearer ")) { | |
| 91 | + return null; | |
| 92 | + } | |
| 93 | + | |
| 94 | + const token = header.slice("Bearer ".length).trim(); | |
| 95 | + return token.length > 0 ? token : null; | |
| 96 | +}; | |
| 97 | + | |
| 98 | +export const resourceMetadataUrl = (request: Request): string => { | |
| 99 | + const url = new URL(request.url); | |
| 100 | + const origin = externalOriginFromRequest(request); | |
| 101 | + const metadata = new URL( | |
| 102 | + "/.well-known/oauth-protected-resource", | |
| 103 | + origin, | |
| 104 | + ); | |
| 105 | + metadata.search = url.search; | |
| 106 | + | |
| 107 | + const resource = new URL(url.pathname, origin); | |
| 108 | + resource.search = url.search; | |
| 109 | + metadata.searchParams.set("resource", resource.toString()); | |
| 110 | + | |
| 111 | + return metadata.toString(); | |
| 112 | +}; | |
| 113 | + | |
| 114 | +export const unauthorizedMcpResponse = ( | |
| 115 | + request: Request, | |
| 116 | + message: string, | |
| 117 | +): Response => { | |
| 118 | + const challenge = [ | |
| 119 | + 'Bearer error="unauthorized"', | |
| 120 | + 'error_description="Authorization needed"', | |
| 121 | + `resource_metadata="${resourceMetadataUrl(request)}"`, | |
| 122 | + ].join(", "); | |
| 123 | + | |
| 124 | + return Response.json( | |
| 125 | + { error: message }, | |
| 126 | + { | |
| 127 | + status: 401, | |
| 128 | + headers: { | |
| 129 | + "WWW-Authenticate": challenge, | |
| 130 | + }, | |
| 131 | + }, | |
| 132 | + ); | |
| 133 | +}; | |
| 134 | + | |
| 135 | +const readDisplayName = (payload: Record<string, unknown>): string | null => { | |
| 136 | + const direct = payload.name; | |
| 137 | + if (typeof direct === "string" && direct.trim().length > 0) { | |
| 138 | + return direct.trim(); | |
| 139 | + } | |
| 140 | + | |
| 141 | + const given = typeof payload.given_name === "string" ? payload.given_name.trim() : ""; | |
| 142 | + const family = typeof payload.family_name === "string" ? payload.family_name.trim() : ""; | |
| 143 | + const combined = [given, family].filter((part) => part.length > 0).join(" "); | |
| 144 | + | |
| 145 | + return combined.length > 0 ? combined : null; | |
| 146 | +}; | |
| 147 | + | |
| 148 | +export const verifyMcpBearerToken = async ( | |
| 149 | + request: Request, | |
| 150 | + config: McpAuthConfig, | |
| 151 | +): Promise<VerifiedMcpToken | null> => { | |
| 152 | + if (!config.enabled || !config.authorizationServer || !config.jwks) { | |
| 153 | + return null; | |
| 154 | + } | |
| 155 | + | |
| 156 | + const token = parseBearerToken(request); | |
| 157 | + if (!token) { | |
| 158 | + return null; | |
| 159 | + } | |
| 160 | + | |
| 161 | + try { | |
| 162 | + const { payload } = await jwtVerify(token, config.jwks, { | |
| 163 | + issuer: config.authorizationServer, | |
| 164 | + }); | |
| 165 | + | |
| 166 | + if (typeof payload.sub !== "string" || payload.sub.length === 0) { | |
| 167 | + return null; | |
| 168 | + } | |
| 169 | + | |
| 170 | + const email = typeof payload.email === "string" ? payload.email : null; | |
| 171 | + const displayName = readDisplayName(payload); | |
| 172 | + | |
| 173 | + return { | |
| 174 | + subject: payload.sub, | |
| 175 | + email, | |
| 176 | + displayName, | |
| 177 | + }; | |
| 178 | + } catch { | |
| 179 | + return null; | |
| 180 | + } | |
| 181 | +}; | |
| @@ -11,9 +11,13 @@ · 10 unmodified lines | ||
| 11 | 11 | "dependencies": { |
| 12 | 12 | "@effect-atom/atom": "^0.4.11", |
| 13 | 13 | "@effect-atom/atom-react": "^0.4.6", |
| 14 | + "@executor-v2/engine": "workspace:*", | |
| 14 | 15 | "@executor-v2/management-api": "workspace:*", |
| 15 | 16 | "@executor-v2/persistence-ports": "workspace:*", |
| 16 | 17 | "@executor-v2/persistence-sql": "workspace:*", |
| 18 | + "@executor-v2/runtime-cloudflare-worker-loader": "workspace:*", | |
| 19 | + "@executor-v2/runtime-deno-subprocess": "workspace:*", | |
| 20 | + "@executor-v2/runtime-local-inproc": "workspace:*", | |
| 17 | 21 | "@executor-v2/schema": "workspace:*", |
| 18 | 22 | "@modelcontextprotocol/sdk": "^1.27.1", |
| 19 | 23 | "@streamdown/code": "^1.0.3", |
| @@ -21,6 +25,7 @@ · 1 unmodified line | ||
| 21 | 25 | "class-variance-authority": "^0.7.1", |
| 22 | 26 | "clsx": "^2.1.1", |
| 23 | 27 | "effect": "catalog:", |
| 28 | + "jose": "^6.1.3", | |
| 24 | 29 | "next": "^15.4.6", |
| 25 | 30 | "next-themes": "^0.4.6", |
| 26 | 31 | "react": "^19.1.1", |
| @@ -47,9 +47,13 @@ · 46 unmodified lines | ||
| 47 | 47 | "dependencies": { |
| 48 | 48 | "@effect-atom/atom": "^0.4.11", |
| 49 | 49 | "@effect-atom/atom-react": "^0.4.6", |
| 50 | + "@executor-v2/engine": "workspace:*", | |
| 50 | 51 | "@executor-v2/management-api": "workspace:*", |
| 51 | 52 | "@executor-v2/persistence-ports": "workspace:*", |
| 52 | 53 | "@executor-v2/persistence-sql": "workspace:*", |
| 54 | + "@executor-v2/runtime-cloudflare-worker-loader": "workspace:*", | |
| 55 | + "@executor-v2/runtime-deno-subprocess": "workspace:*", | |
| 56 | + "@executor-v2/runtime-local-inproc": "workspace:*", | |
| 53 | 57 | "@executor-v2/schema": "workspace:*", |
| 54 | 58 | "@modelcontextprotocol/sdk": "^1.27.1", |
| 55 | 59 | "@streamdown/code": "^1.0.3", |
| @@ -57,6 +61,7 @@ · 1 unmodified line | ||
| 57 | 61 | "class-variance-authority": "^0.7.1", |
| 58 | 62 | "clsx": "^2.1.1", |
| 59 | 63 | "effect": "catalog:", |
| 64 | + "jose": "^6.1.3", | |
| 60 | 65 | "next": "^15.4.6", |
| 61 | 66 | "next-themes": "^0.4.6", |
| 62 | 67 | "react": "^19.1.1", |
| @@ -1472,7 +1477,7 @@ · 1409 unmodified lines | ||
| 1472 | 1477 | |
| 1473 | 1478 | "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], |
| 1474 | 1479 | |
| 1475 | − "jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], | |
| 1480 | + "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], | |
| 1476 | 1481 | |
| 1477 | 1482 | "js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], |
| 1478 | 1483 | |
| @@ -2348,8 +2353,6 @@ · 869 unmodified lines | ||
| 2348 | 2353 | |
| 2349 | 2354 | "@mapbox/node-pre-gyp/tar": ["tar@7.5.9", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg=="], |
| 2350 | 2355 | |
| 2351 | − "@modelcontextprotocol/sdk/jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], | |
| 2352 | − | |
| 2353 | 2356 | "@neondatabase/serverless/@types/node": ["@types/node@22.19.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw=="], |
| 2354 | 2357 | |
| 2355 | 2358 | "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], |
| @@ -2430,7 +2433,7 @@ · 74 unmodified lines | ||
| 2430 | 2433 | |
| 2431 | 2434 | "@vercel/static-config/ajv": ["ajv@8.6.3", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-SMJOdDP6LqTkD0Uq8qLi+gMwSt0imXLSV080qFVwJCpH9U6Mb+SUGHAXM0KNbcBPguytWyvFxcHgMLe2D2XSpw=="], |
| 2432 | 2435 | |
| 2433 | − "@workos-inc/node/jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], | |
| 2436 | + "@workos-inc/authkit-nextjs/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], | |
| 2434 | 2437 | |
| 2435 | 2438 | "accepts/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], |
| 2436 | 2439 | |
| @@ -2522,6 +2525,8 @@ · 85 unmodified lines | ||
| 2522 | 2525 | |
| 2523 | 2526 | "unstorage/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], |
| 2524 | 2527 | |
| 2528 | + "vercel/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], | |
| 2529 | + | |
| 2525 | 2530 | "vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], |
| 2526 | 2531 | |
| 2527 | 2532 | "widest-line/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], |
| @@ -12,8 +12,13 @@ const readWasmBytes = async (): Promise<Uint8Array> => { · 11 unmodified lines | ||
| 12 | 12 | const candidates: string[] = []; |
| 13 | 13 | |
| 14 | 14 | try { |
| 15 | + const wasmModuleUrl = new URL( | |
| 16 | + "./openapi-extractor-wasm/openapi_extractor_bg.wasm", | |
| 17 | + import.meta.url, | |
| 18 | + ).toString(); | |
| 19 | + | |
| 15 | 20 | candidates.push( |
| 16 | − fileURLToPath(new URL("./openapi-extractor-wasm/openapi_extractor_bg.wasm", import.meta.url)), | |
| 21 | + fileURLToPath(wasmModuleUrl), | |
| 17 | 22 | ); |
| 18 | 23 | } catch { |
| 19 | 24 | // Next.js serverless bundling can provide non-URL import.meta.url values. |
| @@ -131,7 +131,7 @@ const defaultDenoExecutable = (): string => { · 130 unmodified lines | ||
| 131 | 131 | }; |
| 132 | 132 | |
| 133 | 133 | const workerScriptPath = fileURLToPath( |
| 134 | − new URL("./deno-subprocess-worker.mjs", import.meta.url), | |
| 134 | + new URL("./deno-subprocess-worker.mjs", import.meta.url).toString(), | |
| 135 | 135 | ); |
| 136 | 136 | |
| 137 | 137 | const writeMessage = ( |