History

Refactor OpenAPI type handling and enhance sign-in flow

- 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.

Rhys Sullivan committed 9 months ago

Commit 092066e1 parent f85d21c11 files · +682 −32Browse files

Unified · Split

11 files
  1. executor/apps/web/src/app/sign-in/route.ts +23 −1
  2. executor/apps/web/src/components/tools-view.tsx +59 −5
  3. executor/apps/web/src/hooks/use-workspace-tools.ts +4 −1
  4. executor/apps/web/src/lib/types.ts +1 −0
  5. executor/convex/executorNode.ts +87 −5
  6. executor/lib/openapi-cache.test.ts +339 −0
  7. executor/lib/openapi-real-specs.test.ts +60 −0
  8. executor/lib/tool_sources.ts +94 −11
  9. executor/lib/types.ts +1 −0
  10. executor/packages/contracts/src/index.ts +12 −0
  11. opencode.json +2 −9
executor/apps/web/src/app/sign-in/route.ts+23−1
@@ -21,6 +21,26 @@ function readOptionalQueryParam(request: NextRequest, keys: string[]): string | · 20 unmodified lines
2121 return undefined;
2222 }
2323
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+
2444 const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL ?? process.env.CONVEX_URL;
2545 const convexClient = convexUrl ? new ConvexHttpClient(convexUrl) : null;
2646
@@ -65,10 +85,12 @@ export async function GET(request: NextRequest) { · 38 unmodified lines
6585 const loginHint = readOptionalQueryParam(request, ["loginHint", "login_hint", "email"]);
6686
6787 const { getSignInUrl } = await import("@workos-inc/authkit-nextjs");
68− const authorizationUrl = await getSignInUrl({
88+ const baseAuthorizationUrl = await getSignInUrl({
6989 redirectUri,
7090 organizationId,
7191 loginHint,
7292 });
93+
94+ const authorizationUrl = appendAuthkitPassthroughQueryParams(request, baseAuthorizationUrl);
7395 return redirect(authorizationUrl);
7496 }
executor/apps/web/src/components/tools-view.tsx+59−5
@@ -48,7 +48,13 @@ import { useSession } from "@/lib/session-context"; · 47 unmodified lines
4848 import { useWorkspaceTools } from "@/hooks/use-workspace-tools";
4949 import { useAction, useMutation, useQuery } from "convex/react";
5050 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";
5258 import { parse as parseDomain } from "tldts";
5359 import { toast } from "sonner";
5460 import { cn } from "@/lib/utils";
@@ -296,6 +302,20 @@ function formatSourceAuthBadge(source: ToolSourceRecord): string | null { · 241 unmodified lines
296302 return `${auth.type}:${mode}`;
297303 }
298304
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+
299319 // ── Add Source Dialog ──
300320
301321 const RAW_HOSTS = new Set([
@@ -785,8 +805,12 @@ function ConfigureSourceAuthDialog({ · 483 unmodified lines
785805
786806 function SourceCard({
787807 source,
808+ quality,
809+ qualityLoading,
788810 }: {
789811 source: ToolSourceRecord;
812+ quality?: OpenApiSourceQuality;
813+ qualityLoading?: boolean;
790814 }) {
791815 const { context } = useSession();
792816 const deleteToolSource = useMutation(convexApi.workspace.deleteToolSource);
@@ -849,6 +873,14 @@ function SourceCard({ · 56 unmodified lines
849873 {authBadge}
850874 </Badge>
851875 )}
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+ )}
852884 </div>
853885 <span className="text-[11px] text-muted-foreground font-mono truncate block">
854886 {source.type === "mcp"
@@ -857,6 +889,19 @@ function SourceCard({ · 2 unmodified lines
857889 ? (source.config.endpoint as string)
858890 : (source.config.spec as string)}
859891 </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+ )}
860905 </div>
861906 <div className="flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity shrink-0">
862907 <ConfigureSourceAuthDialog source={source} />
@@ -1377,7 +1422,7 @@ export function ToolsView({ initialSource }: { initialSource?: string | null }) · 514 unmodified lines
13771422 );
13781423 const credentialsLoading = !!context && credentials === undefined;
13791424
1380− const { tools, warnings, loading: toolsLoading } = useWorkspaceTools(context ?? null);
1425+ const { tools, warnings, sourceQuality, loading: toolsLoading } = useWorkspaceTools(context ?? null);
13811426
13821427 if (sessionLoading) {
13831428 return (
@@ -1476,9 +1521,18 @@ export function ToolsView({ initialSource }: { initialSource?: string | null }) · 92 unmodified lines
14761521 </div>
14771522 </div>
14781523 )}
1479− {sources.map((s: any) => (
1480− <SourceCard key={s.id} source={s} />
1481− ))}
1524+ {sources.map((s: any) => {
1525+ const sourceKey = sourceKeyForSource(s);
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+ })}
14821536 </div>
14831537 )}
14841538 </CardContent>
executor/apps/web/src/hooks/use-workspace-tools.ts+4−1
@@ -3,6 +3,7 @@ · 2 unmodified lines
33 import { useQuery as useTanstackQuery } from "@tanstack/react-query";
44 import { useAction, useQuery as useConvexQuery } from "convex/react";
55 import { convexApi } from "@/lib/convex-api";
6+import type { OpenApiSourceQuality } from "@/lib/types";
67
78 interface WorkspaceContext {
89 workspaceId: string;
@@ -35,7 +36,7 @@ export function useWorkspaceTools(context: WorkspaceContext | null) { · 26 unmodified lines
3536 toolSources,
3637 ],
3738 queryFn: async () => {
38− if (!context) return { tools: [], warnings: [], dtsUrls: {} };
39+ if (!context) return { tools: [], warnings: [], dtsUrls: {}, sourceQuality: {} };
3940 return await listToolsWithWarnings({
4041 workspaceId: context.workspaceId,
4142 ...(context.actorId && { actorId: context.actorId }),
@@ -51,6 +52,8 @@ export function useWorkspaceTools(context: WorkspaceContext | null) { · 9 unmodified lines
5152 warnings: data?.warnings ?? [],
5253 /** Per-source .d.ts download URLs for Monaco IntelliSense. Keyed by source key (e.g. "openapi:cloudflare"). */
5354 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>,
5457 loading: !!context && isLoading,
5558 };
5659 }
executor/apps/web/src/lib/types.ts+1−0
@@ -15,6 +15,7 @@ export type { · 14 unmodified lines
1515 CredentialRecord,
1616 ToolSourceRecord,
1717 ToolDescriptor,
18+ OpenApiSourceQuality,
1819 AnonymousContext,
1920 } from "@executor/contracts";
2021
executor/convex/executorNode.ts+87−5
@@ -31,6 +31,7 @@ import type { · 30 unmodified lines
3131 ToolCredentialSpec,
3232 ToolDefinition,
3333 ToolDescriptor,
34+ OpenApiSourceQuality,
3435 ToolSourceRecord,
3536 ToolRunContext,
3637 } from "../lib/types";
@@ -56,7 +57,7 @@ function policySpecificity(policy: AccessPolicyRecord, actorId?: string, clientI · 19 unmodified lines
5657 }
5758
5859 function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string {
59− const signatureVersion = "v4";
60+ const signatureVersion = "v8";
6061 const parts = sources
6162 .map((source) => `${source.id}:${source.updatedAt}:${source.enabled ? 1 : 0}`)
6263 .sort();
@@ -131,7 +132,7 @@ interface DtsStorageEntry { · 68 unmodified lines
131132 const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;
132133
133134 /** Cache version — bump when PreparedOpenApiSpec shape changes. */
134−const OPENAPI_CACHE_VERSION = "v7";
135+const OPENAPI_CACHE_VERSION = "v11";
135136
136137 async function publish(
137138 ctx: any,
@@ -419,6 +420,67 @@ function toToolDescriptor(tool: ToolDefinition, approval: "auto" | "required"): · 281 unmodified lines
419420 };
420421 }
421422
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+
422484 function listVisibleToolDescriptors(
423485 workspaceTools: Map<string, ToolDefinition>,
424486 context: { workspaceId: string; actorId?: string; clientId?: string },
@@ -453,13 +515,19 @@ async function listToolsForContext( · 28 unmodified lines
453515 async function listToolsWithWarningsForContext(
454516 ctx: any,
455517 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+}> {
457524 const [result, policies] = await Promise.all([
458525 getWorkspaceTools(ctx, context.workspaceId),
459526 ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: context.workspaceId }),
460527 ]);
461528 const typedPolicies = policies as AccessPolicyRecord[];
462529 const tools = listVisibleToolDescriptors(result.tools, context, typedPolicies);
530+ const sourceQuality = computeOpenApiSourceQuality(result.tools);
463531
464532 // Generate download URLs for .d.ts blobs
465533 const dtsUrls: Record<string, string> = {};
@@ -476,6 +544,7 @@ async function listToolsWithWarningsForContext( · 10 unmodified lines
476544 tools,
477545 warnings: result.warnings,
478546 dtsUrls,
547+ sourceQuality,
479548 };
480549 }
481550
@@ -754,7 +823,12 @@ export const listToolsWithWarnings = action({ · 272 unmodified lines
754823 handler: async (
755824 ctx,
756825 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+ }> => {
758832 const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForRequest, {
759833 workspaceId: args.workspaceId,
760834 sessionId: args.sessionId,
@@ -793,7 +867,15 @@ export const listToolsWithWarningsInternal = internalAction({ · 32 unmodified lines
793867 actorId: v.optional(v.string()),
794868 clientId: v.optional(v.string()),
795869 },
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+ }> => {
797879 return await listToolsWithWarningsForContext(ctx, args);
798880 },
799881 });
executor/lib/openapi-cache.test.ts+339−0
@@ -422,6 +422,345 @@ describe("buildOpenApiToolsFromPrepared", () => { · 421 unmodified lines
422422 expect(tool!.metadata?.returnsType).not.toBe("unknown");
423423 });
424424
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+
425764 test("schemaTypes only attached to first tool from a source", async () => {
426765 const specWithSchemas: Record<string, unknown> = {
427766 openapi: "3.0.3",
executor/lib/openapi-real-specs.test.ts+60−0
@@ -206,6 +206,66 @@ describe("real-world OpenAPI specs", () => { · 205 unmodified lines
206206 300_000,
207207 );
208208
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+
209269 test(
210270 "slack: approved apps list keeps typed query params and non-unknown output",
211271 async () => {
executor/lib/tool_sources.ts+94−11
@@ -266,6 +266,42 @@ function resolveSchemaRef( · 265 unmodified lines
266266 return resolved;
267267 }
268268
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+
269305 function parameterSchemaFromEntry(entry: Record<string, unknown>): Record<string, unknown> {
270306 // OpenAPI 3.x (and Swagger body params) use `schema`
271307 const schema = asRecord(entry.schema);
@@ -291,9 +327,13 @@ function parameterSchemaFromEntry(entry: Record<string, unknown>): Record<string · 19 unmodified lines
291327 return fallback;
292328 }
293329
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 {
295335 if (Object.keys(responseSchema).length > 0) {
296− return jsonSchemaTypeHintFallback(responseSchema);
336+ return jsonSchemaTypeHintFallback(responseSchema, 0, componentSchemas);
297337 }
298338
299339 // No-content success responses should be represented as void.
@@ -305,11 +345,27 @@ function responseTypeHintFromSchema(responseSchema: Record<string, unknown>, res · 5 unmodified lines
305345 }
306346
307347 /** 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 {
309353 if (!schema || typeof schema !== "object") return "unknown";
310354 if (depth > 4) return "unknown";
311355
312356 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+
313369 const enumValues = Array.isArray(shape.enum) ? shape.enum : undefined;
314370 if (enumValues && enumValues.length > 0) {
315371 return enumValues.map((value) => JSON.stringify(value)).join(" | ");
@@ -317,35 +373,55 @@ function jsonSchemaTypeHintFallback(schema: unknown, depth = 0): string { · 1 unmodified line
317373
318374 const oneOf = Array.isArray(shape.oneOf) ? shape.oneOf : undefined;
319375 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(" | ");
321377 }
322378
323379 const anyOf = Array.isArray(shape.anyOf) ? shape.anyOf : undefined;
324380 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+ }
326392 }
327393
328394 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+ }
329401 if (type === "integer") return "number";
330402 if (type === "string" || type === "number" || type === "boolean" || type === "null") {
331403 return type;
332404 }
333405
334406 if (type === "array") {
335− return `${jsonSchemaTypeHintFallback(shape.items, depth + 1)}[]`;
407+ return `${jsonSchemaTypeHintFallback(shape.items, depth + 1, componentSchemas)}[]`;
336408 }
337409
338410 const props = asRecord(shape.properties);
411+ const additionalProperties = shape.additionalProperties;
339412 const requiredRaw = Array.isArray(shape.required) ? shape.required : [];
340413 const required = new Set(requiredRaw.filter((item): item is string => typeof item === "string"));
341414 const propEntries = Object.entries(props);
342415 if (type === "object" || propEntries.length > 0) {
343416 if (propEntries.length === 0) {
417+ if (additionalProperties && typeof additionalProperties === "object") {
418+ return `Record<string, ${jsonSchemaTypeHintFallback(additionalProperties, depth + 1, componentSchemas)}>`;
419+ }
344420 return "Record<string, unknown>";
345421 }
346422 const inner = propEntries
347423 .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)}`)
349425 .join("; ");
350426 return `{ ${inner} }`;
351427 }
@@ -541,12 +617,16 @@ function compactOpenApiPaths( · 189 unmodified lines
541617 operationTypeIds: Set<string>,
542618 componentParameters?: Record<string, unknown>,
543619 componentSchemas?: Record<string, unknown>,
620+ componentResponses?: Record<string, unknown>,
621+ componentRequestBodies?: Record<string, unknown>,
544622 ): Record<string, unknown> {
545623 const paths = asRecord(pathsValue);
546624 const methods = ["get", "post", "put", "delete", "patch", "head", "options"] as const;
547625 const compactPaths: Record<string, unknown> = {};
548626 const compParams = componentParameters ? asRecord(componentParameters) : {};
549627 const compSchemas = componentSchemas ? asRecord(componentSchemas) : {};
628+ const compResponses = componentResponses ? asRecord(componentResponses) : {};
629+ const compRequestBodies = componentRequestBodies ? asRecord(componentRequestBodies) : {};
550630
551631 const resolveParam = (entry: Record<string, unknown>): Record<string, unknown> => {
552632 if (typeof entry.$ref === "string") {
@@ -612,7 +692,7 @@ function compactOpenApiPaths( · 59 unmodified lines
612692 // Extract request/response schemas for type hints.
613693 // When .d.ts types exist, pre-compute lightweight hints and skip storing full schemas.
614694 {
615− const requestBody = asRecord(operation.requestBody);
695+ const requestBody = resolveRequestBodyRef(asRecord(operation.requestBody), compRequestBodies);
616696 const requestBodyContent = asRecord(requestBody.content);
617697 const requestBodySchema = resolveSchemaRef(
618698 getPreferredContentSchema(requestBodyContent),
@@ -624,8 +704,9 @@ function compactOpenApiPaths( · 5 unmodified lines
624704 let responseStatus = "";
625705 for (const [status, responseValue] of Object.entries(responses)) {
626706 if (!status.startsWith("2")) continue;
707+ const resolvedResponse = resolveResponseRef(asRecord(responseValue), compResponses);
627708 responseSchema = resolveSchemaRef(
628− getPreferredResponseSchema(asRecord(responseValue)),
709+ getPreferredResponseSchema(resolvedResponse),
629710 compSchemas,
630711 );
631712 responseStatus = status;
@@ -654,8 +735,8 @@ function compactOpenApiPaths( · 22 unmodified lines
654735 : []) as string[]),
655736 ],
656737 };
657− compactOperation._argsTypeHint = jsonSchemaTypeHintFallback(combinedSchema);
658− compactOperation._returnsTypeHint = responseTypeHintFromSchema(responseSchema, responseStatus);
738+ compactOperation._argsTypeHint = jsonSchemaTypeHintFallback(combinedSchema, 0, compSchemas);
739+ compactOperation._returnsTypeHint = responseTypeHintFromSchema(responseSchema, responseStatus, compSchemas);
659740 } else {
660741 // Keep full schemas for the fallback path
661742 if (Object.keys(requestBodySchema).length > 0) {
@@ -766,6 +847,8 @@ export async function prepareOpenApiSpec( · 104 unmodified lines
766847 operationTypeIds,
767848 asRecord(asRecord(bundled.components).parameters),
768849 asRecord(asRecord(bundled.components).schemas),
850+ asRecord(asRecord(bundled.components).responses),
851+ asRecord(asRecord(bundled.components).requestBodies),
769852 ),
770853 dts: dts ?? undefined,
771854 warnings,
executor/lib/types.ts+1−0
@@ -15,6 +15,7 @@ export type { · 14 unmodified lines
1515 CredentialRecord,
1616 ToolSourceRecord,
1717 ToolDescriptor,
18+ OpenApiSourceQuality,
1819 AnonymousContext,
1920 } from "@executor/contracts";
2021
executor/packages/contracts/src/index.ts+12−0
@@ -119,6 +119,18 @@ export interface ToolDescriptor { · 118 unmodified lines
119119 schemaTypes?: Record<string, string>;
120120 }
121121
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+
122134 export interface AnonymousContext {
123135 sessionId: string;
124136 workspaceId: string;
opencode.json+2−9
@@ -1,10 +1,3 @@
11 {
2− "$schema": "https://opencode.ai/config.json",
3− "mcp": {
4− "executor-kn7agh4e": {
5− "type": "remote",
6− "url": "https://perceptive-pigeon-577.convex.site/mcp?workspaceId=kn7agh4e6v5nx8mw03byq5f15580vg4c",
7− "enabled": true
8− }
9− }
10−}
2+ "$schema": "https://opencode.ai/config.json"
3+}