History

feat: add MCP OAuth parity for source onboarding

Rhys Sullivan committed 8 months ago

Commit 54cb0481 parent ad80b6a15 files · +2036 −9Browse files

Unified · Split

15 files
  1. v2/apps/convex/convex/http.ts +14 −0
  2. v2/apps/convex/convex/mcp.ts +93 −2
  3. v2/apps/convex/convex/mcp_auth.ts +216 −0
  4. v2/apps/convex/convex/source_tool_registry.ts +154 −3
  5. v2/apps/web/app/mcp/oauth/callback/route.ts +169 −0
  6. v2/apps/web/app/mcp/oauth/complete/page.tsx +86 −0
  7. v2/apps/web/app/mcp/oauth/detect/route.ts +178 −0
  8. v2/apps/web/app/mcp/oauth/result/route.ts +89 −0
  9. v2/apps/web/app/mcp/oauth/start/route.ts +152 −0
  10. v2/apps/web/components/sources/sources-view.tsx +307 −4
  11. v2/apps/web/lib/mcp/oauth-fetch.ts +112 −0
  12. v2/apps/web/lib/mcp/oauth-popup.ts +128 −0
  13. v2/apps/web/lib/mcp/oauth-provider.ts +249 −0
  14. v2/apps/web/lib/mcp/oauth-request.ts +59 −0
  15. v2/apps/web/lib/mcp/oauth-url.ts +30 −0
