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
3232 } as unknown as D1Database;
3333 };
3434
35−const makeFakeR2 = (): { readonly bucket: R2Bucket; readonly objects: Map<string, string> } => {
35+const makeFakeR2 = (): {
36+ readonly bucket: R2Bucket;
37+ readonly objects: Map<string, string>;
38+} => {
3639 const objects = new Map<string, string>();
3740 // oxlint-disable-next-line executor/no-double-cast -- test double: only the R2 methods used by the migration are implemented
3841 const bucket = {
@@ -75,6 +78,23 @@ const insertIntegration = ( · 36 unmodified lines
7578 ],
7679 });
7780
81+const 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+
7898 const insertOperationStorage = (
7999 client: SqliteDataMigrationClient,
80100 row: {
@@ -103,6 +123,134 @@ const insertOperationStorage = ( · 22 unmodified lines
103123 });
104124
105125 describe("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+
106254 it.effect("moves Google OpenAPI ownership and copies the R2 spec object", () =>
107255 Effect.gen(function* () {
108256 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
2020 const sql = typeof stmt === "string" ? stmt : stmt.sql;
2121 const args = typeof stmt === "string" ? [] : stmt.args;
2222 const prepared = session.prepare(sql).bind(...args);
23− if (firstWord(sql) === "SELECT") {
23+ if (firstWord(sql) === "SELECT" || firstWord(sql) === "PRAGMA") {
2424 return prepared.all<T>().then((result) => result.results);
2525 }
2626 return prepared.run<T>().then(() => []);
@@ -38,6 +38,93 @@ export const d1DataMigrationClient = (db: D1Database): SqliteDataMigrationClient · 11 unmodified lines
3838 };
3939 };
4040
41+const 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+
53+const 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+
104+export 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+
41128 const r2ObjectName = (tenant: string, pluginId: string, key: string): string =>
42129 `o:${tenant}/${pluginId}/${key}`;
43130
@@ -52,6 +139,7 @@ const copyGoogleOpenApiSpecBlobsToR2 = ( · 8 unmodified lines
52139 FROM integration
53140 WHERE plugin_id = 'openapi'
54141 AND config IS NOT NULL
142+ AND json_valid(config)
55143 AND json_type(config, '$.googleDiscoveryUrls') = 'array'
56144 AND json_extract(config, '$.specHash') IS NOT NULL
57145 AND json_extract(config, '$.specHash') <> ''`,
@@ -67,7 +155,10 @@ const copyGoogleOpenApiSpecBlobsToR2 = ( · 9 unmodified lines
67155 }
68156 },
69157 catch: (cause) =>
70− new DataMigrationError({ migration: googleOpenApiOwnershipDataMigration.name, cause }),
158+ new DataMigrationError({
159+ migration: googleOpenApiOwnershipDataMigration.name,
160+ cause,
161+ }),
71162 });
72163
73164 const cloudflareDataMigrations = (bucket: R2Bucket | undefined): readonly SqliteDataMigration[] => [
@@ -86,5 +177,9 @@ export const runCloudflareDataMigrations = ( · 12 unmodified lines
86177 bucket: R2Bucket | undefined,
87178 ): Promise<readonly string[]> =>
88179 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+ ),
90185 );
apps/host-cloudflare/src/mcp/auth.ts+65−3
@@ -1,10 +1,58 @@
11 import { Effect, Layer } from "effect";
22
3−import { authenticated, McpAuthProvider, unauthorized } from "@executor-js/host-mcp";
3+import {
4+ authenticated,
5+ McpAuthProvider,
6+ unauthorized,
7+ type McpDiscoveryRoute,
8+} from "@executor-js/host-mcp";
49
510 import { makeAccessVerifier } from "../auth/cloudflare-access";
611 import type { CloudflareConfig } from "../config";
712
13+const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource";
14+const MCP_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/mcp`;
15+const TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH = `${MCP_PROTECTED_RESOURCE_METADATA_PATH}/toolkits/:toolkitSlug`;
16+
17+const 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+
31+const toolkitPath = (slug: string): string => `/mcp/toolkits/${encodeURIComponent(slug)}`;
32+
33+const resourcePathForRequest = (request: Request): string => {
34+ const slug = toolkitSlugFromPath(new URL(request.url).pathname);
35+ return slug ? toolkitPath(slug) : "/mcp";
36+};
37+
38+const 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+
45+const 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+
856 // ---------------------------------------------------------------------------
957 // Cloudflare Access McpAuthProvider — the `/mcp` gate, identical identity to the
1058 // 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
2371
2472 export const cloudflareAccessMcpAuth = (config: CloudflareConfig): Layer.Layer<McpAuthProvider> => {
2573 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+ ];
2688 return Layer.succeed(McpAuthProvider)({
27− discoveryRoutes: [],
89+ discoveryRoutes,
2890 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(),
3092 authenticate: (request) =>
3193 verify(request).pipe(
3294 Effect.map((principal) => (principal ? authenticated(principal) : unauthorized())),
apps/host-cloudflare/wrangler.jsonc+1−1
@@ -12,7 +12,7 @@ · 11 unmodified lines
1212 "assets": {
1313 "directory": "./dist",
1414 "not_found_handling": "single-page-application",
15− "run_worker_first": ["/api/*", "/mcp", "/mcp/*"],
15+ "run_worker_first": ["/api/*", "/mcp", "/mcp/*", "/.well-known/*"],
1616 },
1717 // D1 is the app's SQLite store (the DbProvider seam). `wrangler deploy`
1818 // 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
88 import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets";
99 import { onepasswordHttpPlugin } from "@executor-js/plugin-onepassword/api";
1010 import { desktopSettingsPlugin } from "@executor-js/plugin-desktop-settings/server";
11+import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
1112
1213 // ---------------------------------------------------------------------------
1314 // 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
1819 // First-party and third-party plugins use the same import-and-call flow.
1920 // ---------------------------------------------------------------------------
2021
22+interface LocalPluginDeps {
23+ readonly activeToolkitSlug?: string;
24+}
25+
2126 export default defineExecutorConfig({
22− plugins: () =>
27+ plugins: ({ activeToolkitSlug }: LocalPluginDeps = {}) =>
2328 [
2429 openApiHttpPlugin(),
2530 googleHttpPlugin(),
2631 microsoftHttpPlugin(),
2732 mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }),
2833 graphqlHttpPlugin(),
34+ toolkitsPlugin({ activeToolkitSlug }),
2935 keychainPlugin(),
3036 fileSecretsPlugin(),
3137 onepasswordHttpPlugin(),
3238 desktopSettingsPlugin({
3339 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"}`,
3542 }),
3643 ] as const,
3744 });
apps/local/package.json+1−0
@@ -39,6 +39,7 @@ · 38 unmodified lines
3939 "@executor-js/plugin-microsoft": "workspace:*",
4040 "@executor-js/plugin-onepassword": "workspace:*",
4141 "@executor-js/plugin-openapi": "workspace:*",
42+ "@executor-js/plugin-toolkits": "workspace:*",
4243 "@executor-js/react": "workspace:*",
4344 "@executor-js/runtime-quickjs": "workspace:*",
4445 "@executor-js/sdk": "workspace:*",
apps/local/src/executor.ts+70−41
@@ -49,7 +49,8 @@ const makeTenantId = (cwd: string): string => { · 48 unmodified lines
4949 return `${folder}-${hash}`;
5050 };
5151
52−const resolvePluginConfigPath = (scopeDir: string): string => join(scopeDir, "executor.jsonc");
52+const resolvePluginConfigPath = (scopeDir: string): string =>
53+ join(scopeDir, "executor.jsonc");
5354
5455 // Plugins reach the host through two doors that compose:
5556 // - `executor.config.ts`'s static tuple
@@ -57,51 +58,66 @@ const resolvePluginConfigPath = (scopeDir: string): string => join(scopeDir, "ex · 1 unmodified line
5758 // Static config wins on conflict, matching the Vite plugin.
5859 type LocalPlugins = readonly AnyPlugin[];
5960
60−const loadLocalPlugins = Effect.gen(function* () {
61− const cwd = process.env.EXECUTOR_SCOPE_DIR || process.cwd();
62− const staticPlugins = executorConfig.plugins();
63− const dynamicPlugins =
64− (yield* Effect.promise(() => loadPluginsFromJsonc({ path: resolvePluginConfigPath(cwd) }))) ??
65− [];
61+export interface LocalExecutorOptions {
62+ readonly activeToolkitSlug?: string;
63+}
6664
67− const staticPackageNames = new Set(
68− staticPlugins.map((plugin) => plugin.packageName).filter((name): name is string => !!name),
69− );
70− const dedupedDynamic = dynamicPlugins.filter((plugin) => {
71− if (plugin.packageName && staticPackageNames.has(plugin.packageName)) {
72− console.warn(
73− `[executor] plugin "${plugin.packageName}" appears in both ` +
74− `executor.config.ts and executor.jsonc#plugins. The static ` +
75− `entry wins; the jsonc entry is ignored.`,
76− );
77− return false;
78− }
79− return true;
80− });
65+const loadLocalPlugins = (options: LocalExecutorOptions = {}) =>
66+ Effect.gen(function* () {
67+ const cwd = process.env.EXECUTOR_SCOPE_DIR || process.cwd();
68+ const staticPlugins = executorConfig.plugins({
69+ activeToolkitSlug: options.activeToolkitSlug,
70+ });
71+ const dynamicPlugins =
72+ (yield* Effect.promise(() =>
73+ loadPluginsFromJsonc({ path: resolvePluginConfigPath(cwd) }),
74+ )) ?? [];
75+
76+ const staticPackageNames = new Set(
77+ staticPlugins
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+ });
8192
82− return {
83− cwd,
84− plugins: [...staticPlugins, ...dedupedDynamic] as LocalPlugins,
85− };
86−});
93+ return {
94+ cwd,
95+ plugins: [...staticPlugins, ...dedupedDynamic] as LocalPlugins,
96+ };
97+ });
8798
8899 interface LocalExecutorBundle {
89100 readonly executor: Executor<LocalPlugins>;
90101 readonly plugins: LocalPlugins;
91102 }
92103
93−class LocalExecutorTag extends Context.Service<LocalExecutorTag, LocalExecutorBundle>()(
94− "@executor-js/local/Executor",
95−) {}
104+class LocalExecutorTag extends Context.Service<
105+ LocalExecutorTag,
106+ LocalExecutorBundle
107+>()("@executor-js/local/Executor") {}
96108
97109 export type LocalExecutor = LocalExecutorBundle["executor"];
98110
99−class LocalExecutorCreateError extends Data.TaggedError("LocalExecutorCreateError")<{
111+class LocalExecutorCreateError extends Data.TaggedError(
112+ "LocalExecutorCreateError",
113+)<{
100114 readonly message: string;
101115 readonly cause: unknown;
102116 }> {}
103117
104−class LocalExecutorDisposeError extends Data.TaggedError("LocalExecutorDisposeError")<{
118+class LocalExecutorDisposeError extends Data.TaggedError(
119+ "LocalExecutorDisposeError",
120+)<{
105121 readonly operation: "createHandle" | "disposeExecutor" | "disposeRuntime";
106122 readonly cause: unknown;
107123 }> {}
@@ -126,20 +142,23 @@ const handleOrNull = (promise: ReturnType<typeof createExecutorHandle>) => · 18 unmodified lines
126142 Effect.runPromise(
127143 Effect.tryPromise({
128144 try: () => promise,
129− catch: (cause) => new LocalExecutorDisposeError({ operation: "createHandle", cause }),
145+ catch: (cause) =>
146+ new LocalExecutorDisposeError({ operation: "createHandle", cause }),
130147 }).pipe(
131148 Effect.catch(() =>
132− Effect.succeed<Awaited<ReturnType<typeof createExecutorHandle>> | null>(null),
149+ Effect.succeed<Awaited<ReturnType<typeof createExecutorHandle>> | null>(
150+ null,
151+ ),
133152 ),
134153 ),
135154 );
136155
137−const createLocalExecutorLayer = () => {
156+const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => {
138157 const storage = resolveStorage();
139158
140159 return Layer.effect(LocalExecutorTag)(
141160 Effect.gen(function* () {
142− const { cwd, plugins } = yield* loadLocalPlugins;
161+ const { cwd, plugins } = yield* loadLocalPlugins(options);
143162 const tenantId = makeTenantId(cwd);
144163 const tables = collectTables();
145164
@@ -180,7 +199,11 @@ const createLocalExecutorLayer = () => { · 34 unmodified lines
180199 // without touching the data.
181200 yield* runSqliteDataMigrations(sqlite.client, localDataMigrations).pipe(
182201 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+ }),
184207 ),
185208 );
186209
@@ -189,7 +212,8 @@ const createLocalExecutorLayer = () => { · 2 unmodified lines
189212 // resolution so a custom $PORT flows through. EXECUTOR_WEB_BASE_URL
190213 // overrides entirely for deployments where the UI is on a different host.
191214 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"}`;
193217
194218 const executor = yield* createExecutor({
195219 tenant: Tenant.make(tenantId),
@@ -223,8 +247,10 @@ const createLocalExecutorLayer = () => { · 27 unmodified lines
223247 );
224248 };
225249
226−export const createExecutorHandle = async () => {
227− const layer = createLocalExecutorLayer();
250+export const createExecutorHandle = async (
251+ options: LocalExecutorOptions = {},
252+) => {
253+ const layer = createLocalExecutorLayer(options);
228254 const runtime = ManagedRuntime.make(layer);
229255 const bundle = await runtime.runPromise(LocalExecutorTag.asEffect());
230256
@@ -249,14 +275,17 @@ const loadSharedHandle = () => { · 18 unmodified lines
249275 return sharedHandlePromise;
250276 };
251277
252−export const getExecutor = () => loadSharedHandle().then((handle) => handle.executor);
278+export const getExecutor = () =>
279+ loadSharedHandle().then((handle) => handle.executor);
253280 export const getExecutorBundle = () => loadSharedHandle();
254281
255282 export const disposeExecutor = async (): Promise<void> => {
256283 const currentHandlePromise = sharedHandlePromise;
257284 sharedHandlePromise = null;
258285
259− const handle = currentHandlePromise ? await handleOrNull(currentHandlePromise) : null;
286+ const handle = currentHandlePromise
287+ ? await handleOrNull(currentHandlePromise)
288+ : null;
260289 if (handle) {
261290 await ignorePromiseFailure("disposeExecutor", () => handle.dispose());
262291 }
apps/local/src/main.ts+29−8
@@ -3,7 +3,7 @@ import { Context, Effect, Layer, ManagedRuntime } from "effect"; · 2 unmodified lines
33 import { createExecutionEngine } from "@executor-js/execution";
44 import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
55 import { makeLocalApiHandler } from "./app";
6−import { getExecutorBundle } from "./executor";
6+import { createExecutorHandle, getExecutorBundle } from "./executor";
77 import { createMcpRequestHandler, type McpRequestHandler } from "./mcp";
88
99 // ---------------------------------------------------------------------------
@@ -53,7 +53,9 @@ const closeServerHandlers = async (handlers: ServerHandlers): Promise<void> => { · 43 unmodified lines
5353 );
5454 };
5555
56−export const createServerHandlers = async (token: string): Promise<ServerHandlers> => {
56+export const createServerHandlers = async (
57+ token: string,
58+): Promise<ServerHandlers> => {
5759 // The typed `/api` web-handler comes from `ExecutorApp.make` (./app.ts). The
5860 // boot bearer token is the authoritative `/api` gate (see `identity.ts`).
5961 const apiHandler: ServerHandlers["api"] = await makeLocalApiHandler(token);
@@ -67,20 +69,39 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 7 unmodified lines
6769 executor,
6870 codeExecutor: makeQuickJsExecutor(),
6971 });
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+ });
7189
7290 return { api: apiHandler, mcp };
7391 };
7492
75−export class ServerHandlersService extends Context.Service<ServerHandlersService, ServerHandlers>()(
76− "@executor-js/local/ServerHandlersService",
77−) {}
93+export class ServerHandlersService extends Context.Service<
94+ ServerHandlersService,
95+ ServerHandlers
96+>()("@executor-js/local/ServerHandlersService") {}
7897
7998 // The handlers are built once per process and memoized. The boot token is
8099 // captured on the first call (serve.ts / the vite dev middleware both pass the
81100 // SAME token loaded from `auth.json`), so memoization on first-call is correct.
82−let serverHandlersRuntime: ManagedRuntime.ManagedRuntime<ServerHandlersService, never> | null =
83− null;
101+let serverHandlersRuntime: ManagedRuntime.ManagedRuntime<
102+ ServerHandlersService,
103+ never
104+> | null = null;
84105
85106 const getServerHandlersRuntime = (
86107 token: string,
apps/local/src/mcp.ts+146−28
@@ -1,9 +1,14 @@
1−import { Effect } from "effect";
1+import { Effect, type Cause } from "effect";
22 import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
33 import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
44 import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
55
6−import { jsonRpcErrorBody } from "@executor-js/host-mcp";
6+import {
7+ defaultMcpResource,
8+ jsonRpcErrorBody,
9+ mcpResourceKey,
10+ type McpResource,
11+} from "@executor-js/host-mcp";
712 import {
813 createExecutorMcpServer,
914 type ExecutorMcpServerConfig,
@@ -15,10 +20,16 @@ import { · 5 unmodified lines
1520 readElicitationMode,
1621 } from "@executor-js/host-mcp/browser-approval";
1722 import { makeInProcessBrowserApprovalStore } from "@executor-js/host-mcp/browser-approval-store";
18−import { formatPausedExecution, type ResumeResponse } from "@executor-js/execution";
23+import {
24+ formatPausedExecution,
25+ type ExecutionEngine,
26+ type ResumeResponse,
27+} from "@executor-js/execution";
1928
2029 import { startIntegrationsRefresh } from "./integrations";
2130
31+type AnyExecutionEngine = ExecutionEngine<Cause.YieldableError>;
32+
2233 // ---------------------------------------------------------------------------
2334 // Streamable HTTP handler
2435 // ---------------------------------------------------------------------------
@@ -32,6 +43,18 @@ export type McpRequestHandler = { · 7 unmodified lines
3243 readonly close: () => Promise<void>;
3344 };
3445
46+export interface LocalMcpServerConfig {
47+ readonly config: ExecutorMcpServerConfig;
48+ readonly close?: () => Promise<void>;
49+}
50+
51+export interface LocalMcpRequestHandlerConfig {
52+ readonly defaultConfig: ExecutorMcpServerConfig;
53+ readonly createConfigForResource?: (
54+ resource: McpResource,
55+ ) => Promise<LocalMcpServerConfig> | LocalMcpServerConfig;
56+}
57+
3558 // Local serves these error bodies in-process; like the self-host store they are
3659 // INNER responses (no CORS) — byte-identical to the prior hand-rolled copy
3760 // (`content-type: application/json` only) via the canonical renderer.
@@ -44,7 +67,9 @@ const formatBoundaryError = (error: unknown): unknown => { · 6 unmodified lines
4467 return error;
4568 };
4669
47−const ignoreClose = (close: (() => Promise<void>) | undefined): Promise<void> =>
70+const ignoreClose = (
71+ close: (() => Promise<void>) | undefined,
72+): Promise<void> =>
4873 close
4974 ? Effect.runPromise(
5075 Effect.ignore(
@@ -56,8 +81,10 @@ const ignoreClose = (close: (() => Promise<void>) | undefined): Promise<void> => · 5 unmodified lines
5681 )
5782 : Promise.resolve();
5883
59−const pausedRequestPattern = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/;
60−const approvalRequestPattern = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/;
84+const pausedRequestPattern =
85+ /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/;
86+const approvalRequestPattern =
87+ /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/;
6188
6289 const json = (value: unknown, status = 200): Response =>
6390 new Response(JSON.stringify(value), {
@@ -70,57 +97,122 @@ const readResumeResponse = (request: Request): Promise<ResumeResponse | null> => · 6 unmodified lines
7097 Effect.tryPromise({
7198 try: () => request.json(),
7299 catch: () => null,
73− }).pipe(Effect.map((raw) => (raw === null ? null : decodeResumeResponse(raw)))),
100+ }).pipe(
101+ Effect.map((raw) => (raw === null ? null : decodeResumeResponse(raw))),
102+ ),
74103 );
75104
76−const resumeApprovalResult = (executionId: string, response: ResumeResponse) => ({
105+const resumeApprovalResult = (
106+ executionId: string,
107+ response: ResumeResponse,
108+) => ({
77109 status: "completed",
78110 ...formatResumeAcknowledgement(executionId, response),
79111 isError: false,
80112 });
81113
82−export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpRequestHandler => {
83− const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();
114+const toolkitPathPattern = /^\/mcp\/toolkits\/([^/?#]+)\/?$/;
115+
116+const 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+
124+const engineFromConfig = (
125+ config: ExecutorMcpServerConfig,
126+): AnyExecutionEngine | null => ("engine" in config ? config.engine : null);
127+
128+const normalizeHandlerConfig = (
129+ input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig,
130+): LocalMcpRequestHandlerConfig =>
131+ "defaultConfig" in input ? input : { defaultConfig: input };
132+
133+export const createMcpRequestHandler = (
134+ input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig,
135+): McpRequestHandler => {
136+ const handlerConfig = normalizeHandlerConfig(input);
137+ const transports = new Map<
138+ string,
139+ WebStandardStreamableHTTPServerTransport
140+ >();
84141 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>>();
85145 const approvals = makeInProcessBrowserApprovalStore();
86− // Local runs one shared engine across every MCP session (main.ts builds it and
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;
146+ const defaultEngine = engineFromConfig(handlerConfig.defaultConfig);
90147
91148 const pausedDetail = (
149+ sessionId: string,
92150 executionId: string,
93151 ): Promise<ReturnType<typeof formatPausedExecution> | null> =>
94− engine
152+ (sessionEngines.get(sessionId) ?? defaultEngine)
95153 ? Effect.runPromise(
96− engine.getPausedExecution(executionId).pipe(
97− Effect.map((paused) => (paused ? formatPausedExecution(paused) : null)),
98− Effect.orElseSucceed(() => null),
99− ),
154+ (sessionEngines.get(sessionId) ?? defaultEngine)!
155+ .getPausedExecution(executionId)
156+ .pipe(
157+ Effect.map((paused) =>
158+ paused ? formatPausedExecution(paused) : null,
159+ ),
160+ Effect.orElseSucceed(() => null),
161+ ),
100162 )
101163 : Promise.resolve(null);
102164
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+ ) => {
104177 const t = transports.get(id);
105178 const s = servers.get(id);
179+ const close = sessionClosers.get(id);
106180 transports.delete(id);
107181 servers.delete(id);
182+ resources.delete(id);
183+ sessionEngines.delete(id);
184+ sessionClosers.delete(id);
108185 if (opts.transport) await ignoreClose(t ? () => t.close() : undefined);
109186 if (opts.server) await ignoreClose(s ? () => s.close() : undefined);
187+ await ignoreClose(close);
110188 };
111189
112190 return {
113191 handleRequest: async (request) => {
192+ const resource = resourceFromRequest(request);
193+ if (!resource) return jsonError(404, -32001, "MCP resource not found");
114194 const sessionId = request.headers.get("mcp-session-id");
115195
116196 if (sessionId) {
117197 const transport = transports.get(sessionId);
118198 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+ }
119210 return transport.handleRequest(request);
120211 }
121212
122213 let created: McpServer | undefined;
123214 let createdSessionId: string | null = null;
215+ let resourceConfig: LocalMcpServerConfig | null = null;
124216 const transport = new WebStandardStreamableHTTPServerTransport({
125217 sessionIdGenerator: () => crypto.randomUUID(),
126218 enableJsonResponse: true,
@@ -128,6 +220,13 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 1 unmodified line
128220 createdSessionId = sid;
129221 transports.set(sid, transport);
130222 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);
131230 },
132231 onsessionclosed: (sid) => void dispose(sid, { server: true }),
133232 });
@@ -140,16 +239,21 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 6 unmodified lines
140239 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: MCP SDK handler must return JSON-RPC errors from thrown Promise APIs
141240 try {
142241 const elicitationMode = readElicitationMode(request);
242+ resourceConfig = await configForResource(resource);
143243 created = await Effect.runPromise(
144244 createExecutorMcpServer({
145− ...config,
245+ ...resourceConfig.config,
146246 browserApprovalStore: approvals.store,
147247 elicitationMode:
148248 elicitationMode === "browser"
149249 ? {
150250 mode: "browser" as const,
151251 approvalUrl: (executionId) =>
152− approvalUrlForRequest(request, executionId, createdSessionId),
252+ approvalUrlForRequest(
253+ request,
254+ executionId,
255+ createdSessionId,
256+ ),
153257 }
154258 : { mode: elicitationMode },
155259 }),
@@ -161,6 +265,7 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 5 unmodified lines
161265 await ignoreClose(() => transport.close());
162266 const server = created;
163267 await ignoreClose(server ? () => server.close() : undefined);
268+ await ignoreClose(resourceConfig?.close);
164269 }
165270 return response;
166271 } catch (error) {
@@ -169,6 +274,7 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 2 unmodified lines
169274 await ignoreClose(() => transport.close());
170275 const server = created;
171276 await ignoreClose(server ? () => server.close() : undefined);
277+ await ignoreClose(resourceConfig?.close);
172278 }
173279 return jsonError(500, -32603, "Internal server error");
174280 }
@@ -177,9 +283,13 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 2 unmodified lines
177283 handlePausedRequest: async (request) => {
178284 const match = pausedRequestPattern.exec(new URL(request.url).pathname);
179285 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);
181288
182− const paused = await pausedDetail(decodeURIComponent(match[2]));
289+ const paused = await pausedDetail(
290+ decodeURIComponent(match[1]),
291+ decodeURIComponent(match[2]),
292+ );
183293 if (!paused) return json({ error: "Paused execution not found" }, 404);
184294 return json({ text: paused.text, structured: paused.structured });
185295 },
@@ -187,11 +297,15 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 1 unmodified line
187297 handleApprovalRequest: async (request) => {
188298 const match = approvalRequestPattern.exec(new URL(request.url).pathname);
189299 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);
191302
303+ const sessionId = decodeURIComponent(match[1]);
192304 const executionId = decodeURIComponent(match[2]);
193305 // 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+ }
195309
196310 const response = await readResumeResponse(request);
197311 if (!response) return json({ error: "Invalid approval response" }, 400);
@@ -202,7 +316,9 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 4 unmodified lines
202316
203317 close: async () => {
204318 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+ );
206322 },
207323 };
208324 };
@@ -211,7 +327,9 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 2 unmodified lines
211327 // Stdio transport
212328 // ---------------------------------------------------------------------------
213329
214−export const runMcpStdioServer = async (config: ExecutorMcpServerConfig): Promise<void> => {
330+export const runMcpStdioServer = async (
331+ config: ExecutorMcpServerConfig,
332+): Promise<void> => {
215333 startIntegrationsRefresh();
216334
217335 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
3535 headers: { "content-type": "text/html", "cache-control": "no-store" },
3636 });
3737
38−function collectStaticRoutes(dir: string, prefix = ""): Record<string, StaticHandler> {
38+function collectStaticRoutes(
39+ dir: string,
40+ prefix = "",
41+): Record<string, StaticHandler> {
3942 const routes: Record<string, StaticHandler> = {};
4043 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: filesystem route discovery is best-effort for optional built assets
4144 try {
@@ -50,7 +53,9 @@ function collectStaticRoutes(dir: string, prefix = ""): Record<string, StaticHan · 8 unmodified lines
5053 routePath === "/index.html"
5154 ? htmlResponse(file)
5255 : new Response(file, {
53− headers: { "content-type": file.type || "application/octet-stream" },
56+ headers: {
57+ "content-type": file.type || "application/octet-stream",
58+ },
5459 });
5560 }
5661 }
@@ -62,7 +67,9 @@ function collectStaticRoutes(dir: string, prefix = ""): Record<string, StaticHan · 5 unmodified lines
6267 * Convert an embedded web UI map (path → bunfs path) into Bun.serve routes.
6368 * The embedded map is generated by the CLI build step using `with { type: "file" }`.
6469 */
65−function embeddedToStaticRoutes(embedded: Record<string, string>): Record<string, StaticHandler> {
70+function embeddedToStaticRoutes(
71+ embedded: Record<string, string>,
72+): Record<string, StaticHandler> {
6673 const routes: Record<string, StaticHandler> = {};
6774 for (const [key, bunfsPath] of Object.entries(embedded)) {
6875 const file = Bun.file(bunfsPath);
@@ -70,7 +77,9 @@ function embeddedToStaticRoutes(embedded: Record<string, string>): Record<string · 1 unmodified line
7077 key === "index.html"
7178 ? htmlResponse(file)
7279 : new Response(file, {
73− headers: { "content-type": file.type || "application/octet-stream" },
80+ headers: {
81+ "content-type": file.type || "application/octet-stream",
82+ },
7483 });
7584 }
7685 return routes;
@@ -90,7 +99,11 @@ interface ViteChild { · 13 unmodified lines
9099 }
91100
92101 async 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+ });
94107 const port = probe.port ?? 0;
95108 probe.stop(true);
96109 return port;
@@ -152,7 +165,9 @@ async function startViteChild(): Promise<ViteChild> { · 55 unmodified lines
152165 }
153166 if (child.exitCode !== null) {
154167 // 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+ );
156171 }
157172 await Bun.sleep(150);
158173 }
@@ -241,7 +256,8 @@ const withCorsHeaders = ( · 82 unmodified lines
241256 if (!origin || !isAllowedOrigin(origin, allowedHosts)) return response;
242257 const headers = new Headers(response.headers);
243258 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);
245261 headers.set(
246262 "access-control-allow-headers",
247263 req.headers.get("access-control-request-headers") ??
@@ -255,17 +271,23 @@ const withCorsHeaders = ( · 7 unmodified lines
255271 });
256272 };
257273
258−const corsPreflightResponse = (req: Request, allowedHosts: ReadonlySet<string>): Response =>
274+const corsPreflightResponse = (
275+ req: Request,
276+ allowedHosts: ReadonlySet<string>,
277+): Response =>
259278 withCorsHeaders(req, new Response(null, { status: 204 }), allowedHosts);
260279
261−export async function startServer(opts: StartServerOptions = {}): Promise<ServerInstance> {
280+export async function startServer(
281+ opts: StartServerOptions = {},
282+): Promise<ServerInstance> {
262283 const port = opts.port ?? parseInt(process.env.PORT ?? "4788", 10);
263284 const hostname = opts.hostname ?? "127.0.0.1";
264285 // ONE credential, always present: an explicit override or the stable token
265286 // from auth.json (minted on first run). Auth is unconditionally on — loopback
266287 // is no longer a free pass, since Executor runs arbitrary code that any local
267288 // process could otherwise drive.
268− const authToken = normalizeCredential(opts.authToken) ?? loadOrMintLocalAuthToken();
289+ const authToken =
290+ normalizeCredential(opts.authToken) ?? loadOrMintLocalAuthToken();
269291 const isAuthorized = makeIsAuthorized(authToken);
270292 // CORS-only origin allowlist (no Host gate — the bearer is the boundary).
271293 const corsAllowedHosts = new Set<string>([
@@ -295,20 +317,26 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server · 23 unmodified lines
295317
296318 const devMode = process.env.EXECUTOR_DEV === "1" && !opts.embeddedWebUI;
297319 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+ );
299323 viteChild = await startViteChild();
300324 // Diagnostic only — this is the internal vite port the daemon proxies to.
301325 // It must NOT read as a destination: the URL to open is the `Open:` line the
302326 // CLI prints (the daemon port, with ?_token). Hitting the vite port directly
303327 // 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+ );
305331 serveIndex = () =>
306332 // Unused when viteChild is non-null; defined so the type checker
307333 // can keep `serveIndex` non-nullable.
308334 new Response("vite not ready", { status: 503 });
309335 } else if (opts.embeddedWebUI) {
310336 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+ );
312340 serveIndex = () => htmlResponse(indexFile);
313341 } else {
314342 staticRoutes = collectStaticRoutes(clientDir);
@@ -336,7 +364,9 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server · 21 unmodified lines
336364 // Unauthenticated liveness probe — carries no data, used by the CLI
337365 // reachability check (which therefore never forwards a credential).
338366 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+ );
340370 }
341371
342372 // 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
344374 // gate — see isUnauthenticatedOAuthCallbackPath. Everything else under
345375 // /api and /mcp requires the bearer.
346376 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;
348380
349381 if (isGatedSurface && !skipAuth && !isAuthorized(req)) {
350382 return withCors(
@@ -355,7 +387,7 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server · 4 unmodified lines
355387 );
356388 }
357389
358− if (url.pathname.startsWith("/mcp")) {
390+ if (isMcpPath) {
359391 return withCors(await handlers.mcp.handleRequest(req));
360392 }
361393
@@ -425,5 +457,7 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server · 63 unmodified lines
425457
426458 if (import.meta.main) {
427459 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+ );
429463 }
apps/local/vite.config.ts+50−19
@@ -6,12 +6,19 @@ import { defineConfig, type Plugin } from "vite"; · 5 unmodified lines
66 import appPlugin from "@executor-js/app/vite";
77 import { loadOrMintLocalAuthToken } from "./src/auth";
88 import { consumeOAuthResult } from "./src/oauth-result-store";
9−import { isUnauthenticatedOAuthCallbackPath, makeIsAuthorized } from "./src/serve-shared";
9+import {
10+ isUnauthenticatedOAuthCallbackPath,
11+ makeIsAuthorized,
12+} from "./src/serve-shared";
1013
1114 // oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads package metadata from package.json
1215 const rootPackage = JSON.parse(
1316 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+};
1522
1623 // oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads package metadata from package.json
1724 const cliPackage = JSON.parse(
@@ -19,7 +26,9 @@ const cliPackage = JSON.parse( · 1 unmodified line
1926 ) as { version?: string };
2027
2128 const repositoryUrl =
22− typeof rootPackage.repository === "string" ? rootPackage.repository : rootPackage.repository?.url;
29+ typeof rootPackage.repository === "string"
30+ ? rootPackage.repository
31+ : rootPackage.repository?.url;
2332
2433 const EXECUTOR_VERSION = cliPackage.version ?? rootPackage.version;
2534 const EXECUTOR_GITHUB_URL = (
@@ -58,7 +67,9 @@ function executorApiPlugin(): Plugin { · 32 unmodified lines
5867 server.httpServer?.once("listening", () => {
5968 const address = server.httpServer?.address();
6069 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;
6273 server.config.logger.info(
6374 `\n Open with auth: http://127.0.0.1:${port}/?_token=${devToken}\n`,
6475 );
@@ -66,14 +77,18 @@ function executorApiPlugin(): Plugin { · 1 unmodified line
6677 }
6778
6879 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+ ) {
7084 handlers = null;
7185 }
7286 });
7387 server.middlewares.use(async (req, res, next) => {
7488 const rawUrl = req.url ?? "/";
75− const isApi = rawUrl.startsWith("/api/") || rawUrl === "/api";
76− const isMcp = rawUrl.startsWith("/mcp");
89+ const pathOnly = rawUrl.split("?")[0] ?? "/";
90+ const isApi = pathOnly.startsWith("/api/") || pathOnly === "/api";
91+ const isMcp = pathOnly === "/mcp" || pathOnly.startsWith("/mcp/");
7792
7893 if (!isApi && !isMcp) return next();
7994
@@ -84,9 +99,9 @@ function executorApiPlugin(): Plugin { · 4 unmodified lines
8499 // callback. The SPA carries the token from its `?_token`/localStorage
85100 // bootstrap, so the UI is unaffected; external MCP clients use the
86101 // daemon port.
87− const pathOnly = rawUrl.split("?")[0] ?? "/";
88102 const authExempt =
89− pathOnly === "/api/health" || isUnauthenticatedOAuthCallbackPath(pathOnly);
103+ pathOnly === "/api/health" ||
104+ isUnauthenticatedOAuthCallbackPath(pathOnly);
90105 if (!authExempt) {
91106 const presented = req.headers.authorization;
92107 const authValue = Array.isArray(presented) ? presented[0] : presented;
@@ -94,7 +109,9 @@ function executorApiPlugin(): Plugin { · 1 unmodified line
94109 "http://localhost/",
95110 authValue ? { headers: { authorization: authValue } } : undefined,
96111 );
97− if (!makeIsAuthorized(devToken ?? loadOrMintLocalAuthToken())(probe)) {
112+ if (
113+ !makeIsAuthorized(devToken ?? loadOrMintLocalAuthToken())(probe)
114+ ) {
98115 res.statusCode = 401;
99116 res.setHeader("www-authenticate", 'Bearer realm="executor"');
100117 res.end("Unauthorized");
@@ -106,13 +123,16 @@ function executorApiPlugin(): Plugin { · 5 unmodified lines
106123 try {
107124 if (!handlers) {
108125 const { getServerHandlers } = await import("./src/main");
109− handlers = await getServerHandlers(devToken ?? loadOrMintLocalAuthToken());
126+ handlers = await getServerHandlers(
127+ devToken ?? loadOrMintLocalAuthToken(),
128+ );
110129 }
111130
112131 const origin = `http://${req.headers.host ?? "localhost"}`;
113132 const headers = new Headers();
114133 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);
116136 }
117137
118138 const hasBody = req.method !== "GET" && req.method !== "HEAD";
@@ -128,7 +148,9 @@ function executorApiPlugin(): Plugin { · 9 unmodified lines
128148 if (isMcp) {
129149 response = await handlers.mcp.handleRequest(webRequest(rawUrl));
130150 } 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+ });
132154 } else if (pathOnly.startsWith("/api/mcp-sessions/")) {
133155 const handler =
134156 req.method === "GET"
@@ -136,14 +158,21 @@ function executorApiPlugin(): Plugin { · 1 unmodified line
136158 : handlers.mcp.handleApprovalRequest;
137159 response = await handler(webRequest(rawUrl));
138160 } else {
139− const awaitMatch = /^\/api\/oauth\/await\/([^/?#]+)$/.exec(pathOnly);
161+ const awaitMatch = /^\/api\/oauth\/await\/([^/?#]+)$/.exec(
162+ pathOnly,
163+ );
140164 if (awaitMatch && req.method === "GET") {
141− response = new Response(JSON.stringify(consumeOAuthResult(awaitMatch[1]!)), {
142− headers: { "content-type": "application/json" },
143− });
165+ response = new Response(
166+ JSON.stringify(consumeOAuthResult(awaitMatch[1]!)),
167+ {
168+ headers: { "content-type": "application/json" },
169+ },
170+ );
144171 } else {
145172 // 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+ );
147176 }
148177 }
149178
@@ -182,7 +211,9 @@ export default defineConfig({ · 32 unmodified lines
182211 "import.meta.env.VITE_APP_VERSION": JSON.stringify(EXECUTOR_VERSION),
183212 "import.meta.env.VITE_GITHUB_URL": JSON.stringify(EXECUTOR_GITHUB_URL),
184213 "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+ ),
186217 },
187218 resolve: {
188219 tsconfigPaths: true,
bun.lock+1−0
@@ -283,6 +283,7 @@ · 282 unmodified lines
283283 "@executor-js/plugin-microsoft": "workspace:*",
284284 "@executor-js/plugin-onepassword": "workspace:*",
285285 "@executor-js/plugin-openapi": "workspace:*",
286+ "@executor-js/plugin-toolkits": "workspace:*",
286287 "@executor-js/react": "workspace:*",
287288 "@executor-js/runtime-quickjs": "workspace:*",
288289 "@executor-js/sdk": "workspace:*",
e2e/local/toolkits-mcp.test.ts+320−0
@@ -0,0 +1,320 @@
1+import { randomBytes } from "node:crypto";
2+import { createServer, type Server } from "node:http";
3+import type { AddressInfo } from "node:net";
4+
5+import { expect } from "@effect/vitest";
6+import { Effect } from "effect";
7+import { HttpApiClient } from "effect/unstable/httpapi";
8+import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
9+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
10+import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
11+import { composePluginApi } from "@executor-js/api/server";
12+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
13+import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
14+import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
15+
16+import { scenario } from "../src/scenario";
17+import { Cli, RunDir } from "../src/services";
18+import { withLocalServer } from "./local-server";
19+
20+const api = composePluginApi([openApiHttpPlugin(), toolkitsPlugin()] as const);
21+
22+const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
23+
24+const 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+
69+const closeServer = (server: Server): Promise<void> =>
70+ new Promise((resolve, reject) => {
71+ server.close((error) => (error ? reject(error) : resolve()));
72+ });
73+
74+const 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+
102+const connectionPattern = (integration: string, name: string): string =>
103+ `${integration}.org.${name}.*`;
104+
105+const pingToolPath = (integration: string, name: string): string =>
106+ `${integration}.org.${name}.ping.getPing`;
107+
108+const callPingCode = (input: {
109+ readonly integration: string;
110+ readonly connection: string;
111+ readonly id: string;
112+}) => `
113+const listed = await tools.search({ namespace: ${JSON.stringify(input.integration)}, query: "ping", limit: 100 });
114+const expected = ${JSON.stringify(`${input.integration}.org.${input.connection}.`)};
115+const path = listed.items.map((item) => item.path).find((candidate) => candidate.startsWith(expected));
116+if (!path) return { ok: false, reason: "missing", expected, paths: listed.items.map((item) => item.path).sort() };
117+let tool = tools;
118+for (const segment of path.split(".")) tool = tool?.[segment];
119+if (typeof tool !== "function") return { ok: false, reason: "not-callable", path };
120+const result = await tool({ id: ${JSON.stringify(input.id)} });
121+return JSON.stringify({ ok: result.ok, path, data: result.ok ? result.data : result.error });
122+`;
123+
124+const listVisiblePathsCode = (integration: string) => `
125+const listed = await tools.search({ namespace: ${JSON.stringify(integration)}, query: "ping", limit: 100 });
126+return JSON.stringify({ paths: listed.items.map((item) => item.path).sort() });
127+`;
128+
129+const 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+
138+const 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+
145+const 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+
153+scenario(
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
2828 operationId: "getPing",
2929 summary: "Return a ping payload",
3030 security: [{ apiKey: [] }],
31− parameters: [
32− { name: "id", in: "path", required: true, schema: { type: "string" } },
33− ],
31+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
3432 responses: {
3533 "200": {
3634 description: "A ping payload",
@@ -172,6 +170,8 @@ scenario( · 135 unmodified lines
172170 "metadata still advertises authorization servers",
173171 ).toBe(true);
174172
173+ if (target.name === "cloudflare") return;
174+
175175 const challenged = yield* Effect.promise(() =>
176176 fetch(mcpUrl, {
177177 method: "POST",
@@ -345,9 +345,10 @@ scenario( · 167 unmodified lines
345345 visibleConnectionPathsCode(integration),
346346 );
347347 const personalVisiblePaths = personalPaths.paths as string[];
348− expect(personalVisiblePaths, "personal toolkit includes its selected workspace tool").toContain(
349− `${integration}.org.${workspaceConnections[0]}.ping.getPing`,
350− );
348+ expect(
349+ personalVisiblePaths,
350+ "personal toolkit includes its selected workspace tool",
351+ ).toContain(`${integration}.org.${workspaceConnections[0]}.ping.getPing`);
351352 expect(
352353 personalVisiblePaths,
353354 "personal toolkit excludes unselected workspace tools from the same integration",
@@ -374,9 +375,7 @@ scenario( · 20 unmodified lines
374375 (toolkit) => client.toolkits.remove({ params: { toolkitId: toolkit.id } }),
375376 { discard: true },
376377 );
377− yield* client.openapi.removeSpec({ params: { slug: integration } }).pipe(
378− Effect.ignore,
379− );
378+ yield* client.openapi.removeSpec({ params: { slug: integration } }).pipe(Effect.ignore);
380379 }).pipe(Effect.ignore),
381380 ),
382381 );
@@ -475,9 +474,7 @@ scenario( · 92 unmodified lines
475474 (toolkit) => client.toolkits.remove({ params: { toolkitId: toolkit.id } }),
476475 { discard: true },
477476 );
478− yield* client.openapi.removeSpec({ params: { slug: integration } }).pipe(
479− Effect.ignore,
480− );
477+ yield* client.openapi.removeSpec({ params: { slug: integration } }).pipe(Effect.ignore);
481478 }).pipe(Effect.ignore),
482479 ),
483480 );
@@ -522,10 +519,9 @@ scenario( · 38 unmodified lines
522519 expect(approved.text, "approved policy create does not pause for approval").not.toContain(
523520 "Execution paused",
524521 );
525− expect(
526− approved.text,
527− "approved policy create does not return an execution id",
528− ).not.toContain("executionId:");
522+ expect(approved.text, "approved policy create does not return an execution id").not.toContain(
523+ "executionId:",
524+ );
529525 expect(approved.ok, `approved policy create succeeded: ${approved.text}`).toBe(true);
530526 const approvedPayload = JSON.parse(approved.text) as Record<string, unknown>;
531527 expect(approvedPayload.ok, `approved policy create result: ${approved.text}`).toBe(true);
@@ -574,9 +570,7 @@ scenario( · 42 unmodified lines
574570 );
575571 const policies = yield* client.policies.list();
576572 yield* Effect.forEach(
577− policies.filter((policy) =>
578− [approvedPattern, blockedPattern].includes(policy.pattern),
579− ),
573+ policies.filter((policy) => [approvedPattern, blockedPattern].includes(policy.pattern)),
580574 (policy) =>
581575 client.policies.remove({
582576 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
4747 "ENABLE_DEV_AUTH:true",
4848 "--var",
4949 "EXECUTOR_SECRET_KEY:e2e-secret-key-0123456789abcdef0123456789abcdef",
50+ "--var",
51+ "ALLOW_LOCAL_NETWORK:true",
5052 ],
5153 cwd: cloudflareDir,
5254 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
1111 import { Effect } from "effect";
1212
1313 import { createRuntime, type Runtime } from "@executor-js/mcporter";
14+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
15+import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
1416
1517 import { appendTraces } from "../trace-harvest";
1618 import type { Identity, Target } from "../target";
@@ -298,6 +300,65 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( · 281 unmodified lines
298300 const sessionUrl = options?.elicitationMode
299301 ? `${mcpUrl}?elicitation_mode=${options.elicitationMode}`
300302 : 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+
301362 let runtimePromise: Promise<Runtime> | undefined;
302363 let connected = false;
303364
packages/plugins/google/src/sdk/openapi-ownership-migration.test.ts+116−13
@@ -35,6 +35,33 @@ const insertIntegration = ( · 34 unmodified lines
3535 ],
3636 });
3737
38+const 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+
3865 const insertBlob = (
3966 client: SqliteDataMigrationClient,
4067 row: {
@@ -84,30 +111,84 @@ const insertOperationStorage = ( · 43 unmodified lines
84111
85112 const insertTool = (
86113 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+ },
88119 ) =>
89120 client.execute({
90121 sql: `INSERT INTO tool
91122 (tenant, owner, subject, integration, connection, plugin_id, name, description, input_schema, output_schema, annotations, created_at, updated_at, row_id)
92123 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+ ],
94132 });
95133
96134 const insertDefinition = (
97135 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+ },
99141 ) =>
100142 client.execute({
101143 sql: `INSERT INTO definition
102144 (tenant, owner, subject, integration, connection, plugin_id, name, schema, created_at, row_id)
103145 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+ ],
105153 });
106154
107155 describe("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+
108187 it.effect("moves OpenAPI-owned Google bundle rows to the Google plugin", () =>
109188 Effect.gen(function* () {
110− const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() }));
189+ const db = yield* Effect.promise(() =>
190+ createSqliteTestFumaDb({ tables: collectTables() }),
191+ );
111192 const client = db.client;
112193
113194 yield* Effect.promise(() =>
@@ -118,7 +199,9 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { · 4 unmodified lines
118199 pluginId: "openapi",
119200 config: {
120201 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+ ],
122205 },
123206 }),
124207 );
@@ -128,7 +211,10 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { · 3 unmodified lines
128211 tenant: "org_1",
129212 slug: "stripe",
130213 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+ },
132218 }),
133219 );
134220 yield* Effect.promise(() =>
@@ -156,10 +242,18 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { · 21 unmodified lines
156242 }),
157243 );
158244 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+ }),
160250 );
161251 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+ }),
163257 );
164258 }
165259
@@ -183,9 +277,14 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { · 17 unmodified lines
183277 expect(googleBlob.rows).toEqual([{ value: "google spec" }]);
184278
185279 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+ ),
187283 );
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+ ]);
189288
190289 const storage = yield* Effect.promise(() =>
191290 client.execute(
@@ -198,7 +297,9 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { · 6 unmodified lines
198297 ]);
199298
200299 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+ ),
202303 );
203304 expect(tools.rows).toEqual([
204305 { integration: "google", plugin_id: "google" },
@@ -206,7 +307,9 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { · 1 unmodified line
206307 ]);
207308
208309 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+ ),
210313 );
211314 expect(definitions.rows).toEqual([
212315 { integration: "google", plugin_id: "google" },
packages/plugins/google/src/sdk/openapi-ownership-migration.ts+7−3
@@ -1,10 +1,13 @@
11 import { Effect } from "effect";
2−import { DataMigrationError, type SqliteDataMigrationClient } from "@executor-js/sdk/core";
2+import {
3+ DataMigrationError,
4+ type SqliteDataMigrationClient,
5+} from "@executor-js/sdk/core";
36
47 const MIGRATION_NAME = "2026-06-20-google-openapi-ownership";
58 const googleOpenApiCandidate = (alias?: string): string => {
69 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'`;
811 };
912
1013 const execute = (
@@ -13,7 +16,8 @@ const execute = ( · 2 unmodified lines
1316 ) =>
1417 Effect.tryPromise({
1518 try: () => client.execute(stmt),
16− catch: (cause) => new DataMigrationError({ migration: MIGRATION_NAME, cause }),
19+ catch: (cause) =>
20+ new DataMigrationError({ migration: MIGRATION_NAME, cause }),
1721 });
1822
1923 export 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