Skip to content

Commit dabe51f

Browse files
committed
Add executor generate: typed TypeScript client for the tool catalog
Running executor generate against an instance writes one self-contained TypeScript file: a dependency-free Proxy-based runtime client plus full input/output types for every visible tool, so client.github.org.main.issues.create({ title }) is typed end to end and executes through the server's /api/executions endpoint (auth, policies, approvals included). New pieces: - tools.export SDK surface + GET /tools/export: the bulk schema-bearing catalog read (schemas + trimmed shared $defs, grouped per connection, policy-filtered), one request regardless of catalog size. - compileToolChunkTypeScript: compiles many tools per compiler pass. Per-tool passes pay fixed overhead thousands of times and one whole-catalog pass is super-linear (30s+ at 10k tools); 200-tool chunks generate a 10k-tool catalog in ~400ms. - generateToolProxySource: assembles the generated file (type-only namespaces per connection, ExecutorTools path tree, embedded runtime). - executor generate CLI command with --output/--integration/--connection/ --include-static, reusing the server-target and auth machinery. Tested down to the wire: generated source is typechecked with the real compiler (valid calls accept, invalid reject), the transpiled runtime is imported and run against a fake fetch (completed/paused/injection paths), and a scale test ingests a 10,000-operation OpenAPI spec through addSpec and proves export + generate + strict typecheck hold up with regression tripwires on time.
1 parent e39dbcf commit dabe51f

14 files changed

Lines changed: 1733 additions & 5 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@executor-js/sdk": minor
3+
"executor": minor
4+
---
5+
6+
Add `executor generate`: emit a typed TypeScript client for an instance's tool catalog.
7+
8+
Running `executor generate` against a server writes a single self-contained
9+
file (default `executor.gen.ts`) with a dependency-free runtime client and
10+
full input/output types for every visible tool, so
11+
`client.github.org.main.issues.create({ title })` is typed end to end and
12+
calls go through the server's execution endpoint (auth, policies, and
13+
approvals included). New `GET /tools/export` endpoint and
14+
`executor.tools.export()` SDK surface return the whole schema-bearing catalog
15+
in one read; generation compiles schemas in chunks and stays fast at
16+
10,000-tool scale.

apps/cli/src/main.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
6969

7070
import { ExecutorApi, checkForUpdate } from "@executor-js/api";
7171
import {
72+
ConnectionName,
73+
IntegrationSlug,
7274
getExecutorServerAuthorizationHeader,
7375
normalizeExecutorServerConnection,
7476
normalizeExecutorServerOrigin,
@@ -77,6 +79,7 @@ import {
7779
type ExecutorServerConnection,
7880
type ExecutorServerConnectionInput,
7981
} from "@executor-js/sdk/shared";
82+
import { generateToolProxySource } from "@executor-js/sdk/core";
8083
import {
8184
decodeAccessTokenClaims,
8285
discoverCliLogin,
@@ -2710,6 +2713,105 @@ const mcpCommand = Command.make(
27102713
}),
27112714
).pipe(Command.withDescription("Start an MCP server over stdio"));
27122715

