Use source-id based credential keys, infer OpenAPI auth defaults from security schemes, and clean up tools UI source/credential presentation including baseUrl-driven favicons. Also bump tool/OpenAPI cache versions and include related workspace prewarm and MCP config updates.
Unified · Split
| @@ -5,7 +5,6 @@ import { · 4 unmodified lines | ||
| 5 | 5 | Wrench, |
| 6 | 6 | Plus, |
| 7 | 7 | Trash2, |
| 8 | − ShieldCheck, | |
| 9 | 8 | Globe, |
| 10 | 9 | Server, |
| 11 | 10 | ChevronRight, |
| @@ -50,7 +49,6 @@ import { useAction, useMutation, useQuery } from "convex/react"; · 38 unmodified lines | ||
| 50 | 49 | import { convexApi } from "@/lib/convex-api"; |
| 51 | 50 | import type { |
| 52 | 51 | ToolSourceRecord, |
| 53 | − ToolDescriptor, | |
| 54 | 52 | CredentialRecord, |
| 55 | 53 | CredentialScope, |
| 56 | 54 | OpenApiSourceQuality, |
| @@ -222,54 +220,44 @@ function faviconForUrl(url: string | undefined | null): string | null { · 165 unmodified lines | ||
| 222 | 220 | } |
| 223 | 221 | } |
| 224 | 222 | |
| 225 | −function isTemplateHostname(hostname: string): boolean { | |
| 226 | − const lower = hostname.toLowerCase(); | |
| 227 | − return ( | |
| 228 | − lower === "localhost" || | |
| 229 | − lower === "example.com" || | |
| 230 | − lower.endsWith(".example.com") || | |
| 231 | − lower.startsWith("your-domain") || | |
| 232 | − lower.includes(".your-domain") | |
| 233 | − ); | |
| 234 | −} | |
| 235 | − | |
| 236 | −function pickFaviconTarget(urls: Array<string | undefined | null>): string | null { | |
| 237 | − const parsed = urls | |
| 238 | − .filter((value): value is string => Boolean(value)) | |
| 239 | − .map((value) => { | |
| 240 | − try { | |
| 241 | − return { value, hostname: new URL(value).hostname }; | |
| 242 | − } catch { | |
| 243 | − return null; | |
| 244 | − } | |
| 245 | − }) | |
| 246 | − .filter((entry): entry is { value: string; hostname: string } => entry !== null); | |
| 247 | − | |
| 248 | − const preferred = parsed.find((entry) => !isTemplateHostname(entry.hostname)); | |
| 249 | − if (preferred) return preferred.value; | |
| 250 | − return parsed[0]?.value ?? null; | |
| 251 | −} | |
| 252 | − | |
| 253 | 223 | function getFaviconUrl(preset: ApiPreset): string | null { |
| 254 | − return faviconForUrl(pickFaviconTarget([preset.baseUrl, preset.endpoint, preset.url, preset.spec])); | |
| 224 | + if (preset.type === "openapi") { | |
| 225 | + return faviconForUrl(preset.baseUrl ?? null); | |
| 226 | + } | |
| 227 | + if (preset.type === "graphql") { | |
| 228 | + return faviconForUrl(preset.endpoint ?? null); | |
| 229 | + } | |
| 230 | + return faviconForUrl(preset.url ?? null); | |
| 255 | 231 | } |
| 256 | 232 | |
| 257 | 233 | function getSourceFavicon(source: ToolSourceRecord): string | null { |
| 258 | − const urls = | |
| 259 | − source.type === "mcp" | |
| 260 | − ? [source.config.url as string] | |
| 261 | − : source.type === "graphql" | |
| 262 | − ? [source.config.endpoint as string] | |
| 263 | − : [source.config.baseUrl as string, source.config.spec as string]; | |
| 264 | − return faviconForUrl(pickFaviconTarget(urls)); | |
| 234 | + if (source.type === "mcp") { | |
| 235 | + return faviconForUrl((source.config.url as string) ?? null); | |
| 236 | + } | |
| 237 | + if (source.type === "graphql") { | |
| 238 | + return faviconForUrl((source.config.endpoint as string) ?? null); | |
| 239 | + } | |
| 240 | + return faviconForUrl((source.config.baseUrl as string) ?? null); | |
| 265 | 241 | } |
| 266 | 242 | |
| 267 | 243 | function sourceKeyForSource(source: ToolSourceRecord): string | null { |
| 268 | − if (source.type === "openapi") return `openapi:${source.name}`; | |
| 269 | − if (source.type === "graphql") return `graphql:${source.name}`; | |
| 244 | + if (source.type === "openapi") return `source:${source.id}`; | |
| 245 | + if (source.type === "graphql") return `source:${source.id}`; | |
| 270 | 246 | return null; |
| 271 | 247 | } |
| 272 | 248 | |
| 249 | +function toolSourceLabelForSource(source: ToolSourceRecord): string { | |
| 250 | + return `${source.type}:${source.name}`; | |
| 251 | +} | |
| 252 | + | |
| 253 | +function sourceForCredentialKey(sources: ToolSourceRecord[], sourceKey: string): ToolSourceRecord | null { | |
| 254 | + const prefix = "source:"; | |
| 255 | + if (!sourceKey.startsWith(prefix)) return null; | |
| 256 | + const sourceId = sourceKey.slice(prefix.length); | |
| 257 | + if (!sourceId) return null; | |
| 258 | + return sources.find((source) => source.id === sourceId) ?? null; | |
| 259 | +} | |
| 260 | + | |
| 273 | 261 | type SourceAuthType = "none" | "bearer" | "apiKey" | "basic"; |
| 274 | 262 | type SourceAuthMode = "workspace" | "actor"; |
| 275 | 263 | |
| @@ -311,6 +299,26 @@ function formatSourceAuthBadge(source: ToolSourceRecord): string | null { · 35 unmodified lines | ||
| 311 | 299 | return `${auth.type}:${mode}`; |
| 312 | 300 | } |
| 313 | 301 | |
| 302 | +function credentialStatsForSource(source: ToolSourceRecord, credentials: CredentialRecord[]): { | |
| 303 | + workspaceCount: number; | |
| 304 | + actorCount: number; | |
| 305 | +} { | |
| 306 | + const sourceKey = sourceKeyForSource(source); | |
| 307 | + if (!sourceKey) { | |
| 308 | + return { workspaceCount: 0, actorCount: 0 }; | |
| 309 | + } | |
| 310 | + | |
| 311 | + let workspaceCount = 0; | |
| 312 | + let actorCount = 0; | |
| 313 | + for (const credential of credentials) { | |
| 314 | + if (credential.sourceKey !== sourceKey) continue; | |
| 315 | + if (credential.scope === "workspace") workspaceCount += 1; | |
| 316 | + if (credential.scope === "actor") actorCount += 1; | |
| 317 | + } | |
| 318 | + | |
| 319 | + return { workspaceCount, actorCount }; | |
| 320 | +} | |
| 321 | + | |
| 314 | 322 | function formatQualityPercent(value: number): string { |
| 315 | 323 | return `${Math.round(value * 100)}%`; |
| 316 | 324 | } |
| @@ -816,10 +824,12 @@ function SourceCard({ · 499 unmodified lines | ||
| 816 | 824 | source, |
| 817 | 825 | quality, |
| 818 | 826 | qualityLoading, |
| 827 | + credentialStats, | |
| 819 | 828 | }: { |
| 820 | 829 | source: ToolSourceRecord; |
| 821 | 830 | quality?: OpenApiSourceQuality; |
| 822 | 831 | qualityLoading?: boolean; |
| 832 | + credentialStats: { workspaceCount: number; actorCount: number }; | |
| 823 | 833 | }) { |
| 824 | 834 | const { context } = useSession(); |
| 825 | 835 | const deleteToolSource = useMutation(convexApi.workspace.deleteToolSource); |
| @@ -845,6 +855,9 @@ function SourceCard({ · 19 unmodified lines | ||
| 845 | 855 | const TypeIcon = source.type === "mcp" ? Server : Globe; |
| 846 | 856 | const favicon = getSourceFavicon(source); |
| 847 | 857 | const authBadge = formatSourceAuthBadge(source); |
| 858 | + const auth = readSourceAuth(source); | |
| 859 | + const hasAuthConfigured = auth.type !== "none"; | |
| 860 | + const totalCredentials = credentialStats.workspaceCount + credentialStats.actorCount; | |
| 848 | 861 | |
| 849 | 862 | return ( |
| 850 | 863 | <div className="flex items-center gap-3 px-3 py-2.5 rounded-md bg-muted/40 group"> |
| @@ -882,6 +895,19 @@ function SourceCard({ · 31 unmodified lines | ||
| 882 | 895 | {authBadge} |
| 883 | 896 | </Badge> |
| 884 | 897 | )} |
| 898 | + {hasAuthConfigured && ( | |
| 899 | + <Badge | |
| 900 | + variant="outline" | |
| 901 | + className={cn( | |
| 902 | + "text-[9px] font-mono uppercase tracking-wider", | |
| 903 | + totalCredentials > 0 | |
| 904 | + ? "text-terminal-green border-terminal-green/30" | |
| 905 | + : "text-terminal-amber border-terminal-amber/30", | |
| 906 | + )} | |
| 907 | + > | |
| 908 | + creds ws:{credentialStats.workspaceCount} actor:{credentialStats.actorCount} | |
| 909 | + </Badge> | |
| 910 | + )} | |
| 885 | 911 | {source.type === "openapi" && quality && ( |
| 886 | 912 | <Badge |
| 887 | 913 | variant="outline" |
| @@ -936,7 +962,7 @@ function formatCredentialSecret(secretJson: Record<string, unknown>): string { · 48 unmodified lines | ||
| 936 | 962 | } |
| 937 | 963 | } |
| 938 | 964 | |
| 939 | −type SourceOption = { source: ToolSourceRecord; key: string }; | |
| 965 | +type SourceOption = { source: ToolSourceRecord; key: string; label: string }; | |
| 940 | 966 | |
| 941 | 967 | function sourceAuthForKey(sourceOptions: SourceOption[], key: string): { |
| 942 | 968 | type: SourceAuthType; |
| @@ -950,6 +976,10 @@ function sourceAuthForKey(sourceOptions: SourceOption[], key: string): { · 7 unmodified lines | ||
| 950 | 976 | return readSourceAuth(match.source); |
| 951 | 977 | } |
| 952 | 978 | |
| 979 | +function sourceOptionLabel(source: ToolSourceRecord): string { | |
| 980 | + return `${source.name} (${source.type})`; | |
| 981 | +} | |
| 982 | + | |
| 953 | 983 | function parseJsonObject(text: string): { value?: Record<string, unknown>; error?: string } { |
| 954 | 984 | try { |
| 955 | 985 | const parsed = JSON.parse(text); |
| @@ -996,8 +1026,15 @@ function CredentialsPanel({ · 40 unmodified lines | ||
| 996 | 1026 | const [secretJsonText, setSecretJsonText] = useState("{}"); |
| 997 | 1027 | |
| 998 | 1028 | const sourceOptions = sources |
| 999 | − .map((source) => ({ source, key: sourceKeyForSource(source) })) | |
| 1000 | − .filter((entry): entry is { source: ToolSourceRecord; key: string } => entry.key !== null); | |
| 1029 | + .map((source) => { | |
| 1030 | + const key = sourceKeyForSource(source); | |
| 1031 | + return { | |
| 1032 | + source, | |
| 1033 | + key, | |
| 1034 | + label: sourceOptionLabel(source), | |
| 1035 | + }; | |
| 1036 | + }) | |
| 1037 | + .filter((entry): entry is SourceOption => entry.key !== null); | |
| 1001 | 1038 | |
| 1002 | 1039 | const selectedAuth = sourceAuthForKey(sourceOptions, sourceKey); |
| 1003 | 1040 | const selectedAuthBadge = selectedAuth.type === "none" |
| @@ -1213,9 +1250,39 @@ function CredentialsPanel({ · 209 unmodified lines | ||
| 1213 | 1250 | key={`${credential.sourceKey}:${credential.scope}:${credential.actorId ?? "workspace"}`} |
| 1214 | 1251 | className="flex items-center gap-3 px-3 py-2.5 rounded-md bg-muted/40" |
| 1215 | 1252 | > |
| 1253 | + {(() => { | |
| 1254 | + const source = sourceForCredentialKey(sources, credential.sourceKey); | |
| 1255 | + const favicon = source ? getSourceFavicon(source) : null; | |
| 1256 | + return ( | |
| 1257 | + <div className="h-8 w-8 rounded bg-muted flex items-center justify-center shrink-0 overflow-hidden"> | |
| 1258 | + {favicon ? ( | |
| 1259 | + <img src={favicon} alt="" width={20} height={20} className="w-5 h-5" loading="lazy" /> | |
| 1260 | + ) : ( | |
| 1261 | + <KeyRound className="h-4 w-4 text-muted-foreground" /> | |
| 1262 | + )} | |
| 1263 | + </div> | |
| 1264 | + ); | |
| 1265 | + })()} | |
| 1216 | 1266 | <div className="flex-1 min-w-0"> |
| 1267 | + {(() => { | |
| 1268 | + const source = sourceForCredentialKey(sources, credential.sourceKey); | |
| 1269 | + if (!source) { | |
| 1270 | + return ( | |
| 1271 | + <p className="text-[11px] text-muted-foreground/80 font-mono mb-1"> | |
| 1272 | + {credential.sourceKey} | |
| 1273 | + </p> | |
| 1274 | + ); | |
| 1275 | + } | |
| 1276 | + return ( | |
| 1277 | + <div className="flex items-center gap-1.5 mb-1"> | |
| 1278 | + <span className="text-sm font-mono font-medium">{source.name}</span> | |
| 1279 | + <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> | |
| 1280 | + {source.type} | |
| 1281 | + </Badge> | |
| 1282 | + </div> | |
| 1283 | + ); | |
| 1284 | + })()} | |
| 1217 | 1285 | <div className="flex items-center gap-2 flex-wrap"> |
| 1218 | − <span className="text-sm font-mono font-medium">{credential.sourceKey}</span> | |
| 1219 | 1286 | <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> |
| 1220 | 1287 | {credential.scope} |
| 1221 | 1288 | </Badge> |
| @@ -1255,7 +1322,7 @@ function CredentialsPanel({ · 33 unmodified lines | ||
| 1255 | 1322 | </DialogHeader> |
| 1256 | 1323 | <div className="space-y-3"> |
| 1257 | 1324 | <div className="space-y-1.5"> |
| 1258 | − <Label className="text-xs text-muted-foreground">Source Key</Label> | |
| 1325 | + <Label className="text-xs text-muted-foreground">Source</Label> | |
| 1259 | 1326 | {sourceOptions.length > 0 ? ( |
| 1260 | 1327 | <Select value={sourceKey} onValueChange={handleSourceKeyChange}> |
| 1261 | 1328 | <SelectTrigger className="h-8 text-xs bg-background"> |
| @@ -1263,8 +1330,8 @@ function CredentialsPanel({ · 1 unmodified line | ||
| 1263 | 1330 | </SelectTrigger> |
| 1264 | 1331 | <SelectContent> |
| 1265 | 1332 | {sourceOptions.map((entry) => ( |
| 1266 | − <SelectItem key={entry.key} value={entry.key} className="text-xs font-mono"> | |
| 1267 | − {entry.key} | |
| 1333 | + <SelectItem key={entry.key} value={entry.key} className="text-xs"> | |
| 1334 | + {entry.label} | |
| 1268 | 1335 | </SelectItem> |
| 1269 | 1336 | ))} |
| 1270 | 1337 | </SelectContent> |
| @@ -1273,10 +1340,13 @@ function CredentialsPanel({ · 2 unmodified lines | ||
| 1273 | 1340 | <Input |
| 1274 | 1341 | value={sourceKey} |
| 1275 | 1342 | onChange={(e) => handleSourceKeyChange(e.target.value)} |
| 1276 | − placeholder="openapi:github" | |
| 1343 | + placeholder="source:<source-id>" | |
| 1277 | 1344 | className="h-8 text-xs font-mono bg-background" |
| 1278 | 1345 | /> |
| 1279 | 1346 | )} |
| 1347 | + {sourceKey && ( | |
| 1348 | + <p className="text-[10px] text-muted-foreground font-mono">key: {sourceKey}</p> | |
| 1349 | + )} | |
| 1280 | 1350 | </div> |
| 1281 | 1351 | |
| 1282 | 1352 | <div className="flex items-center gap-2 flex-wrap"> |
| @@ -1535,14 +1605,15 @@ export function ToolsView({ initialSource }: { initialSource?: string | null }) · 252 unmodified lines | ||
| 1535 | 1605 | </div> |
| 1536 | 1606 | )} |
| 1537 | 1607 | {sourceItems.map((s) => { |
| 1538 | − const sourceKey = sourceKeyForSource(s); | |
| 1539 | − const quality = sourceKey ? sourceQuality[sourceKey] : undefined; | |
| 1608 | + const quality = sourceQuality[toolSourceLabelForSource(s)]; | |
| 1609 | + const credentialStats = credentialStatsForSource(s, credentials ?? []); | |
| 1540 | 1610 | return ( |
| 1541 | 1611 | <SourceCard |
| 1542 | 1612 | key={s.id} |
| 1543 | 1613 | source={s} |
| 1544 | 1614 | quality={quality} |
| 1545 | 1615 | qualityLoading={toolsLoading} |
| 1616 | + credentialStats={credentialStats} | |
| 1546 | 1617 | /> |
| 1547 | 1618 | ); |
| 1548 | 1619 | })} |
| @@ -0,0 +1,61 @@ | ||
| 1 | +import { expect, test } from "bun:test"; | |
| 2 | +import { convexTest } from "convex-test"; | |
| 3 | +import { internal } from "./_generated/api"; | |
| 4 | +import schema from "./schema"; | |
| 5 | + | |
| 6 | +const GITHUB_OPENAPI_SPEC_URL = | |
| 7 | + "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json?convex_test_profile=github"; | |
| 8 | + | |
| 9 | +function setup() { | |
| 10 | + return convexTest(schema, { | |
| 11 | + "./database.ts": () => import("./database"), | |
| 12 | + "./executorNode.ts": () => import("./executorNode"), | |
| 13 | + "./workspaceToolCache.ts": () => import("./workspaceToolCache"), | |
| 14 | + "./openApiSpecCache.ts": () => import("./openApiSpecCache"), | |
| 15 | + "./_generated/api.js": () => import("./_generated/api.js"), | |
| 16 | + }); | |
| 17 | +} | |
| 18 | + | |
| 19 | +test("convex-test keeps GitHub inventory build warm-cache fast", async () => { | |
| 20 | + const t = setup(); | |
| 21 | + const session = await t.mutation(internal.database.bootstrapAnonymousSession, {}); | |
| 22 | + | |
| 23 | + await t.mutation(internal.database.upsertToolSource, { | |
| 24 | + workspaceId: session.workspaceId, | |
| 25 | + name: "github-profile", | |
| 26 | + type: "openapi", | |
| 27 | + config: { | |
| 28 | + spec: GITHUB_OPENAPI_SPEC_URL, | |
| 29 | + }, | |
| 30 | + }); | |
| 31 | + | |
| 32 | + const coldStart = performance.now(); | |
| 33 | + const cold = await t.action(internal.executorNode.listToolsWithWarningsInternal, { | |
| 34 | + workspaceId: session.workspaceId, | |
| 35 | + actorId: session.actorId, | |
| 36 | + clientId: session.clientId, | |
| 37 | + }); | |
| 38 | + const coldMs = performance.now() - coldStart; | |
| 39 | + | |
| 40 | + const warmStart = performance.now(); | |
| 41 | + const warm = await t.action(internal.executorNode.listToolsWithWarningsInternal, { | |
| 42 | + workspaceId: session.workspaceId, | |
| 43 | + actorId: session.actorId, | |
| 44 | + clientId: session.clientId, | |
| 45 | + }); | |
| 46 | + const warmMs = performance.now() - warmStart; | |
| 47 | + | |
| 48 | + console.log( | |
| 49 | + `github openapi convex-test profiling: cold=${coldMs.toFixed(0)}ms warm=${warmMs.toFixed(0)}ms tools=${cold.tools.length}`, | |
| 50 | + ); | |
| 51 | + | |
| 52 | + expect(cold.tools.length).toBeGreaterThan(500); | |
| 53 | + expect(cold.tools.length).toBe(warm.tools.length); | |
| 54 | + expect(cold.warnings.some((warning: string) => warning.includes("skipped bundle"))).toBe(false); | |
| 55 | + expect(warm.warnings.some((warning: string) => warning.includes("skipped bundle"))).toBe(false); | |
| 56 | + expect(Object.keys(cold.dtsUrls).length).toBeGreaterThan(0); | |
| 57 | + expect(Object.keys(warm.dtsUrls).length).toBeGreaterThan(0); | |
| 58 | + | |
| 59 | + expect(coldMs).toBeLessThan(12_000); | |
| 60 | + expect(coldMs).toBeGreaterThan(warmMs * 3); | |
| 61 | +}, 240_000); | |
| @@ -64,7 +64,7 @@ function policySpecificity(policy: AccessPolicyRecord, actorId?: string, clientI · 63 unmodified lines | ||
| 64 | 64 | } |
| 65 | 65 | |
| 66 | 66 | function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string { |
| 67 | − const signatureVersion = "v14"; | |
| 67 | + const signatureVersion = "v15"; | |
| 68 | 68 | const parts = sources |
| 69 | 69 | .map((source) => `${source.id}:${source.updatedAt}:${source.enabled ? 1 : 0}`) |
| 70 | 70 | .sort(); |
| @@ -176,6 +176,7 @@ function suggestToolPaths( · 105 unmodified lines | ||
| 176 | 176 | } |
| 177 | 177 | |
| 178 | 178 | function normalizeExternalToolSource(raw: { |
| 179 | + id: string; | |
| 179 | 180 | type: ToolSourceRecord["type"]; |
| 180 | 181 | name: string; |
| 181 | 182 | config: Record<string, unknown>; |
| @@ -211,6 +212,8 @@ function normalizeExternalToolSource(raw: { · 29 unmodified lines | ||
| 211 | 212 | const result: McpToolSourceConfig = { |
| 212 | 213 | type: "mcp", |
| 213 | 214 | name: raw.name, |
| 215 | + sourceId: raw.id, | |
| 216 | + sourceKey: `source:${raw.id}`, | |
| 214 | 217 | url: config.url, |
| 215 | 218 | transport: config.transport as McpToolSourceConfig["transport"], |
| 216 | 219 | queryParams: config.queryParams as McpToolSourceConfig["queryParams"], |
| @@ -228,6 +231,8 @@ function normalizeExternalToolSource(raw: { · 11 unmodified lines | ||
| 228 | 231 | const result: GraphqlToolSourceConfig = { |
| 229 | 232 | type: "graphql", |
| 230 | 233 | name: raw.name, |
| 234 | + sourceId: raw.id, | |
| 235 | + sourceKey: `source:${raw.id}`, | |
| 231 | 236 | endpoint: config.endpoint, |
| 232 | 237 | schema: config.schema as GraphqlToolSourceConfig["schema"], |
| 233 | 238 | auth: config.auth as GraphqlToolSourceConfig["auth"], |
| @@ -246,6 +251,8 @@ function normalizeExternalToolSource(raw: { · 12 unmodified lines | ||
| 246 | 251 | const result: OpenApiToolSourceConfig = { |
| 247 | 252 | type: "openapi", |
| 248 | 253 | name: raw.name, |
| 254 | + sourceId: raw.id, | |
| 255 | + sourceKey: `source:${raw.id}`, | |
| 249 | 256 | spec: spec as OpenApiToolSourceConfig["spec"], |
| 250 | 257 | baseUrl: config.baseUrl as OpenApiToolSourceConfig["baseUrl"], |
| 251 | 258 | auth: config.auth as OpenApiToolSourceConfig["auth"], |
| @@ -269,7 +276,7 @@ interface DtsStorageEntry { · 17 unmodified lines | ||
| 269 | 276 | const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000; |
| 270 | 277 | |
| 271 | 278 | /** Cache version — bump when PreparedOpenApiSpec shape changes. */ |
| 272 | −const OPENAPI_CACHE_VERSION = "v14"; | |
| 279 | +const OPENAPI_CACHE_VERSION = "v15"; | |
| 273 | 280 | |
| 274 | 281 | async function publish( |
| 275 | 282 | ctx: ActionCtx, |
| @@ -10,7 +10,6 @@ import type { Id } from "./_generated/dataModel"; · 9 unmodified lines | ||
| 10 | 10 | |
| 11 | 11 | const http = httpRouter(); |
| 12 | 12 | const internalToken = process.env.EXECUTOR_INTERNAL_TOKEN ?? null; |
| 13 | −const mcpJwksByServer = new Map<string, ReturnType<typeof createRemoteJWKSet>>(); | |
| 14 | 13 | |
| 15 | 14 | function getMcpAuthorizationServer(): string | null { |
| 16 | 15 | return process.env.MCP_AUTHORIZATION_SERVER |
| @@ -34,17 +33,7 @@ function getMcpAuthConfig(): { · 17 unmodified lines | ||
| 34 | 33 | }; |
| 35 | 34 | } |
| 36 | 35 | |
| 37 | − const existingJwks = mcpJwksByServer.get(authorizationServer); | |
| 38 | − if (existingJwks) { | |
| 39 | − return { | |
| 40 | − enabled: true, | |
| 41 | − authorizationServer, | |
| 42 | − jwks: existingJwks, | |
| 43 | − }; | |
| 44 | − } | |
| 45 | − | |
| 46 | 36 | const jwks = createRemoteJWKSet(new URL("/oauth2/jwks", authorizationServer)); |
| 47 | − mcpJwksByServer.set(authorizationServer, jwks); | |
| 48 | 37 | return { |
| 49 | 38 | enabled: true, |
| 50 | 39 | authorizationServer, |
| @@ -166,10 +166,20 @@ export const upsertToolSource = workspaceMutation({ · 165 unmodified lines | ||
| 166 | 166 | enabled: v.optional(v.boolean()), |
| 167 | 167 | }, |
| 168 | 168 | handler: async (ctx, args) => { |
| 169 | − return await ctx.runMutation(internal.database.upsertToolSource, { | |
| 169 | + const source = await ctx.runMutation(internal.database.upsertToolSource, { | |
| 170 | 170 | workspaceId: ctx.workspaceId, |
| 171 | 171 | ...args, |
| 172 | 172 | }); |
| 173 | + | |
| 174 | + try { | |
| 175 | + await ctx.scheduler.runAfter(0, internal.executorNode.listToolsWithWarningsInternal, { | |
| 176 | + workspaceId: ctx.workspaceId, | |
| 177 | + }); | |
| 178 | + } catch { | |
| 179 | + // Best effort prewarm only. | |
| 180 | + } | |
| 181 | + | |
| 182 | + return source; | |
| 173 | 183 | }, |
| 174 | 184 | }); |
| 175 | 185 | |
| @@ -186,9 +196,19 @@ export const deleteToolSource = workspaceMutation({ · 10 unmodified lines | ||
| 186 | 196 | requireAdmin: true, |
| 187 | 197 | args: { sourceId: v.string() }, |
| 188 | 198 | handler: async (ctx, args) => { |
| 189 | − return await ctx.runMutation(internal.database.deleteToolSource, { | |
| 199 | + const deleted = await ctx.runMutation(internal.database.deleteToolSource, { | |
| 190 | 200 | workspaceId: ctx.workspaceId, |
| 191 | 201 | sourceId: args.sourceId, |
| 192 | 202 | }); |
| 203 | + | |
| 204 | + try { | |
| 205 | + await ctx.scheduler.runAfter(0, internal.executorNode.listToolsWithWarningsInternal, { | |
| 206 | + workspaceId: ctx.workspaceId, | |
| 207 | + }); | |
| 208 | + } catch { | |
| 209 | + // Best effort prewarm only. | |
| 210 | + } | |
| 211 | + | |
| 212 | + return deleted; | |
| 193 | 213 | }, |
| 194 | 214 | }); |
| @@ -176,7 +176,7 @@ describe("prepareOpenApiSpec with large specs", () => { · 175 unmodified lines | ||
| 176 | 176 | |
| 177 | 177 | expect(Object.keys(prepared.paths)).toHaveLength(250); |
| 178 | 178 | expect(prepared.dts).toBeDefined(); |
| 179 | − expect(prepared.warnings.some((warning: string) => warning.includes("fast mode"))).toBe(false); | |
| 179 | + expect(prepared.warnings.some((warning: string) => warning.includes("skipped bundle"))).toBe(false); | |
| 180 | 180 | |
| 181 | 181 | const tools = buildOpenApiToolsFromPrepared( |
| 182 | 182 | { |
| @@ -344,6 +344,65 @@ describe("buildOpenApiToolsFromPrepared", () => { · 161 unmodified lines | ||
| 344 | 344 | ); |
| 345 | 345 | }); |
| 346 | 346 | |
| 347 | + test("infers workspace auth from OpenAPI securitySchemes when source auth is unset", async () => { | |
| 348 | + const spec: Record<string, unknown> = { | |
| 349 | + openapi: "3.0.3", | |
| 350 | + info: { title: "Auth inferred", version: "1.0.0" }, | |
| 351 | + servers: [{ url: "https://api.example.com" }], | |
| 352 | + security: [{ bearerAuth: [] }], | |
| 353 | + components: { | |
| 354 | + securitySchemes: { | |
| 355 | + bearerAuth: { | |
| 356 | + type: "http", | |
| 357 | + scheme: "bearer", | |
| 358 | + }, | |
| 359 | + }, | |
| 360 | + }, | |
| 361 | + paths: { | |
| 362 | + "/me": { | |
| 363 | + get: { | |
| 364 | + operationId: "getMe", | |
| 365 | + tags: ["users"], | |
| 366 | + responses: { | |
| 367 | + "200": { | |
| 368 | + description: "ok", | |
| 369 | + content: { | |
| 370 | + "application/json": { | |
| 371 | + schema: { | |
| 372 | + type: "object", | |
| 373 | + properties: { id: { type: "string" } }, | |
| 374 | + }, | |
| 375 | + }, | |
| 376 | + }, | |
| 377 | + }, | |
| 378 | + }, | |
| 379 | + }, | |
| 380 | + }, | |
| 381 | + }, | |
| 382 | + }; | |
| 383 | + | |
| 384 | + const prepared = await prepareOpenApiSpec(spec, "auth-inferred"); | |
| 385 | + expect(prepared.inferredAuth).toEqual({ type: "bearer", mode: "workspace" }); | |
| 386 | + | |
| 387 | + const tools = buildOpenApiToolsFromPrepared( | |
| 388 | + { | |
| 389 | + type: "openapi", | |
| 390 | + name: "auth-source", | |
| 391 | + sourceKey: "source:test-source-id", | |
| 392 | + spec, | |
| 393 | + baseUrl: "https://api.example.com", | |
| 394 | + }, | |
| 395 | + prepared, | |
| 396 | + ); | |
| 397 | + | |
| 398 | + const meTool = tools.find((tool) => tool.metadata?.operationId === "getMe"); | |
| 399 | + expect(meTool?.credential).toEqual({ | |
| 400 | + sourceKey: "source:test-source-id", | |
| 401 | + mode: "workspace", | |
| 402 | + authType: "bearer", | |
| 403 | + }); | |
| 404 | + }); | |
| 405 | + | |
| 347 | 406 | test("resolves shared parameter refs and maps 204 responses to void", async () => { |
| 348 | 407 | const spec: Record<string, unknown> = { |
| 349 | 408 | openapi: "3.0.3", |
| @@ -720,6 +720,7 @@ test("graphql helper tools inherit credential spec from source auth", async () = · 719 unmodified lines | ||
| 720 | 720 | { |
| 721 | 721 | type: "graphql", |
| 722 | 722 | name: "linear", |
| 723 | + sourceKey: "source:stable-linear-id", | |
| 723 | 724 | endpoint: `http://127.0.0.1:${server.port}/graphql`, |
| 724 | 725 | auth: { |
| 725 | 726 | type: "bearer", |
| @@ -734,12 +735,12 @@ test("graphql helper tools inherit credential spec from source auth", async () = · 8 unmodified lines | ||
| 734 | 735 | const helperTool = tools.find((tool) => tool.path === "linear.query.teams"); |
| 735 | 736 | |
| 736 | 737 | expect(rawTool?.credential).toEqual({ |
| 737 | − sourceKey: "graphql:linear", | |
| 738 | + sourceKey: "source:stable-linear-id", | |
| 738 | 739 | mode: "actor", |
| 739 | 740 | authType: "bearer", |
| 740 | 741 | }); |
| 741 | 742 | expect(helperTool?.credential).toEqual({ |
| 742 | − sourceKey: "graphql:linear", | |
| 743 | + sourceKey: "source:stable-linear-id", | |
| 743 | 744 | mode: "actor", |
| 744 | 745 | authType: "bearer", |
| 745 | 746 | }); |
| @@ -15,6 +15,8 @@ type JsonSchema = Record<string, unknown>; · 14 unmodified lines | ||
| 15 | 15 | export interface McpToolSourceConfig { |
| 16 | 16 | type: "mcp"; |
| 17 | 17 | name: string; |
| 18 | + sourceId?: string; | |
| 19 | + sourceKey?: string; | |
| 18 | 20 | url: string; |
| 19 | 21 | transport?: "sse" | "streamable-http"; |
| 20 | 22 | queryParams?: Record<string, string>; |
| @@ -31,6 +33,8 @@ export type OpenApiAuth = · 10 unmodified lines | ||
| 31 | 33 | export interface OpenApiToolSourceConfig { |
| 32 | 34 | type: "openapi"; |
| 33 | 35 | name: string; |
| 36 | + sourceId?: string; | |
| 37 | + sourceKey?: string; | |
| 34 | 38 | spec: string | Record<string, unknown>; |
| 35 | 39 | baseUrl?: string; |
| 36 | 40 | auth?: OpenApiAuth; |
| @@ -42,6 +46,8 @@ export interface OpenApiToolSourceConfig { · 5 unmodified lines | ||
| 42 | 46 | export interface GraphqlToolSourceConfig { |
| 43 | 47 | type: "graphql"; |
| 44 | 48 | name: string; |
| 49 | + sourceId?: string; | |
| 50 | + sourceKey?: string; | |
| 45 | 51 | endpoint: string; |
| 46 | 52 | /** Optional static introspection result — if omitted, we introspect at load time */ |
| 47 | 53 | schema?: Record<string, unknown>; |
| @@ -651,6 +657,14 @@ function buildCredentialSpec(sourceKey: string, auth?: OpenApiAuth): ToolCredent · 603 unmodified lines | ||
| 651 | 657 | }; |
| 652 | 658 | } |
| 653 | 659 | |
| 660 | +function getCredentialSourceKey(config: { | |
| 661 | + type: ExternalToolSourceConfig["type"]; | |
| 662 | + name: string; | |
| 663 | + sourceKey?: string; | |
| 664 | +}): string { | |
| 665 | + return config.sourceKey ?? `${config.type}:${config.name}`; | |
| 666 | +} | |
| 667 | + | |
| 654 | 668 | function buildOpenApiUrl( |
| 655 | 669 | baseUrl: string, |
| 656 | 670 | pathTemplate: string, |
| @@ -866,9 +880,59 @@ export interface PreparedOpenApiSpec { · 209 unmodified lines | ||
| 866 | 880 | paths: Record<string, unknown>; |
| 867 | 881 | /** Raw .d.ts from openapiTS — cached as-is, extracted lazily on read */ |
| 868 | 882 | dts?: string; |
| 883 | + /** Auth inferred from OpenAPI security schemes when explicit source auth is not configured. */ | |
| 884 | + inferredAuth?: OpenApiAuth; | |
| 869 | 885 | warnings: string[]; |
| 870 | 886 | } |
| 871 | 887 | |
| 888 | +function inferOpenApiAuth(spec: Record<string, unknown>): OpenApiAuth | undefined { | |
| 889 | + const components = asRecord(spec.components); | |
| 890 | + const securitySchemes = asRecord(components.securitySchemes); | |
| 891 | + if (Object.keys(securitySchemes).length === 0) { | |
| 892 | + return undefined; | |
| 893 | + } | |
| 894 | + | |
| 895 | + const security = Array.isArray(spec.security) | |
| 896 | + ? spec.security.filter((entry): entry is Record<string, unknown> => Boolean(entry && typeof entry === "object")) | |
| 897 | + : []; | |
| 898 | + | |
| 899 | + const referencedSchemeName = security | |
| 900 | + .flatMap((entry) => Object.keys(entry)) | |
| 901 | + .find((name) => typeof securitySchemes[name] === "object"); | |
| 902 | + | |
| 903 | + const schemeName = referencedSchemeName ?? Object.keys(securitySchemes)[0]; | |
| 904 | + if (!schemeName) return undefined; | |
| 905 | + | |
| 906 | + const scheme = asRecord(securitySchemes[schemeName]); | |
| 907 | + const type = String(scheme.type ?? "").toLowerCase(); | |
| 908 | + | |
| 909 | + if (type === "http") { | |
| 910 | + const httpScheme = String(scheme.scheme ?? "").toLowerCase(); | |
| 911 | + if (httpScheme === "bearer") { | |
| 912 | + return { type: "bearer", mode: "workspace" }; | |
| 913 | + } | |
| 914 | + if (httpScheme === "basic") { | |
| 915 | + return { type: "basic", mode: "workspace" }; | |
| 916 | + } | |
| 917 | + return undefined; | |
| 918 | + } | |
| 919 | + | |
| 920 | + if (type === "apikey") { | |
| 921 | + const location = String(scheme.in ?? "").toLowerCase(); | |
| 922 | + const header = typeof scheme.name === "string" ? scheme.name.trim() : ""; | |
| 923 | + if (location === "header" && header.length > 0) { | |
| 924 | + return { type: "apiKey", mode: "workspace", header }; | |
| 925 | + } | |
| 926 | + return undefined; | |
| 927 | + } | |
| 928 | + | |
| 929 | + if (type === "oauth2" || type === "openidconnect") { | |
| 930 | + return { type: "bearer", mode: "workspace" }; | |
| 931 | + } | |
| 932 | + | |
| 933 | + return undefined; | |
| 934 | +} | |
| 935 | + | |
| 872 | 936 | export async function prepareOpenApiSpec( |
| 873 | 937 | spec: string | Record<string, unknown>, |
| 874 | 938 | sourceName = "openapi", |
| @@ -917,6 +981,7 @@ export async function prepareOpenApiSpec( · 42 unmodified lines | ||
| 917 | 981 | const operationTypeIds = dts ? extractOperationIdsFromDts(dts) : new Set<string>(); |
| 918 | 982 | |
| 919 | 983 | const servers = Array.isArray(bundled.servers) ? (bundled.servers as Array<{ url?: unknown }>) : []; |
| 984 | + const inferredAuth = inferOpenApiAuth(bundled); | |
| 920 | 985 | |
| 921 | 986 | return { |
| 922 | 987 | servers: servers |
| @@ -931,6 +996,7 @@ export async function prepareOpenApiSpec( · 8 unmodified lines | ||
| 931 | 996 | asRecord(asRecord(bundled.components).requestBodies), |
| 932 | 997 | ), |
| 933 | 998 | dts: dts ?? undefined, |
| 999 | + ...(inferredAuth ? { inferredAuth } : {}), | |
| 934 | 1000 | warnings, |
| 935 | 1001 | }; |
| 936 | 1002 | } |
| @@ -944,9 +1010,11 @@ export function buildOpenApiToolsFromPrepared( · 7 unmodified lines | ||
| 944 | 1010 | throw new Error(`OpenAPI source ${config.name} has no base URL (set baseUrl)`); |
| 945 | 1011 | } |
| 946 | 1012 | |
| 947 | − const authHeaders = buildStaticAuthHeaders(config.auth); | |
| 948 | − const sourceKey = `openapi:${config.name}`; | |
| 949 | − const credentialSpec = buildCredentialSpec(sourceKey, config.auth); | |
| 1013 | + const effectiveAuth = config.auth ?? prepared.inferredAuth; | |
| 1014 | + const authHeaders = buildStaticAuthHeaders(effectiveAuth); | |
| 1015 | + const sourceLabel = `openapi:${config.name}`; | |
| 1016 | + const credentialSourceKey = getCredentialSourceKey(config); | |
| 1017 | + const credentialSpec = buildCredentialSpec(credentialSourceKey, effectiveAuth); | |
| 950 | 1018 | const paths = asRecord(prepared.paths); |
| 951 | 1019 | const tools: ToolDefinition[] = []; |
| 952 | 1020 | |
| @@ -1064,7 +1132,7 @@ export function buildOpenApiToolsFromPrepared( · 111 unmodified lines | ||
| 1064 | 1132 | |
| 1065 | 1133 | const tool: ToolDefinition & { _runSpec: SerializedTool["runSpec"] } = { |
| 1066 | 1134 | path: buildOpenApiToolPath(config.name, tagRaw, operationIdRaw, usedToolPaths), |
| 1067 | − source: sourceKey, | |
| 1135 | + source: sourceLabel, | |
| 1068 | 1136 | approval, |
| 1069 | 1137 | description: String(operation.summary ?? operation.description ?? `${method.toUpperCase()} ${pathTemplate}`), |
| 1070 | 1138 | metadata: { |
| @@ -1553,7 +1621,7 @@ export function parseGraphqlOperationPaths( · 482 unmodified lines | ||
| 1553 | 1621 | async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise<ToolDefinition[]> { |
| 1554 | 1622 | const authHeaders = buildStaticAuthHeaders(config.auth); |
| 1555 | 1623 | const sourceKey = `graphql:${config.name}`; |
| 1556 | − const credentialSpec = buildCredentialSpec(sourceKey, config.auth); | |
| 1624 | + const credentialSpec = buildCredentialSpec(getCredentialSourceKey(config), config.auth); | |
| 1557 | 1625 | const sourceName = sanitizeSegment(config.name); |
| 1558 | 1626 | |
| 1559 | 1627 | // Introspect the schema |
| @@ -1,3 +1,10 @@ | ||
| 1 | 1 | { |
| 2 | − "$schema": "https://opencode.ai/config.json" | |
| 2 | + "$schema": "https://opencode.ai/config.json", | |
| 3 | + "mcp":{ | |
| 4 | + "answeroverflow": { | |
| 5 | + "type": "remote", | |
| 6 | + "url": "https://www.answeroverflow.com/mcp", | |
| 7 | + "enabled": true | |
| 8 | + } | |
| 9 | + } | |
| 3 | 10 | } |