Unified · Split
| @@ -30,6 +30,7 @@ · 29 unmodified lines | |||
| 30 | "@executor-js/runtime-quickjs": "workspace:*", | 30 | "@executor-js/runtime-quickjs": "workspace:*", |
| 31 | "@executor-js/sdk": "workspace:*", | 31 | "@executor-js/sdk": "workspace:*", |
| 32 | "@jitl/quickjs-wasmfile-release-sync": "catalog:", | 32 | "@jitl/quickjs-wasmfile-release-sync": "catalog:", |
| 33 | "@modelcontextprotocol/sdk": "^1.29.0", | ||
| 33 | "@sentry/bun": "^10.57.0", | 34 | "@sentry/bun": "^10.57.0", |
| 34 | "effect": "catalog:", | 35 | "effect": "catalog:", |
| 35 | "quickjs-emscripten": "catalog:" | 36 | "quickjs-emscripten": "catalog:" |
| @@ -64,6 +64,9 @@ import type { PlatformError } from "effect/PlatformError"; · 63 unmodified lines | |||
| 64 | import * as Effect from "effect/Effect"; | 64 | import * as Effect from "effect/Effect"; |
| 65 | import * as Option from "effect/Option"; | 65 | import * as Option from "effect/Option"; |
| 66 | import * as Cause from "effect/Cause"; | 66 | import * as Cause from "effect/Cause"; |
| 67 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; | ||
| 68 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; | ||
| 69 | import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; | ||
| 67 | 70 | ||
| 68 | import { ExecutorApi } from "@executor-js/api"; | 71 | import { ExecutorApi } from "@executor-js/api"; |
| 69 | import { | 72 | import { |
| @@ -1122,9 +1125,123 @@ const withStdoutReroutedToStderr = async <A>(body: () => Promise<A>): Promise<A> · 1052 unmodified lines | |||
| 1122 | } | 1125 | } |
| 1123 | }; | 1126 | }; |
| 1124 | 1127 | ||
| 1128 | const mcpUrlForActiveLocalServer = ( | ||
| 1129 | connection: ExecutorServerConnection, | ||
| 1130 | elicitationMode: "browser" | "model", | ||
| 1131 | ): URL => { | ||
| 1132 | const url = new URL("/mcp", connection.origin); | ||
| 1133 | if (elicitationMode === "browser") { | ||
| 1134 | url.searchParams.set("elicitation_mode", "browser"); | ||
| 1135 | } | ||
| 1136 | return url; | ||
| 1137 | }; | ||
| 1138 | |||
| 1139 | const closeMcpBridgeTransport = async (close: () => Promise<void>): Promise<void> => { | ||
| 1140 | await close(); | ||
| 1141 | }; | ||
| 1142 | |||
| 1143 | const isAbortDuringMcpBridgeClose = (error: Error): boolean => | ||
| 1144 | error.name === "AbortError" || error.message.toLowerCase().includes("aborted"); | ||
| 1145 | |||
| 1146 | const runMcpHttpBridge = async (input: { | ||
| 1147 | readonly manifest: ExecutorLocalServerManifest; | ||
| 1148 | readonly elicitationMode: "browser" | "model"; | ||
| 1149 | }): Promise<void> => { | ||
| 1150 | const stdio = new StdioServerTransport(); | ||
| 1151 | const authorization = getExecutorServerAuthorizationHeader(input.manifest.connection); | ||
| 1152 | const http = new StreamableHTTPClientTransport( | ||
| 1153 | mcpUrlForActiveLocalServer(input.manifest.connection, input.elicitationMode), | ||
| 1154 | authorization ? { requestInit: { headers: { Authorization: authorization } } } : undefined, | ||
| 1155 | ); | ||
| 1156 | |||
| 1157 | let finished = false; | ||
| 1158 | let closing = false; | ||
| 1159 | let closePromise: Promise<void> | null = null; | ||
| 1160 | let resolveExit: () => void = () => {}; | ||
| 1161 | |||
| 1162 | const waitForExit = new Promise<void>((resolve) => { | ||
| 1163 | resolveExit = resolve; | ||
| 1164 | }); | ||
| 1165 | |||
| 1166 | const finish = () => { | ||
| 1167 | if (finished) return; | ||
| 1168 | finished = true; | ||
| 1169 | process.off("SIGINT", shutdown); | ||
| 1170 | process.off("SIGTERM", shutdown); | ||
| 1171 | process.stdin.off("end", shutdown); | ||
| 1172 | process.stdin.off("close", shutdown); | ||
| 1173 | resolveExit(); | ||
| 1174 | }; | ||
| 1175 | |||
| 1176 | const closeBoth = (): Promise<void> => { | ||
| 1177 | if (!closePromise) { | ||
| 1178 | closing = true; | ||
| 1179 | closePromise = Promise.resolve() | ||
| 1180 | .then(() => | ||
| 1181 | Promise.allSettled([ | ||
| 1182 | closeMcpBridgeTransport(() => stdio.close()), | ||
| 1183 | closeMcpBridgeTransport(() => http.close()), | ||
| 1184 | ]), | ||
| 1185 | ) | ||
| 1186 | .then(() => undefined); | ||
| 1187 | } | ||
| 1188 | return closePromise; | ||
| 1189 | }; | ||
| 1190 | |||
| 1191 | function shutdown() { | ||
| 1192 | finish(); | ||
| 1193 | void closeBoth(); | ||
| 1194 | } | ||
| 1195 | |||
| 1196 | const reportError = (context: string, cause: unknown) => { | ||
| 1197 | const error = toError(cause); | ||
| 1198 | if (closing && isAbortDuringMcpBridgeClose(error)) return; | ||
| 1199 | console.error(`Executor MCP bridge ${context}: ${error.message}`); | ||
| 1200 | }; | ||
| 1201 | |||
| 1202 | const forwardMessage = | ||
| 1203 | (send: (message: JSONRPCMessage) => Promise<void>, context: string) => | ||
| 1204 | (message: JSONRPCMessage) => { | ||
| 1205 | void send(message).then(undefined, (cause: unknown) => { | ||
| 1206 | reportError(context, cause); | ||
| 1207 | shutdown(); | ||
| 1208 | }); | ||
| 1209 | }; | ||
| 1210 | |||
| 1211 | process.once("SIGINT", shutdown); | ||
| 1212 | process.once("SIGTERM", shutdown); | ||
| 1213 | process.stdin.once("end", shutdown); | ||
| 1214 | process.stdin.once("close", shutdown); | ||
| 1215 | |||
| 1216 | stdio.onclose = shutdown; | ||
| 1217 | http.onclose = shutdown; | ||
| 1218 | stdio.onerror = (error) => reportError("stdio transport error", error); | ||
| 1219 | http.onerror = (error) => reportError("daemon transport error", error); | ||
| 1220 | stdio.onmessage = forwardMessage((message) => http.send(message), "failed to send to daemon"); | ||
| 1221 | http.onmessage = forwardMessage((message) => stdio.send(message), "failed to send to stdio"); | ||
| 1222 | |||
| 1223 | try { | ||
| 1224 | await http.start(); | ||
| 1225 | await stdio.start(); | ||
| 1226 | await waitForExit; | ||
| 1227 | } finally { | ||
| 1228 | finish(); | ||
| 1229 | await closeBoth(); | ||
| 1230 | } | ||
| 1231 | }; | ||
| 1232 | |||
| 1125 | const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "model" }) => | 1233 | const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "model" }) => |
| 1126 | Effect.gen(function* () { | 1234 | Effect.gen(function* () { |
| 1235 | const active = yield* readActiveLocalServerManifest(); | ||
| 1236 | if (active) { | ||
| 1237 | yield* Effect.promise(() => | ||
| 1238 | runMcpHttpBridge({ manifest: active, elicitationMode: input.elicitationMode }), | ||
| 1239 | ); | ||
| 1240 | return; | ||
| 1241 | } | ||
| 1242 | |||
| 1127 | const startupLock = yield* acquireLocalServerStartLock(); | 1243 | const startupLock = yield* acquireLocalServerStartLock(); |
| 1244 | let activeAfterLock: ExecutorLocalServerManifest | null = null; | ||
| 1128 | let web: Awaited< | 1245 | let web: Awaited< |
| 1129 | ReturnType< | 1246 | ReturnType< |
| 1130 | typeof withStdoutReroutedToStderr<{ | 1247 | typeof withStdoutReroutedToStderr<{ |
| @@ -1137,44 +1254,53 @@ const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "mode · 6 unmodified lines | |||
| 1137 | > | null = null; | 1254 | > | null = null; |
| 1138 | 1255 | ||
| 1139 | try { | 1256 | try { |
| 1140 | yield* assertNoOtherActiveLocalServer(); | 1257 | activeAfterLock = yield* readActiveLocalServerManifest(); |
| 1141 | web = yield* Effect.promise(() => | 1258 | if (!activeAfterLock) { |
| 1142 | withStdoutReroutedToStderr(async () => { | 1259 | web = yield* Effect.promise(() => |
| 1143 | const host = "127.0.0.1"; | 1260 | withStdoutReroutedToStderr(async () => { |
| 1144 | const port = await Effect.runPromise( | 1261 | const host = "127.0.0.1"; |
| 1145 | chooseDaemonPort({ preferredPort: DEFAULT_PORT, hostname: host }), | 1262 | const port = await Effect.runPromise( |
| 1146 | ); | 1263 | chooseDaemonPort({ preferredPort: DEFAULT_PORT, hostname: host }), |
| 1147 | const baseUrl = `http://localhost:${port}`; | 1264 | ); |
| 1148 | const restoreWebBaseUrl = installDefaultExecutorWebBaseUrl(baseUrl); | 1265 | const baseUrl = `http://localhost:${port}`; |
| 1149 | 1266 | const restoreWebBaseUrl = installDefaultExecutorWebBaseUrl(baseUrl); | |
| 1150 | try { | 1267 | |
| 1151 | const executor = await getExecutor(); | 1268 | try { |
| 1152 | const server = await startServer({ | 1269 | const executor = await getExecutor(); |
| 1153 | port, | 1270 | const server = await startServer({ |
| 1154 | hostname: host, | 1271 | port, |
| 1155 | embeddedWebUI, | 1272 | hostname: host, |
| 1156 | }); | 1273 | embeddedWebUI, |
| 1157 | const serverBaseUrl = `http://localhost:${server.port}`; | 1274 | }); |
| 1158 | return { executor, server, baseUrl: serverBaseUrl, restoreWebBaseUrl }; | 1275 | const serverBaseUrl = `http://localhost:${server.port}`; |
| 1159 | } catch (cause) { | 1276 | return { executor, server, baseUrl: serverBaseUrl, restoreWebBaseUrl }; |
| 1160 | restoreWebBaseUrl(); | 1277 | } catch (cause) { |
| 1161 | throw cause; | 1278 | restoreWebBaseUrl(); |
| 1162 | } | 1279 | throw cause; |
| 1163 | }), | 1280 | } |
| 1164 | ); | 1281 | }), |
| 1165 | yield* publishLocalServerManifest({ | 1282 | ); |
| 1166 | kind: "foreground", | 1283 | yield* publishLocalServerManifest({ |
| 1167 | connection: normalizeExecutorServerConnection({ | 1284 | kind: "foreground", |
| 1168 | kind: "http", | 1285 | connection: normalizeExecutorServerConnection({ |
| 1169 | origin: web.baseUrl, | 1286 | kind: "http", |
| 1170 | displayName: "CLI MCP", | 1287 | origin: web.baseUrl, |
| 1171 | auth: { kind: "bearer", token: web.server.authToken }, | 1288 | displayName: "CLI MCP", |
| 1172 | }), | 1289 | auth: { kind: "bearer", token: web.server.authToken }, |
| 1173 | }); | 1290 | }), |
| 1291 | }); | ||
| 1292 | } | ||
| 1174 | } finally { | 1293 | } finally { |
| 1175 | yield* releaseLocalServerStartLock(startupLock).pipe(Effect.ignore); | 1294 | yield* releaseLocalServerStartLock(startupLock).pipe(Effect.ignore); |
| 1176 | } | 1295 | } |
| 1177 | 1296 | ||
| 1297 | if (activeAfterLock) { | ||
| 1298 | yield* Effect.promise(() => | ||
| 1299 | runMcpHttpBridge({ manifest: activeAfterLock, elicitationMode: input.elicitationMode }), | ||
| 1300 | ); | ||
| 1301 | return; | ||
| 1302 | } | ||
| 1303 | |||
| 1178 | if (!web) return yield* Effect.fail(new Error("Failed to start local Executor MCP server.")); | 1304 | if (!web) return yield* Effect.fail(new Error("Failed to start local Executor MCP server.")); |
| 1179 | 1305 | ||
| 1180 | try { | 1306 | try { |
| @@ -42,6 +42,7 @@ · 41 unmodified lines | |||
| 42 | "@executor-js/runtime-quickjs": "workspace:*", | 42 | "@executor-js/runtime-quickjs": "workspace:*", |
| 43 | "@executor-js/sdk": "workspace:*", | 43 | "@executor-js/sdk": "workspace:*", |
| 44 | "@jitl/quickjs-wasmfile-release-sync": "catalog:", | 44 | "@jitl/quickjs-wasmfile-release-sync": "catalog:", |
| 45 | "@modelcontextprotocol/sdk": "^1.29.0", | ||
| 45 | "@sentry/bun": "^10.57.0", | 46 | "@sentry/bun": "^10.57.0", |
| 46 | "effect": "catalog:", | 47 | "effect": "catalog:", |
| 47 | "quickjs-emscripten": "catalog:", | 48 | "quickjs-emscripten": "catalog:", |
| @@ -0,0 +1,168 @@ | |||
| 1 | // Local CLI: MCP clients commonly install `executor mcp` as a stdio command. | ||
| 2 | // When a durable local daemon is already running, that command should attach to | ||
| 3 | // the daemon's HTTP MCP endpoint instead of starting a second local server and | ||
| 4 | // tripping the data-dir singleton guard. | ||
| 5 | import { expect } from "@effect/vitest"; | ||
| 6 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | ||
| 7 | import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; | ||
| 8 | import { Effect } from "effect"; | ||
| 9 | import { mkdtempSync, rmSync } from "node:fs"; | ||
| 10 | import { tmpdir } from "node:os"; | ||
| 11 | import { join } from "node:path"; | ||
| 12 | import { fileURLToPath } from "node:url"; | ||
| 13 | import type { Subprocess } from "bun"; | ||
| 14 | |||
| 15 | import { scenario } from "../src/scenario"; | ||
| 16 | |||
| 17 | const repoRoot = fileURLToPath(new URL("../../", import.meta.url)); | ||
| 18 | const testScope = join(repoRoot, "apps/local"); | ||
| 19 | const readyTimeoutMs = 60_000; | ||
| 20 | |||
| 21 | const waitForDaemonReady = ( | ||
| 22 | proc: Subprocess<"ignore", "pipe", "pipe">, | ||
| 23 | ): Promise<{ readonly port: number; readonly stderr: () => string }> => | ||
| 24 | // oxlint-disable-next-line executor/no-promise-reject -- boundary: local e2e watches a real daemon process | ||
| 25 | new Promise((resolveReady, rejectReady) => { | ||
| 26 | let stdoutBuffer = ""; | ||
| 27 | let stderrBuffer = ""; | ||
| 28 | let settled = false; | ||
| 29 | const decoder = new TextDecoder(); | ||
| 30 | const stdout = proc.stdout.getReader(); | ||
| 31 | const stderr = proc.stderr.getReader(); | ||
| 32 | |||
| 33 | const deadline = setTimeout(() => { | ||
| 34 | if (settled) return; | ||
| 35 | settled = true; | ||
| 36 | // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: local e2e failure includes captured daemon stderr | ||
| 37 | rejectReady(new Error(`daemon did not announce ready: ${stderrBuffer}`)); | ||
| 38 | }, readyTimeoutMs); | ||
| 39 | |||
| 40 | void (async () => { | ||
| 41 | while (true) { | ||
| 42 | const { value, done } = await stderr.read(); | ||
| 43 | if (done) return; | ||
| 44 | stderrBuffer += decoder.decode(value); | ||
| 45 | } | ||
| 46 | })(); | ||
| 47 | |||
| 48 | void (async () => { | ||
| 49 | while (true) { | ||
| 50 | const { value, done } = await stdout.read(); | ||
| 51 | if (done) { | ||
| 52 | if (!settled) { | ||
| 53 | settled = true; | ||
| 54 | clearTimeout(deadline); | ||
| 55 | // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: local e2e failure includes captured daemon stderr | ||
| 56 | rejectReady(new Error(`daemon stdout closed before ready: ${stderrBuffer}`)); | ||
| 57 | } | ||
| 58 | return; | ||
| 59 | } | ||
| 60 | |||
| 61 | stdoutBuffer += decoder.decode(value); | ||
| 62 | const match = /Daemon ready on http:\/\/(?:\[[^\]]+\]|[^:\s]+):(\d+)/.exec(stdoutBuffer); | ||
| 63 | if (match) { | ||
| 64 | settled = true; | ||
| 65 | clearTimeout(deadline); | ||
| 66 | resolveReady({ port: Number(match[1]), stderr: () => stderrBuffer }); | ||
| 67 | return; | ||
| 68 | } | ||
| 69 | } | ||
| 70 | })(); | ||
| 71 | }); | ||
| 72 | |||
| 73 | const stopDaemonProcess = async ( | ||
| 74 | proc: Subprocess<"ignore", "pipe", "pipe">, | ||
| 75 | exitCode: () => number | null, | ||
| 76 | ): Promise<void> => { | ||
| 77 | if (exitCode() !== null) return; | ||
| 78 | proc.kill("SIGTERM"); | ||
| 79 | await Promise.race([proc.exited, Bun.sleep(3000)]); | ||
| 80 | if (exitCode() === null) proc.kill("SIGKILL"); | ||
| 81 | }; | ||
| 82 | |||
| 83 | const withTempRoot = Effect.acquireRelease( | ||
| 84 | Effect.sync(() => mkdtempSync(join(tmpdir(), "executor-local-mcp-daemon-attach-"))), | ||
| 85 | (root) => Effect.sync(() => rmSync(root, { recursive: true, force: true })), | ||
| 86 | ); | ||
| 87 | |||
| 88 | const startForegroundDaemon = (dataDir: string) => | ||
| 89 | Effect.acquireRelease( | ||
| 90 | Effect.gen(function* () { | ||
| 91 | const proc = Bun.spawn( | ||
| 92 | [ | ||
| 93 | "bun", | ||
| 94 | "run", | ||
| 95 | "dev:cli", | ||
| 96 | "daemon", | ||
| 97 | "run", | ||
| 98 | "--foreground", | ||
| 99 | "--port", | ||
| 100 | "0", | ||
| 101 | "--hostname", | ||
| 102 | "127.0.0.1", | ||
| 103 | "--scope", | ||
| 104 | testScope, | ||
| 105 | ], | ||
| 106 | { | ||
| 107 | cwd: repoRoot, | ||
| 108 | env: { ...process.env, EXECUTOR_DATA_DIR: dataDir }, | ||
| 109 | stdin: "ignore", | ||
| 110 | stdout: "pipe", | ||
| 111 | stderr: "pipe", | ||
| 112 | }, | ||
| 113 | ); | ||
| 114 | let exitCode: number | null = null; | ||
| 115 | void proc.exited.then((code) => { | ||
| 116 | exitCode = code; | ||
| 117 | }); | ||
| 118 | |||
| 119 | const ready = yield* Effect.promise(() => waitForDaemonReady(proc)).pipe( | ||
| 120 | Effect.tapError(() => Effect.promise(() => stopDaemonProcess(proc, () => exitCode))), | ||
| 121 | ); | ||
| 122 | return { proc, port: ready.port, stderr: ready.stderr, exitCode: () => exitCode }; | ||
| 123 | }), | ||
| 124 | ({ proc, exitCode }) => Effect.promise(() => stopDaemonProcess(proc, exitCode)), | ||
| 125 | ); | ||
| 126 | |||
| 127 | scenario( | ||
| 128 | "Local CLI MCP · stdio attaches to the active daemon instead of starting a competing server", | ||
| 129 | { timeout: 120_000 }, | ||
| 130 | Effect.gen(function* () { | ||
| 131 | const root = yield* withTempRoot; | ||
| 132 | const dataDir = join(root, "data"); | ||
| 133 | const daemon = yield* startForegroundDaemon(dataDir); | ||
| 134 | |||
| 135 | const transport = new StdioClientTransport({ | ||
| 136 | command: "bun", | ||
| 137 | args: ["run", "dev:cli", "mcp", "--scope", testScope], | ||
| 138 | cwd: repoRoot, | ||
| 139 | env: { ...process.env, EXECUTOR_DATA_DIR: dataDir }, | ||
| 140 | stderr: "pipe", | ||
| 141 | }); | ||
| 142 | const client = new Client({ name: "e2e-local-mcp-daemon-attach", version: "1.0.0" }); | ||
| 143 | |||
| 144 | yield* Effect.acquireRelease( | ||
| 145 | Effect.promise(() => client.connect(transport)), | ||
| 146 | () => Effect.promise(() => transport.close()), | ||
| 147 | ); | ||
| 148 | |||
| 149 | const { tools } = yield* Effect.promise(() => client.listTools()); | ||
| 150 | expect( | ||
| 151 | tools.map((tool) => tool.name), | ||
| 152 | "daemon-backed stdio MCP lists tools", | ||
| 153 | ).toContain("execute"); | ||
| 154 | |||
| 155 | const result = yield* Effect.promise(() => | ||
| 156 | client.callTool({ | ||
| 157 | name: "execute", | ||
| 158 | arguments: { code: "return 2 + 2" }, | ||
| 159 | }), | ||
| 160 | ); | ||
| 161 | |||
| 162 | const text = (result.content as Array<{ type: string; text: string }>)[0]?.text; | ||
| 163 | expect(text, "execute ran through the daemon-backed MCP bridge").toContain("4"); | ||
| 164 | expect(result.isError, "execute should not report a tool error").toBeFalsy(); | ||
| 165 | expect(daemon.exitCode(), daemon.stderr()).toBeNull(); | ||
| 166 | expect(daemon.port, "daemon announced an OS-assigned port").toBeGreaterThan(0); | ||
| 167 | }).pipe(Effect.scoped), | ||
| 168 | ); | ||
| @@ -2,13 +2,122 @@ import { describe, expect, it } from "@effect/vitest"; · 1 unmodified line | |||
| 2 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | 2 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
| 3 | import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; | 3 | import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; |
| 4 | import { Effect } from "effect"; | 4 | import { Effect } from "effect"; |
| 5 | import { spawn, type ChildProcessByStdio } from "node:child_process"; | ||
| 5 | import { mkdtempSync } from "node:fs"; | 6 | import { mkdtempSync } from "node:fs"; |
| 6 | import { tmpdir } from "node:os"; | 7 | import { tmpdir } from "node:os"; |
| 7 | import { join, resolve } from "node:path"; | 8 | import { join, resolve } from "node:path"; |
| 9 | import type { Readable } from "node:stream"; | ||
| 8 | 10 | ||
| 9 | const repoRoot = resolve(import.meta.dirname, "../../../.."); | 11 | const repoRoot = resolve(import.meta.dirname, "../../../.."); |
| 10 | const cliEntry = resolve(repoRoot, "apps/cli/src/main.ts"); | 12 | const cliEntry = resolve(repoRoot, "apps/cli/src/main.ts"); |
| 11 | const testScope = resolve(repoRoot, "apps/local"); | 13 | const testScope = resolve(repoRoot, "apps/local"); |
| 14 | const readyTimeoutMs = 30_000; | ||
| 15 | type DaemonProcess = ChildProcessByStdio<null, Readable, Readable>; | ||
| 16 | |||
| 17 | const waitForDaemonReady = ( | ||
| 18 | proc: DaemonProcess, | ||
| 19 | ): Promise<{ readonly port: number; readonly stderr: () => string }> => | ||
| 20 | // oxlint-disable-next-line executor/no-promise-reject -- boundary: integration test watches a real daemon process | ||
| 21 | new Promise((resolveReady, rejectReady) => { | ||
| 22 | let stdoutBuffer = ""; | ||
| 23 | let stderrBuffer = ""; | ||
| 24 | let settled = false; | ||
| 25 | |||
| 26 | const cleanup = () => { | ||
| 27 | clearTimeout(deadline); | ||
| 28 | proc.stdout.off("data", onStdout); | ||
| 29 | proc.stderr.off("data", onStderr); | ||
| 30 | proc.off("error", onError); | ||
| 31 | proc.off("close", onClose); | ||
| 32 | }; | ||
| 33 | |||
| 34 | const fail = (cause: Error) => { | ||
| 35 | if (settled) return; | ||
| 36 | settled = true; | ||
| 37 | cleanup(); | ||
| 38 | // oxlint-disable-next-line executor/no-promise-reject -- boundary: adapts child_process callbacks into the Promise waited by the Effect test helper | ||
| 39 | rejectReady(cause); | ||
| 40 | }; | ||
| 41 | |||
| 42 | const deadline = setTimeout(() => { | ||
| 43 | // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: integration test failure includes captured daemon stderr | ||
| 44 | fail(new Error(`daemon did not announce ready: ${stderrBuffer}`)); | ||
| 45 | }, readyTimeoutMs); | ||
| 46 | |||
| 47 | const onStderr = (chunk: Buffer | string) => { | ||
| 48 | stderrBuffer += chunk.toString(); | ||
| 49 | }; | ||
| 50 | |||
| 51 | const onStdout = (chunk: Buffer | string) => { | ||
| 52 | stdoutBuffer += chunk.toString(); | ||
| 53 | const match = /Daemon ready on http:\/\/(?:\[[^\]]+\]|[^:\s]+):(\d+)/.exec(stdoutBuffer); | ||
| 54 | if (match) { | ||
| 55 | settled = true; | ||
| 56 | cleanup(); | ||
| 57 | resolveReady({ port: Number(match[1]), stderr: () => stderrBuffer }); | ||
| 58 | } | ||
| 59 | }; | ||
| 60 | |||
| 61 | const onError = (error: Error) => fail(error); | ||
| 62 | |||
| 63 | const onClose = () => { | ||
| 64 | // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: integration test failure includes captured daemon stderr | ||
| 65 | fail(new Error(`daemon stdout closed before ready: ${stderrBuffer}`)); | ||
| 66 | }; | ||
| 67 | |||
| 68 | proc.stdout.on("data", onStdout); | ||
| 69 | proc.stderr.on("data", onStderr); | ||
| 70 | proc.once("error", onError); | ||
| 71 | proc.once("close", onClose); | ||
| 72 | }); | ||
| 73 | |||
| 74 | const stopDaemonProcess = async ( | ||
| 75 | proc: DaemonProcess, | ||
| 76 | exitCode: () => number | null, | ||
| 77 | ): Promise<void> => { | ||
| 78 | if (exitCode() !== null) return; | ||
| 79 | proc.kill("SIGTERM"); | ||
| 80 | await Promise.race([ | ||
| 81 | new Promise<void>((resolve) => { | ||
| 82 | proc.once("close", () => resolve()); | ||
| 83 | }), | ||
| 84 | new Promise<void>((resolve) => setTimeout(resolve, 3000)), | ||
| 85 | ]); | ||
| 86 | if (exitCode() === null) proc.kill("SIGKILL"); | ||
| 87 | }; | ||
| 88 | |||
| 89 | const startForegroundDaemon = (dataDir: string) => | ||
| 90 | Effect.acquireRelease( | ||
| 91 | Effect.gen(function* () { | ||
| 92 | const proc = spawn( | ||
| 93 | "bun", | ||
| 94 | [ | ||
| 95 | "run", | ||
| 96 | cliEntry, | ||
| 97 | "daemon", | ||
| 98 | "run", | ||
| 99 | "--foreground", | ||
| 100 | "--port", | ||
| 101 | "0", | ||
| 102 | "--hostname", | ||
| 103 | "127.0.0.1", | ||
| 104 | "--scope", | ||
| 105 | testScope, | ||
| 106 | ], | ||
| 107 | { env: { ...process.env, EXECUTOR_DATA_DIR: dataDir }, stdio: ["ignore", "pipe", "pipe"] }, | ||
| 108 | ); | ||
| 109 | let exitCode: number | null = null; | ||
| 110 | proc.once("close", (code) => { | ||
| 111 | exitCode = code; | ||
| 112 | }); | ||
| 113 | |||
| 114 | const ready = yield* Effect.promise(() => waitForDaemonReady(proc)).pipe( | ||
| 115 | Effect.tapError(() => Effect.promise(() => stopDaemonProcess(proc, () => exitCode))), | ||
| 116 | ); | ||
| 117 | return { proc, port: ready.port, stderr: ready.stderr, exitCode: () => exitCode }; | ||
| 118 | }), | ||
| 119 | ({ proc, exitCode }) => Effect.promise(() => stopDaemonProcess(proc, exitCode)), | ||
| 120 | ); | ||
| 12 | 121 | ||
| 13 | describe("MCP stdio integration", () => { | 122 | describe("MCP stdio integration", () => { |
| 14 | it.effect( | 123 | it.effect( |
| @@ -48,4 +157,43 @@ describe("MCP stdio integration", () => { · 33 unmodified lines | |||
| 48 | }).pipe(Effect.scoped), | 157 | }).pipe(Effect.scoped), |
| 49 | { timeout: 30_000 }, | 158 | { timeout: 30_000 }, |
| 50 | ); | 159 | ); |
| 160 | |||
| 161 | it.effect( | ||
| 162 | "attaches stdio MCP to an active local daemon", | ||
| 163 | () => | ||
| 164 | Effect.gen(function* () { | ||
| 165 | const dataDir = mkdtempSync(join(tmpdir(), "executor-mcp-daemon-test-")); | ||
| 166 | const daemon = yield* startForegroundDaemon(dataDir); | ||
| 167 | |||
| 168 | const transport = new StdioClientTransport({ | ||
| 169 | command: "bun", | ||
| 170 | args: ["run", cliEntry, "mcp", "--scope", testScope], | ||
| 171 | env: { ...process.env, EXECUTOR_DATA_DIR: dataDir }, | ||
| 172 | }); | ||
| 173 | |||
| 174 | const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} }); | ||
| 175 | |||
| 176 | yield* Effect.acquireRelease( | ||
| 177 | Effect.promise(() => client.connect(transport)), | ||
| 178 | () => Effect.promise(() => transport.close()), | ||
| 179 | ); | ||
| 180 | |||
| 181 | const { tools } = yield* Effect.promise(() => client.listTools()); | ||
| 182 | expect(tools.map((t) => t.name)).toContain("execute"); | ||
| 183 | |||
| 184 | const result = yield* Effect.promise(() => | ||
| 185 | client.callTool({ | ||
| 186 | name: "execute", | ||
| 187 | arguments: { code: "return 2+2" }, | ||
| 188 | }), | ||
| 189 | ); | ||
| 190 | |||
| 191 | const text = (result.content as Array<{ type: string; text: string }>)[0]?.text; | ||
| 192 | expect(text).toContain("4"); | ||
| 193 | expect(result.isError).toBeFalsy(); | ||
| 194 | expect(daemon.exitCode(), daemon.stderr()).toBeNull(); | ||
| 195 | expect(daemon.port).toBeGreaterThan(0); | ||
| 196 | }).pipe(Effect.scoped), | ||
| 197 | { timeout: 45_000 }, | ||
| 198 | ); | ||
| 51 | }); | 199 | }); |