History

Merge pull request #4 from RhysSullivan/feat/mcp-oauth-source-flow

Add MCP OAuth source flow and authenticated runtime wiring

Rhys Sullivan committed 9 months ago

Commit a5df1d82 parents eeca14c 527767e26 files · +1501 −102Browse files

Unified · Split

26 files
  1. bun.lock +1 −0
  2. executor/apps/web/package.json +1 −0
  3. executor/apps/web/src/app/mcp/oauth/callback/route.ts +123 −0
  4. executor/apps/web/src/app/mcp/oauth/complete/page.tsx +82 −0
  5. executor/apps/web/src/app/mcp/oauth/detect/route.ts +74 −0
  6. executor/apps/web/src/app/mcp/oauth/result/route.ts +79 −0
  7. executor/apps/web/src/app/mcp/oauth/start/route.ts +106 −0
  8. executor/apps/web/src/components/tools/add-source-auth-panel.tsx +74 −34
  9. executor/apps/web/src/components/tools/add-source-dialog-helpers.ts +1 −0
  10. executor/apps/web/src/components/tools/add-source-dialog.tsx +104 −23
  11. executor/apps/web/src/components/tools/add-source-form-utils.ts +2 −0
  12. executor/apps/web/src/components/tools/add-source-submit.ts +50 −2
  13. executor/apps/web/src/components/tools/use-add-source-form-state.ts +113 −3
  14. executor/apps/web/src/lib/mcp-oauth-popup.ts +104 −0
  15. executor/apps/web/src/lib/mcp-oauth-provider.ts +189 −0
  16. executor/apps/web/src/lib/mcp-oauth-request.ts +48 −0
  17. executor/apps/web/src/lib/mcp-oauth-url.ts +101 −0
  18. executor/apps/web/src/lib/tools-source-helpers.ts +2 −1
  19. executor/packages/convex/runtime/tool_call_resolution.ts +3 −1
  20. executor/packages/convex/runtime/tool_source_loading.ts +127 −4
  21. executor/packages/convex/runtime/workspace_tools.ts +33 −18
  22. executor/packages/core/src/mcp-runtime.ts +38 −4
  23. executor/packages/core/src/tool-source-loaders/mcp-loader.ts +23 −6
  24. executor/packages/core/src/tool-source-serialization.ts +20 −6
  25. executor/packages/core/src/tool-source-types.ts +2 −0
  26. executor/packages/core/src/workspace-tool-cache.test.ts +1 −0
