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
2 2
3import { controlPlaneHttpHandler } from "./controlPlane";3import { controlPlaneHttpHandler } from "./controlPlane";
4import { mcpHandler } from "./mcp";4import { mcpHandler } from "./mcp";
5import {
6 oauthAuthorizationServerHandler,
7 oauthProtectedResourceHandler,
8} from "./mcp_auth";
5import { handleToolCallHttp } from "./runtimeCallbacks";9import { handleToolCallHttp } from "./runtimeCallbacks";
6 10
7const http = httpRouter();11const http = httpRouter();
@@ -9,6 +13,16 @@ const http = httpRouter(); · 1 unmodified line
9http.route({ path: "/v1/mcp", method: "POST", handler: mcpHandler });13http.route({ path: "/v1/mcp", method: "POST", handler: mcpHandler });
10http.route({ path: "/v1/mcp", method: "GET", handler: mcpHandler });14http.route({ path: "/v1/mcp", method: "GET", handler: mcpHandler });
11http.route({ path: "/v1/mcp", method: "DELETE", handler: mcpHandler });15http.route({ path: "/v1/mcp", method: "DELETE", handler: mcpHandler });
16http.route({
17 path: "/.well-known/oauth-protected-resource",
18 method: "GET",
19 handler: oauthProtectedResourceHandler,
20});
21http.route({
22 path: "/.well-known/oauth-authorization-server",
23 method: "GET",
24 handler: oauthAuthorizationServerHandler,
25});
12http.route({26http.route({
13 path: "/v1/runtime/tool-call",27 path: "/v1/runtime/tool-call",
14 method: "POST",28 method: "POST",
v2/apps/convex/convex/mcp.ts+93−2
@@ -3,8 +3,13 @@ import { createExecutorRunClient } from "@executor-v2/sdk"; · 2 unmodified lines
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4 4
5import { internal } from "./_generated/api";5import { internal } from "./_generated/api";
6import { httpAction } from "./_generated/server";6import { httpAction, type ActionCtx } from "./_generated/server";
7import { executeRunImpl } from "./executor";7import { executeRunImpl } from "./executor";
8import {
9 getMcpAuthConfig,
10 unauthorizedMcpResponse,
11 verifyMcpToken,
12} from "./mcp_auth";
8import { createConvexSourceToolRegistry } from "./source_tool_registry";13import { createConvexSourceToolRegistry } from "./source_tool_registry";
9 14
10const readConfiguredWorkspaceId = (value: string | undefined): string => {15const readConfiguredWorkspaceId = (value: string | undefined): string => {
@@ -12,10 +17,96 @@ const readConfiguredWorkspaceId = (value: string | undefined): string => { · 1 unmodified line
12 return normalized && normalized.length > 0 ? normalized : "ws_local";17 return normalized && normalized.length > 0 ? normalized : "ws_local";
13};18};
14 19
15const workspaceId = readConfiguredWorkspaceId(process.env.CONVEX_WORKSPACE_ID);20const 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
25const readRequestedWorkspaceId = (request: Request, fallbackWorkspaceId: string): string =>
26 readWorkspaceIdFromRequest(request) ?? fallbackWorkspaceId;
27
28const 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
67const fallbackWorkspaceId = readConfiguredWorkspaceId(process.env.CONVEX_WORKSPACE_ID);
16const runtimeInternal = internal as any;68const runtimeInternal = internal as any;
17 69
18export const mcpHandler = httpAction(async (ctx, request) => {70export const mcpHandler = httpAction(async (ctx, request) => {
71 const mcpAuthConfig = getMcpAuthConfig();
72 const requestWorkspaceId = readWorkspaceIdFromRequest(request);
73 const workspaceId = readRequestedWorkspaceId(request, fallbackWorkspaceId);
74
75 if (!mcpAuthConfig.enabled) {
76 if (mcpAuthConfig.required) {
77 return Response.json(
78 {
79 error: "MCP OAuth must be configured for cloud deployments",
80 },
81 { status: 503 },
82 );
83 }
84 } else {
85 const auth = await verifyMcpToken(request, mcpAuthConfig);
86 if (!auth) {
87 return unauthorizedMcpResponse(request, "No valid bearer token provided.");
88 }
89
90 if (!requestWorkspaceId) {
91 return Response.json(
92 {
93 error: "workspaceId query parameter is required when MCP OAuth is enabled",
94 },
95 { status: 400 },
96 );
97 }
98
99 const authorized = await hasWorkspaceAccess(ctx, requestWorkspaceId, auth.subject);
100 if (!authorized) {
101 return Response.json(
102 {
103 error: "Workspace authorization failed",
104 },
105 { status: 403 },
106 );
107 }
108 }
109
19 const toolRegistry = createConvexSourceToolRegistry(ctx, workspaceId);110 const toolRegistry = createConvexSourceToolRegistry(ctx, workspaceId);
20 111
21 const runClient = createExecutorRunClient(async (input) => {112 const runClient = createExecutorRunClient(async (input) => {
v2/apps/convex/convex/mcp_auth.ts+216−0
@@ -0,0 +1,216 @@
1import { createRemoteJWKSet, jwtVerify } from "jose";
2
3import { httpAction } from "./_generated/server";
4
5export const MCP_PATH = "/v1/mcp";
6
7type McpAuthConfig = {
8 required: boolean;
9 enabled: boolean;
10 authorizationServer: string | null;
11 jwks: ReturnType<typeof createRemoteJWKSet> | null;
12};
13
14type VerifiedMcpToken = {
15 provider: "workos";
16 subject: string;
17};
18
19const isTruthyEnvValue = (value: string | undefined): boolean => {
20 const normalized = value?.trim().toLowerCase();
21 return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
22};
23
24const 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
46const 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
53export 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
74const 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
84const 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
96export 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
114export 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
151const 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
161export 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
195export 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
266 }266 }
267};267};
268 268
269const 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
278const parseSourceConfig = (source: Source): Record<string, unknown> =>
279 jsonObjectFromUnknown(safeJsonParse(source.configJson));
280
281const 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
298const 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
331const 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
357const 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
389const 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
269const headersToRecord = (headers: Headers): Record<string, string> => {402const headersToRecord = (headers: Headers): Record<string, string> => {
270 const record: Record<string, string> = {};403 const record: Record<string, string> = {};
271 headers.forEach((value, key) => {404 headers.forEach((value, key) => {
@@ -829,11 +962,16 @@ const createConvexGraphqlToolProvider = (ctx: ActionCtx): ToolProvider => ({ · 557 unmodified lines
829 operationName = payload.fieldName;962 operationName = payload.fieldName;
830 }963 }
831 964
965 const sourceHeaders = await resolveSourceHeadersForInvocation(ctx, input.source);
966
832 const response = await fetch(payload.endpoint, {967 const response = await fetch(payload.endpoint, {
833 method: "POST",968 method: "POST",
834 headers: {969 headers: mergeHeaders(
835 "content-type": "application/json",970 {
836 },971 "content-type": "application/json",
972 },
973 sourceHeaders,
974 ),
837 body: JSON.stringify({975 body: JSON.stringify({
838 query,976 query,
839 variables,977 variables,
@@ -878,12 +1016,17 @@ const postMcpJsonRpc = async ( · 38 unmodified lines
878 endpoint: URL,1016 endpoint: URL,
879 body: unknown,1017 body: unknown,
880 sessionId: string | null,1018 sessionId: string | null,
1019 sourceHeaders: Record<string, string>,
881): Promise<Response> => {1020): Promise<Response> => {
882 const headers = new Headers({1021 const headers = new Headers({
883 "content-type": "application/json",1022 "content-type": "application/json",
884 accept: "application/json, text/event-stream",1023 accept: "application/json, text/event-stream",
885 });1024 });
886 1025
1026 for (const [key, value] of Object.entries(sourceHeaders)) {
1027 headers.set(key, value);
1028 }
1029
887 if (sessionId && sessionId.trim().length > 0) {1030 if (sessionId && sessionId.trim().length > 0) {
888 headers.set("mcp-session-id", sessionId);1031 headers.set("mcp-session-id", sessionId);
889 }1032 }
@@ -934,8 +1077,13 @@ const createConvexMcpToolProvider = (ctx: ActionCtx): ToolProvider => ({ · 44 unmodified lines
934 invoke: (input) =>1077 invoke: (input) =>
935 Effect.tryPromise({1078 Effect.tryPromise({
936 try: async () => {1079 try: async () => {
1080 if (!input.source) {
1081 throw new Error("MCP provider requires a source");
1082 }
1083
937 const payload = decodeMcpInvocationPayload(input.tool.providerPayload);1084 const payload = decodeMcpInvocationPayload(input.tool.providerPayload);
938 const args = normalizeGraphqlInvokeInput(input.args);1085 const args = normalizeGraphqlInvokeInput(input.args);
1086 const sourceHeaders = await resolveSourceHeadersForInvocation(ctx, input.source);
939 1087
940 if (payload.transport !== "streamable-http") {1088 if (payload.transport !== "streamable-http") {
941 throw new Error(1089 throw new Error(
@@ -960,6 +1108,7 @@ const createConvexMcpToolProvider = (ctx: ActionCtx): ToolProvider => ({ · 18 unmodified lines
960 },1108 },
961 },1109 },
962 null,1110 null,
1111 sourceHeaders,
963 );1112 );
964 const initializeBody = await decodeMcpJsonResponse(initializeResponse);1113 const initializeBody = await decodeMcpJsonResponse(initializeResponse);
965 1114
@@ -984,6 +1133,7 @@ const createConvexMcpToolProvider = (ctx: ActionCtx): ToolProvider => ({ · 18 unmodified lines
984 params: {},1133 params: {},
985 },1134 },
986 sessionId,1135 sessionId,
1136 sourceHeaders,
987 );1137 );
988 1138
989 const callResponse = await postMcpJsonRpc(1139 const callResponse = await postMcpJsonRpc(
@@ -998,6 +1148,7 @@ const createConvexMcpToolProvider = (ctx: ActionCtx): ToolProvider => ({ · 8 unmodified lines
998 },1148 },
999 },1149 },
1000 sessionId,1150 sessionId,
1151 sourceHeaders,
1001 );1152 );
1002 const callBody = await decodeMcpJsonResponse(callResponse);1153 const callBody = await decodeMcpJsonResponse(callResponse);
1003 1154
v2/apps/web/app/mcp/oauth/callback/route.ts+169−0
@@ -0,0 +1,169 @@
1import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
2import type { NextRequest } from "next/server";
3import { NextResponse } from "next/server";
4
5import { fetchMcpOAuth } from "../../../../lib/mcp/oauth-fetch";
6import { getExternalOrigin, isExternalHttps } from "../../../../lib/mcp/oauth-request";
7import { parseMcpSourceUrl } from "../../../../lib/mcp/oauth-url";
8import {
9 buildPendingCookieName,
10 decodePendingCookieValue,
11 encodePopupResultCookieValue,
12 MCP_OAUTH_RESULT_COOKIE,
13 McpPopupOAuthProvider,
14 type McpOAuthPopupResult,
15} from "../../../../lib/mcp/oauth-provider";
16
17const MCP_OAUTH_CALLBACK_FLOW_TIMEOUT_MS = 75_000;
18const MCP_OAUTH_CALLBACK_REQUEST_TIMEOUT_MS = 20_000;
19
20const 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
42const 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
72const toErrorMessage = (error: unknown, fallback: string): string =>
73 error instanceof Error ? error.message : fallback;
74
75export 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
3import { useEffect, useRef, useState } from "react";
4
5type 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
23const 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
29const 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
44export 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 @@
1import { discoverOAuthProtectedResourceMetadata, extractWWWAuthenticateParams } from "@modelcontextprotocol/sdk/client/auth.js";
2import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js";
3
4import { fetchMcpOAuth } from "../../../../lib/mcp/oauth-fetch";
5import { parseMcpSourceUrl } from "../../../../lib/mcp/oauth-url";
6
7type DetectResponse = {
8 oauth: boolean;
9 authorizationServers: Array<string>;
10 detail?: string;
11};
12
13const MCP_OAUTH_CHALLENGE_PROBE_TIMEOUT_MS = 2_500;
14const MCP_OAUTH_METADATA_TIMEOUT_MS = 12_000;
15
16const noStoreDetectJson = (payload: DetectResponse, status = 200): Response =>
17 Response.json(payload, {
18 status,
19 headers: {
20 "cache-control": "no-store",
21 },
22 });
23
24const 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
46const 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
90const toErrorDetail = (error: unknown, fallback: string): string =>
91 error instanceof Error ? error.message : fallback;
92
93export 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 @@
1import type { NextRequest } from "next/server";
2import { NextResponse } from "next/server";
3
4import {
5 decodePopupResultCookieValue,
6 MCP_OAUTH_RESULT_COOKIE,
7 type McpOAuthPopupResult,
8} from "../../../../lib/mcp/oauth-provider";
9
10type 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
28const 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
69export 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 @@
1import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
2import type { NextRequest } from "next/server";
3import { NextResponse } from "next/server";
4
5import { fetchMcpOAuth } from "../../../../lib/mcp/oauth-fetch";
6import { getExternalOrigin, isExternalHttps } from "../../../../lib/mcp/oauth-request";
7import { parseMcpSourceUrl } from "../../../../lib/mcp/oauth-url";
8import {
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
18const MCP_OAUTH_FLOW_TIMEOUT_MS = 75_000;
19const MCP_OAUTH_REQUEST_TIMEOUT_MS = 20_000;
20
21const 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
43const 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
63const badPopupResponse = (request: NextRequest, message: string): Response =>
64 popupResultRedirect(request, { ok: false, error: message });
65
66const toErrorMessage = (error: unknown, fallback: string): string =>
67 error instanceof Error ? error.message : fallback;
68
69export 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 @@
1"use client";1"use client";
2 2
3import type { ChangeEvent, FormEvent } from "react";3import type { ChangeEvent, FormEvent } from "react";
4import { useMemo, useState } from "react";4import { useEffect, useMemo, useState } from "react";
5import { useAtomSet, useAtomValue } from "@effect-atom/atom-react";5import { useAtomSet, useAtomValue } from "@effect-atom/atom-react";
6import type { SourceId } from "@executor-v2/schema";6import type { SourceCredentialBinding, SourceId } from "@executor-v2/schema";
7 7
8import { useWorkspace } from "../../lib/hooks/use-workspace";8import { useWorkspace } from "../../lib/hooks/use-workspace";
9import {9import {
10 credentialBindingsByWorkspace,
10 optimisticRemoveSources,11 optimisticRemoveSources,
11 optimisticSourcesByWorkspace,12 optimisticSourcesByWorkspace,
12 optimisticUpsertSources,13 optimisticUpsertSources,
13 removeSource,14 removeSource,
14 sourcesByWorkspace,15 sourcesByWorkspace,
15 sourcesPendingByWorkspace,16 sourcesPendingByWorkspace,
17 toCredentialBindingUpsertPayload,
18 upsertCredentialBinding,
16 upsertSource,19 upsertSource,
17} from "../../lib/control-plane/atoms";20} from "../../lib/control-plane/atoms";
18import {21import {
@@ -22,6 +25,10 @@ import { · 3 unmodified lines
22 type LegacySourceFormState,25 type LegacySourceFormState,
23 type LegacySourceType,26 type LegacySourceType,
24} from "../../lib/control-plane/legacy-source";27} from "../../lib/control-plane/legacy-source";
28import {
29 startMcpOAuthPopup,
30 type McpOAuthPopupSuccess,
31} from "../../lib/mcp/oauth-popup";
25import { Badge } from "../ui/badge";32import { Badge } from "../ui/badge";
26import { Button } from "../ui/button";33import { Button } from "../ui/button";
27import {34import {
@@ -177,6 +184,20 @@ const defaultFormState = (): LegacySourceFormState => ({ · 149 unmodified lines
177 enabled: true,184 enabled: true,
178});185});
179 186
187type McpOAuthDetectionState = {
188 status: "idle" | "checking" | "oauth" | "none" | "error";
189 detail: string;
190 authorizationServers: ReadonlyArray<string>;
191};
192
193const defaultMcpOAuthDetectionState = (): McpOAuthDetectionState => ({
194 status: "idle",
195 detail: "",
196 authorizationServers: [],
197});
198
199const normalizeEndpoint = (value: string): string => value.trim();
200
180const statusBadgeVariant = (201const statusBadgeVariant = (
181 status: string,202 status: string,
182): "outline" | "pending" | "approved" | "denied" => {203): "outline" | "pending" | "approved" | "denied" => {
@@ -197,14 +218,25 @@ export default function SourcesView() { · 14 unmodified lines
197 218
198 const sources = useAtomValue(sourcesByWorkspace(workspaceId));219 const sources = useAtomValue(sourcesByWorkspace(workspaceId));
199 const sourcesPending = useAtomValue(sourcesPendingByWorkspace(workspaceId));220 const sourcesPending = useAtomValue(sourcesPendingByWorkspace(workspaceId));
221 const credentialBindings = useAtomValue(credentialBindingsByWorkspace(workspaceId));
200 const setOptimisticSources = useAtomSet(optimisticSourcesByWorkspace(workspaceId));222 const setOptimisticSources = useAtomSet(optimisticSourcesByWorkspace(workspaceId));
201 const runUpsertSource = useAtomSet(upsertSource, { mode: "promise" });223 const runUpsertSource = useAtomSet(upsertSource, { mode: "promise" });
224 const runUpsertCredentialBinding = useAtomSet(upsertCredentialBinding, {
225 mode: "promise",
226 });
202 const runRemoveSource = useAtomSet(removeSource, { mode: "promise" });227 const runRemoveSource = useAtomSet(removeSource, { mode: "promise" });
203 228
204 const [formState, setFormState] = useState<LegacySourceFormState>(() => defaultFormState());229 const [formState, setFormState] = useState<LegacySourceFormState>(() => defaultFormState());
205 const [searchQuery, setSearchQuery] = useState("");230 const [searchQuery, setSearchQuery] = useState("");
206 const [templateQuery, setTemplateQuery] = useState("");231 const [templateQuery, setTemplateQuery] = useState("");
207 const [statusText, setStatusText] = useState<string | null>(null);232 const [statusText, setStatusText] = useState<string | null>(null);
233 const [mcpOAuthDetection, setMcpOAuthDetection] = useState<McpOAuthDetectionState>(
234 () => defaultMcpOAuthDetectionState(),
235 );
236 const [mcpOAuthBusy, setMcpOAuthBusy] = useState(false);
237 const [mcpOAuthSession, setMcpOAuthSession] = useState<McpOAuthPopupSuccess | null>(
238 null,
239 );
208 240
209 const sourceItems = useMemo(241 const sourceItems = useMemo(
210 () => sources.items.map(sourceToLegacyRecord),242 () => sources.items.map(sourceToLegacyRecord),
@@ -246,6 +278,33 @@ export default function SourcesView() { · 35 unmodified lines
246 278
247 const isEditing = Boolean(formState.id);279 const isEditing = Boolean(formState.id);
248 280
281 const existingMcpOAuthCredential = useMemo(() => {
282 if (!formState.id) {
283 return null;
284 }
285
286 const sourceKey = `source:${formState.id}`;
287 return (
288 credentialBindings.items.find(
289 (binding) =>
290 binding.sourceKey === sourceKey && binding.provider === "oauth2",
291 ) ?? null
292 );
293 }, [credentialBindings.items, formState.id]);
294
295 const mcpOAuthSessionMatchesEndpoint = Boolean(
296 mcpOAuthSession
297 && normalizeEndpoint(mcpOAuthSession.sourceUrl)
298 === normalizeEndpoint(formState.endpoint),
299 );
300
301 const mcpOAuthConnected =
302 formState.type === "mcp"
303 && (mcpOAuthSessionMatchesEndpoint || Boolean(existingMcpOAuthCredential));
304
305 const mcpOAuthCanConnect =
306 formState.type === "mcp" && mcpOAuthDetection.status === "oauth";
307
249 const setFormField = <K extends keyof LegacySourceFormState>(308 const setFormField = <K extends keyof LegacySourceFormState>(
250 key: K,309 key: K,
251 value: LegacySourceFormState[K],310 value: LegacySourceFormState[K],
@@ -258,9 +317,122 @@ export default function SourcesView() { · 6 unmodified lines
258 317
259 const resetForm = () => {318 const resetForm = () => {
260 setFormState(defaultFormState());319 setFormState(defaultFormState());
320 setMcpOAuthDetection(defaultMcpOAuthDetectionState());
321 setMcpOAuthSession(null);
322 setMcpOAuthBusy(false);
323 };
324
325 useEffect(() => {
326 const endpoint = normalizeEndpoint(formState.endpoint);
327
328 if (formState.type !== "mcp" || endpoint.length === 0) {
329 setMcpOAuthDetection(defaultMcpOAuthDetectionState());
330 return;
331 }
332
333 const controller = new AbortController();
334 setMcpOAuthDetection({
335 status: "checking",
336 detail: "Checking for OAuth support...",
337 authorizationServers: [],
338 });
339
340 void fetch(`/mcp/oauth/detect?sourceUrl=${encodeURIComponent(endpoint)}`, {
341 signal: controller.signal,
342 cache: "no-store",
343 })
344 .then(async (response) => {
345 const payload = (await response.json().catch(() => null)) as
346 | {
347 oauth?: unknown;
348 authorizationServers?: unknown;
349 detail?: unknown;
350 }
351 | null;
352
353 if (controller.signal.aborted) {
354 return;
355 }
356
357 const oauth = payload?.oauth === true;
358 const authorizationServers = Array.isArray(payload?.authorizationServers)
359 ? payload.authorizationServers
360 .filter((entry): entry is string => typeof entry === "string")
361 .map((entry) => entry.trim())
362 .filter((entry) => entry.length > 0)
363 : [];
364 const detail = typeof payload?.detail === "string" ? payload.detail.trim() : "";
365
366 if (!response.ok) {
367 setMcpOAuthDetection({
368 status: "error",
369 detail: detail || `OAuth detection failed (${response.status})`,
370 authorizationServers,
371 });
372 return;
373 }
374
375 setMcpOAuthDetection({
376 status: oauth ? "oauth" : "none",
377 detail,
378 authorizationServers,
379 });
380 })
381 .catch((error) => {
382 if (controller.signal.aborted) {
383 return;
384 }
385
386 setMcpOAuthDetection({
387 status: "error",
388 detail: error instanceof Error ? error.message : "OAuth detection failed",
389 authorizationServers: [],
390 });
391 });
392
393 return () => {
394 controller.abort();
395 };
396 }, [formState.endpoint, formState.type]);
397
398 useEffect(() => {
399 if (
400 mcpOAuthSession
401 && normalizeEndpoint(mcpOAuthSession.sourceUrl)
402 !== normalizeEndpoint(formState.endpoint)
403 ) {
404 setMcpOAuthSession(null);
405 }
406 }, [formState.endpoint, mcpOAuthSession]);
407
408 const handleMcpOAuthConnect = () => {
409 const endpoint = normalizeEndpoint(formState.endpoint);
410 if (endpoint.length === 0 || mcpOAuthBusy) {
411 return;
412 }
413
414 setMcpOAuthBusy(true);
415
416 void startMcpOAuthPopup(endpoint)
417 .then((result) => {
418 setMcpOAuthSession(result);
419 setFormField("authType", "bearer");
420 setFormField("authMode", "workspace");
421 setStatusText("OAuth connected. Save source to persist credentials.");
422 })
423 .catch((error) => {
424 setStatusText(
425 error instanceof Error ? error.message : "OAuth connection failed.",
426 );
427 })
428 .finally(() => {
429 setMcpOAuthBusy(false);
430 });
261 };431 };
262 432
263 const handleTemplateUse = (template: CatalogTemplate) => {433 const handleTemplateUse = (template: CatalogTemplate) => {
434 setMcpOAuthSession(null);
435 setMcpOAuthBusy(false);
264 setFormState((current) => ({436 setFormState((current) => ({
265 ...current,437 ...current,
266 id: undefined,438 id: undefined,
@@ -279,6 +451,8 @@ export default function SourcesView() { · 12 unmodified lines
279 return;451 return;
280 }452 }
281 453
454 setMcpOAuthSession(null);
455 setMcpOAuthBusy(false);
282 setFormState(formStateFromSource(source));456 setFormState(formStateFromSource(source));
283 setStatusText(`Editing ${source.name}.`);457 setStatusText(`Editing ${source.name}.`);
284 };458 };
@@ -305,6 +479,14 @@ export default function SourcesView() { · 20 unmodified lines
305 const previousSources = sources.items;479 const previousSources = sources.items;
306 const optimistic = optimisticUpsertSources(previousSources, workspaceId, payload);480 const optimistic = optimisticUpsertSources(previousSources, workspaceId, payload);
307 481
482 const endpoint = normalizeEndpoint(formState.endpoint);
483 const oauthSessionForEndpoint =
484 formState.type === "mcp"
485 && mcpOAuthSession
486 && normalizeEndpoint(mcpOAuthSession.sourceUrl) === endpoint
487 ? mcpOAuthSession
488 : null;
489
308 setOptimisticSources({490 setOptimisticSources({
309 items: optimistic.items,491 items: optimistic.items,
310 pendingAck: {492 pendingAck: {
@@ -314,8 +496,74 @@ export default function SourcesView() { · 3 unmodified lines
314 });496 });
315 497
316 void runUpsertSource({ path: { workspaceId }, payload })498 void runUpsertSource({ path: { workspaceId }, payload })
317 .then(() => {499 .then(async () => {
318 setStatusText(isEditing ? `Updated ${formState.name.trim()}.` : `Saved ${formState.name.trim()}.`);500 let oauthLinked = false;
501 let oauthLinkNote: string | null = null;
502
503 if (formState.type === "mcp" && oauthSessionForEndpoint) {
504 const sourceKey = `source:${sourceId}`;
505 const existingBindingForSource =
506 credentialBindings.items.find(
507 (binding) =>
508 binding.sourceKey === sourceKey && binding.provider === "oauth2",
509 ) ?? null;
510
511 const scopeType =
512 formState.authMode === "organization"
513 ? "organization"
514 : "workspace";
515
516 if (formState.authMode === "account") {
517 oauthLinkNote =
518 "Account scope is not supported from this flow; OAuth credential was saved as workspace scope.";
519 }
520
521 try {
522 await runUpsertCredentialBinding({
523 path: { workspaceId },
524 payload: toCredentialBindingUpsertPayload({
525 ...(existingBindingForSource
526 ? { id: existingBindingForSource.id }
527 : {}),
528 credentialId: (
529 existingBindingForSource?.credentialId
530 ?? createLocalId("cred_")
531 ) as SourceCredentialBinding["credentialId"],
532 scopeType,
533 sourceKey,
534 provider: "oauth2",
535 secretRef: oauthSessionForEndpoint.accessToken,
536 accountId: null,
537 additionalHeadersJson: null,
538 boundAuthFingerprint: null,
539 }),
540 });
541
542 oauthLinked = true;
543 setMcpOAuthSession(null);
544 } catch {
545 oauthLinkNote = "Source saved, but OAuth credential linking failed.";
546 }
547 }
548
549 if (oauthLinked) {
550 setStatusText(
551 `${isEditing ? "Updated" : "Saved"} ${formState.name.trim()} and linked OAuth credentials.${oauthLinkNote ? ` ${oauthLinkNote}` : ""}`,
552 );
553 } else if (oauthLinkNote) {
554 setStatusText(
555 oauthLinkNote.startsWith("Source saved")
556 ? oauthLinkNote
557 : `${isEditing ? "Updated" : "Saved"} ${formState.name.trim()}. ${oauthLinkNote}`,
558 );
559 } else {
560 setStatusText(
561 isEditing
562 ? `Updated ${formState.name.trim()}.`
563 : `Saved ${formState.name.trim()}.`,
564 );
565 }
566
319 resetForm();567 resetForm();
320 })568 })
321 .catch(() => {569 .catch(() => {
@@ -498,6 +746,61 @@ export default function SourcesView() { · 176 unmodified lines
498 </div>746 </div>
499 ) : null}747 ) : null}
500 748
749 {formState.type === "mcp" ? (
750 <div className="space-y-2 rounded-md border border-border/70 bg-muted/20 p-3">
751 <div className="flex items-start justify-between gap-3">
752 <div className="space-y-1">
753 <div className="flex items-center gap-2">
754 <p className="text-xs font-medium text-foreground">MCP OAuth</p>
755 {mcpOAuthConnected ? (
756 <Badge variant="approved" className="text-[10px] uppercase tracking-wide">
757 connected
758 </Badge>
759 ) : null}
760 </div>
761 <p className="text-[11px] text-muted-foreground">
762 {mcpOAuthDetection.status === "checking"
763 ? "Checking endpoint for OAuth support..."
764 : mcpOAuthDetection.status === "oauth"
765 ? "OAuth is supported for this MCP endpoint."
766 : mcpOAuthDetection.status === "none"
767 ? "OAuth was not detected for this endpoint."
768 : mcpOAuthDetection.status === "error"
769 ? "OAuth detection failed."
770 : "Set an MCP endpoint to detect OAuth support."}
771 </p>
772 {mcpOAuthDetection.detail.length > 0 ? (
773 <p className="text-[11px] text-muted-foreground">
774 {mcpOAuthDetection.detail}
775 </p>
776 ) : null}
777 {mcpOAuthDetection.authorizationServers.length > 0 ? (
778 <p className="text-[11px] text-muted-foreground">
779 Auth servers: {mcpOAuthDetection.authorizationServers.join(", ")}
780 </p>
781 ) : null}
782 </div>
783
784 {mcpOAuthCanConnect ? (
785 <Button
786 type="button"
787 size="sm"
788 variant="outline"
789 className="shrink-0"
790 onClick={handleMcpOAuthConnect}
791 disabled={mcpOAuthBusy}
792 >
793 {mcpOAuthBusy
794 ? "Connecting..."
795 : mcpOAuthConnected
796 ? "Reconnect OAuth"
797 : "Connect OAuth"}
798 </Button>
799 ) : null}
800 </div>
801 </div>
802 ) : null}
803
501 <div className="grid gap-3 sm:grid-cols-2">804 <div className="grid gap-3 sm:grid-cols-2">
502 <div className="grid gap-1.5">805 <div className="grid gap-1.5">
503 <label className="text-xs text-muted-foreground" htmlFor="source-auth-type">Auth Type</label>806 <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 @@
1import { parseMcpSourceUrl } from "./oauth-url";
2
3const REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]);
4const DEFAULT_MAX_REDIRECTS = 5;
5
6type FetchMcpOAuthOptions = {
7 timeoutMs: number;
8 label: string;
9 maxRedirects?: number;
10};
11
12const 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
24const 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
63export 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 @@
1export type McpOAuthPopupSuccess = {
2 sourceUrl: string;
3 accessToken: string;
4 refreshToken?: string;
5 scope?: string;
6 expiresIn?: number;
7};
8
9type 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
27const MCP_OAUTH_POPUP_RESULT_TIMEOUT_MS = 2 * 60_000;
28
29export 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 @@
1import { randomUUID } from "node:crypto";
2import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
3import type {
4 OAuthClientInformationMixed,
5 OAuthClientMetadata,
6 OAuthTokens,
7} from "@modelcontextprotocol/sdk/shared/auth.js";
8
9const COOKIE_PREFIX = "executor_v2_mcp_oauth_";
10export const MCP_OAUTH_RESULT_COOKIE = "executor_v2_mcp_oauth_result";
11
12export type McpOAuthPending = {
13 state: string;
14 sourceUrl: string;
15 redirectUrl: string;
16 codeVerifier?: string;
17 clientInformation?: OAuthClientInformationMixed;
18};
19
20export 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
30const 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
38const readString = (record: Record<string, unknown>, key: string): string | null => {
39 const value = record[key];
40 return typeof value === "string" ? value : null;
41};
42
43const readOptionalString = (record: Record<string, unknown>, key: string): string | undefined => {
44 const value = record[key];
45 return typeof value === "string" ? value : undefined;
46};
47
48const readOptionalNumber = (record: Record<string, unknown>, key: string): number | undefined => {
49 const value = record[key];
50 return typeof value === "number" ? value : undefined;
51};
52
53const 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
62const 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
91const 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
113export const buildPendingCookieName = (state: string): string =>
114 `${COOKIE_PREFIX}${state}`;
115
116export const createOAuthState = (): string => randomUUID();
117
118export const encodePendingCookieValue = (pending: McpOAuthPending): string =>
119 Buffer.from(
120 JSON.stringify({
121 version: 1,
122 pending,
123 }),
124 "utf8",
125 ).toString("base64url");
126
127export 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
137export const encodePopupResultCookieValue = (result: McpOAuthPopupResult): string =>
138 Buffer.from(
139 JSON.stringify({
140 version: 1,
141 result,
142 }),
143 "utf8",
144 ).toString("base64url");
145
146export 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
158export 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 @@
1const firstHeaderValue = (value: string | null): string => {
2 if (!value) {
3 return "";
4 }
5
6 return value.split(",")[0]?.trim() ?? "";
7};
8
9const 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
33export 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
58export const isExternalHttps = (request: Request): boolean =>
59 getExternalOrigin(request).startsWith("https://");
v2/apps/web/lib/mcp/oauth-url.ts+30−0
@@ -0,0 +1,30 @@
1const isLocalHost = (hostname: string): boolean =>
2 hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
3
4const localHttpAllowed = (): boolean =>
5 process.env.NODE_ENV !== "production"
6 || process.env.EXECUTOR_ALLOW_LOCAL_MCP_OAUTH === "1";
7
8export 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};