v2/apps/convex/convex/http.ts+14−0
@@ -2,6 +2,10 @@ import { httpRouter } from "convex/server"; · 1 unmodified line
22
33 import { controlPlaneHttpHandler } from "./controlPlane";
44 import { mcpHandler } from "./mcp";
5+import {
6+ oauthAuthorizationServerHandler,
7+ oauthProtectedResourceHandler,
8+} from "./mcp_auth";
59 import { handleToolCallHttp } from "./runtimeCallbacks";
610
711 const http = httpRouter();
@@ -9,6 +13,16 @@ const http = httpRouter(); · 1 unmodified line
913 http.route({ path: "/v1/mcp", method: "POST", handler: mcpHandler });
1014 http.route({ path: "/v1/mcp", method: "GET", handler: mcpHandler });
1115 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+});
1226 http.route({
1327 path: "/v1/runtime/tool-call",
1428 method: "POST",
v2/apps/convex/convex/mcp.ts+93−2
@@ -3,8 +3,13 @@ import { createExecutorRunClient } from "@executor-v2/sdk"; · 2 unmodified lines
33 import * as Effect from "effect/Effect";
44
55 import { internal } from "./_generated/api";
6−import { httpAction } from "./_generated/server";
6+import { httpAction, type ActionCtx } from "./_generated/server";
77 import { executeRunImpl } from "./executor";
8+import {
9+ getMcpAuthConfig,
10+ unauthorizedMcpResponse,
11+ verifyMcpToken,
12+} from "./mcp_auth";
813 import { createConvexSourceToolRegistry } from "./source_tool_registry";
914
1015 const readConfiguredWorkspaceId = (value: string | undefined): string => {
@@ -12,10 +17,96 @@ const readConfiguredWorkspaceId = (value: string | undefined): string => { · 1 unmodified line
1217 return normalized && normalized.length > 0 ? normalized : "ws_local";
1318 };
1419
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);
1668 const runtimeInternal = internal as any;
1769
1870 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+
19110 const toolRegistry = createConvexSourceToolRegistry(ctx, workspaceId);
20111
21112 const runClient = createExecutorRunClient(async (input) => {
v2/apps/convex/convex/mcp_auth.ts+216−0
@@ -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+});
v2/apps/convex/convex/source_tool_registry.ts+154−3
@@ -266,6 +266,139 @@ const safeJsonParse = (value: string): unknown => { · 265 unmodified lines
266266 }
267267 };
268268
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+
269402 const headersToRecord = (headers: Headers): Record<string, string> => {
270403 const record: Record<string, string> = {};
271404 headers.forEach((value, key) => {
@@ -829,11 +962,16 @@ const createConvexGraphqlToolProvider = (ctx: ActionCtx): ToolProvider => ({ · 557 unmodified lines
829962 operationName = payload.fieldName;
830963 }
831964
965+ const sourceHeaders = await resolveSourceHeadersForInvocation(ctx, input.source);
966+
832967 const response = await fetch(payload.endpoint, {
833968 method: "POST",
834− headers: {
835− "content-type": "application/json",
836− },
969+ headers: mergeHeaders(
970+ {
971+ "content-type": "application/json",
972+ },
973+ sourceHeaders,
974+ ),
837975 body: JSON.stringify({
838976 query,
839977 variables,
@@ -878,12 +1016,17 @@ const postMcpJsonRpc = async ( · 38 unmodified lines
8781016 endpoint: URL,
8791017 body: unknown,
8801018 sessionId: string | null,
1019+ sourceHeaders: Record<string, string>,
8811020 ): Promise<Response> => {
8821021 const headers = new Headers({
8831022 "content-type": "application/json",
8841023 accept: "application/json, text/event-stream",
8851024 });
8861025
1026+ for (const [key, value] of Object.entries(sourceHeaders)) {
1027+ headers.set(key, value);
1028+ }
1029+
8871030 if (sessionId && sessionId.trim().length > 0) {
8881031 headers.set("mcp-session-id", sessionId);
8891032 }
@@ -934,8 +1077,13 @@ const createConvexMcpToolProvider = (ctx: ActionCtx): ToolProvider => ({ · 44 unmodified lines
9341077 invoke: (input) =>
9351078 Effect.tryPromise({
9361079 try: async () => {
1080+ if (!input.source) {
1081+ throw new Error("MCP provider requires a source");
1082+ }
1083+
9371084 const payload = decodeMcpInvocationPayload(input.tool.providerPayload);
9381085 const args = normalizeGraphqlInvokeInput(input.args);
1086+ const sourceHeaders = await resolveSourceHeadersForInvocation(ctx, input.source);
9391087
9401088 if (payload.transport !== "streamable-http") {
9411089 throw new Error(
@@ -960,6 +1108,7 @@ const createConvexMcpToolProvider = (ctx: ActionCtx): ToolProvider => ({ · 18 unmodified lines
9601108 },
9611109 },
9621110 null,
1111+ sourceHeaders,
9631112 );
9641113 const initializeBody = await decodeMcpJsonResponse(initializeResponse);
9651114
@@ -984,6 +1133,7 @@ const createConvexMcpToolProvider = (ctx: ActionCtx): ToolProvider => ({ · 18 unmodified lines
9841133 params: {},
9851134 },
9861135 sessionId,
1136+ sourceHeaders,
9871137 );
9881138
9891139 const callResponse = await postMcpJsonRpc(
@@ -998,6 +1148,7 @@ const createConvexMcpToolProvider = (ctx: ActionCtx): ToolProvider => ({ · 8 unmodified lines
9981148 },
9991149 },
10001150 sessionId,
1151+ sourceHeaders,
10011152 );
10021153 const callBody = await decodeMcpJsonResponse(callResponse);
10031154
v2/apps/web/app/mcp/oauth/callback/route.ts+169−0
@@ -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+}
v2/apps/web/app/mcp/oauth/complete/page.tsx+86−0
@@ -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+}
v2/apps/web/app/mcp/oauth/detect/route.ts+178−0
@@ -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+}
v2/apps/web/app/mcp/oauth/result/route.ts+89−0
@@ -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+}
v2/apps/web/app/mcp/oauth/start/route.ts+152−0
@@ -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+}
v2/apps/web/components/sources/sources-view.tsx+307−4
@@ -1,18 +1,21 @@
11 "use client";
22
33 import type { ChangeEvent, FormEvent } from "react";
4−import { useMemo, useState } from "react";
4+import { useEffect, useMemo, useState } from "react";
55 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";
77
88 import { useWorkspace } from "../../lib/hooks/use-workspace";
99 import {
10+ credentialBindingsByWorkspace,
1011 optimisticRemoveSources,
1112 optimisticSourcesByWorkspace,
1213 optimisticUpsertSources,
1314 removeSource,
1415 sourcesByWorkspace,
1516 sourcesPendingByWorkspace,
17+ toCredentialBindingUpsertPayload,
18+ upsertCredentialBinding,
1619 upsertSource,
1720 } from "../../lib/control-plane/atoms";
1821 import {
@@ -22,6 +25,10 @@ import { · 3 unmodified lines
2225 type LegacySourceFormState,
2326 type LegacySourceType,
2427 } from "../../lib/control-plane/legacy-source";
28+import {
29+ startMcpOAuthPopup,
30+ type McpOAuthPopupSuccess,
31+} from "../../lib/mcp/oauth-popup";
2532 import { Badge } from "../ui/badge";
2633 import { Button } from "../ui/button";
2734 import {
@@ -177,6 +184,20 @@ const defaultFormState = (): LegacySourceFormState => ({ · 149 unmodified lines
177184 enabled: true,
178185 });
179186
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+
180201 const statusBadgeVariant = (
181202 status: string,
182203 ): "outline" | "pending" | "approved" | "denied" => {
@@ -197,14 +218,25 @@ export default function SourcesView() { · 14 unmodified lines
197218
198219 const sources = useAtomValue(sourcesByWorkspace(workspaceId));
199220 const sourcesPending = useAtomValue(sourcesPendingByWorkspace(workspaceId));
221+ const credentialBindings = useAtomValue(credentialBindingsByWorkspace(workspaceId));
200222 const setOptimisticSources = useAtomSet(optimisticSourcesByWorkspace(workspaceId));
201223 const runUpsertSource = useAtomSet(upsertSource, { mode: "promise" });
224+ const runUpsertCredentialBinding = useAtomSet(upsertCredentialBinding, {
225+ mode: "promise",
226+ });
202227 const runRemoveSource = useAtomSet(removeSource, { mode: "promise" });
203228
204229 const [formState, setFormState] = useState<LegacySourceFormState>(() => defaultFormState());
205230 const [searchQuery, setSearchQuery] = useState("");
206231 const [templateQuery, setTemplateQuery] = useState("");
207232 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+ );
208240
209241 const sourceItems = useMemo(
210242 () => sources.items.map(sourceToLegacyRecord),
@@ -246,6 +278,33 @@ export default function SourcesView() { · 35 unmodified lines
246278
247279 const isEditing = Boolean(formState.id);
248280
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+
249308 const setFormField = <K extends keyof LegacySourceFormState>(
250309 key: K,
251310 value: LegacySourceFormState[K],
@@ -258,9 +317,122 @@ export default function SourcesView() { · 6 unmodified lines
258317
259318 const resetForm = () => {
260319 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+ });
261431 };
262432
263433 const handleTemplateUse = (template: CatalogTemplate) => {
434+ setMcpOAuthSession(null);
435+ setMcpOAuthBusy(false);
264436 setFormState((current) => ({
265437 ...current,
266438 id: undefined,
@@ -279,6 +451,8 @@ export default function SourcesView() { · 12 unmodified lines
279451 return;
280452 }
281453
454+ setMcpOAuthSession(null);
455+ setMcpOAuthBusy(false);
282456 setFormState(formStateFromSource(source));
283457 setStatusText(`Editing ${source.name}.`);
284458 };
@@ -305,6 +479,14 @@ export default function SourcesView() { · 20 unmodified lines
305479 const previousSources = sources.items;
306480 const optimistic = optimisticUpsertSources(previousSources, workspaceId, payload);
307481
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+
308490 setOptimisticSources({
309491 items: optimistic.items,
310492 pendingAck: {
@@ -314,8 +496,74 @@ export default function SourcesView() { · 3 unmodified lines
314496 });
315497
316498 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+
319567 resetForm();
320568 })
321569 .catch(() => {
@@ -498,6 +746,61 @@ export default function SourcesView() { · 176 unmodified lines
498746 </div>
499747 ) : null}
500748
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+
501804 <div className="grid gap-3 sm:grid-cols-2">
502805 <div className="grid gap-1.5">
503806 <label className="text-xs text-muted-foreground" htmlFor="source-auth-type">Auth Type</label>
v2/apps/web/lib/mcp/oauth-fetch.ts+112−0
@@ -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+};
v2/apps/web/lib/mcp/oauth-popup.ts+128−0
@@ -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+};
v2/apps/web/lib/mcp/oauth-provider.ts+249−0
@@ -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+}
v2/apps/web/lib/mcp/oauth-request.ts+59−0
@@ -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://");
v2/apps/web/lib/mcp/oauth-url.ts+30−0
@@ -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+};