2716+
// ---------------------------------------------------------------------------
2717+
// Generate — emit a typed TypeScript client for the instance's tool catalog
2718+
// ---------------------------------------------------------------------------
2719+
2720+
const generateCommand = Command.make(
2721+
"generate",
2722+
{
2723+
output: Options.string("output").pipe(
2724+
Options.withAlias("o"),
2725+
Options.withDefault("executor.gen.ts"),
2726+
Options.withDescription("Path of the generated TypeScript file."),
2727+
),
2728+
integration: Options.string("integration").pipe(
2729+
Options.optional,
2730+
Options.withDescription("Only generate tools from this integration slug."),
2731+
),
2732+
connection: Options.string("connection").pipe(
2733+
Options.optional,
2734+
Options.withDescription("Only generate tools from this connection name."),
2735+
),
2736+
includeStatic: Options.boolean("include-static").pipe(
2737+
Options.withDefault(false),
2738+
Options.withDescription(
2739+
"Include Executor's built-in static tools (executor.*, search, describe.*).",
2740+
),
2741+
),
2742+
baseUrl: serverBaseUrl,
2743+
server: serverProfile,
2744+
scope,
2745+
},
2746+
({ output, integration, connection, includeStatic, baseUrl, server, scope }) =>
2747+
Effect.gen(function* () {
2748+
applyScope(scope);
2749+
const target = serverTargetFromOptions({ baseUrl, server });
2750+
const serverConnection = yield* resolveExecutorServerConnection(target);
2751+
const client = yield* makeApiClient(serverConnection);
2752+
2753+
const catalog = yield* client.tools
2754+
.export({
2755+
query: {
2756+
...(Option.isSome(integration)
2757+
? { integration: IntegrationSlug.make(integration.value) }
2758+
: {}),
2759+
...(Option.isSome(connection)
2760+
? { connection: ConnectionName.make(connection.value) }
2761+
: {}),
2762+
},
2763+
})
2764+
.pipe(Effect.mapError(toError));
2765+
2766+
const filtered = includeStatic
2767+
? catalog
2768+
: {
2769+
connections: catalog.connections
2770+
.map((entry) => ({
2771+
...entry,
2772+
tools: entry.tools.filter((tool) => tool.static !== true),
2773+
}))
2774+
.filter((entry) => entry.tools.length > 0),
2775+
};
2776+
2777+
const generated = yield* Effect.try({
2778+
try: () => generateToolProxySource(filtered),
2779+
catch: (cause) =>
2780+
cause instanceof Error
2781+
? cause
2782+
: new Error(`Failed to generate TypeScript source: ${String(cause)}`),
2783+
});
2784+
2785+
if (generated.toolCount === 0) {
2786+
return yield* Effect.fail(
2787+
new Error(
2788+
[
2789+
"No tools matched, nothing to generate.",
2790+
`Server: ${serverConnection.origin}`,
2791+
`Check filters, or add an integration first: ${cliPrefix} web`,
2792+
].join("\n"),
2793+
),
2794+
);
2795+
}
2796+
2797+
const fs = yield* FileSystem.FileSystem;
2798+
const path = yield* PlatformPath.Path;
2799+
const outputPath = path.isAbsolute(output) ? output : path.join(process.cwd(), output);
2800+
yield* fs.makeDirectory(path.dirname(outputPath), { recursive: true });
2801+
yield* fs.writeFileString(outputPath, generated.source);
2802+
2803+
console.log(
2804+
`Generated ${outputPath} (${generated.toolCount} tools, ${generated.connectionCount} connections).`,
2805+
);
2806+
console.log("");
2807+
console.log("Use it:");
2808+
console.log(` import { createExecutorClient } from "./${path.basename(output, ".ts")}";`);
2809+
console.log(` const executor = createExecutorClient();`);
2810+
}).pipe(Effect.mapError(toError)),
2811+
).pipe(
2812+
Command.withDescription("Generate a typed TypeScript client for this instance's tool catalog"),
2813+
);
2814+
27132815
// ---------------------------------------------------------------------------
27142816
// Service — register the daemon with the OS so it survives app-quit + restart
27152817
// ---------------------------------------------------------------------------
@@ -3112,6 +3214,7 @@ const root = Command.make("executor").pipe(
31123214
daemonCommand,
31133215
serviceCommand,
31143216
mcpCommand,
3217+
generateCommand,
31153218
openCommand,
31163219
docsCommand,
31173220
] as const),

apps/docs/local/cli.mdx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,28 @@ resume it:
109109
```bash
110110
executor resume --execution-id exec_123
111111
```
112+
113+
## Generate a typed client
114+
115+
`executor generate` writes a single self-contained TypeScript file with full
116+
input/output types for every tool in your catalog, plus a dependency-free
117+
runtime client. Drop it into any TypeScript project and every call is typed
118+
end to end, while still executing through Executor (auth, policies, and
119+
approvals included):
120+
121+
```bash
122+
executor generate # writes executor.gen.ts
123+
executor generate -o src/executor.ts # custom output path
124+
executor generate --integration github # only one integration
125+
```
126+
127+
```ts
128+
import { createExecutorClient } from "./executor.gen";
129+
130+
const executor = createExecutorClient(); // EXECUTOR_API_KEY / EXECUTOR_AUTH_TOKEN
131+
const created = await executor.github.org.main.issues.create({ title: "Hi" });
132+
if (created.ok) console.log(created.data.number);
133+
```
134+
135+
Calls that pause for approval throw `ExecutorPausedError` with the approval
136+
URL. Re-run `executor generate` whenever your catalog changes.

