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
2import { NextResponse } from "next/server";2import { NextResponse } from "next/server";
3import { Result } from "better-result";3import { Result } from "better-result";
4import { auth } from "@modelcontextprotocol/sdk/client/auth.js";4import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
5import { getExternalOrigin, isExternalHttps } from "@/lib/mcp-oauth-request";
6import { parseMcpSourceUrl } from "@/lib/mcp-oauth-url";
5import {7import {
6 buildPendingCookieName,8 buildPendingCookieName,
7 decodePendingCookieValue,9 decodePendingCookieValue,
@@ -16,15 +18,13 @@ function popupResultRedirect( · 8 unmodified lines
16 pendingCookieName: string | null,18 pendingCookieName: string | null,
17 payload: McpOAuthPopupResult,19 payload: McpOAuthPopupResult,
18): NextResponse {20): NextResponse {
19 const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host");21 const origin = getExternalOrigin(request);
20 const proto = request.headers.get("x-forwarded-proto") ?? request.nextUrl.protocol.replace(":", "");
21 const origin = host && proto ? `${proto}://${host}` : request.nextUrl.origin;
22 const response = NextResponse.redirect(`${origin}/mcp/oauth/complete`);22 const response = NextResponse.redirect(`${origin}/mcp/oauth/complete`);
23 response.cookies.set({23 response.cookies.set({
24 name: MCP_OAUTH_RESULT_COOKIE,24 name: MCP_OAUTH_RESULT_COOKIE,
25 value: encodePopupResultCookieValue(payload),25 value: encodePopupResultCookieValue(payload),
26 httpOnly: true,26 httpOnly: true,
27 secure: request.nextUrl.protocol === "https:",27 secure: isExternalHttps(request),
28 sameSite: "lax",28 sameSite: "lax",
29 maxAge: 2 * 60,29 maxAge: 2 * 60,
30 path: "/",30 path: "/",
@@ -79,7 +79,7 @@ export async function GET(request: NextRequest) { · 48 unmodified lines
79 });79 });
80 }80 }
81 81
82 const sourceUrlResult = Result.try(() => new URL(pending.sourceUrl));82 const sourceUrlResult = parseMcpSourceUrl(pending.sourceUrl);
83 if (!sourceUrlResult.isOk()) {83 if (!sourceUrlResult.isOk()) {
84 return popupResultRedirect(request, cookieName, { ok: false, error: "Invalid MCP source URL" });84 return popupResultRedirect(request, cookieName, { ok: false, error: "Invalid MCP source URL" });
85 }85 }
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
2import { NextResponse } from "next/server";2import { NextResponse } from "next/server";
3import { Result } from "better-result";3import { Result } from "better-result";
4import { discoverOAuthProtectedResourceMetadata } from "@modelcontextprotocol/sdk/client/auth.js";4import { discoverOAuthProtectedResourceMetadata } from "@modelcontextprotocol/sdk/client/auth.js";
5import { parseMcpSourceUrl } from "@/lib/mcp-oauth-url";
5 6
6type DetectResponse = {7type DetectResponse = {
7 oauth: boolean;8 oauth: boolean;
@@ -37,9 +38,16 @@ export async function GET(request: NextRequest) { · 29 unmodified lines
37 return noStoreJson({ oauth: false, authorizationServers: [], detail: "Missing sourceUrl" }, 400);38 return noStoreJson({ oauth: false, authorizationServers: [], detail: "Missing sourceUrl" }, 400);
38 }39 }
39 40
40 const sourceUrlResult = Result.try(() => new URL(sourceUrlRaw));41 const sourceUrlResult = parseMcpSourceUrl(sourceUrlRaw);
41 if (!sourceUrlResult.isOk()) {42 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 );
43 }51 }
44 const sourceUrl = sourceUrlResult.value;52 const sourceUrl = sourceUrlResult.value;
45 53
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
2import { NextResponse } from "next/server";2import { NextResponse } from "next/server";
3import { Result } from "better-result";3import { Result } from "better-result";
4import { auth } from "@modelcontextprotocol/sdk/client/auth.js";4import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
5import { getExternalOrigin, isExternalHttps } from "@/lib/mcp-oauth-request";
6import { parseMcpSourceUrl } from "@/lib/mcp-oauth-url";
5import {7import {
6 buildPendingCookieName,8 buildPendingCookieName,
7 createOAuthState,9 createOAuthState,
@@ -12,15 +14,6 @@ import { · 4 unmodified lines
12 type McpOAuthPopupResult,14 type McpOAuthPopupResult,
13} from "@/lib/mcp-oauth-provider";15} from "@/lib/mcp-oauth-provider";
14 16
15function 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
24function popupResultRedirect(request: NextRequest, payload: McpOAuthPopupResult): NextResponse {17function popupResultRedirect(request: NextRequest, payload: McpOAuthPopupResult): NextResponse {
25 const externalOrigin = getExternalOrigin(request);18 const externalOrigin = getExternalOrigin(request);
26 const response = NextResponse.redirect(`${externalOrigin}/mcp/oauth/complete`);19 const response = NextResponse.redirect(`${externalOrigin}/mcp/oauth/complete`);
@@ -28,7 +21,7 @@ function popupResultRedirect(request: NextRequest, payload: McpOAuthPopupResult) · 1 unmodified line
28 name: MCP_OAUTH_RESULT_COOKIE,21 name: MCP_OAUTH_RESULT_COOKIE,
29 value: encodePopupResultCookieValue(payload),22 value: encodePopupResultCookieValue(payload),
30 httpOnly: true,23 httpOnly: true,
31 secure: request.nextUrl.protocol === "https:",24 secure: isExternalHttps(request),
32 sameSite: "lax",25 sameSite: "lax",
33 maxAge: 2 * 60,26 maxAge: 2 * 60,
34 path: "/",27 path: "/",
@@ -59,9 +52,9 @@ export async function GET(request: NextRequest) { · 24 unmodified lines
59 return badPopupResponse(request, "Missing sourceUrl");52 return badPopupResponse(request, "Missing sourceUrl");
60 }53 }
61 54
62 const sourceUrlResult = Result.try(() => new URL(sourceUrlRaw));55 const sourceUrlResult = parseMcpSourceUrl(sourceUrlRaw);
63 if (!sourceUrlResult.isOk()) {56 if (!sourceUrlResult.isOk()) {
64 return badPopupResponse(request, "Invalid sourceUrl");57 return badPopupResponse(request, resultErrorMessage(sourceUrlResult.error, "Invalid sourceUrl"));
65 }58 }
66 const sourceUrl = sourceUrlResult.value;59 const sourceUrl = sourceUrlResult.value;
67 60
@@ -104,7 +97,7 @@ export async function GET(request: NextRequest) { · 36 unmodified lines
104 name: buildPendingCookieName(state),97 name: buildPendingCookieName(state),
105 value: pendingCookie,98 value: pendingCookie,
106 httpOnly: true,99 httpOnly: true,
107 secure: request.nextUrl.protocol === "https:",100 secure: isExternalHttps(request),
108 sameSite: "lax",101 sameSite: "lax",
109 maxAge: 10 * 60,102 maxAge: 10 * 60,
110 path: "/",103 path: "/",
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/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+126−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
@@ -118,6 +123,103 @@ export function normalizeExternalToolSource(raw: { · 96 unmodified lines
118 return result;123 return result;
119}124}
120 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
121async function loadCachedOpenApiSpec(223async function loadCachedOpenApiSpec(
122 ctx: ActionCtx,224 ctx: ActionCtx,
123 specUrl: string,225 specUrl: string,
@@ -176,7 +278,7 @@ async function loadCachedOpenApiSpec( · 52 unmodified lines
176export async function loadSourceArtifact(278export async function loadSourceArtifact(
177 ctx: ActionCtx,279 ctx: ActionCtx,
178 source: ExternalToolSourceConfig,280 source: ExternalToolSourceConfig,
179 options: { includeDts?: boolean } = {},281 options: { includeDts?: boolean; workspaceId: Id<"workspaces">; actorId?: string },
180): Promise<{ artifact?: CompiledToolSourceArtifact; warnings: string[] }> {282): Promise<{ artifact?: CompiledToolSourceArtifact; warnings: string[] }> {
181 const includeDts = options.includeDts ?? true;283 const includeDts = options.includeDts ?? true;
182 284
@@ -197,14 +299,34 @@ export async function loadSourceArtifact( · 14 unmodified lines
197 }299 }
198 }300 }
199 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
200 try {319 try {
201 const artifact = await compileExternalToolSource(source);320 const artifact = await compileExternalToolSource(sourceForCompile);
202 return { artifact, warnings: [] };321 return { artifact, warnings: preWarnings };
203 } catch (error) {322 } catch (error) {
204 const message = error instanceof Error ? error.message : String(error);323 const message = error instanceof Error ? error.message : String(error);
205 return {324 return {
206 artifact: undefined,325 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 ],
208 };330 };
209 }331 }
210}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/tool-source-loaders/mcp-loader.ts+6−2
@@ -18,9 +18,13 @@ export async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDef · 17 unmodified lines
18 )18 )
19 : undefined;19 : undefined;
20 const authHeaders = buildStaticAuthHeaders(config.auth);20 const authHeaders = buildStaticAuthHeaders(config.auth);
21 const discoveryHeaders = {
22 ...authHeaders,
23 ...(config.discoveryHeaders ?? {}),
24 };
21 const credentialSpec = buildCredentialSpec(getCredentialSourceKey(config), config.auth);25 const credentialSpec = buildCredentialSpec(getCredentialSourceKey(config), config.auth);
22 26
23 let connection = await connectMcp(config.url, queryParams, config.transport, authHeaders);27 let connection = await connectMcp(config.url, queryParams, config.transport, discoveryHeaders);
24 28
25 async function callToolWithReconnect(29 async function callToolWithReconnect(
26 name: string,30 name: string,
@@ -28,7 +32,7 @@ export async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDef · 1 unmodified line
28 credentialHeaders?: Record<string, string>,32 credentialHeaders?: Record<string, string>,
29 ): Promise<unknown> {33 ): Promise<unknown> {
30 const mergedHeaders = {34 const mergedHeaders = {
31 ...authHeaders,35 ...discoveryHeaders,
32 ...(credentialHeaders ?? {}),36 ...(credentialHeaders ?? {}),
33 };37 };
34 return await callMcpToolWithReconnect(38 return await callMcpToolWithReconnect(
executor/packages/core/src/tool-source-types.ts+1−0
@@ -7,6 +7,7 @@ export interface McpToolSourceConfig { · 6 unmodified lines
7 sourceKey?: string;7 sourceKey?: string;
8 url: string;8 url: string;
9 auth?: OpenApiAuth;9 auth?: OpenApiAuth;
10 discoveryHeaders?: Record<string, string>;
10 transport?: "sse" | "streamable-http";11 transport?: "sse" | "streamable-http";
11 queryParams?: Record<string, string>;12 queryParams?: Record<string, string>;
12 defaultApproval?: ToolApprovalMode;13 defaultApproval?: ToolApprovalMode;