Unified · Split
| @@ -2,6 +2,8 @@ import type { NextRequest } from "next/server"; · 1 unmodified line | ||
| 2 | 2 | import { NextResponse } from "next/server"; |
| 3 | 3 | import { Result } from "better-result"; |
| 4 | 4 | import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; |
| 5 | +import { getExternalOrigin, isExternalHttps } from "@/lib/mcp-oauth-request"; | |
| 6 | +import { parseMcpSourceUrl } from "@/lib/mcp-oauth-url"; | |
| 5 | 7 | import { |
| 6 | 8 | buildPendingCookieName, |
| 7 | 9 | decodePendingCookieValue, |
| @@ -16,15 +18,13 @@ function popupResultRedirect( · 8 unmodified lines | ||
| 16 | 18 | pendingCookieName: string | null, |
| 17 | 19 | payload: McpOAuthPopupResult, |
| 18 | 20 | ): 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); | |
| 22 | 22 | const response = NextResponse.redirect(`${origin}/mcp/oauth/complete`); |
| 23 | 23 | response.cookies.set({ |
| 24 | 24 | name: MCP_OAUTH_RESULT_COOKIE, |
| 25 | 25 | value: encodePopupResultCookieValue(payload), |
| 26 | 26 | httpOnly: true, |
| 27 | − secure: request.nextUrl.protocol === "https:", | |
| 27 | + secure: isExternalHttps(request), | |
| 28 | 28 | sameSite: "lax", |
| 29 | 29 | maxAge: 2 * 60, |
| 30 | 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 | 83 | if (!sourceUrlResult.isOk()) { |
| 84 | 84 | return popupResultRedirect(request, cookieName, { ok: false, error: "Invalid MCP source URL" }); |
| 85 | 85 | } |
| @@ -2,6 +2,7 @@ import type { NextRequest } from "next/server"; · 1 unmodified line | ||
| 2 | 2 | import { NextResponse } from "next/server"; |
| 3 | 3 | import { Result } from "better-result"; |
| 4 | 4 | import { discoverOAuthProtectedResourceMetadata } from "@modelcontextprotocol/sdk/client/auth.js"; |
| 5 | +import { parseMcpSourceUrl } from "@/lib/mcp-oauth-url"; | |
| 5 | 6 | |
| 6 | 7 | type DetectResponse = { |
| 7 | 8 | oauth: boolean; |
| @@ -37,9 +38,16 @@ export async function GET(request: NextRequest) { · 29 unmodified lines | ||
| 37 | 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 | 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 | 52 | const sourceUrl = sourceUrlResult.value; |
| 45 | 53 | |
| @@ -2,6 +2,8 @@ import type { NextRequest } from "next/server"; · 1 unmodified line | ||
| 2 | 2 | import { NextResponse } from "next/server"; |
| 3 | 3 | import { Result } from "better-result"; |
| 4 | 4 | import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; |
| 5 | +import { getExternalOrigin, isExternalHttps } from "@/lib/mcp-oauth-request"; | |
| 6 | +import { parseMcpSourceUrl } from "@/lib/mcp-oauth-url"; | |
| 5 | 7 | import { |
| 6 | 8 | buildPendingCookieName, |
| 7 | 9 | createOAuthState, |
| @@ -12,15 +14,6 @@ import { · 4 unmodified lines | ||
| 12 | 14 | type McpOAuthPopupResult, |
| 13 | 15 | } from "@/lib/mcp-oauth-provider"; |
| 14 | 16 | |
| 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 | − | |
| 24 | 17 | function popupResultRedirect(request: NextRequest, payload: McpOAuthPopupResult): NextResponse { |
| 25 | 18 | const externalOrigin = getExternalOrigin(request); |
| 26 | 19 | const response = NextResponse.redirect(`${externalOrigin}/mcp/oauth/complete`); |
| @@ -28,7 +21,7 @@ function popupResultRedirect(request: NextRequest, payload: McpOAuthPopupResult) · 1 unmodified line | ||
| 28 | 21 | name: MCP_OAUTH_RESULT_COOKIE, |
| 29 | 22 | value: encodePopupResultCookieValue(payload), |
| 30 | 23 | httpOnly: true, |
| 31 | − secure: request.nextUrl.protocol === "https:", | |
| 24 | + secure: isExternalHttps(request), | |
| 32 | 25 | sameSite: "lax", |
| 33 | 26 | maxAge: 2 * 60, |
| 34 | 27 | path: "/", |
| @@ -59,9 +52,9 @@ export async function GET(request: NextRequest) { · 24 unmodified lines | ||
| 59 | 52 | return badPopupResponse(request, "Missing sourceUrl"); |
| 60 | 53 | } |
| 61 | 54 | |
| 62 | − const sourceUrlResult = Result.try(() => new URL(sourceUrlRaw)); | |
| 55 | + const sourceUrlResult = parseMcpSourceUrl(sourceUrlRaw); | |
| 63 | 56 | if (!sourceUrlResult.isOk()) { |
| 64 | − return badPopupResponse(request, "Invalid sourceUrl"); | |
| 57 | + return badPopupResponse(request, resultErrorMessage(sourceUrlResult.error, "Invalid sourceUrl")); | |
| 65 | 58 | } |
| 66 | 59 | const sourceUrl = sourceUrlResult.value; |
| 67 | 60 | |
| @@ -104,7 +97,7 @@ export async function GET(request: NextRequest) { · 36 unmodified lines | ||
| 104 | 97 | name: buildPendingCookieName(state), |
| 105 | 98 | value: pendingCookie, |
| 106 | 99 | httpOnly: true, |
| 107 | − secure: request.nextUrl.protocol === "https:", | |
| 100 | + secure: isExternalHttps(request), | |
| 108 | 101 | sameSite: "lax", |
| 109 | 102 | maxAge: 10 * 60, |
| 110 | 103 | path: "/", |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -84,7 +84,9 @@ export async function ensureWorkspaceTools( · 83 unmodified lines | ||
| 84 | 84 | if (workspaceTools) { |
| 85 | 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 | 90 | return result.tools; |
| 89 | 91 | } |
| 90 | 92 | |
| @@ -2,20 +2,25 @@ · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import type { ActionCtx } from "../_generated/server"; |
| 4 | 4 | import { internal } from "../_generated/api"; |
| 5 | +import type { Id } from "../_generated/dataModel"; | |
| 5 | 6 | import { |
| 6 | 7 | compileExternalToolSource, |
| 7 | 8 | compileOpenApiToolSourceFromPrepared, |
| 8 | 9 | prepareOpenApiSpec, |
| 9 | 10 | type CompiledToolSourceArtifact, |
| 10 | 11 | } from "../../core/src/tool-sources"; |
| 12 | +import { resolveCredentialPayload } from "../../core/src/credential-providers"; | |
| 13 | +import { buildStaticAuthHeaders } from "../../core/src/tool-source-auth"; | |
| 11 | 14 | import type { |
| 12 | 15 | ExternalToolSourceConfig, |
| 13 | 16 | GraphqlToolSourceConfig, |
| 14 | 17 | McpToolSourceConfig, |
| 18 | + OpenApiAuth, | |
| 15 | 19 | OpenApiToolSourceConfig, |
| 16 | 20 | PreparedOpenApiSpec, |
| 17 | 21 | } from "../../core/src/tool-source-types"; |
| 18 | 22 | import type { ToolSourceRecord } from "../../core/src/types"; |
| 23 | +import { asPayload } from "../lib/object"; | |
| 19 | 24 | |
| 20 | 25 | const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000; |
| 21 | 26 | |
| @@ -118,6 +123,103 @@ export function normalizeExternalToolSource(raw: { · 96 unmodified lines | ||
| 118 | 123 | return result; |
| 119 | 124 | } |
| 120 | 125 | |
| 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 | + | |
| 121 | 223 | async function loadCachedOpenApiSpec( |
| 122 | 224 | ctx: ActionCtx, |
| 123 | 225 | specUrl: string, |
| @@ -176,7 +278,7 @@ async function loadCachedOpenApiSpec( · 52 unmodified lines | ||
| 176 | 278 | export async function loadSourceArtifact( |
| 177 | 279 | ctx: ActionCtx, |
| 178 | 280 | source: ExternalToolSourceConfig, |
| 179 | − options: { includeDts?: boolean } = {}, | |
| 281 | + options: { includeDts?: boolean; workspaceId: Id<"workspaces">; actorId?: string }, | |
| 180 | 282 | ): Promise<{ artifact?: CompiledToolSourceArtifact; warnings: string[] }> { |
| 181 | 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 | 319 | try { |
| 201 | − const artifact = await compileExternalToolSource(source); | |
| 202 | − return { artifact, warnings: [] }; | |
| 320 | + const artifact = await compileExternalToolSource(sourceForCompile); | |
| 321 | + return { artifact, warnings: preWarnings }; | |
| 203 | 322 | } catch (error) { |
| 204 | 323 | const message = error instanceof Error ? error.message : String(error); |
| 205 | 324 | return { |
| 206 | 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 | } |
| @@ -54,6 +54,7 @@ interface GetWorkspaceToolsOptions { · 53 unmodified lines | ||
| 54 | 54 | sourceTimeoutMs?: number; |
| 55 | 55 | allowStaleOnMismatch?: boolean; |
| 56 | 56 | skipCacheRead?: boolean; |
| 57 | + actorId?: string; | |
| 57 | 58 | } |
| 58 | 59 | |
| 59 | 60 | interface WorkspaceToolInventory { |
| @@ -154,16 +155,25 @@ export async function getWorkspaceTools( · 94 unmodified lines | ||
| 154 | 155 | const includeDts = options.includeDts ?? false; |
| 155 | 156 | const sourceTimeoutMs = options.sourceTimeoutMs; |
| 156 | 157 | const allowStaleOnMismatch = options.allowStaleOnMismatch ?? false; |
| 157 | − const skipCacheRead = options.skipCacheRead ?? false; | |
| 158 | + const actorId = options.actorId; | |
| 158 | 159 | const sources = (await ctx.runQuery(internal.database.listToolSources, { workspaceId })) |
| 159 | 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 | 170 | traceStep("listToolSources", listSourcesStartedAt); |
| 161 | 171 | const hasOpenApiSource = sources.some((source: { type: string }) => source.type === "openapi"); |
| 162 | 172 | const signature = sourceSignature(workspaceId, sources); |
| 163 | 173 | 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, | |
| 167 | 177 | sourceCount: sources.length, |
| 168 | 178 | }; |
| 169 | 179 | |
| @@ -251,7 +261,7 @@ export async function getWorkspaceTools( · 81 unmodified lines | ||
| 251 | 261 | const loadedSources = await Promise.all(configs.map(async (config) => { |
| 252 | 262 | if (!sourceTimeoutMs || sourceTimeoutMs <= 0) { |
| 253 | 263 | return { |
| 254 | − ...(await loadSourceArtifact(ctx, config, { includeDts })), | |
| 264 | + ...(await loadSourceArtifact(ctx, config, { includeDts, workspaceId, actorId })), | |
| 255 | 265 | timedOut: false, |
| 256 | 266 | sourceName: config.name, |
| 257 | 267 | }; |
| @@ -274,7 +284,7 @@ export async function getWorkspaceTools( · 16 unmodified lines | ||
| 274 | 284 | }, sourceTimeoutMs); |
| 275 | 285 | }); |
| 276 | 286 | |
| 277 | − const loadResult = loadSourceArtifact(ctx, config, { includeDts }) | |
| 287 | + const loadResult = loadSourceArtifact(ctx, config, { includeDts, workspaceId, actorId }) | |
| 278 | 288 | .then((result) => ({ ...result, timedOut: false, sourceName: config.name })); |
| 279 | 289 | |
| 280 | 290 | const result = await Promise.race([loadResult, timeoutResult]); |
| @@ -354,18 +364,22 @@ export async function getWorkspaceTools( · 73 unmodified lines | ||
| 354 | 364 | warnings, |
| 355 | 365 | }; |
| 356 | 366 | |
| 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 | + } | |
| 369 | 383 | } catch (error) { |
| 370 | 384 | const msg = error instanceof Error ? error.message : String(error); |
| 371 | 385 | console.warn(`[executor] workspace tool cache write failed for '${workspaceId}': ${msg}`); |
| @@ -413,6 +427,7 @@ async function loadWorkspaceToolInventoryForContext( · 41 unmodified lines | ||
| 413 | 427 | sourceTimeoutMs, |
| 414 | 428 | allowStaleOnMismatch, |
| 415 | 429 | skipCacheRead, |
| 430 | + actorId: context.actorId, | |
| 416 | 431 | }), |
| 417 | 432 | ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: context.workspaceId }), |
| 418 | 433 | ]); |
| @@ -18,9 +18,13 @@ export async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDef · 17 unmodified lines | ||
| 18 | 18 | ) |
| 19 | 19 | : undefined; |
| 20 | 20 | const authHeaders = buildStaticAuthHeaders(config.auth); |
| 21 | + const discoveryHeaders = { | |
| 22 | + ...authHeaders, | |
| 23 | + ...(config.discoveryHeaders ?? {}), | |
| 24 | + }; | |
| 21 | 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 | 29 | async function callToolWithReconnect( |
| 26 | 30 | name: string, |
| @@ -28,7 +32,7 @@ export async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDef · 1 unmodified line | ||
| 28 | 32 | credentialHeaders?: Record<string, string>, |
| 29 | 33 | ): Promise<unknown> { |
| 30 | 34 | const mergedHeaders = { |
| 31 | − ...authHeaders, | |
| 35 | + ...discoveryHeaders, | |
| 32 | 36 | ...(credentialHeaders ?? {}), |
| 33 | 37 | }; |
| 34 | 38 | return await callMcpToolWithReconnect( |
| @@ -7,6 +7,7 @@ export interface McpToolSourceConfig { · 6 unmodified lines | ||
| 7 | 7 | sourceKey?: string; |
| 8 | 8 | url: string; |
| 9 | 9 | auth?: OpenApiAuth; |
| 10 | + discoveryHeaders?: Record<string, string>; | |
| 10 | 11 | transport?: "sse" | "streamable-http"; |
| 11 | 12 | queryParams?: Record<string, string>; |
| 12 | 13 | defaultApproval?: ToolApprovalMode; |