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
21 return undefined;21 return undefined;
22}22}
23 23
24const AUTHKIT_PASSTHROUGH_QUERY_KEYS = [
25 "authorization_session_id",
26 "redirect_uri",
27 "state",
28];
29
30function 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
24const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL ?? process.env.CONVEX_URL;44const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL ?? process.env.CONVEX_URL;
25const convexClient = convexUrl ? new ConvexHttpClient(convexUrl) : null;45const 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}
executor/apps/web/src/components/tools-view.tsx+59−5
@@ -48,7 +48,13 @@ import { useSession } from "@/lib/session-context"; · 47 unmodified lines
48import { useWorkspaceTools } from "@/hooks/use-workspace-tools";48import { useWorkspaceTools } from "@/hooks/use-workspace-tools";
49import { useAction, useMutation, useQuery } from "convex/react";49import { useAction, useMutation, useQuery } from "convex/react";
50import { convexApi } from "@/lib/convex-api";50import { convexApi } from "@/lib/convex-api";
51import type { ToolSourceRecord, ToolDescriptor, CredentialRecord, CredentialScope } from "@/lib/types";51import type {
52 ToolSourceRecord,
53 ToolDescriptor,
54 CredentialRecord,
55 CredentialScope,
56 OpenApiSourceQuality,
57} from "@/lib/types";
52import { parse as parseDomain } from "tldts";58import { parse as parseDomain } from "tldts";
53import { toast } from "sonner";59import { toast } from "sonner";
54import { cn } from "@/lib/utils";60import { 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
305function formatQualityPercent(value: number): string {
306 return `${Math.round(value * 100)}%`;
307}
308
309function 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
301const RAW_HOSTS = new Set([321const RAW_HOSTS = new Set([
@@ -785,8 +805,12 @@ function ConfigureSourceAuthDialog({ · 483 unmodified lines
785 805
786function SourceCard({806function 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>
executor/apps/web/src/hooks/use-workspace-tools.ts+4−1
@@ -3,6 +3,7 @@ · 2 unmodified lines
3import { useQuery as useTanstackQuery } from "@tanstack/react-query";3import { useQuery as useTanstackQuery } from "@tanstack/react-query";
4import { useAction, useQuery as useConvexQuery } from "convex/react";4import { useAction, useQuery as useConvexQuery } from "convex/react";
5import { convexApi } from "@/lib/convex-api";5import { convexApi } from "@/lib/convex-api";
6import type { OpenApiSourceQuality } from "@/lib/types";
6 7
7interface WorkspaceContext {8interface 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}
executor/apps/web/src/lib/types.ts+1−0
@@ -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
executor/convex/executorNode.ts+87−5
@@ -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
58function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string {59function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string {
59 const signatureVersion = "v4";60 const signatureVersion = "v8";
60 const parts = sources61 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
131const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;132const 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. */
134const OPENAPI_CACHE_VERSION = "v7";135const OPENAPI_CACHE_VERSION = "v11";
135 136
136async function publish(137async 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
423function 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
422function listVisibleToolDescriptors(484function 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
453async function listToolsWithWarningsForContext(515async 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 blobs532 // 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});
executor/lib/openapi-cache.test.ts+339−0
@@ -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",
executor/lib/openapi-real-specs.test.ts+60−0
@@ -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 () => {
executor/lib/tool_sources.ts+94−11
@@ -266,6 +266,42 @@ function resolveSchemaRef( · 265 unmodified lines
266 return resolved;266 return resolved;
267}267}
268 268
269function 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
287function 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
269function parameterSchemaFromEntry(entry: Record<string, unknown>): Record<string, unknown> {305function 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
294function responseTypeHintFromSchema(responseSchema: Record<string, unknown>, responseStatus: string): string {330function 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) */
308function jsonSchemaTypeHintFallback(schema: unknown, depth = 0): string {348function 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 = propEntries422 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 path741 // 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,
executor/lib/types.ts+1−0
@@ -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
executor/packages/contracts/src/index.ts+12−0
@@ -119,6 +119,18 @@ export interface ToolDescriptor { · 118 unmodified lines
119 schemaTypes?: Record<string, string>;119 schemaTypes?: Record<string, string>;
120}120}
121 121
122export 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
122export interface AnonymousContext {134export interface AnonymousContext {
123 sessionId: string;135 sessionId: string;
124 workspaceId: string;136 workspaceId: string;
opencode.json+2−9
@@ -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}