History

Harden scoped toolkit MCP sessions

Rhys Sullivan committed 4 months ago

Commit bfe23bd1 parent f6354f418 files · +1189 −158Browse files

Unified · Split

18 files
  1. apps/host-cloudflare/src/db/data-migrations.test.ts +149 −1
  2. apps/host-cloudflare/src/db/data-migrations.ts +98 −3
  3. apps/host-cloudflare/src/mcp/auth.ts +65 −3
  4. apps/host-cloudflare/wrangler.jsonc +1 −1
  5. apps/local/executor.config.ts +9 −2
  6. apps/local/package.json +1 −0
  7. apps/local/src/executor.ts +70 −41
  8. apps/local/src/main.ts +29 −8
  9. apps/local/src/mcp.ts +146 −28
  10. apps/local/src/serve.ts +51 −17
  11. apps/local/vite.config.ts +50 −19
  12. bun.lock +1 −0
  13. e2e/local/toolkits-mcp.test.ts +320 −0
  14. e2e/scenarios/toolkits-mcp.test.ts +13 −19
  15. e2e/setup/cloudflare.boot.ts +2 −0
  16. e2e/src/surfaces/mcp.ts +61 −0
  17. packages/plugins/google/src/sdk/openapi-ownership-migration.test.ts +116 −13
  18. packages/plugins/google/src/sdk/openapi-ownership-migration.ts +7 −3
