- Removed unnecessary MCP configuration from opencode.json. - Introduced a function to append authentication query parameters in the sign-in route. - Enhanced the ToolsView component to display OpenAPI source quality metrics. - Updated the useWorkspaceTools hook to return source quality data. - Added type definitions for OpenApiSourceQuality to improve type fidelity across the application.
Unified · Split
| @@ -21,6 +21,26 @@ function readOptionalQueryParam(request: NextRequest, keys: string[]): string | · 20 unmodified lines | |||
| 21 | return undefined; | 21 | return undefined; |
| 22 | } | 22 | } |
| 23 | 23 | ||
| 24 | const AUTHKIT_PASSTHROUGH_QUERY_KEYS = [ | ||
| 25 | "authorization_session_id", | ||
| 26 | "redirect_uri", | ||
| 27 | "state", | ||
| 28 | ]; | ||
| 29 | |||
| 30 | function appendAuthkitPassthroughQueryParams(request: NextRequest, authorizationUrl: string): string { | ||
| 31 | const nextUrl = new URL(authorizationUrl); | ||
| 32 | |||
| 33 | for (const key of AUTHKIT_PASSTHROUGH_QUERY_KEYS) { | ||
| 34 | const value = request.nextUrl.searchParams.get(key); | ||
| 35 | if (!value || value.trim().length === 0) { | ||
| 36 | continue; | ||
| 37 | } | ||
| 38 | nextUrl.searchParams.set(key, value.trim()); | ||
| 39 | } | ||
| 40 | |||
| 41 | return nextUrl.toString(); | ||
| 42 | } | ||
| 43 | |||
| 24 | const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL ?? process.env.CONVEX_URL; | 44 | const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL ?? process.env.CONVEX_URL; |
| 25 | const convexClient = convexUrl ? new ConvexHttpClient(convexUrl) : null; | 45 | const convexClient = convexUrl ? new ConvexHttpClient(convexUrl) : null; |
| 26 | 46 | ||
| @@ -65,10 +85,12 @@ export async function GET(request: NextRequest) { · 38 unmodified lines | |||
| 65 | const loginHint = readOptionalQueryParam(request, ["loginHint", "login_hint", "email"]); | 85 | const loginHint = readOptionalQueryParam(request, ["loginHint", "login_hint", "email"]); |
| 66 | 86 | ||
| 67 | const { getSignInUrl } = await import("@workos-inc/authkit-nextjs"); | 87 | const { getSignInUrl } = await import("@workos-inc/authkit-nextjs"); |
| 68 | const authorizationUrl = await getSignInUrl({ | 88 | const baseAuthorizationUrl = await getSignInUrl({ |
| 69 | redirectUri, | 89 | redirectUri, |
| 70 | organizationId, | 90 | organizationId, |
| 71 | loginHint, | 91 | loginHint, |
| 72 | }); | 92 | }); |
| 93 | |||
| 94 | const authorizationUrl = appendAuthkitPassthroughQueryParams(request, baseAuthorizationUrl); | ||
| 73 | return redirect(authorizationUrl); | 95 | return redirect(authorizationUrl); |
| 74 | } | 96 | } |
| @@ -48,7 +48,13 @@ import { useSession } from "@/lib/session-context"; · 47 unmodified lines | |||
| 48 | import { useWorkspaceTools } from "@/hooks/use-workspace-tools"; | 48 | import { useWorkspaceTools } from "@/hooks/use-workspace-tools"; |
| 49 | import { useAction, useMutation, useQuery } from "convex/react"; | 49 | import { useAction, useMutation, useQuery } from "convex/react"; |
| 50 | import { convexApi } from "@/lib/convex-api"; | 50 | import { convexApi } from "@/lib/convex-api"; |
| 51 | import type { ToolSourceRecord, ToolDescriptor, CredentialRecord, CredentialScope } from "@/lib/types"; | 51 | import type { |
| 52 | ToolSourceRecord, | ||
| 53 | ToolDescriptor, | ||
| 54 | CredentialRecord, | ||
| 55 | CredentialScope, | ||
| 56 | OpenApiSourceQuality, | ||
| 57 | } from "@/lib/types"; | ||
| 52 | import { parse as parseDomain } from "tldts"; | 58 | import { parse as parseDomain } from "tldts"; |
| 53 | import { toast } from "sonner"; | 59 | import { toast } from "sonner"; |
| 54 | import { cn } from "@/lib/utils"; | 60 | import { cn } from "@/lib/utils"; |
| @@ -296,6 +302,20 @@ function formatSourceAuthBadge(source: ToolSourceRecord): string | null { · 241 unmodified lines | |||
| 296 | return `${auth.type}:${mode}`; | 302 | return `${auth.type}:${mode}`; |
| 297 | } | 303 | } |
| 298 | 304 | ||
| 305 | function formatQualityPercent(value: number): string { | ||
| 306 | return `${Math.round(value * 100)}%`; | ||
| 307 | } | ||
| 308 | |||
| 309 | function qualityBadgeClass(quality: OpenApiSourceQuality): string { | ||
| 310 | if (quality.overallQuality >= 0.95) { | ||
| 311 | return "text-terminal-green border-terminal-green/30"; | ||
| 312 | } | ||
| 313 | if (quality.overallQuality >= 0.85) { | ||
| 314 | return "text-terminal-amber border-terminal-amber/30"; | ||
| 315 | } | ||
| 316 | return "text-terminal-red border-terminal-red/30"; | ||
| 317 | } | ||
| 318 | |||
| 299 | // ── Add Source Dialog ── | 319 | // ── Add Source Dialog ── |
| 300 | 320 | ||
| 301 | const RAW_HOSTS = new Set([ | 321 | const RAW_HOSTS = new Set([ |
| @@ -785,8 +805,12 @@ function ConfigureSourceAuthDialog({ · 483 unmodified lines | |||
| 785 | 805 | ||
| 786 | function SourceCard({ | 806 | function SourceCard({ |
| 787 | source, | 807 | source, |
| 808 | quality, | ||
| 809 | qualityLoading, | ||
| 788 | }: { | 810 | }: { |
| 789 | source: ToolSourceRecord; | 811 | source: ToolSourceRecord; |
| 812 | quality?: OpenApiSourceQuality; | ||
| 813 | qualityLoading?: boolean; | ||
| 790 | }) { | 814 | }) { |
| 791 | const { context } = useSession(); | 815 | const { context } = useSession(); |
| 792 | const deleteToolSource = useMutation(convexApi.workspace.deleteToolSource); | 816 | const deleteToolSource = useMutation(convexApi.workspace.deleteToolSource); |
| @@ -849,6 +873,14 @@ function SourceCard({ · 56 unmodified lines | |||
| 849 | {authBadge} | 873 | {authBadge} |
| 850 | </Badge> | 874 | </Badge> |
| 851 | )} | 875 | )} |
| 876 | {source.type === "openapi" && quality && ( | ||
| 877 | <Badge | ||
| 878 | variant="outline" | ||
| 879 | className={cn("text-[9px] font-mono uppercase tracking-wider", qualityBadgeClass(quality))} | ||
| 880 | > | ||
| 881 | quality {formatQualityPercent(quality.overallQuality)} | ||
| 882 | </Badge> | ||
| 883 | )} | ||
| 852 | </div> | 884 | </div> |
| 853 | <span className="text-[11px] text-muted-foreground font-mono truncate block"> | 885 | <span className="text-[11px] text-muted-foreground font-mono truncate block"> |
| 854 | {source.type === "mcp" | 886 | {source.type === "mcp" |
| @@ -857,6 +889,19 @@ function SourceCard({ · 2 unmodified lines | |||
| 857 | ? (source.config.endpoint as string) | 889 | ? (source.config.endpoint as string) |
| 858 | : (source.config.spec as string)} | 890 | : (source.config.spec as string)} |
| 859 | </span> | 891 | </span> |
| 892 | {source.type === "openapi" && quality && ( | ||
| 893 | <span className="text-[10px] text-muted-foreground/90 font-mono truncate block mt-0.5"> | ||
| 894 | args {formatQualityPercent(quality.argsQuality)} | returns {formatQualityPercent(quality.returnsQuality)} | ||
| 895 | {quality.unknownReturnsCount > 0 | ||
| 896 | ? ` | ${quality.unknownReturnsCount} unknown returns` | ||
| 897 | : " | fully typed returns"} | ||
| 898 | </span> | ||
| 899 | )} | ||
| 900 | {source.type === "openapi" && !quality && qualityLoading && ( | ||
| 901 | <span className="text-[10px] text-muted-foreground/70 font-mono truncate block mt-0.5"> | ||
| 902 | Computing OpenAPI type quality... | ||
| 903 | </span> | ||
| 904 | )} | ||
| 860 | </div> | 905 | </div> |
| 861 | <div className="flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity shrink-0"> | 906 | <div className="flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity shrink-0"> |
| 862 | <ConfigureSourceAuthDialog source={source} /> | 907 | <ConfigureSourceAuthDialog source={source} /> |
| @@ -1377,7 +1422,7 @@ export function ToolsView({ initialSource }: { initialSource?: string | null }) · 514 unmodified lines | |||
| 1377 | ); | 1422 | ); |
| 1378 | const credentialsLoading = !!context && credentials === undefined; | 1423 | const credentialsLoading = !!context && credentials === undefined; |
| 1379 | 1424 | ||
| 1380 | const { tools, warnings, loading: toolsLoading } = useWorkspaceTools(context ?? null); | 1425 | const { tools, warnings, sourceQuality, loading: toolsLoading } = useWorkspaceTools(context ?? null); |
| 1381 | 1426 | ||
| 1382 | if (sessionLoading) { | 1427 | if (sessionLoading) { |
| 1383 | return ( | 1428 | return ( |
| @@ -1476,9 +1521,18 @@ export function ToolsView({ initialSource }: { initialSource?: string | null }) · 92 unmodified lines | |||
| 1476 | </div> | 1521 | </div> |
| 1477 | </div> | 1522 | </div> |
| 1478 | )} | 1523 | )} |
| 1479 | {sources.map((s: any) => ( | 1524 | {sources.map((s: any) => { |
| 1480 | <SourceCard key={s.id} source={s} /> | 1525 | const sourceKey = sourceKeyForSource(s); |
| 1481 | ))} | 1526 | const quality = sourceKey ? sourceQuality[sourceKey] : undefined; |
| 1527 | return ( | ||
| 1528 | <SourceCard | ||
| 1529 | key={s.id} | ||
| 1530 | source={s} | ||
| 1531 | quality={quality} | ||
| 1532 | qualityLoading={toolsLoading} | ||
| 1533 | /> | ||
| 1534 | ); | ||
| 1535 | })} | ||
| 1482 | </div> | 1536 | </div> |
| 1483 | )} | 1537 | )} |
| 1484 | </CardContent> | 1538 | </CardContent> |
| @@ -3,6 +3,7 @@ · 2 unmodified lines | |||
| 3 | import { useQuery as useTanstackQuery } from "@tanstack/react-query"; | 3 | import { useQuery as useTanstackQuery } from "@tanstack/react-query"; |
| 4 | import { useAction, useQuery as useConvexQuery } from "convex/react"; | 4 | import { useAction, useQuery as useConvexQuery } from "convex/react"; |
| 5 | import { convexApi } from "@/lib/convex-api"; | 5 | import { convexApi } from "@/lib/convex-api"; |
| 6 | import type { OpenApiSourceQuality } from "@/lib/types"; | ||
| 6 | 7 | ||
| 7 | interface WorkspaceContext { | 8 | interface WorkspaceContext { |
| 8 | workspaceId: string; | 9 | workspaceId: string; |
| @@ -35,7 +36,7 @@ export function useWorkspaceTools(context: WorkspaceContext | null) { · 26 unmodified lines | |||
| 35 | toolSources, | 36 | toolSources, |
| 36 | ], | 37 | ], |
| 37 | queryFn: async () => { | 38 | queryFn: async () => { |
| 38 | if (!context) return { tools: [], warnings: [], dtsUrls: {} }; | 39 | if (!context) return { tools: [], warnings: [], dtsUrls: {}, sourceQuality: {} }; |
| 39 | return await listToolsWithWarnings({ | 40 | return await listToolsWithWarnings({ |
| 40 | workspaceId: context.workspaceId, | 41 | workspaceId: context.workspaceId, |
| 41 | ...(context.actorId && { actorId: context.actorId }), | 42 | ...(context.actorId && { actorId: context.actorId }), |
| @@ -51,6 +52,8 @@ export function useWorkspaceTools(context: WorkspaceContext | null) { · 9 unmodified lines | |||
| 51 | warnings: data?.warnings ?? [], | 52 | warnings: data?.warnings ?? [], |
| 52 | /** Per-source .d.ts download URLs for Monaco IntelliSense. Keyed by source key (e.g. "openapi:cloudflare"). */ | 53 | /** Per-source .d.ts download URLs for Monaco IntelliSense. Keyed by source key (e.g. "openapi:cloudflare"). */ |
| 53 | dtsUrls: (data as any)?.dtsUrls ?? {}, | 54 | dtsUrls: (data as any)?.dtsUrls ?? {}, |
| 55 | /** Per-source OpenAPI quality metrics (unknown/fallback type rates). */ | ||
| 56 | sourceQuality: ((data as any)?.sourceQuality ?? {}) as Record<string, OpenApiSourceQuality>, | ||
| 54 | loading: !!context && isLoading, | 57 | loading: !!context && isLoading, |
| 55 | }; | 58 | }; |
| 56 | } | 59 | } |
| @@ -15,6 +15,7 @@ export type { · 14 unmodified lines | |||
| 15 | CredentialRecord, | 15 | CredentialRecord, |
| 16 | ToolSourceRecord, | 16 | ToolSourceRecord, |
| 17 | ToolDescriptor, | 17 | ToolDescriptor, |
| 18 | OpenApiSourceQuality, | ||
| 18 | AnonymousContext, | 19 | AnonymousContext, |
| 19 | } from "@executor/contracts"; | 20 | } from "@executor/contracts"; |
| 20 | 21 | ||
| @@ -31,6 +31,7 @@ import type { · 30 unmodified lines | |||
| 31 | ToolCredentialSpec, | 31 | ToolCredentialSpec, |
| 32 | ToolDefinition, | 32 | ToolDefinition, |
| 33 | ToolDescriptor, | 33 | ToolDescriptor, |
| 34 | OpenApiSourceQuality, | ||
| 34 | ToolSourceRecord, | 35 | ToolSourceRecord, |
| 35 | ToolRunContext, | 36 | ToolRunContext, |
| 36 | } from "../lib/types"; | 37 | } from "../lib/types"; |
| @@ -56,7 +57,7 @@ function policySpecificity(policy: AccessPolicyRecord, actorId?: string, clientI · 19 unmodified lines | |||
| 56 | } | 57 | } |
| 57 | 58 | ||
| 58 | function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string { | 59 | function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string { |
| 59 | const signatureVersion = "v4"; | 60 | const signatureVersion = "v8"; |
| 60 | const parts = sources | 61 | const parts = sources |
| 61 | .map((source) => `${source.id}:${source.updatedAt}:${source.enabled ? 1 : 0}`) | 62 | .map((source) => `${source.id}:${source.updatedAt}:${source.enabled ? 1 : 0}`) |
| 62 | .sort(); | 63 | .sort(); |
| @@ -131,7 +132,7 @@ interface DtsStorageEntry { · 68 unmodified lines | |||
| 131 | const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000; | 132 | const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000; |
| 132 | 133 | ||
| 133 | /** Cache version — bump when PreparedOpenApiSpec shape changes. */ | 134 | /** Cache version — bump when PreparedOpenApiSpec shape changes. */ |
| 134 | const OPENAPI_CACHE_VERSION = "v7"; | 135 | const OPENAPI_CACHE_VERSION = "v11"; |
| 135 | 136 | ||
| 136 | async function publish( | 137 | async function publish( |
| 137 | ctx: any, | 138 | ctx: any, |
| @@ -419,6 +420,67 @@ function toToolDescriptor(tool: ToolDefinition, approval: "auto" | "required"): · 281 unmodified lines | |||
| 419 | }; | 420 | }; |
| 420 | } | 421 | } |
| 421 | 422 | ||
| 423 | function computeOpenApiSourceQuality( | ||
| 424 | workspaceTools: Map<string, ToolDefinition>, | ||
| 425 | ): Record<string, OpenApiSourceQuality> { | ||
| 426 | const grouped = new Map<string, ToolDefinition[]>(); | ||
| 427 | |||
| 428 | for (const tool of workspaceTools.values()) { | ||
| 429 | const sourceKey = tool.source; | ||
| 430 | if (!sourceKey || !sourceKey.startsWith("openapi:")) continue; | ||
| 431 | const list = grouped.get(sourceKey) ?? []; | ||
| 432 | list.push(tool); | ||
| 433 | grouped.set(sourceKey, list); | ||
| 434 | } | ||
| 435 | |||
| 436 | const qualityBySource: Record<string, OpenApiSourceQuality> = {}; | ||
| 437 | |||
| 438 | for (const [sourceKey, tools] of grouped.entries()) { | ||
| 439 | const toolCount = tools.length; | ||
| 440 | |||
| 441 | let unknownArgsCount = 0; | ||
| 442 | let unknownReturnsCount = 0; | ||
| 443 | let partialUnknownArgsCount = 0; | ||
| 444 | let partialUnknownReturnsCount = 0; | ||
| 445 | |||
| 446 | for (const tool of tools) { | ||
| 447 | const argsType = tool.metadata?.argsType?.trim() ?? ""; | ||
| 448 | const returnsType = tool.metadata?.returnsType?.trim() ?? ""; | ||
| 449 | |||
| 450 | if (!argsType || argsType === "Record<string, unknown>") { | ||
| 451 | unknownArgsCount += 1; | ||
| 452 | } | ||
| 453 | if (!returnsType || returnsType === "unknown") { | ||
| 454 | unknownReturnsCount += 1; | ||
| 455 | } | ||
| 456 | if (argsType.includes("unknown")) { | ||
| 457 | partialUnknownArgsCount += 1; | ||
| 458 | } | ||
| 459 | if (returnsType.includes("unknown")) { | ||
| 460 | partialUnknownReturnsCount += 1; | ||
| 461 | } | ||
| 462 | } | ||
| 463 | |||
| 464 | const argsQuality = toolCount > 0 ? (toolCount - unknownArgsCount) / toolCount : 1; | ||
| 465 | const returnsQuality = toolCount > 0 ? (toolCount - unknownReturnsCount) / toolCount : 1; | ||
| 466 | const overallQuality = (argsQuality + returnsQuality) / 2; | ||
| 467 | |||
| 468 | qualityBySource[sourceKey] = { | ||
| 469 | sourceKey, | ||
| 470 | toolCount, | ||
| 471 | unknownArgsCount, | ||
| 472 | unknownReturnsCount, | ||
| 473 | partialUnknownArgsCount, | ||
| 474 | partialUnknownReturnsCount, | ||
| 475 | argsQuality, | ||
| 476 | returnsQuality, | ||
| 477 | overallQuality, | ||
| 478 | }; | ||
| 479 | } | ||
| 480 | |||
| 481 | return qualityBySource; | ||
| 482 | } | ||
| 483 | |||
| 422 | function listVisibleToolDescriptors( | 484 | function listVisibleToolDescriptors( |
| 423 | workspaceTools: Map<string, ToolDefinition>, | 485 | workspaceTools: Map<string, ToolDefinition>, |
| 424 | context: { workspaceId: string; actorId?: string; clientId?: string }, | 486 | context: { workspaceId: string; actorId?: string; clientId?: string }, |
| @@ -453,13 +515,19 @@ async function listToolsForContext( · 28 unmodified lines | |||
| 453 | async function listToolsWithWarningsForContext( | 515 | async function listToolsWithWarningsForContext( |
| 454 | ctx: any, | 516 | ctx: any, |
| 455 | context: { workspaceId: string; actorId?: string; clientId?: string }, | 517 | context: { workspaceId: string; actorId?: string; clientId?: string }, |
| 456 | ): Promise<{ tools: ToolDescriptor[]; warnings: string[]; dtsUrls: Record<string, string> }> { | 518 | ): Promise<{ |
| 519 | tools: ToolDescriptor[]; | ||
| 520 | warnings: string[]; | ||
| 521 | dtsUrls: Record<string, string>; | ||
| 522 | sourceQuality: Record<string, OpenApiSourceQuality>; | ||
| 523 | }> { | ||
| 457 | const [result, policies] = await Promise.all([ | 524 | const [result, policies] = await Promise.all([ |
| 458 | getWorkspaceTools(ctx, context.workspaceId), | 525 | getWorkspaceTools(ctx, context.workspaceId), |
| 459 | ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: context.workspaceId }), | 526 | ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: context.workspaceId }), |
| 460 | ]); | 527 | ]); |
| 461 | const typedPolicies = policies as AccessPolicyRecord[]; | 528 | const typedPolicies = policies as AccessPolicyRecord[]; |
| 462 | const tools = listVisibleToolDescriptors(result.tools, context, typedPolicies); | 529 | const tools = listVisibleToolDescriptors(result.tools, context, typedPolicies); |
| 530 | const sourceQuality = computeOpenApiSourceQuality(result.tools); | ||
| 463 | 531 | ||
| 464 | // Generate download URLs for .d.ts blobs | 532 | // Generate download URLs for .d.ts blobs |
| 465 | const dtsUrls: Record<string, string> = {}; | 533 | const dtsUrls: Record<string, string> = {}; |
| @@ -476,6 +544,7 @@ async function listToolsWithWarningsForContext( · 10 unmodified lines | |||
| 476 | tools, | 544 | tools, |
| 477 | warnings: result.warnings, | 545 | warnings: result.warnings, |
| 478 | dtsUrls, | 546 | dtsUrls, |
| 547 | sourceQuality, | ||
| 479 | }; | 548 | }; |
| 480 | } | 549 | } |
| 481 | 550 | ||
| @@ -754,7 +823,12 @@ export const listToolsWithWarnings = action({ · 272 unmodified lines | |||
| 754 | handler: async ( | 823 | handler: async ( |
| 755 | ctx, | 824 | ctx, |
| 756 | args, | 825 | args, |
| 757 | ): Promise<{ tools: ToolDescriptor[]; warnings: string[]; dtsUrls: Record<string, string> }> => { | 826 | ): Promise<{ |
| 827 | tools: ToolDescriptor[]; | ||
| 828 | warnings: string[]; | ||
| 829 | dtsUrls: Record<string, string>; | ||
| 830 | sourceQuality: Record<string, OpenApiSourceQuality>; | ||
| 831 | }> => { | ||
| 758 | const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForRequest, { | 832 | const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForRequest, { |
| 759 | workspaceId: args.workspaceId, | 833 | workspaceId: args.workspaceId, |
| 760 | sessionId: args.sessionId, | 834 | sessionId: args.sessionId, |
| @@ -793,7 +867,15 @@ export const listToolsWithWarningsInternal = internalAction({ · 32 unmodified lines | |||
| 793 | actorId: v.optional(v.string()), | 867 | actorId: v.optional(v.string()), |
| 794 | clientId: v.optional(v.string()), | 868 | clientId: v.optional(v.string()), |
| 795 | }, | 869 | }, |
| 796 | handler: async (ctx, args): Promise<{ tools: ToolDescriptor[]; warnings: string[]; dtsUrls: Record<string, string> }> => { | 870 | handler: async ( |
| 871 | ctx, | ||
| 872 | args, | ||
| 873 | ): Promise<{ | ||
| 874 | tools: ToolDescriptor[]; | ||
| 875 | warnings: string[]; | ||
| 876 | dtsUrls: Record<string, string>; | ||
| 877 | sourceQuality: Record<string, OpenApiSourceQuality>; | ||
| 878 | }> => { | ||
| 797 | return await listToolsWithWarningsForContext(ctx, args); | 879 | return await listToolsWithWarningsForContext(ctx, args); |
| 798 | }, | 880 | }, |
| 799 | }); | 881 | }); |
| @@ -422,6 +422,345 @@ describe("buildOpenApiToolsFromPrepared", () => { · 421 unmodified lines | |||
| 422 | expect(tool!.metadata?.returnsType).not.toBe("unknown"); | 422 | expect(tool!.metadata?.returnsType).not.toBe("unknown"); |
| 423 | }); | 423 | }); |
| 424 | 424 | ||
| 425 | test("resolves OpenAPI component schema refs for return type hints", async () => { | ||
| 426 | const spec: Record<string, unknown> = { | ||
| 427 | openapi: "3.0.3", | ||
| 428 | info: { title: "Ref return type", version: "1.0.0" }, | ||
| 429 | servers: [{ url: "https://api.example.com" }], | ||
| 430 | components: { | ||
| 431 | schemas: { | ||
| 432 | Runner: { | ||
| 433 | type: "object", | ||
| 434 | properties: { | ||
| 435 | id: { type: "number" }, | ||
| 436 | name: { type: "string" }, | ||
| 437 | }, | ||
| 438 | required: ["id", "name"], | ||
| 439 | }, | ||
| 440 | }, | ||
| 441 | }, | ||
| 442 | paths: { | ||
| 443 | "/orgs/{org}/actions/hosted-runners": { | ||
| 444 | post: { | ||
| 445 | operationId: "actions/create-hosted-runner-for-org", | ||
| 446 | tags: ["actions"], | ||
| 447 | parameters: [ | ||
| 448 | { name: "org", in: "path", required: true, schema: { type: "string" } }, | ||
| 449 | ], | ||
| 450 | requestBody: { | ||
| 451 | required: true, | ||
| 452 | content: { | ||
| 453 | "application/json": { | ||
| 454 | schema: { | ||
| 455 | type: "object", | ||
| 456 | properties: { | ||
| 457 | name: { type: "string" }, | ||
| 458 | }, | ||
| 459 | required: ["name"], | ||
| 460 | }, | ||
| 461 | }, | ||
| 462 | }, | ||
| 463 | }, | ||
| 464 | responses: { | ||
| 465 | "201": { | ||
| 466 | description: "created", | ||
| 467 | content: { | ||
| 468 | "application/json": { | ||
| 469 | schema: { $ref: "#/components/schemas/Runner" }, | ||
| 470 | }, | ||
| 471 | }, | ||
| 472 | }, | ||
| 473 | }, | ||
| 474 | }, | ||
| 475 | }, | ||
| 476 | }, | ||
| 477 | }; | ||
| 478 | |||
| 479 | const prepared = await prepareOpenApiSpec(spec, "ref-return"); | ||
| 480 | const tools = buildOpenApiToolsFromPrepared( | ||
| 481 | { | ||
| 482 | type: "openapi", | ||
| 483 | name: "github", | ||
| 484 | spec, | ||
| 485 | baseUrl: "https://api.example.com", | ||
| 486 | }, | ||
| 487 | prepared, | ||
| 488 | ); | ||
| 489 | |||
| 490 | const tool = tools.find((t) => t.metadata?.operationId === "actions/create-hosted-runner-for-org"); | ||
| 491 | expect(tool).toBeDefined(); | ||
| 492 | expect(tool!.metadata?.returnsType).toContain("id"); | ||
| 493 | expect(tool!.metadata?.returnsType).toContain("name"); | ||
| 494 | expect(tool!.metadata?.returnsType).not.toBe("unknown"); | ||
| 495 | }); | ||
| 496 | |||
| 497 | test("resolves OpenAPI component response refs for return type hints", async () => { | ||
| 498 | const spec: Record<string, unknown> = { | ||
| 499 | openapi: "3.0.3", | ||
| 500 | info: { title: "Response ref type", version: "1.0.0" }, | ||
| 501 | servers: [{ url: "https://api.example.com" }], | ||
| 502 | components: { | ||
| 503 | schemas: { | ||
| 504 | BudgetList: { | ||
| 505 | type: "object", | ||
| 506 | properties: { | ||
| 507 | total: { type: "number" }, | ||
| 508 | }, | ||
| 509 | required: ["total"], | ||
| 510 | }, | ||
| 511 | }, | ||
| 512 | responses: { | ||
| 513 | BudgetResponse: { | ||
| 514 | description: "ok", | ||
| 515 | content: { | ||
| 516 | "application/json": { | ||
| 517 | schema: { $ref: "#/components/schemas/BudgetList" }, | ||
| 518 | }, | ||
| 519 | }, | ||
| 520 | }, | ||
| 521 | }, | ||
| 522 | }, | ||
| 523 | paths: { | ||
| 524 | "/organizations/{org}/settings/billing/budgets": { | ||
| 525 | get: { | ||
| 526 | operationId: "billing/get-all-budgets-org", | ||
| 527 | tags: ["billing"], | ||
| 528 | parameters: [ | ||
| 529 | { name: "org", in: "path", required: true, schema: { type: "string" } }, | ||
| 530 | ], | ||
| 531 | responses: { | ||
| 532 | "200": { $ref: "#/components/responses/BudgetResponse" }, | ||
| 533 | }, | ||
| 534 | }, | ||
| 535 | }, | ||
| 536 | }, | ||
| 537 | }; | ||
| 538 | |||
| 539 | const prepared = await prepareOpenApiSpec(spec, "response-ref"); | ||
| 540 | const tools = buildOpenApiToolsFromPrepared( | ||
| 541 | { | ||
| 542 | type: "openapi", | ||
| 543 | name: "github", | ||
| 544 | spec, | ||
| 545 | baseUrl: "https://api.example.com", | ||
| 546 | }, | ||
| 547 | prepared, | ||
| 548 | ); | ||
| 549 | |||
| 550 | const tool = tools.find((t) => t.metadata?.operationId === "billing/get-all-budgets-org"); | ||
| 551 | expect(tool).toBeDefined(); | ||
| 552 | expect(tool!.metadata?.returnsType).toContain("total"); | ||
| 553 | expect(tool!.metadata?.returnsType).not.toBe("unknown"); | ||
| 554 | }); | ||
| 555 | |||
| 556 | test("resolves nested schema refs inside array items", async () => { | ||
| 557 | const spec: Record<string, unknown> = { | ||
| 558 | openapi: "3.0.3", | ||
| 559 | info: { title: "Nested refs", version: "1.0.0" }, | ||
| 560 | servers: [{ url: "https://api.example.com" }], | ||
| 561 | components: { | ||
| 562 | schemas: { | ||
| 563 | Budget: { | ||
| 564 | type: "object", | ||
| 565 | properties: { | ||
| 566 | id: { type: "string" }, | ||
| 567 | }, | ||
| 568 | required: ["id"], | ||
| 569 | }, | ||
| 570 | BudgetList: { | ||
| 571 | type: "object", | ||
| 572 | properties: { | ||
| 573 | budgets: { | ||
| 574 | type: "array", | ||
| 575 | items: { $ref: "#/components/schemas/Budget" }, | ||
| 576 | }, | ||
| 577 | }, | ||
| 578 | }, | ||
| 579 | }, | ||
| 580 | }, | ||
| 581 | paths: { | ||
| 582 | "/organizations/{org}/settings/billing/budgets": { | ||
| 583 | get: { | ||
| 584 | operationId: "billing/get-all-budgets-org", | ||
| 585 | tags: ["billing"], | ||
| 586 | parameters: [ | ||
| 587 | { name: "org", in: "path", required: true, schema: { type: "string" } }, | ||
| 588 | ], | ||
| 589 | responses: { | ||
| 590 | "200": { | ||
| 591 | description: "ok", | ||
| 592 | content: { | ||
| 593 | "application/json": { | ||
| 594 | schema: { $ref: "#/components/schemas/BudgetList" }, | ||
| 595 | }, | ||
| 596 | }, | ||
| 597 | }, | ||
| 598 | }, | ||
| 599 | }, | ||
| 600 | }, | ||
| 601 | }, | ||
| 602 | }; | ||
| 603 | |||
| 604 | const prepared = await prepareOpenApiSpec(spec, "nested-refs"); | ||
| 605 | const tools = buildOpenApiToolsFromPrepared( | ||
| 606 | { | ||
| 607 | type: "openapi", | ||
| 608 | name: "github", | ||
| 609 | spec, | ||
| 610 | baseUrl: "https://api.example.com", | ||
| 611 | }, | ||
| 612 | prepared, | ||
| 613 | ); | ||
| 614 | |||
| 615 | const tool = tools.find((t) => t.metadata?.operationId === "billing/get-all-budgets-org"); | ||
| 616 | expect(tool).toBeDefined(); | ||
| 617 | expect(tool!.metadata?.returnsType).toContain("budgets"); | ||
| 618 | expect(tool!.metadata?.returnsType).toContain("id: string"); | ||
| 619 | expect(tool!.metadata?.returnsType).not.toContain("unknown[]"); | ||
| 620 | }); | ||
| 621 | |||
| 622 | test("supports allOf-composed response schemas", async () => { | ||
| 623 | const spec: Record<string, unknown> = { | ||
| 624 | openapi: "3.0.3", | ||
| 625 | info: { title: "AllOf response", version: "1.0.0" }, | ||
| 626 | servers: [{ url: "https://api.example.com" }], | ||
| 627 | components: { | ||
| 628 | schemas: { | ||
| 629 | Envelope: { | ||
| 630 | type: "object", | ||
| 631 | properties: { | ||
| 632 | success: { type: "boolean" }, | ||
| 633 | }, | ||
| 634 | required: ["success"], | ||
| 635 | }, | ||
| 636 | Account: { | ||
| 637 | type: "object", | ||
| 638 | properties: { | ||
| 639 | id: { type: "string" }, | ||
| 640 | }, | ||
| 641 | required: ["id"], | ||
| 642 | }, | ||
| 643 | AccountResponse: { | ||
| 644 | allOf: [ | ||
| 645 | { $ref: "#/components/schemas/Envelope" }, | ||
| 646 | { | ||
| 647 | type: "object", | ||
| 648 | properties: { | ||
| 649 | result: { $ref: "#/components/schemas/Account" }, | ||
| 650 | }, | ||
| 651 | }, | ||
| 652 | ], | ||
| 653 | }, | ||
| 654 | }, | ||
| 655 | }, | ||
| 656 | paths: { | ||
| 657 | "/accounts/{id}": { | ||
| 658 | get: { | ||
| 659 | operationId: "accounts/get", | ||
| 660 | tags: ["accounts"], | ||
| 661 | parameters: [ | ||
| 662 | { name: "id", in: "path", required: true, schema: { type: "string" } }, | ||
| 663 | ], | ||
| 664 | responses: { | ||
| 665 | "200": { | ||
| 666 | description: "ok", | ||
| 667 | content: { | ||
| 668 | "application/json": { | ||
| 669 | schema: { $ref: "#/components/schemas/AccountResponse" }, | ||
| 670 | }, | ||
| 671 | }, | ||
| 672 | }, | ||
| 673 | }, | ||
| 674 | }, | ||
| 675 | }, | ||
| 676 | }, | ||
| 677 | }; | ||
| 678 | |||
| 679 | const prepared = await prepareOpenApiSpec(spec, "allof"); | ||
| 680 | const tools = buildOpenApiToolsFromPrepared( | ||
| 681 | { | ||
| 682 | type: "openapi", | ||
| 683 | name: "cf", | ||
| 684 | spec, | ||
| 685 | baseUrl: "https://api.example.com", | ||
| 686 | }, | ||
| 687 | prepared, | ||
| 688 | ); | ||
| 689 | |||
| 690 | const tool = tools.find((t) => t.metadata?.operationId === "accounts/get"); | ||
| 691 | expect(tool).toBeDefined(); | ||
| 692 | expect(tool!.metadata?.returnsType).toContain("success"); | ||
| 693 | expect(tool!.metadata?.returnsType).toContain("result"); | ||
| 694 | expect(tool!.metadata?.returnsType).not.toBe("unknown"); | ||
| 695 | }); | ||
| 696 | |||
| 697 | test("supports schemas that encode unions via top-level items arrays", async () => { | ||
| 698 | const spec: Record<string, unknown> = { | ||
| 699 | swagger: "2.0", | ||
| 700 | info: { title: "Tuple-style union", version: "1.0.0" }, | ||
| 701 | host: "api.example.com", | ||
| 702 | schemes: ["https"], | ||
| 703 | paths: { | ||
| 704 | "/users.identity": { | ||
| 705 | get: { | ||
| 706 | operationId: "users_identity", | ||
| 707 | tags: ["users"], | ||
| 708 | responses: { | ||
| 709 | "200": { | ||
| 710 | description: "ok", | ||
| 711 | schema: { | ||
| 712 | items: [ | ||
| 713 | { | ||
| 714 | type: "object", | ||
| 715 | properties: { | ||
| 716 | ok: { type: "boolean" }, | ||
| 717 | user: { | ||
| 718 | type: "object", | ||
| 719 | properties: { | ||
| 720 | id: { type: "string" }, | ||
| 721 | }, | ||
| 722 | }, | ||
| 723 | }, | ||
| 724 | }, | ||
| 725 | { | ||
| 726 | type: "object", | ||
| 727 | properties: { | ||
| 728 | ok: { type: "boolean" }, | ||
| 729 | user: { | ||
| 730 | type: "object", | ||
| 731 | properties: { | ||
| 732 | id: { type: "string" }, | ||
| 733 | email: { type: "string" }, | ||
| 734 | }, | ||
| 735 | }, | ||
| 736 | }, | ||
| 737 | }, | ||
| 738 | ], | ||
| 739 | }, | ||
| 740 | }, | ||
| 741 | }, | ||
| 742 | }, | ||
| 743 | }, | ||
| 744 | }, | ||
| 745 | }; | ||
| 746 | |||
| 747 | const prepared = await prepareOpenApiSpec(spec, "tuple-union"); | ||
| 748 | const tools = buildOpenApiToolsFromPrepared( | ||
| 749 | { | ||
| 750 | type: "openapi", | ||
| 751 | name: "slack", | ||
| 752 | spec, | ||
| 753 | baseUrl: "https://api.example.com", | ||
| 754 | }, | ||
| 755 | prepared, | ||
| 756 | ); | ||
| 757 | |||
| 758 | const tool = tools.find((t) => t.metadata?.operationId === "users_identity"); | ||
| 759 | expect(tool).toBeDefined(); | ||
| 760 | expect(tool!.metadata?.returnsType).toContain(" | "); | ||
| 761 | expect(tool!.metadata?.returnsType).not.toBe("unknown"); | ||
| 762 | }); | ||
| 763 | |||
| 425 | test("schemaTypes only attached to first tool from a source", async () => { | 764 | test("schemaTypes only attached to first tool from a source", async () => { |
| 426 | const specWithSchemas: Record<string, unknown> = { | 765 | const specWithSchemas: Record<string, unknown> = { |
| 427 | openapi: "3.0.3", | 766 | openapi: "3.0.3", |
| @@ -206,6 +206,66 @@ describe("real-world OpenAPI specs", () => { · 205 unmodified lines | |||
| 206 | 300_000, | 206 | 300_000, |
| 207 | ); | 207 | ); |
| 208 | 208 | ||
| 209 | test( | ||
| 210 | "github: create hosted runner for org has non-unknown return hint", | ||
| 211 | async () => { | ||
| 212 | const githubUrl = | ||
| 213 | "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml"; | ||
| 214 | |||
| 215 | const prepared = await prepareOpenApiSpec(githubUrl, "github"); | ||
| 216 | const tools = buildOpenApiToolsFromPrepared( | ||
| 217 | { | ||
| 218 | type: "openapi", | ||
| 219 | name: "github", | ||
| 220 | spec: githubUrl, | ||
| 221 | baseUrl: prepared.servers[0] || "https://api.github.com", | ||
| 222 | }, | ||
| 223 | prepared, | ||
| 224 | ); | ||
| 225 | |||
| 226 | const tool = tools.find( | ||
| 227 | (t) => t.metadata?.operationId === "actions/create-hosted-runner-for-org", | ||
| 228 | ); | ||
| 229 | |||
| 230 | expect(tool).toBeDefined(); | ||
| 231 | expect(tool!.path).toBe("github.actions.actions_create_hosted_runner_for_org"); | ||
| 232 | expect(tool!.metadata!.argsType).toContain("org: string"); | ||
| 233 | expect(tool!.metadata!.returnsType).toContain("id"); | ||
| 234 | expect(tool!.metadata!.returnsType).not.toBe("unknown"); | ||
| 235 | }, | ||
| 236 | 300_000, | ||
| 237 | ); | ||
| 238 | |||
| 239 | test( | ||
| 240 | "github: all budgets for org has non-unknown return hint", | ||
| 241 | async () => { | ||
| 242 | const githubUrl = | ||
| 243 | "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml"; | ||
| 244 | |||
| 245 | const prepared = await prepareOpenApiSpec(githubUrl, "github"); | ||
| 246 | const tools = buildOpenApiToolsFromPrepared( | ||
| 247 | { | ||
| 248 | type: "openapi", | ||
| 249 | name: "github", | ||
| 250 | spec: githubUrl, | ||
| 251 | baseUrl: prepared.servers[0] || "https://api.github.com", | ||
| 252 | }, | ||
| 253 | prepared, | ||
| 254 | ); | ||
| 255 | |||
| 256 | const tool = tools.find( | ||
| 257 | (t) => t.metadata?.operationId === "billing/get-all-budgets-org", | ||
| 258 | ); | ||
| 259 | |||
| 260 | expect(tool).toBeDefined(); | ||
| 261 | expect(tool!.path).toBe("github.billing.billing_get_all_budgets_org"); | ||
| 262 | expect(tool!.metadata!.argsType).toContain("org: string"); | ||
| 263 | expect(tool!.metadata!.returnsType).toContain("budgets"); | ||
| 264 | expect(tool!.metadata!.returnsType).not.toBe("unknown"); | ||
| 265 | }, | ||
| 266 | 300_000, | ||
| 267 | ); | ||
| 268 | |||
| 209 | test( | 269 | test( |
| 210 | "slack: approved apps list keeps typed query params and non-unknown output", | 270 | "slack: approved apps list keeps typed query params and non-unknown output", |
| 211 | async () => { | 271 | async () => { |
| @@ -266,6 +266,42 @@ function resolveSchemaRef( · 265 unmodified lines | |||
| 266 | return resolved; | 266 | return resolved; |
| 267 | } | 267 | } |
| 268 | 268 | ||
| 269 | function resolveRequestBodyRef( | ||
| 270 | requestBody: Record<string, unknown>, | ||
| 271 | componentRequestBodies: Record<string, unknown>, | ||
| 272 | ): Record<string, unknown> { | ||
| 273 | const ref = typeof requestBody.$ref === "string" ? requestBody.$ref : ""; | ||
| 274 | const prefix = "#/components/requestBodies/"; | ||
| 275 | if (!ref.startsWith(prefix)) { | ||
| 276 | return requestBody; | ||
| 277 | } | ||
| 278 | |||
| 279 | const key = ref.slice(prefix.length); | ||
| 280 | const resolved = asRecord(componentRequestBodies[key]); | ||
| 281 | if (Object.keys(resolved).length === 0) { | ||
| 282 | return requestBody; | ||
| 283 | } | ||
| 284 | return resolved; | ||
| 285 | } | ||
| 286 | |||
| 287 | function resolveResponseRef( | ||
| 288 | response: Record<string, unknown>, | ||
| 289 | componentResponses: Record<string, unknown>, | ||
| 290 | ): Record<string, unknown> { | ||
| 291 | const ref = typeof response.$ref === "string" ? response.$ref : ""; | ||
| 292 | const prefix = "#/components/responses/"; | ||
| 293 | if (!ref.startsWith(prefix)) { | ||
| 294 | return response; | ||
| 295 | } | ||
| 296 | |||
| 297 | const key = ref.slice(prefix.length); | ||
| 298 | const resolved = asRecord(componentResponses[key]); | ||
| 299 | if (Object.keys(resolved).length === 0) { | ||
| 300 | return response; | ||
| 301 | } | ||
| 302 | return resolved; | ||
| 303 | } | ||
| 304 | |||
| 269 | function parameterSchemaFromEntry(entry: Record<string, unknown>): Record<string, unknown> { | 305 | function parameterSchemaFromEntry(entry: Record<string, unknown>): Record<string, unknown> { |
| 270 | // OpenAPI 3.x (and Swagger body params) use `schema` | 306 | // OpenAPI 3.x (and Swagger body params) use `schema` |
| 271 | const schema = asRecord(entry.schema); | 307 | const schema = asRecord(entry.schema); |
| @@ -291,9 +327,13 @@ function parameterSchemaFromEntry(entry: Record<string, unknown>): Record<string · 19 unmodified lines | |||
| 291 | return fallback; | 327 | return fallback; |
| 292 | } | 328 | } |
| 293 | 329 | ||
| 294 | function responseTypeHintFromSchema(responseSchema: Record<string, unknown>, responseStatus: string): string { | 330 | function responseTypeHintFromSchema( |
| 331 | responseSchema: Record<string, unknown>, | ||
| 332 | responseStatus: string, | ||
| 333 | componentSchemas?: Record<string, unknown>, | ||
| 334 | ): string { | ||
| 295 | if (Object.keys(responseSchema).length > 0) { | 335 | if (Object.keys(responseSchema).length > 0) { |
| 296 | return jsonSchemaTypeHintFallback(responseSchema); | 336 | return jsonSchemaTypeHintFallback(responseSchema, 0, componentSchemas); |
| 297 | } | 337 | } |
| 298 | 338 | ||
| 299 | // No-content success responses should be represented as void. | 339 | // No-content success responses should be represented as void. |
| @@ -305,11 +345,27 @@ function responseTypeHintFromSchema(responseSchema: Record<string, unknown>, res · 5 unmodified lines | |||
| 305 | } | 345 | } |
| 306 | 346 | ||
| 307 | /** Simple depth-limited type hint generator for schemas (used as fallback) */ | 347 | /** Simple depth-limited type hint generator for schemas (used as fallback) */ |
| 308 | function jsonSchemaTypeHintFallback(schema: unknown, depth = 0): string { | 348 | function jsonSchemaTypeHintFallback( |
| 349 | schema: unknown, | ||
| 350 | depth = 0, | ||
| 351 | componentSchemas?: Record<string, unknown>, | ||
| 352 | ): string { | ||
| 309 | if (!schema || typeof schema !== "object") return "unknown"; | 353 | if (!schema || typeof schema !== "object") return "unknown"; |
| 310 | if (depth > 4) return "unknown"; | 354 | if (depth > 4) return "unknown"; |
| 311 | 355 | ||
| 312 | const shape = schema as JsonSchema; | 356 | const shape = schema as JsonSchema; |
| 357 | if (typeof shape.$ref === "string") { | ||
| 358 | const ref = shape.$ref; | ||
| 359 | const prefix = "#/components/schemas/"; | ||
| 360 | if (ref.startsWith(prefix)) { | ||
| 361 | const key = ref.slice(prefix.length); | ||
| 362 | const resolved = componentSchemas ? asRecord(componentSchemas[key]) : {}; | ||
| 363 | if (Object.keys(resolved).length > 0) { | ||
| 364 | return jsonSchemaTypeHintFallback(resolved, depth + 1, componentSchemas); | ||
| 365 | } | ||
| 366 | } | ||
| 367 | } | ||
| 368 | |||
| 313 | const enumValues = Array.isArray(shape.enum) ? shape.enum : undefined; | 369 | const enumValues = Array.isArray(shape.enum) ? shape.enum : undefined; |
| 314 | if (enumValues && enumValues.length > 0) { | 370 | if (enumValues && enumValues.length > 0) { |
| 315 | return enumValues.map((value) => JSON.stringify(value)).join(" | "); | 371 | return enumValues.map((value) => JSON.stringify(value)).join(" | "); |
| @@ -317,35 +373,55 @@ function jsonSchemaTypeHintFallback(schema: unknown, depth = 0): string { · 1 unmodified line | |||
| 317 | 373 | ||
| 318 | const oneOf = Array.isArray(shape.oneOf) ? shape.oneOf : undefined; | 374 | const oneOf = Array.isArray(shape.oneOf) ? shape.oneOf : undefined; |
| 319 | if (oneOf && oneOf.length > 0) { | 375 | if (oneOf && oneOf.length > 0) { |
| 320 | return oneOf.map((entry) => jsonSchemaTypeHintFallback(entry, depth + 1)).join(" | "); | 376 | return oneOf.map((entry) => jsonSchemaTypeHintFallback(entry, depth + 1, componentSchemas)).join(" | "); |
| 321 | } | 377 | } |
| 322 | 378 | ||
| 323 | const anyOf = Array.isArray(shape.anyOf) ? shape.anyOf : undefined; | 379 | const anyOf = Array.isArray(shape.anyOf) ? shape.anyOf : undefined; |
| 324 | if (anyOf && anyOf.length > 0) { | 380 | if (anyOf && anyOf.length > 0) { |
| 325 | return anyOf.map((entry) => jsonSchemaTypeHintFallback(entry, depth + 1)).join(" | "); | 381 | return anyOf.map((entry) => jsonSchemaTypeHintFallback(entry, depth + 1, componentSchemas)).join(" | "); |
| 382 | } | ||
| 383 | |||
| 384 | const allOf = Array.isArray(shape.allOf) ? shape.allOf : undefined; | ||
| 385 | if (allOf && allOf.length > 0) { | ||
| 386 | const parts = allOf | ||
| 387 | .map((entry) => jsonSchemaTypeHintFallback(entry, depth + 1, componentSchemas)) | ||
| 388 | .filter((part) => part.length > 0 && part !== "unknown"); | ||
| 389 | if (parts.length > 0) { | ||
| 390 | return parts.join(" & "); | ||
| 391 | } | ||
| 326 | } | 392 | } |
| 327 | 393 | ||
| 328 | const type = typeof shape.type === "string" ? shape.type : undefined; | 394 | const type = typeof shape.type === "string" ? shape.type : undefined; |
| 395 | const tupleItems = Array.isArray(shape.items) ? shape.items : undefined; | ||
| 396 | if (!type && tupleItems && tupleItems.length > 0) { | ||
| 397 | return tupleItems | ||
| 398 | .map((entry) => jsonSchemaTypeHintFallback(entry, depth + 1, componentSchemas)) | ||
| 399 | .join(" | "); | ||
| 400 | } | ||
| 329 | if (type === "integer") return "number"; | 401 | if (type === "integer") return "number"; |
| 330 | if (type === "string" || type === "number" || type === "boolean" || type === "null") { | 402 | if (type === "string" || type === "number" || type === "boolean" || type === "null") { |
| 331 | return type; | 403 | return type; |
| 332 | } | 404 | } |
| 333 | 405 | ||
| 334 | if (type === "array") { | 406 | if (type === "array") { |
| 335 | return `${jsonSchemaTypeHintFallback(shape.items, depth + 1)}[]`; | 407 | return `${jsonSchemaTypeHintFallback(shape.items, depth + 1, componentSchemas)}[]`; |
| 336 | } | 408 | } |
| 337 | 409 | ||
| 338 | const props = asRecord(shape.properties); | 410 | const props = asRecord(shape.properties); |
| 411 | const additionalProperties = shape.additionalProperties; | ||
| 339 | const requiredRaw = Array.isArray(shape.required) ? shape.required : []; | 412 | const requiredRaw = Array.isArray(shape.required) ? shape.required : []; |
| 340 | const required = new Set(requiredRaw.filter((item): item is string => typeof item === "string")); | 413 | const required = new Set(requiredRaw.filter((item): item is string => typeof item === "string")); |
| 341 | const propEntries = Object.entries(props); | 414 | const propEntries = Object.entries(props); |
| 342 | if (type === "object" || propEntries.length > 0) { | 415 | if (type === "object" || propEntries.length > 0) { |
| 343 | if (propEntries.length === 0) { | 416 | if (propEntries.length === 0) { |
| 417 | if (additionalProperties && typeof additionalProperties === "object") { | ||
| 418 | return `Record<string, ${jsonSchemaTypeHintFallback(additionalProperties, depth + 1, componentSchemas)}>`; | ||
| 419 | } | ||
| 344 | return "Record<string, unknown>"; | 420 | return "Record<string, unknown>"; |
| 345 | } | 421 | } |
| 346 | const inner = propEntries | 422 | const inner = propEntries |
| 347 | .slice(0, 12) | 423 | .slice(0, 12) |
| 348 | .map(([key, value]) => `${key}${required.has(key) ? "" : "?"}: ${jsonSchemaTypeHintFallback(value, depth + 1)}`) | 424 | .map(([key, value]) => `${key}${required.has(key) ? "" : "?"}: ${jsonSchemaTypeHintFallback(value, depth + 1, componentSchemas)}`) |
| 349 | .join("; "); | 425 | .join("; "); |
| 350 | return `{ ${inner} }`; | 426 | return `{ ${inner} }`; |
| 351 | } | 427 | } |
| @@ -541,12 +617,16 @@ function compactOpenApiPaths( · 189 unmodified lines | |||
| 541 | operationTypeIds: Set<string>, | 617 | operationTypeIds: Set<string>, |
| 542 | componentParameters?: Record<string, unknown>, | 618 | componentParameters?: Record<string, unknown>, |
| 543 | componentSchemas?: Record<string, unknown>, | 619 | componentSchemas?: Record<string, unknown>, |
| 620 | componentResponses?: Record<string, unknown>, | ||
| 621 | componentRequestBodies?: Record<string, unknown>, | ||
| 544 | ): Record<string, unknown> { | 622 | ): Record<string, unknown> { |
| 545 | const paths = asRecord(pathsValue); | 623 | const paths = asRecord(pathsValue); |
| 546 | const methods = ["get", "post", "put", "delete", "patch", "head", "options"] as const; | 624 | const methods = ["get", "post", "put", "delete", "patch", "head", "options"] as const; |
| 547 | const compactPaths: Record<string, unknown> = {}; | 625 | const compactPaths: Record<string, unknown> = {}; |
| 548 | const compParams = componentParameters ? asRecord(componentParameters) : {}; | 626 | const compParams = componentParameters ? asRecord(componentParameters) : {}; |
| 549 | const compSchemas = componentSchemas ? asRecord(componentSchemas) : {}; | 627 | const compSchemas = componentSchemas ? asRecord(componentSchemas) : {}; |
| 628 | const compResponses = componentResponses ? asRecord(componentResponses) : {}; | ||
| 629 | const compRequestBodies = componentRequestBodies ? asRecord(componentRequestBodies) : {}; | ||
| 550 | 630 | ||
| 551 | const resolveParam = (entry: Record<string, unknown>): Record<string, unknown> => { | 631 | const resolveParam = (entry: Record<string, unknown>): Record<string, unknown> => { |
| 552 | if (typeof entry.$ref === "string") { | 632 | if (typeof entry.$ref === "string") { |
| @@ -612,7 +692,7 @@ function compactOpenApiPaths( · 59 unmodified lines | |||
| 612 | // Extract request/response schemas for type hints. | 692 | // Extract request/response schemas for type hints. |
| 613 | // When .d.ts types exist, pre-compute lightweight hints and skip storing full schemas. | 693 | // When .d.ts types exist, pre-compute lightweight hints and skip storing full schemas. |
| 614 | { | 694 | { |
| 615 | const requestBody = asRecord(operation.requestBody); | 695 | const requestBody = resolveRequestBodyRef(asRecord(operation.requestBody), compRequestBodies); |
| 616 | const requestBodyContent = asRecord(requestBody.content); | 696 | const requestBodyContent = asRecord(requestBody.content); |
| 617 | const requestBodySchema = resolveSchemaRef( | 697 | const requestBodySchema = resolveSchemaRef( |
| 618 | getPreferredContentSchema(requestBodyContent), | 698 | getPreferredContentSchema(requestBodyContent), |
| @@ -624,8 +704,9 @@ function compactOpenApiPaths( · 5 unmodified lines | |||
| 624 | let responseStatus = ""; | 704 | let responseStatus = ""; |
| 625 | for (const [status, responseValue] of Object.entries(responses)) { | 705 | for (const [status, responseValue] of Object.entries(responses)) { |
| 626 | if (!status.startsWith("2")) continue; | 706 | if (!status.startsWith("2")) continue; |
| 707 | const resolvedResponse = resolveResponseRef(asRecord(responseValue), compResponses); | ||
| 627 | responseSchema = resolveSchemaRef( | 708 | responseSchema = resolveSchemaRef( |
| 628 | getPreferredResponseSchema(asRecord(responseValue)), | 709 | getPreferredResponseSchema(resolvedResponse), |
| 629 | compSchemas, | 710 | compSchemas, |
| 630 | ); | 711 | ); |
| 631 | responseStatus = status; | 712 | responseStatus = status; |
| @@ -654,8 +735,8 @@ function compactOpenApiPaths( · 22 unmodified lines | |||
| 654 | : []) as string[]), | 735 | : []) as string[]), |
| 655 | ], | 736 | ], |
| 656 | }; | 737 | }; |
| 657 | compactOperation._argsTypeHint = jsonSchemaTypeHintFallback(combinedSchema); | 738 | compactOperation._argsTypeHint = jsonSchemaTypeHintFallback(combinedSchema, 0, compSchemas); |
| 658 | compactOperation._returnsTypeHint = responseTypeHintFromSchema(responseSchema, responseStatus); | 739 | compactOperation._returnsTypeHint = responseTypeHintFromSchema(responseSchema, responseStatus, compSchemas); |
| 659 | } else { | 740 | } else { |
| 660 | // Keep full schemas for the fallback path | 741 | // Keep full schemas for the fallback path |
| 661 | if (Object.keys(requestBodySchema).length > 0) { | 742 | if (Object.keys(requestBodySchema).length > 0) { |
| @@ -766,6 +847,8 @@ export async function prepareOpenApiSpec( · 104 unmodified lines | |||
| 766 | operationTypeIds, | 847 | operationTypeIds, |
| 767 | asRecord(asRecord(bundled.components).parameters), | 848 | asRecord(asRecord(bundled.components).parameters), |
| 768 | asRecord(asRecord(bundled.components).schemas), | 849 | asRecord(asRecord(bundled.components).schemas), |
| 850 | asRecord(asRecord(bundled.components).responses), | ||
| 851 | asRecord(asRecord(bundled.components).requestBodies), | ||
| 769 | ), | 852 | ), |
| 770 | dts: dts ?? undefined, | 853 | dts: dts ?? undefined, |
| 771 | warnings, | 854 | warnings, |
| @@ -15,6 +15,7 @@ export type { · 14 unmodified lines | |||
| 15 | CredentialRecord, | 15 | CredentialRecord, |
| 16 | ToolSourceRecord, | 16 | ToolSourceRecord, |
| 17 | ToolDescriptor, | 17 | ToolDescriptor, |
| 18 | OpenApiSourceQuality, | ||
| 18 | AnonymousContext, | 19 | AnonymousContext, |
| 19 | } from "@executor/contracts"; | 20 | } from "@executor/contracts"; |
| 20 | 21 | ||
| @@ -119,6 +119,18 @@ export interface ToolDescriptor { · 118 unmodified lines | |||
| 119 | schemaTypes?: Record<string, string>; | 119 | schemaTypes?: Record<string, string>; |
| 120 | } | 120 | } |
| 121 | 121 | ||
| 122 | export interface OpenApiSourceQuality { | ||
| 123 | sourceKey: string; | ||
| 124 | toolCount: number; | ||
| 125 | unknownArgsCount: number; | ||
| 126 | unknownReturnsCount: number; | ||
| 127 | partialUnknownArgsCount: number; | ||
| 128 | partialUnknownReturnsCount: number; | ||
| 129 | argsQuality: number; | ||
| 130 | returnsQuality: number; | ||
| 131 | overallQuality: number; | ||
| 132 | } | ||
| 133 | |||
| 122 | export interface AnonymousContext { | 134 | export interface AnonymousContext { |
| 123 | sessionId: string; | 135 | sessionId: string; |
| 124 | workspaceId: string; | 136 | workspaceId: string; |
| @@ -1,10 +1,3 @@ | |||
| 1 | { | 1 | { |
| 2 | "$schema": "https://opencode.ai/config.json", | 2 | "$schema": "https://opencode.ai/config.json" |
| 3 | "mcp": { | 3 | } |
| 4 | "executor-kn7agh4e": { | ||
| 5 | "type": "remote", | ||
| 6 | "url": "https://perceptive-pigeon-577.convex.site/mcp?workspaceId=kn7agh4e6v5nx8mw03byq5f15580vg4c", | ||
| 7 | "enabled": true | ||
| 8 | } | ||
| 9 | } | ||
| 10 | } | ||