packages/core/api/src/handlers/tools.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,5 +49,20 @@ export const ToolsHandlers = HttpApiBuilder.group(ExecutorApi, "tools", (handler
4949
return schema;
5050
}),
5151
),
52+
)
53+
.handle("export", ({ query }) =>
54+
capture(
55+
Effect.gen(function* () {
56+
const executor = yield* ExecutorService;
57+
return yield* executor.tools.export({
58+
integration: query.integration,
59+
owner: query.owner,
60+
connection: query.connection,
61+
query: query.query,
62+
includeAnnotations: query.includeAnnotations === "true",
63+
includeBlocked: query.includeBlocked === "true",
64+
});
65+
}),
66+
),
5267
),
5368
);

packages/core/api/src/tools/api.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
InternalError,
1717
Owner,
1818
ToolAddress,
19+
ToolCatalogExport,
1920
ToolNotFoundError,
2021
ToolSchemaView,
2122
} from "@executor-js/sdk/shared";
@@ -82,4 +83,15 @@ export const ToolsApi = HttpApiGroup.make("tools")
8283
success: ToolSchemaView,
8384
error: [InternalError, ToolNotFound],
8485
}),
86+
)
87+
.add(
88+
// Bulk schema-bearing read for codegen (`executor generate`): every
89+
// visible tool's input/output JSON schema grouped per connection with the
90+
// connection's shared `$defs`. One request regardless of catalog size —
91+
// per-tool `schema` round trips do not scale to 10k-tool catalogs.
92+
HttpApiEndpoint.get("export", "/tools/export", {
93+
query: ListToolsQuery,
94+
success: ToolCatalogExport,
95+
error: InternalError,
96+
}),
8597
);

packages/core/execution/src/promise.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ const wrapPromiseExecutor = (pe: PromiseExecutor): EffectExecutor => {
109109
fromPromise(() => pe.tools.list(filter)),
110110
schema: (address: Parameters<PromiseExecutor["tools"]["schema"]>[0]) =>
111111
fromPromise(() => pe.tools.schema(address)),
112+
export: (filter?: Parameters<PromiseExecutor["tools"]["export"]>[0]) =>
113+
fromPromise(() => pe.tools.export(filter)),
112114
},
113115
providers: {
114116
list: () => fromPromise(() => pe.providers.list()),

packages/core/sdk/src/executor.ts

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,13 @@ import {
136136
ORG_SUBJECT,
137137
type ExecutorOwnerPolicyContext,
138138
} from "./owner-policy";
139-
import { ToolSchemaView, type IntegrationDetectionResult } from "./types";
139+
import {
140+
ToolCatalogExport,
141+
ToolSchemaView,
142+
type IntegrationDetectionResult,
143+
type ToolCatalogConnectionExport,
144+
type ToolCatalogToolExport,
145+
} from "./types";
140146
import { type Tool, type ToolAnnotations, type ToolDef, type ToolListFilter } from "./tool";
141147
import { buildToolTypeScriptPreview } from "./schema-types";
142148
import { collectReferencedDefinitions } from "./schema-refs";
@@ -304,6 +310,9 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = readonly []> = {
304310
readonly tools: {
305311
readonly list: (filter?: ToolListFilter) => Effect.Effect<readonly Tool[], StorageFailure>;
306312
readonly schema: (address: ToolAddress) => Effect.Effect<ToolSchemaView | null, StorageFailure>;
313+
/** Bulk schema-bearing read for codegen: every visible tool's input/output
314+
* JSON schema, grouped per connection with trimmed shared `$defs`. */
315+
readonly export: (filter?: ToolListFilter) => Effect.Effect<ToolCatalogExport, StorageFailure>;
307316
};
308317

309318
readonly providers: {
@@ -2752,6 +2761,126 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
27522761
});
27532762
});
27542763

