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 95f7daa1 parent 5abc0a89 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
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
50import { convexApi } from "@/lib/convex-api";49import { convexApi } from "@/lib/convex-api";
51import type {50import 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
225function 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
236function 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
253function getFaviconUrl(preset: ApiPreset): string | null {223function 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
257function getSourceFavicon(source: ToolSourceRecord): string | null {233function 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
267function sourceKeyForSource(source: ToolSourceRecord): string | null {243function 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
249function toolSourceLabelForSource(source: ToolSourceRecord): string {
250 return `${source.type}:${source.name}`;
251}
252
253function 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
273type SourceAuthType = "none" | "bearer" | "apiKey" | "basic";261type SourceAuthType = "none" | "bearer" | "apiKey" | "basic";
274type SourceAuthMode = "workspace" | "actor";262type 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
302function 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
314function formatQualityPercent(value: number): string {322function 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 <Badge912 <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
939type SourceOption = { source: ToolSourceRecord; key: string };965type SourceOption = { source: ToolSourceRecord; key: string; label: string };
940 966
941function sourceAuthForKey(sourceOptions: SourceOption[], key: string): {967function 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
979function sourceOptionLabel(source: ToolSourceRecord): string {
980 return `${source.name} (${source.type})`;
981}
982
953function parseJsonObject(text: string): { value?: Record<string, unknown>; error?: string } {983function 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 = sources1028 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 <Input1340 <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 <SourceCard1611 <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 })}
executor/convex/executorNode.github-profile.e2e.test.ts+61−0
@@ -0,0 +1,61 @@
1import { expect, test } from "bun:test";
2import { convexTest } from "convex-test";
3import { internal } from "./_generated/api";
4import schema from "./schema";
5
6const 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
9function 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
19test("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
64}64}
65 65
66function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string {66function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string {
67 const signatureVersion = "v14";67 const signatureVersion = "v15";
68 const parts = sources68 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
178function normalizeExternalToolSource(raw: {178function 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
269const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;276const 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. */
272const OPENAPI_CACHE_VERSION = "v14";279const OPENAPI_CACHE_VERSION = "v15";
273 280
274async function publish(281async function publish(
275 ctx: ActionCtx,282 ctx: ActionCtx,
executor/convex/http.ts+0−11
@@ -10,7 +10,6 @@ import type { Id } from "./_generated/dataModel"; · 9 unmodified lines
10 10
11const http = httpRouter();11const http = httpRouter();
12const internalToken = process.env.EXECUTOR_INTERNAL_TOKEN ?? null;12const internalToken = process.env.EXECUTOR_INTERNAL_TOKEN ?? null;
13const mcpJwksByServer = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
14 13
15function getMcpAuthorizationServer(): string | null {14function getMcpAuthorizationServer(): string | null {
16 return process.env.MCP_AUTHORIZATION_SERVER15 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,
executor/convex/workspace.ts+22−2
@@ -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});
executor/lib/openapi-cache.test.ts+60−1
@@ -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",
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
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 });
executor/lib/tool_sources.ts+73−5
@@ -15,6 +15,8 @@ type JsonSchema = Record<string, unknown>; · 14 unmodified lines
15export interface McpToolSourceConfig {15export 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
31export interface OpenApiToolSourceConfig {33export 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
42export interface GraphqlToolSourceConfig {46export 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
660function getCredentialSourceKey(config: {
661 type: ExternalToolSourceConfig["type"];
662 name: string;
663 sourceKey?: string;
664}): string {
665 return config.sourceKey ?? `${config.type}:${config.name}`;
666}
667
654function buildOpenApiUrl(668function 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
888function 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
872export async function prepareOpenApiSpec(936export 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: servers987 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
1553async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise<ToolDefinition[]> {1621async 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 schema1627 // Introspect the schema
opencode.json+8−1
@@ -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}