History

Harden MCP OAuth routes and credentialed discovery

Rhys Sullivan committed 8 months ago

Commit 85b0dcc1 parent 3db0cc610 files · +339 −45Browse files

Unified · Split

10 files
  1. executor/apps/web/src/app/mcp/oauth/callback/route.ts +5 −5
  2. executor/apps/web/src/app/mcp/oauth/detect/route.ts +10 −2
  3. executor/apps/web/src/app/mcp/oauth/start/route.ts +6 −13
  4. executor/apps/web/src/lib/mcp-oauth-request.ts +48 −0
  5. executor/apps/web/src/lib/mcp-oauth-url.ts +101 −0
  6. executor/packages/convex/runtime/tool_call_resolution.ts +3 −1
  7. executor/packages/convex/runtime/tool_source_loading.ts +126 −4
  8. executor/packages/convex/runtime/workspace_tools.ts +33 −18
  9. executor/packages/core/src/tool-source-loaders/mcp-loader.ts +6 −2
  10. executor/packages/core/src/tool-source-types.ts +1 −0
executor/apps/web/src/app/mcp/oauth/callback/route.ts+5−5
@@ -2,6 +2,8 @@ import type { NextRequest } from "next/server"; · 1 unmodified line
22 import { NextResponse } from "next/server";
33 import { Result } from "better-result";
44 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";
57 import {
68 buildPendingCookieName,
79 decodePendingCookieValue,
@@ -16,15 +18,13 @@ function popupResultRedirect( · 8 unmodified lines
1618 pendingCookieName: string | null,
1719 payload: McpOAuthPopupResult,
1820 ): NextResponse {
19− const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
20− const proto = request.headers.get("x-forwarded-proto") ?? request.nextUrl.protocol.replace(":", "");
21− const origin = host && proto ? `${proto}://${host}` : request.nextUrl.origin;
21+ const origin = getExternalOrigin(request);
2222 const response = NextResponse.redirect(`${origin}/mcp/oauth/complete`);
2323 response.cookies.set({
2424 name: MCP_OAUTH_RESULT_COOKIE,
2525 value: encodePopupResultCookieValue(payload),
2626 httpOnly: true,
27− secure: request.nextUrl.protocol === "https:",
27+ secure: isExternalHttps(request),
2828 sameSite: "lax",
2929 maxAge: 2 * 60,
3030 path: "/",
@@ -79,7 +79,7 @@ export async function GET(request: NextRequest) { · 48 unmodified lines
7979 });
8080 }
8181
82− const sourceUrlResult = Result.try(() => new URL(pending.sourceUrl));
82+ const sourceUrlResult = parseMcpSourceUrl(pending.sourceUrl);
8383 if (!sourceUrlResult.isOk()) {
8484 return popupResultRedirect(request, cookieName, { ok: false, error: "Invalid MCP source URL" });
8585 }
executor/apps/web/src/app/mcp/oauth/detect/route.ts+10−2
@@ -2,6 +2,7 @@ import type { NextRequest } from "next/server"; · 1 unmodified line
22 import { NextResponse } from "next/server";
33 import { Result } from "better-result";
44 import { discoverOAuthProtectedResourceMetadata } from "@modelcontextprotocol/sdk/client/auth.js";
5+import { parseMcpSourceUrl } from "@/lib/mcp-oauth-url";
56
67 type DetectResponse = {
78 oauth: boolean;
@@ -37,9 +38,16 @@ export async function GET(request: NextRequest) { · 29 unmodified lines
3738 return noStoreJson({ oauth: false, authorizationServers: [], detail: "Missing sourceUrl" }, 400);
3839 }
3940
40− const sourceUrlResult = Result.try(() => new URL(sourceUrlRaw));
41+ const sourceUrlResult = parseMcpSourceUrl(sourceUrlRaw);
4142 if (!sourceUrlResult.isOk()) {
42− return noStoreJson({ oauth: false, authorizationServers: [], detail: "Invalid sourceUrl" }, 400);
43+ return noStoreJson(
44+ {
45+ oauth: false,
46+ authorizationServers: [],
47+ detail: resultErrorMessage(sourceUrlResult.error, "Invalid sourceUrl"),
48+ },
49+ 400,
50+ );
4351 }
4452 const sourceUrl = sourceUrlResult.value;
4553
executor/apps/web/src/app/mcp/oauth/start/route.ts+6−13
@@ -2,6 +2,8 @@ import type { NextRequest } from "next/server"; · 1 unmodified line
22 import { NextResponse } from "next/server";
33 import { Result } from "better-result";
44 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";
57 import {
68 buildPendingCookieName,
79 createOAuthState,
@@ -12,15 +14,6 @@ import { · 4 unmodified lines
1214 type McpOAuthPopupResult,
1315 } from "@/lib/mcp-oauth-provider";
1416
15−function getExternalOrigin(request: NextRequest): string {
16− const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
17− const proto = request.headers.get("x-forwarded-proto") ?? request.nextUrl.protocol.replace(":", "");
18− if (host && proto) {
19− return `${proto}://${host}`;
20− }
21− return request.nextUrl.origin;
22−}
23−
2417 function popupResultRedirect(request: NextRequest, payload: McpOAuthPopupResult): NextResponse {
2518 const externalOrigin = getExternalOrigin(request);
2619 const response = NextResponse.redirect(`${externalOrigin}/mcp/oauth/complete`);
@@ -28,7 +21,7 @@ function popupResultRedirect(request: NextRequest, payload: McpOAuthPopupResult) · 1 unmodified line
2821 name: MCP_OAUTH_RESULT_COOKIE,
2922 value: encodePopupResultCookieValue(payload),
3023 httpOnly: true,
31− secure: request.nextUrl.protocol === "https:",
24+ secure: isExternalHttps(request),
3225 sameSite: "lax",
3326 maxAge: 2 * 60,
3427 path: "/",
@@ -59,9 +52,9 @@ export async function GET(request: NextRequest) { · 24 unmodified lines
5952 return badPopupResponse(request, "Missing sourceUrl");
6053 }
6154
62− const sourceUrlResult = Result.try(() => new URL(sourceUrlRaw));
55+ const sourceUrlResult = parseMcpSourceUrl(sourceUrlRaw);
6356 if (!sourceUrlResult.isOk()) {
64− return badPopupResponse(request, "Invalid sourceUrl");
57+ return badPopupResponse(request, resultErrorMessage(sourceUrlResult.error, "Invalid sourceUrl"));
6558 }
6659 const sourceUrl = sourceUrlResult.value;
6760
@@ -104,7 +97,7 @@ export async function GET(request: NextRequest) { · 36 unmodified lines
10497 name: buildPendingCookieName(state),
10598 value: pendingCookie,
10699 httpOnly: true,
107− secure: request.nextUrl.protocol === "https:",
100+ secure: isExternalHttps(request),
108101 sameSite: "lax",
109102 maxAge: 10 * 60,
110103 path: "/",
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/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+126−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
@@ -118,6 +123,103 @@ export function normalizeExternalToolSource(raw: { · 96 unmodified lines
118123 return result;
119124 }
120125
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+
121223 async function loadCachedOpenApiSpec(
122224 ctx: ActionCtx,
123225 specUrl: string,
@@ -176,7 +278,7 @@ async function loadCachedOpenApiSpec( · 52 unmodified lines
176278 export async function loadSourceArtifact(
177279 ctx: ActionCtx,
178280 source: ExternalToolSourceConfig,
179− options: { includeDts?: boolean } = {},
281+ options: { includeDts?: boolean; workspaceId: Id<"workspaces">; actorId?: string },
180282 ): Promise<{ artifact?: CompiledToolSourceArtifact; warnings: string[] }> {
181283 const includeDts = options.includeDts ?? true;
182284
@@ -197,14 +299,34 @@ export async function loadSourceArtifact( · 14 unmodified lines
197299 }
198300 }
199301
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+
200319 try {
201− const artifact = await compileExternalToolSource(source);
202− return { artifact, warnings: [] };
320+ const artifact = await compileExternalToolSource(sourceForCompile);
321+ return { artifact, warnings: preWarnings };
203322 } catch (error) {
204323 const message = error instanceof Error ? error.message : String(error);
205324 return {
206325 artifact: undefined,
207− 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+ ],
208330 };
209331 }
210332 }
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/tool-source-loaders/mcp-loader.ts+6−2
@@ -18,9 +18,13 @@ export async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDef · 17 unmodified lines
1818 )
1919 : undefined;
2020 const authHeaders = buildStaticAuthHeaders(config.auth);
21+ const discoveryHeaders = {
22+ ...authHeaders,
23+ ...(config.discoveryHeaders ?? {}),
24+ };
2125 const credentialSpec = buildCredentialSpec(getCredentialSourceKey(config), config.auth);
2226
23− let connection = await connectMcp(config.url, queryParams, config.transport, authHeaders);
27+ let connection = await connectMcp(config.url, queryParams, config.transport, discoveryHeaders);
2428
2529 async function callToolWithReconnect(
2630 name: string,
@@ -28,7 +32,7 @@ export async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDef · 1 unmodified line
2832 credentialHeaders?: Record<string, string>,
2933 ): Promise<unknown> {
3034 const mergedHeaders = {
31− ...authHeaders,
35+ ...discoveryHeaders,
3236 ...(credentialHeaders ?? {}),
3337 };
3438 return await callMcpToolWithReconnect(
executor/packages/core/src/tool-source-types.ts+1−0
@@ -7,6 +7,7 @@ export interface McpToolSourceConfig { · 6 unmodified lines
77 sourceKey?: string;
88 url: string;
99 auth?: OpenApiAuth;
10+ discoveryHeaders?: Record<string, string>;
1011 transport?: "sse" | "streamable-http";
1112 queryParams?: Record<string, string>;
1213 defaultApproval?: ToolApprovalMode;