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