Unified · Split
| @@ -32,7 +32,10 @@ const makeFakeD1 = (client: SqliteDataMigrationClient): D1Database => { · 31 unmodified lines | ||
| 32 | 32 | } as unknown as D1Database; |
| 33 | 33 | }; |
| 34 | 34 | |
| 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 | +} => { | |
| 36 | 39 | const objects = new Map<string, string>(); |
| 37 | 40 | // oxlint-disable-next-line executor/no-double-cast -- test double: only the R2 methods used by the migration are implemented |
| 38 | 41 | const bucket = { |
| @@ -75,6 +78,23 @@ const insertIntegration = ( · 36 unmodified lines | ||
| 75 | 78 | ], |
| 76 | 79 | }); |
| 77 | 80 | |
| 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 | + | |
| 78 | 98 | const insertOperationStorage = ( |
| 79 | 99 | client: SqliteDataMigrationClient, |
| 80 | 100 | row: { |
| @@ -103,6 +123,134 @@ const insertOperationStorage = ( · 22 unmodified lines | ||
| 103 | 123 | }); |
| 104 | 124 | |
| 105 | 125 | 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 | + | |
| 106 | 254 | it.effect("moves Google OpenAPI ownership and copies the R2 spec object", () => |
| 107 | 255 | Effect.gen(function* () { |
| 108 | 256 | const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })); |
| @@ -20,7 +20,7 @@ const queryRows = <T extends Record<string, unknown>>( · 19 unmodified lines | ||
| 20 | 20 | const sql = typeof stmt === "string" ? stmt : stmt.sql; |
| 21 | 21 | const args = typeof stmt === "string" ? [] : stmt.args; |
| 22 | 22 | const prepared = session.prepare(sql).bind(...args); |
| 23 | − if (firstWord(sql) === "SELECT") { | |
| 23 | + if (firstWord(sql) === "SELECT" || firstWord(sql) === "PRAGMA") { | |
| 24 | 24 | return prepared.all<T>().then((result) => result.results); |
| 25 | 25 | } |
| 26 | 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 | |
| 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 | + | |
| 41 | 128 | const r2ObjectName = (tenant: string, pluginId: string, key: string): string => |
| 42 | 129 | `o:${tenant}/${pluginId}/${key}`; |
| 43 | 130 | |
| @@ -52,6 +139,7 @@ const copyGoogleOpenApiSpecBlobsToR2 = ( · 8 unmodified lines | ||
| 52 | 139 | FROM integration |
| 53 | 140 | WHERE plugin_id = 'openapi' |
| 54 | 141 | AND config IS NOT NULL |
| 142 | + AND json_valid(config) | |
| 55 | 143 | AND json_type(config, '$.googleDiscoveryUrls') = 'array' |
| 56 | 144 | AND json_extract(config, '$.specHash') IS NOT NULL |
| 57 | 145 | AND json_extract(config, '$.specHash') <> ''`, |
| @@ -67,7 +155,10 @@ const copyGoogleOpenApiSpecBlobsToR2 = ( · 9 unmodified lines | ||
| 67 | 155 | } |
| 68 | 156 | }, |
| 69 | 157 | catch: (cause) => |
| 70 | − new DataMigrationError({ migration: googleOpenApiOwnershipDataMigration.name, cause }), | |
| 158 | + new DataMigrationError({ | |
| 159 | + migration: googleOpenApiOwnershipDataMigration.name, | |
| 160 | + cause, | |
| 161 | + }), | |
| 71 | 162 | }); |
| 72 | 163 | |
| 73 | 164 | const cloudflareDataMigrations = (bucket: R2Bucket | undefined): readonly SqliteDataMigration[] => [ |
| @@ -86,5 +177,9 @@ export const runCloudflareDataMigrations = ( · 12 unmodified lines | ||
| 86 | 177 | bucket: R2Bucket | undefined, |
| 87 | 178 | ): Promise<readonly string[]> => |
| 88 | 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 | ); |
| @@ -1,10 +1,58 @@ | ||
| 1 | 1 | import { Effect, Layer } from "effect"; |
| 2 | 2 | |
| 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"; | |
| 4 | 9 | |
| 5 | 10 | import { makeAccessVerifier } from "../auth/cloudflare-access"; |
| 6 | 11 | import type { CloudflareConfig } from "../config"; |
| 7 | 12 | |
| 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 | + | |
| 8 | 56 | // --------------------------------------------------------------------------- |
| 9 | 57 | // Cloudflare Access McpAuthProvider — the `/mcp` gate, identical identity to the |
| 10 | 58 | // 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 | |
| 24 | 72 | export const cloudflareAccessMcpAuth = (config: CloudflareConfig): Layer.Layer<McpAuthProvider> => { |
| 25 | 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 | 88 | return Layer.succeed(McpAuthProvider)({ |
| 27 | − discoveryRoutes: [], | |
| 89 | + discoveryRoutes, | |
| 28 | 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 | 92 | authenticate: (request) => |
| 31 | 93 | verify(request).pipe( |
| 32 | 94 | Effect.map((principal) => (principal ? authenticated(principal) : unauthorized())), |
| @@ -12,7 +12,7 @@ · 11 unmodified lines | ||
| 12 | 12 | "assets": { |
| 13 | 13 | "directory": "./dist", |
| 14 | 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 | 17 | // D1 is the app's SQLite store (the DbProvider seam). `wrangler deploy` |
| 18 | 18 | // auto-provisions it on first deploy; replace database_id after that, or run |
| @@ -8,6 +8,7 @@ import { keychainPlugin } from "@executor-js/plugin-keychain"; · 7 unmodified lines | ||
| 8 | 8 | import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; |
| 9 | 9 | import { onepasswordHttpPlugin } from "@executor-js/plugin-onepassword/api"; |
| 10 | 10 | import { desktopSettingsPlugin } from "@executor-js/plugin-desktop-settings/server"; |
| 11 | +import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; | |
| 11 | 12 | |
| 12 | 13 | // --------------------------------------------------------------------------- |
| 13 | 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 | 19 | // First-party and third-party plugins use the same import-and-call flow. |
| 19 | 20 | // --------------------------------------------------------------------------- |
| 20 | 21 | |
| 22 | +interface LocalPluginDeps { | |
| 23 | + readonly activeToolkitSlug?: string; | |
| 24 | +} | |
| 25 | + | |
| 21 | 26 | export default defineExecutorConfig({ |
| 22 | − plugins: () => | |
| 27 | + plugins: ({ activeToolkitSlug }: LocalPluginDeps = {}) => | |
| 23 | 28 | [ |
| 24 | 29 | openApiHttpPlugin(), |
| 25 | 30 | googleHttpPlugin(), |
| 26 | 31 | microsoftHttpPlugin(), |
| 27 | 32 | mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }), |
| 28 | 33 | graphqlHttpPlugin(), |
| 34 | + toolkitsPlugin({ activeToolkitSlug }), | |
| 29 | 35 | keychainPlugin(), |
| 30 | 36 | fileSecretsPlugin(), |
| 31 | 37 | onepasswordHttpPlugin(), |
| 32 | 38 | desktopSettingsPlugin({ |
| 33 | 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 | 43 | ] as const, |
| 37 | 44 | }); |
| @@ -39,6 +39,7 @@ · 38 unmodified lines | ||
| 39 | 39 | "@executor-js/plugin-microsoft": "workspace:*", |
| 40 | 40 | "@executor-js/plugin-onepassword": "workspace:*", |
| 41 | 41 | "@executor-js/plugin-openapi": "workspace:*", |
| 42 | + "@executor-js/plugin-toolkits": "workspace:*", | |
| 42 | 43 | "@executor-js/react": "workspace:*", |
| 43 | 44 | "@executor-js/runtime-quickjs": "workspace:*", |
| 44 | 45 | "@executor-js/sdk": "workspace:*", |
| @@ -49,7 +49,8 @@ const makeTenantId = (cwd: string): string => { · 48 unmodified lines | ||
| 49 | 49 | return `${folder}-${hash}`; |
| 50 | 50 | }; |
| 51 | 51 | |
| 52 | −const resolvePluginConfigPath = (scopeDir: string): string => join(scopeDir, "executor.jsonc"); | |
| 52 | +const resolvePluginConfigPath = (scopeDir: string): string => | |
| 53 | + join(scopeDir, "executor.jsonc"); | |
| 53 | 54 | |
| 54 | 55 | // Plugins reach the host through two doors that compose: |
| 55 | 56 | // - `executor.config.ts`'s static tuple |
| @@ -57,51 +58,66 @@ const resolvePluginConfigPath = (scopeDir: string): string => join(scopeDir, "ex · 1 unmodified line | ||
| 57 | 58 | // Static config wins on conflict, matching the Vite plugin. |
| 58 | 59 | type LocalPlugins = readonly AnyPlugin[]; |
| 59 | 60 | |
| 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 | +} | |
| 66 | 64 | |
| 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 | + }); | |
| 81 | 92 | |
| 82 | − return { | |
| 83 | − cwd, | |
| 84 | − plugins: [...staticPlugins, ...dedupedDynamic] as LocalPlugins, | |
| 85 | − }; | |
| 86 | −}); | |
| 93 | + return { | |
| 94 | + cwd, | |
| 95 | + plugins: [...staticPlugins, ...dedupedDynamic] as LocalPlugins, | |
| 96 | + }; | |
| 97 | + }); | |
| 87 | 98 | |
| 88 | 99 | interface LocalExecutorBundle { |
| 89 | 100 | readonly executor: Executor<LocalPlugins>; |
| 90 | 101 | readonly plugins: LocalPlugins; |
| 91 | 102 | } |
| 92 | 103 | |
| 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") {} | |
| 96 | 108 | |
| 97 | 109 | export type LocalExecutor = LocalExecutorBundle["executor"]; |
| 98 | 110 | |
| 99 | −class LocalExecutorCreateError extends Data.TaggedError("LocalExecutorCreateError")<{ | |
| 111 | +class LocalExecutorCreateError extends Data.TaggedError( | |
| 112 | + "LocalExecutorCreateError", | |
| 113 | +)<{ | |
| 100 | 114 | readonly message: string; |
| 101 | 115 | readonly cause: unknown; |
| 102 | 116 | }> {} |
| 103 | 117 | |
| 104 | −class LocalExecutorDisposeError extends Data.TaggedError("LocalExecutorDisposeError")<{ | |
| 118 | +class LocalExecutorDisposeError extends Data.TaggedError( | |
| 119 | + "LocalExecutorDisposeError", | |
| 120 | +)<{ | |
| 105 | 121 | readonly operation: "createHandle" | "disposeExecutor" | "disposeRuntime"; |
| 106 | 122 | readonly cause: unknown; |
| 107 | 123 | }> {} |
| @@ -126,20 +142,23 @@ const handleOrNull = (promise: ReturnType<typeof createExecutorHandle>) => · 18 unmodified lines | ||
| 126 | 142 | Effect.runPromise( |
| 127 | 143 | Effect.tryPromise({ |
| 128 | 144 | try: () => promise, |
| 129 | − catch: (cause) => new LocalExecutorDisposeError({ operation: "createHandle", cause }), | |
| 145 | + catch: (cause) => | |
| 146 | + new LocalExecutorDisposeError({ operation: "createHandle", cause }), | |
| 130 | 147 | }).pipe( |
| 131 | 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 | |
| 137 | −const createLocalExecutorLayer = () => { | |
| 156 | +const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { | |
| 138 | 157 | const storage = resolveStorage(); |
| 139 | 158 | |
| 140 | 159 | return Layer.effect(LocalExecutorTag)( |
| 141 | 160 | Effect.gen(function* () { |
| 142 | − const { cwd, plugins } = yield* loadLocalPlugins; | |
| 161 | + const { cwd, plugins } = yield* loadLocalPlugins(options); | |
| 143 | 162 | const tenantId = makeTenantId(cwd); |
| 144 | 163 | const tables = collectTables(); |
| 145 | 164 | |
| @@ -180,7 +199,11 @@ const createLocalExecutorLayer = () => { · 34 unmodified lines | ||
| 180 | 199 | // without touching the data. |
| 181 | 200 | yield* runSqliteDataMigrations(sqlite.client, localDataMigrations).pipe( |
| 182 | 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 | 212 | // resolution so a custom $PORT flows through. EXECUTOR_WEB_BASE_URL |
| 190 | 213 | // overrides entirely for deployments where the UI is on a different host. |
| 191 | 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 | 218 | const executor = yield* createExecutor({ |
| 195 | 219 | tenant: Tenant.make(tenantId), |
| @@ -223,8 +247,10 @@ const createLocalExecutorLayer = () => { · 27 unmodified lines | ||
| 223 | 247 | ); |
| 224 | 248 | }; |
| 225 | 249 | |
| 226 | −export const createExecutorHandle = async () => { | |
| 227 | − const layer = createLocalExecutorLayer(); | |
| 250 | +export const createExecutorHandle = async ( | |
| 251 | + options: LocalExecutorOptions = {}, | |
| 252 | +) => { | |
| 253 | + const layer = createLocalExecutorLayer(options); | |
| 228 | 254 | const runtime = ManagedRuntime.make(layer); |
| 229 | 255 | const bundle = await runtime.runPromise(LocalExecutorTag.asEffect()); |
| 230 | 256 | |
| @@ -249,14 +275,17 @@ const loadSharedHandle = () => { · 18 unmodified lines | ||
| 249 | 275 | return sharedHandlePromise; |
| 250 | 276 | }; |
| 251 | 277 | |
| 252 | −export const getExecutor = () => loadSharedHandle().then((handle) => handle.executor); | |
| 278 | +export const getExecutor = () => | |
| 279 | + loadSharedHandle().then((handle) => handle.executor); | |
| 253 | 280 | export const getExecutorBundle = () => loadSharedHandle(); |
| 254 | 281 | |
| 255 | 282 | export const disposeExecutor = async (): Promise<void> => { |
| 256 | 283 | const currentHandlePromise = sharedHandlePromise; |
| 257 | 284 | sharedHandlePromise = null; |
| 258 | 285 | |
| 259 | − const handle = currentHandlePromise ? await handleOrNull(currentHandlePromise) : null; | |
| 286 | + const handle = currentHandlePromise | |
| 287 | + ? await handleOrNull(currentHandlePromise) | |
| 288 | + : null; | |
| 260 | 289 | if (handle) { |
| 261 | 290 | await ignorePromiseFailure("disposeExecutor", () => handle.dispose()); |
| 262 | 291 | } |
| @@ -3,7 +3,7 @@ import { Context, Effect, Layer, ManagedRuntime } from "effect"; · 2 unmodified lines | ||
| 3 | 3 | import { createExecutionEngine } from "@executor-js/execution"; |
| 4 | 4 | import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; |
| 5 | 5 | import { makeLocalApiHandler } from "./app"; |
| 6 | −import { getExecutorBundle } from "./executor"; | |
| 6 | +import { createExecutorHandle, getExecutorBundle } from "./executor"; | |
| 7 | 7 | import { 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 | |
| 56 | −export const createServerHandlers = async (token: string): Promise<ServerHandlers> => { | |
| 56 | +export const createServerHandlers = async ( | |
| 57 | + token: string, | |
| 58 | +): Promise<ServerHandlers> => { | |
| 57 | 59 | // The typed `/api` web-handler comes from `ExecutorApp.make` (./app.ts). The |
| 58 | 60 | // boot bearer token is the authoritative `/api` gate (see `identity.ts`). |
| 59 | 61 | const apiHandler: ServerHandlers["api"] = await makeLocalApiHandler(token); |
| @@ -67,20 +69,39 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 7 unmodified lines | ||
| 67 | 69 | executor, |
| 68 | 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 | 90 | return { api: apiHandler, mcp }; |
| 73 | 91 | }; |
| 74 | 92 | |
| 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") {} | |
| 78 | 97 | |
| 79 | 98 | // The handlers are built once per process and memoized. The boot token is |
| 80 | 99 | // captured on the first call (serve.ts / the vite dev middleware both pass the |
| 81 | 100 | // 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; | |
| 84 | 105 | |
| 85 | 106 | const getServerHandlersRuntime = ( |
| 86 | 107 | token: string, |
| @@ -1,9 +1,14 @@ | ||
| 1 | −import { Effect } from "effect"; | |
| 1 | +import { Effect, type Cause } from "effect"; | |
| 2 | 2 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 3 | 3 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; |
| 4 | 4 | import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; |
| 5 | 5 | |
| 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"; | |
| 7 | 12 | import { |
| 8 | 13 | createExecutorMcpServer, |
| 9 | 14 | type ExecutorMcpServerConfig, |
| @@ -15,10 +20,16 @@ import { · 5 unmodified lines | ||
| 15 | 20 | readElicitationMode, |
| 16 | 21 | } from "@executor-js/host-mcp/browser-approval"; |
| 17 | 22 | 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"; | |
| 19 | 28 | |
| 20 | 29 | import { startIntegrationsRefresh } from "./integrations"; |
| 21 | 30 | |
| 31 | +type AnyExecutionEngine = ExecutionEngine<Cause.YieldableError>; | |
| 32 | + | |
| 22 | 33 | // --------------------------------------------------------------------------- |
| 23 | 34 | // Streamable HTTP handler |
| 24 | 35 | // --------------------------------------------------------------------------- |
| @@ -32,6 +43,18 @@ export type McpRequestHandler = { · 7 unmodified lines | ||
| 32 | 43 | readonly close: () => Promise<void>; |
| 33 | 44 | }; |
| 34 | 45 | |
| 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 | + | |
| 35 | 58 | // Local serves these error bodies in-process; like the self-host store they are |
| 36 | 59 | // INNER responses (no CORS) — byte-identical to the prior hand-rolled copy |
| 37 | 60 | // (`content-type: application/json` only) via the canonical renderer. |
| @@ -44,7 +67,9 @@ const formatBoundaryError = (error: unknown): unknown => { · 6 unmodified lines | ||
| 44 | 67 | return error; |
| 45 | 68 | }; |
| 46 | 69 | |
| 47 | −const ignoreClose = (close: (() => Promise<void>) | undefined): Promise<void> => | |
| 70 | +const ignoreClose = ( | |
| 71 | + close: (() => Promise<void>) | undefined, | |
| 72 | +): Promise<void> => | |
| 48 | 73 | close |
| 49 | 74 | ? Effect.runPromise( |
| 50 | 75 | Effect.ignore( |
| @@ -56,8 +81,10 @@ const ignoreClose = (close: (() => Promise<void>) | undefined): Promise<void> => · 5 unmodified lines | ||
| 56 | 81 | ) |
| 57 | 82 | : Promise.resolve(); |
| 58 | 83 | |
| 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$/; | |
| 61 | 88 | |
| 62 | 89 | const json = (value: unknown, status = 200): Response => |
| 63 | 90 | new Response(JSON.stringify(value), { |
| @@ -70,57 +97,122 @@ const readResumeResponse = (request: Request): Promise<ResumeResponse | null> => · 6 unmodified lines | ||
| 70 | 97 | Effect.tryPromise({ |
| 71 | 98 | try: () => request.json(), |
| 72 | 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 | |
| 76 | −const resumeApprovalResult = (executionId: string, response: ResumeResponse) => ({ | |
| 105 | +const resumeApprovalResult = ( | |
| 106 | + executionId: string, | |
| 107 | + response: ResumeResponse, | |
| 108 | +) => ({ | |
| 77 | 109 | status: "completed", |
| 78 | 110 | ...formatResumeAcknowledgement(executionId, response), |
| 79 | 111 | isError: false, |
| 80 | 112 | }); |
| 81 | 113 | |
| 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 | + >(); | |
| 84 | 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 | 145 | 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); | |
| 90 | 147 | |
| 91 | 148 | const pausedDetail = ( |
| 149 | + sessionId: string, | |
| 92 | 150 | executionId: string, |
| 93 | 151 | ): Promise<ReturnType<typeof formatPausedExecution> | null> => |
| 94 | − engine | |
| 152 | + (sessionEngines.get(sessionId) ?? defaultEngine) | |
| 95 | 153 | ? 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 | + ), | |
| 100 | 162 | ) |
| 101 | 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 | 177 | const t = transports.get(id); |
| 105 | 178 | const s = servers.get(id); |
| 179 | + const close = sessionClosers.get(id); | |
| 106 | 180 | transports.delete(id); |
| 107 | 181 | servers.delete(id); |
| 182 | + resources.delete(id); | |
| 183 | + sessionEngines.delete(id); | |
| 184 | + sessionClosers.delete(id); | |
| 108 | 185 | if (opts.transport) await ignoreClose(t ? () => t.close() : undefined); |
| 109 | 186 | if (opts.server) await ignoreClose(s ? () => s.close() : undefined); |
| 187 | + await ignoreClose(close); | |
| 110 | 188 | }; |
| 111 | 189 | |
| 112 | 190 | return { |
| 113 | 191 | handleRequest: async (request) => { |
| 192 | + const resource = resourceFromRequest(request); | |
| 193 | + if (!resource) return jsonError(404, -32001, "MCP resource not found"); | |
| 114 | 194 | const sessionId = request.headers.get("mcp-session-id"); |
| 115 | 195 | |
| 116 | 196 | if (sessionId) { |
| 117 | 197 | const transport = transports.get(sessionId); |
| 118 | 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 | 210 | return transport.handleRequest(request); |
| 120 | 211 | } |
| 121 | 212 | |
| 122 | 213 | let created: McpServer | undefined; |
| 123 | 214 | let createdSessionId: string | null = null; |
| 215 | + let resourceConfig: LocalMcpServerConfig | null = null; | |
| 124 | 216 | const transport = new WebStandardStreamableHTTPServerTransport({ |
| 125 | 217 | sessionIdGenerator: () => crypto.randomUUID(), |
| 126 | 218 | enableJsonResponse: true, |
| @@ -128,6 +220,13 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 1 unmodified line | ||
| 128 | 220 | createdSessionId = sid; |
| 129 | 221 | transports.set(sid, transport); |
| 130 | 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 | 231 | onsessionclosed: (sid) => void dispose(sid, { server: true }), |
| 133 | 232 | }); |
| @@ -140,16 +239,21 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 6 unmodified lines | ||
| 140 | 239 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: MCP SDK handler must return JSON-RPC errors from thrown Promise APIs |
| 141 | 240 | try { |
| 142 | 241 | const elicitationMode = readElicitationMode(request); |
| 242 | + resourceConfig = await configForResource(resource); | |
| 143 | 243 | created = await Effect.runPromise( |
| 144 | 244 | createExecutorMcpServer({ |
| 145 | − ...config, | |
| 245 | + ...resourceConfig.config, | |
| 146 | 246 | browserApprovalStore: approvals.store, |
| 147 | 247 | elicitationMode: |
| 148 | 248 | elicitationMode === "browser" |
| 149 | 249 | ? { |
| 150 | 250 | mode: "browser" as const, |
| 151 | 251 | approvalUrl: (executionId) => |
| 152 | − approvalUrlForRequest(request, executionId, createdSessionId), | |
| 252 | + approvalUrlForRequest( | |
| 253 | + request, | |
| 254 | + executionId, | |
| 255 | + createdSessionId, | |
| 256 | + ), | |
| 153 | 257 | } |
| 154 | 258 | : { mode: elicitationMode }, |
| 155 | 259 | }), |
| @@ -161,6 +265,7 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 5 unmodified lines | ||
| 161 | 265 | await ignoreClose(() => transport.close()); |
| 162 | 266 | const server = created; |
| 163 | 267 | await ignoreClose(server ? () => server.close() : undefined); |
| 268 | + await ignoreClose(resourceConfig?.close); | |
| 164 | 269 | } |
| 165 | 270 | return response; |
| 166 | 271 | } catch (error) { |
| @@ -169,6 +274,7 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 2 unmodified lines | ||
| 169 | 274 | await ignoreClose(() => transport.close()); |
| 170 | 275 | const server = created; |
| 171 | 276 | await ignoreClose(server ? () => server.close() : undefined); |
| 277 | + await ignoreClose(resourceConfig?.close); | |
| 172 | 278 | } |
| 173 | 279 | return jsonError(500, -32603, "Internal server error"); |
| 174 | 280 | } |
| @@ -177,9 +283,13 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 2 unmodified lines | ||
| 177 | 283 | handlePausedRequest: async (request) => { |
| 178 | 284 | const match = pausedRequestPattern.exec(new URL(request.url).pathname); |
| 179 | 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 | 293 | if (!paused) return json({ error: "Paused execution not found" }, 404); |
| 184 | 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 | 297 | handleApprovalRequest: async (request) => { |
| 188 | 298 | const match = approvalRequestPattern.exec(new URL(request.url).pathname); |
| 189 | 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 | 304 | const executionId = decodeURIComponent(match[2]); |
| 193 | 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 | 310 | const response = await readResumeResponse(request); |
| 197 | 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 | 317 | close: async () => { |
| 204 | 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 | 327 | // Stdio transport |
| 212 | 328 | // --------------------------------------------------------------------------- |
| 213 | 329 | |
| 214 | −export const runMcpStdioServer = async (config: ExecutorMcpServerConfig): Promise<void> => { | |
| 330 | +export const runMcpStdioServer = async ( | |
| 331 | + config: ExecutorMcpServerConfig, | |
| 332 | +): Promise<void> => { | |
| 215 | 333 | startIntegrationsRefresh(); |
| 216 | 334 | |
| 217 | 335 | const server = await Effect.runPromise(createExecutorMcpServer(config)); |
| @@ -35,7 +35,10 @@ const htmlResponse = (file: Bun.BunFile): Response => · 34 unmodified lines | ||
| 35 | 35 | headers: { "content-type": "text/html", "cache-control": "no-store" }, |
| 36 | 36 | }); |
| 37 | 37 | |
| 38 | −function collectStaticRoutes(dir: string, prefix = ""): Record<string, StaticHandler> { | |
| 38 | +function collectStaticRoutes( | |
| 39 | + dir: string, | |
| 40 | + prefix = "", | |
| 41 | +): Record<string, StaticHandler> { | |
| 39 | 42 | const routes: Record<string, StaticHandler> = {}; |
| 40 | 43 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: filesystem route discovery is best-effort for optional built assets |
| 41 | 44 | try { |
| @@ -50,7 +53,9 @@ function collectStaticRoutes(dir: string, prefix = ""): Record<string, StaticHan · 8 unmodified lines | ||
| 50 | 53 | routePath === "/index.html" |
| 51 | 54 | ? htmlResponse(file) |
| 52 | 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 | 67 | * Convert an embedded web UI map (path → bunfs path) into Bun.serve routes. |
| 63 | 68 | * The embedded map is generated by the CLI build step using `with { type: "file" }`. |
| 64 | 69 | */ |
| 65 | −function embeddedToStaticRoutes(embedded: Record<string, string>): Record<string, StaticHandler> { | |
| 70 | +function embeddedToStaticRoutes( | |
| 71 | + embedded: Record<string, string>, | |
| 72 | +): Record<string, StaticHandler> { | |
| 66 | 73 | const routes: Record<string, StaticHandler> = {}; |
| 67 | 74 | for (const [key, bunfsPath] of Object.entries(embedded)) { |
| 68 | 75 | const file = Bun.file(bunfsPath); |
| @@ -70,7 +77,9 @@ function embeddedToStaticRoutes(embedded: Record<string, string>): Record<string · 1 unmodified line | ||
| 70 | 77 | key === "index.html" |
| 71 | 78 | ? htmlResponse(file) |
| 72 | 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 | 85 | return routes; |
| @@ -90,7 +99,11 @@ interface ViteChild { · 13 unmodified lines | ||
| 90 | 99 | } |
| 91 | 100 | |
| 92 | 101 | 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 | + }); | |
| 94 | 107 | const port = probe.port ?? 0; |
| 95 | 108 | probe.stop(true); |
| 96 | 109 | return port; |
| @@ -152,7 +165,9 @@ async function startViteChild(): Promise<ViteChild> { · 55 unmodified lines | ||
| 152 | 165 | } |
| 153 | 166 | if (child.exitCode !== null) { |
| 154 | 167 | // 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 | 172 | await Bun.sleep(150); |
| 158 | 173 | } |
| @@ -241,7 +256,8 @@ const withCorsHeaders = ( · 82 unmodified lines | ||
| 241 | 256 | if (!origin || !isAllowedOrigin(origin, allowedHosts)) return response; |
| 242 | 257 | const headers = new Headers(response.headers); |
| 243 | 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 | 261 | headers.set( |
| 246 | 262 | "access-control-allow-headers", |
| 247 | 263 | req.headers.get("access-control-request-headers") ?? |
| @@ -255,17 +271,23 @@ const withCorsHeaders = ( · 7 unmodified lines | ||
| 255 | 271 | }); |
| 256 | 272 | }; |
| 257 | 273 | |
| 258 | −const corsPreflightResponse = (req: Request, allowedHosts: ReadonlySet<string>): Response => | |
| 274 | +const corsPreflightResponse = ( | |
| 275 | + req: Request, | |
| 276 | + allowedHosts: ReadonlySet<string>, | |
| 277 | +): Response => | |
| 259 | 278 | withCorsHeaders(req, new Response(null, { status: 204 }), allowedHosts); |
| 260 | 279 | |
| 261 | −export async function startServer(opts: StartServerOptions = {}): Promise<ServerInstance> { | |
| 280 | +export async function startServer( | |
| 281 | + opts: StartServerOptions = {}, | |
| 282 | +): Promise<ServerInstance> { | |
| 262 | 283 | const port = opts.port ?? parseInt(process.env.PORT ?? "4788", 10); |
| 263 | 284 | const hostname = opts.hostname ?? "127.0.0.1"; |
| 264 | 285 | // ONE credential, always present: an explicit override or the stable token |
| 265 | 286 | // from auth.json (minted on first run). Auth is unconditionally on — loopback |
| 266 | 287 | // is no longer a free pass, since Executor runs arbitrary code that any local |
| 267 | 288 | // process could otherwise drive. |
| 268 | − const authToken = normalizeCredential(opts.authToken) ?? loadOrMintLocalAuthToken(); | |
| 289 | + const authToken = | |
| 290 | + normalizeCredential(opts.authToken) ?? loadOrMintLocalAuthToken(); | |
| 269 | 291 | const isAuthorized = makeIsAuthorized(authToken); |
| 270 | 292 | // CORS-only origin allowlist (no Host gate — the bearer is the boundary). |
| 271 | 293 | const corsAllowedHosts = new Set<string>([ |
| @@ -295,20 +317,26 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server · 23 unmodified lines | ||
| 295 | 317 | |
| 296 | 318 | const devMode = process.env.EXECUTOR_DEV === "1" && !opts.embeddedWebUI; |
| 297 | 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 | 323 | viteChild = await startViteChild(); |
| 300 | 324 | // Diagnostic only — this is the internal vite port the daemon proxies to. |
| 301 | 325 | // It must NOT read as a destination: the URL to open is the `Open:` line the |
| 302 | 326 | // CLI prints (the daemon port, with ?_token). Hitting the vite port directly |
| 303 | 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 | 331 | serveIndex = () => |
| 306 | 332 | // Unused when viteChild is non-null; defined so the type checker |
| 307 | 333 | // can keep `serveIndex` non-nullable. |
| 308 | 334 | new Response("vite not ready", { status: 503 }); |
| 309 | 335 | } else if (opts.embeddedWebUI) { |
| 310 | 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 | 340 | serveIndex = () => htmlResponse(indexFile); |
| 313 | 341 | } else { |
| 314 | 342 | staticRoutes = collectStaticRoutes(clientDir); |
| @@ -336,7 +364,9 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server · 21 unmodified lines | ||
| 336 | 364 | // Unauthenticated liveness probe — carries no data, used by the CLI |
| 337 | 365 | // reachability check (which therefore never forwards a credential). |
| 338 | 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 | 372 | // 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 | 374 | // gate — see isUnauthenticatedOAuthCallbackPath. Everything else under |
| 345 | 375 | // /api and /mcp requires the bearer. |
| 346 | 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 | 381 | if (isGatedSurface && !skipAuth && !isAuthorized(req)) { |
| 350 | 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 | 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 | |
| 426 | 458 | if (import.meta.main) { |
| 427 | 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 | } |
| @@ -6,12 +6,19 @@ import { defineConfig, type Plugin } from "vite"; · 5 unmodified lines | ||
| 6 | 6 | import appPlugin from "@executor-js/app/vite"; |
| 7 | 7 | import { loadOrMintLocalAuthToken } from "./src/auth"; |
| 8 | 8 | 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"; | |
| 10 | 13 | |
| 11 | 14 | // oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads package metadata from package.json |
| 12 | 15 | const rootPackage = JSON.parse( |
| 13 | 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 | 23 | // oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads package metadata from package.json |
| 17 | 24 | const cliPackage = JSON.parse( |
| @@ -19,7 +26,9 @@ const cliPackage = JSON.parse( · 1 unmodified line | ||
| 19 | 26 | ) as { version?: string }; |
| 20 | 27 | |
| 21 | 28 | const 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 | |
| 24 | 33 | const EXECUTOR_VERSION = cliPackage.version ?? rootPackage.version; |
| 25 | 34 | const EXECUTOR_GITHUB_URL = ( |
| @@ -58,7 +67,9 @@ function executorApiPlugin(): Plugin { · 32 unmodified lines | ||
| 58 | 67 | server.httpServer?.once("listening", () => { |
| 59 | 68 | const address = server.httpServer?.address(); |
| 60 | 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 | 73 | server.config.logger.info( |
| 63 | 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 | 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 | 84 | handlers = null; |
| 71 | 85 | } |
| 72 | 86 | }); |
| 73 | 87 | server.middlewares.use(async (req, res, next) => { |
| 74 | 88 | 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/"); | |
| 77 | 92 | |
| 78 | 93 | if (!isApi && !isMcp) return next(); |
| 79 | 94 | |
| @@ -84,9 +99,9 @@ function executorApiPlugin(): Plugin { · 4 unmodified lines | ||
| 84 | 99 | // callback. The SPA carries the token from its `?_token`/localStorage |
| 85 | 100 | // bootstrap, so the UI is unaffected; external MCP clients use the |
| 86 | 101 | // daemon port. |
| 87 | − const pathOnly = rawUrl.split("?")[0] ?? "/"; | |
| 88 | 102 | const authExempt = |
| 89 | − pathOnly === "/api/health" || isUnauthenticatedOAuthCallbackPath(pathOnly); | |
| 103 | + pathOnly === "/api/health" || | |
| 104 | + isUnauthenticatedOAuthCallbackPath(pathOnly); | |
| 90 | 105 | if (!authExempt) { |
| 91 | 106 | const presented = req.headers.authorization; |
| 92 | 107 | const authValue = Array.isArray(presented) ? presented[0] : presented; |
| @@ -94,7 +109,9 @@ function executorApiPlugin(): Plugin { · 1 unmodified line | ||
| 94 | 109 | "http://localhost/", |
| 95 | 110 | authValue ? { headers: { authorization: authValue } } : undefined, |
| 96 | 111 | ); |
| 97 | − if (!makeIsAuthorized(devToken ?? loadOrMintLocalAuthToken())(probe)) { | |
| 112 | + if ( | |
| 113 | + !makeIsAuthorized(devToken ?? loadOrMintLocalAuthToken())(probe) | |
| 114 | + ) { | |
| 98 | 115 | res.statusCode = 401; |
| 99 | 116 | res.setHeader("www-authenticate", 'Bearer realm="executor"'); |
| 100 | 117 | res.end("Unauthorized"); |
| @@ -106,13 +123,16 @@ function executorApiPlugin(): Plugin { · 5 unmodified lines | ||
| 106 | 123 | try { |
| 107 | 124 | if (!handlers) { |
| 108 | 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 | 131 | const origin = `http://${req.headers.host ?? "localhost"}`; |
| 113 | 132 | const headers = new Headers(); |
| 114 | 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 | 138 | const hasBody = req.method !== "GET" && req.method !== "HEAD"; |
| @@ -128,7 +148,9 @@ function executorApiPlugin(): Plugin { · 9 unmodified lines | ||
| 128 | 148 | if (isMcp) { |
| 129 | 149 | response = await handlers.mcp.handleRequest(webRequest(rawUrl)); |
| 130 | 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 | 154 | } else if (pathOnly.startsWith("/api/mcp-sessions/")) { |
| 133 | 155 | const handler = |
| 134 | 156 | req.method === "GET" |
| @@ -136,14 +158,21 @@ function executorApiPlugin(): Plugin { · 1 unmodified line | ||
| 136 | 158 | : handlers.mcp.handleApprovalRequest; |
| 137 | 159 | response = await handler(webRequest(rawUrl)); |
| 138 | 160 | } else { |
| 139 | − const awaitMatch = /^\/api\/oauth\/await\/([^/?#]+)$/.exec(pathOnly); | |
| 161 | + const awaitMatch = /^\/api\/oauth\/await\/([^/?#]+)$/.exec( | |
| 162 | + pathOnly, | |
| 163 | + ); | |
| 140 | 164 | 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 | + ); | |
| 144 | 171 | } else { |
| 145 | 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 | 211 | "import.meta.env.VITE_APP_VERSION": JSON.stringify(EXECUTOR_VERSION), |
| 183 | 212 | "import.meta.env.VITE_GITHUB_URL": JSON.stringify(EXECUTOR_GITHUB_URL), |
| 184 | 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 | 218 | resolve: { |
| 188 | 219 | tsconfigPaths: true, |
| @@ -283,6 +283,7 @@ · 282 unmodified lines | ||
| 283 | 283 | "@executor-js/plugin-microsoft": "workspace:*", |
| 284 | 284 | "@executor-js/plugin-onepassword": "workspace:*", |
| 285 | 285 | "@executor-js/plugin-openapi": "workspace:*", |
| 286 | + "@executor-js/plugin-toolkits": "workspace:*", | |
| 286 | 287 | "@executor-js/react": "workspace:*", |
| 287 | 288 | "@executor-js/runtime-quickjs": "workspace:*", |
| 288 | 289 | "@executor-js/sdk": "workspace:*", |
| @@ -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 | +); | |
| @@ -28,9 +28,7 @@ const pingSpec = (baseUrl: string): string => · 27 unmodified lines | ||
| 28 | 28 | operationId: "getPing", |
| 29 | 29 | summary: "Return a ping payload", |
| 30 | 30 | 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" } }], | |
| 34 | 32 | responses: { |
| 35 | 33 | "200": { |
| 36 | 34 | description: "A ping payload", |
| @@ -172,6 +170,8 @@ scenario( · 135 unmodified lines | ||
| 172 | 170 | "metadata still advertises authorization servers", |
| 173 | 171 | ).toBe(true); |
| 174 | 172 | |
| 173 | + if (target.name === "cloudflare") return; | |
| 174 | + | |
| 175 | 175 | const challenged = yield* Effect.promise(() => |
| 176 | 176 | fetch(mcpUrl, { |
| 177 | 177 | method: "POST", |
| @@ -345,9 +345,10 @@ scenario( · 167 unmodified lines | ||
| 345 | 345 | visibleConnectionPathsCode(integration), |
| 346 | 346 | ); |
| 347 | 347 | 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`); | |
| 351 | 352 | expect( |
| 352 | 353 | personalVisiblePaths, |
| 353 | 354 | "personal toolkit excludes unselected workspace tools from the same integration", |
| @@ -374,9 +375,7 @@ scenario( · 20 unmodified lines | ||
| 374 | 375 | (toolkit) => client.toolkits.remove({ params: { toolkitId: toolkit.id } }), |
| 375 | 376 | { discard: true }, |
| 376 | 377 | ); |
| 377 | − yield* client.openapi.removeSpec({ params: { slug: integration } }).pipe( | |
| 378 | − Effect.ignore, | |
| 379 | − ); | |
| 378 | + yield* client.openapi.removeSpec({ params: { slug: integration } }).pipe(Effect.ignore); | |
| 380 | 379 | }).pipe(Effect.ignore), |
| 381 | 380 | ), |
| 382 | 381 | ); |
| @@ -475,9 +474,7 @@ scenario( · 92 unmodified lines | ||
| 475 | 474 | (toolkit) => client.toolkits.remove({ params: { toolkitId: toolkit.id } }), |
| 476 | 475 | { discard: true }, |
| 477 | 476 | ); |
| 478 | − yield* client.openapi.removeSpec({ params: { slug: integration } }).pipe( | |
| 479 | − Effect.ignore, | |
| 480 | − ); | |
| 477 | + yield* client.openapi.removeSpec({ params: { slug: integration } }).pipe(Effect.ignore); | |
| 481 | 478 | }).pipe(Effect.ignore), |
| 482 | 479 | ), |
| 483 | 480 | ); |
| @@ -522,10 +519,9 @@ scenario( · 38 unmodified lines | ||
| 522 | 519 | expect(approved.text, "approved policy create does not pause for approval").not.toContain( |
| 523 | 520 | "Execution paused", |
| 524 | 521 | ); |
| 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 | + ); | |
| 529 | 525 | expect(approved.ok, `approved policy create succeeded: ${approved.text}`).toBe(true); |
| 530 | 526 | const approvedPayload = JSON.parse(approved.text) as Record<string, unknown>; |
| 531 | 527 | expect(approvedPayload.ok, `approved policy create result: ${approved.text}`).toBe(true); |
| @@ -574,9 +570,7 @@ scenario( · 42 unmodified lines | ||
| 574 | 570 | ); |
| 575 | 571 | const policies = yield* client.policies.list(); |
| 576 | 572 | yield* Effect.forEach( |
| 577 | − policies.filter((policy) => | |
| 578 | − [approvedPattern, blockedPattern].includes(policy.pattern), | |
| 579 | − ), | |
| 573 | + policies.filter((policy) => [approvedPattern, blockedPattern].includes(policy.pattern)), | |
| 580 | 574 | (policy) => |
| 581 | 575 | client.policies.remove({ |
| 582 | 576 | params: { policyId: policy.id }, |
| @@ -47,6 +47,8 @@ export const bootCloudflare = async (options: CloudflareBootOptions): Promise<Bo · 46 unmodified lines | ||
| 47 | 47 | "ENABLE_DEV_AUTH:true", |
| 48 | 48 | "--var", |
| 49 | 49 | "EXECUTOR_SECRET_KEY:e2e-secret-key-0123456789abcdef0123456789abcdef", |
| 50 | + "--var", | |
| 51 | + "ALLOW_LOCAL_NETWORK:true", | |
| 50 | 52 | ], |
| 51 | 53 | cwd: cloudflareDir, |
| 52 | 54 | env: { WRANGLER_SEND_METRICS: "false", CI: "true" }, |
| @@ -11,6 +11,8 @@ import { join } from "node:path"; · 10 unmodified lines | ||
| 11 | 11 | import { Effect } from "effect"; |
| 12 | 12 | |
| 13 | 13 | 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"; | |
| 14 | 16 | |
| 15 | 17 | import { appendTraces } from "../trace-harvest"; |
| 16 | 18 | import type { Identity, Target } from "../target"; |
| @@ -298,6 +300,65 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( · 281 unmodified lines | ||
| 298 | 300 | const sessionUrl = options?.elicitationMode |
| 299 | 301 | ? `${mcpUrl}?elicitation_mode=${options.elicitationMode}` |
| 300 | 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 | 362 | let runtimePromise: Promise<Runtime> | undefined; |
| 302 | 363 | let connected = false; |
| 303 | 364 | |
| @@ -35,6 +35,33 @@ const insertIntegration = ( · 34 unmodified lines | ||
| 35 | 35 | ], |
| 36 | 36 | }); |
| 37 | 37 | |
| 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 | + | |
| 38 | 65 | const insertBlob = ( |
| 39 | 66 | client: SqliteDataMigrationClient, |
| 40 | 67 | row: { |
| @@ -84,30 +111,84 @@ const insertOperationStorage = ( · 43 unmodified lines | ||
| 84 | 111 | |
| 85 | 112 | const insertTool = ( |
| 86 | 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 | 120 | client.execute({ |
| 90 | 121 | sql: `INSERT INTO tool |
| 91 | 122 | (tenant, owner, subject, integration, connection, plugin_id, name, description, input_schema, output_schema, annotations, created_at, updated_at, row_id) |
| 92 | 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 | |
| 96 | 134 | const insertDefinition = ( |
| 97 | 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 | 142 | client.execute({ |
| 101 | 143 | sql: `INSERT INTO definition |
| 102 | 144 | (tenant, owner, subject, integration, connection, plugin_id, name, schema, created_at, row_id) |
| 103 | 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 | |
| 107 | 155 | 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 | + | |
| 108 | 187 | it.effect("moves OpenAPI-owned Google bundle rows to the Google plugin", () => |
| 109 | 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 | 192 | const client = db.client; |
| 112 | 193 | |
| 113 | 194 | yield* Effect.promise(() => |
| @@ -118,7 +199,9 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { · 4 unmodified lines | ||
| 118 | 199 | pluginId: "openapi", |
| 119 | 200 | config: { |
| 120 | 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 | 211 | tenant: "org_1", |
| 129 | 212 | slug: "stripe", |
| 130 | 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 | 220 | yield* Effect.promise(() => |
| @@ -156,10 +242,18 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { · 21 unmodified lines | ||
| 156 | 242 | }), |
| 157 | 243 | ); |
| 158 | 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 | 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 | 277 | expect(googleBlob.rows).toEqual([{ value: "google spec" }]); |
| 184 | 278 | |
| 185 | 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 | 289 | const storage = yield* Effect.promise(() => |
| 191 | 290 | client.execute( |
| @@ -198,7 +297,9 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { · 6 unmodified lines | ||
| 198 | 297 | ]); |
| 199 | 298 | |
| 200 | 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 | 304 | expect(tools.rows).toEqual([ |
| 204 | 305 | { integration: "google", plugin_id: "google" }, |
| @@ -206,7 +307,9 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { · 1 unmodified line | ||
| 206 | 307 | ]); |
| 207 | 308 | |
| 208 | 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 | 314 | expect(definitions.rows).toEqual([ |
| 212 | 315 | { integration: "google", plugin_id: "google" }, |
| @@ -1,10 +1,13 @@ | ||
| 1 | 1 | 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"; | |
| 3 | 6 | |
| 4 | 7 | const MIGRATION_NAME = "2026-06-20-google-openapi-ownership"; |
| 5 | 8 | const googleOpenApiCandidate = (alias?: string): string => { |
| 6 | 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 | |
| 10 | 13 | const execute = ( |
| @@ -13,7 +16,8 @@ const execute = ( · 2 unmodified lines | ||
| 13 | 16 | ) => |
| 14 | 17 | Effect.tryPromise({ |
| 15 | 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 | |
| 19 | 23 | export const runSqliteGoogleOpenApiOwnershipMigration = ( |