History

Stabilize source credential linking and remove OpenAPI fast-mode remnants

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.

Rhys Sullivan committed 9 months ago

Commit 9f5774e1 parent 11b3a889 files · +357 −74Browse files

Unified · Split

9 files
  1. executor/apps/web/src/components/tools-view.tsx +121 −50
  2. executor/convex/executorNode.github-profile.e2e.test.ts +61 −0
  3. executor/convex/executorNode.ts +9 −2
  4. executor/convex/http.ts +0 −11
  5. executor/convex/workspace.ts +22 −2
  6. executor/lib/openapi-cache.test.ts +60 −1
  7. executor/lib/tool-sources.test.ts +3 −2
  8. executor/lib/tool_sources.ts +73 −5
  9. opencode.json +8 −1
executor/apps/web/src/components/tools-view.tsx+121−50
@@ -5,7 +5,6 @@ import { · 4 unmodified lines
55 Wrench,
66 Plus,
77 Trash2,
8− ShieldCheck,
98 Globe,
109 Server,
1110 ChevronRight,
@@ -50,7 +49,6 @@ import { useAction, useMutation, useQuery } from "convex/react"; · 38 unmodified lines
5049 import { convexApi } from "@/lib/convex-api";
5150 import type {
5251 ToolSourceRecord,
53− ToolDescriptor,
5452 CredentialRecord,
5553 CredentialScope,
5654 OpenApiSourceQuality,
@@ -222,54 +220,44 @@ function faviconForUrl(url: string | undefined | null): string | null { · 165 unmodified lines
222220 }
223221 }
224222
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−
253223 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);
255231 }
256232
257233 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);
265241 }
266242
267243 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}`;
270246 return null;
271247 }
272248
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+
273261 type SourceAuthType = "none" | "bearer" | "apiKey" | "basic";
274262 type SourceAuthMode = "workspace" | "actor";
275263
@@ -311,6 +299,26 @@ function formatSourceAuthBadge(source: ToolSourceRecord): string | null { · 35 unmodified lines
311299 return `${auth.type}:${mode}`;
312300 }
313301
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+
314322 function formatQualityPercent(value: number): string {
315323 return `${Math.round(value * 100)}%`;
316324 }
@@ -816,10 +824,12 @@ function SourceCard({ · 499 unmodified lines
816824 source,
817825 quality,
818826 qualityLoading,
827+ credentialStats,
819828 }: {
820829 source: ToolSourceRecord;
821830 quality?: OpenApiSourceQuality;
822831 qualityLoading?: boolean;
832+ credentialStats: { workspaceCount: number; actorCount: number };
823833 }) {
824834 const { context } = useSession();
825835 const deleteToolSource = useMutation(convexApi.workspace.deleteToolSource);
@@ -845,6 +855,9 @@ function SourceCard({ · 19 unmodified lines
845855 const TypeIcon = source.type === "mcp" ? Server : Globe;
846856 const favicon = getSourceFavicon(source);
847857 const authBadge = formatSourceAuthBadge(source);
858+ const auth = readSourceAuth(source);
859+ const hasAuthConfigured = auth.type !== "none";
860+ const totalCredentials = credentialStats.workspaceCount + credentialStats.actorCount;
848861
849862 return (
850863 <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
882895 {authBadge}
883896 </Badge>
884897 )}
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+ )}
885911 {source.type === "openapi" && quality && (
886912 <Badge
887913 variant="outline"
@@ -936,7 +962,7 @@ function formatCredentialSecret(secretJson: Record<string, unknown>): string { · 48 unmodified lines
936962 }
937963 }
938964
939−type SourceOption = { source: ToolSourceRecord; key: string };
965+type SourceOption = { source: ToolSourceRecord; key: string; label: string };
940966
941967 function sourceAuthForKey(sourceOptions: SourceOption[], key: string): {
942968 type: SourceAuthType;
@@ -950,6 +976,10 @@ function sourceAuthForKey(sourceOptions: SourceOption[], key: string): { · 7 unmodified lines
950976 return readSourceAuth(match.source);
951977 }
952978
979+function sourceOptionLabel(source: ToolSourceRecord): string {
980+ return `${source.name} (${source.type})`;
981+}
982+
953983 function parseJsonObject(text: string): { value?: Record<string, unknown>; error?: string } {
954984 try {
955985 const parsed = JSON.parse(text);
@@ -996,8 +1026,15 @@ function CredentialsPanel({ · 40 unmodified lines
9961026 const [secretJsonText, setSecretJsonText] = useState("{}");
9971027
9981028 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);
10011038
10021039 const selectedAuth = sourceAuthForKey(sourceOptions, sourceKey);
10031040 const selectedAuthBadge = selectedAuth.type === "none"
@@ -1213,9 +1250,39 @@ function CredentialsPanel({ · 209 unmodified lines
12131250 key={`${credential.sourceKey}:${credential.scope}:${credential.actorId ?? "workspace"}`}
12141251 className="flex items-center gap-3 px-3 py-2.5 rounded-md bg-muted/40"
12151252 >
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+ })()}
12161266 <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+ })()}
12171285 <div className="flex items-center gap-2 flex-wrap">
1218− <span className="text-sm font-mono font-medium">{credential.sourceKey}</span>
12191286 <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">
12201287 {credential.scope}
12211288 </Badge>
@@ -1255,7 +1322,7 @@ function CredentialsPanel({ · 33 unmodified lines
12551322 </DialogHeader>
12561323 <div className="space-y-3">
12571324 <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>
12591326 {sourceOptions.length > 0 ? (
12601327 <Select value={sourceKey} onValueChange={handleSourceKeyChange}>
12611328 <SelectTrigger className="h-8 text-xs bg-background">
@@ -1263,8 +1330,8 @@ function CredentialsPanel({ · 1 unmodified line
12631330 </SelectTrigger>
12641331 <SelectContent>
12651332 {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}
12681335 </SelectItem>
12691336 ))}
12701337 </SelectContent>
@@ -1273,10 +1340,13 @@ function CredentialsPanel({ · 2 unmodified lines
12731340 <Input
12741341 value={sourceKey}
12751342 onChange={(e) => handleSourceKeyChange(e.target.value)}
1276− placeholder="openapi:github"
1343+ placeholder="source:<source-id>"
12771344 className="h-8 text-xs font-mono bg-background"
12781345 />
12791346 )}
1347+ {sourceKey && (
1348+ <p className="text-[10px] text-muted-foreground font-mono">key: {sourceKey}</p>
1349+ )}
12801350 </div>
12811351
12821352 <div className="flex items-center gap-2 flex-wrap">
@@ -1535,14 +1605,15 @@ export function ToolsView({ initialSource }: { initialSource?: string | null }) · 252 unmodified lines
15351605 </div>
15361606 )}
15371607 {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 ?? []);
15401610 return (
15411611 <SourceCard
15421612 key={s.id}
15431613 source={s}
15441614 quality={quality}
15451615 qualityLoading={toolsLoading}
1616+ credentialStats={credentialStats}
15461617 />
15471618 );
15481619 })}
executor/convex/executorNode.github-profile.e2e.test.ts+61−0
@@ -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);
executor/convex/executorNode.ts+9−2
@@ -64,7 +64,7 @@ function policySpecificity(policy: AccessPolicyRecord, actorId?: string, clientI · 63 unmodified lines
6464 }
6565
6666 function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string {
67− const signatureVersion = "v14";
67+ const signatureVersion = "v15";
6868 const parts = sources
6969 .map((source) => `${source.id}:${source.updatedAt}:${source.enabled ? 1 : 0}`)
7070 .sort();
@@ -176,6 +176,7 @@ function suggestToolPaths( · 105 unmodified lines
176176 }
177177
178178 function normalizeExternalToolSource(raw: {
179+ id: string;
179180 type: ToolSourceRecord["type"];
180181 name: string;
181182 config: Record<string, unknown>;
@@ -211,6 +212,8 @@ function normalizeExternalToolSource(raw: { · 29 unmodified lines
211212 const result: McpToolSourceConfig = {
212213 type: "mcp",
213214 name: raw.name,
215+ sourceId: raw.id,
216+ sourceKey: `source:${raw.id}`,
214217 url: config.url,
215218 transport: config.transport as McpToolSourceConfig["transport"],
216219 queryParams: config.queryParams as McpToolSourceConfig["queryParams"],
@@ -228,6 +231,8 @@ function normalizeExternalToolSource(raw: { · 11 unmodified lines
228231 const result: GraphqlToolSourceConfig = {
229232 type: "graphql",
230233 name: raw.name,
234+ sourceId: raw.id,
235+ sourceKey: `source:${raw.id}`,
231236 endpoint: config.endpoint,
232237 schema: config.schema as GraphqlToolSourceConfig["schema"],
233238 auth: config.auth as GraphqlToolSourceConfig["auth"],
@@ -246,6 +251,8 @@ function normalizeExternalToolSource(raw: { · 12 unmodified lines
246251 const result: OpenApiToolSourceConfig = {
247252 type: "openapi",
248253 name: raw.name,
254+ sourceId: raw.id,
255+ sourceKey: `source:${raw.id}`,
249256 spec: spec as OpenApiToolSourceConfig["spec"],
250257 baseUrl: config.baseUrl as OpenApiToolSourceConfig["baseUrl"],
251258 auth: config.auth as OpenApiToolSourceConfig["auth"],
@@ -269,7 +276,7 @@ interface DtsStorageEntry { · 17 unmodified lines
269276 const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;
270277
271278 /** Cache version — bump when PreparedOpenApiSpec shape changes. */
272−const OPENAPI_CACHE_VERSION = "v14";
279+const OPENAPI_CACHE_VERSION = "v15";
273280
274281 async function publish(
275282 ctx: ActionCtx,
executor/convex/http.ts+0−11
@@ -10,7 +10,6 @@ import type { Id } from "./_generated/dataModel"; · 9 unmodified lines
1010
1111 const http = httpRouter();
1212 const internalToken = process.env.EXECUTOR_INTERNAL_TOKEN ?? null;
13−const mcpJwksByServer = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
1413
1514 function getMcpAuthorizationServer(): string | null {
1615 return process.env.MCP_AUTHORIZATION_SERVER
@@ -34,17 +33,7 @@ function getMcpAuthConfig(): { · 17 unmodified lines
3433 };
3534 }
3635
37− const existingJwks = mcpJwksByServer.get(authorizationServer);
38− if (existingJwks) {
39− return {
40− enabled: true,
41− authorizationServer,
42− jwks: existingJwks,
43− };
44− }
45−
4636 const jwks = createRemoteJWKSet(new URL("/oauth2/jwks", authorizationServer));
47− mcpJwksByServer.set(authorizationServer, jwks);
4837 return {
4938 enabled: true,
5039 authorizationServer,
executor/convex/workspace.ts+22−2
@@ -166,10 +166,20 @@ export const upsertToolSource = workspaceMutation({ · 165 unmodified lines
166166 enabled: v.optional(v.boolean()),
167167 },
168168 handler: async (ctx, args) => {
169− return await ctx.runMutation(internal.database.upsertToolSource, {
169+ const source = await ctx.runMutation(internal.database.upsertToolSource, {
170170 workspaceId: ctx.workspaceId,
171171 ...args,
172172 });
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;
173183 },
174184 });
175185
@@ -186,9 +196,19 @@ export const deleteToolSource = workspaceMutation({ · 10 unmodified lines
186196 requireAdmin: true,
187197 args: { sourceId: v.string() },
188198 handler: async (ctx, args) => {
189− return await ctx.runMutation(internal.database.deleteToolSource, {
199+ const deleted = await ctx.runMutation(internal.database.deleteToolSource, {
190200 workspaceId: ctx.workspaceId,
191201 sourceId: args.sourceId,
192202 });
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;
193213 },
194214 });
executor/lib/openapi-cache.test.ts+60−1
@@ -176,7 +176,7 @@ describe("prepareOpenApiSpec with large specs", () => { · 175 unmodified lines
176176
177177 expect(Object.keys(prepared.paths)).toHaveLength(250);
178178 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);
180180
181181 const tools = buildOpenApiToolsFromPrepared(
182182 {
@@ -344,6 +344,65 @@ describe("buildOpenApiToolsFromPrepared", () => { · 161 unmodified lines
344344 );
345345 });
346346
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+
347406 test("resolves shared parameter refs and maps 204 responses to void", async () => {
348407 const spec: Record<string, unknown> = {
349408 openapi: "3.0.3",
executor/lib/tool-sources.test.ts+3−2
@@ -720,6 +720,7 @@ test("graphql helper tools inherit credential spec from source auth", async () = · 719 unmodified lines
720720 {
721721 type: "graphql",
722722 name: "linear",
723+ sourceKey: "source:stable-linear-id",
723724 endpoint: `http://127.0.0.1:${server.port}/graphql`,
724725 auth: {
725726 type: "bearer",
@@ -734,12 +735,12 @@ test("graphql helper tools inherit credential spec from source auth", async () = · 8 unmodified lines
734735 const helperTool = tools.find((tool) => tool.path === "linear.query.teams");
735736
736737 expect(rawTool?.credential).toEqual({
737− sourceKey: "graphql:linear",
738+ sourceKey: "source:stable-linear-id",
738739 mode: "actor",
739740 authType: "bearer",
740741 });
741742 expect(helperTool?.credential).toEqual({
742− sourceKey: "graphql:linear",
743+ sourceKey: "source:stable-linear-id",
743744 mode: "actor",
744745 authType: "bearer",
745746 });
executor/lib/tool_sources.ts+73−5
@@ -15,6 +15,8 @@ type JsonSchema = Record<string, unknown>; · 14 unmodified lines
1515 export interface McpToolSourceConfig {
1616 type: "mcp";
1717 name: string;
18+ sourceId?: string;
19+ sourceKey?: string;
1820 url: string;
1921 transport?: "sse" | "streamable-http";
2022 queryParams?: Record<string, string>;
@@ -31,6 +33,8 @@ export type OpenApiAuth = · 10 unmodified lines
3133 export interface OpenApiToolSourceConfig {
3234 type: "openapi";
3335 name: string;
36+ sourceId?: string;
37+ sourceKey?: string;
3438 spec: string | Record<string, unknown>;
3539 baseUrl?: string;
3640 auth?: OpenApiAuth;
@@ -42,6 +46,8 @@ export interface OpenApiToolSourceConfig { · 5 unmodified lines
4246 export interface GraphqlToolSourceConfig {
4347 type: "graphql";
4448 name: string;
49+ sourceId?: string;
50+ sourceKey?: string;
4551 endpoint: string;
4652 /** Optional static introspection result — if omitted, we introspect at load time */
4753 schema?: Record<string, unknown>;
@@ -651,6 +657,14 @@ function buildCredentialSpec(sourceKey: string, auth?: OpenApiAuth): ToolCredent · 603 unmodified lines
651657 };
652658 }
653659
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+
654668 function buildOpenApiUrl(
655669 baseUrl: string,
656670 pathTemplate: string,
@@ -866,9 +880,59 @@ export interface PreparedOpenApiSpec { · 209 unmodified lines
866880 paths: Record<string, unknown>;
867881 /** Raw .d.ts from openapiTS — cached as-is, extracted lazily on read */
868882 dts?: string;
883+ /** Auth inferred from OpenAPI security schemes when explicit source auth is not configured. */
884+ inferredAuth?: OpenApiAuth;
869885 warnings: string[];
870886 }
871887
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+
872936 export async function prepareOpenApiSpec(
873937 spec: string | Record<string, unknown>,
874938 sourceName = "openapi",
@@ -917,6 +981,7 @@ export async function prepareOpenApiSpec( · 42 unmodified lines
917981 const operationTypeIds = dts ? extractOperationIdsFromDts(dts) : new Set<string>();
918982
919983 const servers = Array.isArray(bundled.servers) ? (bundled.servers as Array<{ url?: unknown }>) : [];
984+ const inferredAuth = inferOpenApiAuth(bundled);
920985
921986 return {
922987 servers: servers
@@ -931,6 +996,7 @@ export async function prepareOpenApiSpec( · 8 unmodified lines
931996 asRecord(asRecord(bundled.components).requestBodies),
932997 ),
933998 dts: dts ?? undefined,
999+ ...(inferredAuth ? { inferredAuth } : {}),
9341000 warnings,
9351001 };
9361002 }
@@ -944,9 +1010,11 @@ export function buildOpenApiToolsFromPrepared( · 7 unmodified lines
9441010 throw new Error(`OpenAPI source ${config.name} has no base URL (set baseUrl)`);
9451011 }
9461012
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);
9501018 const paths = asRecord(prepared.paths);
9511019 const tools: ToolDefinition[] = [];
9521020
@@ -1064,7 +1132,7 @@ export function buildOpenApiToolsFromPrepared( · 111 unmodified lines
10641132
10651133 const tool: ToolDefinition & { _runSpec: SerializedTool["runSpec"] } = {
10661134 path: buildOpenApiToolPath(config.name, tagRaw, operationIdRaw, usedToolPaths),
1067− source: sourceKey,
1135+ source: sourceLabel,
10681136 approval,
10691137 description: String(operation.summary ?? operation.description ?? `${method.toUpperCase()} ${pathTemplate}`),
10701138 metadata: {
@@ -1553,7 +1621,7 @@ export function parseGraphqlOperationPaths( · 482 unmodified lines
15531621 async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise<ToolDefinition[]> {
15541622 const authHeaders = buildStaticAuthHeaders(config.auth);
15551623 const sourceKey = `graphql:${config.name}`;
1556− const credentialSpec = buildCredentialSpec(sourceKey, config.auth);
1624+ const credentialSpec = buildCredentialSpec(getCredentialSourceKey(config), config.auth);
15571625 const sourceName = sanitizeSegment(config.name);
15581626
15591627 // Introspect the schema
opencode.json+8−1
@@ -1,3 +1,10 @@
11 {
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+ }
310 }