History

Enhance executor functionality and improve diagnostics

- Integrated health checks for managed backend and web services, ensuring proper status reporting. - Updated the `runDoctor` function to provide detailed feedback on backend readiness and function bootstrap status. - Added a new script function to wait for executor services to be fully ready during installation. - Refactored type hinting logic to improve clarity and maintainability in OpenAPI tool generation. - Removed obsolete end-to-end test file for anonymous MCP endpoint.

Rhys Sullivan committed 9 months ago

Commit 2a77b621 parent a96804a15 files · +591 −122Browse files

Unified · Split

15 files
  1. executor/apps/web/src/components/tools/explorer-tool-detail.tsx +2 −2
  2. executor/executor.ts +45 −1
  3. executor/install +34 −0
  4. executor/packages/convex/auth.config.ts +2 −4
  5. executor/packages/convex/http-anonymous-mcp.e2e.test.ts +0 −90
  6. executor/packages/convex/runtime/tool_source_loading.ts +1 −1
  7. executor/packages/core/src/managed-runtime-bootstrap.ts +68 −9
  8. executor/packages/core/src/mcp-server-formatting.ts +1 −1
  9. executor/packages/core/src/openapi-cache.test.ts +48 −2
  10. executor/packages/core/src/openapi-real-specs.test.ts +179 −0
  11. executor/packages/core/src/openapi-tool-builder.ts +2 −4
  12. executor/packages/core/src/tool-discovery.ts +2 −3
  13. executor/packages/core/src/tool-discovery/formatting.ts +2 −5
  14. executor/packages/core/src/type-hints.test.ts +21 −0
  15. executor/packages/core/src/type-hints.ts +184 −0
