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 | Wrench, | 5 | Wrench, |
| 6 | Plus, | 6 | Plus, |
| 7 | Trash2, | 7 | Trash2, |
| 8 | ShieldCheck, | ||
| 9 | Globe, | 8 | Globe, |
| 10 | Server, | 9 | Server, |
| 11 | ChevronRight, | 10 | ChevronRight, |
| @@ -50,7 +49,6 @@ import { useAction, useMutation, useQuery } from "convex/react"; · 38 unmodified lines | |||
| 50 | import { convexApi } from "@/lib/convex-api"; | 49 | import { convexApi } from "@/lib/convex-api"; |
| 51 | import type { | 50 | import type { |
| 52 | ToolSourceRecord, | 51 | ToolSourceRecord, |
| 53 | ToolDescriptor, | ||
| 54 | CredentialRecord, | 52 | CredentialRecord, |
| 55 | CredentialScope, | 53 | CredentialScope, |
| 56 | OpenApiSourceQuality, | 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 | function getFaviconUrl(preset: ApiPreset): string | null { | 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 | function getSourceFavicon(source: ToolSourceRecord): string | null { | 233 | function getSourceFavicon(source: ToolSourceRecord): string | null { |
| 258 | const urls = | 234 | if (source.type === "mcp") { |
| 259 | source.type === "mcp" | 235 | return faviconForUrl((source.config.url as string) ?? null); |
| 260 | ? [source.config.url as string] | 236 | } |
| 261 | : source.type === "graphql" | 237 | if (source.type === "graphql") { |
| 262 | ? [source.config.endpoint as string] | 238 | return faviconForUrl((source.config.endpoint as string) ?? null); |
| 263 | : [source.config.baseUrl as string, source.config.spec as string]; | 239 | } |
| 264 | return faviconForUrl(pickFaviconTarget(urls)); | 240 | return faviconForUrl((source.config.baseUrl as string) ?? null); |
| 265 | } | 241 | } |
| 266 | 242 | ||
| 267 | function sourceKeyForSource(source: ToolSourceRecord): string | null { | 243 | function sourceKeyForSource(source: ToolSourceRecord): string | null { |
| 268 | if (source.type === "openapi") return `openapi:${source.name}`; | 244 | if (source.type === "openapi") return `source:${source.id}`; |
| 269 | if (source.type === "graphql") return `graphql:${source.name}`; | 245 | if (source.type === "graphql") return `source:${source.id}`; |
| 270 | return null; | 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 | type SourceAuthType = "none" | "bearer" | "apiKey" | "basic"; | 261 | type SourceAuthType = "none" | "bearer" | "apiKey" | "basic"; |
| 274 | type SourceAuthMode = "workspace" | "actor"; | 262 | type SourceAuthMode = "workspace" | "actor"; |
| 275 | 263 | ||
| @@ -311,6 +299,26 @@ function formatSourceAuthBadge(source: ToolSourceRecord): string | null { · 35 unmodified lines | |||
| 311 | return `${auth.type}:${mode}`; | 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 | function formatQualityPercent(value: number): string { | 322 | function formatQualityPercent(value: number): string { |
| 315 | return `${Math.round(value * 100)}%`; | 323 | return `${Math.round(value * 100)}%`; |
| 316 | } | 324 | } |
| @@ -816,10 +824,12 @@ function SourceCard({ · 499 unmodified lines | |||
| 816 | source, | 824 | source, |
| 817 | quality, | 825 | quality, |
| 818 | qualityLoading, | 826 | qualityLoading, |
| 827 | credentialStats, | ||
| 819 | }: { | 828 | }: { |
| 820 | source: ToolSourceRecord; | 829 | source: ToolSourceRecord; |
| 821 | quality?: OpenApiSourceQuality; | 830 | quality?: OpenApiSourceQuality; |
| 822 | qualityLoading?: boolean; | 831 | qualityLoading?: boolean; |
| 832 | credentialStats: { workspaceCount: number; actorCount: number }; | ||
| 823 | }) { | 833 | }) { |
| 824 | const { context } = useSession(); | 834 | const { context } = useSession(); |
| 825 | const deleteToolSource = useMutation(convexApi.workspace.deleteToolSource); | 835 | const deleteToolSource = useMutation(convexApi.workspace.deleteToolSource); |
| @@ -845,6 +855,9 @@ function SourceCard({ · 19 unmodified lines | |||
| 845 | const TypeIcon = source.type === "mcp" ? Server : Globe; | 855 | const TypeIcon = source.type === "mcp" ? Server : Globe; |
| 846 | const favicon = getSourceFavicon(source); | 856 | const favicon = getSourceFavicon(source); |
| 847 | const authBadge = formatSourceAuthBadge(source); | 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 | return ( | 862 | return ( |
| 850 | <div className="flex items-center gap-3 px-3 py-2.5 rounded-md bg-muted/40 group"> | 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 | {authBadge} | 895 | {authBadge} |
| 883 | </Badge> | 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 | {source.type === "openapi" && quality && ( | 911 | {source.type === "openapi" && quality && ( |
| 886 | <Badge | 912 | <Badge |
| 887 | variant="outline" | 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 | function sourceAuthForKey(sourceOptions: SourceOption[], key: string): { | 967 | function sourceAuthForKey(sourceOptions: SourceOption[], key: string): { |
| 942 | type: SourceAuthType; | 968 | type: SourceAuthType; |
| @@ -950,6 +976,10 @@ function sourceAuthForKey(sourceOptions: SourceOption[], key: string): { · 7 unmodified lines | |||
| 950 | return readSourceAuth(match.source); | 976 | return readSourceAuth(match.source); |
| 951 | } | 977 | } |
| 952 | 978 | ||
| 979 | function sourceOptionLabel(source: ToolSourceRecord): string { | ||
| 980 | return `${source.name} (${source.type})`; | ||
| 981 | } | ||
| 982 | |||
| 953 | function parseJsonObject(text: string): { value?: Record<string, unknown>; error?: string } { | 983 | function parseJsonObject(text: string): { value?: Record<string, unknown>; error?: string } { |
| 954 | try { | 984 | try { |
| 955 | const parsed = JSON.parse(text); | 985 | const parsed = JSON.parse(text); |
| @@ -996,8 +1026,15 @@ function CredentialsPanel({ · 40 unmodified lines | |||
| 996 | const [secretJsonText, setSecretJsonText] = useState("{}"); | 1026 | const [secretJsonText, setSecretJsonText] = useState("{}"); |
| 997 | 1027 | ||
| 998 | const sourceOptions = sources | 1028 | const sourceOptions = sources |
| 999 | .map((source) => ({ source, key: sourceKeyForSource(source) })) | 1029 | .map((source) => { |
| 1000 | .filter((entry): entry is { source: ToolSourceRecord; key: string } => entry.key !== null); | 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 | const selectedAuth = sourceAuthForKey(sourceOptions, sourceKey); | 1039 | const selectedAuth = sourceAuthForKey(sourceOptions, sourceKey); |
| 1003 | const selectedAuthBadge = selectedAuth.type === "none" | 1040 | const selectedAuthBadge = selectedAuth.type === "none" |
| @@ -1213,9 +1250,39 @@ function CredentialsPanel({ · 209 unmodified lines | |||
| 1213 | key={`${credential.sourceKey}:${credential.scope}:${credential.actorId ?? "workspace"}`} | 1250 | key={`${credential.sourceKey}:${credential.scope}:${credential.actorId ?? "workspace"}`} |
| 1214 | className="flex items-center gap-3 px-3 py-2.5 rounded-md bg-muted/40" | 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 | <div className="flex-1 min-w-0"> | 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 | <div className="flex items-center gap-2 flex-wrap"> | 1285 | <div className="flex items-center gap-2 flex-wrap"> |
| 1218 | <span className="text-sm font-mono font-medium">{credential.sourceKey}</span> | ||
| 1219 | <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> | 1286 | <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> |
| 1220 | {credential.scope} | 1287 | {credential.scope} |
| 1221 | </Badge> | 1288 | </Badge> |
| @@ -1255,7 +1322,7 @@ function CredentialsPanel({ · 33 unmodified lines | |||
| 1255 | </DialogHeader> | 1322 | </DialogHeader> |
| 1256 | <div className="space-y-3"> | 1323 | <div className="space-y-3"> |
| 1257 | <div className="space-y-1.5"> | 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 | {sourceOptions.length > 0 ? ( | 1326 | {sourceOptions.length > 0 ? ( |
| 1260 | <Select value={sourceKey} onValueChange={handleSourceKeyChange}> | 1327 | <Select value={sourceKey} onValueChange={handleSourceKeyChange}> |
| 1261 | <SelectTrigger className="h-8 text-xs bg-background"> | 1328 | <SelectTrigger className="h-8 text-xs bg-background"> |
| @@ -1263,8 +1330,8 @@ function CredentialsPanel({ · 1 unmodified line | |||
| 1263 | </SelectTrigger> | 1330 | </SelectTrigger> |
| 1264 | <SelectContent> | 1331 | <SelectContent> |
| 1265 | {sourceOptions.map((entry) => ( | 1332 | {sourceOptions.map((entry) => ( |
| 1266 | <SelectItem key={entry.key} value={entry.key} className="text-xs font-mono"> | 1333 | <SelectItem key={entry.key} value={entry.key} className="text-xs"> |
| 1267 | {entry.key} | 1334 | {entry.label} |
| 1268 | </SelectItem> | 1335 | </SelectItem> |
| 1269 | ))} | 1336 | ))} |
| 1270 | </SelectContent> | 1337 | </SelectContent> |
| @@ -1273,10 +1340,13 @@ function CredentialsPanel({ · 2 unmodified lines | |||
| 1273 | <Input | 1340 | <Input |
| 1274 | value={sourceKey} | 1341 | value={sourceKey} |
| 1275 | onChange={(e) => handleSourceKeyChange(e.target.value)} | 1342 | onChange={(e) => handleSourceKeyChange(e.target.value)} |
| 1276 | placeholder="openapi:github" | 1343 | placeholder="source:<source-id>" |
| 1277 | className="h-8 text-xs font-mono bg-background" | 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 | </div> | 1350 | </div> |
| 1281 | 1351 | ||
| 1282 | <div className="flex items-center gap-2 flex-wrap"> | 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 | </div> | 1605 | </div> |
| 1536 | )} | 1606 | )} |
| 1537 | {sourceItems.map((s) => { | 1607 | {sourceItems.map((s) => { |
| 1538 | const sourceKey = sourceKeyForSource(s); | 1608 | const quality = sourceQuality[toolSourceLabelForSource(s)]; |
| 1539 | const quality = sourceKey ? sourceQuality[sourceKey] : undefined; | 1609 | const credentialStats = credentialStatsForSource(s, credentials ?? []); |
| 1540 | return ( | 1610 | return ( |
| 1541 | <SourceCard | 1611 | <SourceCard |
| 1542 | key={s.id} | 1612 | key={s.id} |
| 1543 | source={s} | 1613 | source={s} |
| 1544 | quality={quality} | 1614 | quality={quality} |
| 1545 | qualityLoading={toolsLoading} | 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 | function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string { | 66 | function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string { |
| 67 | const signatureVersion = "v14"; | 67 | const signatureVersion = "v15"; |
| 68 | const parts = sources | 68 | const parts = sources |
| 69 | .map((source) => `${source.id}:${source.updatedAt}:${source.enabled ? 1 : 0}`) | 69 | .map((source) => `${source.id}:${source.updatedAt}:${source.enabled ? 1 : 0}`) |
| 70 | .sort(); | 70 | .sort(); |
| @@ -176,6 +176,7 @@ function suggestToolPaths( · 105 unmodified lines | |||
| 176 | } | 176 | } |
| 177 | 177 | ||
| 178 | function normalizeExternalToolSource(raw: { | 178 | function normalizeExternalToolSource(raw: { |
| 179 | id: string; | ||
| 179 | type: ToolSourceRecord["type"]; | 180 | type: ToolSourceRecord["type"]; |
| 180 | name: string; | 181 | name: string; |
| 181 | config: Record<string, unknown>; | 182 | config: Record<string, unknown>; |
| @@ -211,6 +212,8 @@ function normalizeExternalToolSource(raw: { · 29 unmodified lines | |||
| 211 | const result: McpToolSourceConfig = { | 212 | const result: McpToolSourceConfig = { |
| 212 | type: "mcp", | 213 | type: "mcp", |
| 213 | name: raw.name, | 214 | name: raw.name, |
| 215 | sourceId: raw.id, | ||
| 216 | sourceKey: `source:${raw.id}`, | ||
| 214 | url: config.url, | 217 | url: config.url, |
| 215 | transport: config.transport as McpToolSourceConfig["transport"], | 218 | transport: config.transport as McpToolSourceConfig["transport"], |
| 216 | queryParams: config.queryParams as McpToolSourceConfig["queryParams"], | 219 | queryParams: config.queryParams as McpToolSourceConfig["queryParams"], |
| @@ -228,6 +231,8 @@ function normalizeExternalToolSource(raw: { · 11 unmodified lines | |||
| 228 | const result: GraphqlToolSourceConfig = { | 231 | const result: GraphqlToolSourceConfig = { |
| 229 | type: "graphql", | 232 | type: "graphql", |
| 230 | name: raw.name, | 233 | name: raw.name, |
| 234 | sourceId: raw.id, | ||
| 235 | sourceKey: `source:${raw.id}`, | ||
| 231 | endpoint: config.endpoint, | 236 | endpoint: config.endpoint, |
| 232 | schema: config.schema as GraphqlToolSourceConfig["schema"], | 237 | schema: config.schema as GraphqlToolSourceConfig["schema"], |
| 233 | auth: config.auth as GraphqlToolSourceConfig["auth"], | 238 | auth: config.auth as GraphqlToolSourceConfig["auth"], |
| @@ -246,6 +251,8 @@ function normalizeExternalToolSource(raw: { · 12 unmodified lines | |||
| 246 | const result: OpenApiToolSourceConfig = { | 251 | const result: OpenApiToolSourceConfig = { |
| 247 | type: "openapi", | 252 | type: "openapi", |
| 248 | name: raw.name, | 253 | name: raw.name, |
| 254 | sourceId: raw.id, | ||
| 255 | sourceKey: `source:${raw.id}`, | ||
| 249 | spec: spec as OpenApiToolSourceConfig["spec"], | 256 | spec: spec as OpenApiToolSourceConfig["spec"], |
| 250 | baseUrl: config.baseUrl as OpenApiToolSourceConfig["baseUrl"], | 257 | baseUrl: config.baseUrl as OpenApiToolSourceConfig["baseUrl"], |
| 251 | auth: config.auth as OpenApiToolSourceConfig["auth"], | 258 | auth: config.auth as OpenApiToolSourceConfig["auth"], |
| @@ -269,7 +276,7 @@ interface DtsStorageEntry { · 17 unmodified lines | |||
| 269 | const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000; | 276 | const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000; |
| 270 | 277 | ||
| 271 | /** Cache version — bump when PreparedOpenApiSpec shape changes. */ | 278 | /** Cache version — bump when PreparedOpenApiSpec shape changes. */ |
| 272 | const OPENAPI_CACHE_VERSION = "v14"; | 279 | const OPENAPI_CACHE_VERSION = "v15"; |
| 273 | 280 | ||
| 274 | async function publish( | 281 | async function publish( |
| 275 | ctx: ActionCtx, | 282 | ctx: ActionCtx, |
| @@ -10,7 +10,6 @@ import type { Id } from "./_generated/dataModel"; · 9 unmodified lines | |||
| 10 | 10 | ||
| 11 | const http = httpRouter(); | 11 | const http = httpRouter(); |
| 12 | const internalToken = process.env.EXECUTOR_INTERNAL_TOKEN ?? null; | 12 | const internalToken = process.env.EXECUTOR_INTERNAL_TOKEN ?? null; |
| 13 | const mcpJwksByServer = new Map<string, ReturnType<typeof createRemoteJWKSet>>(); | ||
| 14 | 13 | ||
| 15 | function getMcpAuthorizationServer(): string | null { | 14 | function getMcpAuthorizationServer(): string | null { |
| 16 | return process.env.MCP_AUTHORIZATION_SERVER | 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 | const jwks = createRemoteJWKSet(new URL("/oauth2/jwks", authorizationServer)); | 36 | const jwks = createRemoteJWKSet(new URL("/oauth2/jwks", authorizationServer)); |
| 47 | mcpJwksByServer.set(authorizationServer, jwks); | ||
| 48 | return { | 37 | return { |
| 49 | enabled: true, | 38 | enabled: true, |
| 50 | authorizationServer, | 39 | authorizationServer, |
| @@ -166,10 +166,20 @@ export const upsertToolSource = workspaceMutation({ · 165 unmodified lines | |||
| 166 | enabled: v.optional(v.boolean()), | 166 | enabled: v.optional(v.boolean()), |
| 167 | }, | 167 | }, |
| 168 | handler: async (ctx, args) => { | 168 | handler: async (ctx, args) => { |
| 169 | return await ctx.runMutation(internal.database.upsertToolSource, { | 169 | const source = await ctx.runMutation(internal.database.upsertToolSource, { |
| 170 | workspaceId: ctx.workspaceId, | 170 | workspaceId: ctx.workspaceId, |
| 171 | ...args, | 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 | requireAdmin: true, | 196 | requireAdmin: true, |
| 187 | args: { sourceId: v.string() }, | 197 | args: { sourceId: v.string() }, |
| 188 | handler: async (ctx, args) => { | 198 | handler: async (ctx, args) => { |
| 189 | return await ctx.runMutation(internal.database.deleteToolSource, { | 199 | const deleted = await ctx.runMutation(internal.database.deleteToolSource, { |
| 190 | workspaceId: ctx.workspaceId, | 200 | workspaceId: ctx.workspaceId, |
| 191 | sourceId: args.sourceId, | 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 | expect(Object.keys(prepared.paths)).toHaveLength(250); | 177 | expect(Object.keys(prepared.paths)).toHaveLength(250); |
| 178 | expect(prepared.dts).toBeDefined(); | 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 | const tools = buildOpenApiToolsFromPrepared( | 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 | test("resolves shared parameter refs and maps 204 responses to void", async () => { | 406 | test("resolves shared parameter refs and maps 204 responses to void", async () => { |
| 348 | const spec: Record<string, unknown> = { | 407 | const spec: Record<string, unknown> = { |
| 349 | openapi: "3.0.3", | 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 | type: "graphql", | 721 | type: "graphql", |
| 722 | name: "linear", | 722 | name: "linear", |
| 723 | sourceKey: "source:stable-linear-id", | ||
| 723 | endpoint: `http://127.0.0.1:${server.port}/graphql`, | 724 | endpoint: `http://127.0.0.1:${server.port}/graphql`, |
| 724 | auth: { | 725 | auth: { |
| 725 | type: "bearer", | 726 | type: "bearer", |
| @@ -734,12 +735,12 @@ test("graphql helper tools inherit credential spec from source auth", async () = · 8 unmodified lines | |||
| 734 | const helperTool = tools.find((tool) => tool.path === "linear.query.teams"); | 735 | const helperTool = tools.find((tool) => tool.path === "linear.query.teams"); |
| 735 | 736 | ||
| 736 | expect(rawTool?.credential).toEqual({ | 737 | expect(rawTool?.credential).toEqual({ |
| 737 | sourceKey: "graphql:linear", | 738 | sourceKey: "source:stable-linear-id", |
| 738 | mode: "actor", | 739 | mode: "actor", |
| 739 | authType: "bearer", | 740 | authType: "bearer", |
| 740 | }); | 741 | }); |
| 741 | expect(helperTool?.credential).toEqual({ | 742 | expect(helperTool?.credential).toEqual({ |
| 742 | sourceKey: "graphql:linear", | 743 | sourceKey: "source:stable-linear-id", |
| 743 | mode: "actor", | 744 | mode: "actor", |
| 744 | authType: "bearer", | 745 | authType: "bearer", |
| 745 | }); | 746 | }); |
| @@ -15,6 +15,8 @@ type JsonSchema = Record<string, unknown>; · 14 unmodified lines | |||
| 15 | export interface McpToolSourceConfig { | 15 | export interface McpToolSourceConfig { |
| 16 | type: "mcp"; | 16 | type: "mcp"; |
| 17 | name: string; | 17 | name: string; |
| 18 | sourceId?: string; | ||
| 19 | sourceKey?: string; | ||
| 18 | url: string; | 20 | url: string; |
| 19 | transport?: "sse" | "streamable-http"; | 21 | transport?: "sse" | "streamable-http"; |
| 20 | queryParams?: Record<string, string>; | 22 | queryParams?: Record<string, string>; |
| @@ -31,6 +33,8 @@ export type OpenApiAuth = · 10 unmodified lines | |||
| 31 | export interface OpenApiToolSourceConfig { | 33 | export interface OpenApiToolSourceConfig { |
| 32 | type: "openapi"; | 34 | type: "openapi"; |
| 33 | name: string; | 35 | name: string; |
| 36 | sourceId?: string; | ||
| 37 | sourceKey?: string; | ||
| 34 | spec: string | Record<string, unknown>; | 38 | spec: string | Record<string, unknown>; |
| 35 | baseUrl?: string; | 39 | baseUrl?: string; |
| 36 | auth?: OpenApiAuth; | 40 | auth?: OpenApiAuth; |
| @@ -42,6 +46,8 @@ export interface OpenApiToolSourceConfig { · 5 unmodified lines | |||
| 42 | export interface GraphqlToolSourceConfig { | 46 | export interface GraphqlToolSourceConfig { |
| 43 | type: "graphql"; | 47 | type: "graphql"; |
| 44 | name: string; | 48 | name: string; |
| 49 | sourceId?: string; | ||
| 50 | sourceKey?: string; | ||
| 45 | endpoint: string; | 51 | endpoint: string; |
| 46 | /** Optional static introspection result — if omitted, we introspect at load time */ | 52 | /** Optional static introspection result — if omitted, we introspect at load time */ |
| 47 | schema?: Record<string, unknown>; | 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 | function buildOpenApiUrl( | 668 | function buildOpenApiUrl( |
| 655 | baseUrl: string, | 669 | baseUrl: string, |
| 656 | pathTemplate: string, | 670 | pathTemplate: string, |
| @@ -866,9 +880,59 @@ export interface PreparedOpenApiSpec { · 209 unmodified lines | |||
| 866 | paths: Record<string, unknown>; | 880 | paths: Record<string, unknown>; |
| 867 | /** Raw .d.ts from openapiTS — cached as-is, extracted lazily on read */ | 881 | /** Raw .d.ts from openapiTS — cached as-is, extracted lazily on read */ |
| 868 | dts?: string; | 882 | dts?: string; |
| 883 | /** Auth inferred from OpenAPI security schemes when explicit source auth is not configured. */ | ||
| 884 | inferredAuth?: OpenApiAuth; | ||
| 869 | warnings: string[]; | 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 | export async function prepareOpenApiSpec( | 936 | export async function prepareOpenApiSpec( |
| 873 | spec: string | Record<string, unknown>, | 937 | spec: string | Record<string, unknown>, |
| 874 | sourceName = "openapi", | 938 | sourceName = "openapi", |
| @@ -917,6 +981,7 @@ export async function prepareOpenApiSpec( · 42 unmodified lines | |||
| 917 | const operationTypeIds = dts ? extractOperationIdsFromDts(dts) : new Set<string>(); | 981 | const operationTypeIds = dts ? extractOperationIdsFromDts(dts) : new Set<string>(); |
| 918 | 982 | ||
| 919 | const servers = Array.isArray(bundled.servers) ? (bundled.servers as Array<{ url?: unknown }>) : []; | 983 | const servers = Array.isArray(bundled.servers) ? (bundled.servers as Array<{ url?: unknown }>) : []; |
| 984 | const inferredAuth = inferOpenApiAuth(bundled); | ||
| 920 | 985 | ||
| 921 | return { | 986 | return { |
| 922 | servers: servers | 987 | servers: servers |
| @@ -931,6 +996,7 @@ export async function prepareOpenApiSpec( · 8 unmodified lines | |||
| 931 | asRecord(asRecord(bundled.components).requestBodies), | 996 | asRecord(asRecord(bundled.components).requestBodies), |
| 932 | ), | 997 | ), |
| 933 | dts: dts ?? undefined, | 998 | dts: dts ?? undefined, |
| 999 | ...(inferredAuth ? { inferredAuth } : {}), | ||
| 934 | warnings, | 1000 | warnings, |
| 935 | }; | 1001 | }; |
| 936 | } | 1002 | } |
| @@ -944,9 +1010,11 @@ export function buildOpenApiToolsFromPrepared( · 7 unmodified lines | |||
| 944 | throw new Error(`OpenAPI source ${config.name} has no base URL (set baseUrl)`); | 1010 | throw new Error(`OpenAPI source ${config.name} has no base URL (set baseUrl)`); |
| 945 | } | 1011 | } |
| 946 | 1012 | ||
| 947 | const authHeaders = buildStaticAuthHeaders(config.auth); | 1013 | const effectiveAuth = config.auth ?? prepared.inferredAuth; |
| 948 | const sourceKey = `openapi:${config.name}`; | 1014 | const authHeaders = buildStaticAuthHeaders(effectiveAuth); |
| 949 | const credentialSpec = buildCredentialSpec(sourceKey, config.auth); | 1015 | const sourceLabel = `openapi:${config.name}`; |
| 1016 | const credentialSourceKey = getCredentialSourceKey(config); | ||
| 1017 | const credentialSpec = buildCredentialSpec(credentialSourceKey, effectiveAuth); | ||
| 950 | const paths = asRecord(prepared.paths); | 1018 | const paths = asRecord(prepared.paths); |
| 951 | const tools: ToolDefinition[] = []; | 1019 | const tools: ToolDefinition[] = []; |
| 952 | 1020 | ||
| @@ -1064,7 +1132,7 @@ export function buildOpenApiToolsFromPrepared( · 111 unmodified lines | |||
| 1064 | 1132 | ||
| 1065 | const tool: ToolDefinition & { _runSpec: SerializedTool["runSpec"] } = { | 1133 | const tool: ToolDefinition & { _runSpec: SerializedTool["runSpec"] } = { |
| 1066 | path: buildOpenApiToolPath(config.name, tagRaw, operationIdRaw, usedToolPaths), | 1134 | path: buildOpenApiToolPath(config.name, tagRaw, operationIdRaw, usedToolPaths), |
| 1067 | source: sourceKey, | 1135 | source: sourceLabel, |
| 1068 | approval, | 1136 | approval, |
| 1069 | description: String(operation.summary ?? operation.description ?? `${method.toUpperCase()} ${pathTemplate}`), | 1137 | description: String(operation.summary ?? operation.description ?? `${method.toUpperCase()} ${pathTemplate}`), |
| 1070 | metadata: { | 1138 | metadata: { |
| @@ -1553,7 +1621,7 @@ export function parseGraphqlOperationPaths( · 482 unmodified lines | |||
| 1553 | async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise<ToolDefinition[]> { | 1621 | async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise<ToolDefinition[]> { |
| 1554 | const authHeaders = buildStaticAuthHeaders(config.auth); | 1622 | const authHeaders = buildStaticAuthHeaders(config.auth); |
| 1555 | const sourceKey = `graphql:${config.name}`; | 1623 | const sourceKey = `graphql:${config.name}`; |
| 1556 | const credentialSpec = buildCredentialSpec(sourceKey, config.auth); | 1624 | const credentialSpec = buildCredentialSpec(getCredentialSourceKey(config), config.auth); |
| 1557 | const sourceName = sanitizeSegment(config.name); | 1625 | const sourceName = sanitizeSegment(config.name); |
| 1558 | 1626 | ||
| 1559 | // Introspect the schema | 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 | } |