Unified · Split
| @@ -2,6 +2,8 @@ import type { NextRequest } from "next/server"; · 1 unmodified line | |||
| 2 | import { NextResponse } from "next/server"; | 2 | import { NextResponse } from "next/server"; |
| 3 | import { Result } from "better-result"; | 3 | import { Result } from "better-result"; |
| 4 | import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; | 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 | import { | 7 | import { |
| 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 | } |
| @@ -2,6 +2,7 @@ import type { NextRequest } from "next/server"; · 1 unmodified line | |||
| 2 | import { NextResponse } from "next/server"; | 2 | import { NextResponse } from "next/server"; |
| 3 | import { Result } from "better-result"; | 3 | import { Result } from "better-result"; |
| 4 | import { discoverOAuthProtectedResourceMetadata } from "@modelcontextprotocol/sdk/client/auth.js"; | 4 | import { discoverOAuthProtectedResourceMetadata } from "@modelcontextprotocol/sdk/client/auth.js"; |
| 5 | import { parseMcpSourceUrl } from "@/lib/mcp-oauth-url"; | ||
| 5 | 6 | ||
| 6 | type DetectResponse = { | 7 | type 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 | ||
| @@ -2,6 +2,8 @@ import type { NextRequest } from "next/server"; · 1 unmodified line | |||
| 2 | import { NextResponse } from "next/server"; | 2 | import { NextResponse } from "next/server"; |
| 3 | import { Result } from "better-result"; | 3 | import { Result } from "better-result"; |
| 4 | import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; | 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 | import { | 7 | import { |
| 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 | ||
| 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 | function popupResultRedirect(request: NextRequest, payload: McpOAuthPopupResult): NextResponse { | 17 | function 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: "/", |
| @@ -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 | 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 | ||
| @@ -2,20 +2,25 @@ · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | import type { ActionCtx } from "../_generated/server"; | 3 | import type { ActionCtx } from "../_generated/server"; |
| 4 | import { internal } from "../_generated/api"; | 4 | import { internal } from "../_generated/api"; |
| 5 | import type { Id } from "../_generated/dataModel"; | ||
| 5 | import { | 6 | import { |
| 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"; |
| 12 | import { resolveCredentialPayload } from "../../core/src/credential-providers"; | ||
| 13 | import { buildStaticAuthHeaders } from "../../core/src/tool-source-auth"; | ||
| 11 | import type { | 14 | import 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"; |
| 18 | import type { ToolSourceRecord } from "../../core/src/types"; | 22 | import type { ToolSourceRecord } from "../../core/src/types"; |
| 23 | import { asPayload } from "../lib/object"; | ||
| 19 | 24 | ||
| 20 | const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000; | 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 | return result; | 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 | async function loadCachedOpenApiSpec( | 223 | async function loadCachedOpenApiSpec( |
| 122 | ctx: ActionCtx, | 224 | ctx: ActionCtx, |
| 123 | specUrl: string, | 225 | specUrl: string, |
| @@ -176,7 +278,7 @@ async function loadCachedOpenApiSpec( · 52 unmodified lines | |||
| 176 | export async function loadSourceArtifact( | 278 | export 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 | } |
| @@ -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 | ||
| 59 | interface WorkspaceToolInventory { | 60 | interface 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 | ]); |
| @@ -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( |
| @@ -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; |