executor/apps/web/src/components/tools/explorer-tool-detail.tsx+2−2
@@ -20,8 +20,8 @@ export function ToolDetail({ · 19 unmodified lines
2020 loading?: boolean;
2121 }) {
2222 const insetLeft = depth * 20 + 8 + 16 + 8;
23− const argsType = tool.strictArgsType?.trim() || tool.argsType;
24− const returnsType = tool.strictReturnsType?.trim() || tool.returnsType;
23+ const argsType = tool.argsType?.trim() || tool.strictArgsType;
24+ const returnsType = tool.returnsType?.trim() || tool.strictReturnsType;
2525 const hasDetails = Boolean(tool.description || argsType || returnsType);
2626
2727 return (
executor/executor.ts+45−1
@@ -3,6 +3,7 @@ · 2 unmodified lines
33 import fs from "node:fs/promises";
44 import os from "node:os";
55 import path from "node:path";
6+import { checkBootstrapHealth } from "./packages/core/src/managed-runtime-bootstrap";
67 import { managedRuntimeDiagnostics, runManagedBackend, runManagedWeb } from "./packages/core/src/managed-runtime";
78
89 interface InstallPaths {
@@ -297,6 +298,18 @@ async function stopManagedServices(): Promise<{ backendStopped: boolean; webStop · 288 unmodified lines
297298 webStopped = (await terminatePidTree(pid)) && webStopped;
298299 }
299300
301+ const [remainingBackendPidsRaw, remainingWebPidsRaw] = await Promise.all([
302+ listListeningPids(Number(Bun.env.EXECUTOR_BACKEND_PORT ?? 5410)),
303+ listListeningPids(Number(Bun.env.EXECUTOR_WEB_PORT ?? 5312)),
304+ ]);
305+ const [remainingBackendPids, remainingWebPids] = await Promise.all([
306+ filterManagedPids(remainingBackendPidsRaw, "backend"),
307+ filterManagedPids(remainingWebPidsRaw, "web"),
308+ ]);
309+
310+ backendStopped = remainingBackendPids.length === 0;
311+ webStopped = remainingWebPids.length === 0;
312+
300313 await fs.rm(paths.backendPidFile, { force: true });
301314 await fs.rm(paths.webPidFile, { force: true });
302315
@@ -341,11 +354,29 @@ async function runDoctor(args: string[]): Promise<number> { · 38 unmodified lines
341354 const nodeInstalled = await pathExists(info.nodeBin);
342355 const backendRunning = await checkHttp(`${info.convexUrl}/version`);
343356 const webRunning = await checkHttp(`http://127.0.0.1:${webPort}/`);
344− const healthy = nodeInstalled && webInstalled && backendRunning && webRunning;
357+
358+ let bootstrapState: Awaited<ReturnType<typeof checkBootstrapHealth>> | null = null;
359+ if (backendRunning) {
360+ bootstrapState = await checkBootstrapHealth(info);
361+ }
362+
363+ const functionsReady = bootstrapState?.state === "ready";
364+ const healthy = nodeInstalled && webInstalled && backendRunning && webRunning && functionsReady;
345365
346366 console.log(`Executor status: ${healthy ? "ready" : "needs attention"}`);
347367 console.log(`Dashboard: http://127.0.0.1:${webPort} (${webRunning ? "running" : "not running"})`);
348368 console.log(`Backend: ${backendRunning ? "running" : "not running"} (${info.convexUrl})`);
369+ if (!backendRunning) {
370+ console.log("Functions: unavailable (backend is not running)");
371+ } else if (bootstrapState?.state === "ready") {
372+ console.log("Functions: bootstrapped");
373+ } else if (bootstrapState?.state === "no_project") {
374+ console.log("Functions: not bootstrapped (no local Convex project found)");
375+ } else if (bootstrapState?.state === "missing_functions") {
376+ console.log("Functions: not bootstrapped (missing deployed functions)");
377+ } else {
378+ console.log("Functions: check failed");
379+ }
349380 console.log(`MCP endpoint: ${info.convexSiteUrl}/mcp`);
350381
351382 if (!backendRunning) {
@@ -354,6 +385,15 @@ async function runDoctor(args: string[]): Promise<number> { · 2 unmodified lines
354385 if (!webRunning) {
355386 console.log("Next step: run `executor web`");
356387 }
388+ if (backendRunning && bootstrapState?.state === "no_project") {
389+ console.log("Next step: run from your repo root or set `EXECUTOR_PROJECT_DIR` to a Convex project.");
390+ }
391+ if (backendRunning && bootstrapState?.state === "missing_functions") {
392+ console.log("Next step: run `executor up` from your project root to bootstrap functions.");
393+ }
394+ if (backendRunning && bootstrapState?.state === "check_failed") {
395+ console.log("Next step: run `executor up` and inspect ~/.executor/runtime/logs/backend.log");
396+ }
357397 if (!webInstalled || !nodeInstalled) {
358398 console.log("Missing runtime artifacts detected. Re-run install if needed.");
359399 }
@@ -367,6 +407,10 @@ async function runDoctor(args: string[]): Promise<number> { · 7 unmodified lines
367407 console.log(` convex site: ${info.convexSiteUrl}`);
368408 console.log(` node runtime: ${nodeInstalled ? info.nodeBin : "missing"}`);
369409 console.log(` web bundle: ${webInstalled ? info.webServerEntry : "missing"}`);
410+ console.log(` functions: ${bootstrapState?.state ?? "unavailable"}`);
411+ if (bootstrapState?.detail) {
412+ console.log(` functions detail: ${bootstrapState.detail}`);
413+ }
370414 console.log(` running: backend=${backendRunning ? "yes" : "no"} web=${webRunning ? "yes" : "no"}`);
371415 console.log(` config: ${info.configPath}`);
372416 }
executor/install+34−0
@@ -293,8 +293,10 @@ start_background_services() { · 292 unmodified lines
293293
294294 if wait_for_http "http://127.0.0.1:${BACKEND_PORT}/version" 2; then
295295 echo -e "${MUTED}Managed backend already running${NC} http://127.0.0.1:${BACKEND_PORT}"
296+ "$INSTALL_DIR/$APP" up >>"$LOG_DIR/backend.log" 2>&1 || true
296297 elif [[ -f "$BACKEND_PID_FILE" ]]; then
297298 echo -e "${MUTED}Managed backend already tracked${NC} (pid $(cat "$BACKEND_PID_FILE"))"
299+ "$INSTALL_DIR/$APP" up >>"$LOG_DIR/backend.log" 2>&1 || true
298300 else
299301 nohup "$INSTALL_DIR/$APP" up >"$LOG_DIR/backend.log" 2>&1 &
300302 echo "$!" > "$BACKEND_PID_FILE"
@@ -322,6 +324,37 @@ start_background_services() { · 21 unmodified lines
322324 fi
323325 }
324326
327+wait_for_executor_ready() {
328+ local max_seconds="${1:-120}"
329+ local started
330+ started="$(date +%s)"
331+
332+ while true; do
333+ if "$INSTALL_DIR/$APP" doctor >/dev/null 2>&1; then
334+ echo -e "${MUTED}Executor services healthy${NC}"
335+ return 0
336+ fi
337+
338+ local now
339+ now="$(date +%s)"
340+ if (( now - started >= max_seconds )); then
341+ break
342+ fi
343+
344+ sleep 0.5
345+ done
346+
347+ echo -e "${RED}Executor services are running but not fully ready yet.${NC}"
348+ echo " Check: $INSTALL_DIR/$APP doctor --verbose"
349+ if [[ -f "$LOG_DIR/backend.log" ]]; then
350+ echo " Backend log: $LOG_DIR/backend.log"
351+ fi
352+ if [[ -f "$LOG_DIR/web.log" ]]; then
353+ echo " Web log: $LOG_DIR/web.log"
354+ fi
355+ return 1
356+}
357+
325358 install_web_bundle
326359
327360 if [[ "$NO_MODIFY_PATH" != "1" ]]; then
@@ -341,6 +374,7 @@ if [[ "$NO_MODIFY_PATH" != "1" ]]; then · 13 unmodified lines
341374 fi
342375
343376 start_background_services
377+wait_for_executor_ready || true
344378
345379 echo ""
346380 echo "Installed to: $INSTALL_DIR/$APP"
executor/packages/convex/auth.config.ts+2−4
@@ -1,7 +1,5 @@
1−const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env;
2−const workosClientIdKey = ["WORKOS", "CLIENT", "ID"].join("_");
3−const rawClientId = env?.[workosClientIdKey]?.trim();
4−const clientId = rawClientId && rawClientId.length > 0 ? rawClientId : undefined;
1+const rawClientId = process.env.WORKOS_CLIENT_ID?.trim();
2+const clientId = rawClientId && rawClientId !== "disabled" ? rawClientId : undefined;
53
64 const authConfig = clientId
75 ? {
executor/packages/convex/http-anonymous-mcp.e2e.test.ts+0−90
@@ -1,90 +0,0 @@
1−import { expect, test } from "bun:test";
2−import { convexTest } from "convex-test";
3−import { internal } from "./_generated/api";
4−import schema from "./schema";
5−
6−function setup() {
7− return convexTest(schema, {
8− "./database.ts": () => import("./database"),
9− "./executor.ts": () => import("./executor"),
10− "./executorNode.ts": () => import("./executorNode"),
11− "./http.ts": () => import("./http"),
12− "./auth.ts": () => import("./auth"),
13− "./workspaceAuthInternal.ts": () => import("./workspaceAuthInternal"),
14− "./workspaceToolCache.ts": () => import("./workspaceToolCache"),
15− "./openApiSpecCache.ts": () => import("./openApiSpecCache"),
16− "./_generated/api.js": () => import("./_generated/api.js"),
17− });
18−}
19−
20−test("anonymous MCP endpoint allows direct access without OAuth token", async () => {
21− const t = setup();
22− const session = await t.mutation(internal.database.bootstrapAnonymousSession, {});
23− const query = new URLSearchParams({
24− workspaceId: session.workspaceId,
25− actorId: session.actorId,
26− }).toString();
27−
28− const anonymousRes = await t.fetch(`/mcp/anonymous?${query}`, {
29− method: "POST",
30− headers: {
31− "content-type": "application/json",
32− },
33− body: JSON.stringify({}),
34− });
35− expect(anonymousRes.status).not.toBe(401);
36− expect(anonymousRes.status).not.toBe(403);
37−
38− const otherSession = await t.mutation(internal.database.bootstrapAnonymousSession, {});
39− const mismatchedQuery = new URLSearchParams({
40− workspaceId: otherSession.workspaceId,
41− actorId: session.actorId,
42− }).toString();
43− const mismatchedRes = await t.fetch(`/mcp/anonymous?${mismatchedQuery}`, {
44− method: "POST",
45− headers: {
46− "content-type": "application/json",
47− },
48− body: JSON.stringify({}),
49− });
50− expect(mismatchedRes.status).toBe(403);
51−
52− const wrongEndpointRes = await t.fetch(`/mcp?${query}`, {
53− method: "POST",
54− headers: {
55− "content-type": "application/json",
56− },
57− body: JSON.stringify({}),
58− });
59− expect(wrongEndpointRes.status).toBe(400);
60−});
61−
62−test("OAuth discovery only applies to /mcp and not /mcp/anonymous", async () => {
63− const previousAuthorizationServer = process.env.MCP_AUTHORIZATION_SERVER;
64− process.env.MCP_AUTHORIZATION_SERVER = "https://victorious-point-35-staging.authkit.app";
65−
66− const t = setup();
67− try {
68− const protectedResourceRes = await t.fetch("/.well-known/oauth-protected-resource?workspaceId=ms7ewkx14rnc73wwnqsj80cngd812kqt");
69− expect(protectedResourceRes.status).toBe(200);
70− const protectedResourceBody = await protectedResourceRes.json() as {
71− authorization_servers: string[];
72− resource: string;
73− };
74− expect(protectedResourceBody.authorization_servers[0]).toBe(process.env.MCP_AUTHORIZATION_SERVER);
75− const protectedResourceUrl = new URL(protectedResourceBody.resource);
76− expect(protectedResourceUrl.pathname).toBe("/mcp");
77−
78− const anonymousResource = encodeURIComponent(
79− `${protectedResourceUrl.origin}/mcp/anonymous?workspaceId=ms7ewkx14rnc73wwnqsj80cngd812kqt&actorId=anon_123`,
80− );
81− const anonymousRes = await t.fetch(`/.well-known/oauth-protected-resource?resource=${anonymousResource}`);
82− expect(anonymousRes.status).toBe(404);
83− } finally {
84− if (previousAuthorizationServer === undefined) {
85− delete process.env.MCP_AUTHORIZATION_SERVER;
86− } else {
87− process.env.MCP_AUTHORIZATION_SERVER = previousAuthorizationServer;
88− }
89− }
90−});
executor/packages/convex/runtime/tool_source_loading.ts+1−1
@@ -20,7 +20,7 @@ import type { ToolSourceRecord } from "../../core/src/types"; · 19 unmodified lines
2020 const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;
2121
2222 /** Cache version - bump when PreparedOpenApiSpec shape changes. */
23−const TOOL_SOURCE_CACHE_VERSION = "v16";
23+const TOOL_SOURCE_CACHE_VERSION = "v17";
2424
2525 export function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string {
2626 const parts = sources
executor/packages/core/src/managed-runtime-bootstrap.ts+68−9
@@ -122,6 +122,7 @@ async function writeBootstrapEnvFile(info: ManagedRuntimeInfo, adminKey: string) · 121 unmodified lines
122122 const contents = [
123123 `CONVEX_SELF_HOSTED_URL=http://${info.config.hostInterface}:${info.config.backendPort}`,
124124 `CONVEX_SELF_HOSTED_ADMIN_KEY=${adminKey}`,
125+ "WORKOS_CLIENT_ID=disabled",
125126 ].join("\n");
126127 await fs.writeFile(filePath, `${contents}\n`, "utf8");
127128 return filePath;
@@ -173,15 +174,16 @@ export async function waitForBackendReady(info: ManagedRuntimeInfo, timeoutMs = · 45 unmodified lines
173174 throw new Error("Timed out waiting for local Convex backend to become ready.");
174175 }
175176
176−export async function ensureProjectBootstrapped(info: ManagedRuntimeInfo): Promise<void> {
177− if (Bun.env.EXECUTOR_SKIP_BOOTSTRAP === "1") {
178− return;
179− }
177+export type BootstrapHealth = {
178+ state: "ready" | "no_project" | "missing_functions" | "check_failed";
179+ projectDir?: string;
180+ detail?: string;
181+};
180182
183+export async function checkBootstrapHealth(info: ManagedRuntimeInfo): Promise<BootstrapHealth> {
181184 const projectDir = await findProjectDir();
182185 if (!projectDir) {
183− console.log("[executor] no local Convex project found; skipping function bootstrap");
184− return;
186+ return { state: "no_project" };
185187 }
186188
187189 await ensureConvexCliRuntime(info);
@@ -189,26 +191,83 @@ export async function ensureProjectBootstrapped(info: ManagedRuntimeInfo): Promi · 1 unmodified line
189191 const envFilePath = await writeBootstrapEnvFile(info, adminKey);
190192
191193 try {
194+ const ensureWorkosClientId = await runManagedConvexCli(
195+ info,
196+ projectDir,
197+ ["env", "set", "WORKOS_CLIENT_ID", "disabled"],
198+ envFilePath,
199+ {
200+ stdout: "pipe",
201+ stderr: "pipe",
202+ },
203+ );
204+ if (ensureWorkosClientId.exitCode !== 0) {
205+ const detail = ensureWorkosClientId.stderr.trim() || ensureWorkosClientId.stdout.trim() || "unknown error";
206+ console.warn(`[executor] could not seed WORKOS_CLIENT_ID in local backend env: ${detail}`);
207+ }
208+
192209 const check = await runManagedConvexCli(info, projectDir, ["run", "app:getClientConfig"], envFilePath, {
193210 stdout: "pipe",
194211 stderr: "pipe",
195212 });
196213
197214 if (check.exitCode === 0) {
198− console.log("[executor] Convex functions already bootstrapped");
199− return;
215+ return { state: "ready", projectDir };
200216 }
201217
218+ const detail = check.stderr.trim() || check.stdout.trim() || `exit ${check.exitCode}`;
219+ return { state: "missing_functions", projectDir, detail };
220+ } catch (error) {
221+ const detail = error instanceof Error ? error.message : String(error);
222+ return { state: "check_failed", projectDir, detail };
223+ } finally {
224+ await fs.rm(path.dirname(envFilePath), { recursive: true, force: true });
225+ }
226+}
227+
228+export async function ensureProjectBootstrapped(info: ManagedRuntimeInfo): Promise<void> {
229+ if (Bun.env.EXECUTOR_SKIP_BOOTSTRAP === "1") {
230+ return;
231+ }
232+
233+ const check = await checkBootstrapHealth(info);
234+
235+ if (check.state === "no_project") {
236+ console.log("[executor] no local Convex project found; skipping function bootstrap");
237+ return;
238+ }
239+
240+ if (check.state === "ready") {
241+ console.log("[executor] Convex functions already bootstrapped");
242+ return;
243+ }
244+
245+ if (check.state === "check_failed") {
246+ const detail = check.detail ? `: ${check.detail}` : "";
247+ throw new Error(`Convex bootstrap preflight failed${detail}`);
248+ }
249+
250+ const projectDir = check.projectDir;
251+ if (!projectDir) {
252+ throw new Error("Convex bootstrap failed to resolve project directory.");
253+ }
254+
255+ const adminKey = await generateSelfHostedAdminKey(info);
256+ const envFilePath = await writeBootstrapEnvFile(info, adminKey);
257+
258+ try {
202259 console.log("[executor] bootstrapping Convex functions to local backend");
203260 const deploy = await runManagedConvexCli(
204261 info,
205262 projectDir,
206263 ["deploy", "--yes", "--typecheck", "disable", "--codegen", "disable"],
207264 envFilePath,
265+ { stdout: "pipe", stderr: "pipe" },
208266 );
209267
210268 if (deploy.exitCode !== 0) {
211− throw new Error("Convex bootstrap failed while deploying local functions.");
269+ const detail = deploy.stderr.trim() || deploy.stdout.trim() || "unknown deploy error";
270+ throw new Error(`Convex bootstrap failed while deploying local functions: ${detail}`);
212271 }
213272 } finally {
214273 await fs.rm(path.dirname(envFilePath), { recursive: true, force: true });
executor/packages/core/src/mcp-server-formatting.ts+1−1
@@ -55,7 +55,7 @@ export function buildRunCodeDescription(tools?: ToolDescriptor[]): string { · 54 unmodified lines
5555 ? `\n\nTop-level tool keys: ${topLevelKeys.join(", ")}`
5656 : "";
5757 const hasGraphqlTools = toolList.some((tool) => tool.path.endsWith(".graphql"));
58− const discoverNote = "\n\nTooling tip: avoid repeated tiny discovery calls. Start with a single broad inventory pass via `tools.catalog.namespaces({})` and `tools.catalog.tools({ namespace?, query?, compact: false, depth: 2, limit: 20 })` to get typed signatures, then do at most one focused `tools.discover({ query, compact: false, depth: 2, limit: 12 })`. `discover` returns `{ bestPath, results, total }`; prefer `bestPath` when present, otherwise copy `results[i].exampleCall`. Do not assign to `const tools = ...`; use a different variable name (e.g. `const discovered = ...`).";
58+ const discoverNote = "\n\nTooling tip: avoid repeated tiny discovery calls. Start with a single broad inventory pass via `tools.catalog.namespaces({})` and `tools.catalog.tools({ namespace?, query?, compact: true, depth: 1, limit: 20 })`, then do at most one focused `tools.discover({ query, compact: true, depth: 1, limit: 12 })`. If you still need full signatures for a final pick, rerun only that shortlist with `compact: false`. `discover` returns `{ bestPath, results, total }`; prefer `bestPath` when present, otherwise copy `results[i].exampleCall`. Do not assign to `const tools = ...`; use a different variable name (e.g. `const discovered = ...`).";
5959 const executionNote = "\n\nExecution tip: for migration/ETL-style tasks, discover once, then run in small batches and `return` compact summaries (counts, IDs, and top-N samples) instead of full objects.";
6060 const graphqlNote = hasGraphqlTools
6161 ? "\n\nGraphQL tip: prefer `source.query.*` / `source.mutation.*` helper paths when available; GraphQL tools return `{ data, errors }`."
executor/packages/core/src/openapi-cache.test.ts+48−2
@@ -456,12 +456,58 @@ describe("buildOpenApiToolsFromPrepared", () => { · 455 unmodified lines
456456 const tool = tools.find((candidate) => candidate.metadata?.operationId === "createRecord");
457457 expect(tool).toBeDefined();
458458 expect(tool?.metadata?.argPreviewKeys).toEqual(["domain", "type", "name", "value"]);
459− expect(tool?.metadata?.displayArgsType).toContain("name: ...");
460− expect(tool?.metadata?.displayArgsType).toContain("value: ...");
459+ expect(tool?.metadata?.displayArgsType).toContain("name: string");
460+ expect(tool?.metadata?.displayArgsType).toContain("value: string");
461461 expect(tool?.metadata?.argsType).toContain("name");
462462 expect(tool?.metadata?.argsType).toContain("value");
463463 });
464464
465+ test("keeps concrete primitive type for compact single-arg hints", async () => {
466+ const spec: Record<string, unknown> = {
467+ openapi: "3.0.3",
468+ info: { title: "Threads", version: "1.0.0" },
469+ servers: [{ url: "https://api.example.com" }],
470+ paths: {
471+ "/threads/{thread_id}/subscription": {
472+ delete: {
473+ operationId: "deleteThreadSubscription",
474+ tags: ["activity"],
475+ summary: "Delete a thread subscription",
476+ parameters: [
477+ {
478+ name: "thread_id",
479+ in: "path",
480+ required: true,
481+ schema: { type: "string" },
482+ },
483+ ],
484+ responses: {
485+ "204": {
486+ description: "deleted",
487+ },
488+ },
489+ },
490+ },
491+ },
492+ };
493+
494+ const prepared = await prepareOpenApiSpec(spec, "threads");
495+ const tools = buildOpenApiToolsFromPrepared(
496+ {
497+ type: "openapi",
498+ name: "github",
499+ spec,
500+ baseUrl: "https://api.example.com",
501+ },
502+ prepared,
503+ );
504+
505+ const tool = tools.find((candidate) => candidate.metadata?.operationId === "deleteThreadSubscription");
506+ expect(tool).toBeDefined();
507+ expect(tool?.metadata?.displayArgsType).toBe("{ thread_id: string }");
508+ expect(tool?.metadata?.returnsType).toBe("void");
509+ });
510+
465511 test("infers workspace auth from OpenAPI securitySchemes when source auth is unset", async () => {
466512 const spec: Record<string, unknown> = {
467513 openapi: "3.0.3",
executor/packages/core/src/openapi-real-specs.test.ts+179−0
@@ -8,6 +8,53 @@ · 7 unmodified lines
88 */
99 import { test, expect, describe } from "bun:test";
1010 import { prepareOpenApiSpec, buildOpenApiToolsFromPrepared } from "./tool-sources";
11+import { generateToolDeclarations, typecheckCode } from "./typechecker";
12+import type { ToolDefinition, ToolDescriptor } from "./types";
13+
14+function compareStrictHintsToOpenApiTypes(
15+ tool: ToolDefinition,
16+ sourceName: string,
17+ sourceDts: string,
18+) {
19+ const operationId = tool.metadata?.operationId;
20+ if (!operationId) {
21+ throw new Error(`Tool ${tool.path} is missing operationId`);
22+ }
23+
24+ const descriptor: ToolDescriptor = {
25+ path: tool.path,
26+ description: tool.description,
27+ approval: tool.approval,
28+ source: tool.source,
29+ operationId,
30+ argsType: tool.metadata?.displayArgsType,
31+ returnsType: tool.metadata?.displayReturnsType,
32+ strictArgsType: tool.metadata?.argsType,
33+ strictReturnsType: tool.metadata?.returnsType,
34+ };
35+
36+ const declarations = generateToolDeclarations([descriptor], {
37+ sourceDtsBySource: {
38+ [`openapi:${sourceName}`]: sourceDts,
39+ },
40+ });
41+
42+ const strictArgs = tool.metadata?.argsType ?? "{}";
43+ const strictReturns = tool.metadata?.returnsType ?? "unknown";
44+ const code = [
45+ `type GeneratedInput = ToolInput<operations[${JSON.stringify(operationId)}]>`,
46+ `type StrictInput = ${strictArgs}`,
47+ "const _inputA: GeneratedInput = {} as StrictInput",
48+ "const _inputB: StrictInput = {} as GeneratedInput",
49+ `type GeneratedOutput = ToolOutput<operations[${JSON.stringify(operationId)}]>`,
50+ `type StrictOutput = ${strictReturns}`,
51+ "const _outputA: GeneratedOutput = {} as StrictOutput",
52+ "const _outputB: StrictOutput = {} as GeneratedOutput",
53+ "return true;",
54+ ].join("\n");
55+
56+ return typecheckCode(code, declarations);
57+}
1158
1259 interface SpecFixture {
1360 name: string;
@@ -206,6 +253,38 @@ describe("real-world OpenAPI specs", () => { · 192 unmodified lines
206253 300_000,
207254 );
208255
256+ test(
257+ "github: add custom labels to repo runner keeps concrete compact arg hints",
258+ async () => {
259+ const githubUrl =
260+ "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml";
261+
262+ const prepared = await prepareOpenApiSpec(githubUrl, "github", { includeDts: false });
263+ const tools = buildOpenApiToolsFromPrepared(
264+ {
265+ type: "openapi",
266+ name: "github",
267+ spec: githubUrl,
268+ baseUrl: prepared.servers[0] || "https://api.github.com",
269+ },
270+ prepared,
271+ );
272+
273+ const tool = tools.find(
274+ (t) => t.metadata?.operationId === "actions/add-custom-labels-to-self-hosted-runner-for-repo",
275+ );
276+
277+ expect(tool).toBeDefined();
278+ expect(tool!.path).toBe("github.actions.add_custom_labels_to_self_hosted_runner_for_repo");
279+ expect(tool!.metadata!.displayArgsType).toContain("owner: string");
280+ expect(tool!.metadata!.displayArgsType).toContain("repo: string");
281+ expect(tool!.metadata!.displayArgsType).toContain("runner_id: number");
282+ expect(tool!.metadata!.displayArgsType).toContain("labels: string[]");
283+ expect(tool!.metadata!.displayArgsType).not.toContain("owner: ...");
284+ },
285+ 300_000,
286+ );
287+
209288 test(
210289 "github: create hosted runner for org has non-unknown return hint",
211290 async () => {
@@ -378,4 +457,104 @@ describe("real-world OpenAPI specs", () => { · 166 unmodified lines
378457 },
379458 300_000,
380459 );
460+
461+ test(
462+ "cloudflare: access authentication logs keeps non-ellipsis compact hints",
463+ async () => {
464+ const cloudflareUrl = "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.yaml";
465+
466+ const prepared = await prepareOpenApiSpec(cloudflareUrl, "cloudflare", { includeDts: false });
467+ const tools = buildOpenApiToolsFromPrepared(
468+ {
469+ type: "openapi",
470+ name: "cloudflare",
471+ spec: cloudflareUrl,
472+ baseUrl: prepared.servers[0] || "https://api.cloudflare.com/client/v4",
473+ },
474+ prepared,
475+ );
476+
477+ const tool = tools.find(
478+ (t) => t.metadata?.operationId === "access-authentication-logs-get-access-authentication-logs",
479+ );
480+
481+ expect(tool).toBeDefined();
482+ expect(tool!.path).toBe("cloudflare.access_authentication_logs.get_access_authentication_logs");
483+ expect(tool!.metadata!.displayArgsType).toContain("account_id: string");
484+ expect(tool!.metadata!.displayArgsType).toContain("direction?: \"desc\" | \"asc\"");
485+ expect(tool!.metadata!.displayArgsType).not.toContain("...");
486+ expect(tool!.metadata!.displayReturnsType).toContain("messages: { code: number");
487+ expect(tool!.metadata!.displayReturnsType).toContain("result?: {");
488+ expect(tool!.metadata!.displayReturnsType).not.toContain("...");
489+ },
490+ 300_000,
491+ );
492+
493+ test(
494+ "cloudflare: delete access application keeps concrete compact args/returns",
495+ async () => {
496+ const cloudflareUrl = "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.yaml";
497+
498+ const prepared = await prepareOpenApiSpec(cloudflareUrl, "cloudflare", { includeDts: false });
499+ const tools = buildOpenApiToolsFromPrepared(
500+ {
501+ type: "openapi",
502+ name: "cloudflare",
503+ spec: cloudflareUrl,
504+ baseUrl: prepared.servers[0] || "https://api.cloudflare.com/client/v4",
505+ },
506+ prepared,
507+ );
508+
509+ const tool = tools.find(
510+ (t) => t.metadata?.operationId === "access-applications-delete-an-access-application",
511+ );
512+
513+ expect(tool).toBeDefined();
514+ expect(tool!.metadata!.displayArgsType).toBe("{ app_id: string; account_id: string }");
515+ expect(tool!.metadata!.displayArgsType).not.toContain("...");
516+ expect(tool!.metadata!.displayReturnsType).toContain("result?: { id?: string }");
517+ expect(tool!.metadata!.displayReturnsType).not.toContain("num...");
518+ },
519+ 300_000,
520+ );
521+
522+ test(
523+ "cloudflare: add access application type hints diverge from openapi-typescript canonical types",
524+ async () => {
525+ const cloudflareUrl = "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.yaml";
526+
527+ const prepared = await prepareOpenApiSpec(cloudflareUrl, "cloudflare");
528+ const tools = buildOpenApiToolsFromPrepared(
529+ {
530+ type: "openapi",
531+ name: "cloudflare",
532+ spec: cloudflareUrl,
533+ baseUrl: prepared.servers[0] || "https://api.cloudflare.com/client/v4",
534+ },
535+ prepared,
536+ );
537+
538+ const tool = tools.find(
539+ (t) => t.metadata?.operationId === "access-applications-add-an-application",
540+ );
541+
542+ expect(tool).toBeDefined();
543+ expect(prepared.dts).toBeDefined();
544+
545+ const comparison = compareStrictHintsToOpenApiTypes(tool!, "cloudflare", prepared.dts!);
546+
547+ // Reproduces the current mismatch between fallback strict hints and generated operation types.
548+ expect(comparison.ok).toBe(false);
549+ expect(
550+ comparison.errors.some((error) => error.includes("allowed_methods") || error.includes("target_criteria")),
551+ ).toBe(true);
552+
553+ // Ensure UI/discovery hints remain compact even when strict hints are very large.
554+ expect(tool!.metadata!.displayArgsType).toContain("account_id");
555+ expect(tool!.metadata!.displayArgsType!.length).toBeLessThan(240);
556+ expect(tool!.metadata!.argsType!.length).toBeGreaterThan(8_000);
557+ },
558+ 300_000,
559+ );
381560 });
executor/packages/core/src/openapi-tool-builder.ts+2−4
@@ -9,7 +9,7 @@ import { · 8 unmodified lines
99 import { buildOpenApiToolPath } from "./openapi-tool-path";
1010 import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "./tool-source-auth";
1111 import { executeOpenApiRequest } from "./tool-source-execution";
12−import { compactArgKeysHint, compactArgTypeHint, compactReturnTypeHint } from "./type-hints";
12+import { compactArgDisplayHint, compactReturnTypeHint } from "./type-hints";
1313 import type { OpenApiToolSourceConfig, PreparedOpenApiSpec } from "./tool-source-types";
1414 import type { ToolDefinition } from "./types";
1515 import { asRecord } from "./utils";
@@ -135,9 +135,7 @@ export function buildOpenApiToolsFromPrepared( · 119 unmodified lines
135135
136136 const { argsType, returnsType, argPreviewKeys } = buildOpenApiOperationTypeHints(operation, parameters);
137137
138− const displayArgsType = argPreviewKeys.length > 0
139− ? compactArgKeysHint(argPreviewKeys)
140− : compactArgTypeHint(argsType);
138+ const displayArgsType = compactArgDisplayHint(argsType, argPreviewKeys);
141139 const displayReturnsType = compactReturnTypeHint(returnsType);
142140
143141 const approval = config.overrides?.[operationIdRaw]?.approval
executor/packages/core/src/tool-discovery.ts+2−3
@@ -1,7 +1,6 @@
11 import type { ToolDefinition } from "./types";
22 import {
3− compactArgKeysHint,
4− compactArgTypeHint,
3+ compactArgDisplayHint,
54 compactDescriptionLine,
65 compactReturnTypeHint,
76 } from "./type-hints";
@@ -95,7 +94,7 @@ export function createCatalogTools(tools: ToolDefinition[]): ToolDefinition[] { · 87 unmodified lines
9594 approval: entry.approval,
9695 description: compact ? compactDescriptionLine(entry.description) : entry.description,
9796 argsType: compact
98− ? (entry.argPreviewKeys.length > 0 ? compactArgKeysHint(entry.argPreviewKeys) : compactArgTypeHint(entry.argsType))
97+ ? compactArgDisplayHint(entry.argsType, entry.argPreviewKeys)
9998 : entry.argsType,
10099 returnsType: compact ? compactReturnTypeHint(entry.returnsType) : entry.returnsType,
101100 signature: formatSignature(entry, depth, compact),
executor/packages/core/src/tool-discovery/formatting.ts+2−5
@@ -1,6 +1,5 @@
11 import {
2− compactArgKeysHint,
3− compactArgTypeHint,
2+ compactArgDisplayHint,
43 compactReturnTypeHint,
54 extractTopLevelTypeKeys,
65 } from "../type-hints";
@@ -35,9 +34,7 @@ export function formatSignature(entry: DiscoverIndexEntry, depth: number, compac · 28 unmodified lines
3534 return "(input: ...): Promise<...>";
3635 }
3736
38− const args = entry.argPreviewKeys.length > 0
39− ? compactArgKeysHint(entry.argPreviewKeys)
40− : compactArgTypeHint(entry.argsType);
37+ const args = compactArgDisplayHint(entry.argsType, entry.argPreviewKeys);
4138 const returns = compactReturnTypeHint(entry.returnsType);
4239
4340 if (depth === 1) {
executor/packages/core/src/type-hints.test.ts+21−0
@@ -19,6 +19,18 @@ test("compactArgTypeHint keeps only top-level previews", async () => { · 18 unmodified lines
1919 expect(compact).toBe("{ parent: ...; title: ...; icon: ...; properties: ... }");
2020 });
2121
22+test("compactArgTypeHint flattens simple object intersections", async () => {
23+ const compact = compactArgTypeHint(
24+ "{ owner: string; repo: string; runner_id: number } & { labels: string[] }",
25+ );
26+ expect(compact).toBe("{ owner: string; repo: string; runner_id: number; labels: string[] }");
27+});
28+
29+test("compactArgTypeHint dedupes trivial unions while preserving concrete types", async () => {
30+ const compact = compactArgTypeHint("{ app_id: string | string; account_id: string }");
31+ expect(compact).toBe("{ app_id: string; account_id: string }");
32+});
33+
2234 test("compactArgKeysHint caps key list with ellipsis", async () => {
2335 const compact = compactArgKeysHint(["a", "b", "c", "d", "e", "f", "g", "h"]);
2436 expect(compact).toBe("{ a: ...; b: ...; c: ...; d: ...; e: ...; f: ...; ... }");
@@ -29,6 +41,15 @@ test("compactReturnTypeHint collapses graphql envelopes", async () => { · 4 unmodified lines
2941 expect(compact).toBe("{ data: ...; errors: unknown[] }");
3042 });
3143
44+test("compactReturnTypeHint flattens simple object intersections", async () => {
45+ const compact = compactReturnTypeHint(
46+ "{ errors: { code: number }[]; messages: { code: number }[]; success: true } & { result?: { id?: string } }",
47+ );
48+ expect(compact).toBe(
49+ "{ errors: { code: number }[]; messages: { code: number }[]; success: true; result?: { id?: string } }",
50+ );
51+});
52+
3253 test("compactDescriptionLine trims to a single concise line", async () => {
3354 const compact = compactDescriptionLine("Create a database in Notion.\nThis endpoint supports parent/page context.");
3455 expect(compact).toBe("Create a database in Notion.");
executor/packages/core/src/type-hints.ts+184−0
@@ -4,6 +4,134 @@ function truncateInline(value: string, maxLength: number): string { · 3 unmodified lines
44 return `${normalized.slice(0, Math.max(16, maxLength - 3)).trim()}...`;
55 }
66
7+function splitTopLevelBy(value: string, separator: string): string[] {
8+ const parts: string[] = [];
9+ let segment = "";
10+ let depthCurly = 0;
11+ let depthSquare = 0;
12+ let depthParen = 0;
13+ let depthAngle = 0;
14+
15+ for (const char of value) {
16+ if (char === "{") depthCurly += 1;
17+ else if (char === "}") depthCurly = Math.max(0, depthCurly - 1);
18+ else if (char === "[") depthSquare += 1;
19+ else if (char === "]") depthSquare = Math.max(0, depthSquare - 1);
20+ else if (char === "(") depthParen += 1;
21+ else if (char === ")") depthParen = Math.max(0, depthParen - 1);
22+ else if (char === "<") depthAngle += 1;
23+ else if (char === ">") depthAngle = Math.max(0, depthAngle - 1);
24+
25+ if (
26+ char === separator
27+ && depthCurly === 0
28+ && depthSquare === 0
29+ && depthParen === 0
30+ && depthAngle === 0
31+ ) {
32+ const trimmed = segment.trim();
33+ if (trimmed.length > 0) parts.push(trimmed);
34+ segment = "";
35+ continue;
36+ }
37+
38+ segment += char;
39+ }
40+
41+ const trimmed = segment.trim();
42+ if (trimmed.length > 0) parts.push(trimmed);
43+ return parts;
44+}
45+
46+function normalizeSimpleUnion(typeExpression: string): string {
47+ const parts = splitTopLevelBy(typeExpression, "|")
48+ .map((part) => part.trim())
49+ .filter((part) => part.length > 0);
50+ if (parts.length < 2) return typeExpression;
51+ if (parts.some((part) => /[{};]/.test(part))) return typeExpression;
52+
53+ const unique: string[] = [];
54+ for (const part of parts) {
55+ if (!unique.includes(part)) unique.push(part);
56+ }
57+
58+ if (unique.length === parts.length) return typeExpression;
59+ return unique.join(" | ");
60+}
61+
62+function normalizeFlatObjectHint(typeHint: string): string | null {
63+ const trimmed = typeHint.trim();
64+ if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null;
65+
66+ const inner = trimmed.slice(1, -1);
67+ const segments = splitTopLevelBy(inner, ";");
68+ if (segments.length === 0) return null;
69+
70+ const normalizedSegments: string[] = [];
71+ for (const rawSegment of segments) {
72+ const segment = rawSegment.trim();
73+ if (!segment) continue;
74+
75+ const colonIndex = segment.indexOf(":");
76+ if (colonIndex <= 0) {
77+ normalizedSegments.push(segment);
78+ continue;
79+ }
80+
81+ const key = segment.slice(0, colonIndex).trim();
82+ const value = normalizeSimpleUnion(segment.slice(colonIndex + 1).trim());
83+ normalizedSegments.push(`${key}: ${value}`);
84+ }
85+
86+ if (normalizedSegments.length === 0) return null;
87+ return `{ ${normalizedSegments.join("; ")} }`;
88+}
89+
90+function normalizeIntersectionObjectHint(
91+ typeHint: string,
92+ options: { maxParts?: number; maxKeys?: number; maxLength?: number } = {},
93+): string | null {
94+ const maxParts = options.maxParts ?? 6;
95+ const maxKeys = options.maxKeys ?? 8;
96+ const maxLength = options.maxLength ?? 140;
97+ const parts = splitTopLevelBy(typeHint, "&");
98+ if (parts.length < 2 || parts.length > maxParts) return null;
99+
100+ const orderedKeys: string[] = [];
101+ const keyToType = new Map<string, string>();
102+
103+ for (const part of parts) {
104+ const trimmed = part.trim();
105+ if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null;
106+
107+ const inner = trimmed.slice(1, -1);
108+ const segments = splitTopLevelBy(inner, ";");
109+ for (const rawSegment of segments) {
110+ const segment = rawSegment.trim();
111+ if (!segment || segment.startsWith("[")) continue;
112+ const colonIndex = segment.indexOf(":");
113+ if (colonIndex <= 0) continue;
114+
115+ const key = segment.slice(0, colonIndex).trim().replace(/["']/g, "");
116+ if (!key) continue;
117+ const value = normalizeSimpleUnion(segment.slice(colonIndex + 1).trim());
118+ if (!value) continue;
119+
120+ if (!keyToType.has(key)) {
121+ orderedKeys.push(key);
122+ keyToType.set(key, value);
123+ }
124+ }
125+ }
126+
127+ if (orderedKeys.length === 0 || orderedKeys.length > maxKeys) return null;
128+
129+ const entries = orderedKeys.map((key) => `${key}: ${keyToType.get(key)}`);
130+ const compact = `{ ${entries.join("; ")} }`;
131+ if (compact.length > maxLength) return null;
132+ return compact;
133+}
134+
7135 export function extractTopLevelTypeKeys(typeHint: string): string[] {
8136 const text = typeHint.trim();
9137 if (!text.startsWith("{") || !text.endsWith("}")) return [];
@@ -70,6 +198,30 @@ export function compactArgKeysHint(keys: string[]): string { · 60 unmodified lines
70198
71199 export function compactArgTypeHint(argsType: string): string {
72200 if (argsType === "{}") return "{}";
201+ const normalized = argsType.replace(/\s+/g, " ").trim();
202+
203+ const flattenedIntersection = normalizeIntersectionObjectHint(normalized, { maxLength: 160 });
204+ if (flattenedIntersection) {
205+ return flattenedIntersection;
206+ }
207+
208+ const inlineFriendlyObject = normalized.startsWith("{")
209+ && normalized.endsWith("}")
210+ && normalized.length <= 100
211+ && !normalized.includes("&");
212+ if (inlineFriendlyObject) {
213+ return normalizeFlatObjectHint(normalized) ?? normalized;
214+ }
215+
216+ const inlineObjectWithSimpleUnions = normalized.startsWith("{")
217+ && normalized.endsWith("}")
218+ && normalized.length <= 220
219+ && !normalized.includes("&")
220+ && !normalized.slice(1, -1).includes("{")
221+ && !normalized.slice(1, -1).includes("}");
222+ if (inlineObjectWithSimpleUnions) {
223+ return normalizeFlatObjectHint(normalized) ?? normalized;
224+ }
73225 const keys = extractTopLevelTypeKeys(argsType);
74226 if (keys.length > 0) {
75227 return compactArgKeysHint(keys);
@@ -77,11 +229,43 @@ export function compactArgTypeHint(argsType: string): string { · 1 unmodified line
77229 return truncateInline(argsType, 120);
78230 }
79231
232+export function compactArgDisplayHint(argsType: string, argPreviewKeys: string[] = []): string {
233+ const compactFromType = compactArgTypeHint(argsType);
234+ if (compactFromType !== "{}" && compactFromType.includes(":")) {
235+ const normalized = compactFromType.replace(/\s+/g, " ").trim();
236+ if (!normalized.includes("...")) {
237+ return compactFromType;
238+ }
239+ }
240+
241+ if (argPreviewKeys.length > 0) {
242+ return compactArgKeysHint(argPreviewKeys);
243+ }
244+
245+ return compactFromType;
246+}
247+
80248 export function compactReturnTypeHint(returnsType: string): string {
81249 const normalized = returnsType.replace(/\s+/g, " ").trim();
82250 if (normalized.startsWith("{ data:") && normalized.includes("errors:")) {
83251 return "{ data: ...; errors: unknown[] }";
84252 }
253+ const flattenedIntersection = normalizeIntersectionObjectHint(normalized, {
254+ maxParts: 6,
255+ maxKeys: 16,
256+ maxLength: 520,
257+ });
258+ if (flattenedIntersection) {
259+ return flattenedIntersection;
260+ }
261+ const inlineFriendlyObject = normalized.startsWith("{")
262+ && normalized.endsWith("}")
263+ && normalized.length <= 520
264+ && !normalized.includes("|")
265+ && !normalized.includes("&");
266+ if (inlineFriendlyObject) {
267+ return normalized;
268+ }
85269 if (normalized.endsWith("[]") && normalized.length > 90) {
86270 return "Array<...>";
87271 }

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t