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
92 "@streamdown/code": "^1.0.1",92 "@streamdown/code": "^1.0.1",
93 "@tanstack/react-query": "^5.80.7",93 "@tanstack/react-query": "^5.80.7",
94 "@workos-inc/authkit-nextjs": "^2.13.0",94 "@workos-inc/authkit-nextjs": "^2.13.0",
95 "better-result": "^2.7.0",
95 "class-variance-authority": "^0.7.1",96 "class-variance-authority": "^0.7.1",
96 "clsx": "^2.1.1",97 "clsx": "^2.1.1",
97 "convex": "^1.31.7",98 "convex": "^1.31.7",
executor/apps/web/package.json+1−0
@@ -17,6 +17,7 @@ · 16 unmodified lines
17 "@streamdown/code": "^1.0.1",17 "@streamdown/code": "^1.0.1",
18 "@tanstack/react-query": "^5.80.7",18 "@tanstack/react-query": "^5.80.7",
19 "@workos-inc/authkit-nextjs": "^2.13.0",19 "@workos-inc/authkit-nextjs": "^2.13.0",
20 "better-result": "^2.7.0",
20 "class-variance-authority": "^0.7.1",21 "class-variance-authority": "^0.7.1",
21 "clsx": "^2.1.1",22 "clsx": "^2.1.1",
22 "convex": "^1.31.7",23 "convex": "^1.31.7",
executor/apps/web/src/app/mcp/oauth/callback/route.ts+123−0
@@ -0,0 +1,123 @@
1import type { NextRequest } from "next/server";
2import { NextResponse } from "next/server";
3import { Result } from "better-result";
4import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
5import { getExternalOrigin, isExternalHttps } from "@/lib/mcp-oauth-request";
6import { parseMcpSourceUrl } from "@/lib/mcp-oauth-url";
7import {
8 buildPendingCookieName,
9 decodePendingCookieValue,
10 encodePopupResultCookieValue,
11 MCP_OAUTH_RESULT_COOKIE,
12 McpPopupOAuthProvider,
13 type McpOAuthPopupResult,
14} from "@/lib/mcp-oauth-provider";
15
16function 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
38function 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
51export 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
3import { useEffect, useRef } from "react";
4import { useQuery as useTanstackQuery } from "@tanstack/react-query";
5
6type 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
24function 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
32async 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
46export 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 @@
1import type { NextRequest } from "next/server";
2import { NextResponse } from "next/server";
3import { Result } from "better-result";
4import { discoverOAuthProtectedResourceMetadata } from "@modelcontextprotocol/sdk/client/auth.js";
5import { parseMcpSourceUrl } from "@/lib/mcp-oauth-url";
6
7type DetectResponse = {
8 oauth: boolean;
9 authorizationServers: string[];
10 detail?: string;
11};
12
13function noStoreJson(payload: DetectResponse, status = 200): NextResponse {
14 return NextResponse.json(payload, {
15 status,
16 headers: {
17 "cache-control": "no-store",
18 },
19 });
20}
21
22function 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
35export 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 @@
1import type { NextRequest } from "next/server";
2import { NextResponse } from "next/server";
3import {
4 decodePopupResultCookieValue,
5 MCP_OAUTH_RESULT_COOKIE,
6 type McpOAuthPopupResult,
7} from "@/lib/mcp-oauth-provider";
8
9type 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
27function 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
66export 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 @@
1import type { NextRequest } from "next/server";
2import { NextResponse } from "next/server";
3import { Result } from "better-result";
4import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
5import { getExternalOrigin, isExternalHttps } from "@/lib/mcp-oauth-request";
6import { parseMcpSourceUrl } from "@/lib/mcp-oauth-url";
7import {
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
17function 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
32function badPopupResponse(request: NextRequest, message: string): NextResponse {
33 return popupResultRedirect(request, { ok: false, error: message });
34}
35
36function 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
49export 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 @@
1import { KeyRound, LockKeyhole, ShieldCheck, UserRound } from "lucide-react";1import { KeyRound, LockKeyhole, UserRound } from "lucide-react";
2import { Badge } from "@/components/ui/badge";2import { Badge } from "@/components/ui/badge";
3import { Button } from "@/components/ui/button";
3import { Input } from "@/components/ui/input";4import { Input } from "@/components/ui/input";
4import { Label } from "@/components/ui/label";5import { Label } from "@/components/ui/label";
5import {6import {
@@ -25,6 +26,10 @@ export type SourceAuthPanelModel = { · 19 unmodified lines
25 specStatus: "idle" | "detecting" | "ready" | "error";26 specStatus: "idle" | "detecting" | "ready" | "error";
26 inferredSpecAuth: InferredSpecAuth | null;27 inferredSpecAuth: InferredSpecAuth | null;
27 specError: string;28 specError: string;
29 mcpOAuthStatus: "idle" | "checking" | "oauth" | "none" | "error";
30 mcpOAuthDetail: string;
31 mcpOAuthAuthorizationServers: string[];
32 mcpOAuthConnected: boolean;
28 authType: Exclude<SourceAuthType, "mixed">;33 authType: Exclude<SourceAuthType, "mixed">;
29 authScope: CredentialScope;34 authScope: CredentialScope;
30 apiKeyHeader: string;35 apiKeyHeader: string;
@@ -51,7 +56,7 @@ function inferredAuthBadge(inferredSpecAuth: InferredSpecAuth | null): string | · 20 unmodified lines
51 if (inferredSpecAuth.type === "bearer") {56 if (inferredSpecAuth.type === "bearer") {
52 return "Bearer";57 return "Bearer";
53 }58 }
54 return "No auth";59 return null;
55}60}
56 61
57export function SourceAuthPanel({62export function SourceAuthPanel({
@@ -59,17 +64,25 @@ export function SourceAuthPanel({ · 1 unmodified line
59 onAuthTypeChange,64 onAuthTypeChange,
60 onAuthScopeChange,65 onAuthScopeChange,
61 onFieldChange,66 onFieldChange,
67 onMcpOAuthConnect,
68 mcpOAuthBusy = false,
62}: {69}: {
63 model: SourceAuthPanelModel;70 model: SourceAuthPanelModel;
64 onAuthTypeChange: (value: Exclude<SourceAuthType, "mixed">) => void;71 onAuthTypeChange: (value: Exclude<SourceAuthType, "mixed">) => void;
65 onAuthScopeChange: (value: CredentialScope) => void;72 onAuthScopeChange: (value: CredentialScope) => void;
66 onFieldChange: (field: SourceAuthPanelEditableField, value: string) => void;73 onFieldChange: (field: SourceAuthPanelEditableField, value: string) => void;
74 onMcpOAuthConnect?: () => void;
75 mcpOAuthBusy?: boolean;
67}) {76}) {
68 const {77 const {
69 sourceType,78 sourceType,
70 specStatus,79 specStatus,
71 inferredSpecAuth,80 inferredSpecAuth,
72 specError,81 specError,
82 mcpOAuthStatus,
83 mcpOAuthDetail,
84 mcpOAuthAuthorizationServers,
85 mcpOAuthConnected,
73 authType,86 authType,
74 authScope,87 authScope,
75 apiKeyHeader,88 apiKeyHeader,
@@ -80,44 +93,22 @@ export function SourceAuthPanel({ · 4 unmodified lines
80 hasExistingCredential,93 hasExistingCredential,
81 } = model;94 } = model;
82 95
83 if (sourceType !== "openapi" && sourceType !== "graphql") {96 if (sourceType !== "openapi" && sourceType !== "graphql" && sourceType !== "mcp") {
84 return null;97 return null;
85 }98 }
86 99
87 const badge = inferredAuthBadge(inferredSpecAuth);100 const badge = inferredAuthBadge(inferredSpecAuth);
88 const scopeHint = authScope === "workspace" ? "Shared with workspace" : "Private to your user";101 const mcpBearerConnected = sourceType === "mcp" && authType === "bearer" && mcpOAuthConnected;
89
90 return (102 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">103 <div className="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>
118 104
119 {sourceType === "openapi" ? (105 {sourceType === "openapi" ? (
120 <div className="flex items-center gap-2 flex-wrap">106 <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}
121 {badge ? (112 {badge ? (
122 <Badge variant="outline" className="text-[10px] uppercase tracking-wider">113 <Badge variant="outline" className="text-[10px] uppercase tracking-wider">
123 {badge}114 {badge}
@@ -127,6 +118,30 @@ export function SourceAuthPanel({ · 3 unmodified lines
127 </div>118 </div>
128 ) : null}119 ) : null}
129 120
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
130 <div className="grid grid-cols-2 gap-3">145 <div className="grid grid-cols-2 gap-3">
131 <div className="space-y-1.5">146 <div className="space-y-1.5">
132 <Label className="text-xs text-muted-foreground">Auth Type</Label>147 <Label className="text-xs text-muted-foreground">Auth Type</Label>
@@ -158,10 +173,35 @@ export function SourceAuthPanel({ · 25 unmodified lines
158 <SelectItem value="actor" className="text-xs">Only me</SelectItem>173 <SelectItem value="actor" className="text-xs">Only me</SelectItem>
159 </SelectContent>174 </SelectContent>
160 </Select>175 </Select>
161 <p className="text-[10px] text-muted-foreground">{scopeHint}</p>
162 </div>176 </div>
163 </div>177 </div>
164 178
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
165 {authType === "apiKey" ? (205 {authType === "apiKey" ? (
166 <div className="space-y-1.5">206 <div className="space-y-1.5">
167 <Label className="text-xs text-muted-foreground">API Key Header</Label>207 <Label className="text-xs text-muted-foreground">API Key Header</Label>
@@ -174,7 +214,7 @@ export function SourceAuthPanel({ · 6 unmodified lines
174 </div>214 </div>
175 ) : null}215 ) : null}
176 216
177 {authType === "bearer" ? (217 {authType === "bearer" && !mcpBearerConnected ? (
178 <div className="space-y-1.5">218 <div className="space-y-1.5">
179 <Label className="text-xs text-muted-foreground flex items-center gap-1.5">219 <Label className="text-xs text-muted-foreground flex items-center gap-1.5">
180 <LockKeyhole className="h-3 w-3" />220 <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
77 if (type === "mcp") {77 if (type === "mcp") {
78 return {78 return {
79 url: endpoint,79 url: endpoint,
80 ...(auth ? { auth } : {}),
80 ...(mcpTransport !== "auto" ? { transport: mcpTransport } : {}),81 ...(mcpTransport !== "auto" ? { transport: mcpTransport } : {}),
81 ...(actorId82 ...(actorId
82 ? { queryParams: { [DEFAULT_MCP_ACTOR_QUERY_PARAM_KEY]: actorId } }83 ? { 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
3import { useState, type ReactNode } from "react";3import { useState, type ReactNode } from "react";
4import { Plus } from "lucide-react";4import { Plus } from "lucide-react";
5import { useAction, useMutation, useQuery } from "convex/react";5import { useAction, useMutation, useQuery } from "convex/react";
6import { Result } from "better-result";
6import { toast } from "sonner";7import { toast } from "sonner";
7import { Button } from "@/components/ui/button";8import { Button } from "@/components/ui/button";
8import {9import {
@@ -17,6 +18,7 @@ import { convexApi } from "@/lib/convex-api"; · 8 unmodified lines
17import type { CredentialRecord, ToolSourceRecord } from "@/lib/types";18import type { CredentialRecord, ToolSourceRecord } from "@/lib/types";
18import { workspaceQueryArgs } from "@/lib/workspace-query-args";19import { workspaceQueryArgs } from "@/lib/workspace-query-args";
19import type { CatalogCollectionItem } from "@/lib/catalog-collections";20import type { CatalogCollectionItem } from "@/lib/catalog-collections";
21import { startMcpOAuthPopup } from "@/lib/mcp-oauth-popup";
20import {22import {
21 CatalogViewSection,23 CatalogViewSection,
22 CustomViewSection,24 CustomViewSection,
@@ -27,6 +29,31 @@ import { · 4 unmodified lines
27} from "./use-add-source-form-state";29} from "./use-add-source-form-state";
28import { saveSourceWithCredentials } from "./add-source-submit";30import { saveSourceWithCredentials } from "./add-source-submit";
29 31
32function 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
45function 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
30export function AddSourceDialog({57export function AddSourceDialog({
31 existingSourceNames,58 existingSourceNames,
32 onSourceAdded,59 onSourceAdded,
@@ -49,6 +76,7 @@ export function AddSourceDialog({ · 16 unmodified lines
49 const credentialsLoading = Boolean(context) && credentials === undefined;76 const credentialsLoading = Boolean(context) && credentials === undefined;
50 const [open, setOpen] = useState(false);77 const [open, setOpen] = useState(false);
51 const [submitting, setSubmitting] = useState(false);78 const [submitting, setSubmitting] = useState(false);
79 const [mcpOAuthBusy, setMcpOAuthBusy] = useState(false);
52 const form = useAddSourceFormState({80 const form = useAddSourceFormState({
53 open,81 open,
54 sourceToEdit,82 sourceToEdit,
@@ -85,8 +113,8 @@ export function AddSourceDialog({ · 30 unmodified lines
85 }113 }
86 114
87 setSubmitting(true);115 setSubmitting(true);
88 try {116 const saveResult = await Result.tryPromise(() =>
89 const result = await saveSourceWithCredentials({117 saveSourceWithCredentials({
90 context,118 context,
91 sourceToEdit,119 sourceToEdit,
92 credentialsLoading,120 credentialsLoading,
@@ -100,33 +128,80 @@ export function AddSourceDialog({ · 7 unmodified lines
100 mcpTransport: form.mcpTransport,128 mcpTransport: form.mcpTransport,
101 authType: form.authType,129 authType: form.authType,
102 authScope: form.authScope,130 authScope: form.authScope,
131 apiKeyHeader: form.apiKeyHeader,
103 existingScopedCredential: form.existingScopedCredential,132 existingScopedCredential: form.existingScopedCredential,
104 buildAuthConfig: form.buildAuthConfig,133 buildAuthConfig: form.buildAuthConfig,
105 hasCredentialInput: form.hasCredentialInput,134 hasCredentialInput: form.hasCredentialInput,
106 buildSecretJson: form.buildSecretJson,135 buildSecretJson: form.buildSecretJson,
107 },136 },
108 });137 })
109 138 );
110 onSourceAdded?.(result.source, { connected: result.connected });139 setSubmitting(false);
111 toast.success(140
112 sourceToEdit141 if (!saveResult.isOk()) {
113 ? result.connected142 toast.error(resultErrorMessage(
114 ? `Source "${form.name.trim()}" updated with credentials`143 saveResult.error,
115 : `Source "${form.name.trim()}" updated`144 sourceToEdit ? "Failed to update source" : "Failed to add source",
116 : result.connected145 ));
117 ? `Source "${form.name.trim()}" added with credentials — loading tools…`146 return;
118 : `Source "${form.name.trim()}" added — loading tools…`,147 }
119 );148
120 if (!sourceToEdit) {149 const result = saveResult.value;
121 form.reserveSourceName(form.name.trim());150 onSourceAdded?.(result.source, { connected: result.connected });
122 }151 toast.success(
123 form.setView("catalog");152 sourceToEdit
124 setOpen(false);153 ? result.connected
125 } catch (err) {154 ? `Source "${form.name.trim()}" updated with credentials`
126 toast.error(err instanceof Error ? err.message : sourceToEdit ? "Failed to update source" : "Failed to add source");155 : `Source "${form.name.trim()}" updated`
127 } finally {156 : result.connected
128 setSubmitting(false);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");
129 }201 }
202 form.handleAuthFieldChange("tokenValue", oauthResult.value.accessToken);
203 form.markMcpOAuthLinked(initiatedEndpoint);
204 toast.success("OAuth linked successfully.");
130 };205 };
131 206
132 const dialogTitle = sourceToEdit ? "Edit Tool Source" : "Add Tool Source";207 const dialogTitle = sourceToEdit ? "Edit Tool Source" : "Add Tool Source";
@@ -194,6 +269,10 @@ export function AddSourceDialog({ · 61 unmodified lines
194 specStatus: form.specStatus,269 specStatus: form.specStatus,
195 inferredSpecAuth: form.inferredSpecAuth,270 inferredSpecAuth: form.inferredSpecAuth,
196 specError: form.specError,271 specError: form.specError,
272 mcpOAuthStatus: form.mcpOAuthStatus,
273 mcpOAuthDetail: form.mcpOAuthDetail,
274 mcpOAuthAuthorizationServers: form.mcpOAuthAuthorizationServers,
275 mcpOAuthConnected: form.mcpOAuthConnected,
197 authType: form.authType,276 authType: form.authType,
198 authScope: form.authScope,277 authScope: form.authScope,
199 apiKeyHeader: form.apiKeyHeader,278 apiKeyHeader: form.apiKeyHeader,
@@ -206,6 +285,8 @@ export function AddSourceDialog({ · 6 unmodified lines
206 onAuthTypeChange={form.handleAuthTypeChange}285 onAuthTypeChange={form.handleAuthTypeChange}
207 onAuthScopeChange={form.handleAuthScopeChange}286 onAuthScopeChange={form.handleAuthScopeChange}
208 onFieldChange={form.handleAuthFieldChange}287 onFieldChange={form.handleAuthFieldChange}
288 onMcpOAuthConnect={form.type === "mcp" ? handleMcpOAuthConnect : undefined}
289 mcpOAuthBusy={mcpOAuthBusy}
209 />290 />
210 </CustomViewSection>291 </CustomViewSection>
211 )}292 )}
executor/apps/web/src/components/tools/add-source-form-utils.ts+2−0
@@ -45,6 +45,7 @@ export type AddSourceUiState = { · 44 unmodified lines
45 locallyReservedNames: string[];45 locallyReservedNames: string[];
46 authManuallyEdited: boolean;46 authManuallyEdited: boolean;
47 authRevision: number;47 authRevision: number;
48 mcpOAuthLinkedEndpoint: string | null;
48};49};
49 50
50export function createDefaultUiState(view: SourceDialogView = "catalog"): AddSourceUiState {51export function createDefaultUiState(view: SourceDialogView = "catalog"): AddSourceUiState {
@@ -56,6 +57,7 @@ export function createDefaultUiState(view: SourceDialogView = "catalog"): AddSou · 5 unmodified lines
56 locallyReservedNames: [],57 locallyReservedNames: [],
57 authManuallyEdited: false,58 authManuallyEdited: false,
58 authRevision: 0,59 authRevision: 0,
60 mcpOAuthLinkedEndpoint: null,
59 };61 };
60}62}
61 63
executor/apps/web/src/components/tools/add-source-submit.ts+50−2
@@ -36,12 +36,45 @@ type SaveFormSnapshot = { · 35 unmodified lines
36 mcpTransport: "auto" | "streamable-http" | "sse";36 mcpTransport: "auto" | "streamable-http" | "sse";
37 authType: Exclude<SourceAuthType, "mixed">;37 authType: Exclude<SourceAuthType, "mixed">;
38 authScope: CredentialScope;38 authScope: CredentialScope;
39 apiKeyHeader: string;
39 existingScopedCredential: CredentialRecord | null;40 existingScopedCredential: CredentialRecord | null;
40 buildAuthConfig: () => Record<string, unknown> | undefined;41 buildAuthConfig: () => Record<string, unknown> | undefined;
41 hasCredentialInput: () => boolean;42 hasCredentialInput: () => boolean;
42 buildSecretJson: () => { value?: Record<string, unknown>; error?: string };43 buildSecretJson: () => { value?: Record<string, unknown>; error?: string };
43};44};
44 45
46function 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
45export async function saveSourceWithCredentials({78export async function saveSourceWithCredentials({
46 context,79 context,
47 sourceToEdit,80 sourceToEdit,
@@ -57,7 +90,7 @@ export async function saveSourceWithCredentials({ · 9 unmodified lines
57 upsertToolSource: UpsertToolSourceFn;90 upsertToolSource: UpsertToolSourceFn;
58 upsertCredential: UpsertCredentialFn;91 upsertCredential: UpsertCredentialFn;
59}): Promise<{ source: ToolSourceRecord; connected: boolean }> {92}): 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"
61 ? form.buildAuthConfig()94 ? form.buildAuthConfig()
62 : undefined;95 : undefined;
63 96
@@ -81,7 +114,7 @@ export async function saveSourceWithCredentials({ · 17 unmodified lines
81 114
82 let linkedCredential = false;115 let linkedCredential = false;
83 116
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") {
85 const sourceKey = sourceKeyForSource(created);118 const sourceKey = sourceKeyForSource(created);
86 if (!sourceKey) {119 if (!sourceKey) {
87 throw new Error("Failed to resolve source key for credentials");120 throw new Error("Failed to resolve source key for credentials");
@@ -117,6 +150,21 @@ export async function saveSourceWithCredentials({ · 29 unmodified lines
117 throw new Error("Enter credentials for the selected auth type");150 throw new Error("Enter credentials for the selected auth type");
118 }151 }
119 linkedCredential = true;152 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;
120 } else {168 } else {
121 throw new Error("Enter credentials to finish setup");169 throw new Error("Enter credentials to finish setup");
122 }170 }
executor/apps/web/src/components/tools/use-add-source-form-state.ts+113−3
@@ -28,6 +28,18 @@ import { · 27 unmodified lines
28 28
29export { existingCredentialMatchesAuthType } from "./add-source-form-utils";29export { existingCredentialMatchesAuthType } from "./add-source-form-utils";
30 30
31function 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
31function patchUi(43function patchUi(
32 setUi: Dispatch<SetStateAction<AddSourceUiState>>,44 setUi: Dispatch<SetStateAction<AddSourceUiState>>,
33 patch: Partial<AddSourceUiState>,45 patch: Partial<AddSourceUiState>,
@@ -91,6 +103,16 @@ export function useAddSourceFormState({ · 57 unmodified lines
91 .sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? null;103 .sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? null;
92 }, [actorId, credentialItems, editingSourceKey, values.authScope]);104 }, [actorId, credentialItems, editingSourceKey, values.authScope]);
93 105
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
94 useEffect(() => {116 useEffect(() => {
95 if (!open) {117 if (!open) {
96 return;118 return;
@@ -129,6 +151,7 @@ export function useAddSourceFormState({ · 32 unmodified lines
129 151
130 const handleEndpointChange = (endpoint: string) => {152 const handleEndpointChange = (endpoint: string) => {
131 form.setValue("endpoint", endpoint, { shouldDirty: true, shouldTouch: true });153 form.setValue("endpoint", endpoint, { shouldDirty: true, shouldTouch: true });
154 patchUi(setUi, { mcpOAuthLinkedEndpoint: null });
132 155
133 if (values.type === "openapi") {156 if (values.type === "openapi") {
134 const inferredBaseUrl = deriveBaseUrlFromEndpoint(endpoint);157 const inferredBaseUrl = deriveBaseUrlFromEndpoint(endpoint);
@@ -179,6 +202,7 @@ export function useAddSourceFormState({ · 44 unmodified lines
179 patchUi(setUi, {202 patchUi(setUi, {
180 openApiBaseUrlOptions: inferredBaseUrl ? [inferredBaseUrl] : [],203 openApiBaseUrlOptions: inferredBaseUrl ? [inferredBaseUrl] : [],
181 authManuallyEdited: false,204 authManuallyEdited: false,
205 mcpOAuthLinkedEndpoint: null,
182 });206 });
183 if (!baseUrlManuallyEdited) {207 if (!baseUrlManuallyEdited) {
184 form.setValue("baseUrl", inferredBaseUrl, { shouldDirty: false, shouldTouch: false });208 form.setValue("baseUrl", inferredBaseUrl, { shouldDirty: false, shouldTouch: false });
@@ -189,6 +213,7 @@ export function useAddSourceFormState({ · 4 unmodified lines
189 patchUiWithAuthRevision(setUi, {213 patchUiWithAuthRevision(setUi, {
190 openApiBaseUrlOptions: [],214 openApiBaseUrlOptions: [],
191 authManuallyEdited: false,215 authManuallyEdited: false,
216 mcpOAuthLinkedEndpoint: null,
192 });217 });
193 form.setValue("authType", "none", { shouldDirty: false, shouldTouch: false });218 form.setValue("authType", "none", { shouldDirty: false, shouldTouch: false });
194 form.setValue("authScope", "workspace", { shouldDirty: false, shouldTouch: false });219 form.setValue("authScope", "workspace", { shouldDirty: false, shouldTouch: false });
@@ -203,12 +228,18 @@ export function useAddSourceFormState({ · 8 unmodified lines
203 228
204 const handleAuthTypeChange = (authType: Exclude<SourceAuthType, "mixed">) => {229 const handleAuthTypeChange = (authType: Exclude<SourceAuthType, "mixed">) => {
205 form.setValue("authType", authType, { shouldDirty: true, shouldTouch: true });230 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 });
207 };235 };
208 236
209 const handleAuthScopeChange = (authScope: CredentialScope) => {237 const handleAuthScopeChange = (authScope: CredentialScope) => {
210 form.setValue("authScope", authScope, { shouldDirty: true, shouldTouch: true });238 form.setValue("authScope", authScope, { shouldDirty: true, shouldTouch: true });
211 patchUiWithAuthRevision(setUi, { authManuallyEdited: true });239 patchUiWithAuthRevision(setUi, {
240 authManuallyEdited: true,
241 mcpOAuthLinkedEndpoint: null,
242 });
212 };243 };
213 244
214 const handleAuthFieldChange = (field: SourceAuthPanelEditableField, value: string) => {245 const handleAuthFieldChange = (field: SourceAuthPanelEditableField, value: string) => {
@@ -220,7 +251,7 @@ export function useAddSourceFormState({ · 5 unmodified lines
220 251
221 if (field === "tokenValue") {252 if (field === "tokenValue") {
222 form.setValue("tokenValue", value, { shouldDirty: true, shouldTouch: true });253 form.setValue("tokenValue", value, { shouldDirty: true, shouldTouch: true });
223 bumpAuthRevision();254 patchUiWithAuthRevision(setUi, { mcpOAuthLinkedEndpoint: null });
224 return;255 return;
225 }256 }
226 if (field === "apiKeyValue") {257 if (field === "apiKeyValue") {
@@ -319,6 +350,59 @@ export function useAddSourceFormState({ · 92 unmodified lines
319 : "Failed to fetch spec"350 : "Failed to fetch spec"
320 : "";351 : "";
321 352
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
322 useEffect(() => {406 useEffect(() => {
323 if (!inspectionEnabled || !specInspectionQuery.data) {407 if (!inspectionEnabled || !specInspectionQuery.data) {
324 return;408 return;
@@ -377,6 +461,23 @@ export function useAddSourceFormState({ · 52 unmodified lines
377 }461 }
378 }, [inspectionEnabled, inspectionEndpoint, specInspectionQuery.isError, ui.openApiBaseUrlOptions]);462 }, [inspectionEnabled, inspectionEndpoint, specInspectionQuery.isError, ui.openApiBaseUrlOptions]);
379 463
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
380 const isNameTaken = (candidate: string) => {481 const isNameTaken = (candidate: string) => {
381 const taken = [...getTakenSourceNames()].map((entry) => entry.toLowerCase());482 const taken = [...getTakenSourceNames()].map((entry) => entry.toLowerCase());
382 return taken.includes(candidate.trim().toLowerCase());483 return taken.includes(candidate.trim().toLowerCase());
@@ -407,6 +508,11 @@ export function useAddSourceFormState({ · 24 unmodified lines
407 visibleCatalogItems,508 visibleCatalogItems,
408 specStatus,509 specStatus,
409 specError,510 specError,
511 mcpOAuthStatus,
512 mcpOAuthDetail,
513 mcpOAuthAuthorizationServers,
514 mcpOAuthConnected,
515 hasPersistedMcpBearerToken,
410 inferredSpecAuth,516 inferredSpecAuth,
411 authType: values.authType,517 authType: values.authType,
412 authScope: values.authScope,518 authScope: values.authScope,
@@ -423,6 +529,10 @@ export function useAddSourceFormState({ · 10 unmodified lines
423 handleAuthTypeChange,529 handleAuthTypeChange,
424 handleAuthScopeChange,530 handleAuthScopeChange,
425 handleAuthFieldChange,531 handleAuthFieldChange,
532 markMcpOAuthLinked: (endpoint: string) =>
533 patchUi(setUi, {
534 mcpOAuthLinkedEndpoint: normalizeEndpointForOAuth(endpoint),
535 }),
426 setBaseUrl: (baseUrl: string) => form.setValue("baseUrl", baseUrl, { shouldDirty: true, shouldTouch: true }),536 setBaseUrl: (baseUrl: string) => form.setValue("baseUrl", baseUrl, { shouldDirty: true, shouldTouch: true }),
427 setMcpTransport: (mcpTransport: "auto" | "streamable-http" | "sse") =>537 setMcpTransport: (mcpTransport: "auto" | "streamable-http" | "sse") =>
428 form.setValue("mcpTransport", mcpTransport, { shouldDirty: true, shouldTouch: true }),538 form.setValue("mcpTransport", mcpTransport, { shouldDirty: true, shouldTouch: true }),
executor/apps/web/src/lib/mcp-oauth-popup.ts+104−0
@@ -0,0 +1,104 @@
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: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
27export 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 @@
1import { randomUUID } from "node:crypto";
2import { Result } from "better-result";
3import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
4import type {
5 OAuthClientInformationMixed,
6 OAuthClientMetadata,
7 OAuthTokens,
8} from "@modelcontextprotocol/sdk/shared/auth.js";
9
10export type McpOAuthPending = {
11 state: string;
12 sourceUrl: string;
13 redirectUrl: string;
14 codeVerifier?: string;
15 clientInformation?: OAuthClientInformationMixed;
16};
17
18export 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
28const COOKIE_PREFIX = "executor_mcp_oauth_";
29export const MCP_OAUTH_RESULT_COOKIE = "executor_mcp_oauth_result";
30
31export function buildPendingCookieName(state: string): string {
32 return `${COOKIE_PREFIX}${state}`;
33}
34
35export function createOAuthState(): string {
36 return randomUUID();
37}
38
39export function encodePendingCookieValue(pending: McpOAuthPending): string {
40 return Buffer.from(JSON.stringify(pending), "utf8").toString("base64url");
41}
42
43export 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
72export function encodePopupResultCookieValue(result: McpOAuthPopupResult): string {
73 return Buffer.from(JSON.stringify(result), "utf8").toString("base64url");
74}
75
76export 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
103export 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 @@
1import type { NextRequest } from "next/server";
2import { Result } from "better-result";
3
4function firstHeaderValue(value: string | null): string {
5 if (!value) {
6 return "";
7 }
8 return value.split(",")[0]?.trim() ?? "";
9}
10
11function 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
27export 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
45export 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 @@
1import { isIP } from "node:net";
2import { Result } from "better-result";
3import { parse as parseDomain } from "tldts";
4
5function isLocalHost(hostname: string): boolean {
6 return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
7}
8
9function normalizeIpHost(hostname: string): string {
10 if (hostname.startsWith("[") && hostname.endsWith("]")) {
11 return hostname.slice(1, -1);
12 }
13 return hostname;
14}
15
16function 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
33function 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
49function 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
61function isPublicDnsHostname(hostname: string): boolean {
62 const parsed = parseDomain(hostname);
63 return Boolean(parsed.domain && parsed.publicSuffix);
64}
65
66export 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
72export function sourceKeyForSource(source: ToolSourceRecord): string | null {72export function sourceKeyForSource(source: ToolSourceRecord): string | null {
73 if (source.type === "openapi") return `source:${source.id}`;73 if (source.type === "openapi") return `source:${source.id}`;
74 if (source.type === "graphql") return `source:${source.id}`;74 if (source.type === "graphql") return `source:${source.id}`;
75 if (source.type === "mcp") return `source:${source.id}`;
75 return null;76 return null;
76}77}
77 78
@@ -146,7 +147,7 @@ export function readSourceAuth( · 68 unmodified lines
146 header?: string;147 header?: string;
147 inferred?: boolean;148 inferred?: boolean;
148} {149} {
149 if (source.type !== "openapi" && source.type !== "graphql") {150 if (source.type !== "openapi" && source.type !== "graphql" && source.type !== "mcp") {
150 return { type: "none" };151 return { type: "none" };
151 }152 }
152 153
executor/packages/convex/runtime/tool_call_resolution.ts+3−1
@@ -84,7 +84,9 @@ export async function ensureWorkspaceTools( · 83 unmodified lines
84 if (workspaceTools) {84 if (workspaceTools) {
85 return workspaceTools;85 return workspaceTools;
86 }86 }
87 const result = await getWorkspaceTools(ctx, task.workspaceId);87 const result = await getWorkspaceTools(ctx, task.workspaceId, {
88 actorId: task.actorId,
89 });
88 return result.tools;90 return result.tools;
89}91}
90 92
executor/packages/convex/runtime/tool_source_loading.ts+127−4
@@ -2,20 +2,25 @@ · 1 unmodified line
2 2
3import type { ActionCtx } from "../_generated/server";3import type { ActionCtx } from "../_generated/server";
4import { internal } from "../_generated/api";4import { internal } from "../_generated/api";
5import type { Id } from "../_generated/dataModel";
5import {6import {
6 compileExternalToolSource,7 compileExternalToolSource,
7 compileOpenApiToolSourceFromPrepared,8 compileOpenApiToolSourceFromPrepared,
8 prepareOpenApiSpec,9 prepareOpenApiSpec,
9 type CompiledToolSourceArtifact,10 type CompiledToolSourceArtifact,
10} from "../../core/src/tool-sources";11} from "../../core/src/tool-sources";
12import { resolveCredentialPayload } from "../../core/src/credential-providers";
13import { buildStaticAuthHeaders } from "../../core/src/tool-source-auth";
11import type {14import type {
12 ExternalToolSourceConfig,15 ExternalToolSourceConfig,
13 GraphqlToolSourceConfig,16 GraphqlToolSourceConfig,
14 McpToolSourceConfig,17 McpToolSourceConfig,
18 OpenApiAuth,
15 OpenApiToolSourceConfig,19 OpenApiToolSourceConfig,
16 PreparedOpenApiSpec,20 PreparedOpenApiSpec,
17} from "../../core/src/tool-source-types";21} from "../../core/src/tool-source-types";
18import type { ToolSourceRecord } from "../../core/src/types";22import type { ToolSourceRecord } from "../../core/src/types";
23import { asPayload } from "../lib/object";
19 24
20const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;25const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;
21 26
@@ -69,6 +74,7 @@ export function normalizeExternalToolSource(raw: { · 47 unmodified lines
69 sourceId: raw.id,74 sourceId: raw.id,
70 sourceKey: `source:${raw.id}`,75 sourceKey: `source:${raw.id}`,
71 url: config.url,76 url: config.url,
77 auth: config.auth as McpToolSourceConfig["auth"],
72 transport: config.transport as McpToolSourceConfig["transport"],78 transport: config.transport as McpToolSourceConfig["transport"],
73 queryParams: config.queryParams as McpToolSourceConfig["queryParams"],79 queryParams: config.queryParams as McpToolSourceConfig["queryParams"],
74 defaultApproval: config.defaultApproval as McpToolSourceConfig["defaultApproval"],80 defaultApproval: config.defaultApproval as McpToolSourceConfig["defaultApproval"],
@@ -117,6 +123,103 @@ export function normalizeExternalToolSource(raw: { · 42 unmodified lines
117 return result;123 return result;
118}124}
119 125
126function 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
153async 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
120async function loadCachedOpenApiSpec(223async function loadCachedOpenApiSpec(
121 ctx: ActionCtx,224 ctx: ActionCtx,
122 specUrl: string,225 specUrl: string,
@@ -175,7 +278,7 @@ async function loadCachedOpenApiSpec( · 52 unmodified lines
175export async function loadSourceArtifact(278export async function loadSourceArtifact(
176 ctx: ActionCtx,279 ctx: ActionCtx,
177 source: ExternalToolSourceConfig,280 source: ExternalToolSourceConfig,
178 options: { includeDts?: boolean } = {},281 options: { includeDts?: boolean; workspaceId: Id<"workspaces">; actorId?: string },
179): Promise<{ artifact?: CompiledToolSourceArtifact; warnings: string[] }> {282): Promise<{ artifact?: CompiledToolSourceArtifact; warnings: string[] }> {
180 const includeDts = options.includeDts ?? true;283 const includeDts = options.includeDts ?? true;
181 284
@@ -196,14 +299,34 @@ export async function loadSourceArtifact( · 14 unmodified lines
196 }299 }
197 }300 }
198 301
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
199 try {319 try {
200 const artifact = await compileExternalToolSource(source);320 const artifact = await compileExternalToolSource(sourceForCompile);
201 return { artifact, warnings: [] };321 return { artifact, warnings: preWarnings };
202 } catch (error) {322 } catch (error) {
203 const message = error instanceof Error ? error.message : String(error);323 const message = error instanceof Error ? error.message : String(error);
204 return {324 return {
205 artifact: undefined,325 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 ],
207 };330 };
208 }331 }
209}332}
executor/packages/convex/runtime/workspace_tools.ts+33−18
@@ -54,6 +54,7 @@ interface GetWorkspaceToolsOptions { · 53 unmodified lines
54 sourceTimeoutMs?: number;54 sourceTimeoutMs?: number;
55 allowStaleOnMismatch?: boolean;55 allowStaleOnMismatch?: boolean;
56 skipCacheRead?: boolean;56 skipCacheRead?: boolean;
57 actorId?: string;
57}58}
58 59
59interface WorkspaceToolInventory {60interface WorkspaceToolInventory {
@@ -154,16 +155,25 @@ export async function getWorkspaceTools( · 94 unmodified lines
154 const includeDts = options.includeDts ?? false;155 const includeDts = options.includeDts ?? false;
155 const sourceTimeoutMs = options.sourceTimeoutMs;156 const sourceTimeoutMs = options.sourceTimeoutMs;
156 const allowStaleOnMismatch = options.allowStaleOnMismatch ?? false;157 const allowStaleOnMismatch = options.allowStaleOnMismatch ?? false;
157 const skipCacheRead = options.skipCacheRead ?? false;158 const actorId = options.actorId;
158 const sources = (await ctx.runQuery(internal.database.listToolSources, { workspaceId }))159 const sources = (await ctx.runQuery(internal.database.listToolSources, { workspaceId }))
159 .filter((source: { enabled: boolean }) => source.enabled);160 .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;
160 traceStep("listToolSources", listSourcesStartedAt);170 traceStep("listToolSources", listSourcesStartedAt);
161 const hasOpenApiSource = sources.some((source: { type: string }) => source.type === "openapi");171 const hasOpenApiSource = sources.some((source: { type: string }) => source.type === "openapi");
162 const signature = sourceSignature(workspaceId, sources);172 const signature = sourceSignature(workspaceId, sources);
163 const debugBase: Omit<WorkspaceToolsDebug, "mode" | "normalizedSourceCount" | "cacheHit" | "cacheFresh" | "timedOutSources" | "durationMs" | "trace"> = {173 const debugBase: Omit<WorkspaceToolsDebug, "mode" | "normalizedSourceCount" | "cacheHit" | "cacheFresh" | "timedOutSources" | "durationMs" | "trace"> = {
164 includeDts,174 includeDts,
165 sourceTimeoutMs: sourceTimeoutMs ?? null,175 sourceTimeoutMs: sourceTimeoutMs ?? null,
166 skipCacheRead,176 skipCacheRead,
167 sourceCount: sources.length,177 sourceCount: sources.length,
168 };178 };
169 179
@@ -251,7 +261,7 @@ export async function getWorkspaceTools( · 81 unmodified lines
251 const loadedSources = await Promise.all(configs.map(async (config) => {261 const loadedSources = await Promise.all(configs.map(async (config) => {
252 if (!sourceTimeoutMs || sourceTimeoutMs <= 0) {262 if (!sourceTimeoutMs || sourceTimeoutMs <= 0) {
253 return {263 return {
254 ...(await loadSourceArtifact(ctx, config, { includeDts })),264 ...(await loadSourceArtifact(ctx, config, { includeDts, workspaceId, actorId })),
255 timedOut: false,265 timedOut: false,
256 sourceName: config.name,266 sourceName: config.name,
257 };267 };
@@ -274,7 +284,7 @@ export async function getWorkspaceTools( · 16 unmodified lines
274 }, sourceTimeoutMs);284 }, sourceTimeoutMs);
275 });285 });
276 286
277 const loadResult = loadSourceArtifact(ctx, config, { includeDts })287 const loadResult = loadSourceArtifact(ctx, config, { includeDts, workspaceId, actorId })
278 .then((result) => ({ ...result, timedOut: false, sourceName: config.name }));288 .then((result) => ({ ...result, timedOut: false, sourceName: config.name }));
279 289
280 const result = await Promise.race([loadResult, timeoutResult]);290 const result = await Promise.race([loadResult, timeoutResult]);
@@ -354,18 +364,22 @@ export async function getWorkspaceTools( · 73 unmodified lines
354 warnings,364 warnings,
355 };365 };
356 366
357 const json = JSON.stringify(snapshot);367 if (!skipCacheWrite) {
358 const blob = new Blob([json], { type: "application/json" });368 const json = JSON.stringify(snapshot);
359 const storageId = await ctx.storage.store(blob);369 const blob = new Blob([json], { type: "application/json" });
360 await ctx.runMutation(internal.workspaceToolCache.putEntry, {370 const storageId = await ctx.storage.store(blob);
361 workspaceId,371 await ctx.runMutation(internal.workspaceToolCache.putEntry, {
362 signature,372 workspaceId,
363 storageId,373 signature,
364 dtsStorageIds: storedDts.map((e) => ({ sourceKey: e.sourceKey, storageId: e.storageId })),374 storageId,
365 toolCount: allTools.length,375 dtsStorageIds: storedDts.map((e) => ({ sourceKey: e.sourceKey, storageId: e.storageId })),
366 sizeBytes: json.length,376 toolCount: allTools.length,
367 });377 sizeBytes: json.length,
368 traceStep("snapshotWrite", snapshotWriteStartedAt);378 });
379 traceStep("snapshotWrite", snapshotWriteStartedAt);
380 } else {
381 trace.push("snapshotWrite=skipped(actor-scoped-mcp)");
382 }
369 } catch (error) {383 } catch (error) {
370 const msg = error instanceof Error ? error.message : String(error);384 const msg = error instanceof Error ? error.message : String(error);
371 console.warn(`[executor] workspace tool cache write failed for '${workspaceId}': ${msg}`);385 console.warn(`[executor] workspace tool cache write failed for '${workspaceId}': ${msg}`);
@@ -413,6 +427,7 @@ async function loadWorkspaceToolInventoryForContext( · 41 unmodified lines
413 sourceTimeoutMs,427 sourceTimeoutMs,
414 allowStaleOnMismatch,428 allowStaleOnMismatch,
415 skipCacheRead,429 skipCacheRead,
430 actorId: context.actorId,
416 }),431 }),
417 ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: context.workspaceId }),432 ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: context.workspaceId }),
418 ]);433 ]);
executor/packages/core/src/mcp-runtime.ts+38−4
@@ -2,10 +2,26 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; · 1 unmodified line
2import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";2import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
3import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";3import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4 4
5function 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
5export async function connectMcp(20export async function connectMcp(
6 url: string,21 url: string,
7 queryParams: Record<string, string> | undefined,22 queryParams: Record<string, string> | undefined,
8 preferredTransport?: "sse" | "streamable-http",23 preferredTransport?: "sse" | "streamable-http",
24 headers?: Record<string, string>,
9): Promise<{ client: Client; close: () => Promise<void> }> {25): Promise<{ client: Client; close: () => Promise<void> }> {
10 const endpoint = new URL(url);26 const endpoint = new URL(url);
11 if (queryParams) {27 if (queryParams) {
@@ -20,20 +36,38 @@ export async function connectMcp( · 8 unmodified lines
20 );36 );
21 37
22 if (preferredTransport === "sse") {38 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 }));
24 return { client, close: () => client.close() };47 return { client, close: () => client.close() };
25 }48 }
26 49
27 if (preferredTransport === "streamable-http") {50 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]);
29 return { client, close: () => client.close() };54 return { client, close: () => client.close() };
30 }55 }
31 56
32 try {57 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]);
34 return { client, close: () => client.close() };61 return { client, close: () => client.close() };
35 } catch {62 } 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 }));
37 return { client, close: () => client.close() };71 return { client, close: () => client.close() };
38 }72 }
39}73}
executor/packages/core/src/tool-source-loaders/mcp-loader.ts+23−6
@@ -2,6 +2,7 @@ · 1 unmodified line
2 2
3import { connectMcp, extractMcpResult } from "../mcp-runtime";3import { connectMcp, extractMcpResult } from "../mcp-runtime";
4import { jsonSchemaTypeHintFallback } from "../openapi-schema-hints";4import { jsonSchemaTypeHintFallback } from "../openapi-schema-hints";
5import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool-source-auth";
5import { callMcpToolWithReconnect } from "../tool-source-execution";6import { callMcpToolWithReconnect } from "../tool-source-execution";
6import { sanitizeSegment } from "../tool-path-utils";7import { sanitizeSegment } from "../tool-path-utils";
7import type { McpToolSourceConfig } from "../tool-source-types";8import type { McpToolSourceConfig } from "../tool-source-types";
@@ -15,11 +16,25 @@ export async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDef · 7 unmodified lines
15 ? Object.fromEntries(16 ? Object.fromEntries(
16 Object.entries(config.queryParams).map(([key, value]) => [key, String(value)]),17 Object.entries(config.queryParams).map(([key, value]) => [key, String(value)]),
17 )18 )
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);
19 26
20 let connection = await connectMcp(config.url, queryParams, config.transport);27 let connection = await connectMcp(config.url, queryParams, config.transport, discoveryHeaders);
21 28
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 };
23 return await callMcpToolWithReconnect(38 return await callMcpToolWithReconnect(
24 () => connection.client.callTool({ name, arguments: input }),39 () => connection.client.callTool({ name, arguments: input }),
25 async () => {40 async () => {
@@ -29,7 +44,7 @@ export async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDef · 3 unmodified lines
29 // ignore44 // ignore
30 }45 }
31 46
32 connection = await connectMcp(config.url, queryParams, config.transport);47 connection = await connectMcp(config.url, queryParams, config.transport, mergedHeaders);
33 return await connection.client.callTool({ name, arguments: input });48 return await connection.client.callTool({ name, arguments: input });
34 },49 },
35 );50 );
@@ -61,16 +76,18 @@ export async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDef · 25 unmodified lines
61 displayReturnsType: compactReturnTypeHint(returnsType),76 displayReturnsType: compactReturnTypeHint(returnsType),
62 ...(argPreviewKeys.length > 0 ? { argPreviewKeys } : {}),77 ...(argPreviewKeys.length > 0 ? { argPreviewKeys } : {}),
63 },78 },
79 credential: credentialSpec,
64 _runSpec: {80 _runSpec: {
65 kind: "mcp" as const,81 kind: "mcp" as const,
66 url: config.url,82 url: config.url,
67 transport: config.transport,83 transport: config.transport,
68 queryParams: config.queryParams,84 queryParams: config.queryParams,
85 authHeaders,
69 toolName,86 toolName,
70 },87 },
71 run: async (input: unknown) => {88 run: async (input: unknown, context) => {
72 const payload = asRecord(input);89 const payload = asRecord(input);
73 const result = await callToolWithReconnect(toolName, payload);90 const result = await callToolWithReconnect(toolName, payload, context.credential?.headers);
74 return extractMcpResult(result);91 return extractMcpResult(result);
75 },92 },
76 } satisfies ToolDefinition & { _runSpec: SerializedTool["runSpec"] };93 } 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
28 url: string;28 url: string;
29 transport?: "sse" | "streamable-http";29 transport?: "sse" | "streamable-http";
30 queryParams?: Record<string, string>;30 queryParams?: Record<string, string>;
31 authHeaders: Record<string, string>;
31 toolName: string;32 toolName: string;
32 }33 }
33 | PostmanSerializedRunSpec34 | PostmanSerializedRunSpec
@@ -57,8 +58,16 @@ function resolveSerializedRunSpec(tool: ToolDefinition): SerializedTool["runSpec · 23 unmodified lines
57 return runSpec ?? { kind: "builtin" };58 return runSpec ?? { kind: "builtin" };
58}59}
59 60
60function buildMcpConnectionKey(url: string, transport?: "sse" | "streamable-http"): string {61function buildMcpConnectionKey(
61 return `${url}|${transport ?? ""}`;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}`;
62}71}
63 72
64function getOrCreateMcpConnection(73function getOrCreateMcpConnection(
@@ -137,14 +146,19 @@ export function rehydrateTools( · 72 unmodified lines
137 146
138 if (st.runSpec.kind === "mcp") {147 if (st.runSpec.kind === "mcp") {
139 const { url, transport, queryParams, toolName } = st.runSpec;148 const { url, transport, queryParams, toolName } = st.runSpec;
149 const authHeaders = st.runSpec.authHeaders ?? {};
140 return {150 return {
141 ...base,151 ...base,
142 run: async (input: unknown) => {152 run: async (input: unknown, context) => {
143 const connKey = buildMcpConnectionKey(url, transport);153 const mergedHeaders = {
154 ...authHeaders,
155 ...(context.credential?.headers ?? {}),
156 };
157 const connKey = buildMcpConnectionKey(url, transport, mergedHeaders);
144 let conn = await getOrCreateMcpConnection(158 let conn = await getOrCreateMcpConnection(
145 mcpConnections,159 mcpConnections,
146 connKey,160 connKey,
147 () => connectMcp(url, queryParams, transport),161 () => connectMcp(url, queryParams, transport, mergedHeaders),
148 );162 );
149 163
150 const payload = asRecord(input);164 const payload = asRecord(input);
@@ -156,7 +170,7 @@ export function rehydrateTools( · 5 unmodified lines
156 } catch {170 } catch {
157 // ignore171 // ignore
158 }172 }
159 const newConnPromise = connectMcp(url, queryParams, transport);173 const newConnPromise = connectMcp(url, queryParams, transport, mergedHeaders);
160 mcpConnections.set(connKey, { promise: newConnPromise });174 mcpConnections.set(connKey, { promise: newConnPromise });
161 conn = await newConnPromise;175 conn = await newConnPromise;
162 return await conn.client.callTool({ name: toolName, arguments: payload });176 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
6 sourceId?: string;6 sourceId?: string;
7 sourceKey?: string;7 sourceKey?: string;
8 url: string;8 url: string;
9 auth?: OpenApiAuth;
10 discoveryHeaders?: Record<string, string>;
9 transport?: "sse" | "streamable-http";11 transport?: "sse" | "streamable-http";
10 queryParams?: Record<string, string>;12 queryParams?: Record<string, string>;
11 defaultApproval?: ToolApprovalMode;13 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
256 kind: "mcp" as const,256 kind: "mcp" as const,
257 url: "https://mcp.example.com/sse",257 url: "https://mcp.example.com/sse",
258 transport: "sse" as const,258 transport: "sse" as const,
259 authHeaders: {},
259 toolName: "some_tool",260 toolName: "some_tool",
260 },261 },
261 run: async () => "mock",262 run: async () => "mock",