2764+
// The bulk schema-bearing read behind `executor generate`. One pass over
2765+
// full tool rows (schemas included, unlike the projected `toolsList`),
2766+
// policy-filtered like `toolsList`, grouped per connection with that
2767+
// connection's `$defs` trimmed to the referenced subset. Kept as a single
2768+
// surface instead of N `toolSchema` calls: a 10k-tool catalog must not pay
2769+
// 10k round trips or 10k per-tool definition queries.
2770+
const toolsExport = (
2771+
filter?: ToolListFilter,
2772+
): Effect.Effect<ToolCatalogExport, StorageFailure> =>
2773+
Effect.gen(function* () {
2774+
yield* syncStaleConnectionTools;
2775+
const rows = yield* core.findMany("tool", {
2776+
where: (b: AnyCb) =>
2777+
b.and(
2778+
filter?.integration === undefined
2779+
? true
2780+
: b("integration", "=", String(filter.integration)),
2781+
filter?.owner === undefined ? true : b("owner", "=", filter.owner),
2782+
filter?.connection === undefined
2783+
? true
2784+
: b("connection", "=", String(filter.connection)),
2785+
),
2786+
});
2787+
const includeBlocked = filter?.includeBlocked ?? false;
2788+
const policyRules = yield* listActivePolicyRuleSet();
2789+
2790+
type ConnectionGroup = {
2791+
readonly owner: Owner;
2792+
readonly integration: IntegrationSlug;
2793+
readonly connection: ConnectionName;
2794+
readonly tools: ToolCatalogToolExport[];
2795+
};
2796+
const groups = new Map<string, ConnectionGroup>();
2797+
const groupFor = (tool: Tool): ConnectionGroup => {
2798+
const key = `${tool.owner}${tool.integration}${tool.connection}`;
2799+
let group = groups.get(key);
2800+
if (!group) {
2801+
group = {
2802+
owner: tool.owner,
2803+
integration: tool.integration,
2804+
connection: tool.connection,
2805+
tools: [],
2806+
};
2807+
groups.set(key, group);
2808+
}
2809+
return group;
2810+
};
2811+
2812+
const visible = (tool: Tool): Effect.Effect<boolean, StorageFailure> =>
2813+
Effect.gen(function* () {
2814+
if (!matchesToolFilter(tool, filter)) return false;
2815+
if (includeBlocked) return true;
2816+
const effective = yield* resolvePolicyFromRuleSet(
2817+
normalizedPolicyId(tool),
2818+
policyRules,
2819+
tool.annotations?.requiresApproval,
2820+
);
2821+
return effective.action !== "block";
2822+
});
2823+
2824+
for (const row of rows) {
2825+
const tool = rowToTool(row);
2826+
if (!(yield* visible(tool))) continue;
2827+
groupFor(tool).tools.push({
2828+
address: tool.address,
2829+
name: String(tool.name),
2830+
description: tool.description,
2831+
inputSchema: tool.inputSchema,
2832+
outputSchema: tool.outputSchema,
2833+
});
2834+
}
2835+
for (const entry of staticTools.values()) {
2836+
const tool = staticToolToTool(entry);
2837+
if (!(yield* visible(tool))) continue;
2838+
groupFor(tool).tools.push({
2839+
address: tool.address,
2840+
name: String(tool.name),
2841+
description: tool.description,
2842+
inputSchema: tool.inputSchema,
2843+
outputSchema: tool.outputSchema,
2844+
static: true,
2845+
});
2846+
}
2847+
2848+
const connectionsOut: ToolCatalogConnectionExport[] = [];
2849+
for (const group of groups.values()) {
2850+
// Static pseudo-connections have no definition rows; skip the query.
2851+
const isStatic = group.tools.every((tool) => tool.static === true);
2852+
let definitions: Record<string, unknown> | undefined;
2853+
if (!isStatic) {
2854+
const definitionRows = yield* core.findMany("definition", {
2855+
where: (b: AnyCb) =>
2856+
b.and(
2857+
byOwner(group.owner)(b),
2858+
b("integration", "=", String(group.integration)),
2859+
b("connection", "=", String(group.connection)),
2860+
),
2861+
});
2862+
const defs = new Map<string, unknown>();
2863+
for (const def of definitionRows) defs.set(def.name, decodeJsonColumn(def.schema));
2864+
const referenced = collectReferencedDefinitions(
2865+
group.tools.flatMap((tool) => [tool.inputSchema, tool.outputSchema]),
2866+
defs,
2867+
);
2868+
if (Object.keys(referenced).length > 0) {
2869+
definitions = referenced;
2870+
}
2871+
}
2872+
connectionsOut.push({
2873+
owner: group.owner,
2874+
integration: group.integration,
2875+
connection: group.connection,
2876+
tools: group.tools,
2877+
...(definitions !== undefined ? { definitions } : {}),
2878+
});
2879+
}
2880+
2881+
return ToolCatalogExport.make({ connections: connectionsOut });
2882+
});
2883+
27552884
// ------------------------------------------------------------------
27562885
// Providers
27572886
// ------------------------------------------------------------------
@@ -3465,6 +3594,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
34653594
tools: {
34663595
list: toolsList,
34673596
schema: toolSchema,
3597+
export: toolsExport,
34683598
},
34693599
providers: {
34703600
list: providersList,

0 commit comments

Comments
 (0)