bun.lock+1−0
@@ -92,6 +92,7 @@ · 91 unmodified lines
9292 "@streamdown/code": "^1.0.1",
9393 "@tanstack/react-query": "^5.80.7",
9494 "@workos-inc/authkit-nextjs": "^2.13.0",
95+ "better-result": "^2.7.0",
9596 "class-variance-authority": "^0.7.1",
9697 "clsx": "^2.1.1",
9798 "convex": "^1.31.7",
executor/apps/web/package.json+1−0
@@ -17,6 +17,7 @@ · 16 unmodified lines
1717 "@streamdown/code": "^1.0.1",
1818 "@tanstack/react-query": "^5.80.7",
1919 "@workos-inc/authkit-nextjs": "^2.13.0",
20+ "better-result": "^2.7.0",
2021 "class-variance-authority": "^0.7.1",
2122 "clsx": "^2.1.1",
2223 "convex": "^1.31.7",
executor/apps/web/src/app/mcp/oauth/callback/route.ts+123−0
@@ -0,0 +1,123 @@
1+import type { NextRequest } from "next/server";
2+import { NextResponse } from "next/server";
3+import { Result } from "better-result";
4+import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
5+import { getExternalOrigin, isExternalHttps } from "@/lib/mcp-oauth-request";
6+import { parseMcpSourceUrl } from "@/lib/mcp-oauth-url";
7+import {
8+ buildPendingCookieName,
9+ decodePendingCookieValue,
10+ encodePopupResultCookieValue,
11+ MCP_OAUTH_RESULT_COOKIE,
12+ McpPopupOAuthProvider,
13+ type McpOAuthPopupResult,
14+} from "@/lib/mcp-oauth-provider";
15+
16+function popupResultRedirect(
17+ request: NextRequest,
18+ pendingCookieName: string | null,
19+ payload: McpOAuthPopupResult,
20+): NextResponse {
21+ const origin = getExternalOrigin(request);
22+ const response = NextResponse.redirect(`${origin}/mcp/oauth/complete`);
23+ response.cookies.set({
24+ name: MCP_OAUTH_RESULT_COOKIE,
25+ value: encodePopupResultCookieValue(payload),
26+ httpOnly: true,
27+ secure: isExternalHttps(request),
28+ sameSite: "lax",
29+ maxAge: 2 * 60,
30+ path: "/",
31+ });
32+ if (pendingCookieName) {
33+ response.cookies.delete(pendingCookieName);
34+ }
35+ return response;
36+}
37+
38+function resultErrorMessage(error: unknown, fallback: string): string {
39+ const cause = typeof error === "object" && error && "cause" in error
40+ ? (error as { cause?: unknown }).cause
41+ : error;
42+ if (cause instanceof Error && cause.message.trim()) {
43+ return cause.message;
44+ }
45+ if (typeof cause === "string" && cause.trim()) {
46+ return cause;
47+ }
48+ return fallback;
49+}
50+
51+export async function GET(request: NextRequest) {
52+ const code = request.nextUrl.searchParams.get("code")?.trim() ?? "";
53+ const state = request.nextUrl.searchParams.get("state")?.trim() ?? "";
54+ const error = request.nextUrl.searchParams.get("error")?.trim();
55+
56+ if (!state) {
57+ return popupResultRedirect(request, null, { ok: false, error: "Missing OAuth state" });
58+ }
59+
60+ const cookieName = buildPendingCookieName(state);
61+ const rawPending = request.cookies.get(cookieName)?.value;
62+ const pending = rawPending ? decodePendingCookieValue(rawPending) : null;
63+
64+ if (!pending) {
65+ return popupResultRedirect(request, cookieName, {
66+ ok: false,
67+ error: "OAuth session expired. Try connecting again.",
68+ });
69+ }
70+
71+ if (error) {
72+ return popupResultRedirect(request, cookieName, { ok: false, error: `OAuth error: ${error}` });
73+ }
74+
75+ if (!code) {
76+ return popupResultRedirect(request, cookieName, {
77+ ok: false,
78+ error: "Missing OAuth authorization code",
79+ });
80+ }
81+
82+ const sourceUrlResult = parseMcpSourceUrl(pending.sourceUrl);
83+ if (!sourceUrlResult.isOk()) {
84+ return popupResultRedirect(request, cookieName, { ok: false, error: "Invalid MCP source URL" });
85+ }
86+ const sourceUrl = sourceUrlResult.value;
87+
88+ const provider = new McpPopupOAuthProvider({
89+ redirectUrl: pending.redirectUrl,
90+ state: pending.state,
91+ codeVerifier: pending.codeVerifier,
92+ clientInformation: pending.clientInformation,
93+ });
94+
95+ const authResult = await Result.tryPromise(() => auth(provider, {
96+ serverUrl: sourceUrl,
97+ authorizationCode: code,
98+ }));
99+ if (!authResult.isOk()) {
100+ return popupResultRedirect(request, cookieName, {
101+ ok: false,
102+ error: resultErrorMessage(authResult.error, "Failed to finish OAuth"),
103+ });
104+ }
105+
106+ const tokens = provider.getTokens();
107+ const accessToken = tokens?.access_token?.trim() ?? "";
108+ if (!accessToken) {
109+ return popupResultRedirect(request, cookieName, {
110+ ok: false,
111+ error: "OAuth completed without an access token",
112+ });
113+ }
114+
115+ return popupResultRedirect(request, cookieName, {
116+ ok: true,
117+ sourceUrl: pending.sourceUrl,
118+ accessToken,
119+ refreshToken: tokens?.refresh_token,
120+ scope: tokens?.scope,
121+ expiresIn: typeof tokens?.expires_in === "number" ? tokens.expires_in : undefined,
122+ });
123+}
executor/apps/web/src/app/mcp/oauth/complete/page.tsx+82−0
@@ -0,0 +1,82 @@
1+"use client";
2+
3+import { useEffect, useRef } from "react";
4+import { useQuery as useTanstackQuery } from "@tanstack/react-query";
5+
6+type PopupMessage =
7+ | {
8+ type: "executor:mcp-oauth-result";
9+ ok: true;
10+ sourceUrl: string;
11+ payload: {
12+ accessToken: string;
13+ refreshToken?: string;
14+ scope?: string;
15+ expiresIn?: number;
16+ };
17+ }
18+ | {
19+ type: "executor:mcp-oauth-result";
20+ ok: false;
21+ error: string;
22+ };
23+
24+function fallbackErrorMessage(error: unknown): PopupMessage {
25+ return {
26+ type: "executor:mcp-oauth-result",
27+ ok: false,
28+ error: error instanceof Error ? error.message : "Failed to finalize OAuth",
29+ };
30+}
31+
32+async function fetchPopupResult(): Promise<PopupMessage> {
33+ const response = await fetch("/mcp/oauth/result", {
34+ method: "GET",
35+ cache: "no-store",
36+ credentials: "same-origin",
37+ });
38+
39+ const data = await response.json() as PopupMessage;
40+ if (!data || data.type !== "executor:mcp-oauth-result") {
41+ throw new Error("Invalid OAuth completion payload");
42+ }
43+ return data;
44+}
45+
46+export default function McpOAuthCompletePage() {
47+ const sentRef = useRef(false);
48+ const resultQuery = useTanstackQuery({
49+ queryKey: ["mcp-oauth-popup-result"],
50+ queryFn: fetchPopupResult,
51+ retry: false,
52+ staleTime: 0,
53+ });
54+
55+ useEffect(() => {
56+ if (sentRef.current || resultQuery.isPending) {
57+ return;
58+ }
59+
60+ sentRef.current = true;
61+ const message = resultQuery.isSuccess
62+ ? resultQuery.data
63+ : fallbackErrorMessage(resultQuery.error);
64+
65+ try {
66+ if (window.opener) {
67+ window.opener.postMessage(message, window.location.origin);
68+ }
69+ } finally {
70+ window.close();
71+ }
72+ }, [resultQuery.data, resultQuery.error, resultQuery.isPending, resultQuery.isSuccess]);
73+
74+ return (
75+ <main className="min-h-screen flex items-center justify-center bg-background p-6">
76+ <div className="text-center space-y-2">
77+ <h1 className="text-base font-medium">Finishing OAuth</h1>
78+ <p className="text-sm text-muted-foreground">You can close this window if it does not close automatically.</p>
79+ </div>
80+ </main>
81+ );
82+}
executor/apps/web/src/app/mcp/oauth/detect/route.ts+74−0
@@ -0,0 +1,74 @@
1+import type { NextRequest } from "next/server";
2+import { NextResponse } from "next/server";
3+import { Result } from "better-result";
4+import { discoverOAuthProtectedResourceMetadata } from "@modelcontextprotocol/sdk/client/auth.js";
5+import { parseMcpSourceUrl } from "@/lib/mcp-oauth-url";
6+
7+type DetectResponse = {
8+ oauth: boolean;
9+ authorizationServers: string[];
10+ detail?: string;
11+};
12+
13+function noStoreJson(payload: DetectResponse, status = 200): NextResponse {
14+ return NextResponse.json(payload, {
15+ status,
16+ headers: {
17+ "cache-control": "no-store",
18+ },
19+ });
20+}
21+
22+function resultErrorMessage(error: unknown, fallback: string): string {
23+ const cause = typeof error === "object" && error && "cause" in error
24+ ? (error as { cause?: unknown }).cause
25+ : error;
26+ if (cause instanceof Error && cause.message.trim()) {
27+ return cause.message;
28+ }
29+ if (typeof cause === "string" && cause.trim()) {
30+ return cause;
31+ }
32+ return fallback;
33+}
34+
35+export async function GET(request: NextRequest) {
36+ const sourceUrlRaw = request.nextUrl.searchParams.get("sourceUrl")?.trim() ?? "";
37+ if (!sourceUrlRaw) {
38+ return noStoreJson({ oauth: false, authorizationServers: [], detail: "Missing sourceUrl" }, 400);
39+ }
40+
41+ const sourceUrlResult = parseMcpSourceUrl(sourceUrlRaw);
42+ if (!sourceUrlResult.isOk()) {
43+ return noStoreJson(
44+ {
45+ oauth: false,
46+ authorizationServers: [],
47+ detail: resultErrorMessage(sourceUrlResult.error, "Invalid sourceUrl"),
48+ },
49+ 400,
50+ );
51+ }
52+ const sourceUrl = sourceUrlResult.value;
53+
54+ const metadataResult = await Result.tryPromise(() => discoverOAuthProtectedResourceMetadata(sourceUrl));
55+ if (!metadataResult.isOk()) {
56+ return noStoreJson({
57+ oauth: false,
58+ authorizationServers: [],
59+ detail: resultErrorMessage(metadataResult.error, "OAuth metadata lookup failed"),
60+ });
61+ }
62+
63+ const metadata = metadataResult.value;
64+ const authorizationServers = Array.isArray((metadata as { authorization_servers?: unknown }).authorization_servers)
65+ ? (metadata as { authorization_servers: unknown[] }).authorization_servers.filter(
66+ (entry): entry is string => typeof entry === "string" && entry.trim().length > 0,
67+ )
68+ : [];
69+
70+ return noStoreJson({
71+ oauth: authorizationServers.length > 0,
72+ authorizationServers,
73+ });
74+}
executor/apps/web/src/app/mcp/oauth/result/route.ts+79−0
@@ -0,0 +1,79 @@
1+import type { NextRequest } from "next/server";
2+import { NextResponse } from "next/server";
3+import {
4+ decodePopupResultCookieValue,
5+ MCP_OAUTH_RESULT_COOKIE,
6+ type McpOAuthPopupResult,
7+} from "@/lib/mcp-oauth-provider";
8+
9+type PopupMessage =
10+ | {
11+ type: "executor: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:mcp-oauth-result";
23+ ok: false;
24+ error: string;
25+ };
26+
27+function toPopupMessage(result: McpOAuthPopupResult | null): PopupMessage {
28+ if (!result) {
29+ return {
30+ type: "executor:mcp-oauth-result",
31+ ok: false,
32+ error: "OAuth result is missing or expired",
33+ };
34+ }
35+
36+ if (!result.ok) {
37+ return {
38+ type: "executor:mcp-oauth-result",
39+ ok: false,
40+ error: result.error ?? "OAuth failed",
41+ };
42+ }
43+
44+ const accessToken = result.accessToken?.trim() ?? "";
45+ if (!accessToken) {
46+ return {
47+ type: "executor:mcp-oauth-result",
48+ ok: false,
49+ error: "OAuth finished without an access token",
50+ };
51+ }
52+
53+ return {
54+ type: "executor:mcp-oauth-result",
55+ ok: true,
56+ sourceUrl: result.sourceUrl ?? "",
57+ payload: {
58+ accessToken,
59+ ...(result.refreshToken ? { refreshToken: result.refreshToken } : {}),
60+ ...(result.scope ? { scope: result.scope } : {}),
61+ ...(typeof result.expiresIn === "number" ? { expiresIn: result.expiresIn } : {}),
62+ },
63+ };
64+}
65+
66+export async function GET(request: NextRequest) {
67+ const encoded = request.cookies.get(MCP_OAUTH_RESULT_COOKIE)?.value ?? "";
68+ const parsed = encoded ? decodePopupResultCookieValue(encoded) : null;
69+ const message = toPopupMessage(parsed);
70+
71+ const response = NextResponse.json(message, {
72+ status: message.ok ? 200 : 400,
73+ headers: {
74+ "cache-control": "no-store",
75+ },
76+ });
77+ response.cookies.delete(MCP_OAUTH_RESULT_COOKIE);
78+ return response;
79+}
executor/apps/web/src/app/mcp/oauth/start/route.ts+106−0
@@ -0,0 +1,106 @@
1+import type { NextRequest } from "next/server";
2+import { NextResponse } from "next/server";
3+import { Result } from "better-result";
4+import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
5+import { getExternalOrigin, isExternalHttps } from "@/lib/mcp-oauth-request";
6+import { parseMcpSourceUrl } from "@/lib/mcp-oauth-url";
7+import {
8+ buildPendingCookieName,
9+ createOAuthState,
10+ encodePendingCookieValue,
11+ encodePopupResultCookieValue,
12+ MCP_OAUTH_RESULT_COOKIE,
13+ McpPopupOAuthProvider,
14+ type McpOAuthPopupResult,
15+} from "@/lib/mcp-oauth-provider";
16+
17+function popupResultRedirect(request: NextRequest, payload: McpOAuthPopupResult): NextResponse {
18+ const externalOrigin = getExternalOrigin(request);
19+ const response = NextResponse.redirect(`${externalOrigin}/mcp/oauth/complete`);
20+ response.cookies.set({
21+ name: MCP_OAUTH_RESULT_COOKIE,
22+ value: encodePopupResultCookieValue(payload),
23+ httpOnly: true,
24+ secure: isExternalHttps(request),
25+ sameSite: "lax",
26+ maxAge: 2 * 60,
27+ path: "/",
28+ });
29+ return response;
30+}
31+
32+function badPopupResponse(request: NextRequest, message: string): NextResponse {
33+ return popupResultRedirect(request, { ok: false, error: message });
34+}
35+
36+function resultErrorMessage(error: unknown, fallback: string): string {
37+ const cause = typeof error === "object" && error && "cause" in error
38+ ? (error as { cause?: unknown }).cause
39+ : error;
40+ if (cause instanceof Error && cause.message.trim()) {
41+ return cause.message;
42+ }
43+ if (typeof cause === "string" && cause.trim()) {
44+ return cause;
45+ }
46+ return fallback;
47+}
48+
49+export async function GET(request: NextRequest) {
50+ const sourceUrlRaw = request.nextUrl.searchParams.get("sourceUrl")?.trim() ?? "";
51+ if (!sourceUrlRaw) {
52+ return badPopupResponse(request, "Missing sourceUrl");
53+ }
54+
55+ const sourceUrlResult = parseMcpSourceUrl(sourceUrlRaw);
56+ if (!sourceUrlResult.isOk()) {
57+ return badPopupResponse(request, resultErrorMessage(sourceUrlResult.error, "Invalid sourceUrl"));
58+ }
59+ const sourceUrl = sourceUrlResult.value;
60+
61+ const state = createOAuthState();
62+ const redirectUrl = `${getExternalOrigin(request)}/mcp/oauth/callback`;
63+ const provider = new McpPopupOAuthProvider({
64+ redirectUrl,
65+ state,
66+ });
67+
68+ const authResult = await Result.tryPromise(() => auth(provider, { serverUrl: sourceUrl }));
69+ if (!authResult.isOk()) {
70+ return badPopupResponse(request, resultErrorMessage(authResult.error, "Failed to start OAuth flow"));
71+ }
72+
73+ if (authResult.value === "AUTHORIZED") {
74+ const tokens = provider.getTokens();
75+ const accessToken = tokens?.access_token?.trim() ?? "";
76+ if (!accessToken) {
77+ return badPopupResponse(request, "OAuth flow completed without an access token");
78+ }
79+ return popupResultRedirect(request, {
80+ ok: true,
81+ sourceUrl: sourceUrl.toString(),
82+ accessToken,
83+ refreshToken: tokens?.refresh_token,
84+ scope: tokens?.scope,
85+ expiresIn: typeof tokens?.expires_in === "number" ? tokens.expires_in : undefined,
86+ });
87+ }
88+
89+ const authorizationUrl = provider.getAuthorizationUrl();
90+ if (!authorizationUrl) {
91+ return badPopupResponse(request, "Server did not request an OAuth authorization step");
92+ }
93+
94+ const pendingCookie = encodePendingCookieValue(provider.toPending(sourceUrl.toString()));
95+ const response = NextResponse.redirect(authorizationUrl);
96+ response.cookies.set({
97+ name: buildPendingCookieName(state),
98+ value: pendingCookie,
99+ httpOnly: true,
100+ secure: isExternalHttps(request),
101+ sameSite: "lax",
102+ maxAge: 10 * 60,
103+ path: "/",
104+ });
105+ return response;
106+}
executor/apps/web/src/components/tools/add-source-auth-panel.tsx+74−34
@@ -1,5 +1,6 @@
1−import { KeyRound, LockKeyhole, ShieldCheck, UserRound } from "lucide-react";
1+import { KeyRound, LockKeyhole, UserRound } from "lucide-react";
22 import { Badge } from "@/components/ui/badge";
3+import { Button } from "@/components/ui/button";
34 import { Input } from "@/components/ui/input";
45 import { Label } from "@/components/ui/label";
56 import {
@@ -25,6 +26,10 @@ export type SourceAuthPanelModel = { · 19 unmodified lines
2526 specStatus: "idle" | "detecting" | "ready" | "error";
2627 inferredSpecAuth: InferredSpecAuth | null;
2728 specError: string;
29+ mcpOAuthStatus: "idle" | "checking" | "oauth" | "none" | "error";
30+ mcpOAuthDetail: string;
31+ mcpOAuthAuthorizationServers: string[];
32+ mcpOAuthConnected: boolean;
2833 authType: Exclude<SourceAuthType, "mixed">;
2934 authScope: CredentialScope;
3035 apiKeyHeader: string;
@@ -51,7 +56,7 @@ function inferredAuthBadge(inferredSpecAuth: InferredSpecAuth | null): string | · 20 unmodified lines
5156 if (inferredSpecAuth.type === "bearer") {
5257 return "Bearer";
5358 }
54− return "No auth";
59+ return null;
5560 }
5661
5762 export function SourceAuthPanel({
@@ -59,17 +64,25 @@ export function SourceAuthPanel({ · 1 unmodified line
5964 onAuthTypeChange,
6065 onAuthScopeChange,
6166 onFieldChange,
67+ onMcpOAuthConnect,
68+ mcpOAuthBusy = false,
6269 }: {
6370 model: SourceAuthPanelModel;
6471 onAuthTypeChange: (value: Exclude<SourceAuthType, "mixed">) => void;
6572 onAuthScopeChange: (value: CredentialScope) => void;
6673 onFieldChange: (field: SourceAuthPanelEditableField, value: string) => void;
74+ onMcpOAuthConnect?: () => void;
75+ mcpOAuthBusy?: boolean;
6776 }) {
6877 const {
6978 sourceType,
7079 specStatus,
7180 inferredSpecAuth,
7281 specError,
82+ mcpOAuthStatus,
83+ mcpOAuthDetail,
84+ mcpOAuthAuthorizationServers,
85+ mcpOAuthConnected,
7386 authType,
7487 authScope,
7588 apiKeyHeader,
@@ -80,44 +93,22 @@ export function SourceAuthPanel({ · 4 unmodified lines
8093 hasExistingCredential,
8194 } = model;
8295
83− if (sourceType !== "openapi" && sourceType !== "graphql") {
96+ if (sourceType !== "openapi" && sourceType !== "graphql" && sourceType !== "mcp") {
8497 return null;
8598 }
8699
87100 const badge = inferredAuthBadge(inferredSpecAuth);
88− const scopeHint = authScope === "workspace" ? "Shared with workspace" : "Private to your user";
89−
101+ const mcpBearerConnected = sourceType === "mcp" && authType === "bearer" && mcpOAuthConnected;
90102 return (
91− <div className="rounded-xl border border-border/70 bg-gradient-to-br from-muted/60 via-muted/30 to-background p-3 space-y-3">
92− <div className="flex items-start justify-between gap-2">
93− <div className="space-y-1">
94− <div className="flex items-center gap-1.5 text-xs font-medium">
95− <ShieldCheck className="h-3.5 w-3.5 text-primary" />
96− Authentication
97− </div>
98− <p className="text-[11px] text-muted-foreground">
99− Configure runtime and spec access credentials together.
100− </p>
101− </div>
102− {sourceType === "openapi" ? (
103− <Badge variant="outline" className="text-[10px] uppercase tracking-wider">
104− {specStatus === "detecting"
105− ? "Inspecting"
106− : specStatus === "ready"
107− ? "Schema ready"
108− : specStatus === "error"
109− ? "Schema error"
110− : "Awaiting URL"}
111− </Badge>
112− ) : (
113− <Badge variant="outline" className="text-[10px] uppercase tracking-wider">
114− GraphQL
115− </Badge>
116− )}
117− </div>
103+ <div className="space-y-3">
118104
119105 {sourceType === "openapi" ? (
120106 <div className="flex items-center gap-2 flex-wrap">
107+ {specStatus !== "ready" && specStatus !== "detecting" ? (
108+ <Badge variant="outline" className="text-[10px] uppercase tracking-wider">
109+ {specStatus === "error" ? "Schema error" : "Awaiting URL"}
110+ </Badge>
111+ ) : null}
121112 {badge ? (
122113 <Badge variant="outline" className="text-[10px] uppercase tracking-wider">
123114 {badge}
@@ -127,6 +118,30 @@ export function SourceAuthPanel({ · 3 unmodified lines
127118 </div>
128119 ) : null}
129120
121+ {sourceType === "mcp" ? (
122+ <div className="flex items-center gap-2 flex-wrap">
123+ <Badge variant="outline" className="text-[10px] uppercase tracking-wider">
124+ {mcpOAuthStatus === "idle"
125+ ? "Awaiting URL"
126+ : mcpOAuthStatus === "checking"
127+ ? "Checking OAuth"
128+ : mcpOAuthStatus === "oauth"
129+ ? "OAuth detected"
130+ : mcpOAuthStatus === "error"
131+ ? "OAuth unknown"
132+ : "No OAuth metadata"}
133+ </Badge>
134+ {mcpOAuthAuthorizationServers.length > 0 ? (
135+ <span className="text-[10px] text-muted-foreground">
136+ {mcpOAuthAuthorizationServers[0]}
137+ </span>
138+ ) : null}
139+ {mcpOAuthStatus === "error" && mcpOAuthDetail ? (
140+ <span className="text-[10px] text-terminal-amber">{mcpOAuthDetail}</span>
141+ ) : null}
142+ </div>
143+ ) : null}
144+
130145 <div className="grid grid-cols-2 gap-3">
131146 <div className="space-y-1.5">
132147 <Label className="text-xs text-muted-foreground">Auth Type</Label>
@@ -158,10 +173,35 @@ export function SourceAuthPanel({ · 25 unmodified lines
158173 <SelectItem value="actor" className="text-xs">Only me</SelectItem>
159174 </SelectContent>
160175 </Select>
161− <p className="text-[10px] text-muted-foreground">{scopeHint}</p>
162176 </div>
163177 </div>
164178
179+ {sourceType === "mcp" && authType === "bearer" && onMcpOAuthConnect ? (
180+ <div className="space-y-1.5">
181+ <div className="flex items-center gap-2">
182+ <Label className="text-xs text-muted-foreground">OAuth</Label>
183+ {mcpBearerConnected ? (
184+ <Badge variant="outline" className="text-[10px] uppercase tracking-wider text-terminal-green">
185+ Connected
186+ </Badge>
187+ ) : null}
188+ </div>
189+ <Button
190+ type="button"
191+ variant="outline"
192+ size="sm"
193+ className="h-8 text-xs"
194+ disabled={mcpOAuthBusy}
195+ onClick={onMcpOAuthConnect}
196+ >
197+ {mcpOAuthBusy ? "Connecting..." : mcpBearerConnected ? "Reconnect OAuth" : "Connect OAuth in popup"}
198+ </Button>
199+ {mcpBearerConnected ? (
200+ <p className="text-[11px] text-muted-foreground">OAuth linked successfully.</p>
201+ ) : null}
202+ </div>
203+ ) : null}
204+
165205 {authType === "apiKey" ? (
166206 <div className="space-y-1.5">
167207 <Label className="text-xs text-muted-foreground">API Key Header</Label>
@@ -174,7 +214,7 @@ export function SourceAuthPanel({ · 6 unmodified lines
174214 </div>
175215 ) : null}
176216
177− {authType === "bearer" ? (
217+ {authType === "bearer" && !mcpBearerConnected ? (
178218 <div className="space-y-1.5">
179219 <Label className="text-xs text-muted-foreground flex items-center gap-1.5">
180220 <LockKeyhole className="h-3 w-3" />
executor/apps/web/src/components/tools/add-source-dialog-helpers.ts+1−0
@@ -77,6 +77,7 @@ export function createCustomSourceConfig({ · 76 unmodified lines
7777 if (type === "mcp") {
7878 return {
7979 url: endpoint,
80+ ...(auth ? { auth } : {}),
8081 ...(mcpTransport !== "auto" ? { transport: mcpTransport } : {}),
8182 ...(actorId
8283 ? { queryParams: { [DEFAULT_MCP_ACTOR_QUERY_PARAM_KEY]: actorId } }
executor/apps/web/src/components/tools/add-source-dialog.tsx+104−23
@@ -3,6 +3,7 @@ · 2 unmodified lines
33 import { useState, type ReactNode } from "react";
44 import { Plus } from "lucide-react";
55 import { useAction, useMutation, useQuery } from "convex/react";
6+import { Result } from "better-result";
67 import { toast } from "sonner";
78 import { Button } from "@/components/ui/button";
89 import {
@@ -17,6 +18,7 @@ import { convexApi } from "@/lib/convex-api"; · 8 unmodified lines
1718 import type { CredentialRecord, ToolSourceRecord } from "@/lib/types";
1819 import { workspaceQueryArgs } from "@/lib/workspace-query-args";
1920 import type { CatalogCollectionItem } from "@/lib/catalog-collections";
21+import { startMcpOAuthPopup } from "@/lib/mcp-oauth-popup";
2022 import {
2123 CatalogViewSection,
2224 CustomViewSection,
@@ -27,6 +29,31 @@ import { · 4 unmodified lines
2729 } from "./use-add-source-form-state";
2830 import { saveSourceWithCredentials } from "./add-source-submit";
2931
32+function resultErrorMessage(error: unknown, fallback: string): string {
33+ const cause = typeof error === "object" && error && "cause" in error
34+ ? (error as { cause?: unknown }).cause
35+ : error;
36+ if (cause instanceof Error && cause.message.trim()) {
37+ return cause.message;
38+ }
39+ if (typeof cause === "string" && cause.trim()) {
40+ return cause;
41+ }
42+ return fallback;
43+}
44+
45+function normalizeEndpoint(value: string): string {
46+ const trimmed = value.trim();
47+ if (!trimmed) {
48+ return "";
49+ }
50+ try {
51+ return new URL(trimmed).toString();
52+ } catch {
53+ return trimmed;
54+ }
55+}
56+
3057 export function AddSourceDialog({
3158 existingSourceNames,
3259 onSourceAdded,
@@ -49,6 +76,7 @@ export function AddSourceDialog({ · 16 unmodified lines
4976 const credentialsLoading = Boolean(context) && credentials === undefined;
5077 const [open, setOpen] = useState(false);
5178 const [submitting, setSubmitting] = useState(false);
79+ const [mcpOAuthBusy, setMcpOAuthBusy] = useState(false);
5280 const form = useAddSourceFormState({
5381 open,
5482 sourceToEdit,
@@ -85,8 +113,8 @@ export function AddSourceDialog({ · 30 unmodified lines
85113 }
86114
87115 setSubmitting(true);
88− try {
89− const result = await saveSourceWithCredentials({
116+ const saveResult = await Result.tryPromise(() =>
117+ saveSourceWithCredentials({
90118 context,
91119 sourceToEdit,
92120 credentialsLoading,
@@ -100,33 +128,80 @@ export function AddSourceDialog({ · 7 unmodified lines
100128 mcpTransport: form.mcpTransport,
101129 authType: form.authType,
102130 authScope: form.authScope,
131+ apiKeyHeader: form.apiKeyHeader,
103132 existingScopedCredential: form.existingScopedCredential,
104133 buildAuthConfig: form.buildAuthConfig,
105134 hasCredentialInput: form.hasCredentialInput,
106135 buildSecretJson: form.buildSecretJson,
107136 },
108− });
109−
110− onSourceAdded?.(result.source, { connected: result.connected });
111− toast.success(
112− sourceToEdit
113− ? result.connected
114− ? `Source "${form.name.trim()}" updated with credentials`
115− : `Source "${form.name.trim()}" updated`
116− : result.connected
117− ? `Source "${form.name.trim()}" added with credentials — loading tools…`
118− : `Source "${form.name.trim()}" added — loading tools…`,
119− );
120− if (!sourceToEdit) {
121− form.reserveSourceName(form.name.trim());
122− }
123− form.setView("catalog");
124− setOpen(false);
125− } catch (err) {
126− toast.error(err instanceof Error ? err.message : sourceToEdit ? "Failed to update source" : "Failed to add source");
127− } finally {
128− setSubmitting(false);
137+ })
138+ );
139+ setSubmitting(false);
140+
141+ if (!saveResult.isOk()) {
142+ toast.error(resultErrorMessage(
143+ saveResult.error,
144+ sourceToEdit ? "Failed to update source" : "Failed to add source",
145+ ));
146+ return;
147+ }
148+
149+ const result = saveResult.value;
150+ onSourceAdded?.(result.source, { connected: result.connected });
151+ toast.success(
152+ sourceToEdit
153+ ? result.connected
154+ ? `Source "${form.name.trim()}" updated with credentials`
155+ : `Source "${form.name.trim()}" updated`
156+ : result.connected
157+ ? `Source "${form.name.trim()}" added with credentials — loading tools…`
158+ : `Source "${form.name.trim()}" added — loading tools…`,
159+ );
160+ if (!sourceToEdit) {
161+ form.reserveSourceName(form.name.trim());
162+ }
163+ form.setView("catalog");
164+ setOpen(false);
165+ };
166+
167+ const handleMcpOAuthConnect = async () => {
168+ if (form.type !== "mcp") {
169+ return;
170+ }
171+ const endpoint = form.endpoint.trim();
172+ if (!endpoint) {
173+ toast.error("Enter an MCP endpoint URL first");
174+ return;
175+ }
176+ const initiatedEndpoint = normalizeEndpoint(endpoint);
177+
178+ setMcpOAuthBusy(true);
179+ const oauthResult = await Result.tryPromise(() => startMcpOAuthPopup(endpoint));
180+ setMcpOAuthBusy(false);
181+
182+ if (!oauthResult.isOk()) {
183+ toast.error(resultErrorMessage(oauthResult.error, "Failed to connect OAuth"));
184+ return;
185+ }
186+
187+ const returnedEndpoint = normalizeEndpoint(oauthResult.value.sourceUrl);
188+ if (returnedEndpoint && initiatedEndpoint && returnedEndpoint !== initiatedEndpoint) {
189+ toast.error("OAuth finished for a different endpoint. Try again.");
190+ return;
191+ }
192+
193+ const currentEndpoint = normalizeEndpoint(form.endpoint);
194+ if (currentEndpoint !== initiatedEndpoint) {
195+ toast.error("Endpoint changed while OAuth was running. Please reconnect OAuth.");
196+ return;
197+ }
198+
199+ if (form.authType !== "bearer") {
200+ form.handleAuthTypeChange("bearer");
129201 }
202+ form.handleAuthFieldChange("tokenValue", oauthResult.value.accessToken);
203+ form.markMcpOAuthLinked(initiatedEndpoint);
204+ toast.success("OAuth linked successfully.");
130205 };
131206
132207 const dialogTitle = sourceToEdit ? "Edit Tool Source" : "Add Tool Source";
@@ -194,6 +269,10 @@ export function AddSourceDialog({ · 61 unmodified lines
194269 specStatus: form.specStatus,
195270 inferredSpecAuth: form.inferredSpecAuth,
196271 specError: form.specError,
272+ mcpOAuthStatus: form.mcpOAuthStatus,
273+ mcpOAuthDetail: form.mcpOAuthDetail,
274+ mcpOAuthAuthorizationServers: form.mcpOAuthAuthorizationServers,
275+ mcpOAuthConnected: form.mcpOAuthConnected,
197276 authType: form.authType,
198277 authScope: form.authScope,
199278 apiKeyHeader: form.apiKeyHeader,
@@ -206,6 +285,8 @@ export function AddSourceDialog({ · 6 unmodified lines
206285 onAuthTypeChange={form.handleAuthTypeChange}
207286 onAuthScopeChange={form.handleAuthScopeChange}
208287 onFieldChange={form.handleAuthFieldChange}
288+ onMcpOAuthConnect={form.type === "mcp" ? handleMcpOAuthConnect : undefined}
289+ mcpOAuthBusy={mcpOAuthBusy}
209290 />
210291 </CustomViewSection>
211292 )}
executor/apps/web/src/components/tools/add-source-form-utils.ts+2−0
@@ -45,6 +45,7 @@ export type AddSourceUiState = { · 44 unmodified lines
4545 locallyReservedNames: string[];
4646 authManuallyEdited: boolean;
4747 authRevision: number;
48+ mcpOAuthLinkedEndpoint: string | null;
4849 };
4950
5051 export function createDefaultUiState(view: SourceDialogView = "catalog"): AddSourceUiState {
@@ -56,6 +57,7 @@ export function createDefaultUiState(view: SourceDialogView = "catalog"): AddSou · 5 unmodified lines
5657 locallyReservedNames: [],
5758 authManuallyEdited: false,
5859 authRevision: 0,
60+ mcpOAuthLinkedEndpoint: null,
5961 };
6062 }
6163
executor/apps/web/src/components/tools/add-source-submit.ts+50−2
@@ -36,12 +36,45 @@ type SaveFormSnapshot = { · 35 unmodified lines
3636 mcpTransport: "auto" | "streamable-http" | "sse";
3737 authType: Exclude<SourceAuthType, "mixed">;
3838 authScope: CredentialScope;
39+ apiKeyHeader: string;
3940 existingScopedCredential: CredentialRecord | null;
4041 buildAuthConfig: () => Record<string, unknown> | undefined;
4142 hasCredentialInput: () => boolean;
4243 buildSecretJson: () => { value?: Record<string, unknown>; error?: string };
4344 };
4445
46+function readLegacyMcpStaticSecret(
47+ sourceToEdit: ToolSourceRecord | undefined,
48+ authType: Exclude<SourceAuthType, "mixed">,
49+): Record<string, unknown> | null {
50+ if (sourceToEdit?.type !== "mcp") {
51+ return null;
52+ }
53+
54+ const auth = sourceToEdit.config.auth as Record<string, unknown> | undefined;
55+ if (!auth || auth.mode !== "static" || auth.type !== authType) {
56+ return null;
57+ }
58+
59+ if (authType === "bearer") {
60+ const token = typeof auth.token === "string" ? auth.token.trim() : "";
61+ return token ? { token } : null;
62+ }
63+
64+ if (authType === "apiKey") {
65+ const value = typeof auth.value === "string" ? auth.value.trim() : "";
66+ return value ? { value } : null;
67+ }
68+
69+ if (authType === "basic") {
70+ const username = typeof auth.username === "string" ? auth.username.trim() : "";
71+ const password = typeof auth.password === "string" ? auth.password.trim() : "";
72+ return username && password ? { username, password } : null;
73+ }
74+
75+ return null;
76+}
77+
4578 export async function saveSourceWithCredentials({
4679 context,
4780 sourceToEdit,
@@ -57,7 +90,7 @@ export async function saveSourceWithCredentials({ · 9 unmodified lines
5790 upsertToolSource: UpsertToolSourceFn;
5891 upsertCredential: UpsertCredentialFn;
5992 }): Promise<{ source: ToolSourceRecord; connected: boolean }> {
60− const authConfig = form.type === "openapi" || form.type === "graphql"
93+ const authConfig = form.type === "openapi" || form.type === "graphql" || form.type === "mcp"
6194 ? form.buildAuthConfig()
6295 : undefined;
6396
@@ -81,7 +114,7 @@ export async function saveSourceWithCredentials({ · 17 unmodified lines
81114
82115 let linkedCredential = false;
83116
84− if ((form.type === "openapi" || form.type === "graphql") && form.authType !== "none") {
117+ if ((form.type === "openapi" || form.type === "graphql" || form.type === "mcp") && form.authType !== "none") {
85118 const sourceKey = sourceKeyForSource(created);
86119 if (!sourceKey) {
87120 throw new Error("Failed to resolve source key for credentials");
@@ -117,6 +150,21 @@ export async function saveSourceWithCredentials({ · 29 unmodified lines
117150 throw new Error("Enter credentials for the selected auth type");
118151 }
119152 linkedCredential = true;
153+ } else if (form.type === "mcp") {
154+ const legacySecret = readLegacyMcpStaticSecret(sourceToEdit, form.authType);
155+ if (!legacySecret) {
156+ throw new Error("Enter credentials to finish setup");
157+ }
158+
159+ await upsertCredential({
160+ workspaceId: context.workspaceId,
161+ sessionId: context.sessionId,
162+ sourceKey,
163+ scope: form.authScope,
164+ ...(form.authScope === "actor" ? { actorId: context.actorId } : {}),
165+ secretJson: legacySecret,
166+ });
167+ linkedCredential = true;
120168 } else {
121169 throw new Error("Enter credentials to finish setup");
122170 }
executor/apps/web/src/components/tools/use-add-source-form-state.ts+113−3
@@ -28,6 +28,18 @@ import { · 27 unmodified lines
2828
2929 export { existingCredentialMatchesAuthType } from "./add-source-form-utils";
3030
31+function normalizeEndpointForOAuth(value: string): string {
32+ const trimmed = value.trim();
33+ if (!trimmed) {
34+ return "";
35+ }
36+ try {
37+ return new URL(trimmed).toString();
38+ } catch {
39+ return trimmed;
40+ }
41+}
42+
3143 function patchUi(
3244 setUi: Dispatch<SetStateAction<AddSourceUiState>>,
3345 patch: Partial<AddSourceUiState>,
@@ -91,6 +103,16 @@ export function useAddSourceFormState({ · 57 unmodified lines
91103 .sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? null;
92104 }, [actorId, credentialItems, editingSourceKey, values.authScope]);
93105
106+ const hasPersistedMcpBearerToken = useMemo(() => {
107+ if (values.type !== "mcp" || values.authType !== "bearer") {
108+ return false;
109+ }
110+ if (!existingScopedCredential) {
111+ return false;
112+ }
113+ return existingCredentialMatchesAuthType(existingScopedCredential, "bearer");
114+ }, [existingScopedCredential, values.authType, values.type]);
115+
94116 useEffect(() => {
95117 if (!open) {
96118 return;
@@ -129,6 +151,7 @@ export function useAddSourceFormState({ · 32 unmodified lines
129151
130152 const handleEndpointChange = (endpoint: string) => {
131153 form.setValue("endpoint", endpoint, { shouldDirty: true, shouldTouch: true });
154+ patchUi(setUi, { mcpOAuthLinkedEndpoint: null });
132155
133156 if (values.type === "openapi") {
134157 const inferredBaseUrl = deriveBaseUrlFromEndpoint(endpoint);
@@ -179,6 +202,7 @@ export function useAddSourceFormState({ · 44 unmodified lines
179202 patchUi(setUi, {
180203 openApiBaseUrlOptions: inferredBaseUrl ? [inferredBaseUrl] : [],
181204 authManuallyEdited: false,
205+ mcpOAuthLinkedEndpoint: null,
182206 });
183207 if (!baseUrlManuallyEdited) {
184208 form.setValue("baseUrl", inferredBaseUrl, { shouldDirty: false, shouldTouch: false });
@@ -189,6 +213,7 @@ export function useAddSourceFormState({ · 4 unmodified lines
189213 patchUiWithAuthRevision(setUi, {
190214 openApiBaseUrlOptions: [],
191215 authManuallyEdited: false,
216+ mcpOAuthLinkedEndpoint: null,
192217 });
193218 form.setValue("authType", "none", { shouldDirty: false, shouldTouch: false });
194219 form.setValue("authScope", "workspace", { shouldDirty: false, shouldTouch: false });
@@ -203,12 +228,18 @@ export function useAddSourceFormState({ · 8 unmodified lines
203228
204229 const handleAuthTypeChange = (authType: Exclude<SourceAuthType, "mixed">) => {
205230 form.setValue("authType", authType, { shouldDirty: true, shouldTouch: true });
206− patchUiWithAuthRevision(setUi, { authManuallyEdited: true });
231+ patchUiWithAuthRevision(setUi, {
232+ authManuallyEdited: true,
233+ ...(authType === "bearer" ? {} : { mcpOAuthLinkedEndpoint: null }),
234+ });
207235 };
208236
209237 const handleAuthScopeChange = (authScope: CredentialScope) => {
210238 form.setValue("authScope", authScope, { shouldDirty: true, shouldTouch: true });
211− patchUiWithAuthRevision(setUi, { authManuallyEdited: true });
239+ patchUiWithAuthRevision(setUi, {
240+ authManuallyEdited: true,
241+ mcpOAuthLinkedEndpoint: null,
242+ });
212243 };
213244
214245 const handleAuthFieldChange = (field: SourceAuthPanelEditableField, value: string) => {
@@ -220,7 +251,7 @@ export function useAddSourceFormState({ · 5 unmodified lines
220251
221252 if (field === "tokenValue") {
222253 form.setValue("tokenValue", value, { shouldDirty: true, shouldTouch: true });
223− bumpAuthRevision();
254+ patchUiWithAuthRevision(setUi, { mcpOAuthLinkedEndpoint: null });
224255 return;
225256 }
226257 if (field === "apiKeyValue") {
@@ -319,6 +350,59 @@ export function useAddSourceFormState({ · 92 unmodified lines
319350 : "Failed to fetch spec"
320351 : "";
321352
353+ const mcpDetectionEndpoint = useDeferredValue(values.endpoint.trim());
354+ const mcpDetectionEnabled = open
355+ && ui.view === "custom"
356+ && values.type === "mcp"
357+ && mcpDetectionEndpoint.length > 0;
358+
359+ const mcpOAuthQuery = useTanstackQuery({
360+ queryKey: ["mcp-oauth-detect", mcpDetectionEndpoint],
361+ queryFn: async () => {
362+ const response = await fetch(`/mcp/oauth/detect?sourceUrl=${encodeURIComponent(mcpDetectionEndpoint)}`);
363+ const json = await response.json() as {
364+ oauth?: boolean;
365+ authorizationServers?: unknown[];
366+ detail?: string;
367+ };
368+ return {
369+ oauth: Boolean(json.oauth),
370+ authorizationServers: Array.isArray(json.authorizationServers)
371+ ? json.authorizationServers.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
372+ : [],
373+ detail: typeof json.detail === "string" ? json.detail : "",
374+ };
375+ },
376+ enabled: mcpDetectionEnabled,
377+ retry: false,
378+ staleTime: 60_000,
379+ });
380+
381+ const mcpOAuthStatus: "idle" | "checking" | "oauth" | "none" | "error" = !mcpDetectionEnabled
382+ ? "idle"
383+ : mcpOAuthQuery.isFetching
384+ ? "checking"
385+ : mcpOAuthQuery.isError
386+ ? "error"
387+ : mcpOAuthQuery.data?.oauth
388+ ? "oauth"
389+ : "none";
390+
391+ const mcpOAuthDetail = mcpDetectionEnabled
392+ ? mcpOAuthQuery.isError
393+ ? mcpOAuthQuery.error instanceof Error
394+ ? mcpOAuthQuery.error.message
395+ : "OAuth detection failed"
396+ : mcpOAuthQuery.data?.detail ?? ""
397+ : "";
398+
399+ const mcpOAuthAuthorizationServers = mcpOAuthQuery.data?.authorizationServers ?? [];
400+ const mcpOAuthConnected = values.type === "mcp"
401+ && values.authType === "bearer"
402+ && normalizeEndpointForOAuth(values.endpoint) !== ""
403+ && normalizeEndpointForOAuth(values.endpoint) === ui.mcpOAuthLinkedEndpoint
404+ && values.tokenValue.trim().length > 0;
405+
322406 useEffect(() => {
323407 if (!inspectionEnabled || !specInspectionQuery.data) {
324408 return;
@@ -377,6 +461,23 @@ export function useAddSourceFormState({ · 52 unmodified lines
377461 }
378462 }, [inspectionEnabled, inspectionEndpoint, specInspectionQuery.isError, ui.openApiBaseUrlOptions]);
379463
464+ useEffect(() => {
465+ if (values.type !== "mcp") {
466+ return;
467+ }
468+ if (mcpOAuthStatus !== "oauth") {
469+ return;
470+ }
471+ if (ui.authManuallyEdited) {
472+ return;
473+ }
474+ if (values.authType !== "none") {
475+ return;
476+ }
477+ form.setValue("authType", "bearer", { shouldDirty: false, shouldTouch: false });
478+ form.setValue("authScope", "workspace", { shouldDirty: false, shouldTouch: false });
479+ }, [form, mcpOAuthStatus, ui.authManuallyEdited, values.authType, values.type]);
480+
380481 const isNameTaken = (candidate: string) => {
381482 const taken = [...getTakenSourceNames()].map((entry) => entry.toLowerCase());
382483 return taken.includes(candidate.trim().toLowerCase());
@@ -407,6 +508,11 @@ export function useAddSourceFormState({ · 24 unmodified lines
407508 visibleCatalogItems,
408509 specStatus,
409510 specError,
511+ mcpOAuthStatus,
512+ mcpOAuthDetail,
513+ mcpOAuthAuthorizationServers,
514+ mcpOAuthConnected,
515+ hasPersistedMcpBearerToken,
410516 inferredSpecAuth,
411517 authType: values.authType,
412518 authScope: values.authScope,
@@ -423,6 +529,10 @@ export function useAddSourceFormState({ · 10 unmodified lines
423529 handleAuthTypeChange,
424530 handleAuthScopeChange,
425531 handleAuthFieldChange,
532+ markMcpOAuthLinked: (endpoint: string) =>
533+ patchUi(setUi, {
534+ mcpOAuthLinkedEndpoint: normalizeEndpointForOAuth(endpoint),
535+ }),
426536 setBaseUrl: (baseUrl: string) => form.setValue("baseUrl", baseUrl, { shouldDirty: true, shouldTouch: true }),
427537 setMcpTransport: (mcpTransport: "auto" | "streamable-http" | "sse") =>
428538 form.setValue("mcpTransport", mcpTransport, { shouldDirty: true, shouldTouch: true }),
executor/apps/web/src/lib/mcp-oauth-popup.ts+104−0
@@ -0,0 +1,104 @@
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: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:mcp-oauth-result";
23+ ok: false;
24+ error: string;
25+ };
26+
27+export async function startMcpOAuthPopup(sourceUrl: string): Promise<McpOAuthPopupSuccess> {
28+ if (typeof window === "undefined") {
29+ throw new Error("OAuth popup is only available in a browser context");
30+ }
31+
32+ const startUrl = `/mcp/oauth/start?sourceUrl=${encodeURIComponent(sourceUrl)}`;
33+ const popup = window.open(
34+ startUrl,
35+ "executor-mcp-oauth",
36+ "popup=yes,width=520,height=720",
37+ );
38+
39+ if (!popup) {
40+ throw new Error("Popup blocked. Allow popups and try again.");
41+ }
42+
43+ return await new Promise<McpOAuthPopupSuccess>((resolve, reject) => {
44+ let settled = false;
45+
46+ const closeAndReject = (message: string) => {
47+ if (settled) return;
48+ settled = true;
49+ cleanup();
50+ reject(new Error(message));
51+ };
52+
53+ const cleanup = () => {
54+ window.removeEventListener("message", onMessage);
55+ window.clearInterval(closedPoll);
56+ if (!popup.closed) {
57+ popup.close();
58+ }
59+ };
60+
61+ const onMessage = (event: MessageEvent) => {
62+ if (event.origin !== window.location.origin) {
63+ return;
64+ }
65+
66+ const data = event.data as McpOAuthPopupMessage | undefined;
67+ if (!data || data.type !== "executor:mcp-oauth-result") {
68+ return;
69+ }
70+
71+ if (!data.ok) {
72+ closeAndReject(data.error || "OAuth failed");
73+ return;
74+ }
75+
76+ if (!data.payload?.accessToken) {
77+ closeAndReject("OAuth finished without an access token");
78+ return;
79+ }
80+
81+ if (settled) {
82+ return;
83+ }
84+
85+ settled = true;
86+ cleanup();
87+ resolve({
88+ sourceUrl: data.sourceUrl,
89+ accessToken: data.payload.accessToken,
90+ ...(data.payload.refreshToken ? { refreshToken: data.payload.refreshToken } : {}),
91+ ...(data.payload.scope ? { scope: data.payload.scope } : {}),
92+ ...(typeof data.payload.expiresIn === "number" ? { expiresIn: data.payload.expiresIn } : {}),
93+ });
94+ };
95+
96+ window.addEventListener("message", onMessage);
97+
98+ const closedPoll = window.setInterval(() => {
99+ if (popup.closed) {
100+ closeAndReject("OAuth popup was closed before completion");
101+ }
102+ }, 300);
103+ });
104+}
executor/apps/web/src/lib/mcp-oauth-provider.ts+189−0
@@ -0,0 +1,189 @@
1+import { randomUUID } from "node:crypto";
2+import { Result } from "better-result";
3+import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
4+import type {
5+ OAuthClientInformationMixed,
6+ OAuthClientMetadata,
7+ OAuthTokens,
8+} from "@modelcontextprotocol/sdk/shared/auth.js";
9+
10+export type McpOAuthPending = {
11+ state: string;
12+ sourceUrl: string;
13+ redirectUrl: string;
14+ codeVerifier?: string;
15+ clientInformation?: OAuthClientInformationMixed;
16+};
17+
18+export type McpOAuthPopupResult = {
19+ ok: boolean;
20+ sourceUrl?: string;
21+ accessToken?: string;
22+ refreshToken?: string;
23+ scope?: string;
24+ expiresIn?: number;
25+ error?: string;
26+};
27+
28+const COOKIE_PREFIX = "executor_mcp_oauth_";
29+export const MCP_OAUTH_RESULT_COOKIE = "executor_mcp_oauth_result";
30+
31+export function buildPendingCookieName(state: string): string {
32+ return `${COOKIE_PREFIX}${state}`;
33+}
34+
35+export function createOAuthState(): string {
36+ return randomUUID();
37+}
38+
39+export function encodePendingCookieValue(pending: McpOAuthPending): string {
40+ return Buffer.from(JSON.stringify(pending), "utf8").toString("base64url");
41+}
42+
43+export function decodePendingCookieValue(raw: string): McpOAuthPending | null {
44+ const decodedResult = Result.try(() => Buffer.from(raw, "base64url").toString("utf8"));
45+ if (!decodedResult.isOk()) {
46+ return null;
47+ }
48+
49+ const parsedResult = Result.try(() => JSON.parse(decodedResult.value) as Partial<McpOAuthPending>);
50+ if (!parsedResult.isOk()) {
51+ return null;
52+ }
53+
54+ const parsed = parsedResult.value;
55+ if (
56+ typeof parsed.state !== "string"
57+ || typeof parsed.sourceUrl !== "string"
58+ || typeof parsed.redirectUrl !== "string"
59+ ) {
60+ return null;
61+ }
62+
63+ return {
64+ state: parsed.state,
65+ sourceUrl: parsed.sourceUrl,
66+ redirectUrl: parsed.redirectUrl,
67+ ...(typeof parsed.codeVerifier === "string" ? { codeVerifier: parsed.codeVerifier } : {}),
68+ ...(parsed.clientInformation ? { clientInformation: parsed.clientInformation } : {}),
69+ };
70+}
71+
72+export function encodePopupResultCookieValue(result: McpOAuthPopupResult): string {
73+ return Buffer.from(JSON.stringify(result), "utf8").toString("base64url");
74+}
75+
76+export function decodePopupResultCookieValue(raw: string): McpOAuthPopupResult | null {
77+ const decodedResult = Result.try(() => Buffer.from(raw, "base64url").toString("utf8"));
78+ if (!decodedResult.isOk()) {
79+ return null;
80+ }
81+
82+ const parsedResult = Result.try(() => JSON.parse(decodedResult.value) as Partial<McpOAuthPopupResult>);
83+ if (!parsedResult.isOk()) {
84+ return null;
85+ }
86+
87+ const parsed = parsedResult.value;
88+ if (typeof parsed.ok !== "boolean") {
89+ return null;
90+ }
91+
92+ return {
93+ ok: parsed.ok,
94+ ...(typeof parsed.sourceUrl === "string" ? { sourceUrl: parsed.sourceUrl } : {}),
95+ ...(typeof parsed.accessToken === "string" ? { accessToken: parsed.accessToken } : {}),
96+ ...(typeof parsed.refreshToken === "string" ? { refreshToken: parsed.refreshToken } : {}),
97+ ...(typeof parsed.scope === "string" ? { scope: parsed.scope } : {}),
98+ ...(typeof parsed.expiresIn === "number" ? { expiresIn: parsed.expiresIn } : {}),
99+ ...(typeof parsed.error === "string" ? { error: parsed.error } : {}),
100+ };
101+}
102+
103+export class McpPopupOAuthProvider implements OAuthClientProvider {
104+ public clientMetadata: OAuthClientMetadata;
105+ private stateValue: string;
106+ private redirectTarget: string;
107+ private codeVerifierValue?: string;
108+ private clientInfo?: OAuthClientInformationMixed;
109+ private tokenValue?: OAuthTokens;
110+ private authorizationUrl?: string;
111+
112+ constructor(input: {
113+ redirectUrl: string;
114+ state: string;
115+ codeVerifier?: string;
116+ clientInformation?: OAuthClientInformationMixed;
117+ tokens?: OAuthTokens;
118+ }) {
119+ this.redirectTarget = input.redirectUrl;
120+ this.stateValue = input.state;
121+ this.codeVerifierValue = input.codeVerifier;
122+ this.clientInfo = input.clientInformation;
123+ this.tokenValue = input.tokens;
124+ this.clientMetadata = {
125+ redirect_uris: [input.redirectUrl],
126+ grant_types: ["authorization_code", "refresh_token"],
127+ response_types: ["code"],
128+ token_endpoint_auth_method: "none",
129+ client_name: "Executor MCP Connector",
130+ };
131+ }
132+
133+ get redirectUrl(): string {
134+ return this.redirectTarget;
135+ }
136+
137+ async state(): Promise<string> {
138+ return this.stateValue;
139+ }
140+
141+ clientInformation(): OAuthClientInformationMixed | undefined {
142+ return this.clientInfo;
143+ }
144+
145+ async saveClientInformation(clientInformation: OAuthClientInformationMixed): Promise<void> {
146+ this.clientInfo = clientInformation;
147+ }
148+
149+ tokens(): OAuthTokens | undefined {
150+ return this.tokenValue;
151+ }
152+
153+ async saveTokens(tokens: OAuthTokens): Promise<void> {
154+ this.tokenValue = tokens;
155+ }
156+
157+ async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
158+ this.authorizationUrl = authorizationUrl.toString();
159+ }
160+
161+ async saveCodeVerifier(codeVerifier: string): Promise<void> {
162+ this.codeVerifierValue = codeVerifier;
163+ }
164+
165+ codeVerifier(): string {
166+ if (!this.codeVerifierValue) {
167+ throw new Error("Missing PKCE code verifier");
168+ }
169+ return this.codeVerifierValue;
170+ }
171+
172+ getAuthorizationUrl(): string | undefined {
173+ return this.authorizationUrl;
174+ }
175+
176+ toPending(sourceUrl: string): McpOAuthPending {
177+ return {
178+ state: this.stateValue,
179+ sourceUrl,
180+ redirectUrl: this.redirectTarget,
181+ ...(this.codeVerifierValue ? { codeVerifier: this.codeVerifierValue } : {}),
182+ ...(this.clientInfo ? { clientInformation: this.clientInfo } : {}),
183+ };
184+ }
185+
186+ getTokens(): OAuthTokens | undefined {
187+ return this.tokenValue;
188+ }
189+}
executor/apps/web/src/lib/mcp-oauth-request.ts+48−0
@@ -0,0 +1,48 @@
1+import type { NextRequest } from "next/server";
2+import { Result } from "better-result";
3+
4+function firstHeaderValue(value: string | null): string {
5+ if (!value) {
6+ return "";
7+ }
8+ return value.split(",")[0]?.trim() ?? "";
9+}
10+
11+function configuredExternalOrigin(): string | null {
12+ const configured =
13+ process.env.EXECUTOR_PUBLIC_ORIGIN
14+ ?? process.env.NEXT_PUBLIC_EXECUTOR_HTTP_URL
15+ ?? "";
16+ if (!configured.trim()) {
17+ return null;
18+ }
19+
20+ const parsed = Result.try(() => new URL(configured));
21+ if (!parsed.isOk()) {
22+ return null;
23+ }
24+ return parsed.value.origin;
25+}
26+
27+export function getExternalOrigin(request: NextRequest): string {
28+ const configured = configuredExternalOrigin();
29+ if (configured) {
30+ return configured;
31+ }
32+
33+ const host = firstHeaderValue(request.headers.get("x-forwarded-host") ?? request.headers.get("host"));
34+ const proto = firstHeaderValue(request.headers.get("x-forwarded-proto"))
35+ || request.nextUrl.protocol.replace(":", "");
36+ if (host && proto) {
37+ const parsed = Result.try(() => new URL(`${proto}://${host}`));
38+ if (parsed.isOk()) {
39+ return parsed.value.origin;
40+ }
41+ }
42+ return request.nextUrl.origin;
43+}
44+
45+export function isExternalHttps(request: NextRequest): boolean {
46+ const origin = getExternalOrigin(request);
47+ return origin.startsWith("https://");
48+}
executor/apps/web/src/lib/mcp-oauth-url.ts+101−0
@@ -0,0 +1,101 @@
1+import { isIP } from "node:net";
2+import { Result } from "better-result";
3+import { parse as parseDomain } from "tldts";
4+
5+function isLocalHost(hostname: string): boolean {
6+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
7+}
8+
9+function normalizeIpHost(hostname: string): string {
10+ if (hostname.startsWith("[") && hostname.endsWith("]")) {
11+ return hostname.slice(1, -1);
12+ }
13+ return hostname;
14+}
15+
16+function isPrivateIpv4(hostname: string): boolean {
17+ const parts = hostname.split(".").map((part) => Number.parseInt(part, 10));
18+ if (parts.length !== 4 || parts.some((part) => Number.isNaN(part))) {
19+ return true;
20+ }
21+ const [a, b] = parts;
22+ if (a === 10) return true;
23+ if (a === 127) return true;
24+ if (a === 169 && b === 254) return true;
25+ if (a === 172 && b >= 16 && b <= 31) return true;
26+ if (a === 192 && b === 168) return true;
27+ if (a === 100 && b >= 64 && b <= 127) return true;
28+ if (a === 0) return true;
29+ if (a >= 224) return true;
30+ return false;
31+}
32+
33+function isPrivateIpv6(hostname: string): boolean {
34+ const value = hostname.toLowerCase();
35+ if (value === "::1") return true;
36+ if (value.startsWith("fc") || value.startsWith("fd")) return true;
37+ if (value.startsWith("fe8") || value.startsWith("fe9") || value.startsWith("fea") || value.startsWith("feb")) {
38+ return true;
39+ }
40+
41+ if (value.startsWith("::ffff:")) {
42+ const mapped = value.slice("::ffff:".length);
43+ return isPrivateIpv4(mapped);
44+ }
45+
46+ return false;
47+}
48+
49+function isPrivateIp(hostname: string): boolean {
50+ const normalized = normalizeIpHost(hostname);
51+ const family = isIP(normalized);
52+ if (family === 4) {
53+ return isPrivateIpv4(normalized);
54+ }
55+ if (family === 6) {
56+ return isPrivateIpv6(normalized);
57+ }
58+ return false;
59+}
60+
61+function isPublicDnsHostname(hostname: string): boolean {
62+ const parsed = parseDomain(hostname);
63+ return Boolean(parsed.domain && parsed.publicSuffix);
64+}
65+
66+export function parseMcpSourceUrl(raw: string): Result<URL, Error> {
67+ return Result.try({
68+ try: () => {
69+ const url = new URL(raw);
70+ const protocol = url.protocol.toLowerCase();
71+ const hostname = url.hostname.toLowerCase();
72+
73+ if (url.username || url.password) {
74+ throw new Error("Credentials in MCP source URL are not allowed");
75+ }
76+
77+ if (protocol !== "https:" && protocol !== "http:") {
78+ throw new Error("MCP source URL must use https:// (or http:// for localhost in dev)");
79+ }
80+
81+ const localAllowed = process.env.NODE_ENV !== "production" || process.env.EXECUTOR_ALLOW_LOCAL_MCP_OAUTH === "1";
82+ if (protocol === "http:" && !(localAllowed && isLocalHost(hostname))) {
83+ throw new Error("MCP source URL must use https://");
84+ }
85+
86+ if (isPrivateIp(hostname) && !(localAllowed && isLocalHost(hostname))) {
87+ throw new Error("Private or local MCP hosts are not allowed");
88+ }
89+
90+ if (!isIP(normalizeIpHost(hostname)) && !isPublicDnsHostname(hostname)) {
91+ throw new Error("MCP source host must be a public DNS host");
92+ }
93+
94+ return url;
95+ },
96+ catch: (error) =>
97+ error instanceof Error
98+ ? error
99+ : new Error(String(error)),
100+ });
101+}
executor/apps/web/src/lib/tools-source-helpers.ts+2−1
@@ -72,6 +72,7 @@ export function sourceEndpointLabel(source: ToolSourceRecord): string { · 71 unmodified lines
7272 export function sourceKeyForSource(source: ToolSourceRecord): string | null {
7373 if (source.type === "openapi") return `source:${source.id}`;
7474 if (source.type === "graphql") return `source:${source.id}`;
75+ if (source.type === "mcp") return `source:${source.id}`;
7576 return null;
7677 }
7778
@@ -146,7 +147,7 @@ export function readSourceAuth( · 68 unmodified lines
146147 header?: string;
147148 inferred?: boolean;
148149 } {
149− if (source.type !== "openapi" && source.type !== "graphql") {
150+ if (source.type !== "openapi" && source.type !== "graphql" && source.type !== "mcp") {
150151 return { type: "none" };
151152 }
152153
executor/packages/convex/runtime/tool_call_resolution.ts+3−1
@@ -84,7 +84,9 @@ export async function ensureWorkspaceTools( · 83 unmodified lines
8484 if (workspaceTools) {
8585 return workspaceTools;
8686 }
87− const result = await getWorkspaceTools(ctx, task.workspaceId);
87+ const result = await getWorkspaceTools(ctx, task.workspaceId, {
88+ actorId: task.actorId,
89+ });
8890 return result.tools;
8991 }
9092
executor/packages/convex/runtime/tool_source_loading.ts+127−4
@@ -2,20 +2,25 @@ · 1 unmodified line
22
33 import type { ActionCtx } from "../_generated/server";
44 import { internal } from "../_generated/api";
5+import type { Id } from "../_generated/dataModel";
56 import {
67 compileExternalToolSource,
78 compileOpenApiToolSourceFromPrepared,
89 prepareOpenApiSpec,
910 type CompiledToolSourceArtifact,
1011 } from "../../core/src/tool-sources";
12+import { resolveCredentialPayload } from "../../core/src/credential-providers";
13+import { buildStaticAuthHeaders } from "../../core/src/tool-source-auth";
1114 import type {
1215 ExternalToolSourceConfig,
1316 GraphqlToolSourceConfig,
1417 McpToolSourceConfig,
18+ OpenApiAuth,
1519 OpenApiToolSourceConfig,
1620 PreparedOpenApiSpec,
1721 } from "../../core/src/tool-source-types";
1822 import type { ToolSourceRecord } from "../../core/src/types";
23+import { asPayload } from "../lib/object";
1924
2025 const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;
2126
@@ -69,6 +74,7 @@ export function normalizeExternalToolSource(raw: { · 47 unmodified lines
6974 sourceId: raw.id,
7075 sourceKey: `source:${raw.id}`,
7176 url: config.url,
77+ auth: config.auth as McpToolSourceConfig["auth"],
7278 transport: config.transport as McpToolSourceConfig["transport"],
7379 queryParams: config.queryParams as McpToolSourceConfig["queryParams"],
7480 defaultApproval: config.defaultApproval as McpToolSourceConfig["defaultApproval"],
@@ -117,6 +123,103 @@ export function normalizeExternalToolSource(raw: { · 42 unmodified lines
117123 return result;
118124 }
119125
126+function buildHeadersFromCredentialSecret(
127+ auth: OpenApiAuth,
128+ secret: Record<string, unknown>,
129+): Record<string, string> {
130+ if (auth.type === "bearer") {
131+ const token = String(secret.token ?? "").trim();
132+ return token ? { authorization: `Bearer ${token}` } : {};
133+ }
134+
135+ if (auth.type === "apiKey") {
136+ const value = String(secret.value ?? secret.token ?? "").trim();
137+ return value ? { [auth.header]: value } : {};
138+ }
139+
140+ if (auth.type === "basic") {
141+ const username = String(secret.username ?? "");
142+ const password = String(secret.password ?? "");
143+ if (!username && !password) {
144+ return {};
145+ }
146+ const encoded = Buffer.from(`${username}:${password}`, "utf8").toString("base64");
147+ return { authorization: `Basic ${encoded}` };
148+ }
149+
150+ return {};
151+}
152+
153+async function resolveMcpDiscoveryHeaders(
154+ ctx: ActionCtx,
155+ source: McpToolSourceConfig,
156+ workspaceId: Id<"workspaces">,
157+ actorId?: string,
158+): Promise<{ headers: Record<string, string>; warnings: string[] }> {
159+ const auth = source.auth;
160+ if (!auth || auth.type === "none") {
161+ return { headers: {}, warnings: [] };
162+ }
163+
164+ const mode = auth.mode ?? "static";
165+ if (mode === "static") {
166+ return { headers: buildStaticAuthHeaders(auth), warnings: [] };
167+ }
168+
169+ if (!source.sourceKey) {
170+ return {
171+ headers: {},
172+ warnings: [`Source '${source.name}': missing source key for MCP credential discovery`],
173+ };
174+ }
175+
176+ const record = await ctx.runQuery(internal.database.resolveCredential, {
177+ workspaceId,
178+ sourceKey: source.sourceKey,
179+ scope: mode,
180+ actorId,
181+ });
182+
183+ if (!record) {
184+ return {
185+ headers: {},
186+ warnings: [`Source '${source.name}': missing ${mode} credential for MCP discovery`],
187+ };
188+ }
189+
190+ try {
191+ const secret = await resolveCredentialPayload(record);
192+ if (!secret) {
193+ return {
194+ headers: {},
195+ warnings: [`Source '${source.name}': credential payload unavailable for MCP discovery`],
196+ };
197+ }
198+
199+ const headers = buildHeadersFromCredentialSecret(auth, secret);
200+ const overrideHeaders = asPayload(asPayload(record.overridesJson).headers);
201+ for (const [key, value] of Object.entries(overrideHeaders)) {
202+ if (!key) continue;
203+ headers[key] = String(value);
204+ }
205+
206+ if (Object.keys(headers).length === 0) {
207+ return {
208+ headers: {},
209+ warnings: [`Source '${source.name}': credential did not produce MCP auth headers for discovery`],
210+ };
211+ }
212+
213+ return { headers, warnings: [] };
214+ } catch (error) {
215+ const message = error instanceof Error ? error.message : String(error);
216+ return {
217+ headers: {},
218+ warnings: [`Source '${source.name}': failed to resolve MCP credential for discovery: ${message}`],
219+ };
220+ }
221+}
222+
120223 async function loadCachedOpenApiSpec(
121224 ctx: ActionCtx,
122225 specUrl: string,
@@ -175,7 +278,7 @@ async function loadCachedOpenApiSpec( · 52 unmodified lines
175278 export async function loadSourceArtifact(
176279 ctx: ActionCtx,
177280 source: ExternalToolSourceConfig,
178− options: { includeDts?: boolean } = {},
281+ options: { includeDts?: boolean; workspaceId: Id<"workspaces">; actorId?: string },
179282 ): Promise<{ artifact?: CompiledToolSourceArtifact; warnings: string[] }> {
180283 const includeDts = options.includeDts ?? true;
181284
@@ -196,14 +299,34 @@ export async function loadSourceArtifact( · 14 unmodified lines
196299 }
197300 }
198301
302+ const preWarnings: string[] = [];
303+ let sourceForCompile: ExternalToolSourceConfig = source;
304+
305+ if (source.type === "mcp") {
306+ const resolved = await resolveMcpDiscoveryHeaders(
307+ ctx,
308+ source,
309+ options.workspaceId,
310+ options.actorId,
311+ );
312+ preWarnings.push(...resolved.warnings);
313+ sourceForCompile = {
314+ ...source,
315+ discoveryHeaders: resolved.headers,
316+ };
317+ }
318+
199319 try {
200− const artifact = await compileExternalToolSource(source);
201− return { artifact, warnings: [] };
320+ const artifact = await compileExternalToolSource(sourceForCompile);
321+ return { artifact, warnings: preWarnings };
202322 } catch (error) {
203323 const message = error instanceof Error ? error.message : String(error);
204324 return {
205325 artifact: undefined,
206− warnings: [`Failed to load ${source.type} source '${source.name}': ${message}`],
326+ warnings: [
327+ ...preWarnings,
328+ `Failed to load ${source.type} source '${source.name}': ${message}`,
329+ ],
207330 };
208331 }
209332 }
executor/packages/convex/runtime/workspace_tools.ts+33−18
@@ -54,6 +54,7 @@ interface GetWorkspaceToolsOptions { · 53 unmodified lines
5454 sourceTimeoutMs?: number;
5555 allowStaleOnMismatch?: boolean;
5656 skipCacheRead?: boolean;
57+ actorId?: string;
5758 }
5859
5960 interface WorkspaceToolInventory {
@@ -154,16 +155,25 @@ export async function getWorkspaceTools( · 94 unmodified lines
154155 const includeDts = options.includeDts ?? false;
155156 const sourceTimeoutMs = options.sourceTimeoutMs;
156157 const allowStaleOnMismatch = options.allowStaleOnMismatch ?? false;
157− const skipCacheRead = options.skipCacheRead ?? false;
158+ const actorId = options.actorId;
158159 const sources = (await ctx.runQuery(internal.database.listToolSources, { workspaceId }))
159160 .filter((source: { enabled: boolean }) => source.enabled);
161+ const hasActorScopedMcpSource = sources.some((source: { type: string; config: Record<string, unknown> }) => {
162+ if (source.type !== "mcp") {
163+ return false;
164+ }
165+ const auth = source.config.auth as Record<string, unknown> | undefined;
166+ return auth?.mode === "actor";
167+ });
168+ const skipCacheRead = (options.skipCacheRead ?? false) || hasActorScopedMcpSource;
169+ const skipCacheWrite = hasActorScopedMcpSource;
160170 traceStep("listToolSources", listSourcesStartedAt);
161171 const hasOpenApiSource = sources.some((source: { type: string }) => source.type === "openapi");
162172 const signature = sourceSignature(workspaceId, sources);
163173 const debugBase: Omit<WorkspaceToolsDebug, "mode" | "normalizedSourceCount" | "cacheHit" | "cacheFresh" | "timedOutSources" | "durationMs" | "trace"> = {
164− includeDts,
165− sourceTimeoutMs: sourceTimeoutMs ?? null,
166− skipCacheRead,
174+ includeDts,
175+ sourceTimeoutMs: sourceTimeoutMs ?? null,
176+ skipCacheRead,
167177 sourceCount: sources.length,
168178 };
169179
@@ -251,7 +261,7 @@ export async function getWorkspaceTools( · 81 unmodified lines
251261 const loadedSources = await Promise.all(configs.map(async (config) => {
252262 if (!sourceTimeoutMs || sourceTimeoutMs <= 0) {
253263 return {
254− ...(await loadSourceArtifact(ctx, config, { includeDts })),
264+ ...(await loadSourceArtifact(ctx, config, { includeDts, workspaceId, actorId })),
255265 timedOut: false,
256266 sourceName: config.name,
257267 };
@@ -274,7 +284,7 @@ export async function getWorkspaceTools( · 16 unmodified lines
274284 }, sourceTimeoutMs);
275285 });
276286
277− const loadResult = loadSourceArtifact(ctx, config, { includeDts })
287+ const loadResult = loadSourceArtifact(ctx, config, { includeDts, workspaceId, actorId })
278288 .then((result) => ({ ...result, timedOut: false, sourceName: config.name }));
279289
280290 const result = await Promise.race([loadResult, timeoutResult]);
@@ -354,18 +364,22 @@ export async function getWorkspaceTools( · 73 unmodified lines
354364 warnings,
355365 };
356366
357− const json = JSON.stringify(snapshot);
358− const blob = new Blob([json], { type: "application/json" });
359− const storageId = await ctx.storage.store(blob);
360− await ctx.runMutation(internal.workspaceToolCache.putEntry, {
361− workspaceId,
362− signature,
363− storageId,
364− dtsStorageIds: storedDts.map((e) => ({ sourceKey: e.sourceKey, storageId: e.storageId })),
365− toolCount: allTools.length,
366− sizeBytes: json.length,
367− });
368− traceStep("snapshotWrite", snapshotWriteStartedAt);
367+ if (!skipCacheWrite) {
368+ const json = JSON.stringify(snapshot);
369+ const blob = new Blob([json], { type: "application/json" });
370+ const storageId = await ctx.storage.store(blob);
371+ await ctx.runMutation(internal.workspaceToolCache.putEntry, {
372+ workspaceId,
373+ signature,
374+ storageId,
375+ dtsStorageIds: storedDts.map((e) => ({ sourceKey: e.sourceKey, storageId: e.storageId })),
376+ toolCount: allTools.length,
377+ sizeBytes: json.length,
378+ });
379+ traceStep("snapshotWrite", snapshotWriteStartedAt);
380+ } else {
381+ trace.push("snapshotWrite=skipped(actor-scoped-mcp)");
382+ }
369383 } catch (error) {
370384 const msg = error instanceof Error ? error.message : String(error);
371385 console.warn(`[executor] workspace tool cache write failed for '${workspaceId}': ${msg}`);
@@ -413,6 +427,7 @@ async function loadWorkspaceToolInventoryForContext( · 41 unmodified lines
413427 sourceTimeoutMs,
414428 allowStaleOnMismatch,
415429 skipCacheRead,
430+ actorId: context.actorId,
416431 }),
417432 ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: context.workspaceId }),
418433 ]);
executor/packages/core/src/mcp-runtime.ts+38−4
@@ -2,10 +2,26 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; · 1 unmodified line
22 import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
33 import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
44
5+function withHeaders(
6+ input: RequestInfo | URL,
7+ init: RequestInit | undefined,
8+ headers: Record<string, string>,
9+): Promise<Response> {
10+ const mergedHeaders = new Headers(init?.headers ?? {});
11+ for (const [key, value] of Object.entries(headers)) {
12+ mergedHeaders.set(key, value);
13+ }
14+ return fetch(input, {
15+ ...init,
16+ headers: mergedHeaders,
17+ });
18+}
19+
520 export async function connectMcp(
621 url: string,
722 queryParams: Record<string, string> | undefined,
823 preferredTransport?: "sse" | "streamable-http",
24+ headers?: Record<string, string>,
925 ): Promise<{ client: Client; close: () => Promise<void> }> {
1026 const endpoint = new URL(url);
1127 if (queryParams) {
@@ -20,20 +36,38 @@ export async function connectMcp( · 8 unmodified lines
2036 );
2137
2238 if (preferredTransport === "sse") {
23− await client.connect(new SSEClientTransport(endpoint));
39+ await client.connect(new SSEClientTransport(endpoint, {
40+ requestInit: headers ? { headers } : undefined,
41+ eventSourceInit: headers
42+ ? {
43+ fetch: (input, init) => withHeaders(input, init, headers),
44+ }
45+ : undefined,
46+ }));
2447 return { client, close: () => client.close() };
2548 }
2649
2750 if (preferredTransport === "streamable-http") {
28− await client.connect(new StreamableHTTPClientTransport(endpoint) as Parameters<Client["connect"]>[0]);
51+ await client.connect(new StreamableHTTPClientTransport(endpoint, {
52+ requestInit: headers ? { headers } : undefined,
53+ }) as Parameters<Client["connect"]>[0]);
2954 return { client, close: () => client.close() };
3055 }
3156
3257 try {
33− await client.connect(new StreamableHTTPClientTransport(endpoint) as Parameters<Client["connect"]>[0]);
58+ await client.connect(new StreamableHTTPClientTransport(endpoint, {
59+ requestInit: headers ? { headers } : undefined,
60+ }) as Parameters<Client["connect"]>[0]);
3461 return { client, close: () => client.close() };
3562 } catch {
36− await client.connect(new SSEClientTransport(endpoint));
63+ await client.connect(new SSEClientTransport(endpoint, {
64+ requestInit: headers ? { headers } : undefined,
65+ eventSourceInit: headers
66+ ? {
67+ fetch: (input, init) => withHeaders(input, init, headers),
68+ }
69+ : undefined,
70+ }));
3771 return { client, close: () => client.close() };
3872 }
3973 }
executor/packages/core/src/tool-source-loaders/mcp-loader.ts+23−6
@@ -2,6 +2,7 @@ · 1 unmodified line
22
33 import { connectMcp, extractMcpResult } from "../mcp-runtime";
44 import { jsonSchemaTypeHintFallback } from "../openapi-schema-hints";
5+import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool-source-auth";
56 import { callMcpToolWithReconnect } from "../tool-source-execution";
67 import { sanitizeSegment } from "../tool-path-utils";
78 import type { McpToolSourceConfig } from "../tool-source-types";
@@ -15,11 +16,25 @@ export async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDef · 7 unmodified lines
1516 ? Object.fromEntries(
1617 Object.entries(config.queryParams).map(([key, value]) => [key, String(value)]),
1718 )
18− : undefined;
19+ : undefined;
20+ const authHeaders = buildStaticAuthHeaders(config.auth);
21+ const discoveryHeaders = {
22+ ...authHeaders,
23+ ...(config.discoveryHeaders ?? {}),
24+ };
25+ const credentialSpec = buildCredentialSpec(getCredentialSourceKey(config), config.auth);
1926
20− let connection = await connectMcp(config.url, queryParams, config.transport);
27+ let connection = await connectMcp(config.url, queryParams, config.transport, discoveryHeaders);
2128
22− async function callToolWithReconnect(name: string, input: Record<string, unknown>): Promise<unknown> {
29+ async function callToolWithReconnect(
30+ name: string,
31+ input: Record<string, unknown>,
32+ credentialHeaders?: Record<string, string>,
33+ ): Promise<unknown> {
34+ const mergedHeaders = {
35+ ...discoveryHeaders,
36+ ...(credentialHeaders ?? {}),
37+ };
2338 return await callMcpToolWithReconnect(
2439 () => connection.client.callTool({ name, arguments: input }),
2540 async () => {
@@ -29,7 +44,7 @@ export async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDef · 3 unmodified lines
2944 // ignore
3045 }
3146
32− connection = await connectMcp(config.url, queryParams, config.transport);
47+ connection = await connectMcp(config.url, queryParams, config.transport, mergedHeaders);
3348 return await connection.client.callTool({ name, arguments: input });
3449 },
3550 );
@@ -61,16 +76,18 @@ export async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDef · 25 unmodified lines
6176 displayReturnsType: compactReturnTypeHint(returnsType),
6277 ...(argPreviewKeys.length > 0 ? { argPreviewKeys } : {}),
6378 },
79+ credential: credentialSpec,
6480 _runSpec: {
6581 kind: "mcp" as const,
6682 url: config.url,
6783 transport: config.transport,
6884 queryParams: config.queryParams,
85+ authHeaders,
6986 toolName,
7087 },
71− run: async (input: unknown) => {
88+ run: async (input: unknown, context) => {
7289 const payload = asRecord(input);
73− const result = await callToolWithReconnect(toolName, payload);
90+ const result = await callToolWithReconnect(toolName, payload, context.credential?.headers);
7491 return extractMcpResult(result);
7592 },
7693 } satisfies ToolDefinition & { _runSpec: SerializedTool["runSpec"] };
executor/packages/core/src/tool-source-serialization.ts+20−6
@@ -28,6 +28,7 @@ export interface SerializedTool { · 27 unmodified lines
2828 url: string;
2929 transport?: "sse" | "streamable-http";
3030 queryParams?: Record<string, string>;
31+ authHeaders: Record<string, string>;
3132 toolName: string;
3233 }
3334 | PostmanSerializedRunSpec
@@ -57,8 +58,16 @@ function resolveSerializedRunSpec(tool: ToolDefinition): SerializedTool["runSpec · 23 unmodified lines
5758 return runSpec ?? { kind: "builtin" };
5859 }
5960
60−function buildMcpConnectionKey(url: string, transport?: "sse" | "streamable-http"): string {
61− return `${url}|${transport ?? ""}`;
61+function buildMcpConnectionKey(
62+ url: string,
63+ transport: "sse" | "streamable-http" | undefined,
64+ headers: Record<string, string>,
65+): string {
66+ const headerEntries = Object.entries(headers)
67+ .sort(([left], [right]) => left.localeCompare(right))
68+ .map(([key, value]) => `${key}:${value}`)
69+ .join("|");
70+ return `${url}|${transport ?? ""}|${headerEntries}`;
6271 }
6372
6473 function getOrCreateMcpConnection(
@@ -137,14 +146,19 @@ export function rehydrateTools( · 72 unmodified lines
137146
138147 if (st.runSpec.kind === "mcp") {
139148 const { url, transport, queryParams, toolName } = st.runSpec;
149+ const authHeaders = st.runSpec.authHeaders ?? {};
140150 return {
141151 ...base,
142− run: async (input: unknown) => {
143− const connKey = buildMcpConnectionKey(url, transport);
152+ run: async (input: unknown, context) => {
153+ const mergedHeaders = {
154+ ...authHeaders,
155+ ...(context.credential?.headers ?? {}),
156+ };
157+ const connKey = buildMcpConnectionKey(url, transport, mergedHeaders);
144158 let conn = await getOrCreateMcpConnection(
145159 mcpConnections,
146160 connKey,
147− () => connectMcp(url, queryParams, transport),
161+ () => connectMcp(url, queryParams, transport, mergedHeaders),
148162 );
149163
150164 const payload = asRecord(input);
@@ -156,7 +170,7 @@ export function rehydrateTools( · 5 unmodified lines
156170 } catch {
157171 // ignore
158172 }
159− const newConnPromise = connectMcp(url, queryParams, transport);
173+ const newConnPromise = connectMcp(url, queryParams, transport, mergedHeaders);
160174 mcpConnections.set(connKey, { promise: newConnPromise });
161175 conn = await newConnPromise;
162176 return await conn.client.callTool({ name: toolName, arguments: payload });
executor/packages/core/src/tool-source-types.ts+2−0
@@ -6,6 +6,8 @@ export interface McpToolSourceConfig { · 5 unmodified lines
66 sourceId?: string;
77 sourceKey?: string;
88 url: string;
9+ auth?: OpenApiAuth;
10+ discoveryHeaders?: Record<string, string>;
911 transport?: "sse" | "streamable-http";
1012 queryParams?: Record<string, string>;
1113 defaultApproval?: ToolApprovalMode;
executor/packages/core/src/workspace-tool-cache.test.ts+1−0
@@ -256,6 +256,7 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 255 unmodified lines
256256 kind: "mcp" as const,
257257 url: "https://mcp.example.com/sse",
258258 transport: "sse" as const,
259+ authHeaders: {},
259260 toolName: "some_tool",
260261 },
261262 run: async () => "mock",