apps/host-cloudflare/src/db/data-migrations.test.ts+149−1
@@ -32,7 +32,10 @@ const makeFakeD1 = (client: SqliteDataMigrationClient): D1Database => { · 31 unmodified lines
32 } as unknown as D1Database;32 } as unknown as D1Database;
33};33};
34 34
35const makeFakeR2 = (): { readonly bucket: R2Bucket; readonly objects: Map<string, string> } => {35const makeFakeR2 = (): {
36 readonly bucket: R2Bucket;
37 readonly objects: Map<string, string>;
38} => {
36 const objects = new Map<string, string>();39 const objects = new Map<string, string>();
37 // oxlint-disable-next-line executor/no-double-cast -- test double: only the R2 methods used by the migration are implemented40 // oxlint-disable-next-line executor/no-double-cast -- test double: only the R2 methods used by the migration are implemented
38 const bucket = {41 const bucket = {
@@ -75,6 +78,23 @@ const insertIntegration = ( · 36 unmodified lines
75 ],78 ],
76 });79 });
77 80
81const insertIntegrationRawConfig = (
82 client: SqliteDataMigrationClient,
83 row: {
84 readonly rowId: string;
85 readonly tenant: string;
86 readonly slug: string;
87 readonly pluginId: string;
88 readonly config: string;
89 },
90) =>
91 client.execute({
92 sql: `INSERT INTO integration
93 (row_id, tenant, slug, plugin_id, name, description, config, can_remove, can_refresh, created_at, updated_at)
94 VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?)`,
95 args: [row.rowId, row.tenant, row.slug, row.pluginId, row.slug, row.slug, row.config, now, now],
96 });
97
78const insertOperationStorage = (98const insertOperationStorage = (
79 client: SqliteDataMigrationClient,99 client: SqliteDataMigrationClient,
80 row: {100 row: {
@@ -103,6 +123,134 @@ const insertOperationStorage = ( · 22 unmodified lines
103 });123 });
104 124
105describe("runCloudflareDataMigrations", () => {125describe("runCloudflareDataMigrations", () => {
126 it.effect("rebuilds legacy connection tables from item_id to item_ids", () =>
127 Effect.gen(function* () {
128 const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() }));
129 const { bucket } = makeFakeR2();
130
131 yield* Effect.promise(() => db.client.execute("DROP TABLE connection"));
132 yield* Effect.promise(() =>
133 db.client.execute(`
134 CREATE TABLE connection (
135 integration text NOT NULL,
136 name text NOT NULL,
137 template text NOT NULL,
138 provider text NOT NULL,
139 item_id text NOT NULL,
140 identity_label text,
141 description text,
142 tools_synced_at integer,
143 oauth_client text,
144 oauth_client_owner text,
145 refresh_item_id text,
146 expires_at integer,
147 oauth_scope text,
148 oauth_token_url text,
149 provider_state text,
150 created_at integer NOT NULL,
151 updated_at integer NOT NULL,
152 row_id text PRIMARY KEY NOT NULL,
153 tenant text NOT NULL,
154 owner text NOT NULL,
155 subject text NOT NULL
156 )
157 `),
158 );
159 yield* Effect.promise(() =>
160 db.client.execute({
161 sql: `INSERT INTO connection
162 (integration, name, template, provider, item_id, created_at, updated_at, row_id, tenant, owner, subject)
163 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
164 args: [
165 "legacy",
166 "main",
167 "apiKey",
168 "encrypted",
169 "legacy-item",
170 now,
171 now,
172 "legacy-connection-row",
173 "default",
174 "org",
175 "",
176 ],
177 }),
178 );
179
180 const d1 = makeFakeD1(db.client);
181 expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toContain(
182 "2026-06-20-google-openapi-ownership",
183 );
184
185 const columns = yield* Effect.promise(() =>
186 db.client.execute("PRAGMA table_info('connection')"),
187 );
188 expect(columns.rows.map((row) => row.name)).toContain("item_ids");
189 expect(columns.rows.map((row) => row.name)).not.toContain("item_id");
190
191 yield* Effect.promise(() =>
192 db.client.execute({
193 sql: `INSERT INTO connection
194 (integration, name, template, provider, item_ids, created_at, updated_at, row_id, tenant, owner, subject)
195 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
196 args: [
197 "probe",
198 "selected",
199 "apiKey",
200 "encrypted",
201 JSON.stringify({ token: "item" }),
202 now,
203 now,
204 "connection-row",
205 "default",
206 "org",
207 "",
208 ],
209 }),
210 );
211
212 const rows = yield* Effect.promise(() =>
213 db.client.execute("SELECT name, item_ids FROM connection ORDER BY name"),
214 );
215 expect(rows.rows).toEqual([
216 { name: "main", item_ids: JSON.stringify({ token: "legacy-item" }) },
217 { name: "selected", item_ids: JSON.stringify({ token: "item" }) },
218 ]);
219
220 yield* Effect.promise(() => db.close());
221 }),
222 );
223
224 it.effect("clears malformed legacy OpenAPI integration config rows", () =>
225 Effect.gen(function* () {
226 const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() }));
227 const { bucket } = makeFakeR2();
228
229 yield* Effect.promise(() =>
230 insertIntegrationRawConfig(db.client, {
231 rowId: "malformed-row",
232 tenant: "org_1",
233 slug: "broken",
234 pluginId: "openapi",
235 config: "",
236 }),
237 );
238
239 const d1 = makeFakeD1(db.client);
240 expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([
241 "2026-06-20-google-openapi-ownership",
242 ]);
243 expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([]);
244
245 const integrations = yield* Effect.promise(() =>
246 db.client.execute("SELECT slug, plugin_id, config FROM integration"),
247 );
248 expect(integrations.rows).toEqual([{ slug: "broken", plugin_id: "openapi", config: null }]);
249
250 yield* Effect.promise(() => db.close());
251 }),
252 );
253
106 it.effect("moves Google OpenAPI ownership and copies the R2 spec object", () =>254 it.effect("moves Google OpenAPI ownership and copies the R2 spec object", () =>
107 Effect.gen(function* () {255 Effect.gen(function* () {
108 const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() }));256 const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() }));
apps/host-cloudflare/src/db/data-migrations.ts+98−3
@@ -20,7 +20,7 @@ const queryRows = <T extends Record<string, unknown>>( · 19 unmodified lines
20 const sql = typeof stmt === "string" ? stmt : stmt.sql;20 const sql = typeof stmt === "string" ? stmt : stmt.sql;
21 const args = typeof stmt === "string" ? [] : stmt.args;21 const args = typeof stmt === "string" ? [] : stmt.args;
22 const prepared = session.prepare(sql).bind(...args);22 const prepared = session.prepare(sql).bind(...args);
23 if (firstWord(sql) === "SELECT") {23 if (firstWord(sql) === "SELECT" || firstWord(sql) === "PRAGMA") {
24 return prepared.all<T>().then((result) => result.results);24 return prepared.all<T>().then((result) => result.results);
25 }25 }
26 return prepared.run<T>().then(() => []);26 return prepared.run<T>().then(() => []);
@@ -38,6 +38,93 @@ export const d1DataMigrationClient = (db: D1Database): SqliteDataMigrationClient · 11 unmodified lines
38 };38 };
39};39};
40 40
41const tableColumns = async (db: D1Database, table: string): Promise<ReadonlySet<string>> => {
42 const session = db.withSession("first-primary");
43 const result = await session.prepare(`PRAGMA table_info('${table}')`).all<{
44 readonly name?: unknown;
45 }>();
46 return new Set(
47 result.results
48 .map((row) => row.name)
49 .filter((name): name is string => typeof name === "string"),
50 );
51};
52
53const rebuildLegacyConnectionTable = async (db: D1Database): Promise<void> => {
54 const statements = [
55 `DROP TABLE IF EXISTS connection_next`,
56 `DROP TABLE IF EXISTS connection_legacy_item_id`,
57 `CREATE TABLE connection_next (
58 integration text NOT NULL,
59 name text NOT NULL,
60 template text NOT NULL,
61 provider text NOT NULL,
62 item_ids json NOT NULL,
63 identity_label text,
64 description text,
65 tools_synced_at integer,
66 oauth_client text,
67 oauth_client_owner text,
68 refresh_item_id text,
69 expires_at integer,
70 oauth_scope text,
71 oauth_token_url text,
72 provider_state text,
73 created_at integer NOT NULL,
74 updated_at integer NOT NULL,
75 row_id text PRIMARY KEY NOT NULL,
76 tenant text NOT NULL,
77 owner text NOT NULL,
78 subject text NOT NULL
79 )`,
80 `INSERT INTO connection_next
81 (integration, name, template, provider, item_ids, identity_label,
82 description, tools_synced_at, oauth_client, oauth_client_owner,
83 refresh_item_id, expires_at, oauth_scope, oauth_token_url,
84 provider_state, created_at, updated_at, row_id, tenant, owner, subject)
85 SELECT integration, name, template, provider,
86 CASE
87 WHEN item_ids IS NOT NULL AND item_ids <> '{}' THEN item_ids
88 ELSE json_object('token', item_id)
89 END,
90 identity_label, description, tools_synced_at, oauth_client,
91 oauth_client_owner, refresh_item_id, expires_at, oauth_scope,
92 oauth_token_url, provider_state, created_at, updated_at, row_id,
93 tenant, owner, subject
94 FROM connection`,
95 `ALTER TABLE connection RENAME TO connection_legacy_item_id`,
96 `ALTER TABLE connection_next RENAME TO connection`,
97 `DROP TABLE connection_legacy_item_id`,
98 ];
99 for (const statement of statements) {
100 await db.prepare(statement).run();
101 }
102};
103
104export const ensureCloudflareD1SchemaCompatibility = async (db: D1Database): Promise<void> => {
105 const integrationColumns = await tableColumns(db, "integration");
106 if (integrationColumns.has("config")) {
107 await db
108 .prepare(
109 `UPDATE integration
110 SET config = NULL
111 WHERE config IS NOT NULL
112 AND NOT json_valid(config)`,
113 )
114 .run();
115 }
116
117 const connectionColumns = await tableColumns(db, "connection");
118 if (connectionColumns.size === 0) return;
119 if (!connectionColumns.has("item_ids")) {
120 await db.prepare(`ALTER TABLE connection ADD COLUMN item_ids json NOT NULL DEFAULT '{}'`).run();
121 }
122 const updatedConnectionColumns = await tableColumns(db, "connection");
123 if (updatedConnectionColumns.has("item_id")) {
124 await rebuildLegacyConnectionTable(db);
125 }
126};
127
41const r2ObjectName = (tenant: string, pluginId: string, key: string): string =>128const r2ObjectName = (tenant: string, pluginId: string, key: string): string =>
42 `o:${tenant}/${pluginId}/${key}`;129 `o:${tenant}/${pluginId}/${key}`;
43 130
@@ -52,6 +139,7 @@ const copyGoogleOpenApiSpecBlobsToR2 = ( · 8 unmodified lines
52 FROM integration139 FROM integration
53 WHERE plugin_id = 'openapi'140 WHERE plugin_id = 'openapi'
54 AND config IS NOT NULL141 AND config IS NOT NULL
142 AND json_valid(config)
55 AND json_type(config, '$.googleDiscoveryUrls') = 'array'143 AND json_type(config, '$.googleDiscoveryUrls') = 'array'
56 AND json_extract(config, '$.specHash') IS NOT NULL144 AND json_extract(config, '$.specHash') IS NOT NULL
57 AND json_extract(config, '$.specHash') <> ''`,145 AND json_extract(config, '$.specHash') <> ''`,
@@ -67,7 +155,10 @@ const copyGoogleOpenApiSpecBlobsToR2 = ( · 9 unmodified lines
67 }155 }
68 },156 },
69 catch: (cause) =>157 catch: (cause) =>
70 new DataMigrationError({ migration: googleOpenApiOwnershipDataMigration.name, cause }),158 new DataMigrationError({
159 migration: googleOpenApiOwnershipDataMigration.name,
160 cause,
161 }),
71 });162 });
72 163
73const cloudflareDataMigrations = (bucket: R2Bucket | undefined): readonly SqliteDataMigration[] => [164const cloudflareDataMigrations = (bucket: R2Bucket | undefined): readonly SqliteDataMigration[] => [
@@ -86,5 +177,9 @@ export const runCloudflareDataMigrations = ( · 12 unmodified lines
86 bucket: R2Bucket | undefined,177 bucket: R2Bucket | undefined,
87): Promise<readonly string[]> =>178): Promise<readonly string[]> =>
88 Effect.runPromise(179 Effect.runPromise(
89 runSqliteDataMigrations(d1DataMigrationClient(db), cloudflareDataMigrations(bucket)),180 Effect.promise(() => ensureCloudflareD1SchemaCompatibility(db)).pipe(
181 Effect.flatMap(() =>
182 runSqliteDataMigrations(d1DataMigrationClient(db), cloudflareDataMigrations(bucket)),
183 ),
184 ),
90 );185 );
apps/host-cloudflare/src/mcp/auth.ts+65−3
@@ -1,10 +1,58 @@
1import { Effect, Layer } from "effect";1import { Effect, Layer } from "effect";
2 2
3import { authenticated, McpAuthProvider, unauthorized } from "@executor-js/host-mcp";3import {
4 authenticated,
5 McpAuthProvider,
6 unauthorized,
7 type McpDiscoveryRoute,
8} from "@executor-js/host-mcp";
4 9
5import { makeAccessVerifier } from "../auth/cloudflare-access";10import { makeAccessVerifier } from "../auth/cloudflare-access";
6import type { CloudflareConfig } from "../config";11import type { CloudflareConfig } from "../config";
7 12
13const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource";
14const MCP_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/mcp`;
15const TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH = `${MCP_PROTECTED_RESOURCE_METADATA_PATH}/toolkits/:toolkitSlug`;
16
17const toolkitSlugFromPath = (pathname: string): string | undefined => {
18 const mcpPrefix = "/mcp/toolkits/";
19 if (pathname.startsWith(mcpPrefix)) {
20 const slug = pathname.slice(mcpPrefix.length).split("/", 1)[0];
21 return slug ? decodeURIComponent(slug) : undefined;
22 }
23 const metadataPrefix = `${MCP_PROTECTED_RESOURCE_METADATA_PATH}/toolkits/`;
24 if (pathname.startsWith(metadataPrefix)) {
25 const slug = pathname.slice(metadataPrefix.length).split("/", 1)[0];
26 return slug ? decodeURIComponent(slug) : undefined;
27 }
28 return undefined;
29};
30
31const toolkitPath = (slug: string): string => `/mcp/toolkits/${encodeURIComponent(slug)}`;
32
33const resourcePathForRequest = (request: Request): string => {
34 const slug = toolkitSlugFromPath(new URL(request.url).pathname);
35 return slug ? toolkitPath(slug) : "/mcp";
36};
37
38const metadataPathForRequest = (request: Request): string => {
39 const slug = toolkitSlugFromPath(new URL(request.url).pathname);
40 return slug
41 ? `${MCP_PROTECTED_RESOURCE_METADATA_PATH}/toolkits/${encodeURIComponent(slug)}`
42 : MCP_PROTECTED_RESOURCE_METADATA_PATH;
43};
44
45const protectedResourceMetadataResponse = (request: Request): Response => {
46 const url = new URL(request.url);
47 return new Response(
48 JSON.stringify({
49 resource: new URL(resourcePathForRequest(request), url.origin).toString(),
50 authorization_servers: [],
51 }),
52 { headers: { "content-type": "application/json" } },
53 );
54};
55
8// ---------------------------------------------------------------------------56// ---------------------------------------------------------------------------
9// Cloudflare Access McpAuthProvider — the `/mcp` gate, identical identity to the57// Cloudflare Access McpAuthProvider — the `/mcp` gate, identical identity to the
10// API gate. Cloudflare Access sits in front of the Worker and forwards the58// API gate. Cloudflare Access sits in front of the Worker and forwards the
@@ -23,10 +71,24 @@ import type { CloudflareConfig } from "../config"; · 12 unmodified lines
23 71
24export const cloudflareAccessMcpAuth = (config: CloudflareConfig): Layer.Layer<McpAuthProvider> => {72export const cloudflareAccessMcpAuth = (config: CloudflareConfig): Layer.Layer<McpAuthProvider> => {
25 const { verify } = makeAccessVerifier(config);73 const { verify } = makeAccessVerifier(config);
74 const discoveryRoutes: ReadonlyArray<McpDiscoveryRoute> = [
75 {
76 path: PROTECTED_RESOURCE_METADATA_PATH,
77 handler: (request) => Effect.succeed(protectedResourceMetadataResponse(request)),
78 },
79 {
80 path: MCP_PROTECTED_RESOURCE_METADATA_PATH,
81 handler: (request) => Effect.succeed(protectedResourceMetadataResponse(request)),
82 },
83 {
84 path: TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH,
85 handler: (request) => Effect.succeed(protectedResourceMetadataResponse(request)),
86 },
87 ];
26 return Layer.succeed(McpAuthProvider)({88 return Layer.succeed(McpAuthProvider)({
27 discoveryRoutes: [],89 discoveryRoutes,
28 resourceMetadataUrl: (request) =>90 resourceMetadataUrl: (request) =>
29 new URL("/.well-known/oauth-protected-resource", new URL(request.url).origin).toString(),91 new URL(metadataPathForRequest(request), new URL(request.url).origin).toString(),
30 authenticate: (request) =>92 authenticate: (request) =>
31 verify(request).pipe(93 verify(request).pipe(
32 Effect.map((principal) => (principal ? authenticated(principal) : unauthorized())),94 Effect.map((principal) => (principal ? authenticated(principal) : unauthorized())),
apps/host-cloudflare/wrangler.jsonc+1−1
@@ -12,7 +12,7 @@ · 11 unmodified lines
12 "assets": {12 "assets": {
13 "directory": "./dist",13 "directory": "./dist",
14 "not_found_handling": "single-page-application",14 "not_found_handling": "single-page-application",
15 "run_worker_first": ["/api/*", "/mcp", "/mcp/*"],15 "run_worker_first": ["/api/*", "/mcp", "/mcp/*", "/.well-known/*"],
16 },16 },
17 // D1 is the app's SQLite store (the DbProvider seam). `wrangler deploy`17 // D1 is the app's SQLite store (the DbProvider seam). `wrangler deploy`
18 // auto-provisions it on first deploy; replace database_id after that, or run18 // auto-provisions it on first deploy; replace database_id after that, or run
apps/local/executor.config.ts+9−2
@@ -8,6 +8,7 @@ import { keychainPlugin } from "@executor-js/plugin-keychain"; · 7 unmodified lines
8import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets";8import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets";
9import { onepasswordHttpPlugin } from "@executor-js/plugin-onepassword/api";9import { onepasswordHttpPlugin } from "@executor-js/plugin-onepassword/api";
10import { desktopSettingsPlugin } from "@executor-js/plugin-desktop-settings/server";10import { desktopSettingsPlugin } from "@executor-js/plugin-desktop-settings/server";
11import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
11 12
12// ---------------------------------------------------------------------------13// ---------------------------------------------------------------------------
13// Single source of truth for the local app's plugin list.14// Single source of truth for the local app's plugin list.
@@ -18,20 +19,26 @@ import { desktopSettingsPlugin } from "@executor-js/plugin-desktop-settings/serv · 4 unmodified lines
18// First-party and third-party plugins use the same import-and-call flow.19// First-party and third-party plugins use the same import-and-call flow.
19// ---------------------------------------------------------------------------20// ---------------------------------------------------------------------------
20 21
22interface LocalPluginDeps {
23 readonly activeToolkitSlug?: string;
24}
25
21export default defineExecutorConfig({26export default defineExecutorConfig({
22 plugins: () =>27 plugins: ({ activeToolkitSlug }: LocalPluginDeps = {}) =>
23 [28 [
24 openApiHttpPlugin(),29 openApiHttpPlugin(),
25 googleHttpPlugin(),30 googleHttpPlugin(),
26 microsoftHttpPlugin(),31 microsoftHttpPlugin(),
27 mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }),32 mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }),
28 graphqlHttpPlugin(),33 graphqlHttpPlugin(),
34 toolkitsPlugin({ activeToolkitSlug }),
29 keychainPlugin(),35 keychainPlugin(),
30 fileSecretsPlugin(),36 fileSecretsPlugin(),
31 onepasswordHttpPlugin(),37 onepasswordHttpPlugin(),
32 desktopSettingsPlugin({38 desktopSettingsPlugin({
33 webBaseUrl:39 webBaseUrl:
34 process.env.EXECUTOR_WEB_BASE_URL ?? `http://localhost:${process.env.PORT ?? "4788"}`,40 process.env.EXECUTOR_WEB_BASE_URL ??
41 `http://localhost:${process.env.PORT ?? "4788"}`,
35 }),42 }),
36 ] as const,43 ] as const,
37});44});
apps/local/package.json+1−0
@@ -39,6 +39,7 @@ · 38 unmodified lines
39 "@executor-js/plugin-microsoft": "workspace:*",39 "@executor-js/plugin-microsoft": "workspace:*",
40 "@executor-js/plugin-onepassword": "workspace:*",40 "@executor-js/plugin-onepassword": "workspace:*",
41 "@executor-js/plugin-openapi": "workspace:*",41 "@executor-js/plugin-openapi": "workspace:*",
42 "@executor-js/plugin-toolkits": "workspace:*",
42 "@executor-js/react": "workspace:*",43 "@executor-js/react": "workspace:*",
43 "@executor-js/runtime-quickjs": "workspace:*",44 "@executor-js/runtime-quickjs": "workspace:*",
44 "@executor-js/sdk": "workspace:*",45 "@executor-js/sdk": "workspace:*",
apps/local/src/executor.ts+70−41
@@ -49,7 +49,8 @@ const makeTenantId = (cwd: string): string => { · 48 unmodified lines
49 return `${folder}-${hash}`;49 return `${folder}-${hash}`;
50};50};
51 51
52const resolvePluginConfigPath = (scopeDir: string): string => join(scopeDir, "executor.jsonc");52const resolvePluginConfigPath = (scopeDir: string): string =>
53 join(scopeDir, "executor.jsonc");
53 54
54// Plugins reach the host through two doors that compose:55// Plugins reach the host through two doors that compose:
55// - `executor.config.ts`'s static tuple56// - `executor.config.ts`'s static tuple
@@ -57,51 +58,66 @@ const resolvePluginConfigPath = (scopeDir: string): string => join(scopeDir, "ex · 1 unmodified line
57// Static config wins on conflict, matching the Vite plugin.58// Static config wins on conflict, matching the Vite plugin.
58type LocalPlugins = readonly AnyPlugin[];59type LocalPlugins = readonly AnyPlugin[];
59 60
60const loadLocalPlugins = Effect.gen(function* () {61export interface LocalExecutorOptions {
61 const cwd = process.env.EXECUTOR_SCOPE_DIR || process.cwd();62 readonly activeToolkitSlug?: string;
62 const staticPlugins = executorConfig.plugins();63}
63 const dynamicPlugins =
64 (yield* Effect.promise(() => loadPluginsFromJsonc({ path: resolvePluginConfigPath(cwd) }))) ??
65 [];
66 64
67 const staticPackageNames = new Set(65const loadLocalPlugins = (options: LocalExecutorOptions = {}) =>
68 staticPlugins.map((plugin) => plugin.packageName).filter((name): name is string => !!name),66 Effect.gen(function* () {
69 );67 const cwd = process.env.EXECUTOR_SCOPE_DIR || process.cwd();
70 const dedupedDynamic = dynamicPlugins.filter((plugin) => {68 const staticPlugins = executorConfig.plugins({
71 if (plugin.packageName && staticPackageNames.has(plugin.packageName)) {69 activeToolkitSlug: options.activeToolkitSlug,
72 console.warn(70 });
73 `[executor] plugin "${plugin.packageName}" appears in both ` +71 const dynamicPlugins =
74 `executor.config.ts and executor.jsonc#plugins. The static ` +72 (yield* Effect.promise(() =>
75 `entry wins; the jsonc entry is ignored.`,73 loadPluginsFromJsonc({ path: resolvePluginConfigPath(cwd) }),
76 );74 )) ?? [];
77 return false;75
78 }76 const staticPackageNames = new Set(
79 return true;77 staticPlugins
80 });78 .map((plugin) => plugin.packageName)
79 .filter((name): name is string => !!name),
80 );
81 const dedupedDynamic = dynamicPlugins.filter((plugin) => {
82 if (plugin.packageName && staticPackageNames.has(plugin.packageName)) {
83 console.warn(
84 `[executor] plugin "${plugin.packageName}" appears in both ` +
85 `executor.config.ts and executor.jsonc#plugins. The static ` +
86 `entry wins; the jsonc entry is ignored.`,
87 );
88 return false;
89 }
90 return true;
91 });
81 92
82 return {93 return {
83 cwd,94 cwd,
84 plugins: [...staticPlugins, ...dedupedDynamic] as LocalPlugins,95 plugins: [...staticPlugins, ...dedupedDynamic] as LocalPlugins,
85 };96 };
86});97 });
87 98
88interface LocalExecutorBundle {99interface LocalExecutorBundle {
89 readonly executor: Executor<LocalPlugins>;100 readonly executor: Executor<LocalPlugins>;
90 readonly plugins: LocalPlugins;101 readonly plugins: LocalPlugins;
91}102}
92 103
93class LocalExecutorTag extends Context.Service<LocalExecutorTag, LocalExecutorBundle>()(104class LocalExecutorTag extends Context.Service<
94 "@executor-js/local/Executor",105 LocalExecutorTag,
95) {}106 LocalExecutorBundle
107>()("@executor-js/local/Executor") {}
96 108
97export type LocalExecutor = LocalExecutorBundle["executor"];109export type LocalExecutor = LocalExecutorBundle["executor"];
98 110
99class LocalExecutorCreateError extends Data.TaggedError("LocalExecutorCreateError")<{111class LocalExecutorCreateError extends Data.TaggedError(
112 "LocalExecutorCreateError",
113)<{
100 readonly message: string;114 readonly message: string;
101 readonly cause: unknown;115 readonly cause: unknown;
102}> {}116}> {}
103 117
104class LocalExecutorDisposeError extends Data.TaggedError("LocalExecutorDisposeError")<{118class LocalExecutorDisposeError extends Data.TaggedError(
119 "LocalExecutorDisposeError",
120)<{
105 readonly operation: "createHandle" | "disposeExecutor" | "disposeRuntime";121 readonly operation: "createHandle" | "disposeExecutor" | "disposeRuntime";
106 readonly cause: unknown;122 readonly cause: unknown;
107}> {}123}> {}
@@ -126,20 +142,23 @@ const handleOrNull = (promise: ReturnType<typeof createExecutorHandle>) => · 18 unmodified lines
126 Effect.runPromise(142 Effect.runPromise(
127 Effect.tryPromise({143 Effect.tryPromise({
128 try: () => promise,144 try: () => promise,
129 catch: (cause) => new LocalExecutorDisposeError({ operation: "createHandle", cause }),145 catch: (cause) =>
146 new LocalExecutorDisposeError({ operation: "createHandle", cause }),
130 }).pipe(147 }).pipe(
131 Effect.catch(() =>148 Effect.catch(() =>
132 Effect.succeed<Awaited<ReturnType<typeof createExecutorHandle>> | null>(null),149 Effect.succeed<Awaited<ReturnType<typeof createExecutorHandle>> | null>(
150 null,
151 ),
133 ),152 ),
134 ),153 ),
135 );154 );
136 155
137const createLocalExecutorLayer = () => {156const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => {
138 const storage = resolveStorage();157 const storage = resolveStorage();
139 158
140 return Layer.effect(LocalExecutorTag)(159 return Layer.effect(LocalExecutorTag)(
141 Effect.gen(function* () {160 Effect.gen(function* () {
142 const { cwd, plugins } = yield* loadLocalPlugins;161 const { cwd, plugins } = yield* loadLocalPlugins(options);
143 const tenantId = makeTenantId(cwd);162 const tenantId = makeTenantId(cwd);
144 const tables = collectTables();163 const tables = collectTables();
145 164
@@ -180,7 +199,11 @@ const createLocalExecutorLayer = () => { · 34 unmodified lines
180 // without touching the data.199 // without touching the data.
181 yield* runSqliteDataMigrations(sqlite.client, localDataMigrations).pipe(200 yield* runSqliteDataMigrations(sqlite.client, localDataMigrations).pipe(
182 Effect.mapError(201 Effect.mapError(
183 (cause) => new LocalExecutorCreateError({ message: CREATE_SQLITE_ERROR_MESSAGE, cause }),202 (cause) =>
203 new LocalExecutorCreateError({
204 message: CREATE_SQLITE_ERROR_MESSAGE,
205 cause,
206 }),
184 ),207 ),
185 );208 );
186 209
@@ -189,7 +212,8 @@ const createLocalExecutorLayer = () => { · 2 unmodified lines
189 // resolution so a custom $PORT flows through. EXECUTOR_WEB_BASE_URL212 // resolution so a custom $PORT flows through. EXECUTOR_WEB_BASE_URL
190 // overrides entirely for deployments where the UI is on a different host.213 // overrides entirely for deployments where the UI is on a different host.
191 const webBaseUrl =214 const webBaseUrl =
192 process.env.EXECUTOR_WEB_BASE_URL ?? `http://localhost:${process.env.PORT ?? "4788"}`;215 process.env.EXECUTOR_WEB_BASE_URL ??
216 `http://localhost:${process.env.PORT ?? "4788"}`;
193 217
194 const executor = yield* createExecutor({218 const executor = yield* createExecutor({
195 tenant: Tenant.make(tenantId),219 tenant: Tenant.make(tenantId),
@@ -223,8 +247,10 @@ const createLocalExecutorLayer = () => { · 27 unmodified lines
223 );247 );
224};248};
225 249
226export const createExecutorHandle = async () => {250export const createExecutorHandle = async (
227 const layer = createLocalExecutorLayer();251 options: LocalExecutorOptions = {},
252) => {
253 const layer = createLocalExecutorLayer(options);
228 const runtime = ManagedRuntime.make(layer);254 const runtime = ManagedRuntime.make(layer);
229 const bundle = await runtime.runPromise(LocalExecutorTag.asEffect());255 const bundle = await runtime.runPromise(LocalExecutorTag.asEffect());
230 256
@@ -249,14 +275,17 @@ const loadSharedHandle = () => { · 18 unmodified lines
249 return sharedHandlePromise;275 return sharedHandlePromise;
250};276};
251 277
252export const getExecutor = () => loadSharedHandle().then((handle) => handle.executor);278export const getExecutor = () =>
279 loadSharedHandle().then((handle) => handle.executor);
253export const getExecutorBundle = () => loadSharedHandle();280export const getExecutorBundle = () => loadSharedHandle();
254 281
255export const disposeExecutor = async (): Promise<void> => {282export const disposeExecutor = async (): Promise<void> => {
256 const currentHandlePromise = sharedHandlePromise;283 const currentHandlePromise = sharedHandlePromise;
257 sharedHandlePromise = null;284 sharedHandlePromise = null;
258 285
259 const handle = currentHandlePromise ? await handleOrNull(currentHandlePromise) : null;286 const handle = currentHandlePromise
287 ? await handleOrNull(currentHandlePromise)
288 : null;
260 if (handle) {289 if (handle) {
261 await ignorePromiseFailure("disposeExecutor", () => handle.dispose());290 await ignorePromiseFailure("disposeExecutor", () => handle.dispose());
262 }291 }
apps/local/src/main.ts+29−8
@@ -3,7 +3,7 @@ import { Context, Effect, Layer, ManagedRuntime } from "effect"; · 2 unmodified lines
3import { createExecutionEngine } from "@executor-js/execution";3import { createExecutionEngine } from "@executor-js/execution";
4import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";4import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
5import { makeLocalApiHandler } from "./app";5import { makeLocalApiHandler } from "./app";
6import { getExecutorBundle } from "./executor";6import { createExecutorHandle, getExecutorBundle } from "./executor";
7import { createMcpRequestHandler, type McpRequestHandler } from "./mcp";7import { createMcpRequestHandler, type McpRequestHandler } from "./mcp";
8 8
9// ---------------------------------------------------------------------------9// ---------------------------------------------------------------------------
@@ -53,7 +53,9 @@ const closeServerHandlers = async (handlers: ServerHandlers): Promise<void> => { · 43 unmodified lines
53 );53 );
54};54};
55 55
56export const createServerHandlers = async (token: string): Promise<ServerHandlers> => {56export const createServerHandlers = async (
57 token: string,
58): Promise<ServerHandlers> => {
57 // The typed `/api` web-handler comes from `ExecutorApp.make` (./app.ts). The59 // The typed `/api` web-handler comes from `ExecutorApp.make` (./app.ts). The
58 // boot bearer token is the authoritative `/api` gate (see `identity.ts`).60 // boot bearer token is the authoritative `/api` gate (see `identity.ts`).
59 const apiHandler: ServerHandlers["api"] = await makeLocalApiHandler(token);61 const apiHandler: ServerHandlers["api"] = await makeLocalApiHandler(token);
@@ -67,20 +69,39 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 7 unmodified lines
67 executor,69 executor,
68 codeExecutor: makeQuickJsExecutor(),70 codeExecutor: makeQuickJsExecutor(),
69 });71 });
70 const mcp = createMcpRequestHandler({ engine });72 const mcp = createMcpRequestHandler({
73 defaultConfig: { engine },
74 createConfigForResource: async (resource) => {
75 if (resource.kind === "default") return { config: { engine } };
76 const handle = await createExecutorHandle({
77 activeToolkitSlug: resource.slug,
78 });
79 const toolkitEngine = createExecutionEngine({
80 executor: handle.executor,
81 codeExecutor: makeQuickJsExecutor(),
82 });
83 return {
84 config: { engine: toolkitEngine },
85 close: handle.dispose,
86 };
87 },
88 });
71 89
72 return { api: apiHandler, mcp };90 return { api: apiHandler, mcp };
73};91};
74 92
75export class ServerHandlersService extends Context.Service<ServerHandlersService, ServerHandlers>()(93export class ServerHandlersService extends Context.Service<
76 "@executor-js/local/ServerHandlersService",94 ServerHandlersService,
77) {}95 ServerHandlers
96>()("@executor-js/local/ServerHandlersService") {}
78 97
79// The handlers are built once per process and memoized. The boot token is98// The handlers are built once per process and memoized. The boot token is
80// captured on the first call (serve.ts / the vite dev middleware both pass the99// captured on the first call (serve.ts / the vite dev middleware both pass the
81// SAME token loaded from `auth.json`), so memoization on first-call is correct.100// SAME token loaded from `auth.json`), so memoization on first-call is correct.
82let serverHandlersRuntime: ManagedRuntime.ManagedRuntime<ServerHandlersService, never> | null =101let serverHandlersRuntime: ManagedRuntime.ManagedRuntime<
83 null;102 ServerHandlersService,
103 never
104> | null = null;
84 105
85const getServerHandlersRuntime = (106const getServerHandlersRuntime = (
86 token: string,107 token: string,
apps/local/src/mcp.ts+146−28
@@ -1,9 +1,14 @@
1import { Effect } from "effect";1import { Effect, type Cause } from "effect";
2import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";2import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";3import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";4import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
5 5
6import { jsonRpcErrorBody } from "@executor-js/host-mcp";6import {
7 defaultMcpResource,
8 jsonRpcErrorBody,
9 mcpResourceKey,
10 type McpResource,
11} from "@executor-js/host-mcp";
7import {12import {
8 createExecutorMcpServer,13 createExecutorMcpServer,
9 type ExecutorMcpServerConfig,14 type ExecutorMcpServerConfig,
@@ -15,10 +20,16 @@ import { · 5 unmodified lines
15 readElicitationMode,20 readElicitationMode,
16} from "@executor-js/host-mcp/browser-approval";21} from "@executor-js/host-mcp/browser-approval";
17import { makeInProcessBrowserApprovalStore } from "@executor-js/host-mcp/browser-approval-store";22import { makeInProcessBrowserApprovalStore } from "@executor-js/host-mcp/browser-approval-store";
18import { formatPausedExecution, type ResumeResponse } from "@executor-js/execution";23import {
24 formatPausedExecution,
25 type ExecutionEngine,
26 type ResumeResponse,
27} from "@executor-js/execution";
19 28
20import { startIntegrationsRefresh } from "./integrations";29import { startIntegrationsRefresh } from "./integrations";
21 30
31type AnyExecutionEngine = ExecutionEngine<Cause.YieldableError>;
32
22// ---------------------------------------------------------------------------33// ---------------------------------------------------------------------------
23// Streamable HTTP handler34// Streamable HTTP handler
24// ---------------------------------------------------------------------------35// ---------------------------------------------------------------------------
@@ -32,6 +43,18 @@ export type McpRequestHandler = { · 7 unmodified lines
32 readonly close: () => Promise<void>;43 readonly close: () => Promise<void>;
33};44};
34 45
46export interface LocalMcpServerConfig {
47 readonly config: ExecutorMcpServerConfig;
48 readonly close?: () => Promise<void>;
49}
50
51export interface LocalMcpRequestHandlerConfig {
52 readonly defaultConfig: ExecutorMcpServerConfig;
53 readonly createConfigForResource?: (
54 resource: McpResource,
55 ) => Promise<LocalMcpServerConfig> | LocalMcpServerConfig;
56}
57
35// Local serves these error bodies in-process; like the self-host store they are58// Local serves these error bodies in-process; like the self-host store they are
36// INNER responses (no CORS) — byte-identical to the prior hand-rolled copy59// INNER responses (no CORS) — byte-identical to the prior hand-rolled copy
37// (`content-type: application/json` only) via the canonical renderer.60// (`content-type: application/json` only) via the canonical renderer.
@@ -44,7 +67,9 @@ const formatBoundaryError = (error: unknown): unknown => { · 6 unmodified lines
44 return error;67 return error;
45};68};
46 69
47const ignoreClose = (close: (() => Promise<void>) | undefined): Promise<void> =>70const ignoreClose = (
71 close: (() => Promise<void>) | undefined,
72): Promise<void> =>
48 close73 close
49 ? Effect.runPromise(74 ? Effect.runPromise(
50 Effect.ignore(75 Effect.ignore(
@@ -56,8 +81,10 @@ const ignoreClose = (close: (() => Promise<void>) | undefined): Promise<void> => · 5 unmodified lines
56 )81 )
57 : Promise.resolve();82 : Promise.resolve();
58 83
59const pausedRequestPattern = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/;84const pausedRequestPattern =
60const approvalRequestPattern = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/;85 /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/;
86const approvalRequestPattern =
87 /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/;
61 88
62const json = (value: unknown, status = 200): Response =>89const json = (value: unknown, status = 200): Response =>
63 new Response(JSON.stringify(value), {90 new Response(JSON.stringify(value), {
@@ -70,57 +97,122 @@ const readResumeResponse = (request: Request): Promise<ResumeResponse | null> => · 6 unmodified lines
70 Effect.tryPromise({97 Effect.tryPromise({
71 try: () => request.json(),98 try: () => request.json(),
72 catch: () => null,99 catch: () => null,
73 }).pipe(Effect.map((raw) => (raw === null ? null : decodeResumeResponse(raw)))),100 }).pipe(
101 Effect.map((raw) => (raw === null ? null : decodeResumeResponse(raw))),
102 ),
74 );103 );
75 104
76const resumeApprovalResult = (executionId: string, response: ResumeResponse) => ({105const resumeApprovalResult = (
106 executionId: string,
107 response: ResumeResponse,
108) => ({
77 status: "completed",109 status: "completed",
78 ...formatResumeAcknowledgement(executionId, response),110 ...formatResumeAcknowledgement(executionId, response),
79 isError: false,111 isError: false,
80});112});
81 113
82export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpRequestHandler => {114const toolkitPathPattern = /^\/mcp\/toolkits\/([^/?#]+)\/?$/;
83 const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();115
116const resourceFromRequest = (request: Request): McpResource | null => {
117 const pathname = new URL(request.url).pathname;
118 if (pathname === "/mcp" || pathname === "/mcp/") return defaultMcpResource;
119 const match = toolkitPathPattern.exec(pathname);
120 if (!match) return null;
121 return { kind: "toolkit", slug: decodeURIComponent(match[1]) };
122};
123
124const engineFromConfig = (
125 config: ExecutorMcpServerConfig,
126): AnyExecutionEngine | null => ("engine" in config ? config.engine : null);
127
128const normalizeHandlerConfig = (
129 input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig,
130): LocalMcpRequestHandlerConfig =>
131 "defaultConfig" in input ? input : { defaultConfig: input };
132
133export const createMcpRequestHandler = (
134 input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig,
135): McpRequestHandler => {
136 const handlerConfig = normalizeHandlerConfig(input);
137 const transports = new Map<
138 string,
139 WebStandardStreamableHTTPServerTransport
140 >();
84 const servers = new Map<string, McpServer>();141 const servers = new Map<string, McpServer>();
142 const resources = new Map<string, McpResource>();
143 const sessionEngines = new Map<string, AnyExecutionEngine>();
144 const sessionClosers = new Map<string, () => Promise<void>>();
85 const approvals = makeInProcessBrowserApprovalStore();145 const approvals = makeInProcessBrowserApprovalStore();
86 // Local runs one shared engine across every MCP session (main.ts builds it and146 const defaultEngine = engineFromConfig(handlerConfig.defaultConfig);
87 // passes it in), so the paused-execution lookup for browser approval reads it
88 // directly — there is no per-session engine to track.
89 const engine = "engine" in config ? config.engine : null;
90 147
91 const pausedDetail = (148 const pausedDetail = (
149 sessionId: string,
92 executionId: string,150 executionId: string,
93 ): Promise<ReturnType<typeof formatPausedExecution> | null> =>151 ): Promise<ReturnType<typeof formatPausedExecution> | null> =>
94 engine152 (sessionEngines.get(sessionId) ?? defaultEngine)
95 ? Effect.runPromise(153 ? Effect.runPromise(
96 engine.getPausedExecution(executionId).pipe(154 (sessionEngines.get(sessionId) ?? defaultEngine)!
97 Effect.map((paused) => (paused ? formatPausedExecution(paused) : null)),155 .getPausedExecution(executionId)
98 Effect.orElseSucceed(() => null),156 .pipe(
99 ),157 Effect.map((paused) =>
158 paused ? formatPausedExecution(paused) : null,
159 ),
160 Effect.orElseSucceed(() => null),
161 ),
100 )162 )
101 : Promise.resolve(null);163 : Promise.resolve(null);
102 164
103 const dispose = async (id: string, opts: { transport?: boolean; server?: boolean } = {}) => {165 const configForResource = async (
166 resource: McpResource,
167 ): Promise<LocalMcpServerConfig> => {
168 if (!handlerConfig.createConfigForResource)
169 return { config: handlerConfig.defaultConfig };
170 return handlerConfig.createConfigForResource(resource);
171 };
172
173 const dispose = async (
174 id: string,
175 opts: { transport?: boolean; server?: boolean } = {},
176 ) => {
104 const t = transports.get(id);177 const t = transports.get(id);
105 const s = servers.get(id);178 const s = servers.get(id);
179 const close = sessionClosers.get(id);
106 transports.delete(id);180 transports.delete(id);
107 servers.delete(id);181 servers.delete(id);
182 resources.delete(id);
183 sessionEngines.delete(id);
184 sessionClosers.delete(id);
108 if (opts.transport) await ignoreClose(t ? () => t.close() : undefined);185 if (opts.transport) await ignoreClose(t ? () => t.close() : undefined);
109 if (opts.server) await ignoreClose(s ? () => s.close() : undefined);186 if (opts.server) await ignoreClose(s ? () => s.close() : undefined);
187 await ignoreClose(close);
110 };188 };
111 189
112 return {190 return {
113 handleRequest: async (request) => {191 handleRequest: async (request) => {
192 const resource = resourceFromRequest(request);
193 if (!resource) return jsonError(404, -32001, "MCP resource not found");
114 const sessionId = request.headers.get("mcp-session-id");194 const sessionId = request.headers.get("mcp-session-id");
115 195
116 if (sessionId) {196 if (sessionId) {
117 const transport = transports.get(sessionId);197 const transport = transports.get(sessionId);
118 if (!transport) return jsonError(404, -32001, "Session not found");198 if (!transport) return jsonError(404, -32001, "Session not found");
199 const sessionResource = resources.get(sessionId);
200 if (
201 !sessionResource ||
202 mcpResourceKey(sessionResource) !== mcpResourceKey(resource)
203 ) {
204 return jsonError(
205 403,
206 -32003,
207 "Session belongs to a different MCP resource",
208 );
209 }
119 return transport.handleRequest(request);210 return transport.handleRequest(request);
120 }211 }
121 212
122 let created: McpServer | undefined;213 let created: McpServer | undefined;
123 let createdSessionId: string | null = null;214 let createdSessionId: string | null = null;
215 let resourceConfig: LocalMcpServerConfig | null = null;
124 const transport = new WebStandardStreamableHTTPServerTransport({216 const transport = new WebStandardStreamableHTTPServerTransport({
125 sessionIdGenerator: () => crypto.randomUUID(),217 sessionIdGenerator: () => crypto.randomUUID(),
126 enableJsonResponse: true,218 enableJsonResponse: true,
@@ -128,6 +220,13 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 1 unmodified line
128 createdSessionId = sid;220 createdSessionId = sid;
129 transports.set(sid, transport);221 transports.set(sid, transport);
130 if (created) servers.set(sid, created);222 if (created) servers.set(sid, created);
223 resources.set(sid, resource);
224 const engine = resourceConfig
225 ? engineFromConfig(resourceConfig.config)
226 : null;
227 if (engine) sessionEngines.set(sid, engine);
228 if (resourceConfig?.close)
229 sessionClosers.set(sid, resourceConfig.close);
131 },230 },
132 onsessionclosed: (sid) => void dispose(sid, { server: true }),231 onsessionclosed: (sid) => void dispose(sid, { server: true }),
133 });232 });
@@ -140,16 +239,21 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 6 unmodified lines
140 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: MCP SDK handler must return JSON-RPC errors from thrown Promise APIs239 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: MCP SDK handler must return JSON-RPC errors from thrown Promise APIs
141 try {240 try {
142 const elicitationMode = readElicitationMode(request);241 const elicitationMode = readElicitationMode(request);
242 resourceConfig = await configForResource(resource);
143 created = await Effect.runPromise(243 created = await Effect.runPromise(
144 createExecutorMcpServer({244 createExecutorMcpServer({
145 ...config,245 ...resourceConfig.config,
146 browserApprovalStore: approvals.store,246 browserApprovalStore: approvals.store,
147 elicitationMode:247 elicitationMode:
148 elicitationMode === "browser"248 elicitationMode === "browser"
149 ? {249 ? {
150 mode: "browser" as const,250 mode: "browser" as const,
151 approvalUrl: (executionId) =>251 approvalUrl: (executionId) =>
152 approvalUrlForRequest(request, executionId, createdSessionId),252 approvalUrlForRequest(
253 request,
254 executionId,
255 createdSessionId,
256 ),
153 }257 }
154 : { mode: elicitationMode },258 : { mode: elicitationMode },
155 }),259 }),
@@ -161,6 +265,7 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 5 unmodified lines
161 await ignoreClose(() => transport.close());265 await ignoreClose(() => transport.close());
162 const server = created;266 const server = created;
163 await ignoreClose(server ? () => server.close() : undefined);267 await ignoreClose(server ? () => server.close() : undefined);
268 await ignoreClose(resourceConfig?.close);
164 }269 }
165 return response;270 return response;
166 } catch (error) {271 } catch (error) {
@@ -169,6 +274,7 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 2 unmodified lines
169 await ignoreClose(() => transport.close());274 await ignoreClose(() => transport.close());
170 const server = created;275 const server = created;
171 await ignoreClose(server ? () => server.close() : undefined);276 await ignoreClose(server ? () => server.close() : undefined);
277 await ignoreClose(resourceConfig?.close);
172 }278 }
173 return jsonError(500, -32603, "Internal server error");279 return jsonError(500, -32603, "Internal server error");
174 }280 }
@@ -177,9 +283,13 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 2 unmodified lines
177 handlePausedRequest: async (request) => {283 handlePausedRequest: async (request) => {
178 const match = pausedRequestPattern.exec(new URL(request.url).pathname);284 const match = pausedRequestPattern.exec(new URL(request.url).pathname);
179 if (!match) return json({ error: "Not found" }, 404);285 if (!match) return json({ error: "Not found" }, 404);
180 if (request.method !== "GET") return json({ error: "Method not allowed" }, 405);286 if (request.method !== "GET")
287 return json({ error: "Method not allowed" }, 405);
181 288
182 const paused = await pausedDetail(decodeURIComponent(match[2]));289 const paused = await pausedDetail(
290 decodeURIComponent(match[1]),
291 decodeURIComponent(match[2]),
292 );
183 if (!paused) return json({ error: "Paused execution not found" }, 404);293 if (!paused) return json({ error: "Paused execution not found" }, 404);
184 return json({ text: paused.text, structured: paused.structured });294 return json({ text: paused.text, structured: paused.structured });
185 },295 },
@@ -187,11 +297,15 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 1 unmodified line
187 handleApprovalRequest: async (request) => {297 handleApprovalRequest: async (request) => {
188 const match = approvalRequestPattern.exec(new URL(request.url).pathname);298 const match = approvalRequestPattern.exec(new URL(request.url).pathname);
189 if (!match) return json({ error: "Not found" }, 404);299 if (!match) return json({ error: "Not found" }, 404);
190 if (request.method !== "POST") return json({ error: "Method not allowed" }, 405);300 if (request.method !== "POST")
301 return json({ error: "Method not allowed" }, 405);
191 302
303 const sessionId = decodeURIComponent(match[1]);
192 const executionId = decodeURIComponent(match[2]);304 const executionId = decodeURIComponent(match[2]);
193 // The shared engine must still hold the paused execution — guards stale ids.305 // The shared engine must still hold the paused execution — guards stale ids.
194 if (!(await pausedDetail(executionId))) return json({ error: "MCP session not found" }, 404);306 if (!(await pausedDetail(sessionId, executionId))) {
307 return json({ error: "MCP session not found" }, 404);
308 }
195 309
196 const response = await readResumeResponse(request);310 const response = await readResumeResponse(request);
197 if (!response) return json({ error: "Invalid approval response" }, 400);311 if (!response) return json({ error: "Invalid approval response" }, 400);
@@ -202,7 +316,9 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 4 unmodified lines
202 316
203 close: async () => {317 close: async () => {
204 const ids = new Set([...transports.keys(), ...servers.keys()]);318 const ids = new Set([...transports.keys(), ...servers.keys()]);
205 await Promise.all([...ids].map((id) => dispose(id, { transport: true, server: true })));319 await Promise.all(
320 [...ids].map((id) => dispose(id, { transport: true, server: true })),
321 );
206 },322 },
207 };323 };
208};324};
@@ -211,7 +327,9 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 2 unmodified lines
211// Stdio transport327// Stdio transport
212// ---------------------------------------------------------------------------328// ---------------------------------------------------------------------------
213 329
214export const runMcpStdioServer = async (config: ExecutorMcpServerConfig): Promise<void> => {330export const runMcpStdioServer = async (
331 config: ExecutorMcpServerConfig,
332): Promise<void> => {
215 startIntegrationsRefresh();333 startIntegrationsRefresh();
216 334
217 const server = await Effect.runPromise(createExecutorMcpServer(config));335 const server = await Effect.runPromise(createExecutorMcpServer(config));
apps/local/src/serve.ts+51−17
@@ -35,7 +35,10 @@ const htmlResponse = (file: Bun.BunFile): Response => · 34 unmodified lines
35 headers: { "content-type": "text/html", "cache-control": "no-store" },35 headers: { "content-type": "text/html", "cache-control": "no-store" },
36 });36 });
37 37
38function collectStaticRoutes(dir: string, prefix = ""): Record<string, StaticHandler> {38function collectStaticRoutes(
39 dir: string,
40 prefix = "",
41): Record<string, StaticHandler> {
39 const routes: Record<string, StaticHandler> = {};42 const routes: Record<string, StaticHandler> = {};
40 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: filesystem route discovery is best-effort for optional built assets43 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: filesystem route discovery is best-effort for optional built assets
41 try {44 try {
@@ -50,7 +53,9 @@ function collectStaticRoutes(dir: string, prefix = ""): Record<string, StaticHan · 8 unmodified lines
50 routePath === "/index.html"53 routePath === "/index.html"
51 ? htmlResponse(file)54 ? htmlResponse(file)
52 : new Response(file, {55 : new Response(file, {
53 headers: { "content-type": file.type || "application/octet-stream" },56 headers: {
57 "content-type": file.type || "application/octet-stream",
58 },
54 });59 });
55 }60 }
56 }61 }
@@ -62,7 +67,9 @@ function collectStaticRoutes(dir: string, prefix = ""): Record<string, StaticHan · 5 unmodified lines
62 * Convert an embedded web UI map (path → bunfs path) into Bun.serve routes.67 * Convert an embedded web UI map (path → bunfs path) into Bun.serve routes.
63 * The embedded map is generated by the CLI build step using `with { type: "file" }`.68 * The embedded map is generated by the CLI build step using `with { type: "file" }`.
64 */69 */
65function embeddedToStaticRoutes(embedded: Record<string, string>): Record<string, StaticHandler> {70function embeddedToStaticRoutes(
71 embedded: Record<string, string>,
72): Record<string, StaticHandler> {
66 const routes: Record<string, StaticHandler> = {};73 const routes: Record<string, StaticHandler> = {};
67 for (const [key, bunfsPath] of Object.entries(embedded)) {74 for (const [key, bunfsPath] of Object.entries(embedded)) {
68 const file = Bun.file(bunfsPath);75 const file = Bun.file(bunfsPath);
@@ -70,7 +77,9 @@ function embeddedToStaticRoutes(embedded: Record<string, string>): Record<string · 1 unmodified line
70 key === "index.html"77 key === "index.html"
71 ? htmlResponse(file)78 ? htmlResponse(file)
72 : new Response(file, {79 : new Response(file, {
73 headers: { "content-type": file.type || "application/octet-stream" },80 headers: {
81 "content-type": file.type || "application/octet-stream",
82 },
74 });83 });
75 }84 }
76 return routes;85 return routes;
@@ -90,7 +99,11 @@ interface ViteChild { · 13 unmodified lines
90}99}
91 100
92async function allocatePort(): Promise<number> {101async function allocatePort(): Promise<number> {
93 const probe = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response() });102 const probe = Bun.serve({
103 port: 0,
104 hostname: "127.0.0.1",
105 fetch: () => new Response(),
106 });
94 const port = probe.port ?? 0;107 const port = probe.port ?? 0;
95 probe.stop(true);108 probe.stop(true);
96 return port;109 return port;
@@ -152,7 +165,9 @@ async function startViteChild(): Promise<ViteChild> { · 55 unmodified lines
152 }165 }
153 if (child.exitCode !== null) {166 if (child.exitCode !== null) {
154 // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: child process aborted before becoming ready167 // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: child process aborted before becoming ready
155 throw new Error(`vite dev exited with code ${child.exitCode} before becoming ready`);168 throw new Error(
169 `vite dev exited with code ${child.exitCode} before becoming ready`,
170 );
156 }171 }
157 await Bun.sleep(150);172 await Bun.sleep(150);
158 }173 }
@@ -241,7 +256,8 @@ const withCorsHeaders = ( · 82 unmodified lines
241 if (!origin || !isAllowedOrigin(origin, allowedHosts)) return response;256 if (!origin || !isAllowedOrigin(origin, allowedHosts)) return response;
242 const headers = new Headers(response.headers);257 const headers = new Headers(response.headers);
243 headers.set("access-control-allow-origin", origin);258 headers.set("access-control-allow-origin", origin);
244 for (const [key, value] of Object.entries(corsHeaders)) headers.set(key, value);259 for (const [key, value] of Object.entries(corsHeaders))
260 headers.set(key, value);
245 headers.set(261 headers.set(
246 "access-control-allow-headers",262 "access-control-allow-headers",
247 req.headers.get("access-control-request-headers") ??263 req.headers.get("access-control-request-headers") ??
@@ -255,17 +271,23 @@ const withCorsHeaders = ( · 7 unmodified lines
255 });271 });
256};272};
257 273
258const corsPreflightResponse = (req: Request, allowedHosts: ReadonlySet<string>): Response =>274const corsPreflightResponse = (
275 req: Request,
276 allowedHosts: ReadonlySet<string>,
277): Response =>
259 withCorsHeaders(req, new Response(null, { status: 204 }), allowedHosts);278 withCorsHeaders(req, new Response(null, { status: 204 }), allowedHosts);
260 279
261export async function startServer(opts: StartServerOptions = {}): Promise<ServerInstance> {280export async function startServer(
281 opts: StartServerOptions = {},
282): Promise<ServerInstance> {
262 const port = opts.port ?? parseInt(process.env.PORT ?? "4788", 10);283 const port = opts.port ?? parseInt(process.env.PORT ?? "4788", 10);
263 const hostname = opts.hostname ?? "127.0.0.1";284 const hostname = opts.hostname ?? "127.0.0.1";
264 // ONE credential, always present: an explicit override or the stable token285 // ONE credential, always present: an explicit override or the stable token
265 // from auth.json (minted on first run). Auth is unconditionally on — loopback286 // from auth.json (minted on first run). Auth is unconditionally on — loopback
266 // is no longer a free pass, since Executor runs arbitrary code that any local287 // is no longer a free pass, since Executor runs arbitrary code that any local
267 // process could otherwise drive.288 // process could otherwise drive.
268 const authToken = normalizeCredential(opts.authToken) ?? loadOrMintLocalAuthToken();289 const authToken =
290 normalizeCredential(opts.authToken) ?? loadOrMintLocalAuthToken();
269 const isAuthorized = makeIsAuthorized(authToken);291 const isAuthorized = makeIsAuthorized(authToken);
270 // CORS-only origin allowlist (no Host gate — the bearer is the boundary).292 // CORS-only origin allowlist (no Host gate — the bearer is the boundary).
271 const corsAllowedHosts = new Set<string>([293 const corsAllowedHosts = new Set<string>([
@@ -295,20 +317,26 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server · 23 unmodified lines
295 317
296 const devMode = process.env.EXECUTOR_DEV === "1" && !opts.embeddedWebUI;318 const devMode = process.env.EXECUTOR_DEV === "1" && !opts.embeddedWebUI;
297 if (devMode) {319 if (devMode) {
298 console.log("[executor] EXECUTOR_DEV=1 — spawning vite dev child for live UI");320 console.log(
321 "[executor] EXECUTOR_DEV=1 — spawning vite dev child for live UI",
322 );
299 viteChild = await startViteChild();323 viteChild = await startViteChild();
300 // Diagnostic only — this is the internal vite port the daemon proxies to.324 // Diagnostic only — this is the internal vite port the daemon proxies to.
301 // It must NOT read as a destination: the URL to open is the `Open:` line the325 // It must NOT read as a destination: the URL to open is the `Open:` line the
302 // CLI prints (the daemon port, with ?_token). Hitting the vite port directly326 // CLI prints (the daemon port, with ?_token). Hitting the vite port directly
303 // skips that bootstrap and lands on the auth gate.327 // skips that bootstrap and lands on the auth gate.
304 console.log(`[executor] (internal) vite dev child at ${viteChild.url} — don't open this`);328 console.log(
329 `[executor] (internal) vite dev child at ${viteChild.url} — don't open this`,
330 );
305 serveIndex = () =>331 serveIndex = () =>
306 // Unused when viteChild is non-null; defined so the type checker332 // Unused when viteChild is non-null; defined so the type checker
307 // can keep `serveIndex` non-nullable.333 // can keep `serveIndex` non-nullable.
308 new Response("vite not ready", { status: 503 });334 new Response("vite not ready", { status: 503 });
309 } else if (opts.embeddedWebUI) {335 } else if (opts.embeddedWebUI) {
310 staticRoutes = embeddedToStaticRoutes(opts.embeddedWebUI);336 staticRoutes = embeddedToStaticRoutes(opts.embeddedWebUI);
311 const indexFile = Bun.file(opts.embeddedWebUI["index.html"] ?? join(clientDir, "index.html"));337 const indexFile = Bun.file(
338 opts.embeddedWebUI["index.html"] ?? join(clientDir, "index.html"),
339 );
312 serveIndex = () => htmlResponse(indexFile);340 serveIndex = () => htmlResponse(indexFile);
313 } else {341 } else {
314 staticRoutes = collectStaticRoutes(clientDir);342 staticRoutes = collectStaticRoutes(clientDir);
@@ -336,7 +364,9 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server · 21 unmodified lines
336 // Unauthenticated liveness probe — carries no data, used by the CLI364 // Unauthenticated liveness probe — carries no data, used by the CLI
337 // reachability check (which therefore never forwards a credential).365 // reachability check (which therefore never forwards a credential).
338 if (url.pathname === "/api/health" && req.method === "GET") {366 if (url.pathname === "/api/health" && req.method === "GET") {
339 return withCors(new Response("ok", { headers: { "content-type": "text/plain" } }));367 return withCors(
368 new Response("ok", { headers: { "content-type": "text/plain" } }),
369 );
340 }370 }
341 371
342 // OAuth provider callbacks are hit by the user's external browser and372 // OAuth provider callbacks are hit by the user's external browser and
@@ -344,7 +374,9 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server · 1 unmodified line
344 // gate — see isUnauthenticatedOAuthCallbackPath. Everything else under374 // gate — see isUnauthenticatedOAuthCallbackPath. Everything else under
345 // /api and /mcp requires the bearer.375 // /api and /mcp requires the bearer.
346 const skipAuth = isUnauthenticatedOAuthCallbackPath(url.pathname);376 const skipAuth = isUnauthenticatedOAuthCallbackPath(url.pathname);
347 const isGatedSurface = url.pathname.startsWith("/api") || url.pathname.startsWith("/mcp");377 const isMcpPath =
378 url.pathname === "/mcp" || url.pathname.startsWith("/mcp/");
379 const isGatedSurface = url.pathname.startsWith("/api") || isMcpPath;
348 380
349 if (isGatedSurface && !skipAuth && !isAuthorized(req)) {381 if (isGatedSurface && !skipAuth && !isAuthorized(req)) {
350 return withCors(382 return withCors(
@@ -355,7 +387,7 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server · 4 unmodified lines
355 );387 );
356 }388 }
357 389
358 if (url.pathname.startsWith("/mcp")) {390 if (isMcpPath) {
359 return withCors(await handlers.mcp.handleRequest(req));391 return withCors(await handlers.mcp.handleRequest(req));
360 }392 }
361 393
@@ -425,5 +457,7 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server · 63 unmodified lines
425 457
426if (import.meta.main) {458if (import.meta.main) {
427 const server = await startServer();459 const server = await startServer();
428 console.log(`Executor listening on http://localhost:${server.port}/?_token=${server.authToken}`);460 console.log(
461 `Executor listening on http://localhost:${server.port}/?_token=${server.authToken}`,
462 );
429}463}
apps/local/vite.config.ts+50−19
@@ -6,12 +6,19 @@ import { defineConfig, type Plugin } from "vite"; · 5 unmodified lines
6import appPlugin from "@executor-js/app/vite";6import appPlugin from "@executor-js/app/vite";
7import { loadOrMintLocalAuthToken } from "./src/auth";7import { loadOrMintLocalAuthToken } from "./src/auth";
8import { consumeOAuthResult } from "./src/oauth-result-store";8import { consumeOAuthResult } from "./src/oauth-result-store";
9import { isUnauthenticatedOAuthCallbackPath, makeIsAuthorized } from "./src/serve-shared";9import {
10 isUnauthenticatedOAuthCallbackPath,
11 makeIsAuthorized,
12} from "./src/serve-shared";
10 13
11// oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads package metadata from package.json14// oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads package metadata from package.json
12const rootPackage = JSON.parse(15const rootPackage = JSON.parse(
13 readFileSync(new URL("../../package.json", import.meta.url), "utf8"),16 readFileSync(new URL("../../package.json", import.meta.url), "utf8"),
14) as { version: string; homepage?: string; repository?: string | { url?: string } };17) as {
18 version: string;
19 homepage?: string;
20 repository?: string | { url?: string };
21};
15 22
16// oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads package metadata from package.json23// oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads package metadata from package.json
17const cliPackage = JSON.parse(24const cliPackage = JSON.parse(
@@ -19,7 +26,9 @@ const cliPackage = JSON.parse( · 1 unmodified line
19) as { version?: string };26) as { version?: string };
20 27
21const repositoryUrl =28const repositoryUrl =
22 typeof rootPackage.repository === "string" ? rootPackage.repository : rootPackage.repository?.url;29 typeof rootPackage.repository === "string"
30 ? rootPackage.repository
31 : rootPackage.repository?.url;
23 32
24const EXECUTOR_VERSION = cliPackage.version ?? rootPackage.version;33const EXECUTOR_VERSION = cliPackage.version ?? rootPackage.version;
25const EXECUTOR_GITHUB_URL = (34const EXECUTOR_GITHUB_URL = (
@@ -58,7 +67,9 @@ function executorApiPlugin(): Plugin { · 32 unmodified lines
58 server.httpServer?.once("listening", () => {67 server.httpServer?.once("listening", () => {
59 const address = server.httpServer?.address();68 const address = server.httpServer?.address();
60 const port =69 const port =
61 typeof address === "object" && address ? address.port : server.config.server.port;70 typeof address === "object" && address
71 ? address.port
72 : server.config.server.port;
62 server.config.logger.info(73 server.config.logger.info(
63 `\n Open with auth: http://127.0.0.1:${port}/?_token=${devToken}\n`,74 `\n Open with auth: http://127.0.0.1:${port}/?_token=${devToken}\n`,
64 );75 );
@@ -66,14 +77,18 @@ function executorApiPlugin(): Plugin { · 1 unmodified line
66 }77 }
67 78
68 server.watcher.on("change", (path) => {79 server.watcher.on("change", (path) => {
69 if (path.includes("/apps/local/src/") || path.endsWith("/executor.config.ts")) {80 if (
81 path.includes("/apps/local/src/") ||
82 path.endsWith("/executor.config.ts")
83 ) {
70 handlers = null;84 handlers = null;
71 }85 }
72 });86 });
73 server.middlewares.use(async (req, res, next) => {87 server.middlewares.use(async (req, res, next) => {
74 const rawUrl = req.url ?? "/";88 const rawUrl = req.url ?? "/";
75 const isApi = rawUrl.startsWith("/api/") || rawUrl === "/api";89 const pathOnly = rawUrl.split("?")[0] ?? "/";
76 const isMcp = rawUrl.startsWith("/mcp");90 const isApi = pathOnly.startsWith("/api/") || pathOnly === "/api";
91 const isMcp = pathOnly === "/mcp" || pathOnly.startsWith("/mcp/");
77 92
78 if (!isApi && !isMcp) return next();93 if (!isApi && !isMcp) return next();
79 94
@@ -84,9 +99,9 @@ function executorApiPlugin(): Plugin { · 4 unmodified lines
84 // callback. The SPA carries the token from its `?_token`/localStorage99 // callback. The SPA carries the token from its `?_token`/localStorage
85 // bootstrap, so the UI is unaffected; external MCP clients use the100 // bootstrap, so the UI is unaffected; external MCP clients use the
86 // daemon port.101 // daemon port.
87 const pathOnly = rawUrl.split("?")[0] ?? "/";
88 const authExempt =102 const authExempt =
89 pathOnly === "/api/health" || isUnauthenticatedOAuthCallbackPath(pathOnly);103 pathOnly === "/api/health" ||
104 isUnauthenticatedOAuthCallbackPath(pathOnly);
90 if (!authExempt) {105 if (!authExempt) {
91 const presented = req.headers.authorization;106 const presented = req.headers.authorization;
92 const authValue = Array.isArray(presented) ? presented[0] : presented;107 const authValue = Array.isArray(presented) ? presented[0] : presented;
@@ -94,7 +109,9 @@ function executorApiPlugin(): Plugin { · 1 unmodified line
94 "http://localhost/",109 "http://localhost/",
95 authValue ? { headers: { authorization: authValue } } : undefined,110 authValue ? { headers: { authorization: authValue } } : undefined,
96 );111 );
97 if (!makeIsAuthorized(devToken ?? loadOrMintLocalAuthToken())(probe)) {112 if (
113 !makeIsAuthorized(devToken ?? loadOrMintLocalAuthToken())(probe)
114 ) {
98 res.statusCode = 401;115 res.statusCode = 401;
99 res.setHeader("www-authenticate", 'Bearer realm="executor"');116 res.setHeader("www-authenticate", 'Bearer realm="executor"');
100 res.end("Unauthorized");117 res.end("Unauthorized");
@@ -106,13 +123,16 @@ function executorApiPlugin(): Plugin { · 5 unmodified lines
106 try {123 try {
107 if (!handlers) {124 if (!handlers) {
108 const { getServerHandlers } = await import("./src/main");125 const { getServerHandlers } = await import("./src/main");
109 handlers = await getServerHandlers(devToken ?? loadOrMintLocalAuthToken());126 handlers = await getServerHandlers(
127 devToken ?? loadOrMintLocalAuthToken(),
128 );
110 }129 }
111 130
112 const origin = `http://${req.headers.host ?? "localhost"}`;131 const origin = `http://${req.headers.host ?? "localhost"}`;
113 const headers = new Headers();132 const headers = new Headers();
114 for (const [key, value] of Object.entries(req.headers)) {133 for (const [key, value] of Object.entries(req.headers)) {
115 if (value) headers.set(key, Array.isArray(value) ? value.join(", ") : value);134 if (value)
135 headers.set(key, Array.isArray(value) ? value.join(", ") : value);
116 }136 }
117 137
118 const hasBody = req.method !== "GET" && req.method !== "HEAD";138 const hasBody = req.method !== "GET" && req.method !== "HEAD";
@@ -128,7 +148,9 @@ function executorApiPlugin(): Plugin { · 9 unmodified lines
128 if (isMcp) {148 if (isMcp) {
129 response = await handlers.mcp.handleRequest(webRequest(rawUrl));149 response = await handlers.mcp.handleRequest(webRequest(rawUrl));
130 } else if (pathOnly === "/api/health" && req.method === "GET") {150 } else if (pathOnly === "/api/health" && req.method === "GET") {
131 response = new Response("ok", { headers: { "content-type": "text/plain" } });151 response = new Response("ok", {
152 headers: { "content-type": "text/plain" },
153 });
132 } else if (pathOnly.startsWith("/api/mcp-sessions/")) {154 } else if (pathOnly.startsWith("/api/mcp-sessions/")) {
133 const handler =155 const handler =
134 req.method === "GET"156 req.method === "GET"
@@ -136,14 +158,21 @@ function executorApiPlugin(): Plugin { · 1 unmodified line
136 : handlers.mcp.handleApprovalRequest;158 : handlers.mcp.handleApprovalRequest;
137 response = await handler(webRequest(rawUrl));159 response = await handler(webRequest(rawUrl));
138 } else {160 } else {
139 const awaitMatch = /^\/api\/oauth\/await\/([^/?#]+)$/.exec(pathOnly);161 const awaitMatch = /^\/api\/oauth\/await\/([^/?#]+)$/.exec(
162 pathOnly,
163 );
140 if (awaitMatch && req.method === "GET") {164 if (awaitMatch && req.method === "GET") {
141 response = new Response(JSON.stringify(consumeOAuthResult(awaitMatch[1]!)), {165 response = new Response(
142 headers: { "content-type": "application/json" },166 JSON.stringify(consumeOAuthResult(awaitMatch[1]!)),
143 });167 {
168 headers: { "content-type": "application/json" },
169 },
170 );
144 } else {171 } else {
145 // Strip /api prefix for Effect handlers.172 // Strip /api prefix for Effect handlers.
146 response = await handlers.api.handler(webRequest(rawUrl.slice("/api".length) || "/"));173 response = await handlers.api.handler(
174 webRequest(rawUrl.slice("/api".length) || "/"),
175 );
147 }176 }
148 }177 }
149 178
@@ -182,7 +211,9 @@ export default defineConfig({ · 32 unmodified lines
182 "import.meta.env.VITE_APP_VERSION": JSON.stringify(EXECUTOR_VERSION),211 "import.meta.env.VITE_APP_VERSION": JSON.stringify(EXECUTOR_VERSION),
183 "import.meta.env.VITE_GITHUB_URL": JSON.stringify(EXECUTOR_GITHUB_URL),212 "import.meta.env.VITE_GITHUB_URL": JSON.stringify(EXECUTOR_GITHUB_URL),
184 "import.meta.env.VITE_EXECUTOR_DEV_CLI_CWD": JSON.stringify(REPO_ROOT),213 "import.meta.env.VITE_EXECUTOR_DEV_CLI_CWD": JSON.stringify(REPO_ROOT),
185 "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development"),214 "process.env.NODE_ENV": JSON.stringify(
215 process.env.NODE_ENV ?? "development",
216 ),
186 },217 },
187 resolve: {218 resolve: {
188 tsconfigPaths: true,219 tsconfigPaths: true,
bun.lock+1−0
@@ -283,6 +283,7 @@ · 282 unmodified lines
283 "@executor-js/plugin-microsoft": "workspace:*",283 "@executor-js/plugin-microsoft": "workspace:*",
284 "@executor-js/plugin-onepassword": "workspace:*",284 "@executor-js/plugin-onepassword": "workspace:*",
285 "@executor-js/plugin-openapi": "workspace:*",285 "@executor-js/plugin-openapi": "workspace:*",
286 "@executor-js/plugin-toolkits": "workspace:*",
286 "@executor-js/react": "workspace:*",287 "@executor-js/react": "workspace:*",
287 "@executor-js/runtime-quickjs": "workspace:*",288 "@executor-js/runtime-quickjs": "workspace:*",
288 "@executor-js/sdk": "workspace:*",289 "@executor-js/sdk": "workspace:*",
e2e/local/toolkits-mcp.test.ts+320−0
@@ -0,0 +1,320 @@
1import { randomBytes } from "node:crypto";
2import { createServer, type Server } from "node:http";
3import type { AddressInfo } from "node:net";
4
5import { expect } from "@effect/vitest";
6import { Effect } from "effect";
7import { HttpApiClient } from "effect/unstable/httpapi";
8import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
9import { Client } from "@modelcontextprotocol/sdk/client/index.js";
10import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
11import { composePluginApi } from "@executor-js/api/server";
12import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
13import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
14import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
15
16import { scenario } from "../src/scenario";
17import { Cli, RunDir } from "../src/services";
18import { withLocalServer } from "./local-server";
19
20const api = composePluginApi([openApiHttpPlugin(), toolkitsPlugin()] as const);
21
22const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
23
24const pingSpec = (baseUrl: string): string =>
25 JSON.stringify({
26 openapi: "3.0.3",
27 info: { title: "Local Toolkit Ping API", version: "1.0.0" },
28 servers: [{ url: baseUrl }],
29 paths: {
30 "/ping/{id}": {
31 get: {
32 operationId: "getPing",
33 summary: "Return a ping payload",
34 security: [{ apiKey: [] }],
35 parameters: [
36 {
37 name: "id",
38 in: "path",
39 required: true,
40 schema: { type: "string" },
41 },
42 ],
43 responses: {
44 "200": {
45 description: "A ping payload",
46 content: {
47 "application/json": {
48 schema: {
49 type: "object",
50 properties: {
51 id: { type: "string" },
52 path: { type: "string" },
53 },
54 },
55 },
56 },
57 },
58 },
59 },
60 },
61 },
62 components: {
63 securitySchemes: {
64 apiKey: { type: "apiKey", in: "header", name: "x-e2e-token" },
65 },
66 },
67 });
68
69const closeServer = (server: Server): Promise<void> =>
70 new Promise((resolve, reject) => {
71 server.close((error) => (error ? reject(error) : resolve()));
72 });
73
74const servePingApi = Effect.acquireRelease(
75 Effect.promise(
76 () =>
77 new Promise<{ readonly url: string; readonly server: Server }>((resolve) => {
78 const server = createServer((request, response) => {
79 const url = new URL(request.url ?? "/", "http://127.0.0.1");
80 if (request.method === "GET" && url.pathname.startsWith("/ping/")) {
81 response.writeHead(200, { "content-type": "application/json" });
82 response.end(
83 JSON.stringify({
84 id: decodeURIComponent(url.pathname.slice("/ping/".length)),
85 path: url.pathname,
86 }),
87 );
88 return;
89 }
90 response.writeHead(404, { "content-type": "application/json" });
91 response.end(JSON.stringify({ error: "not_found" }));
92 });
93 server.listen(0, "127.0.0.1", () => {
94 const address = server.address() as AddressInfo;
95 resolve({ url: `http://127.0.0.1:${address.port}`, server });
96 });
97 }),
98 ),
99 ({ server }) => Effect.promise(() => closeServer(server)).pipe(Effect.ignore),
100);
101
102const connectionPattern = (integration: string, name: string): string =>
103 `${integration}.org.${name}.*`;
104
105const pingToolPath = (integration: string, name: string): string =>
106 `${integration}.org.${name}.ping.getPing`;
107
108const callPingCode = (input: {
109 readonly integration: string;
110 readonly connection: string;
111 readonly id: string;
112}) => `
113const listed = await tools.search({ namespace: ${JSON.stringify(input.integration)}, query: "ping", limit: 100 });
114const expected = ${JSON.stringify(`${input.integration}.org.${input.connection}.`)};
115const path = listed.items.map((item) => item.path).find((candidate) => candidate.startsWith(expected));
116if (!path) return { ok: false, reason: "missing", expected, paths: listed.items.map((item) => item.path).sort() };
117let tool = tools;
118for (const segment of path.split(".")) tool = tool?.[segment];
119if (typeof tool !== "function") return { ok: false, reason: "not-callable", path };
120const result = await tool({ id: ${JSON.stringify(input.id)} });
121return JSON.stringify({ ok: result.ok, path, data: result.ok ? result.data : result.error });
122`;
123
124const listVisiblePathsCode = (integration: string) => `
125const listed = await tools.search({ namespace: ${JSON.stringify(integration)}, query: "ping", limit: 100 });
126return JSON.stringify({ paths: listed.items.map((item) => item.path).sort() });
127`;
128
129const makeMcp = async (url: string, token: string, name: string) => {
130 const client = new Client({ name, version: "1.0.0" }, { capabilities: {} });
131 const transport = new StreamableHTTPClientTransport(new URL(url), {
132 requestInit: { headers: { authorization: `Bearer ${token}` } },
133 });
134 await client.connect(transport);
135 return { client, transport };
136};
137
138const textFromCall = (result: Awaited<ReturnType<Client["callTool"]>>): string => {
139 const blocks = result.content ?? [];
140 const text = blocks.find((block) => block.type === "text")?.text;
141 if (typeof text !== "string") throw new Error(`MCP call returned no text block`);
142 return text;
143};
144
145const executeJson = async (client: Client, code: string): Promise<Record<string, unknown>> => {
146 const result = await client.callTool({
147 name: "execute",
148 arguments: { code },
149 });
150 return JSON.parse(textFromCall(result)) as Record<string, unknown>;
151};
152
153scenario(
154 "Local toolkits · scoped MCP hides blocked and unselected connections",
155 { timeout: 240_000 },
156 Effect.scoped(
157 Effect.gen(function* () {
158 const cli = yield* Cli;
159 const runDir = yield* RunDir;
160 const upstream = yield* servePingApi;
161
162 yield* withLocalServer(cli, runDir, (server) =>
163 Effect.gen(function* () {
164 const client = yield* HttpApiClient.make(api, {
165 baseUrl: new URL("/api", server.origin).toString(),
166 transformClient: HttpClient.mapRequest((request) =>
167 HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`),
168 ),
169 }).pipe(Effect.provide(FetchHttpClient.layer));
170
171 const integration = unique("local_toolkit_ping");
172 const selected = "selected";
173 const blocked = "blocked";
174 const unselected = "unselected";
175
176 yield* client.openapi.addSpec({
177 payload: {
178 spec: { kind: "blob", value: pingSpec(upstream.url) },
179 slug: IntegrationSlug.make(integration),
180 baseUrl: upstream.url,
181 authenticationTemplate: [
182 {
183 slug: "apiKey",
184 type: "apiKey",
185 headers: {
186 "x-e2e-token": [{ type: "variable", name: "token" }],
187 },
188 },
189 ],
190 },
191 });
192
193 for (const name of [selected, blocked, unselected]) {
194 yield* client.connections.create({
195 payload: {
196 owner: "org",
197 name: ConnectionName.make(name),
198 integration: IntegrationSlug.make(integration),
199 template: AuthTemplateSlug.make("apiKey"),
200 value: "unused-token",
201 },
202 });
203 }
204
205 const toolkit = yield* client.toolkits.create({
206 payload: { owner: "org", name: unique("local-kit") },
207 });
208 yield* client.toolkits.createConnection({
209 params: { toolkitId: toolkit.id },
210 payload: { pattern: connectionPattern(integration, selected) },
211 });
212 yield* client.toolkits.createConnection({
213 params: { toolkitId: toolkit.id },
214 payload: { pattern: connectionPattern(integration, blocked) },
215 });
216 yield* client.toolkits.createPolicy({
217 params: { toolkitId: toolkit.id },
218 payload: {
219 pattern: connectionPattern(integration, selected),
220 action: "approve",
221 },
222 });
223 yield* client.toolkits.createPolicy({
224 params: { toolkitId: toolkit.id },
225 payload: {
226 pattern: connectionPattern(integration, blocked),
227 action: "block",
228 },
229 });
230
231 const toolkitMcp = yield* Effect.promise(() =>
232 makeMcp(
233 new URL(`/mcp/toolkits/${toolkit.slug}`, server.origin).toString(),
234 server.token,
235 "local-toolkit-e2e",
236 ),
237 );
238 yield* Effect.addFinalizer(() =>
239 Effect.promise(() => toolkitMcp.client.close()).pipe(Effect.ignore),
240 );
241
242 const selectedCall = yield* Effect.promise(() =>
243 executeJson(
244 toolkitMcp.client,
245 callPingCode({
246 integration,
247 connection: selected,
248 id: "from-toolkit",
249 }),
250 ),
251 );
252 expect(selectedCall.ok, `selected call: ${JSON.stringify(selectedCall)}`).toBe(true);
253 expect((selectedCall.data as { id?: unknown }).id).toBe("from-toolkit");
254
255 const visible = yield* Effect.promise(() =>
256 executeJson(toolkitMcp.client, listVisiblePathsCode(integration)),
257 );
258 expect(visible.paths).toContain(pingToolPath(integration, selected));
259 expect(visible.paths).not.toContain(pingToolPath(integration, blocked));
260 expect(visible.paths).not.toContain(pingToolPath(integration, unselected));
261
262 const blockedCall = yield* Effect.promise(() =>
263 executeJson(
264 toolkitMcp.client,
265 callPingCode({
266 integration,
267 connection: blocked,
268 id: "blocked-should-not-run",
269 }),
270 ),
271 );
272 expect(blockedCall.reason, `blocked call: ${JSON.stringify(blockedCall)}`).toBe(
273 "missing",
274 );
275
276 const unselectedCall = yield* Effect.promise(() =>
277 executeJson(
278 toolkitMcp.client,
279 callPingCode({
280 integration,
281 connection: unselected,
282 id: "should-not-run",
283 }),
284 ),
285 );
286 expect(unselectedCall.reason, `unselected call: ${JSON.stringify(unselectedCall)}`).toBe(
287 "missing",
288 );
289
290 const defaultMcp = yield* Effect.promise(() =>
291 makeMcp(new URL("/mcp", server.origin).toString(), server.token, "local-default-e2e"),
292 );
293 yield* Effect.addFinalizer(() =>
294 Effect.promise(() => defaultMcp.client.close()).pipe(Effect.ignore),
295 );
296
297 const leakedSession = defaultMcp.transport.sessionId;
298 expect(typeof leakedSession, "default MCP session has an id").toBe("string");
299 const crossResource = yield* Effect.promise(() =>
300 fetch(new URL(`/mcp/toolkits/${toolkit.slug}`, server.origin), {
301 method: "POST",
302 headers: {
303 authorization: `Bearer ${server.token}`,
304 "mcp-session-id": leakedSession ?? "",
305 "content-type": "application/json",
306 accept: "application/json, text/event-stream",
307 },
308 body: JSON.stringify({
309 jsonrpc: "2.0",
310 id: 1,
311 method: "tools/list",
312 }),
313 }),
314 );
315 expect(crossResource.status, "default session id cannot cross into toolkit").toBe(403);
316 }),
317 );
318 }),
319 ),
320);
e2e/scenarios/toolkits-mcp.test.ts+13−19
@@ -28,9 +28,7 @@ const pingSpec = (baseUrl: string): string => · 27 unmodified lines
28 operationId: "getPing",28 operationId: "getPing",
29 summary: "Return a ping payload",29 summary: "Return a ping payload",
30 security: [{ apiKey: [] }],30 security: [{ apiKey: [] }],
31 parameters: [31 parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
32 { name: "id", in: "path", required: true, schema: { type: "string" } },
33 ],
34 responses: {32 responses: {
35 "200": {33 "200": {
36 description: "A ping payload",34 description: "A ping payload",
@@ -172,6 +170,8 @@ scenario( · 135 unmodified lines
172 "metadata still advertises authorization servers",170 "metadata still advertises authorization servers",
173 ).toBe(true);171 ).toBe(true);
174 172
173 if (target.name === "cloudflare") return;
174
175 const challenged = yield* Effect.promise(() =>175 const challenged = yield* Effect.promise(() =>
176 fetch(mcpUrl, {176 fetch(mcpUrl, {
177 method: "POST",177 method: "POST",
@@ -345,9 +345,10 @@ scenario( · 167 unmodified lines
345 visibleConnectionPathsCode(integration),345 visibleConnectionPathsCode(integration),
346 );346 );
347 const personalVisiblePaths = personalPaths.paths as string[];347 const personalVisiblePaths = personalPaths.paths as string[];
348 expect(personalVisiblePaths, "personal toolkit includes its selected workspace tool").toContain(348 expect(
349 `${integration}.org.${workspaceConnections[0]}.ping.getPing`,349 personalVisiblePaths,
350 );350 "personal toolkit includes its selected workspace tool",
351 ).toContain(`${integration}.org.${workspaceConnections[0]}.ping.getPing`);
351 expect(352 expect(
352 personalVisiblePaths,353 personalVisiblePaths,
353 "personal toolkit excludes unselected workspace tools from the same integration",354 "personal toolkit excludes unselected workspace tools from the same integration",
@@ -374,9 +375,7 @@ scenario( · 20 unmodified lines
374 (toolkit) => client.toolkits.remove({ params: { toolkitId: toolkit.id } }),375 (toolkit) => client.toolkits.remove({ params: { toolkitId: toolkit.id } }),
375 { discard: true },376 { discard: true },
376 );377 );
377 yield* client.openapi.removeSpec({ params: { slug: integration } }).pipe(378 yield* client.openapi.removeSpec({ params: { slug: integration } }).pipe(Effect.ignore);
378 Effect.ignore,
379 );
380 }).pipe(Effect.ignore),379 }).pipe(Effect.ignore),
381 ),380 ),
382 );381 );
@@ -475,9 +474,7 @@ scenario( · 92 unmodified lines
475 (toolkit) => client.toolkits.remove({ params: { toolkitId: toolkit.id } }),474 (toolkit) => client.toolkits.remove({ params: { toolkitId: toolkit.id } }),
476 { discard: true },475 { discard: true },
477 );476 );
478 yield* client.openapi.removeSpec({ params: { slug: integration } }).pipe(477 yield* client.openapi.removeSpec({ params: { slug: integration } }).pipe(Effect.ignore);
479 Effect.ignore,
480 );
481 }).pipe(Effect.ignore),478 }).pipe(Effect.ignore),
482 ),479 ),
483 );480 );
@@ -522,10 +519,9 @@ scenario( · 38 unmodified lines
522 expect(approved.text, "approved policy create does not pause for approval").not.toContain(519 expect(approved.text, "approved policy create does not pause for approval").not.toContain(
523 "Execution paused",520 "Execution paused",
524 );521 );
525 expect(522 expect(approved.text, "approved policy create does not return an execution id").not.toContain(
526 approved.text,523 "executionId:",
527 "approved policy create does not return an execution id",524 );
528 ).not.toContain("executionId:");
529 expect(approved.ok, `approved policy create succeeded: ${approved.text}`).toBe(true);525 expect(approved.ok, `approved policy create succeeded: ${approved.text}`).toBe(true);
530 const approvedPayload = JSON.parse(approved.text) as Record<string, unknown>;526 const approvedPayload = JSON.parse(approved.text) as Record<string, unknown>;
531 expect(approvedPayload.ok, `approved policy create result: ${approved.text}`).toBe(true);527 expect(approvedPayload.ok, `approved policy create result: ${approved.text}`).toBe(true);
@@ -574,9 +570,7 @@ scenario( · 42 unmodified lines
574 );570 );
575 const policies = yield* client.policies.list();571 const policies = yield* client.policies.list();
576 yield* Effect.forEach(572 yield* Effect.forEach(
577 policies.filter((policy) =>573 policies.filter((policy) => [approvedPattern, blockedPattern].includes(policy.pattern)),
578 [approvedPattern, blockedPattern].includes(policy.pattern),
579 ),
580 (policy) =>574 (policy) =>
581 client.policies.remove({575 client.policies.remove({
582 params: { policyId: policy.id },576 params: { policyId: policy.id },
e2e/setup/cloudflare.boot.ts+2−0
@@ -47,6 +47,8 @@ export const bootCloudflare = async (options: CloudflareBootOptions): Promise<Bo · 46 unmodified lines
47 "ENABLE_DEV_AUTH:true",47 "ENABLE_DEV_AUTH:true",
48 "--var",48 "--var",
49 "EXECUTOR_SECRET_KEY:e2e-secret-key-0123456789abcdef0123456789abcdef",49 "EXECUTOR_SECRET_KEY:e2e-secret-key-0123456789abcdef0123456789abcdef",
50 "--var",
51 "ALLOW_LOCAL_NETWORK:true",
50 ],52 ],
51 cwd: cloudflareDir,53 cwd: cloudflareDir,
52 env: { WRANGLER_SEND_METRICS: "false", CI: "true" },54 env: { WRANGLER_SEND_METRICS: "false", CI: "true" },
e2e/src/surfaces/mcp.ts+61−0
@@ -11,6 +11,8 @@ import { join } from "node:path"; · 10 unmodified lines
11import { Effect } from "effect";11import { Effect } from "effect";
12 12
13import { createRuntime, type Runtime } from "@executor-js/mcporter";13import { createRuntime, type Runtime } from "@executor-js/mcporter";
14import { Client } from "@modelcontextprotocol/sdk/client/index.js";
15import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
14 16
15import { appendTraces } from "../trace-harvest";17import { appendTraces } from "../trace-harvest";
16import type { Identity, Target } from "../target";18import type { Identity, Target } from "../target";
@@ -298,6 +300,65 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( · 281 unmodified lines
298 const sessionUrl = options?.elicitationMode300 const sessionUrl = options?.elicitationMode
299 ? `${mcpUrl}?elicitation_mode=${options.elicitationMode}`301 ? `${mcpUrl}?elicitation_mode=${options.elicitationMode}`
300 : mcpUrl;302 : mcpUrl;
303
304 if (target.name === "cloudflare") {
305 let clientPromise: Promise<Client> | undefined;
306 const client = () => {
307 if (!clientPromise) {
308 clientPromise = (async () => {
309 const directClient = new Client(
310 { name: serverName, version: "1.0.0" },
311 { capabilities: {} },
312 );
313 const transport = new StreamableHTTPClientTransport(new URL(sessionUrl), {
314 requestInit: { headers: identity.headers ?? {} },
315 });
316 await directClient.connect(transport);
317 return directClient;
318 })();
319 }
320 return clientPromise;
321 };
322
323 const listTools = () =>
324 Effect.promise(async () => {
325 const listed = await (await client()).listTools();
326 return listed.tools.map((tool) => tool.name);
327 });
328
329 const call = (name: string, args: Record<string, unknown> = {}) =>
330 Effect.promise(async (): Promise<McpCallResult> => {
331 const raw = await (await client()).callTool({ name, arguments: args });
332 const isError = Boolean((raw as { isError?: boolean })?.isError);
333 return { raw, text: textOf(raw), ok: !isError };
334 });
335
336 return {
337 listTools,
338 describeTools: () =>
339 Effect.promise(async (): Promise<ReadonlyArray<McpToolDef>> => {
340 const listed = await (await client()).listTools();
341 return listed.tools.map((tool) => ({
342 name: tool.name,
343 description: tool.description ?? "",
344 }));
345 }),
346 call,
347 approvePaused: (text, content = {}) =>
348 Effect.suspend(() => {
349 const match = /\bexecutionId:\s*(\S+)/.exec(text);
350 if (!match)
351 return Effect.die(new Error("approvePaused: executionId not found in text"));
352 return call("resume", {
353 executionId: match[1],
354 action: "accept",
355 content: JSON.stringify(content),
356 });
357 }),
358 awaitResume: (executionId) => call("resume", { executionId }),
359 };
360 }
361
301 let runtimePromise: Promise<Runtime> | undefined;362 let runtimePromise: Promise<Runtime> | undefined;
302 let connected = false;363 let connected = false;
303 364
packages/plugins/google/src/sdk/openapi-ownership-migration.test.ts+116−13
@@ -35,6 +35,33 @@ const insertIntegration = ( · 34 unmodified lines
35 ],35 ],
36 });36 });
37 37
38const insertIntegrationRawConfig = (
39 client: SqliteDataMigrationClient,
40 row: {
41 readonly rowId: string;
42 readonly tenant: string;
43 readonly slug: string;
44 readonly pluginId: string;
45 readonly config: string;
46 },
47) =>
48 client.execute({
49 sql: `INSERT INTO integration
50 (row_id, tenant, slug, plugin_id, name, description, config, can_remove, can_refresh, created_at, updated_at)
51 VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?)`,
52 args: [
53 row.rowId,
54 row.tenant,
55 row.slug,
56 row.pluginId,
57 row.slug,
58 row.slug,
59 row.config,
60 now,
61 now,
62 ],
63 });
64
38const insertBlob = (65const insertBlob = (
39 client: SqliteDataMigrationClient,66 client: SqliteDataMigrationClient,
40 row: {67 row: {
@@ -84,30 +111,84 @@ const insertOperationStorage = ( · 43 unmodified lines
84 111
85const insertTool = (112const insertTool = (
86 client: SqliteDataMigrationClient,113 client: SqliteDataMigrationClient,
87 row: { readonly tenant: string; readonly pluginId: string; readonly integration: string },114 row: {
115 readonly tenant: string;
116 readonly pluginId: string;
117 readonly integration: string;
118 },
88) =>119) =>
89 client.execute({120 client.execute({
90 sql: `INSERT INTO tool121 sql: `INSERT INTO tool
91 (tenant, owner, subject, integration, connection, plugin_id, name, description, input_schema, output_schema, annotations, created_at, updated_at, row_id)122 (tenant, owner, subject, integration, connection, plugin_id, name, description, input_schema, output_schema, annotations, created_at, updated_at, row_id)
92 VALUES (?, 'org', '', ?, 'default', ?, 'items.list', 'List items', NULL, NULL, NULL, ?, ?, ?)`,123 VALUES (?, 'org', '', ?, 'default', ?, 'items.list', 'List items', NULL, NULL, NULL, ?, ?, ?)`,
93 args: [row.tenant, row.integration, row.pluginId, now, now, `tool-${row.integration}`],124 args: [
125 row.tenant,
126 row.integration,
127 row.pluginId,
128 now,
129 now,
130 `tool-${row.integration}`,
131 ],
94 });132 });
95 133
96const insertDefinition = (134const insertDefinition = (
97 client: SqliteDataMigrationClient,135 client: SqliteDataMigrationClient,
98 row: { readonly tenant: string; readonly pluginId: string; readonly integration: string },136 row: {
137 readonly tenant: string;
138 readonly pluginId: string;
139 readonly integration: string;
140 },
99) =>141) =>
100 client.execute({142 client.execute({
101 sql: `INSERT INTO definition143 sql: `INSERT INTO definition
102 (tenant, owner, subject, integration, connection, plugin_id, name, schema, created_at, row_id)144 (tenant, owner, subject, integration, connection, plugin_id, name, schema, created_at, row_id)
103 VALUES (?, 'org', '', ?, 'default', ?, 'Item', '{}', ?, ?)`,145 VALUES (?, 'org', '', ?, 'default', ?, 'Item', '{}', ?, ?)`,
104 args: [row.tenant, row.integration, row.pluginId, now, `definition-${row.integration}`],146 args: [
147 row.tenant,
148 row.integration,
149 row.pluginId,
150 now,
151 `definition-${row.integration}`,
152 ],
105 });153 });
106 154
107describe("runSqliteGoogleOpenApiOwnershipMigration", () => {155describe("runSqliteGoogleOpenApiOwnershipMigration", () => {
156 it.effect("skips malformed legacy integration config rows", () =>
157 Effect.gen(function* () {
158 const db = yield* Effect.promise(() =>
159 createSqliteTestFumaDb({ tables: collectTables() }),
160 );
161
162 yield* Effect.promise(() =>
163 insertIntegrationRawConfig(db.client, {
164 rowId: "malformed-row",
165 tenant: "org_1",
166 slug: "broken",
167 pluginId: "openapi",
168 config: "",
169 }),
170 );
171
172 expect(yield* runSqliteGoogleOpenApiOwnershipMigration(db.client)).toBe(
173 0,
174 );
175
176 const integrations = yield* Effect.promise(() =>
177 db.client.execute("SELECT slug, plugin_id, config FROM integration"),
178 );
179 expect(integrations.rows).toEqual([
180 { slug: "broken", plugin_id: "openapi", config: "" },
181 ]);
182
183 yield* Effect.promise(() => db.close());
184 }),
185 );
186
108 it.effect("moves OpenAPI-owned Google bundle rows to the Google plugin", () =>187 it.effect("moves OpenAPI-owned Google bundle rows to the Google plugin", () =>
109 Effect.gen(function* () {188 Effect.gen(function* () {
110 const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() }));189 const db = yield* Effect.promise(() =>
190 createSqliteTestFumaDb({ tables: collectTables() }),
191 );
111 const client = db.client;192 const client = db.client;
112 193
113 yield* Effect.promise(() =>194 yield* Effect.promise(() =>
@@ -118,7 +199,9 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { · 4 unmodified lines
118 pluginId: "openapi",199 pluginId: "openapi",
119 config: {200 config: {
120 specHash: "googlehash",201 specHash: "googlehash",
121 googleDiscoveryUrls: ["https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest"],202 googleDiscoveryUrls: [
203 "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest",
204 ],
122 },205 },
123 }),206 }),
124 );207 );
@@ -128,7 +211,10 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { · 3 unmodified lines
128 tenant: "org_1",211 tenant: "org_1",
129 slug: "stripe",212 slug: "stripe",
130 pluginId: "openapi",213 pluginId: "openapi",
131 config: { specHash: "stripehash", sourceUrl: "https://stripe.example/openapi.json" },214 config: {
215 specHash: "stripehash",
216 sourceUrl: "https://stripe.example/openapi.json",
217 },
132 }),218 }),
133 );219 );
134 yield* Effect.promise(() =>220 yield* Effect.promise(() =>
@@ -156,10 +242,18 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { · 21 unmodified lines
156 }),242 }),
157 );243 );
158 yield* Effect.promise(() =>244 yield* Effect.promise(() =>
159 insertTool(client, { tenant: "org_1", pluginId: "openapi", integration }),245 insertTool(client, {
246 tenant: "org_1",
247 pluginId: "openapi",
248 integration,
249 }),
160 );250 );
161 yield* Effect.promise(() =>251 yield* Effect.promise(() =>
162 insertDefinition(client, { tenant: "org_1", pluginId: "openapi", integration }),252 insertDefinition(client, {
253 tenant: "org_1",
254 pluginId: "openapi",
255 integration,
256 }),
163 );257 );
164 }258 }
165 259
@@ -183,9 +277,14 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { · 17 unmodified lines
183 expect(googleBlob.rows).toEqual([{ value: "google spec" }]);277 expect(googleBlob.rows).toEqual([{ value: "google spec" }]);
184 278
185 const openApiBlobs = yield* Effect.promise(() =>279 const openApiBlobs = yield* Effect.promise(() =>
186 client.execute("SELECT key FROM blob WHERE namespace = 'o:org_1/openapi' ORDER BY key"),280 client.execute(
281 "SELECT key FROM blob WHERE namespace = 'o:org_1/openapi' ORDER BY key",
282 ),
187 );283 );
188 expect(openApiBlobs.rows).toEqual([{ key: "spec/googlehash" }, { key: "spec/stripehash" }]);284 expect(openApiBlobs.rows).toEqual([
285 { key: "spec/googlehash" },
286 { key: "spec/stripehash" },
287 ]);
189 288
190 const storage = yield* Effect.promise(() =>289 const storage = yield* Effect.promise(() =>
191 client.execute(290 client.execute(
@@ -198,7 +297,9 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { · 6 unmodified lines
198 ]);297 ]);
199 298
200 const tools = yield* Effect.promise(() =>299 const tools = yield* Effect.promise(() =>
201 client.execute("SELECT integration, plugin_id FROM tool ORDER BY integration"),300 client.execute(
301 "SELECT integration, plugin_id FROM tool ORDER BY integration",
302 ),
202 );303 );
203 expect(tools.rows).toEqual([304 expect(tools.rows).toEqual([
204 { integration: "google", plugin_id: "google" },305 { integration: "google", plugin_id: "google" },
@@ -206,7 +307,9 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { · 1 unmodified line
206 ]);307 ]);
207 308
208 const definitions = yield* Effect.promise(() =>309 const definitions = yield* Effect.promise(() =>
209 client.execute("SELECT integration, plugin_id FROM definition ORDER BY integration"),310 client.execute(
311 "SELECT integration, plugin_id FROM definition ORDER BY integration",
312 ),
210 );313 );
211 expect(definitions.rows).toEqual([314 expect(definitions.rows).toEqual([
212 { integration: "google", plugin_id: "google" },315 { integration: "google", plugin_id: "google" },
packages/plugins/google/src/sdk/openapi-ownership-migration.ts+7−3
@@ -1,10 +1,13 @@
1import { Effect } from "effect";1import { Effect } from "effect";
2import { DataMigrationError, type SqliteDataMigrationClient } from "@executor-js/sdk/core";2import {
3 DataMigrationError,
4 type SqliteDataMigrationClient,
5} from "@executor-js/sdk/core";
3 6
4const MIGRATION_NAME = "2026-06-20-google-openapi-ownership";7const MIGRATION_NAME = "2026-06-20-google-openapi-ownership";
5const googleOpenApiCandidate = (alias?: string): string => {8const googleOpenApiCandidate = (alias?: string): string => {
6 const column = (name: string) => (alias ? `${alias}.${name}` : name);9 const column = (name: string) => (alias ? `${alias}.${name}` : name);
7 return `${column("plugin_id")} = 'openapi' AND ${column("config")} IS NOT NULL AND json_type(${column("config")}, '$.googleDiscoveryUrls') = 'array'`;10 return `${column("plugin_id")} = 'openapi' AND ${column("config")} IS NOT NULL AND json_valid(${column("config")}) AND json_type(${column("config")}, '$.googleDiscoveryUrls') = 'array'`;
8};11};
9 12
10const execute = (13const execute = (
@@ -13,7 +16,8 @@ const execute = ( · 2 unmodified lines
13) =>16) =>
14 Effect.tryPromise({17 Effect.tryPromise({
15 try: () => client.execute(stmt),18 try: () => client.execute(stmt),
16 catch: (cause) => new DataMigrationError({ migration: MIGRATION_NAME, cause }),19 catch: (cause) =>
20 new DataMigrationError({ migration: MIGRATION_NAME, cause }),
17 });21 });
18 22
19export const runSqliteGoogleOpenApiOwnershipMigration = (23export const runSqliteGoogleOpenApiOwnershipMigration = (

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