From 4e41d45f5f288ff76dc959a5cc8bda6745201694 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Wed, 14 Oct 2026 19:59:42 +0530 Subject: [PATCH 01/17] feat(harness): add MCP client Connect to one MCP server over stdio or Streamable HTTP through the official SDK. Stdio servers run on the host in their own process group and are stopped with the group; HTTP connects retry transient failures. List pages and tool results are validated by the SDK. The process-group stop sequence moves out of the host shell into a shared Process.terminate helper. Co-Authored-By: Claude Opus 5.5 --- packages/harness/package.json | 2 + packages/harness/src/mcp/client.ts | 395 +++++++++++ packages/harness/src/mcp/content.ts | 44 ++ packages/harness/src/mcp/error.ts | 36 + packages/harness/src/mcp/stdio.ts | 246 +++++++ packages/harness/src/sandbox/shell/host.ts | 39 +- packages/harness/src/sandbox/utils/process.ts | 41 +- .../test/__artifacts__/mcp.client.json | 384 +++++++++++ packages/harness/test/fixtures/mcp/http.ts | 123 ++++ .../harness/test/fixtures/mcp/malformed.mjs | 53 ++ packages/harness/test/fixtures/mcp/server.mjs | 33 + .../harness/test/fixtures/mcp/stubborn.mjs | 23 + packages/harness/test/fixtures/mcp/tools.mjs | 131 ++++ packages/harness/test/mcp.client.test.ts | 624 ++++++++++++++++++ pnpm-lock.yaml | 51 ++ 15 files changed, 2191 insertions(+), 34 deletions(-) create mode 100644 packages/harness/src/mcp/client.ts create mode 100644 packages/harness/src/mcp/content.ts create mode 100644 packages/harness/src/mcp/error.ts create mode 100644 packages/harness/src/mcp/stdio.ts create mode 100644 packages/harness/test/__artifacts__/mcp.client.json create mode 100644 packages/harness/test/fixtures/mcp/http.ts create mode 100644 packages/harness/test/fixtures/mcp/malformed.mjs create mode 100644 packages/harness/test/fixtures/mcp/server.mjs create mode 100644 packages/harness/test/fixtures/mcp/stubborn.mjs create mode 100644 packages/harness/test/fixtures/mcp/tools.mjs create mode 100644 packages/harness/test/mcp.client.test.ts diff --git a/packages/harness/package.json b/packages/harness/package.json index b897a456..5ecde2bf 100644 --- a/packages/harness/package.json +++ b/packages/harness/package.json @@ -82,6 +82,7 @@ "@effect/platform-node": "4.0.5", "@effect/platform-node-shared": "4.0.5", "@effect/sql-sqlite-node": "4.0.5", + "@modelcontextprotocol/client": "2.3.1", "@npmcli/arborist": "^10.0.2", "@npmcli/config": "^11.0.1", "@platformatic/vfs": "^0.4.0", @@ -98,6 +99,7 @@ }, "devDependencies": { "@daytona/sdk": "^0.211.2", + "@modelcontextprotocol/server": "2.3.1", "@types/node": "catalog:", "@types/npm-package-arg": "^6.1.4", "@vercel/sandbox": "^3.3.0", diff --git a/packages/harness/src/mcp/client.ts b/packages/harness/src/mcp/client.ts new file mode 100644 index 00000000..cc4a7f45 --- /dev/null +++ b/packages/harness/src/mcp/client.ts @@ -0,0 +1,395 @@ +import { pathToFileURL } from "node:url"; +import { + type CallToolResult, + Client, + type Implementation, + type OAuthClientProvider, + ProtocolError, + ProtocolErrorCode, + type ListResourcesResult, + type ListResourceTemplatesResult, + type ReadResourceResult, + SdkError, + SdkErrorCode, + SdkHttpError, + type ServerCapabilities, + StreamableHTTPClientTransport, + type Tool, + type Transport, + UnauthorizedError, +} from "@modelcontextprotocol/client"; +import { Cause, Clock, Data, Duration, Effect, Exit, FiberSet, Schedule } from "effect"; +import { hostPath } from "../host.ts"; +import { ConnectError, NeedsAuthError, RequestError, SessionExpiredError } from "./error.ts"; +import { McpStdio } from "./stdio.ts"; + +const DEFAULT_STARTUP_TIMEOUT = Duration.seconds(30); +const DEFAULT_REQUEST_TIMEOUT = Duration.seconds(60); +const TERMINATE_TIMEOUT = Duration.seconds(1); + +export interface Stdio { + readonly _tag: "Stdio"; + readonly command: string; + readonly args: ReadonlyArray; + readonly env: Record; + readonly cwd: string; +} + +export interface Http { + readonly _tag: "Http"; + readonly url: URL; + readonly headers: Record; +} + +export type Endpoint = Stdio | Http; + +export interface Progress { + readonly progress: number; + readonly total?: number | undefined; + readonly message?: string | undefined; +} + +type Failed = RequestError | SessionExpiredError | NeedsAuthError; + +/** Handle over a connected MCP server that keeps the SDK `Client` out of the rest of the harness. */ +export interface Connection { + readonly serverInfo: Implementation | undefined; + readonly capabilities: ServerCapabilities | undefined; + readonly instructions: string | undefined; + /** Every tool, following pagination. Empty when the server has no tools capability. */ + readonly tools: () => Effect.Effect; + /** One page of resources, starting at `cursor`. Empty when the server has no resources capability. */ + readonly resources: (cursor?: string) => Effect.Effect; + /** One page of resource templates; empty when the server does not implement the method. */ + readonly resourceTemplates: (cursor?: string) => Effect.Effect; + readonly readResource: (uri: string) => Effect.Effect; + readonly callTool: (input: { + readonly name: string; + readonly args?: Record; + readonly onProgress?: (progress: Progress) => void; + }) => Effect.Effect; + /** Each handler has one consumer (the owner of this connection); setting it again replaces it. */ + readonly onToolsChanged: (handler: Effect.Effect) => void; + readonly onResourcesChanged: (handler: Effect.Effect) => void; + readonly onSessionExpired: (handler: Effect.Effect) => void; + /** + * The connection dropped (for example the server process exited). Runs right away when it already + * dropped before the handler was set. Never runs for a close caused by closing the owning scope. + */ + readonly onClose: (handler: (reason: string) => Effect.Effect) => void; +} + +export interface ConnectInput { + readonly server: string; + readonly endpoint: Endpoint; + /** Directories sent as roots; only when the session's sandbox is local, where these paths exist for the server. */ + readonly roots?: ReadonlyArray; + /** HTTP only. */ + readonly authProvider?: OAuthClientProvider; + readonly timeout?: { readonly startup?: Duration.Input; readonly request?: Duration.Input }; + readonly clientInfo?: Implementation; +} + +/** A raw SDK or transport failure, before it is mapped to one of the public errors. */ +class Failure extends Data.TaggedError("Mcp.Failure")<{ readonly error: unknown }> {} +const fail = (error: unknown) => new Failure({ error }); + +// Node's fetch reports network failures as `TypeError("fetch failed")` with the system code on `cause`. +const systemCode = (error: Error): string | undefined => { + const code = + (error as { readonly code?: unknown }).code ?? (error.cause as { readonly code?: unknown } | undefined)?.code; + return typeof code === "string" ? code : undefined; +}; + +// HTTP statuses and network error codes live on error properties and are lost once flattened to a message. +const describe = (error: unknown) => { + if (!(error instanceof Error)) return String(error); + const detail = + error instanceof SdkHttpError + ? `HTTP ${error.status}` + : !(error instanceof SdkError) + ? systemCode(error) + : undefined; + return detail && !error.message.includes(detail) ? `${error.message} (${detail})` : error.message; +}; + +// Overloaded or restarting servers and dropped connections usually recover within a second. +// 501 means the server will never support the request. +const isTransient = (error: unknown) => + error instanceof SdkHttpError + ? error.status === 408 || error.status === 429 || (error.status >= 500 && error.status !== 501) + : error instanceof TypeError; + +/** Connects one MCP server; closing the calling scope tears down the transport and any spawned process. */ +export const connect = Effect.fnUntraced(function* (input: ConnectInput) { + const { server, endpoint } = input; + const startupTimeout = Duration.toMillis(input.timeout?.startup ?? DEFAULT_STARTUP_TIMEOUT); + const requestTimeout = Duration.toMillis(input.timeout?.request ?? DEFAULT_REQUEST_TIMEOUT); + const runFork = yield* FiberSet.makeRuntime(); + + // Handlers the owner registers after connect; notifications can arrive before that. + const handlers = { + tools: Effect.void as Effect.Effect, + resources: Effect.void as Effect.Effect, + expired: Effect.void as Effect.Effect, + }; + // Every connect attempt builds its own client; only the one that connected reports a close. + const closed: { + shuttingDown: boolean; + active?: Client | undefined; + reason?: string | undefined; + handler?: ((reason: string) => Effect.Effect) | undefined; + } = { shuttingDown: false }; + const early = new Map(); + const report = (reason: string) => { + if (closed.reason !== undefined) return; + closed.reason = reason; + if (closed.handler) runFork(closed.handler(reason)); + }; + + // The SDK fails pending requests with a bare "Connection closed"; the transport error before it says why. + let lastError: string | undefined; + const explain = (error: unknown) => + error instanceof SdkError && error.code === SdkErrorCode.ConnectionClosed && lastError + ? `${describe(error)}: ${lastError}` + : describe(error); + // A server that wants (new) credentials. Without an auth provider a 401 is a configuration error. + const needsAuth = (error: unknown) => + error instanceof UnauthorizedError || + (input.authProvider !== undefined && error instanceof SdkHttpError && error.status === 401); + + // Two more attempts, 250 ms and 1 s later, while the failure is transient and the deadline has not passed. + const retrying = (what: string, effect: Effect.Effect, deadline = Infinity) => + Effect.retry( + effect, + Schedule.max([Schedule.exponential("250 millis", 4), Schedule.recurs(2)]).pipe( + Schedule.setInputType(), + Schedule.while(({ input, now, duration }) => + !isTransient(input.error) || now + Duration.toMillis(duration) >= deadline + ? Effect.succeed(false) + : Effect.logWarning(`retrying ${what}`, { server, error: describe(input.error) }).pipe( + Effect.as(true), + ), + ), + ), + ); + + const initialize = Effect.fnUntraced(function* (transport: Transport, timeout: number) { + const client = new Client(input.clientInfo ?? { name: "codework", version: "unknown" }, { + capabilities: input.roots ? { roots: {} } : {}, + }); + // Background work such as the standalone SSE stream reports failures only here; aborts come from close. + client.onerror = (error) => { + if (error.name === "AbortError") return; + lastError = describe(error); + runFork(Effect.logWarning("mcp transport error", { server, error: lastError })); + }; + client.onclose = () => { + if (closed.shuttingDown) return; + const reason = lastError ? `connection closed: ${lastError}` : "connection closed"; + // Before connect returns, a close belongs to an attempt that may still fail and be retried. + if (closed.active === client) report(reason); + else if (!early.has(client)) early.set(client, reason); + }; + // Handled whether or not the server advertises `listChanged`, since some servers send them anyway. + client.setNotificationHandler("notifications/tools/list_changed", () => { + runFork(handlers.tools); + }); + client.setNotificationHandler("notifications/resources/list_changed", () => { + runFork(handlers.resources); + }); + const roots = input.roots; + if (roots) { + client.setRequestHandler("roots/list", () => ({ + roots: roots.map((directory) => ({ + uri: pathToFileURL(directory).href, + name: hostPath.basename(directory), + })), + })); + } + yield* Effect.tryPromise({ + try: (signal) => client.connect(transport, { timeout, signal }), + catch: fail, + }).pipe(Effect.onError(() => Effect.promise(() => transport.close()).pipe(Effect.ignore))); + return client; + }); + + // The transport holds the Mcp-Session-Id the server minted, and the server answering it with 404, or + // with the 400 a freshly restarted single-session server emits, means it no longer knows this + // connection. Other 400s pass through untouched. + const session: { transport?: StreamableHTTPClientTransport; reported: boolean } = { reported: false }; + const failure = + (method: string) => + (error: unknown): Failed => { + if (needsAuth(error)) return new NeedsAuthError({ server, message: describe(error) }); + const request = () => new RequestError({ server, method, message: explain(error) }); + if (!(error instanceof SdkHttpError) || session.transport?.sessionId === undefined) return request(); + const text = (error.data as { readonly text?: unknown }).text; + const expired = + error.status === 404 || + (error.status === 400 && typeof text === "string" && text.includes("Bad Request: Server not initialized")); + if (!expired) return request(); + if (!session.reported) { + session.reported = true; + runFork(handlers.expired); + } + return new SessionExpiredError({ server }); + }; + + let stderr = () => ""; + const exit = yield* Effect.gen(function* () { + if (endpoint._tag === "Stdio") { + const stdio = yield* McpStdio.make({ + server, + command: endpoint.command, + args: endpoint.args, + cwd: endpoint.cwd, + env: endpoint.env, + }); + stderr = stdio.stderr; + return yield* initialize(stdio.transport, startupTimeout); + } + // Every attempt opens a fresh transport and gets what is left of the startup timeout. + const deadline = (yield* Clock.currentTimeMillis) + startupTimeout; + const open = Effect.gen(function* () { + const remaining = Math.max(1, deadline - (yield* Clock.currentTimeMillis)); + session.transport = new StreamableHTTPClientTransport(endpoint.url, { + requestInit: { headers: endpoint.headers }, + ...(input.authProvider === undefined ? {} : { authProvider: input.authProvider }), + }); + return yield* initialize(session.transport, remaining); + }); + return yield* retrying("mcp connect", open, deadline); + }).pipe(Effect.exit); + + if (Exit.isFailure(exit)) { + const squashed = Cause.squash(exit.cause); + const error = squashed instanceof Failure ? squashed.error : squashed; + if (needsAuth(error)) return yield* new NeedsAuthError({ server, message: describe(error) }); + // A server that fails to start usually says why on stderr. + const message = explain(error); + const tail = stderr().trim(); + return yield* new ConnectError({ + server, + message: tail && !message.includes(tail) ? `${message}\n${tail}` : message, + }); + } + + const client = exit.value; + closed.active = client; + // The server closed between the handshake finishing and this point. + const dropped = early.get(client); + early.clear(); + if (dropped !== undefined) report(dropped); + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + closed.shuttingDown = true; + // Close only aborts streams; the HTTP session lives on until the server expires it unless + // terminated explicitly. Terminate first: close aborts the signal the DELETE shares. + const transport = session.transport; + // Termination failures are logged below; onerror would report them a second time. + client.onerror = () => {}; + if (transport?.sessionId !== undefined && !session.reported) + yield* Effect.tryPromise({ try: () => transport.terminateSession(), catch: fail }).pipe( + Effect.timeoutOrElse({ + duration: TERMINATE_TIMEOUT, + orElse: () => Effect.fail(fail(`timed out after ${Duration.format(TERMINATE_TIMEOUT)}`)), + }), + Effect.tapError((failure) => + Effect.logWarning("failed to terminate mcp session", { server, error: describe(failure.error) }), + ), + Effect.ignore, + ); + yield* Effect.tryPromise(() => client.close()).pipe(Effect.ignore); + }), + ); + + // A success means transport errors from before the request no longer explain a later close. One + // that arrived meanwhile (a crash while other requests are pending) is kept. + const succeeded = (before: string | undefined) => + Effect.sync(() => { + if (lastError === before) lastError = undefined; + }); + const request = (method: string, run: (signal: AbortSignal) => Promise) => + Effect.suspend(() => { + const before = lastError; + return Effect.tryPromise({ try: run, catch: failure(method) }).pipe(Effect.tap(succeeded(before))); + }); + // Listing and reading are read-only, so a transient failure is safe to retry. Tool calls are never + // retried: the server may already have run them. + const read = (method: string, run: (signal: AbortSignal) => Promise) => + Effect.suspend(() => { + const before = lastError; + return retrying(method, Effect.tryPromise({ try: run, catch: fail })).pipe( + Effect.mapError((error) => failure(method)(error.error)), + Effect.tap(succeeded(before)), + ); + }); + // Every progress notification restarts the timeout, so long calls that report progress keep running. + const options = (signal: AbortSignal, onProgress?: (progress: Progress) => void) => ({ + signal, + timeout: requestTimeout, + resetTimeoutOnProgress: true, + onprogress: onProgress ?? (() => {}), + }); + const capabilities = client.getServerCapabilities(); + + const connection: Connection = { + serverInfo: client.getServerVersion(), + capabilities, + instructions: client.getInstructions()?.trim() || undefined, + tools: () => + // Servers without the tools capability (prompts or resources only) do not answer tools/list. + capabilities?.tools === undefined + ? Effect.succeed([]) + : read("tools/list", (signal) => client.listTools(undefined, options(signal))).pipe( + Effect.map((result) => result.tools), + ), + resources: (cursor) => + capabilities?.resources === undefined + ? Effect.succeed({ resources: [] }) + : read("resources/list", (signal) => + client.request( + { method: "resources/list", params: cursor === undefined ? {} : { cursor } }, + options(signal), + ), + ), + resourceTemplates: (cursor) => + capabilities?.resources === undefined + ? Effect.succeed({ resourceTemplates: [] }) + : read("resources/templates/list", (signal) => + client + .request( + { method: "resources/templates/list", params: cursor === undefined ? {} : { cursor } }, + options(signal), + ) + .catch((error: unknown) => { + // Servers that do not implement templates have none. + if (error instanceof ProtocolError && error.code === ProtocolErrorCode.MethodNotFound) + return { resourceTemplates: [] }; + throw error; + }), + ), + readResource: (uri) => read("resources/read", (signal) => client.readResource({ uri }, options(signal))), + callTool: (call) => + request("tools/call", (signal) => + client.callTool({ name: call.name, arguments: call.args ?? {} }, options(signal, call.onProgress)), + ), + onToolsChanged: (handler) => { + handlers.tools = handler; + }, + onResourcesChanged: (handler) => { + handlers.resources = handler; + }, + onSessionExpired: (handler) => { + handlers.expired = handler; + }, + onClose: (handler) => { + closed.handler = handler; + if (closed.reason !== undefined) runFork(handler(closed.reason)); + }, + }; + return connection; +}); + +export * as McpClient from "./client.ts"; diff --git a/packages/harness/src/mcp/content.ts b/packages/harness/src/mcp/content.ts new file mode 100644 index 00000000..b3f2be69 --- /dev/null +++ b/packages/harness/src/mcp/content.ts @@ -0,0 +1,44 @@ +import type { Message } from "@codeworksh/aikit"; +import type { CallToolResult, ContentBlock } from "@modelcontextprotocol/client"; + +export type Content = Message.TextContent | Message.ImageContent; + +const fromBlock = (block: ContentBlock): Content => { + switch (block.type) { + case "text": + return { type: "text", text: block.text }; + case "image": + return { type: "image", data: block.data, mimeType: block.mimeType }; + case "audio": + return { type: "text", text: `[audio ${block.mimeType} omitted]` }; + case "resource_link": + return { type: "text", text: `${block.name}: ${block.uri}` }; + case "resource": { + const resource = block.resource; + if ("text" in resource) return { type: "text", text: resource.text }; + if (resource.mimeType?.startsWith("image/")) { + return { type: "image", data: resource.blob, mimeType: resource.mimeType }; + } + return { + type: "text", + text: `[binary resource ${resource.uri} (${resource.mimeType ?? "unknown type"}) omitted]`, + }; + } + default: + return { type: "text", text: `[unsupported MCP content ${(block as { type: string }).type}]` }; + } +}; + +/** + * A tool result as model content. A result without content blocks but with `structuredContent` + * becomes its JSON, since servers should, but do not always, mirror structured results as text. + */ +export const fromResult = (result: Pick): Content[] => { + const content = (result.content ?? []).map(fromBlock); + if (content.length === 0 && result.structuredContent !== undefined) { + content.push({ type: "text", text: JSON.stringify(result.structuredContent, null, 2) }); + } + return content; +}; + +export * as McpContent from "./content.ts"; diff --git a/packages/harness/src/mcp/error.ts b/packages/harness/src/mcp/error.ts new file mode 100644 index 00000000..a0b1aa6e --- /dev/null +++ b/packages/harness/src/mcp/error.ts @@ -0,0 +1,36 @@ +import { Schema } from "effect"; + +/** The server could not be started, reached or initialized. */ +export class ConnectError extends Schema.TaggedError()("Mcp.ConnectError", { + server: Schema.String, + message: Schema.String, +}) {} + +/** The server rejected the connection until the user signs in. */ +export class NeedsAuthError extends Schema.TaggedError()("Mcp.NeedsAuthError", { + server: Schema.String, + message: Schema.String, +}) {} + +/** A Streamable HTTP server no longer knows this connection's session; the caller reconnects. */ +export class SessionExpiredError extends Schema.TaggedError()("Mcp.SessionExpiredError", { + server: Schema.String, +}) { + override get message(): string { + return `mcp server session expired: ${this.server}`; + } +} + +/** A request on a connected server failed. */ +export class RequestError extends Schema.TaggedError()("Mcp.RequestError", { + server: Schema.String, + method: Schema.String, + message: Schema.String, +}) {} + +/** A failure inside the stdio transport, reported to the SDK through `onerror` or a rejected promise. */ +export class StdioError extends Schema.TaggedError()("Mcp.StdioError", { + message: Schema.String, +}) {} + +export * as McpError from "./error.ts"; diff --git a/packages/harness/src/mcp/stdio.ts b/packages/harness/src/mcp/stdio.ts new file mode 100644 index 00000000..49728050 --- /dev/null +++ b/packages/harness/src/mcp/stdio.ts @@ -0,0 +1,246 @@ +import { type JSONRPCMessage, ReadBuffer, serializeMessage, type Transport } from "@modelcontextprotocol/client"; +import { Cause, Duration, Effect, Fiber, Option, Queue, Scope, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import { Process } from "../sandbox/utils/process.ts"; +import { StdioError } from "./error.ts"; + +/** How long a server gets to exit on its own after stdin closes, before it is terminated. */ +const CLOSE_GRACE = Duration.seconds(2); +/** Escalate SIGTERM to SIGKILL after this long. */ +const FORCE_KILL_AFTER = Duration.seconds(2); +const OUTGOING_CAPACITY = 64; +const MAX_FRAME_BYTES = 16 * 1024 * 1024; +// An unexpected exit is reported with its exit code and the end of stderr, which usually names the cause. +const EXIT_WAIT = Duration.seconds(1); +const STDERR_TAIL = 2_000; + +/** Process groups of running servers, terminated if the host exits without closing them. */ +const live = new Set(); +let exitHook = false; +const track = (pid: number) => { + if (process.platform === "win32") return () => {}; + if (!exitHook) { + exitHook = true; + process.once("exit", () => { + for (const group of live) { + try { + process.kill(-group, "SIGTERM"); + } catch { + // the group is already gone + } + } + }); + } + live.add(pid); + return () => { + live.delete(pid); + }; +}; + +export interface Options { + /** Server name; only used to attribute logs. */ + readonly server: string; + readonly command: string; + readonly args: ReadonlyArray; + readonly cwd: string; + /** Environment declared by the server config, merged over the host's. */ + readonly env: Record; +} + +/** + * MCP stdio transport for a server process on the host. + * + * The process is acquired in the calling scope: closing the scope kills it and its process group, + * whether or not the transport was closed. + */ +export const make = Effect.fnUntraced(function* (options: Options) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const scope = yield* Effect.scope; + const runPromise = Effect.runPromiseWith(yield* Effect.context()); + // Outgoing frames are queued rather than written to `handle.stdin` directly: the sink closes the + // stream it is run with, and stdin must stay open across the whole connection. + const outgoing = yield* Queue.bounded(OUTGOING_CAPACITY); + const buffer = new ReadBuffer({ maxBufferSize: MAX_FRAME_BYTES }); + const state: { + phase: "ready" | "starting" | "open" | "closed"; + handle?: ChildProcessSpawner.ChildProcessHandle | undefined; + stdoutOpen: boolean; + } = { phase: "ready", stdoutOpen: false }; + let startup: Promise | undefined; + let closing: Promise | undefined; + let untrack = () => {}; + let outputStarted = false; + // Set when reading stdout failed (an oversized frame), which then explains the close instead of an exit. + let broken = false; + let stderr = ""; + + const stop = (handle: ChildProcessSpawner.ChildProcessHandle) => + Effect.timeoutOption(handle.exitCode, CLOSE_GRACE).pipe( + Effect.ignore, + Effect.andThen( + Effect.suspend(() => + Process.terminate(handle, { forceKillAfter: FORCE_KILL_AFTER, pipesOpen: state.stdoutOpen }), + ), + ), + Effect.ensuring(Effect.sync(() => untrack())), + ); + + const close = () => + (closing ??= runPromise( + Effect.gen(function* () { + state.phase = "closed"; + Queue.endUnsafe(outgoing); + if (startup) yield* Effect.promise(() => startup!.catch(() => undefined)); + const handle = state.handle; + if (!handle) { + // Closed before output was read (spawn failed, or closed while starting): nothing else reports it. + if (!outputStarted) transport.onclose?.(); + return; + } + state.handle = undefined; + yield* stop(handle); + }).pipe(Effect.ensuring(Queue.shutdown(outgoing)), Effect.ensuring(Effect.sync(() => buffer.clear()))), + )); + + const transport: Transport = { + start: () => { + if (state.phase !== "ready") + return Promise.reject(new StdioError({ message: "stdio transport already started" })); + state.phase = "starting"; + startup = runPromise( + Effect.gen(function* () { + const handle = yield* spawner + .spawn( + ChildProcess.make(options.command, [...options.args], { + cwd: options.cwd, + env: options.env, + extendEnv: true, + stdin: { stream: Stream.encodeText(Stream.fromQueue(outgoing)), endOnDone: true }, + stdout: "pipe", + stderr: "pipe", + forceKillAfter: FORCE_KILL_AFTER, + }), + ) + .pipe( + Effect.mapError((error) => { + if (error.reason._tag !== "NotFound") return error; + // The spawner checks the working directory before it spawns, with the same tag. + return error.reason.method === "spawn" + ? new StdioError({ message: `command not found: ${options.command}` }) + : new StdioError({ message: `working directory not found: ${options.cwd}` }); + }), + ); + state.handle = handle; + state.stdoutOpen = true; + untrack = track(Number(handle.pid)); + yield* Effect.addFinalizer(() => Effect.sync(() => untrack())); + if (state.phase === "closed") { + state.handle = undefined; + return yield* stop(handle); + } + state.phase = "open"; + yield* startOutput(handle); + }).pipe(Scope.provide(scope)), + ); + return startup; + }, + send: (message: JSONRPCMessage) => + state.phase !== "open" + ? Promise.reject(new StdioError({ message: "not connected" })) + : runPromise( + Queue.offer(outgoing, serializeMessage(message)).pipe( + Effect.flatMap((offered) => + offered ? Effect.void : Effect.fail(new StdioError({ message: "not connected" })), + ), + ), + ), + close, + }; + + const deliver = (chunk: Uint8Array) => + Effect.gen(function* () { + yield* Effect.try({ + try: () => buffer.append(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)), + catch: () => new StdioError({ message: "mcp stdio frame exceeded 16 MiB" }), + }); + while (true) { + // `undefined` means the frame failed to parse: the buffer has already advanced past it, so + // keep draining. `null` means the buffer holds no complete frame yet. + const message = yield* Effect.try({ + try: () => buffer.readMessage(), + catch: (cause) => new StdioError({ message: cause instanceof Error ? cause.message : String(cause) }), + }).pipe( + Effect.catch((error) => + Effect.sync(() => { + transport.onerror?.(error); + return undefined; + }), + ), + ); + if (message === undefined) continue; + if (message === null) return; + transport.onmessage?.(message); + } + }); + + const startOutput = (handle: ChildProcessSpawner.ChildProcessHandle) => + Effect.gen(function* () { + outputStarted = true; + // Drain stderr into the debug log so chatty servers cannot stall on a full pipe. + const drain = yield* Effect.forkScoped( + handle.stderr.pipe( + Stream.decodeText(), + Stream.tap((output) => Effect.sync(() => (stderr = (stderr + output).slice(-STDERR_TAIL)))), + Stream.runForEach((output) => + output.trim() === "" + ? Effect.void + : Effect.logDebug("mcp server stderr", { server: options.server, output }), + ), + Effect.ignore, + ), + ); + + yield* Effect.forkScoped( + Stream.runForEach(handle.stdout, deliver).pipe( + Effect.tapCause((cause) => + Effect.sync(() => { + if (Cause.hasInterruptsOnly(cause)) return; + broken = true; + const error = Cause.squash(cause); + transport.onerror?.(error instanceof Error ? error : new StdioError({ message: String(error) })); + }), + ), + Effect.ignore, + // stdout ending means the server is gone. + Effect.ensuring( + Effect.gen(function* () { + state.stdoutOpen = false; + if (state.phase !== "closed") { + if (!broken) { + // stdout can end before the final stderr output has been read. A grandchild holding + // stderr open must not cost the exit code, so each wait is bounded separately. + yield* Fiber.await(drain).pipe(Effect.timeoutOption(EXIT_WAIT), Effect.ignore); + const code = yield* handle.exitCode.pipe( + Effect.timeoutOption(EXIT_WAIT), + Effect.orElseSucceed(() => Option.none()), + ); + const exited = Option.match(code, { + onNone: () => "mcp server process exited", + onSome: (code) => `mcp server process exited with code ${code}`, + }); + const output = stderr.trim(); + transport.onerror?.(new StdioError({ message: output ? `${exited}: ${output}` : exited })); + } + yield* Effect.promise(close); + } + transport.onclose?.(); + }), + ), + ), + ); + }); + + return { transport, stderr: () => stderr }; +}); + +export * as McpStdio from "./stdio.ts"; diff --git a/packages/harness/src/sandbox/shell/host.ts b/packages/harness/src/sandbox/shell/host.ts index dbaec367..07be7559 100644 --- a/packages/harness/src/sandbox/shell/host.ts +++ b/packages/harness/src/sandbox/shell/host.ts @@ -1,5 +1,6 @@ -import { Duration, Effect, Exit, Layer, Option, Stream } from "effect"; +import { Duration, Effect, Exit, Layer, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import { Process } from "../utils/process.ts"; import { type ExecChunk, type ExecResult, Shell, ShellError, type ShellOptions } from "./shell.ts"; /** @@ -41,15 +42,9 @@ export const layer = (options: Options = {}): Layer.Layer, opts?: ShellOptions) => Effect.gen(function* () { const spawned = yield* spawner.spawn( @@ -69,29 +64,7 @@ export const layer = (options: Options = {}): Layer.Layer Exit.isSuccess(exit) ? Effect.void - : Effect.gen(function* () { - const running = yield* spawned.isRunning.pipe(Effect.orElseSucceed(() => false)); - if (!running && openPipes === 0) return; - if (running) { - yield* spawned.kill({ killSignal: "SIGTERM" }).pipe( - Effect.timeoutOption(forceKillAfter), - Effect.flatMap( - Option.match({ - onSome: () => Effect.void, - onNone: () => spawned.kill({ killSignal: "SIGKILL" }), - }), - ), - Effect.ignore, - ); - } - yield* Effect.sync(() => { - try { - process.kill(-Number(spawned.pid), "SIGKILL"); - } catch { - // the group is already gone - } - }); - }), + : Effect.suspend(() => Process.terminate(spawned, { forceKillAfter, pipesOpen: openPipes > 0 })), ); return { exitCode: spawned.exitCode, ...output }; }); diff --git a/packages/harness/src/sandbox/utils/process.ts b/packages/harness/src/sandbox/utils/process.ts index 3f4e438a..38b70379 100644 --- a/packages/harness/src/sandbox/utils/process.ts +++ b/packages/harness/src/sandbox/utils/process.ts @@ -1,5 +1,5 @@ import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"; -import { Effect, Layer } from "effect"; +import { type Duration, Effect, Layer, Option } from "effect"; import { ChildProcessSpawner } from "effect/process"; /** @@ -17,4 +17,43 @@ export const unsupported = Layer.succeed( ChildProcessSpawner.make(() => Effect.die(new Error("process execution is not supported by this sandbox"))), ); +/** + * Stops a spawned process and its group. + * + * The spawner's own release sends SIGTERM and then waits for exit, so a process + * that ignores SIGTERM (and children inheriting that) would make a scope close + * wait forever. This SIGTERMs the group, SIGKILLs it if it outlives + * `forceKillAfter`, then SIGKILLs whatever is left of the group — children can + * outlive a leader that already exited. Only while the leader runs or one of + * its pipes is still open: either keeps the group id ours, so it cannot have + * been reused by someone else. + */ +export const terminate = ( + handle: ChildProcessSpawner.ChildProcessHandle, + options: { readonly forceKillAfter: Duration.Input; readonly pipesOpen: boolean }, +) => + Effect.gen(function* () { + const running = yield* handle.isRunning.pipe(Effect.orElseSucceed(() => false)); + if (!running && !options.pipesOpen) return; + if (running) { + yield* handle.kill({ killSignal: "SIGTERM" }).pipe( + Effect.timeoutOption(options.forceKillAfter), + Effect.flatMap( + Option.match({ + onSome: () => Effect.void, + onNone: () => handle.kill({ killSignal: "SIGKILL" }), + }), + ), + Effect.ignore, + ); + } + yield* Effect.sync(() => { + try { + process.kill(-Number(handle.pid), "SIGKILL"); + } catch { + // the group is already gone + } + }); + }); + export * as Process from "./process.ts"; diff --git a/packages/harness/test/__artifacts__/mcp.client.json b/packages/harness/test/__artifacts__/mcp.client.json new file mode 100644 index 00000000..45b4f025 --- /dev/null +++ b/packages/harness/test/__artifacts__/mcp.client.json @@ -0,0 +1,384 @@ +{ + "cancel": { + "after": "after cancel", + "cancelled": 1, + }, + "crash": { + "error": { + "message": "Connection closed: mcp server process exited with code 3: fatal: fixture crashed on purpose", + "tag": "Mcp.RequestError", + }, + "method": "tools/call", + "reason": "connection closed: mcp server process exited with code 3: fatal: fixture crashed on purpose", + }, + "frameLimit": { + "error": { + "message": "Connection closed: mcp stdio frame exceeded 16 MiB", + "tag": "Mcp.RequestError", + }, + "reason": "connection closed: mcp stdio frame exceeded 16 MiB", + }, + "framing": { + "after": "still alive", + "framed": "framed", + "toolsChanged": true, + }, + "httpHappy": { + "echo": [ + { + "text": "over http", + "type": "text", + }, + ], + "serverInfo": { + "name": "http-fixture", + "version": "1.0.0", + }, + "tools": [ + "echo", + "mixed", + "structured", + "crash", + "progress", + "slow", + "hang", + "cancelled", + "roots", + "capabilities", + "spawn", + "frame", + "exit", + "huge", + "typed-missing", + "typed-mismatch", + ], + }, + "invalidEntry": { + "message": "Invalid result for tools/list: [ + { + "expected": "object", + "code": "invalid_type", + "path": [ + "tools", + 1, + "inputSchema" + ], + "message": "Invalid input: expected object, received undefined" + } +]", + "tag": "Mcp.RequestError", + }, + "missingCwd": { + "message": "working directory not found: /missing-dir", + "tag": "Mcp.ConnectError", + }, + "noToolsCapability": { + "printed": 0, + "tools": [], + }, + "onClose": { + "afterDeliberateClose": [], + "afterServerExit": "connection closed: mcp server process exited with code 0", + }, + "outputSchema": { + "declared": [ + "typed-missing", + "typed-mismatch", + ], + "mismatch": { + "message": "Structured content does not match the tool's output schema: data/answer must be number", + "tag": "Mcp.RequestError", + }, + "missing": { + "message": "Tool typed-missing has an output schema but did not return structured content", + "tag": "Mcp.RequestError", + }, + }, + "processGroup": { + "killed": true, + }, + "progress": { + "progressed": "progress done", + "sawProgress": true, + "silent": { + "message": "Request timed out", + "tag": "Mcp.RequestError", + }, + }, + "refused": { + "error": { + "message": "fetch failed (ECONNREFUSED)", + "tag": "Mcp.ConnectError", + }, + "retried": true, + }, + "resources": { + "first": { + "nextCursor": "page-2", + "resources": [ + { + "name": "one", + "uri": "fixture://one", + }, + ], + }, + "read": { + "contents": [ + { + "mimeType": "text/plain", + "text": "contents of fixture://one", + "uri": "fixture://one", + }, + ], + }, + "resourcesChanged": true, + "second": { + "resources": [ + { + "name": "changed", + "uri": "fixture://changed", + }, + ], + }, + "templates": { + "resourceTemplates": [], + }, + }, + "retry": { + "initializes": 2, + "tools": [ + "echo", + ], + }, + "retryCloseReason": { + "afterRegister": [], + "afterScope": [], + "initializes": 2, + "tools": [ + "echo", + ], + }, + "roots": { + "withRoots": { + "capabilities": { + "roots": {}, + }, + "roots": { + "roots": [ + { + "name": "project", + "uri": "file:///workspace/project", + }, + { + "name": "data dir", + "uri": "file:///srv/data%20dir", + }, + ], + }, + }, + "withoutRoots": { + "capabilities": {}, + }, + }, + "sessionExpired": { + "errors": [ + { + "message": "mcp server session expired: raw", + "tag": "Mcp.SessionExpiredError", + }, + { + "message": "mcp server session expired: raw", + "tag": "Mcp.SessionExpiredError", + }, + ], + "reported": 1, + "terminated": false, + }, + "spawnFailure": { + "message": "command not found: codework-mcp-missing-binary", + "tag": "Mcp.ConnectError", + }, + "stdioHappy": { + "echo": [ + { + "text": "hi", + "type": "text", + }, + ], + "instructions": "Use the echo tool.", + "mixed": [ + { + "text": "hello", + "type": "text", + }, + { + "data": "iVBORw0KGgo=", + "mimeType": "image/png", + "type": "image", + }, + { + "text": "embedded notes", + "type": "text", + }, + { + "data": "iVBORw0KGgo=", + "mimeType": "image/png", + "type": "image", + }, + { + "text": "[binary resource file:///data.bin (unknown type) omitted]", + "type": "text", + }, + { + "text": "[audio audio/wav omitted]", + "type": "text", + }, + { + "text": "readme: file:///README.md", + "type": "text", + }, + ], + "serverInfo": { + "name": "stdio-fixture", + "version": "1.0.0", + }, + "structured": [ + { + "text": "{ + "answer": 42, + "items": [ + "a", + "b" + ] +}", + "type": "text", + }, + ], + "tools": [ + "echo", + "mixed", + "structured", + "crash", + "progress", + "slow", + "hang", + "cancelled", + "roots", + "capabilities", + "spawn", + "frame", + "exit", + "huge", + "typed-missing", + "typed-mismatch", + ], + }, + "strictResources": { + "unnamed": { + "resources": { + "message": "Invalid result for resources/list: [ + { + "expected": "string", + "code": "invalid_type", + "path": [ + "resources", + 0, + "name" + ], + "message": "Invalid input: expected string, received undefined" + } +]", + "tag": "Mcp.RequestError", + }, + "templates": { + "message": "Invalid result for resources/templates/list: [ + { + "expected": "string", + "code": "invalid_type", + "path": [ + "resourceTemplates", + 0, + "name" + ], + "message": "Invalid input: expected string, received undefined" + } +]", + "tag": "Mcp.RequestError", + }, + }, + "valid": { + "resources": { + "resources": [ + { + "_meta": { + "origin": "fixture", + }, + "description": "A file", + "mimeType": "text/plain", + "name": "a", + "uri": "file:///a.txt", + }, + ], + }, + "templates": { + "resourceTemplates": [ + { + "description": "Any file", + "name": "files", + "uriTemplate": "file:///{path}", + }, + ], + }, + }, + }, + "stubborn": { + "boundedUnder8s": true, + "killed": true, + }, + "toolsPagination": { + "duplicate": { + "tools": [ + "t-start", + "t-again", + ], + }, + "emptyCursor": { + "error": { + "message": "tools/list: exceeded listMaxPages (64); server pagination did not terminate", + "tag": "Mcp.RequestError", + }, + }, + "nullCursor": { + "error": { + "message": "Invalid result for tools/list: [ + { + "expected": "string", + "code": "invalid_type", + "path": [ + "nextCursor" + ], + "message": "Invalid input: expected string, received null" + } +]", + "tag": "Mcp.RequestError", + }, + }, + }, + "unadvertisedListChanged": { + "listChanged": {}, + "toolsChanged": true, + }, + "unauthorized": { + "redirects": [ + "/authorize", + ], + "withProvider": { + "message": "Unauthorized", + "tag": "Mcp.NeedsAuthError", + }, + "without": { + "message": "Error POSTing to endpoint: unauthorized (HTTP 401)", + "tag": "Mcp.ConnectError", + }, + }, +} \ No newline at end of file diff --git a/packages/harness/test/fixtures/mcp/http.ts b/packages/harness/test/fixtures/mcp/http.ts new file mode 100644 index 00000000..78e33773 --- /dev/null +++ b/packages/harness/test/fixtures/mcp/http.ts @@ -0,0 +1,123 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { Server, WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server"; +import { Effect } from "effect"; +// @ts-expect-error plain .mjs fixture shared with the stdio server, which runs without a TS loader +import { install } from "./tools.mjs"; + +type Handler = (request: IncomingMessage, response: ServerResponse) => Promise; + +/** Serves `handler` on 127.0.0.1 at an ephemeral port for the calling scope; yields the `/mcp` URL. */ +export const listen = (handler: Handler) => + Effect.acquireRelease( + Effect.callback>((resume) => { + const server = createServer((request, response) => { + handler(request, response).catch((error: unknown) => { + if (!response.headersSent) response.writeHead(500); + response.end(String(error)); + }); + }); + server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server))); + }), + (server) => + Effect.callback((resume) => { + server.closeAllConnections(); + server.close(() => resume(Effect.void)); + }), + ).pipe(Effect.map((server) => new URL(`http://127.0.0.1:${(server.address() as AddressInfo).port}/mcp`))); + +const readBody = async (request: IncomingMessage) => { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(chunk as Buffer); + return Buffer.concat(chunks); +}; + +/** A single-session streamable HTTP server running the shared fixture tools on the real SDK transport. */ +export const sdk = async (): Promise => { + const server = new Server({ name: "http-fixture", version: "1.0.0" }, { capabilities: { tools: {} } }); + install(server); + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => "fixture-session" }); + await server.connect(transport); + return async (request, response) => { + const headers = new Headers(); + for (let index = 0; index < request.rawHeaders.length; index += 2) + headers.append(request.rawHeaders[index]!, request.rawHeaders[index + 1]!); + const body = request.method === "GET" || request.method === "HEAD" ? undefined : await readBody(request); + const answer = await transport.handleRequest( + new Request(new URL(request.url ?? "/", "http://127.0.0.1"), { + method: request.method ?? "GET", + headers, + ...(body === undefined ? {} : { body }), + }), + ); + response.writeHead(answer.status, Object.fromEntries(answer.headers)); + if (!answer.body) return void response.end(); + const reader = answer.body.getReader(); + response.on("close", () => void reader.cancel().catch(() => {})); + while (true) { + const chunk = await reader.read().catch(() => ({ done: true as const, value: undefined })); + if (chunk.done) break; + response.write(chunk.value); + } + response.end(); + }; +}; + +/** Requests a hand-written server saw, as `METHOD rpc-method`. */ +export interface Raw { + readonly handler: Handler; + readonly seen: string[]; + /** Answer every request carrying a session id with 404, as a server that lost the session. */ + forget: boolean; +} + +/** A hand-written streamable HTTP server for raw status scenarios. */ +export const raw = (options: { readonly failInitialize?: number; readonly unauthorized?: boolean } = {}): Raw => { + let failures = options.failInitialize ?? 0; + const state: Raw = { + seen: [], + forget: false, + handler: async (request, response) => { + // An OAuth-protected server: every MCP request is refused, and there is no discovery metadata. + if (options.unauthorized) { + state.seen.push(`${request.method} ${new URL(request.url ?? "/", "http://127.0.0.1").pathname}`); + await readBody(request); + if (request.url?.startsWith("/mcp") !== true) return void response.writeHead(404).end(); + response.writeHead(401, { "www-authenticate": 'Bearer error="invalid_token"' }); + return void response.end("unauthorized"); + } + const body = request.method === "POST" ? (JSON.parse((await readBody(request)).toString()) as Rpc) : undefined; + state.seen.push(body?.method ? `${request.method} ${body.method}` : `${request.method}`); + if (state.forget && request.headers["mcp-session-id"] !== undefined) { + response.writeHead(404, { "content-type": "text/plain" }); + return void response.end("session not found"); + } + if (request.method === "GET") return void response.writeHead(405).end(); + if (request.method === "DELETE") return void response.writeHead(200).end(); + if (body === undefined || body.id === undefined) return void response.writeHead(202).end(); + if (body.method === "initialize" && failures > 0) { + failures--; + return void response.writeHead(503, { "content-type": "text/plain" }).end("overloaded"); + } + const result = + body.method === "initialize" + ? { + protocolVersion: body.params?.protocolVersion, + capabilities: { tools: {} }, + serverInfo: { name: "raw-fixture", version: "1.0.0" }, + } + : body.method === "tools/list" + ? { tools: [{ name: "echo", inputSchema: { type: "object" } }] } + : {}; + response.writeHead(200, { "content-type": "application/json", "mcp-session-id": "raw-session" }); + response.end(JSON.stringify({ jsonrpc: "2.0", id: body.id, result })); + }, + }; + return state; +}; + +interface Rpc { + readonly id?: number | string; + readonly method?: string; + readonly params?: { readonly protocolVersion?: string }; +} diff --git a/packages/harness/test/fixtures/mcp/malformed.mjs b/packages/harness/test/fixtures/mcp/malformed.mjs new file mode 100644 index 00000000..b19c5f5b --- /dev/null +++ b/packages/harness/test/fixtures/mcp/malformed.mjs @@ -0,0 +1,53 @@ +// Hand-written stdio server sending list pages that stretch the spec; run as `node malformed.mjs`. +// MCP_MALFORMED picks the variant: "null" / "empty" (tools/list ends with nextCursor null / ""), +// "duplicate" (tools/list repeats its cursor), "invalid" (a tool without inputSchema), +// "resources" (well-formed entries with extra fields) or "unnamed" (resources and templates without names). +import { createInterface } from "node:readline"; + +const variant = process.env.MCP_MALFORMED; +const end = variant === "empty" ? "" : null; +const tool = (name) => ({ name, inputSchema: { type: "object" } }); + +const answer = (method, params) => { + switch (method) { + case "initialize": + return { + protocolVersion: params.protocolVersion, + capabilities: variant === "resources" || variant === "unnamed" ? { resources: {} } : { tools: {} }, + serverInfo: { name: "malformed-fixture", version: "1.0.0" }, + }; + case "tools/list": + if (variant === "invalid") return { tools: [tool("good"), { name: "bad" }] }; + if (variant === "duplicate") return { tools: [tool(`t-${params?.cursor ?? "start"}`)], nextCursor: "again" }; + return params?.cursor === "p2" + ? { tools: [tool("second")], nextCursor: end } + : { tools: [tool("first")], nextCursor: "p2" }; + case "resources/list": + return variant === "unnamed" + ? { resources: [{ uri: "file:///a.txt" }] } + : { + resources: [ + { + uri: "file:///a.txt", + name: "a", + description: "A file", + mimeType: "text/plain", + _meta: { origin: "fixture" }, + }, + ], + }; + case "resources/templates/list": + return variant === "unnamed" + ? { resourceTemplates: [{ uriTemplate: "file:///{path}" }] } + : { resourceTemplates: [{ uriTemplate: "file:///{path}", name: "files", description: "Any file" }] }; + default: + return {}; + } +}; + +for await (const line of createInterface({ input: process.stdin, crlfDelay: Infinity })) { + const message = JSON.parse(line); + if (message.id === undefined || message.method === undefined) continue; + const result = answer(message.method, message.params); + process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: message.id, result })}\n`); +} diff --git a/packages/harness/test/fixtures/mcp/server.mjs b/packages/harness/test/fixtures/mcp/server.mjs new file mode 100644 index 00000000..5f01a1d0 --- /dev/null +++ b/packages/harness/test/fixtures/mcp/server.mjs @@ -0,0 +1,33 @@ +// Stdio MCP fixture server built on the SDK; run as `node server.mjs`. +// MCP_FIXTURE picks the variant: unset (tools with listChanged), "plain" (tools without listChanged) +// or "resources" (resources only, paginated, without a templates/list handler). +import { Server } from "@modelcontextprotocol/server"; +import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; +import { install } from "./tools.mjs"; + +const variant = process.env.MCP_FIXTURE; +const capabilities = + variant === "resources" + ? { resources: { listChanged: true } } + : variant === "plain" + ? { tools: {} } + : { tools: { listChanged: true } }; +const server = new Server( + { name: "stdio-fixture", version: "1.0.0" }, + { capabilities, instructions: " Use the echo tool. \n" }, +); + +if (variant === "resources") { + const pages = { + "": { resources: [{ uri: "fixture://one", name: "one" }], nextCursor: "page-2" }, + "page-2": { resources: [{ uri: "fixture://changed", name: "changed" }] }, + }; + server.setRequestHandler("resources/list", (request) => pages[request.params?.cursor ?? ""]); + server.setRequestHandler("resources/read", async (request) => { + const uri = request.params.uri; + if (uri === "fixture://changed") await server.sendResourceListChanged(); + return { contents: [{ uri, mimeType: "text/plain", text: `contents of ${uri}` }] }; + }); +} else install(server); + +await server.connect(new StdioServerTransport()); diff --git a/packages/harness/test/fixtures/mcp/stubborn.mjs b/packages/harness/test/fixtures/mcp/stubborn.mjs new file mode 100644 index 00000000..2d9b5219 --- /dev/null +++ b/packages/harness/test/fixtures/mcp/stubborn.mjs @@ -0,0 +1,23 @@ +// Answers initialize, then ignores stdin EOF and SIGTERM; so does the grandchild it spawns. +// The grandchild pid is reported through the initialize instructions. +import { spawn } from "node:child_process"; +import { createInterface } from "node:readline"; + +process.on("SIGTERM", () => {}); +const grandchild = spawn(process.execPath, ["-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)"], { + stdio: "ignore", +}); +setInterval(() => {}, 1000); + +const lines = createInterface({ input: process.stdin, crlfDelay: Infinity }); +lines.on("line", (line) => { + const message = JSON.parse(line); + if (message.method !== "initialize") return; + const result = { + protocolVersion: message.params.protocolVersion, + capabilities: {}, + serverInfo: { name: "stubborn-fixture", version: "1.0.0" }, + instructions: JSON.stringify({ server: process.pid, grandchild: grandchild.pid }), + }; + process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: message.id, result })}\n`); +}); diff --git a/packages/harness/test/fixtures/mcp/tools.mjs b/packages/harness/test/fixtures/mcp/tools.mjs new file mode 100644 index 00000000..bb23a888 --- /dev/null +++ b/packages/harness/test/fixtures/mcp/tools.mjs @@ -0,0 +1,131 @@ +// Tools shared by the stdio fixture and the in-process streamable HTTP fixture. +import { spawn } from "node:child_process"; + +const PNG = "iVBORw0KGgo="; +const sleep = (ms, signal) => + new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + signal?.addEventListener("abort", () => { + clearTimeout(timer); + resolve(); + }); + }); +const text = (value) => ({ + content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value) }], +}); + +const tools = [ + "echo", + "mixed", + "structured", + "crash", + "progress", + "slow", + "hang", + "cancelled", + "roots", + "capabilities", + "spawn", + "frame", + "exit", + "huge", +] + .map((name) => ({ name, inputSchema: { type: "object" } })) + .concat( + ["typed-missing", "typed-mismatch"].map((name) => ({ + name, + inputSchema: { type: "object" }, + outputSchema: { type: "object", properties: { answer: { type: "number" } }, required: ["answer"] }, + })), + ); + +/** Installs the fixture tools on a low-level SDK `Server`. */ +export const install = (server) => { + const cancelled = []; + + server.setRequestHandler("tools/list", () => ({ tools })); + server.setRequestHandler("tools/call", async (request, ctx) => { + const args = request.params.arguments ?? {}; + const signal = ctx.mcpReq.signal; + switch (request.params.name) { + case "echo": + return text(String(args.text)); + case "mixed": + return { + content: [ + { type: "text", text: "hello" }, + { type: "image", data: PNG, mimeType: "image/png" }, + { + type: "resource", + resource: { uri: "file:///notes.txt", mimeType: "text/plain", text: "embedded notes" }, + }, + { type: "resource", resource: { uri: "file:///pixel.png", mimeType: "image/png", blob: PNG } }, + { type: "resource", resource: { uri: "file:///data.bin", blob: "AAEC" } }, + { type: "audio", data: "AAAA", mimeType: "audio/wav" }, + { type: "resource_link", name: "readme", uri: "file:///README.md" }, + ], + }; + case "structured": + return { content: [], structuredContent: { answer: 42, items: ["a", "b"] } }; + case "crash": + process.stderr.write("fatal: fixture crashed on purpose\n"); + setTimeout(() => process.exit(3), 20); + return new Promise(() => {}); + case "progress": { + const token = request.params._meta?.progressToken; + const ms = Number(args.ms); + const every = Number(args.every); + for (let elapsed = 0, step = 1; elapsed < ms; elapsed += every, step++) { + await sleep(every, signal); + if (token !== undefined) + await ctx.mcpReq.notify({ + method: "notifications/progress", + params: { progressToken: token, progress: step }, + }); + } + return text("progress done"); + } + case "slow": + await sleep(Number(args.ms), signal); + return text("slow done"); + case "hang": + await new Promise((resolve) => + signal.addEventListener("abort", () => { + cancelled.push(String(signal.reason?.message ?? signal.reason ?? "aborted")); + resolve(); + }), + ); + return text("hang aborted"); + case "cancelled": + return text({ cancelled: cancelled.length }); + case "roots": + return text(await server.listRoots()); + case "capabilities": + return text(server.getClientCapabilities() ?? {}); + case "spawn": { + const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" }); + return text({ server: process.pid, grandchild: child.pid }); + } + case "frame": + // An unparsable line, then a CRLF-terminated notification the client must still see. + process.stdout.write("this is not json\n"); + process.stdout.write( + `${JSON.stringify({ jsonrpc: "2.0", method: "notifications/tools/list_changed" })}\r\n`, + ); + return text("framed"); + case "exit": + setTimeout(() => process.exit(0), 20); + return text("bye"); + case "huge": + // One 17 MiB line that never ends: more than the client's 16 MiB frame limit. + process.stdout.write("x".repeat(17 * 1024 * 1024)); + return new Promise(() => {}); + case "typed-missing": + return text("no structured content"); + case "typed-mismatch": + return { content: [{ type: "text", text: "wrong shape" }], structuredContent: { answer: "nope" } }; + default: + throw new Error(`unknown tool ${request.params.name}`); + } + }); +}; diff --git a/packages/harness/test/mcp.client.test.ts b/packages/harness/test/mcp.client.test.ts new file mode 100644 index 00000000..b1b17642 --- /dev/null +++ b/packages/harness/test/mcp.client.test.ts @@ -0,0 +1,624 @@ +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { fileURLToPath } from "node:url"; +import type { OAuthClientProvider } from "@modelcontextprotocol/client"; +import { Deferred, Effect, Exit, Fiber, Option, Ref } from "effect"; +import { describe, expect, vi } from "vite-plus/test"; +import { McpClient } from "../src/mcp/client.ts"; +import { McpContent } from "../src/mcp/content.ts"; +import { Process } from "../src/sandbox/utils/process.ts"; +import { listen, raw, sdk } from "./fixtures/mcp/http.ts"; +import { testEffect } from "./utils/effect.ts"; + +const { live: it } = testEffect(Process.host); + +const fixtures = fileURLToPath(new URL("./fixtures/mcp/", import.meta.url)); +const stdio = (fixture: string, env: Record = {}): McpClient.Stdio => ({ + _tag: "Stdio", + command: process.execPath, + args: [`${fixtures}${fixture}`], + env, + cwd: fixtures, +}); +const http = (url: URL): McpClient.Http => ({ _tag: "Http", url, headers: {} }); + +/** Each scenario's observable outcome, written to the artifact by the last test. */ +const summary: Record = {}; + +const normalise = (message: string) => + message + .replaceAll(fixtures, "/") + .replaceAll(process.execPath, "") + .replace(/127\.0\.0\.1:\d+/g, "127.0.0.1:") + .replace(/\b\d+(\.\d+)?\s?ms\b/g, ""); + +const failure = (error: { readonly _tag: string; readonly message: string }) => ({ + tag: error._tag, + message: normalise(error.message), +}); + +const text = (result: { readonly content?: ReadonlyArray<{ readonly type: string; readonly text?: string }> }) => + result.content?.[0]?.text ?? ""; + +const alive = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +// Orphans are reaped by init shortly after their group dies. +const gone = (pids: ReadonlyArray) => + Effect.gen(function* () { + for (let attempt = 0; attempt < 50 && pids.some(alive); attempt++) yield* Effect.sleep("20 millis"); + return pids.filter(alive).length === 0; + }); + +describe("mcp client", () => { + it( + "stdio: connects, lists tools and converts every content kind", + Effect.gen(function* () { + const connection = yield* McpClient.connect({ server: "fixture", endpoint: stdio("server.mjs") }); + const tools = yield* connection.tools(); + const echo = yield* connection.callTool({ name: "echo", args: { text: "hi" } }); + const mixed = yield* connection.callTool({ name: "mixed" }); + const structured = yield* connection.callTool({ name: "structured" }); + summary.stdioHappy = { + serverInfo: connection.serverInfo, + instructions: connection.instructions, + tools: tools.map((tool) => tool.name), + echo: McpContent.fromResult(echo), + mixed: McpContent.fromResult(mixed), + structured: McpContent.fromResult(structured), + }; + expect(connection.instructions).toBe("Use the echo tool."); + expect(McpContent.fromResult(echo)).toEqual([{ type: "text", text: "hi" }]); + expect(McpContent.fromResult(mixed)).toEqual([ + { type: "text", text: "hello" }, + { type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" }, + { type: "text", text: "embedded notes" }, + { type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" }, + { type: "text", text: "[binary resource file:///data.bin (unknown type) omitted]" }, + { type: "text", text: "[audio audio/wav omitted]" }, + { type: "text", text: "readme: file:///README.md" }, + ]); + expect(McpContent.fromResult(structured)).toEqual([ + { type: "text", text: JSON.stringify({ answer: 42, items: ["a", "b"] }, null, 2) }, + ]); + }), + ); + + it( + "http: connects to a real streamable HTTP server and calls a tool", + Effect.gen(function* () { + const url = yield* listen(yield* Effect.promise(sdk)); + const connection = yield* McpClient.connect({ server: "http", endpoint: http(url) }); + const tools = yield* connection.tools(); + const echo = yield* connection.callTool({ name: "echo", args: { text: "over http" } }); + summary.httpHappy = { + serverInfo: connection.serverInfo, + tools: tools.map((tool) => tool.name), + echo: McpContent.fromResult(echo), + }; + expect(text(echo)).toBe("over http"); + }), + ); + + it( + "stdio: a crash mid-call fails the pending call with the exit code and stderr, and reports close", + Effect.gen(function* () { + const connection = yield* McpClient.connect({ server: "fixture", endpoint: stdio("server.mjs") }); + const closed = yield* Deferred.make(); + connection.onClose((reason) => Deferred.succeed(closed, reason).pipe(Effect.asVoid)); + const error = yield* connection.callTool({ name: "crash" }).pipe(Effect.flip); + const reason = yield* Deferred.await(closed).pipe(Effect.timeout("2 seconds")); + summary.crash = { error: failure(error), method: "method" in error ? error.method : undefined, reason }; + expect(error._tag).toBe("Mcp.RequestError"); + expect(error.message).toContain("exited with code 3"); + expect(error.message).toContain("fatal: fixture crashed on purpose"); + expect(reason).toContain("connection closed"); + }), + ); + + it( + "stdio: closing the scope kills the server and its grandchild", + Effect.gen(function* () { + const pids = yield* Effect.gen(function* () { + const connection = yield* McpClient.connect({ server: "fixture", endpoint: stdio("server.mjs") }); + const spawned = JSON.parse(text(yield* connection.callTool({ name: "spawn" }))) as { + readonly server: number; + readonly grandchild: number; + }; + expect(alive(spawned.server) && alive(spawned.grandchild)).toBe(true); + return [spawned.server, spawned.grandchild]; + }).pipe(Effect.scoped); + const killed = yield* gone(pids); + summary.processGroup = { killed }; + expect(killed).toBe(true); + }), + ); + + it( + "stdio: a server ignoring SIGTERM is still killed within a bounded time", + Effect.gen(function* () { + const started = Date.now(); + const pids = yield* Effect.gen(function* () { + const connection = yield* McpClient.connect({ server: "stubborn", endpoint: stdio("stubborn.mjs") }); + const spawned = JSON.parse(connection.instructions ?? "{}") as { + readonly server: number; + readonly grandchild: number; + }; + return [spawned.server, spawned.grandchild]; + }).pipe(Effect.scoped); + const elapsed = Date.now() - started; + const killed = yield* gone(pids); + summary.stubborn = { killed, boundedUnder8s: elapsed < 8_000 }; + expect(elapsed).toBeLessThan(8_000); + expect(killed).toBe(true); + }), + 15_000, + ); + + it( + "stdio: skips an invalid JSON line and parses a CRLF-terminated message", + Effect.gen(function* () { + const connection = yield* McpClient.connect({ server: "fixture", endpoint: stdio("server.mjs") }); + const changed = yield* Deferred.make(); + connection.onToolsChanged(Deferred.succeed(changed, undefined).pipe(Effect.asVoid)); + const framed = yield* connection.callTool({ name: "frame" }); + yield* Deferred.await(changed).pipe(Effect.timeout("2 seconds")); + const after = yield* connection.callTool({ name: "echo", args: { text: "still alive" } }); + summary.framing = { framed: text(framed), toolsChanged: true, after: text(after) }; + expect(text(after)).toBe("still alive"); + }), + ); + + it( + "stdio: progress keeps a long call alive past the request timeout; silence times out", + Effect.gen(function* () { + const connection = yield* McpClient.connect({ + server: "fixture", + endpoint: stdio("server.mjs"), + timeout: { request: "1 second" }, + }); + let updates = 0; + const progressed = yield* connection.callTool({ + name: "progress", + args: { ms: 2_000, every: 200 }, + onProgress: () => { + updates++; + }, + }); + const count = updates; + const silent = yield* connection.callTool({ name: "slow", args: { ms: 2_000 } }).pipe(Effect.flip); + summary.progress = { progressed: text(progressed), sawProgress: count > 0, silent: failure(silent) }; + expect(text(progressed)).toBe("progress done"); + expect(count).toBeGreaterThan(3); + expect(silent._tag).toBe("Mcp.RequestError"); + expect(silent.message).toContain("timed out"); + }), + ); + + it( + "stdio: interrupting a call cancels it on the server and keeps the connection usable", + Effect.gen(function* () { + const connection = yield* McpClient.connect({ server: "fixture", endpoint: stdio("server.mjs") }); + const fiber = yield* connection.callTool({ name: "hang" }).pipe(Effect.forkChild); + yield* Effect.sleep("200 millis"); + yield* Fiber.interrupt(fiber); + const report = JSON.parse(text(yield* connection.callTool({ name: "cancelled" }))) as { + readonly cancelled: number; + }; + const echo = yield* connection.callTool({ name: "echo", args: { text: "after cancel" } }); + summary.cancel = { cancelled: report.cancelled, after: text(echo) }; + expect(report.cancelled).toBe(1); + expect(text(echo)).toBe("after cancel"); + }), + ); + + it( + "http: a transient 503 on initialize is retried", + Effect.gen(function* () { + const server = raw({ failInitialize: 1 }); + const url = yield* listen(server.handler); + const connection = yield* McpClient.connect({ server: "raw", endpoint: http(url) }); + const tools = yield* connection.tools(); + const initializes = server.seen.filter((entry) => entry === "POST initialize").length; + summary.retry = { initializes, tools: tools.map((tool) => tool.name) }; + expect(initializes).toBe(2); + }), + ); + + it( + "http: a 404 for a known session fails with SessionExpiredError and reports once", + Effect.gen(function* () { + const server = raw(); + const url = yield* listen(server.handler); + const expired = yield* Ref.make(0); + const outcome = yield* Effect.gen(function* () { + const connection = yield* McpClient.connect({ server: "raw", endpoint: http(url) }); + connection.onSessionExpired(Ref.update(expired, (count) => count + 1)); + server.forget = true; + const first = yield* connection.tools().pipe(Effect.flip); + const second = yield* connection.tools().pipe(Effect.flip); + return [failure(first), failure(second)]; + }).pipe(Effect.scoped); + yield* Effect.sleep("50 millis"); + const reported = yield* Ref.get(expired); + const terminated = server.seen.includes("DELETE"); + summary.sessionExpired = { errors: outcome, reported, terminated }; + expect(outcome.map((error) => error.tag)).toEqual(["Mcp.SessionExpiredError", "Mcp.SessionExpiredError"]); + expect(reported).toBe(1); + expect(terminated).toBe(false); + }), + ); + + it( + "stdio: roots are served as file URIs only when passed", + Effect.gen(function* () { + const withRoots = yield* Effect.gen(function* () { + const connection = yield* McpClient.connect({ + server: "fixture", + endpoint: stdio("server.mjs"), + roots: ["/workspace/project", "/srv/data dir"], + }); + return { + roots: JSON.parse(text(yield* connection.callTool({ name: "roots" }))) as unknown, + capabilities: JSON.parse(text(yield* connection.callTool({ name: "capabilities" }))) as unknown, + }; + }).pipe(Effect.scoped); + const without = yield* Effect.gen(function* () { + const connection = yield* McpClient.connect({ server: "fixture", endpoint: stdio("server.mjs") }); + return JSON.parse(text(yield* connection.callTool({ name: "capabilities" }))) as Record; + }).pipe(Effect.scoped); + summary.roots = { withRoots, withoutRoots: { capabilities: without } }; + expect(withRoots.roots).toEqual({ + roots: [ + { uri: "file:///workspace/project", name: "project" }, + { uri: "file:///srv/data%20dir", name: "data dir" }, + ], + }); + expect(without).not.toHaveProperty("roots"); + }), + ); + + it( + "stdio: a missing command fails with ConnectError", + Effect.gen(function* () { + const error = yield* McpClient.connect({ + server: "missing", + endpoint: { ...stdio("server.mjs"), command: "codework-mcp-missing-binary" }, + timeout: { startup: "5 seconds" }, + }).pipe(Effect.flip); + summary.spawnFailure = failure(error); + expect(error._tag).toBe("Mcp.ConnectError"); + expect(error.message).toBe("command not found: codework-mcp-missing-binary"); + }), + ); + + it( + "onClose: silent on a deliberate scope close, immediate when the server already exited", + Effect.gen(function* () { + const deliberate = yield* Ref.make>([]); + yield* Effect.gen(function* () { + const connection = yield* McpClient.connect({ server: "fixture", endpoint: stdio("server.mjs") }); + connection.onClose((reason) => Ref.update(deliberate, (reasons) => [...reasons, reason])); + yield* connection.tools(); + }).pipe(Effect.scoped); + yield* Effect.sleep("300 millis"); + const afterScope = yield* Ref.get(deliberate); + + const connection = yield* McpClient.connect({ server: "fixture", endpoint: stdio("server.mjs") }); + const spawned = JSON.parse(text(yield* connection.callTool({ name: "spawn" }))) as { readonly server: number }; + yield* connection.callTool({ name: "exit" }); + expect(yield* gone([spawned.server])).toBe(true); + // Requests fail once the transport is closed; then give the close a moment to settle. + for (let attempt = 0; attempt < 50; attempt++) { + if ((yield* connection.tools().pipe(Effect.exit))._tag === "Failure") break; + yield* Effect.sleep("20 millis"); + } + yield* Effect.sleep("300 millis"); + const late = yield* Deferred.make(); + connection.onClose((reason) => Deferred.succeed(late, reason).pipe(Effect.asVoid)); + const reason = yield* Deferred.await(late).pipe(Effect.timeout("500 millis")); + summary.onClose = { afterDeliberateClose: afterScope, afterServerExit: normalise(reason) }; + expect(afterScope).toEqual([]); + expect(reason).toContain("connection closed"); + }), + ); + + it( + "callTool: results are validated against the tool's output schema", + Effect.gen(function* () { + const connection = yield* McpClient.connect({ server: "fixture", endpoint: stdio("server.mjs") }); + // Listing caches the output schemas the SDK validates results against. + const tools = yield* connection.tools(); + const missing = yield* connection.callTool({ name: "typed-missing" }).pipe(Effect.flip); + const mismatch = yield* connection.callTool({ name: "typed-mismatch" }).pipe(Effect.flip); + summary.outputSchema = { + declared: tools.filter((tool) => tool.outputSchema !== undefined).map((tool) => tool.name), + missing: failure(missing), + mismatch: failure(mismatch), + }; + expect(missing._tag).toBe("Mcp.RequestError"); + expect(mismatch._tag).toBe("Mcp.RequestError"); + }), + ); + + it( + "http: a refused connection fails with ECONNREFUSED after retrying", + Effect.gen(function* () { + const port = yield* Effect.callback((resume) => { + const server = createServer(); + server.listen(0, "127.0.0.1", () => { + const { port } = server.address() as AddressInfo; + server.close(() => resume(Effect.succeed(port))); + }); + }); + const started = Date.now(); + const error = yield* McpClient.connect({ + server: "refused", + endpoint: http(new URL(`http://127.0.0.1:${port}/mcp`)), + timeout: { startup: "5 seconds" }, + }).pipe(Effect.flip); + const elapsed = Date.now() - started; + summary.refused = { error: failure(error), retried: elapsed >= 1_200 }; + expect(error._tag).toBe("Mcp.ConnectError"); + expect(error.message).toContain("ECONNREFUSED"); + expect(elapsed).toBeGreaterThanOrEqual(1_200); + expect(elapsed).toBeLessThan(5_000); + }), + ); + + it( + "resources: paginated pages, missing templates handler, reads and list_changed", + Effect.gen(function* () { + const connection = yield* McpClient.connect({ + server: "resources", + endpoint: stdio("server.mjs", { MCP_FIXTURE: "resources" }), + }); + const changed = yield* Deferred.make(); + connection.onResourcesChanged(Deferred.succeed(changed, undefined).pipe(Effect.asVoid)); + const templates = yield* connection.resourceTemplates(); + const first = yield* connection.resources(); + const second = yield* connection.resources(first.nextCursor); + const read = yield* connection.readResource("fixture://one"); + yield* connection.readResource("fixture://changed"); + yield* Deferred.await(changed).pipe(Effect.timeout("2 seconds")); + summary.resources = { templates, first, second, read, resourcesChanged: true }; + expect(templates).toEqual({ resourceTemplates: [] }); + expect(first).toEqual({ resources: [{ uri: "fixture://one", name: "one" }], nextCursor: "page-2" }); + expect(second).toEqual({ resources: [{ uri: "fixture://changed", name: "changed" }] }); + expect(read.contents).toEqual([ + { uri: "fixture://one", mimeType: "text/plain", text: "contents of fixture://one" }, + ]); + }), + ); + + it( + "tools: a server without the tools capability lists none and prints nothing", + Effect.gen(function* () { + const connection = yield* McpClient.connect({ + server: "resources", + endpoint: stdio("server.mjs", { MCP_FIXTURE: "resources" }), + }); + const write = vi.spyOn(process.stdout, "write"); + const debug = vi.spyOn(console, "debug"); + const tools = yield* connection.tools().pipe( + Effect.ensuring( + Effect.sync(() => { + write.mockRestore(); + debug.mockRestore(); + }), + ), + ); + const printed = write.mock.calls.length + debug.mock.calls.length; + summary.noToolsCapability = { tools, printed }; + expect(tools).toEqual([]); + expect(printed).toBe(0); + }), + ); + + it( + "tools: list_changed from a server without tools.listChanged still reaches onToolsChanged", + Effect.gen(function* () { + const connection = yield* McpClient.connect({ + server: "plain", + endpoint: stdio("server.mjs", { MCP_FIXTURE: "plain" }), + }); + const changed = yield* Deferred.make(); + connection.onToolsChanged(Deferred.succeed(changed, undefined).pipe(Effect.asVoid)); + yield* connection.callTool({ name: "frame" }); + yield* Deferred.await(changed).pipe(Effect.timeout("2 seconds")); + summary.unadvertisedListChanged = { listChanged: connection.capabilities?.tools, toolsChanged: true }; + expect(connection.capabilities?.tools).toEqual({}); + }), + ); + + it( + "stdio: a frame over 16 MiB fails the pending call and explains the close", + Effect.gen(function* () { + const connection = yield* McpClient.connect({ server: "fixture", endpoint: stdio("server.mjs") }); + const closed = yield* Deferred.make(); + connection.onClose((reason) => Deferred.succeed(closed, reason).pipe(Effect.asVoid)); + const error = yield* connection.callTool({ name: "huge" }).pipe(Effect.flip); + const reason = yield* Deferred.await(closed).pipe(Effect.timeout("5 seconds")); + summary.frameLimit = { error: failure(error), reason: normalise(reason) }; + expect(error._tag).toBe("Mcp.RequestError"); + expect(error.message).toContain("frame exceeded 16 MiB"); + expect(error.message).not.toContain("process exited"); + expect(reason).toContain("frame exceeded 16 MiB"); + }), + 15_000, + ); + + it( + "http: a 401 needs auth with an auth provider and is a ConnectError without one", + Effect.gen(function* () { + const server = raw({ unauthorized: true }); + const url = yield* listen(server.handler); + const redirects: string[] = []; + const authProvider: OAuthClientProvider = { + redirectUrl: "http://127.0.0.1/callback", + clientMetadata: { redirect_uris: ["http://127.0.0.1/callback"], client_name: "codework-test" }, + clientInformation: () => ({ client_id: "codework-test" }), + tokens: () => undefined, + saveTokens: () => {}, + redirectToAuthorization: (authorizationUrl) => { + redirects.push(authorizationUrl.pathname); + }, + saveCodeVerifier: () => {}, + codeVerifier: () => "verifier", + }; + const withProvider = yield* McpClient.connect({ server: "oauth", endpoint: http(url), authProvider }).pipe( + Effect.flip, + ); + const without = yield* McpClient.connect({ server: "oauth", endpoint: http(url) }).pipe(Effect.flip); + summary.unauthorized = { withProvider: failure(withProvider), redirects, without: failure(without) }; + expect(withProvider._tag).toBe("Mcp.NeedsAuthError"); + expect(without._tag).toBe("Mcp.ConnectError"); + expect(without.message).toContain("HTTP 401"); + }), + ); + + it( + "http: a retried connect leaves no stale close reason; a deliberate close stays silent", + Effect.gen(function* () { + const server = raw({ failInitialize: 1 }); + const url = yield* listen(server.handler); + const reasons = yield* Ref.make>([]); + const connected = yield* Effect.gen(function* () { + const connection = yield* McpClient.connect({ server: "raw", endpoint: http(url) }); + connection.onClose((reason) => Ref.update(reasons, (all) => [...all, reason])); + yield* Effect.sleep("300 millis"); + const afterRegister = yield* Ref.get(reasons); + const tools = yield* connection.tools(); + return { afterRegister, tools: tools.map((tool) => tool.name) }; + }).pipe(Effect.scoped); + yield* Effect.sleep("300 millis"); + const afterScope = yield* Ref.get(reasons); + const initializes = server.seen.filter((entry) => entry === "POST initialize").length; + summary.retryCloseReason = { initializes, ...connected, afterScope }; + expect(initializes).toBe(2); + expect(connected.afterRegister).toEqual([]); + expect(afterScope).toEqual([]); + }), + ); + + it( + "stdio: a missing working directory fails with ConnectError", + Effect.gen(function* () { + const error = yield* McpClient.connect({ + server: "missing-cwd", + endpoint: { ...stdio("server.mjs"), cwd: `${fixtures}missing-dir` }, + timeout: { startup: "5 seconds" }, + }).pipe(Effect.flip); + summary.missingCwd = failure(error); + expect(error._tag).toBe("Mcp.ConnectError"); + expect(error.message).toBe(`working directory not found: ${fixtures}missing-dir`); + }), + ); + + it( + "tools/list: strict pagination for null, empty and repeated cursors", + Effect.gen(function* () { + const list = (variant: string) => + Effect.gen(function* () { + const connection = yield* McpClient.connect({ + server: "malformed", + endpoint: stdio("malformed.mjs", { MCP_MALFORMED: variant }), + timeout: { request: "5 seconds" }, + }); + const started = Date.now(); + const exit = yield* connection.tools().pipe(Effect.exit); + return { exit, elapsed: Date.now() - started }; + }).pipe(Effect.scoped); + const outcome = ({ + exit, + }: { + readonly exit: Exit.Exit< + ReadonlyArray<{ readonly name: string }>, + { readonly _tag: string; readonly message: string } + >; + }) => + Exit.isSuccess(exit) + ? { tools: exit.value.map((tool) => tool.name) } + : { error: Exit.findErrorOption(exit).pipe(Option.map(failure), Option.getOrUndefined) }; + const nullCursor = yield* list("null"); + const emptyCursor = yield* list("empty"); + const duplicate = yield* list("duplicate"); + summary.toolsPagination = { + nullCursor: outcome(nullCursor), + emptyCursor: outcome(emptyCursor), + duplicate: outcome(duplicate), + }; + expect(outcome(nullCursor).error?.tag).toBe("Mcp.RequestError"); + // An empty cursor is followed as a real one, so the pages cycle until the SDK's cap. + expect(outcome(emptyCursor).error?.message).toContain("exceeded listMaxPages"); + // The SDK stops quietly at a repeated cursor and returns the pages it has. + expect(outcome(duplicate)).toEqual({ tools: ["t-start", "t-again"] }); + for (const run of [emptyCursor, duplicate]) expect(run.elapsed).toBeLessThan(5_000); + }), + ); + + it( + "resources: well-formed entries keep extra fields; entries without a name fail", + Effect.gen(function* () { + const valid = yield* Effect.gen(function* () { + const connection = yield* McpClient.connect({ + server: "malformed", + endpoint: stdio("malformed.mjs", { MCP_MALFORMED: "resources" }), + }); + return { resources: yield* connection.resources(), templates: yield* connection.resourceTemplates() }; + }).pipe(Effect.scoped); + const unnamed = yield* Effect.gen(function* () { + const connection = yield* McpClient.connect({ + server: "malformed", + endpoint: stdio("malformed.mjs", { MCP_MALFORMED: "unnamed" }), + }); + return { + resources: yield* connection.resources().pipe(Effect.flip), + templates: yield* connection.resourceTemplates().pipe(Effect.flip), + }; + }).pipe(Effect.scoped); + summary.strictResources = { + valid, + unnamed: { resources: failure(unnamed.resources), templates: failure(unnamed.templates) }, + }; + expect(valid.resources.resources).toEqual([ + { + uri: "file:///a.txt", + name: "a", + description: "A file", + mimeType: "text/plain", + _meta: { origin: "fixture" }, + }, + ]); + expect(valid.templates.resourceTemplates).toEqual([ + { uriTemplate: "file:///{path}", name: "files", description: "Any file" }, + ]); + expect(unnamed.resources._tag).toBe("Mcp.RequestError"); + expect(unnamed.templates._tag).toBe("Mcp.RequestError"); + }), + ); + + it( + "tools/list: an invalid entry fails with RequestError", + Effect.gen(function* () { + const connection = yield* McpClient.connect({ + server: "malformed", + endpoint: stdio("malformed.mjs", { MCP_MALFORMED: "invalid" }), + }); + const error = yield* connection.tools().pipe(Effect.flip); + summary.invalidEntry = failure(error); + expect(error._tag).toBe("Mcp.RequestError"); + }), + ); + + it( + "writes the scenario artifact", + Effect.promise(() => expect(summary).toMatchFileSnapshot("./__artifacts__/mcp.client.json")), + ); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 35980607..423b4e50 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -608,6 +608,9 @@ importers: '@effect/sql-sqlite-node': specifier: 4.0.5 version: 4.0.5(effect@4.0.5) + '@modelcontextprotocol/client': + specifier: 2.3.1 + version: 2.3.1 '@npmcli/arborist': specifier: ^10.0.2 version: 10.0.4(supports-color@7.2.0) @@ -651,6 +654,9 @@ importers: '@daytona/sdk': specifier: ^0.211.2 version: 0.211.2(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0) + '@modelcontextprotocol/server': + specifier: 2.3.1 + version: 2.3.1 '@types/node': specifier: 'catalog:' version: 26.6.5 @@ -2098,14 +2104,26 @@ packages: '@mixmark-io/domino@2.2.0': resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} + '@modelcontextprotocol/client@2.3.1': + resolution: {integrity: sha512-mIGZXpHsjnZ6lD+gD/WCMpR5k8yVQQ8nNFH1N0Srf7AvnwTUMYD6pvTY8ng2+He5FfV7YMZLmrjL7cLa/cc3dQ==} + engines: {node: '>=20'} + '@modelcontextprotocol/core@2.0.0': resolution: {integrity: sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==} engines: {node: '>=20'} + '@modelcontextprotocol/core@2.3.1': + resolution: {integrity: sha512-laVmIhPGpWi7rG9q+0UigblRN0Hws/Yjlb6qLFtVqryXVLkc4h+yN8z731POD2LnAGQlFe7/tXhUm7Bi0YB91g==} + engines: {node: '>=20'} + '@modelcontextprotocol/server@2.0.0': resolution: {integrity: sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==} engines: {node: '>=20'} + '@modelcontextprotocol/server@2.3.1': + resolution: {integrity: sha512-e59MfWuSssj6DoQ96JCTqdW1AXT5/XtnTl0WWd6MU1Ka434fFUGGosCLJbGNySDhHGvxQWoRWK137FXYFkYNWw==} + engines: {node: '>=20'} + '@mongodb-js/zstd@7.0.0': resolution: {integrity: sha512-mQ2s0pYYiav+tzCDR05Zptem8Ey2v8s11lri5RKGhTtL4COVCvVCk5vtyRYNT+9L8qSfyOqqefF9UtnW8mC5jA==} engines: {node: '>= 20.19.0'} @@ -5810,6 +5828,10 @@ packages: resolution: {integrity: sha512-N9b0EvATDJfZj+Szg3m8WwqseQhei/mtTd6V0rCciGbI/yG24o3yBk0YVyfOZZsLVMZHWs++Zk3Aqd2y2EztnQ==} engines: {node: '>=22.12'} + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -7369,6 +7391,10 @@ packages: resolution: {integrity: sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg==} hasBin: true + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkijs@3.4.1: resolution: {integrity: sha512-Oo/NZcSWccq8KyoG7gLE9fnltgHns+pNCjCAp/WmjsUySi+sX7y4z4Xqu4fVb42CDHzRPl33fjzT15V1wvcyhA==} engines: {node: '>=16.0.0'} @@ -10383,15 +10409,34 @@ snapshots: '@mixmark-io/domino@2.2.0': {} + '@modelcontextprotocol/client@2.3.1': + dependencies: + '@modelcontextprotocol/core': 2.3.1 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + jose: 6.2.3 + pkce-challenge: 5.0.1 + zod: 4.6.5 + '@modelcontextprotocol/core@2.0.0': dependencies: zod: 4.6.5 + '@modelcontextprotocol/core@2.3.1': + dependencies: + zod: 4.6.5 + '@modelcontextprotocol/server@2.0.0': dependencies: '@modelcontextprotocol/core': 2.0.0 zod: 4.6.5 + '@modelcontextprotocol/server@2.3.1': + dependencies: + '@modelcontextprotocol/core': 2.3.1 + zod: 4.6.5 + '@mongodb-js/zstd@7.0.0': dependencies: node-addon-api: 8.9.2 @@ -14089,6 +14134,10 @@ snapshots: eventsource-parser@4.1.2: {} + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.1 + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -16043,6 +16092,8 @@ snapshots: dependencies: pngjs: 7.0.0 + pkce-challenge@5.0.1: {} + pkijs@3.4.1: dependencies: '@noble/hashes': 1.8.0 From 1f048fe87c0537541ed4f7e6bf8c58c68c22007b Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Wed, 14 Oct 2026 21:30:07 +0530 Subject: [PATCH 02/17] feat(harness): log MCP server messages and condense validation errors Connections pass the server's notifications/message entries to an onLog handler, and McpLog appends them to a file with one line per entry, rotating it past 5 MB. A result that fails the SDK's schema is reported on one line as `Invalid result for : : ` instead of the validator's multi-line issue list, which is still logged at debug level. Co-Authored-By: Claude Opus 5.5 --- packages/harness/src/mcp/client.ts | 37 ++++++++ packages/harness/src/mcp/log.ts | 57 ++++++++++++ .../test/__artifacts__/mcp.client.json | 78 +++++++--------- packages/harness/test/fixtures/mcp/server.mjs | 2 +- packages/harness/test/fixtures/mcp/tools.mjs | 5 ++ packages/harness/test/mcp.client.test.ts | 90 +++++++++++++++++-- 6 files changed, 217 insertions(+), 52 deletions(-) create mode 100644 packages/harness/src/mcp/log.ts diff --git a/packages/harness/src/mcp/client.ts b/packages/harness/src/mcp/client.ts index cc4a7f45..2687479d 100644 --- a/packages/harness/src/mcp/client.ts +++ b/packages/harness/src/mcp/client.ts @@ -8,6 +8,7 @@ import { ProtocolErrorCode, type ListResourcesResult, type ListResourceTemplatesResult, + type LoggingMessageNotificationParams, type ReadResourceResult, SdkError, SdkErrorCode, @@ -72,6 +73,8 @@ export interface Connection { readonly onToolsChanged: (handler: Effect.Effect) => void; readonly onResourcesChanged: (handler: Effect.Effect) => void; readonly onSessionExpired: (handler: Effect.Effect) => void; + /** The server's `notifications/message` log entries. */ + readonly onLog: (handler: (entry: LoggingMessageNotificationParams) => Effect.Effect) => void; /** * The connection dropped (for example the server process exited). Runs right away when it already * dropped before the handler was set. Never runs for a close caused by closing the owning scope. @@ -101,9 +104,33 @@ const systemCode = (error: Error): string | undefined => { return typeof code === "string" ? code : undefined; }; +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +// The SDK reports a result that fails its schema as `Invalid result for : `, the issues +// being the validator's multi-line JSON list. One `path: message` per issue reads better. +const condense = (message: string) => { + const match = /^(Invalid result for [^:]+): (\[[\s\S]*\])$/.exec(message); + if (!match) return message; + try { + const issues: unknown = JSON.parse(match[2]!); + if (!Array.isArray(issues) || issues.length === 0) return message; + const parts = issues.map((issue) => { + if (!isRecord(issue)) return String(issue); + const path = Array.isArray(issue.path) && issue.path.length > 0 ? `${issue.path.join(".")}: ` : ""; + return `${path}${typeof issue.message === "string" ? issue.message : "invalid"}`; + }); + return `${match[1]}: ${parts.join("; ")}`; + } catch { + return message; + } +}; +const isInvalidResult = (error: unknown) => error instanceof SdkError && error.code === SdkErrorCode.InvalidResult; + // HTTP statuses and network error codes live on error properties and are lost once flattened to a message. const describe = (error: unknown) => { if (!(error instanceof Error)) return String(error); + if (isInvalidResult(error)) return condense(error.message); const detail = error instanceof SdkHttpError ? `HTTP ${error.status}` @@ -132,6 +159,7 @@ export const connect = Effect.fnUntraced(function* (input: ConnectInput) { tools: Effect.void as Effect.Effect, resources: Effect.void as Effect.Effect, expired: Effect.void as Effect.Effect, + log: (_entry: LoggingMessageNotificationParams): Effect.Effect => Effect.void, }; // Every connect attempt builds its own client; only the one that connected reports a close. const closed: { @@ -198,6 +226,9 @@ export const connect = Effect.fnUntraced(function* (input: ConnectInput) { client.setNotificationHandler("notifications/resources/list_changed", () => { runFork(handlers.resources); }); + client.setNotificationHandler("notifications/message", (notification) => { + runFork(handlers.log(notification.params)); + }); const roots = input.roots; if (roots) { client.setRequestHandler("roots/list", () => ({ @@ -222,6 +253,9 @@ export const connect = Effect.fnUntraced(function* (input: ConnectInput) { (method: string) => (error: unknown): Failed => { if (needsAuth(error)) return new NeedsAuthError({ server, message: describe(error) }); + // The condensed message drops the validator's raw issue list; keep it for debugging. + if (isInvalidResult(error)) + runFork(Effect.logDebug("mcp invalid result", { server, method, error: (error as Error).message })); const request = () => new RequestError({ server, method, message: explain(error) }); if (!(error instanceof SdkHttpError) || session.transport?.sessionId === undefined) return request(); const text = (error.data as { readonly text?: unknown }).text; @@ -384,6 +418,9 @@ export const connect = Effect.fnUntraced(function* (input: ConnectInput) { onSessionExpired: (handler) => { handlers.expired = handler; }, + onLog: (handler) => { + handlers.log = handler; + }, onClose: (handler) => { closed.handler = handler; if (closed.reason !== undefined) runFork(handler(closed.reason)); diff --git a/packages/harness/src/mcp/log.ts b/packages/harness/src/mcp/log.ts new file mode 100644 index 00000000..c1d22b71 --- /dev/null +++ b/packages/harness/src/mcp/log.ts @@ -0,0 +1,57 @@ +import type { LoggingMessageNotificationParams } from "@modelcontextprotocol/client"; +import { DateTime, Effect, Semaphore } from "effect"; +import { fileSystem, hostPath } from "../host.ts"; + +const MAX_LOG_BYTES = 5 * 1024 * 1024; + +const formatData = (data: unknown): string => { + if (typeof data === "string") return data; + try { + return JSON.stringify(data) ?? String(data); + } catch { + return String(data); + } +}; + +/** One `notifications/message` from `server` as a log line; continuation lines are indented. */ +export const format = (server: string, params: LoggingMessageNotificationParams, now: DateTime.Utc): string => { + const logger = params.logger ? ` ${params.logger}:` : ""; + const text = formatData(params.data).replace(/\r?\n/g, "\n "); + return `${DateTime.formatIso(now)} [${server}] ${params.level}${logger} ${text}\n`; +}; + +/** + * Appends server log messages to one file, rotated to `.1` once it grows past 5 MB. Several + * processes may write to the same file, so the size is re-read before rotating. Write errors are + * ignored: logging must not break tools. + */ +export const make = Effect.fnUntraced(function* (path: string) { + const lock = yield* Semaphore.make(1); + let size: number | undefined; + const current = fileSystem.stat(path).pipe( + Effect.map((info) => Number(info.size)), + Effect.orElseSucceed(() => 0), + ); + + const write = (server: string, params: LoggingMessageNotificationParams) => + lock.withPermits(1)( + Effect.gen(function* () { + const line = format(server, params, yield* DateTime.now); + if (size === undefined) { + yield* fileSystem.makeDirectory(hostPath.dirname(path), { recursive: true }); + size = yield* current; + } + if (size > MAX_LOG_BYTES) { + // Another process may have rotated it already; check before renaming. + if ((yield* current) > MAX_LOG_BYTES) yield* fileSystem.rename(path, `${path}.1`); + size = yield* current; + } + yield* fileSystem.writeFileString(path, line, { flag: "a" }); + size += Buffer.byteLength(line); + }).pipe(Effect.ignore), + ); + + return { path, write }; +}); + +export * as McpLog from "./log.ts"; diff --git a/packages/harness/test/__artifacts__/mcp.client.json b/packages/harness/test/__artifacts__/mcp.client.json index 45b4f025..bcb2463e 100644 --- a/packages/harness/test/__artifacts__/mcp.client.json +++ b/packages/harness/test/__artifacts__/mcp.client.json @@ -49,25 +49,26 @@ "frame", "exit", "huge", + "log", "typed-missing", "typed-mismatch", ], }, "invalidEntry": { - "message": "Invalid result for tools/list: [ - { - "expected": "object", - "code": "invalid_type", - "path": [ - "tools", - 1, - "inputSchema" - ], - "message": "Invalid input: expected object, received undefined" - } -]", + "message": "Invalid result for tools/list: tools.1.inputSchema: Invalid input: expected object, received undefined", "tag": "Mcp.RequestError", }, + "logFile": [ + " [fixture] info first line", + " second line", + " [fixture] warning db: {"query":"select 1","ms":3}", + "", + ], + "logRotation": { + "current": " [fixture] warning db: {"query":"select 1","ms":3} +", + "rotated": true, + }, "missingCwd": { "message": "working directory not found: /missing-dir", "tag": "Mcp.ConnectError", @@ -80,6 +81,21 @@ "afterDeliberateClose": [], "afterServerExit": "connection closed: mcp server process exited with code 0", }, + "onLog": [ + { + "data": "first line +second line", + "level": "info", + }, + { + "data": { + "ms": 3, + "query": "select 1", + }, + "level": "warning", + "logger": "db", + }, + ], "outputSchema": { "declared": [ "typed-missing", @@ -269,6 +285,7 @@ "frame", "exit", "huge", + "log", "typed-missing", "typed-mismatch", ], @@ -276,33 +293,11 @@ "strictResources": { "unnamed": { "resources": { - "message": "Invalid result for resources/list: [ - { - "expected": "string", - "code": "invalid_type", - "path": [ - "resources", - 0, - "name" - ], - "message": "Invalid input: expected string, received undefined" - } -]", + "message": "Invalid result for resources/list: resources.0.name: Invalid input: expected string, received undefined", "tag": "Mcp.RequestError", }, "templates": { - "message": "Invalid result for resources/templates/list: [ - { - "expected": "string", - "code": "invalid_type", - "path": [ - "resourceTemplates", - 0, - "name" - ], - "message": "Invalid input: expected string, received undefined" - } -]", + "message": "Invalid result for resources/templates/list: resourceTemplates.0.name: Invalid input: expected string, received undefined", "tag": "Mcp.RequestError", }, }, @@ -350,16 +345,7 @@ }, "nullCursor": { "error": { - "message": "Invalid result for tools/list: [ - { - "expected": "string", - "code": "invalid_type", - "path": [ - "nextCursor" - ], - "message": "Invalid input: expected string, received null" - } -]", + "message": "Invalid result for tools/list: nextCursor: Invalid input: expected string, received null", "tag": "Mcp.RequestError", }, }, diff --git a/packages/harness/test/fixtures/mcp/server.mjs b/packages/harness/test/fixtures/mcp/server.mjs index 5f01a1d0..5f9117e8 100644 --- a/packages/harness/test/fixtures/mcp/server.mjs +++ b/packages/harness/test/fixtures/mcp/server.mjs @@ -11,7 +11,7 @@ const capabilities = ? { resources: { listChanged: true } } : variant === "plain" ? { tools: {} } - : { tools: { listChanged: true } }; + : { tools: { listChanged: true }, logging: {} }; const server = new Server( { name: "stdio-fixture", version: "1.0.0" }, { capabilities, instructions: " Use the echo tool. \n" }, diff --git a/packages/harness/test/fixtures/mcp/tools.mjs b/packages/harness/test/fixtures/mcp/tools.mjs index bb23a888..edd3b280 100644 --- a/packages/harness/test/fixtures/mcp/tools.mjs +++ b/packages/harness/test/fixtures/mcp/tools.mjs @@ -29,6 +29,7 @@ const tools = [ "frame", "exit", "huge", + "log", ] .map((name) => ({ name, inputSchema: { type: "object" } })) .concat( @@ -120,6 +121,10 @@ export const install = (server) => { // One 17 MiB line that never ends: more than the client's 16 MiB frame limit. process.stdout.write("x".repeat(17 * 1024 * 1024)); return new Promise(() => {}); + case "log": + await server.sendLoggingMessage({ level: "info", data: "first line\nsecond line" }); + await server.sendLoggingMessage({ level: "warning", logger: "db", data: { query: "select 1", ms: 3 } }); + return text("logged"); case "typed-missing": return text("no structured content"); case "typed-mismatch": diff --git a/packages/harness/test/mcp.client.test.ts b/packages/harness/test/mcp.client.test.ts index b1b17642..832ee967 100644 --- a/packages/harness/test/mcp.client.test.ts +++ b/packages/harness/test/mcp.client.test.ts @@ -1,11 +1,15 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import type { OAuthClientProvider } from "@modelcontextprotocol/client"; +import type { LoggingMessageNotificationParams, OAuthClientProvider } from "@modelcontextprotocol/client"; import { Deferred, Effect, Exit, Fiber, Option, Ref } from "effect"; import { describe, expect, vi } from "vite-plus/test"; import { McpClient } from "../src/mcp/client.ts"; import { McpContent } from "../src/mcp/content.ts"; +import { McpLog } from "../src/mcp/log.ts"; import { Process } from "../src/sandbox/utils/process.ts"; import { listen, raw, sdk } from "./fixtures/mcp/http.ts"; import { testEffect } from "./utils/effect.ts"; @@ -56,6 +60,16 @@ const gone = (pids: ReadonlyArray) => return pids.filter(alive).length === 0; }); +const logEntries: ReadonlyArray = [ + { level: "info", data: "first line\nsecond line" }, + { level: "warning", logger: "db", data: { query: "select 1", ms: 3 } }, +]; + +const tempDir = Effect.acquireRelease( + Effect.sync(() => mkdtempSync(join(tmpdir(), "codework-mcp-log-"))), + (dir) => Effect.sync(() => rmSync(dir, { recursive: true, force: true })), +); + describe("mcp client", () => { it( "stdio: connects, lists tools and converts every content kind", @@ -554,7 +568,10 @@ describe("mcp client", () => { emptyCursor: outcome(emptyCursor), duplicate: outcome(duplicate), }; - expect(outcome(nullCursor).error?.tag).toBe("Mcp.RequestError"); + expect(outcome(nullCursor).error).toEqual({ + tag: "Mcp.RequestError", + message: "Invalid result for tools/list: nextCursor: Invalid input: expected string, received null", + }); // An empty cursor is followed as a real one, so the pages cycle until the SDK's cap. expect(outcome(emptyCursor).error?.message).toContain("exceeded listMaxPages"); // The SDK stops quietly at a repeated cursor and returns the pages it has. @@ -599,8 +616,16 @@ describe("mcp client", () => { expect(valid.templates.resourceTemplates).toEqual([ { uriTemplate: "file:///{path}", name: "files", description: "Any file" }, ]); - expect(unnamed.resources._tag).toBe("Mcp.RequestError"); - expect(unnamed.templates._tag).toBe("Mcp.RequestError"); + expect(failure(unnamed.resources)).toEqual({ + tag: "Mcp.RequestError", + message: + "Invalid result for resources/list: resources.0.name: Invalid input: expected string, received undefined", + }); + expect(failure(unnamed.templates)).toEqual({ + tag: "Mcp.RequestError", + message: + "Invalid result for resources/templates/list: resourceTemplates.0.name: Invalid input: expected string, received undefined", + }); }), ); @@ -613,7 +638,62 @@ describe("mcp client", () => { }); const error = yield* connection.tools().pipe(Effect.flip); summary.invalidEntry = failure(error); - expect(error._tag).toBe("Mcp.RequestError"); + expect(failure(error)).toEqual({ + tag: "Mcp.RequestError", + message: + "Invalid result for tools/list: tools.1.inputSchema: Invalid input: expected object, received undefined", + }); + }), + ); + + it( + "onLog: receives the server's notifications/message params", + Effect.gen(function* () { + const connection = yield* McpClient.connect({ server: "fixture", endpoint: stdio("server.mjs") }); + const entries = yield* Ref.make>([]); + connection.onLog((entry) => Ref.update(entries, (all) => [...all, entry])); + yield* connection.callTool({ name: "log" }); + for (let attempt = 0; attempt < 50 && (yield* Ref.get(entries)).length < 2; attempt++) + yield* Effect.sleep("20 millis"); + const received = yield* Ref.get(entries); + summary.onLog = received; + expect(received).toEqual(logEntries); + }), + ); + + it( + "McpLog: writes formatted lines with indented continuations", + Effect.gen(function* () { + const dir = yield* tempDir; + const log = yield* McpLog.make(join(dir, "logs", "mcp.log")); + for (const entry of logEntries) yield* log.write("fixture", entry); + const lines = readFileSync(log.path, "utf8").replace(/^\d{4}-\d\d-\d\dT[\d:.]+Z/gm, ""); + summary.logFile = lines.split("\n"); + expect(lines).toBe( + [ + " [fixture] info first line", + " second line", + ' [fixture] warning db: {"query":"select 1","ms":3}', + "", + ].join("\n"), + ); + }), + ); + + it( + "McpLog: rotates a file over 5 MB to .1", + Effect.gen(function* () { + const dir = yield* tempDir; + const path = join(dir, "mcp.log"); + const old = `${"x".repeat(5 * 1024 * 1024 + 16)}\n`; + writeFileSync(path, old); + const log = yield* McpLog.make(path); + yield* log.write("fixture", logEntries[1]!); + const rotated = readFileSync(`${path}.1`, "utf8") === old; + const current = readFileSync(path, "utf8").replace(/^\d{4}-\d\d-\d\dT[\d:.]+Z/gm, ""); + summary.logRotation = { rotated, current }; + expect(rotated).toBe(true); + expect(current).toBe(' [fixture] warning db: {"query":"select 1","ms":3}\n'); }), ); From 8719543b166be87323a17842d93c644a887ee19e Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Wed, 14 Oct 2026 21:31:42 +0530 Subject: [PATCH 03/17] refactor(harness): trace MCP requests and keep their errors in Effect Name the MCP entry points with Effect.fn and give each request its own span. A server without resource templates is now recovered from inside the Effect rather than on the SDK promise. Co-Authored-By: Claude Opus 5.5 --- packages/harness/src/mcp/client.ts | 53 +++++++++++++++++------------- packages/harness/src/mcp/log.ts | 2 +- packages/harness/src/mcp/stdio.ts | 2 +- 3 files changed, 32 insertions(+), 25 deletions(-) diff --git a/packages/harness/src/mcp/client.ts b/packages/harness/src/mcp/client.ts index 2687479d..4fb9e237 100644 --- a/packages/harness/src/mcp/client.ts +++ b/packages/harness/src/mcp/client.ts @@ -148,7 +148,7 @@ const isTransient = (error: unknown) => : error instanceof TypeError; /** Connects one MCP server; closing the calling scope tears down the transport and any spawned process. */ -export const connect = Effect.fnUntraced(function* (input: ConnectInput) { +export const connect = Effect.fn("McpClient.connect")(function* (input: ConnectInput) { const { server, endpoint } = input; const startupTimeout = Duration.toMillis(input.timeout?.startup ?? DEFAULT_STARTUP_TIMEOUT); const requestTimeout = Duration.toMillis(input.timeout?.request ?? DEFAULT_REQUEST_TIMEOUT); @@ -344,21 +344,21 @@ export const connect = Effect.fnUntraced(function* (input: ConnectInput) { Effect.sync(() => { if (lastError === before) lastError = undefined; }); - const request = (method: string, run: (signal: AbortSignal) => Promise) => + const attempt = (run: (signal: AbortSignal) => Promise) => Effect.tryPromise({ try: run, catch: fail }); + // Maps the raw failure to a public error, traced as one span per request. + const settle = (method: string, effect: Effect.Effect) => Effect.suspend(() => { const before = lastError; - return Effect.tryPromise({ try: run, catch: failure(method) }).pipe(Effect.tap(succeeded(before))); - }); - // Listing and reading are read-only, so a transient failure is safe to retry. Tool calls are never - // retried: the server may already have run them. - const read = (method: string, run: (signal: AbortSignal) => Promise) => - Effect.suspend(() => { - const before = lastError; - return retrying(method, Effect.tryPromise({ try: run, catch: fail })).pipe( + return effect.pipe( Effect.mapError((error) => failure(method)(error.error)), Effect.tap(succeeded(before)), ); - }); + }).pipe(Effect.withSpan(`McpClient.${method}`, { attributes: { "mcp.server": server } })); + const request = (method: string, run: (signal: AbortSignal) => Promise) => settle(method, attempt(run)); + // Listing and reading are read-only, so a transient failure is safe to retry. Tool calls are never + // retried: the server may already have run them. + const read = (method: string, run: (signal: AbortSignal) => Promise) => + settle(method, retrying(method, attempt(run))); // Every progress notification restarts the timeout, so long calls that report progress keep running. const options = (signal: AbortSignal, onProgress?: (progress: Progress) => void) => ({ signal, @@ -391,18 +391,25 @@ export const connect = Effect.fnUntraced(function* (input: ConnectInput) { resourceTemplates: (cursor) => capabilities?.resources === undefined ? Effect.succeed({ resourceTemplates: [] }) - : read("resources/templates/list", (signal) => - client - .request( - { method: "resources/templates/list", params: cursor === undefined ? {} : { cursor } }, - options(signal), - ) - .catch((error: unknown) => { - // Servers that do not implement templates have none. - if (error instanceof ProtocolError && error.code === ProtocolErrorCode.MethodNotFound) - return { resourceTemplates: [] }; - throw error; - }), + : settle( + "resources/templates/list", + retrying( + "resources/templates/list", + attempt((signal) => + client.request( + { method: "resources/templates/list", params: cursor === undefined ? {} : { cursor } }, + options(signal), + ), + ), + ).pipe( + // Servers that do not implement templates have none. + Effect.catchIf( + (failure) => + failure.error instanceof ProtocolError && + failure.error.code === ProtocolErrorCode.MethodNotFound, + () => Effect.succeed({ resourceTemplates: [] }), + ), + ), ), readResource: (uri) => read("resources/read", (signal) => client.readResource({ uri }, options(signal))), callTool: (call) => diff --git a/packages/harness/src/mcp/log.ts b/packages/harness/src/mcp/log.ts index c1d22b71..09c954ae 100644 --- a/packages/harness/src/mcp/log.ts +++ b/packages/harness/src/mcp/log.ts @@ -25,7 +25,7 @@ export const format = (server: string, params: LoggingMessageNotificationParams, * processes may write to the same file, so the size is re-read before rotating. Write errors are * ignored: logging must not break tools. */ -export const make = Effect.fnUntraced(function* (path: string) { +export const make = Effect.fn("McpLog.make")(function* (path: string) { const lock = yield* Semaphore.make(1); let size: number | undefined; const current = fileSystem.stat(path).pipe( diff --git a/packages/harness/src/mcp/stdio.ts b/packages/harness/src/mcp/stdio.ts index 49728050..c20e1fd8 100644 --- a/packages/harness/src/mcp/stdio.ts +++ b/packages/harness/src/mcp/stdio.ts @@ -53,7 +53,7 @@ export interface Options { * The process is acquired in the calling scope: closing the scope kills it and its process group, * whether or not the transport was closed. */ -export const make = Effect.fnUntraced(function* (options: Options) { +export const make = Effect.fn("McpStdio.make")(function* (options: Options) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const scope = yield* Effect.scope; const runPromise = Effect.runPromiseWith(yield* Effect.context()); From 30163878bdcbba649051811b00289dae9297bf17 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Wed, 14 Oct 2026 22:06:47 +0530 Subject: [PATCH 04/17] fix(harness): close MCP connections fully on failure and cleanup - Count stderr as an open pipe, so a descendant holding only stderr is still killed with the server's process group. - Bound the whole connect attempt by the startup timeout; the SDK only times the initialize request, not the initialized notification. - End the HTTP session a failed connect attempt was given, through a fresh transport, since the SDK aborts the failed transport's signal. Tests now cover these, retries of reads but not tool calls, session termination and 400 classification, and register their own teardown for spawned processes. Co-Authored-By: Claude Opus 5.5 --- packages/harness/src/mcp/client.ts | 58 ++-- packages/harness/src/mcp/stdio.ts | 15 +- .../test/__artifacts__/mcp.client.json | 67 ++++- packages/harness/test/fixtures/mcp/http.ts | 27 +- packages/harness/test/fixtures/mcp/tools.mjs | 20 +- packages/harness/test/mcp.client.test.ts | 262 ++++++++++++++++-- 6 files changed, 408 insertions(+), 41 deletions(-) diff --git a/packages/harness/src/mcp/client.ts b/packages/harness/src/mcp/client.ts index 4fb9e237..2856d9d6 100644 --- a/packages/harness/src/mcp/client.ts +++ b/packages/harness/src/mcp/client.ts @@ -202,6 +202,25 @@ export const connect = Effect.fn("McpClient.connect")(function* (input: ConnectI ), ); + const http = (endpoint: Http, sessionId?: string) => + new StreamableHTTPClientTransport(endpoint.url, { + requestInit: { headers: endpoint.headers }, + ...(input.authProvider === undefined ? {} : { authProvider: input.authProvider }), + ...(sessionId === undefined ? {} : { sessionId }), + }); + // Ends the HTTP session a transport holds, bounded: the server expires it anyway. + const terminate = (transport: StreamableHTTPClientTransport) => + Effect.tryPromise({ try: () => transport.terminateSession(), catch: fail }).pipe( + Effect.timeoutOrElse({ + duration: TERMINATE_TIMEOUT, + orElse: () => Effect.fail(fail(`timed out after ${Duration.format(TERMINATE_TIMEOUT)}`)), + }), + Effect.tapError((failure) => + Effect.logWarning("failed to terminate mcp session", { server, error: describe(failure.error) }), + ), + Effect.ignore, + ); + const initialize = Effect.fnUntraced(function* (transport: Transport, timeout: number) { const client = new Client(input.clientInfo ?? { name: "codework", version: "unknown" }, { capabilities: input.roots ? { roots: {} } : {}, @@ -241,7 +260,27 @@ export const connect = Effect.fn("McpClient.connect")(function* (input: ConnectI yield* Effect.tryPromise({ try: (signal) => client.connect(transport, { timeout, signal }), catch: fail, - }).pipe(Effect.onError(() => Effect.promise(() => transport.close()).pipe(Effect.ignore))); + }).pipe( + // The SDK times only the initialize request, not the initialized notification sent after it. + Effect.timeoutOrElse({ + duration: Duration.millis(timeout), + orElse: () => Effect.fail(fail(`mcp connect timed out after ${Duration.format(Duration.millis(timeout))}`)), + }), + Effect.onError(() => + Effect.gen(function* () { + // A failed attempt may already hold a session (unsupported version, failed notification). + // The SDK closes the transport itself when the handshake fails, which aborts the signal its + // DELETE would use, so the session is ended through a fresh, never-started transport. + if ( + endpoint._tag === "Http" && + transport instanceof StreamableHTTPClientTransport && + transport.sessionId !== undefined + ) + yield* terminate(http(endpoint, transport.sessionId)); + yield* Effect.promise(() => transport.close()).pipe(Effect.ignore); + }), + ), + ); return client; }); @@ -287,10 +326,7 @@ export const connect = Effect.fn("McpClient.connect")(function* (input: ConnectI const deadline = (yield* Clock.currentTimeMillis) + startupTimeout; const open = Effect.gen(function* () { const remaining = Math.max(1, deadline - (yield* Clock.currentTimeMillis)); - session.transport = new StreamableHTTPClientTransport(endpoint.url, { - requestInit: { headers: endpoint.headers }, - ...(input.authProvider === undefined ? {} : { authProvider: input.authProvider }), - }); + session.transport = http(endpoint); return yield* initialize(session.transport, remaining); }); return yield* retrying("mcp connect", open, deadline); @@ -323,17 +359,7 @@ export const connect = Effect.fn("McpClient.connect")(function* (input: ConnectI const transport = session.transport; // Termination failures are logged below; onerror would report them a second time. client.onerror = () => {}; - if (transport?.sessionId !== undefined && !session.reported) - yield* Effect.tryPromise({ try: () => transport.terminateSession(), catch: fail }).pipe( - Effect.timeoutOrElse({ - duration: TERMINATE_TIMEOUT, - orElse: () => Effect.fail(fail(`timed out after ${Duration.format(TERMINATE_TIMEOUT)}`)), - }), - Effect.tapError((failure) => - Effect.logWarning("failed to terminate mcp session", { server, error: describe(failure.error) }), - ), - Effect.ignore, - ); + if (transport?.sessionId !== undefined && !session.reported) yield* terminate(transport); yield* Effect.tryPromise(() => client.close()).pipe(Effect.ignore); }), ); diff --git a/packages/harness/src/mcp/stdio.ts b/packages/harness/src/mcp/stdio.ts index c20e1fd8..d7494674 100644 --- a/packages/harness/src/mcp/stdio.ts +++ b/packages/harness/src/mcp/stdio.ts @@ -65,7 +65,8 @@ export const make = Effect.fn("McpStdio.make")(function* (options: Options) { phase: "ready" | "starting" | "open" | "closed"; handle?: ChildProcessSpawner.ChildProcessHandle | undefined; stdoutOpen: boolean; - } = { phase: "ready", stdoutOpen: false }; + stderrOpen: boolean; + } = { phase: "ready", stdoutOpen: false, stderrOpen: false }; let startup: Promise | undefined; let closing: Promise | undefined; let untrack = () => {}; @@ -79,7 +80,10 @@ export const make = Effect.fn("McpStdio.make")(function* (options: Options) { Effect.ignore, Effect.andThen( Effect.suspend(() => - Process.terminate(handle, { forceKillAfter: FORCE_KILL_AFTER, pipesOpen: state.stdoutOpen }), + Process.terminate(handle, { + forceKillAfter: FORCE_KILL_AFTER, + pipesOpen: state.stdoutOpen || state.stderrOpen, + }), ), ), Effect.ensuring(Effect.sync(() => untrack())), @@ -132,6 +136,7 @@ export const make = Effect.fn("McpStdio.make")(function* (options: Options) { ); state.handle = handle; state.stdoutOpen = true; + state.stderrOpen = true; untrack = track(Number(handle.pid)); yield* Effect.addFinalizer(() => Effect.sync(() => untrack())); if (state.phase === "closed") { @@ -197,6 +202,12 @@ export const make = Effect.fn("McpStdio.make")(function* (options: Options) { : Effect.logDebug("mcp server stderr", { server: options.server, output }), ), Effect.ignore, + // A descendant holding only stderr keeps the group alive, and the group id ours. + Effect.ensuring( + Effect.sync(() => { + state.stderrOpen = false; + }), + ), ), ); diff --git a/packages/harness/test/__artifacts__/mcp.client.json b/packages/harness/test/__artifacts__/mcp.client.json index bcb2463e..6dbc13d3 100644 --- a/packages/harness/test/__artifacts__/mcp.client.json +++ b/packages/harness/test/__artifacts__/mcp.client.json @@ -11,6 +11,35 @@ "method": "tools/call", "reason": "connection closed: mcp server process exited with code 3: fatal: fixture crashed on purpose", }, + "failedConnectSession": { + "failedNotification": { + "error": { + "message": "Error POSTing to endpoint: notification failed (HTTP 500)", + "tag": "Mcp.ConnectError", + }, + "seen": [ + "POST initialize", + "POST notifications/initialized", + "DELETE", + "POST initialize", + "POST notifications/initialized", + "DELETE", + "POST initialize", + "POST notifications/initialized", + "DELETE", + ], + }, + "unsupported": { + "error": { + "message": "Server's protocol version is not supported: 1999-01-01", + "tag": "Mcp.ConnectError", + }, + "seen": [ + "POST initialize", + "DELETE", + ], + }, + }, "frameLimit": { "error": { "message": "Connection closed: mcp stdio frame exceeded 16 MiB", @@ -23,6 +52,20 @@ "framed": "framed", "toolsChanged": true, }, + "http400": { + "notInitialized": { + "message": "mcp server session expired: raw", + "tag": "Mcp.SessionExpiredError", + }, + "other": { + "message": "Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32000,"message":"Bad Request: something else"},"id":null} (HTTP 400)", + "tag": "Mcp.RequestError", + }, + }, + "httpClose": { + "deletes": 1, + "hangingDeleteBounded": true, + }, "httpHappy": { "echo": [ { @@ -50,10 +93,29 @@ "exit", "huge", "log", + "stderr-child", "typed-missing", "typed-mismatch", ], }, + "httpRetries": { + "call": { + "message": "Error POSTing to endpoint: overloaded (HTTP 503)", + "tag": "Mcp.RequestError", + }, + "callRequests": 1, + "listRequests": 2, + "tools": [ + "echo", + ], + }, + "initializedTimeout": { + "deleted": true, + "error": { + "message": "mcp connect timed out after ", + "tag": "Mcp.ConnectError", + }, + }, "invalidEntry": { "message": "Invalid result for tools/list: tools.1.inputSchema: Invalid input: expected object, received undefined", "tag": "Mcp.RequestError", @@ -126,7 +188,6 @@ second line", "message": "fetch failed (ECONNREFUSED)", "tag": "Mcp.ConnectError", }, - "retried": true, }, "resources": { "first": { @@ -214,6 +275,9 @@ second line", "message": "command not found: codework-mcp-missing-binary", "tag": "Mcp.ConnectError", }, + "stderrGrandchild": { + "killed": true, + }, "stdioHappy": { "echo": [ { @@ -286,6 +350,7 @@ second line", "exit", "huge", "log", + "stderr-child", "typed-missing", "typed-mismatch", ], diff --git a/packages/harness/test/fixtures/mcp/http.ts b/packages/harness/test/fixtures/mcp/http.ts index 78e33773..ecb7cecb 100644 --- a/packages/harness/test/fixtures/mcp/http.ts +++ b/packages/harness/test/fixtures/mcp/http.ts @@ -63,6 +63,9 @@ export const sdk = async (): Promise => { }; }; +/** A scripted answer: a status with a text body, or no answer at all. */ +export type Reply = { readonly status: number; readonly body: string } | "hang"; + /** Requests a hand-written server saw, as `METHOD rpc-method`. */ export interface Raw { readonly handler: Handler; @@ -72,7 +75,16 @@ export interface Raw { } /** A hand-written streamable HTTP server for raw status scenarios. */ -export const raw = (options: { readonly failInitialize?: number; readonly unauthorized?: boolean } = {}): Raw => { +export const raw = ( + options: { + readonly failInitialize?: number; + readonly unauthorized?: boolean; + /** Protocol version to answer initialize with, instead of echoing the client's. */ + readonly protocolVersion?: string; + /** Overrides the answer to a request, keyed as in `seen`; `undefined` answers normally. */ + readonly script?: (request: string) => Reply | undefined; + } = {}, +): Raw => { let failures = options.failInitialize ?? 0; const state: Raw = { seen: [], @@ -87,7 +99,12 @@ export const raw = (options: { readonly failInitialize?: number; readonly unauth return void response.end("unauthorized"); } const body = request.method === "POST" ? (JSON.parse((await readBody(request)).toString()) as Rpc) : undefined; - state.seen.push(body?.method ? `${request.method} ${body.method}` : `${request.method}`); + const key = body?.method ? `${request.method} ${body.method}` : `${request.method}`; + state.seen.push(key); + const scripted = options.script?.(key); + if (scripted === "hang") return; + if (scripted !== undefined) + return void response.writeHead(scripted.status, { "content-type": "text/plain" }).end(scripted.body); if (state.forget && request.headers["mcp-session-id"] !== undefined) { response.writeHead(404, { "content-type": "text/plain" }); return void response.end("session not found"); @@ -102,13 +119,15 @@ export const raw = (options: { readonly failInitialize?: number; readonly unauth const result = body.method === "initialize" ? { - protocolVersion: body.params?.protocolVersion, + protocolVersion: options.protocolVersion ?? body.params?.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "raw-fixture", version: "1.0.0" }, } : body.method === "tools/list" ? { tools: [{ name: "echo", inputSchema: { type: "object" } }] } - : {}; + : body.method === "tools/call" + ? { content: [{ type: "text", text: "called" }] } + : {}; response.writeHead(200, { "content-type": "application/json", "mcp-session-id": "raw-session" }); response.end(JSON.stringify({ jsonrpc: "2.0", id: body.id, result })); }, diff --git a/packages/harness/test/fixtures/mcp/tools.mjs b/packages/harness/test/fixtures/mcp/tools.mjs index edd3b280..40c46448 100644 --- a/packages/harness/test/fixtures/mcp/tools.mjs +++ b/packages/harness/test/fixtures/mcp/tools.mjs @@ -30,6 +30,7 @@ const tools = [ "exit", "huge", "log", + "stderr-child", ] .map((name) => ({ name, inputSchema: { type: "object" } })) .concat( @@ -89,14 +90,23 @@ export const install = (server) => { case "slow": await sleep(Number(args.ms), signal); return text("slow done"); - case "hang": + case "hang": { + // Acknowledges entering through progress and the cancellation through a log message. + const token = request.params._meta?.progressToken; + if (token !== undefined) + await ctx.mcpReq.notify({ + method: "notifications/progress", + params: { progressToken: token, progress: 1 }, + }); await new Promise((resolve) => signal.addEventListener("abort", () => { cancelled.push(String(signal.reason?.message ?? signal.reason ?? "aborted")); resolve(); }), ); + await server.sendLoggingMessage({ level: "info", data: "hang cancelled" }); return text("hang aborted"); + } case "cancelled": return text({ cancelled: cancelled.length }); case "roots": @@ -121,6 +131,14 @@ export const install = (server) => { // One 17 MiB line that never ends: more than the client's 16 MiB frame limit. process.stdout.write("x".repeat(17 * 1024 * 1024)); return new Promise(() => {}); + case "stderr-child": { + // The grandchild inherits only stderr; the server then exits on its own. + const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + stdio: ["ignore", "ignore", "inherit"], + }); + setTimeout(() => process.exit(0), 50); + return text({ server: process.pid, grandchild: child.pid }); + } case "log": await server.sendLoggingMessage({ level: "info", data: "first line\nsecond line" }); await server.sendLoggingMessage({ level: "warning", logger: "db", data: { query: "select 1", ms: 3 } }); diff --git a/packages/harness/test/mcp.client.test.ts b/packages/harness/test/mcp.client.test.ts index 832ee967..698055c4 100644 --- a/packages/harness/test/mcp.client.test.ts +++ b/packages/harness/test/mcp.client.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import type { LoggingMessageNotificationParams, OAuthClientProvider } from "@modelcontextprotocol/client"; -import { Deferred, Effect, Exit, Fiber, Option, Ref } from "effect"; +import { Deferred, Effect, Exit, Fiber, Option, Ref, Scope } from "effect"; import { describe, expect, vi } from "vite-plus/test"; import { McpClient } from "../src/mcp/client.ts"; import { McpContent } from "../src/mcp/content.ts"; @@ -28,6 +28,43 @@ const http = (url: URL): McpClient.Http => ({ _tag: "Http", url, headers: {} }); /** Each scenario's observable outcome, written to the artifact by the last test. */ const summary: Record = {}; +/** Every key the scenarios record; the artifact test refuses to snapshot without all of them. */ +const scenarios = [ + "cancel", + "crash", + "failedConnectSession", + "frameLimit", + "framing", + "http400", + "httpClose", + "httpHappy", + "httpRetries", + "initializedTimeout", + "invalidEntry", + "logFile", + "logRotation", + "missingCwd", + "noToolsCapability", + "onClose", + "onLog", + "outputSchema", + "processGroup", + "progress", + "refused", + "resources", + "retry", + "retryCloseReason", + "roots", + "sessionExpired", + "spawnFailure", + "stderrGrandchild", + "stdioHappy", + "strictResources", + "stubborn", + "toolsPagination", + "unadvertisedListChanged", + "unauthorized", +]; const normalise = (message: string) => message @@ -60,6 +97,19 @@ const gone = (pids: ReadonlyArray) => return pids.filter(alive).length === 0; }); +// Independent of the client's own cleanup, so a regression there cannot leak processes past the test. +const reap = (pids: ReadonlyArray) => + Effect.sync(() => { + for (const pid of pids) + for (const target of [-pid, pid]) { + try { + process.kill(target, "SIGKILL"); + } catch { + // already gone + } + } + }); + const logEntries: ReadonlyArray = [ { level: "info", data: "first line\nsecond line" }, { level: "warning", logger: "db", data: { query: "select 1", ms: 3 } }, @@ -139,12 +189,14 @@ describe("mcp client", () => { it( "stdio: closing the scope kills the server and its grandchild", Effect.gen(function* () { + const outer = yield* Effect.scope; const pids = yield* Effect.gen(function* () { const connection = yield* McpClient.connect({ server: "fixture", endpoint: stdio("server.mjs") }); const spawned = JSON.parse(text(yield* connection.callTool({ name: "spawn" }))) as { readonly server: number; readonly grandchild: number; }; + yield* Scope.addFinalizer(outer, reap([spawned.server, spawned.grandchild])); expect(alive(spawned.server) && alive(spawned.grandchild)).toBe(true); return [spawned.server, spawned.grandchild]; }).pipe(Effect.scoped); @@ -157,6 +209,7 @@ describe("mcp client", () => { it( "stdio: a server ignoring SIGTERM is still killed within a bounded time", Effect.gen(function* () { + const outer = yield* Effect.scope; const started = Date.now(); const pids = yield* Effect.gen(function* () { const connection = yield* McpClient.connect({ server: "stubborn", endpoint: stdio("stubborn.mjs") }); @@ -164,6 +217,7 @@ describe("mcp client", () => { readonly server: number; readonly grandchild: number; }; + yield* Scope.addFinalizer(outer, reap([spawned.server, spawned.grandchild])); return [spawned.server, spawned.grandchild]; }).pipe(Effect.scoped); const elapsed = Date.now() - started; @@ -175,6 +229,28 @@ describe("mcp client", () => { 15_000, ); + it( + "stdio: a grandchild holding only stderr is killed after the server exits", + Effect.gen(function* () { + const outer = yield* Effect.scope; + const pids = yield* Effect.gen(function* () { + const connection = yield* McpClient.connect({ server: "fixture", endpoint: stdio("server.mjs") }); + const spawned = JSON.parse(text(yield* connection.callTool({ name: "stderr-child" }))) as { + readonly server: number; + readonly grandchild: number; + }; + yield* Scope.addFinalizer(outer, reap([spawned.server, spawned.grandchild])); + // The server exits on its own; its grandchild keeps the stderr pipe open. + yield* gone([spawned.server]); + expect(alive(spawned.grandchild)).toBe(true); + return [spawned.server, spawned.grandchild]; + }).pipe(Effect.scoped); + const killed = yield* gone(pids); + summary.stderrGrandchild = { killed }; + expect(killed).toBe(true); + }), + ); + it( "stdio: skips an invalid JSON line and parses a CRLF-terminated message", Effect.gen(function* () { @@ -219,9 +295,19 @@ describe("mcp client", () => { "stdio: interrupting a call cancels it on the server and keeps the connection usable", Effect.gen(function* () { const connection = yield* McpClient.connect({ server: "fixture", endpoint: stdio("server.mjs") }); - const fiber = yield* connection.callTool({ name: "hang" }).pipe(Effect.forkChild); - yield* Effect.sleep("200 millis"); + const entered = yield* Deferred.make(); + const acknowledged = yield* Deferred.make(); + connection.onLog((entry) => + entry.data === "hang cancelled" + ? Deferred.succeed(acknowledged, undefined).pipe(Effect.asVoid) + : Effect.void, + ); + const fiber = yield* connection + .callTool({ name: "hang", onProgress: () => void Deferred.doneUnsafe(entered, Effect.void) }) + .pipe(Effect.forkChild); + yield* Deferred.await(entered).pipe(Effect.timeout("2 seconds")); yield* Fiber.interrupt(fiber); + yield* Deferred.await(acknowledged).pipe(Effect.timeout("2 seconds")); const report = JSON.parse(text(yield* connection.callTool({ name: "cancelled" }))) as { readonly cancelled: number; }; @@ -362,7 +448,7 @@ describe("mcp client", () => { ); it( - "http: a refused connection fails with ECONNREFUSED after retrying", + "http: a refused connection fails with ECONNREFUSED", Effect.gen(function* () { const port = yield* Effect.callback((resume) => { const server = createServer(); @@ -371,18 +457,14 @@ describe("mcp client", () => { server.close(() => resume(Effect.succeed(port))); }); }); - const started = Date.now(); const error = yield* McpClient.connect({ server: "refused", endpoint: http(new URL(`http://127.0.0.1:${port}/mcp`)), timeout: { startup: "5 seconds" }, }).pipe(Effect.flip); - const elapsed = Date.now() - started; - summary.refused = { error: failure(error), retried: elapsed >= 1_200 }; + summary.refused = { error: failure(error) }; expect(error._tag).toBe("Mcp.ConnectError"); expect(error.message).toContain("ECONNREFUSED"); - expect(elapsed).toBeGreaterThanOrEqual(1_200); - expect(elapsed).toBeLessThan(5_000); }), ); @@ -420,14 +502,14 @@ describe("mcp client", () => { }); const write = vi.spyOn(process.stdout, "write"); const debug = vi.spyOn(console, "debug"); - const tools = yield* connection.tools().pipe( - Effect.ensuring( - Effect.sync(() => { - write.mockRestore(); - debug.mockRestore(); - }), - ), + yield* Effect.addFinalizer(() => + Effect.sync(() => { + write.mockRestore(); + debug.mockRestore(); + }), ); + const tools = yield* connection.tools(); + // Restoring clears the recorded calls, so count first. const printed = write.mock.calls.length + debug.mock.calls.length; summary.noToolsCapability = { tools, printed }; expect(tools).toEqual([]); @@ -697,8 +779,154 @@ describe("mcp client", () => { }), ); + it( + "http: an unanswered initialized notification times the connect out and ends the session", + Effect.gen(function* () { + const server = raw({ + script: (request) => (request === "POST notifications/initialized" ? "hang" : undefined), + }); + const url = yield* listen(server.handler); + const started = Date.now(); + const error = yield* McpClient.connect({ + server: "raw", + endpoint: http(url), + timeout: { startup: "500 millis" }, + }).pipe(Effect.flip); + const elapsed = Date.now() - started; + summary.initializedTimeout = { error: failure(error), deleted: server.seen.includes("DELETE") }; + expect(error._tag).toBe("Mcp.ConnectError"); + expect(error.message).toContain("mcp connect timed out after"); + expect(elapsed).toBeLessThan(2_000); + }), + ); + + it( + "http: a failed connect attempt deletes the session it was given", + Effect.gen(function* () { + const unsupported = raw({ protocolVersion: "1999-01-01" }); + const failing = raw({ + script: (request) => + request === "POST notifications/initialized" ? { status: 500, body: "notification failed" } : undefined, + }); + const errors = []; + for (const server of [unsupported, failing]) { + const url = yield* listen(server.handler); + const error = yield* McpClient.connect({ + server: "raw", + endpoint: http(url), + timeout: { startup: "2 seconds" }, + }).pipe(Effect.flip); + errors.push({ error: failure(error), seen: [...server.seen].filter((entry) => entry !== "GET") }); + } + summary.failedConnectSession = { unsupported: errors[0], failedNotification: errors[1] }; + expect(errors.map(({ error, seen }) => ({ tag: error.tag, deleted: seen.includes("DELETE") }))).toEqual([ + { tag: "Mcp.ConnectError", deleted: true }, + { tag: "Mcp.ConnectError", deleted: true }, + ]); + }), + ); + + it( + "http: list requests retry a transient 503; tool calls never retry", + Effect.gen(function* () { + let listFailures = 1; + const server = raw({ + script: (request) => { + if (request === "POST tools/list" && listFailures-- > 0) return { status: 503, body: "overloaded" }; + // The call has run its side effect before the server fails the response. + if (request === "POST tools/call") return { status: 503, body: "overloaded" }; + return undefined; + }, + }); + const url = yield* listen(server.handler); + const connection = yield* McpClient.connect({ server: "raw", endpoint: http(url) }); + const tools = yield* connection.tools(); + const call = yield* connection.callTool({ name: "echo" }).pipe(Effect.flip); + const count = (key: string) => server.seen.filter((entry) => entry === key).length; + summary.httpRetries = { + tools: tools.map((tool) => tool.name), + listRequests: count("POST tools/list"), + call: failure(call), + callRequests: count("POST tools/call"), + }; + expect(count("POST tools/list")).toBe(2); + expect(call._tag).toBe("Mcp.RequestError"); + expect(count("POST tools/call")).toBe(1); + }), + ); + + it( + "http: closing the scope deletes the session once, and a hanging DELETE is bounded", + Effect.gen(function* () { + const normal = raw(); + const normalUrl = yield* listen(normal.handler); + yield* McpClient.connect({ server: "raw", endpoint: http(normalUrl) }).pipe(Effect.scoped); + const deletes = normal.seen.filter((entry) => entry === "DELETE").length; + + const hanging = raw({ script: (request) => (request === "DELETE" ? "hang" : undefined) }); + const hangingUrl = yield* listen(hanging.handler); + let closedIn = 0; + yield* Effect.gen(function* () { + yield* McpClient.connect({ server: "raw", endpoint: http(hangingUrl) }); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + closedIn = Date.now(); + }), + ); + }).pipe(Effect.scoped); + // Finalizers run in reverse: the one above runs first, so this times the client's close. + const elapsed = Date.now() - closedIn; + summary.httpClose = { deletes, hangingDeleteBounded: elapsed < 1_500 }; + expect(deletes).toBe(1); + expect(hanging.seen).toContain("DELETE"); + expect(elapsed).toBeLessThan(1_500); + }), + ); + + it( + "http: a 400 'Server not initialized' expires the session; any other 400 is a request error", + Effect.gen(function* () { + const reply = (body: string) => { + let armed = false; + const server = raw({ + script: (request) => (armed && request === "POST tools/list" ? { status: 400, body } : undefined), + }); + return { server, arm: () => (armed = true) }; + }; + const run = (body: string) => + Effect.gen(function* () { + const { server, arm } = reply(body); + const url = yield* listen(server.handler); + const connection = yield* McpClient.connect({ server: "raw", endpoint: http(url) }); + arm(); + return failure(yield* connection.tools().pipe(Effect.flip)); + }).pipe(Effect.scoped); + const notInitialized = yield* run( + JSON.stringify({ + jsonrpc: "2.0", + error: { code: -32000, message: "Bad Request: Server not initialized" }, + id: null, + }), + ); + const other = yield* run( + JSON.stringify({ + jsonrpc: "2.0", + error: { code: -32000, message: "Bad Request: something else" }, + id: null, + }), + ); + summary.http400 = { notInitialized, other }; + expect(notInitialized.tag).toBe("Mcp.SessionExpiredError"); + expect(other.tag).toBe("Mcp.RequestError"); + }), + ); + it( "writes the scenario artifact", - Effect.promise(() => expect(summary).toMatchFileSnapshot("./__artifacts__/mcp.client.json")), + Effect.gen(function* () { + // A filtered run must not snapshot a partial summary. + expect(Object.keys(summary).sort()).toEqual([...scenarios].sort()); + yield* Effect.promise(() => expect(summary).toMatchFileSnapshot("./__artifacts__/mcp.client.json")); + }), ); }); From 50909bd5b104fadf41b6973fd8705bb4f77538db Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Wed, 14 Oct 2026 22:10:46 +0530 Subject: [PATCH 05/17] test(harness): check MCP stderr grandchildren die before scope close The process spawner kills the group when the connection's scope closes, which hid a missing kill. Assert instead that a grandchild holding only stderr is gone once the connection reports the server's exit. Co-Authored-By: Claude Opus 5.5 --- packages/harness/test/mcp.client.test.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/harness/test/mcp.client.test.ts b/packages/harness/test/mcp.client.test.ts index 698055c4..114f9fa0 100644 --- a/packages/harness/test/mcp.client.test.ts +++ b/packages/harness/test/mcp.client.test.ts @@ -230,22 +230,25 @@ describe("mcp client", () => { ); it( - "stdio: a grandchild holding only stderr is killed after the server exits", + "stdio: a grandchild holding only stderr is killed when the server exits, before scope close", Effect.gen(function* () { const outer = yield* Effect.scope; - const pids = yield* Effect.gen(function* () { + const killed = yield* Effect.gen(function* () { const connection = yield* McpClient.connect({ server: "fixture", endpoint: stdio("server.mjs") }); + const closed = yield* Deferred.make(); + connection.onClose(() => Deferred.succeed(closed, undefined).pipe(Effect.asVoid)); const spawned = JSON.parse(text(yield* connection.callTool({ name: "stderr-child" }))) as { readonly server: number; readonly grandchild: number; }; yield* Scope.addFinalizer(outer, reap([spawned.server, spawned.grandchild])); - // The server exits on its own; its grandchild keeps the stderr pipe open. + // The server exits on its own; its grandchild keeps the stderr pipe open. The group is killed + // as soon as the connection notices the exit, while the connection's scope is still open: the + // spawner's own release at scope close would hide a missing kill. yield* gone([spawned.server]); - expect(alive(spawned.grandchild)).toBe(true); - return [spawned.server, spawned.grandchild]; + yield* Deferred.await(closed).pipe(Effect.timeout("5 seconds")); + return yield* gone([spawned.grandchild]); }).pipe(Effect.scoped); - const killed = yield* gone(pids); summary.stderrGrandchild = { killed }; expect(killed).toBe(true); }), From f64f0c1c64ba6e5bb7628d20d689d05e84f3f493 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Sat, 24 Oct 2026 19:13:37 +0530 Subject: [PATCH 06/17] fix(harness): retry only network failures and match MCP timeouts and shutdown - Retry connects only on HTTP 408/429/5xx and fetch failures with a system code; any other TypeError, such as an invalid header value, fails once. - Retry list and read requests once, 250 ms later, after a transient HTTP error. - Bound startup by the request timeout unless set on its own. - Ask for progress only on requests given a progress handler. - SIGTERM a stdio server's process group even after the server exits on its own, and before SIGKILL when the leader is gone but a pipe is still open. Co-Authored-By: Claude Opus 5.5 --- packages/harness/src/mcp/client.ts | 53 +++++++++++------ packages/harness/src/mcp/stdio.ts | 1 + packages/harness/src/sandbox/utils/process.ts | 59 +++++++++++-------- 3 files changed, 71 insertions(+), 42 deletions(-) diff --git a/packages/harness/src/mcp/client.ts b/packages/harness/src/mcp/client.ts index 2856d9d6..c3772783 100644 --- a/packages/harness/src/mcp/client.ts +++ b/packages/harness/src/mcp/client.ts @@ -24,7 +24,6 @@ import { hostPath } from "../host.ts"; import { ConnectError, NeedsAuthError, RequestError, SessionExpiredError } from "./error.ts"; import { McpStdio } from "./stdio.ts"; -const DEFAULT_STARTUP_TIMEOUT = Duration.seconds(30); const DEFAULT_REQUEST_TIMEOUT = Duration.seconds(60); const TERMINATE_TIMEOUT = Duration.seconds(1); @@ -89,6 +88,7 @@ export interface ConnectInput { readonly roots?: ReadonlyArray; /** HTTP only. */ readonly authProvider?: OAuthClientProvider; + /** `startup` bounds connecting and initializing, and defaults to `request`. */ readonly timeout?: { readonly startup?: Duration.Input; readonly request?: Duration.Input }; readonly clientInfo?: Implementation; } @@ -140,18 +140,22 @@ const describe = (error: unknown) => { return detail && !error.message.includes(detail) ? `${error.message} (${detail})` : error.message; }; -// Overloaded or restarting servers and dropped connections usually recover within a second. -// 501 means the server will never support the request. +// Overloaded or restarting servers usually recover within a second. 501 means the server will never +// support the request. +const isTransientHttp = (error: unknown) => + error instanceof SdkHttpError && + (error.status === 408 || error.status === 429 || (error.status >= 500 && error.status !== 501)); +// Only a failure that carries a system code is a network one; any other `TypeError` (an invalid header +// value, say) would fail again, and its message may hold the value. const isTransient = (error: unknown) => - error instanceof SdkHttpError - ? error.status === 408 || error.status === 429 || (error.status >= 500 && error.status !== 501) - : error instanceof TypeError; + isTransientHttp(error) || (error instanceof TypeError && systemCode(error) !== undefined); /** Connects one MCP server; closing the calling scope tears down the transport and any spawned process. */ export const connect = Effect.fn("McpClient.connect")(function* (input: ConnectInput) { const { server, endpoint } = input; - const startupTimeout = Duration.toMillis(input.timeout?.startup ?? DEFAULT_STARTUP_TIMEOUT); const requestTimeout = Duration.toMillis(input.timeout?.request ?? DEFAULT_REQUEST_TIMEOUT); + const startupTimeout = + input.timeout?.startup === undefined ? requestTimeout : Duration.toMillis(input.timeout.startup); const runFork = yield* FiberSet.makeRuntime(); // Handlers the owner registers after connect; notifications can arrive before that. @@ -201,6 +205,21 @@ export const connect = Effect.fn("McpClient.connect")(function* (input: ConnectI ), ), ); + // Listing and reading are read-only: one more attempt, 250 ms later, after a transient HTTP error. + const retryingRead = (what: string, effect: Effect.Effect) => + Effect.retry( + effect, + Schedule.max([Schedule.spaced("250 millis"), Schedule.recurs(1)]).pipe( + Schedule.setInputType(), + Schedule.while(({ input }) => + isTransientHttp(input.error) + ? Effect.logWarning(`retrying ${what}`, { server, error: describe(input.error) }).pipe( + Effect.as(true), + ) + : Effect.succeed(false), + ), + ), + ); const http = (endpoint: Http, sessionId?: string) => new StreamableHTTPClientTransport(endpoint.url, { @@ -381,17 +400,15 @@ export const connect = Effect.fn("McpClient.connect")(function* (input: ConnectI ); }).pipe(Effect.withSpan(`McpClient.${method}`, { attributes: { "mcp.server": server } })); const request = (method: string, run: (signal: AbortSignal) => Promise) => settle(method, attempt(run)); - // Listing and reading are read-only, so a transient failure is safe to retry. Tool calls are never - // retried: the server may already have run them. + // Tool calls are never retried: the server may already have run them. const read = (method: string, run: (signal: AbortSignal) => Promise) => - settle(method, retrying(method, attempt(run))); - // Every progress notification restarts the timeout, so long calls that report progress keep running. - const options = (signal: AbortSignal, onProgress?: (progress: Progress) => void) => ({ - signal, - timeout: requestTimeout, - resetTimeoutOnProgress: true, - onprogress: onProgress ?? (() => {}), - }); + settle(method, retryingRead(method, attempt(run))); + // Only a request given a progress handler asks for progress; each notification restarts its timeout, + // so long calls that report progress keep running. + const options = (signal: AbortSignal, onProgress?: (progress: Progress) => void) => + onProgress === undefined + ? { signal, timeout: requestTimeout } + : { signal, timeout: requestTimeout, resetTimeoutOnProgress: true, onprogress: onProgress }; const capabilities = client.getServerCapabilities(); const connection: Connection = { @@ -419,7 +436,7 @@ export const connect = Effect.fn("McpClient.connect")(function* (input: ConnectI ? Effect.succeed({ resourceTemplates: [] }) : settle( "resources/templates/list", - retrying( + retryingRead( "resources/templates/list", attempt((signal) => client.request( diff --git a/packages/harness/src/mcp/stdio.ts b/packages/harness/src/mcp/stdio.ts index d7494674..f69570d2 100644 --- a/packages/harness/src/mcp/stdio.ts +++ b/packages/harness/src/mcp/stdio.ts @@ -83,6 +83,7 @@ export const make = Effect.fn("McpStdio.make")(function* (options: Options) { Process.terminate(handle, { forceKillAfter: FORCE_KILL_AFTER, pipesOpen: state.stdoutOpen || state.stderrOpen, + graceful: true, }), ), ), diff --git a/packages/harness/src/sandbox/utils/process.ts b/packages/harness/src/sandbox/utils/process.ts index 38b70379..4d4e50fb 100644 --- a/packages/harness/src/sandbox/utils/process.ts +++ b/packages/harness/src/sandbox/utils/process.ts @@ -17,43 +17,54 @@ export const unsupported = Layer.succeed( ChildProcessSpawner.make(() => Effect.die(new Error("process execution is not supported by this sandbox"))), ); +const signalGroup = (handle: ChildProcessSpawner.ChildProcessHandle, signal: NodeJS.Signals) => + Effect.sync(() => { + try { + process.kill(-Number(handle.pid), signal); + } catch { + // the group is already gone + } + }); + /** * Stops a spawned process and its group. * * The spawner's own release sends SIGTERM and then waits for exit, so a process * that ignores SIGTERM (and children inheriting that) would make a scope close - * wait forever. This SIGTERMs the group, SIGKILLs it if it outlives + * wait forever. This SIGTERMs the leader, SIGKILLs it if it outlives * `forceKillAfter`, then SIGKILLs whatever is left of the group — children can - * outlive a leader that already exited. Only while the leader runs or one of - * its pipes is still open: either keeps the group id ours, so it cannot have - * been reused by someone else. + * outlive a leader that already exited. + * + * `graceful` gives the rest of the group a SIGTERM first: when the leader has + * already exited it SIGTERMs the group (children that dropped their pipes + * included), and SIGKILLs it `forceKillAfter` later only while a pipe is still + * open. Otherwise an exited leader whose pipes are closed leaves the group alone. */ export const terminate = ( handle: ChildProcessSpawner.ChildProcessHandle, - options: { readonly forceKillAfter: Duration.Input; readonly pipesOpen: boolean }, + options: { readonly forceKillAfter: Duration.Input; readonly pipesOpen: boolean; readonly graceful?: boolean }, ) => Effect.gen(function* () { const running = yield* handle.isRunning.pipe(Effect.orElseSucceed(() => false)); - if (!running && !options.pipesOpen) return; - if (running) { - yield* handle.kill({ killSignal: "SIGTERM" }).pipe( - Effect.timeoutOption(options.forceKillAfter), - Effect.flatMap( - Option.match({ - onSome: () => Effect.void, - onNone: () => handle.kill({ killSignal: "SIGKILL" }), - }), - ), - Effect.ignore, - ); + if (!running) { + if (options.graceful) { + yield* signalGroup(handle, "SIGTERM"); + if (!options.pipesOpen) return; + yield* Effect.sleep(options.forceKillAfter); + } else if (!options.pipesOpen) return; + return yield* signalGroup(handle, "SIGKILL"); } - yield* Effect.sync(() => { - try { - process.kill(-Number(handle.pid), "SIGKILL"); - } catch { - // the group is already gone - } - }); + yield* handle.kill({ killSignal: "SIGTERM" }).pipe( + Effect.timeoutOption(options.forceKillAfter), + Effect.flatMap( + Option.match({ + onSome: () => Effect.void, + onNone: () => handle.kill({ killSignal: "SIGKILL" }), + }), + ), + Effect.ignore, + ); + yield* signalGroup(handle, "SIGKILL"); }); export * as Process from "./process.ts"; From 19af2e98f0afba576a8ebb461c45dde44c23bfc4 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Sat, 24 Oct 2026 21:02:40 +0530 Subject: [PATCH 07/17] fix(harness): receive MCP server log messages from the handshake on The log handler is now a connect option instead of a setter called after the first tool listing, so messages a server sends while starting, or before failing to, are kept. Co-Authored-By: Claude Opus 5.5 --- packages/harness/src/mcp/client.ts | 10 ++----- .../test/__artifacts__/mcp.client.json | 5 ++++ packages/harness/test/fixtures/mcp/server.mjs | 4 +++ packages/harness/test/mcp.client.test.ts | 28 +++++++++++-------- 4 files changed, 29 insertions(+), 18 deletions(-) diff --git a/packages/harness/src/mcp/client.ts b/packages/harness/src/mcp/client.ts index c3772783..7f5daeb3 100644 --- a/packages/harness/src/mcp/client.ts +++ b/packages/harness/src/mcp/client.ts @@ -72,8 +72,6 @@ export interface Connection { readonly onToolsChanged: (handler: Effect.Effect) => void; readonly onResourcesChanged: (handler: Effect.Effect) => void; readonly onSessionExpired: (handler: Effect.Effect) => void; - /** The server's `notifications/message` log entries. */ - readonly onLog: (handler: (entry: LoggingMessageNotificationParams) => Effect.Effect) => void; /** * The connection dropped (for example the server process exited). Runs right away when it already * dropped before the handler was set. Never runs for a close caused by closing the owning scope. @@ -91,6 +89,8 @@ export interface ConnectInput { /** `startup` bounds connecting and initializing, and defaults to `request`. */ readonly timeout?: { readonly startup?: Duration.Input; readonly request?: Duration.Input }; readonly clientInfo?: Implementation; + /** The server's `notifications/message` log entries, from the handshake on: startup failures log too. */ + readonly onLog?: (entry: LoggingMessageNotificationParams) => Effect.Effect; } /** A raw SDK or transport failure, before it is mapped to one of the public errors. */ @@ -163,7 +163,6 @@ export const connect = Effect.fn("McpClient.connect")(function* (input: ConnectI tools: Effect.void as Effect.Effect, resources: Effect.void as Effect.Effect, expired: Effect.void as Effect.Effect, - log: (_entry: LoggingMessageNotificationParams): Effect.Effect => Effect.void, }; // Every connect attempt builds its own client; only the one that connected reports a close. const closed: { @@ -265,7 +264,7 @@ export const connect = Effect.fn("McpClient.connect")(function* (input: ConnectI runFork(handlers.resources); }); client.setNotificationHandler("notifications/message", (notification) => { - runFork(handlers.log(notification.params)); + if (input.onLog) runFork(input.onLog(notification.params)); }); const roots = input.roots; if (roots) { @@ -468,9 +467,6 @@ export const connect = Effect.fn("McpClient.connect")(function* (input: ConnectI onSessionExpired: (handler) => { handlers.expired = handler; }, - onLog: (handler) => { - handlers.log = handler; - }, onClose: (handler) => { closed.handler = handler; if (closed.reason !== undefined) runFork(handler(closed.reason)); diff --git a/packages/harness/test/__artifacts__/mcp.client.json b/packages/harness/test/__artifacts__/mcp.client.json index 6dbc13d3..ca0b1d7f 100644 --- a/packages/harness/test/__artifacts__/mcp.client.json +++ b/packages/harness/test/__artifacts__/mcp.client.json @@ -144,6 +144,11 @@ "afterServerExit": "connection closed: mcp server process exited with code 0", }, "onLog": [ + { + "data": "ready", + "level": "info", + "logger": "startup", + }, { "data": "first line second line", diff --git a/packages/harness/test/fixtures/mcp/server.mjs b/packages/harness/test/fixtures/mcp/server.mjs index 5f9117e8..1138d97f 100644 --- a/packages/harness/test/fixtures/mcp/server.mjs +++ b/packages/harness/test/fixtures/mcp/server.mjs @@ -30,4 +30,8 @@ if (variant === "resources") { }); } else install(server); +// A message sent right after the handshake, before the client has listed anything. +if (capabilities.logging) + server.oninitialized = () => void server.sendLoggingMessage({ level: "info", logger: "startup", data: "ready" }); + await server.connect(new StdioServerTransport()); diff --git a/packages/harness/test/mcp.client.test.ts b/packages/harness/test/mcp.client.test.ts index 114f9fa0..f1405e97 100644 --- a/packages/harness/test/mcp.client.test.ts +++ b/packages/harness/test/mcp.client.test.ts @@ -297,14 +297,16 @@ describe("mcp client", () => { it( "stdio: interrupting a call cancels it on the server and keeps the connection usable", Effect.gen(function* () { - const connection = yield* McpClient.connect({ server: "fixture", endpoint: stdio("server.mjs") }); const entered = yield* Deferred.make(); const acknowledged = yield* Deferred.make(); - connection.onLog((entry) => - entry.data === "hang cancelled" - ? Deferred.succeed(acknowledged, undefined).pipe(Effect.asVoid) - : Effect.void, - ); + const connection = yield* McpClient.connect({ + server: "fixture", + endpoint: stdio("server.mjs"), + onLog: (entry) => + entry.data === "hang cancelled" + ? Deferred.succeed(acknowledged, undefined).pipe(Effect.asVoid) + : Effect.void, + }); const fiber = yield* connection .callTool({ name: "hang", onProgress: () => void Deferred.doneUnsafe(entered, Effect.void) }) .pipe(Effect.forkChild); @@ -732,17 +734,21 @@ describe("mcp client", () => { ); it( - "onLog: receives the server's notifications/message params", + "onLog: receives the server's notifications/message params, from the handshake on", Effect.gen(function* () { - const connection = yield* McpClient.connect({ server: "fixture", endpoint: stdio("server.mjs") }); const entries = yield* Ref.make>([]); - connection.onLog((entry) => Ref.update(entries, (all) => [...all, entry])); + const connection = yield* McpClient.connect({ + server: "fixture", + endpoint: stdio("server.mjs"), + onLog: (entry) => Ref.update(entries, (all) => [...all, entry]), + }); yield* connection.callTool({ name: "log" }); - for (let attempt = 0; attempt < 50 && (yield* Ref.get(entries)).length < 2; attempt++) + for (let attempt = 0; attempt < 50 && (yield* Ref.get(entries)).length < 3; attempt++) yield* Effect.sleep("20 millis"); const received = yield* Ref.get(entries); summary.onLog = received; - expect(received).toEqual(logEntries); + // The handshake's own message arrives before any the client asked for. + expect(received).toEqual([{ level: "info", logger: "startup", data: "ready" }, ...logEntries]); }), ); From d9bb7e97af839fde810eb65d2183b95b539c5b0a Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Sat, 24 Oct 2026 22:22:10 +0530 Subject: [PATCH 08/17] fix(harness): report an MCP list change that arrives before its handler A tools/list_changed sent while the first listing is answered used to reach no handler, leaving the old list in place; it is now reported once the handler is set. Co-Authored-By: Claude Opus 5.5 --- packages/harness/src/mcp/client.ts | 34 +++++++++++-------- .../test/__artifacts__/mcp.client.json | 3 ++ packages/harness/test/fixtures/mcp/tools.mjs | 8 ++++- packages/harness/test/mcp.client.test.ts | 16 +++++++++ 4 files changed, 46 insertions(+), 15 deletions(-) diff --git a/packages/harness/src/mcp/client.ts b/packages/harness/src/mcp/client.ts index 7f5daeb3..abd08ea1 100644 --- a/packages/harness/src/mcp/client.ts +++ b/packages/harness/src/mcp/client.ts @@ -160,10 +160,24 @@ export const connect = Effect.fn("McpClient.connect")(function* (input: ConnectI // Handlers the owner registers after connect; notifications can arrive before that. const handlers = { - tools: Effect.void as Effect.Effect, - resources: Effect.void as Effect.Effect, + tools: undefined as Effect.Effect | undefined, + resources: undefined as Effect.Effect | undefined, expired: Effect.void as Effect.Effect, }; + // A list that changed before its handler was set, during the first listing say, is reported once + // the handler is set: the owner may be holding the list from before the change. + const missed = { tools: false, resources: false }; + const changed = (list: "tools" | "resources") => { + const handler = handlers[list]; + if (handler === undefined) missed[list] = true; + else runFork(handler); + }; + const listen = (list: "tools" | "resources", handler: Effect.Effect) => { + handlers[list] = handler; + if (!missed[list]) return; + missed[list] = false; + runFork(handler); + }; // Every connect attempt builds its own client; only the one that connected reports a close. const closed: { shuttingDown: boolean; @@ -257,12 +271,8 @@ export const connect = Effect.fn("McpClient.connect")(function* (input: ConnectI else if (!early.has(client)) early.set(client, reason); }; // Handled whether or not the server advertises `listChanged`, since some servers send them anyway. - client.setNotificationHandler("notifications/tools/list_changed", () => { - runFork(handlers.tools); - }); - client.setNotificationHandler("notifications/resources/list_changed", () => { - runFork(handlers.resources); - }); + client.setNotificationHandler("notifications/tools/list_changed", () => changed("tools")); + client.setNotificationHandler("notifications/resources/list_changed", () => changed("resources")); client.setNotificationHandler("notifications/message", (notification) => { if (input.onLog) runFork(input.onLog(notification.params)); }); @@ -458,12 +468,8 @@ export const connect = Effect.fn("McpClient.connect")(function* (input: ConnectI request("tools/call", (signal) => client.callTool({ name: call.name, arguments: call.args ?? {} }, options(signal, call.onProgress)), ), - onToolsChanged: (handler) => { - handlers.tools = handler; - }, - onResourcesChanged: (handler) => { - handlers.resources = handler; - }, + onToolsChanged: (handler) => listen("tools", handler), + onResourcesChanged: (handler) => listen("resources", handler), onSessionExpired: (handler) => { handlers.expired = handler; }, diff --git a/packages/harness/test/__artifacts__/mcp.client.json b/packages/harness/test/__artifacts__/mcp.client.json index ca0b1d7f..397353d6 100644 --- a/packages/harness/test/__artifacts__/mcp.client.json +++ b/packages/harness/test/__artifacts__/mcp.client.json @@ -120,6 +120,9 @@ "message": "Invalid result for tools/list: tools.1.inputSchema: Invalid input: expected object, received undefined", "tag": "Mcp.RequestError", }, + "listChangedWhileListing": { + "toolsChanged": true, + }, "logFile": [ " [fixture] info first line", " second line", diff --git a/packages/harness/test/fixtures/mcp/tools.mjs b/packages/harness/test/fixtures/mcp/tools.mjs index 40c46448..2a404216 100644 --- a/packages/harness/test/fixtures/mcp/tools.mjs +++ b/packages/harness/test/fixtures/mcp/tools.mjs @@ -45,7 +45,13 @@ const tools = [ export const install = (server) => { const cancelled = []; - server.setRequestHandler("tools/list", () => ({ tools })); + let listed = false; + server.setRequestHandler("tools/list", async () => { + // MCP_FIXTURE_CHANGE_ON_LIST: the list changes while the first listing is answered. + if (process.env.MCP_FIXTURE_CHANGE_ON_LIST && !listed) await server.sendToolListChanged(); + listed = true; + return { tools }; + }); server.setRequestHandler("tools/call", async (request, ctx) => { const args = request.params.arguments ?? {}; const signal = ctx.mcpReq.signal; diff --git a/packages/harness/test/mcp.client.test.ts b/packages/harness/test/mcp.client.test.ts index f1405e97..613bdd34 100644 --- a/packages/harness/test/mcp.client.test.ts +++ b/packages/harness/test/mcp.client.test.ts @@ -63,6 +63,7 @@ const scenarios = [ "stubborn", "toolsPagination", "unadvertisedListChanged", + "listChangedWhileListing", "unauthorized", ]; @@ -522,6 +523,21 @@ describe("mcp client", () => { }), ); + it( + "tools: a list_changed during the first listing reaches a handler set after it", + Effect.gen(function* () { + const connection = yield* McpClient.connect({ + server: "fixture", + endpoint: stdio("server.mjs", { MCP_FIXTURE_CHANGE_ON_LIST: "1" }), + }); + yield* connection.tools(); + const changed = yield* Deferred.make(); + connection.onToolsChanged(Deferred.succeed(changed, undefined).pipe(Effect.asVoid)); + yield* Deferred.await(changed).pipe(Effect.timeout("2 seconds")); + summary.listChangedWhileListing = { toolsChanged: true }; + }), + ); + it( "tools: list_changed from a server without tools.listChanged still reaches onToolsChanged", Effect.gen(function* () { From 82c416d28891fb4e4564193bb2609e36a1e8cb74 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Wed, 14 Oct 2026 23:49:31 +0530 Subject: [PATCH 09/17] feat(harness): run MCP servers per session and declare their tools Reads mcp.json from the user and project layers, keeps each session's servers connected across exchanges, and declares their tools to the model. - mcp.json: project entries replace user ones or override only enabled, exposure and toolExposure; broken files and entries are reported and skipped. env and headers resolve ${VAR} and !command values. - Plugins get a ctx.plugin.mcp bucket in a new mcp domain that runs first; it is settled before the tool domain, so tool plugins see MCP tools. - Tool definitions are frozen per exchange; each call uses the server's current connection. A dropped connection reconnects on the next call, an expired HTTP session is replaced and the call retried once, tool calls are never retried otherwise. - Tools are named mcp____ with a hash suffix on collisions; exposure is direct or hidden. - Tool.fromJsonSchema sends a server's JSON Schema to the provider as written and validates arguments against its Effect translation. Co-Authored-By: Claude Opus 5.5 --- packages/harness/src/effect/harness.ts | 3 + packages/harness/src/mcp/client.ts | 3 + packages/harness/src/mcp/config.ts | 150 +++++++ packages/harness/src/mcp/mcp.ts | 414 ++++++++++++++++++ packages/harness/src/mcp/tool.ts | 161 +++++++ packages/harness/src/mcp/value.ts | 113 +++++ packages/harness/src/plugin/host.ts | 15 +- packages/harness/src/plugin/mcp/registry.ts | 57 +++ packages/harness/src/plugin/registry.ts | 11 +- packages/harness/src/settings/settings.ts | 31 +- packages/harness/src/state/state.ts | 40 +- packages/harness/src/util/jsonc.ts | 20 + .../test/__artifacts__/mcp.session.json | 135 ++++++ .../harness/test/fixtures/mcp/session.mjs | 43 ++ .../test/fixtures/runner.cycle.spec.ts | 3 + packages/harness/test/mcp.session.test.ts | 188 ++++++++ packages/harness/test/plugin.host.test.ts | 2 +- packages/harness/test/runner.loop.test.ts | 3 + packages/plugin/package.json | 6 + packages/plugin/src/plugin.ts | 1 + packages/plugin/src/plugin/mcp.ts | 69 +++ packages/plugin/src/plugin/plugin.ts | 11 +- packages/plugin/src/plugin/registry.ts | 3 + packages/plugin/src/settings.ts | 19 + packages/plugin/src/tool.ts | 27 +- packages/plugin/vite.config.ts | 1 + 26 files changed, 1487 insertions(+), 42 deletions(-) create mode 100644 packages/harness/src/mcp/config.ts create mode 100644 packages/harness/src/mcp/mcp.ts create mode 100644 packages/harness/src/mcp/tool.ts create mode 100644 packages/harness/src/mcp/value.ts create mode 100644 packages/harness/src/plugin/mcp/registry.ts create mode 100644 packages/harness/src/util/jsonc.ts create mode 100644 packages/harness/test/__artifacts__/mcp.session.json create mode 100644 packages/harness/test/fixtures/mcp/session.mjs create mode 100644 packages/harness/test/mcp.session.test.ts create mode 100644 packages/plugin/src/plugin/mcp.ts diff --git a/packages/harness/src/effect/harness.ts b/packages/harness/src/effect/harness.ts index a9dd1878..e0522354 100644 --- a/packages/harness/src/effect/harness.ts +++ b/packages/harness/src/effect/harness.ts @@ -13,6 +13,7 @@ import { anchor } from "../plugin/loader.ts"; import { PluginSource } from "../plugin/source.ts"; import { PluginStore } from "../plugin/store.ts"; import { RunnerExecute } from "../runner/execute.ts"; +import { Mcp } from "../mcp/mcp.ts"; import { LLM } from "../runner/llm.ts"; import { Loop } from "../runner/loop.ts"; import { SandboxController } from "../sandbox/control.ts"; @@ -21,6 +22,7 @@ import { MemorySandboxDriver } from "../sandbox/drivers/memory.ts"; import { SqldbSandboxDriver } from "../sandbox/drivers/sqldb.ts"; import { SandboxDriverLoader } from "../sandbox/loader.ts"; import { SandboxDriverRegistry } from "../sandbox/registry.ts"; +import { Process } from "../sandbox/utils/process.ts"; import { SessionLive } from "../session/live.ts"; import { SessionRuntime } from "../session/runtime.ts"; import type { Info as SettingsInfo } from "../settings/schema.ts"; @@ -253,6 +255,7 @@ export const layer = (options: Options = {}) => return Control.layer.pipe( Layer.provideMerge(RunnerExecute.layer.pipe(Layer.provide(loop))), Layer.provideMerge(State.layer({}, pool, references, followStore, rebuild, viewOf)), + Layer.provideMerge(Mcp.layer({ hostCwd }).pipe(Layer.provide(Process.host))), Layer.provideMerge(Settings.layer(settingsOptions)), Layer.provideMerge(SessionRuntime.layer), Layer.provideMerge(sandboxes), diff --git a/packages/harness/src/mcp/client.ts b/packages/harness/src/mcp/client.ts index abd08ea1..7ce84607 100644 --- a/packages/harness/src/mcp/client.ts +++ b/packages/harness/src/mcp/client.ts @@ -77,6 +77,8 @@ export interface Connection { * dropped before the handler was set. Never runs for a close caused by closing the owning scope. */ readonly onClose: (handler: (reason: string) => Effect.Effect) => void; + /** Why the connection dropped, as soon as it has, even before `onClose` handlers run. */ + readonly dropped: () => string | undefined; } export interface ConnectInput { @@ -477,6 +479,7 @@ export const connect = Effect.fn("McpClient.connect")(function* (input: ConnectI closed.handler = handler; if (closed.reason !== undefined) runFork(handler(closed.reason)); }, + dropped: () => closed.reason, }; return connection; }); diff --git a/packages/harness/src/mcp/config.ts b/packages/harness/src/mcp/config.ts new file mode 100644 index 00000000..9a5fe536 --- /dev/null +++ b/packages/harness/src/mcp/config.ts @@ -0,0 +1,150 @@ +/** + * MCP server configuration: `mcp.json` in the user config directory and in the project's + * `.codework/`, in the `mcpServers` shape other MCP clients use, so an existing entry can be copied + * over. A project entry replaces a user entry with the same name. + * + * A project entry without `command`, `url` or `type` overrides only `enabled`, `exposure` and + * `toolExposure` of the user server with the same name, for example to turn it off in one project: + * `{ "mcpServers": { "internal-tools": { "enabled": false } } }`. The rest of the user entry is kept, + * including credentials the project could not set itself. + * + * Unlike `settings.jsonc`, a broken file or entry does not fail the read: it is reported and + * skipped, so one bad server never keeps the others from connecting. + */ + +import type { ServerConfig } from "@codeworksh/plugin/plugin/mcp"; +import type { McpServer, McpSettings } from "@codeworksh/plugin/settings"; +import { Effect, Predicate, Result } from "effect"; +import { Global } from "../global.ts"; +import { fileSystem, hostPath } from "../host.ts"; +import * as Jsonc from "../util/jsonc.ts"; + +const SERVER_NAME = /^[A-Za-z0-9_-]+$/; +const EXPOSURES: ReadonlyArray = ["direct", "hidden"]; +const OVERRIDE_KEYS: ReadonlyArray = ["enabled", "exposure", "toolExposure"]; + +/** Names that differ only in `-` and `_` would give their tools the same names. */ +export const namespace = (server: string) => `mcp__${server.replace(/-/g, "_")}`; + +const isRecord = (value: unknown): value is Record => + Predicate.isObject(value) && !Array.isArray(value); +const isStringRecord = (value: unknown): value is Record => + isRecord(value) && Object.values(value).every(Predicate.isString); + +/** Whether a project entry overrides a user server instead of defining one. */ +const isOverride = (value: Record) => + value.command === undefined && value.url === undefined && value.type === undefined; + +/** One `mcpServers` entry, checked. Returns the config, or what is wrong with it. */ +export const validate = (name: string, value: unknown): ServerConfig | string => { + if (!SERVER_NAME.test(name)) return `invalid server name "${name}" (use letters, digits, "_" and "-")`; + if (!isRecord(value)) return `server "${name}" must be an object`; + const { type, exposure, enabled, timeout, toolExposure, description } = value; + const exposures = EXPOSURES.map((one) => `"${one}"`).join(", "); + if (exposure !== undefined && !EXPOSURES.includes(exposure as string)) + return `server "${name}": exposure must be one of ${exposures}`; + if (toolExposure !== undefined) { + if (!isRecord(toolExposure)) return `server "${name}": toolExposure must map tool names to exposures`; + for (const [tool, one] of Object.entries(toolExposure)) { + if (!EXPOSURES.includes(one as string)) + return `server "${name}": toolExposure "${tool}" must be one of ${exposures}`; + } + } + if (enabled !== undefined && !Predicate.isBoolean(enabled)) return `server "${name}": enabled must be a boolean`; + if (description !== undefined && !Predicate.isString(description)) + return `server "${name}": description must be a string`; + if (timeout !== undefined && !(Predicate.isNumber(timeout) && timeout > 0)) + return `server "${name}": timeout must be a positive number of seconds`; + if (type === "sse") return `server "${name}": legacy SSE transport is not supported; use the streamable HTTP URL`; + + if (Predicate.isString(value.url) && (type === undefined || type === "http" || type === "streamable-http")) { + if (!URL.canParse(value.url) || !/^https?:$/.test(new URL(value.url).protocol)) + return `server "${name}": url must be an http or https URL`; + if (value.headers !== undefined && !isStringRecord(value.headers)) + return `server "${name}": headers must map names to strings`; + return value as unknown as ServerConfig; + } + if (Predicate.isString(value.command) && (type === undefined || type === "stdio")) { + if (value.args !== undefined && !(Array.isArray(value.args) && value.args.every(Predicate.isString))) + return `server "${name}": args must be an array of strings`; + if (value.env !== undefined && !isStringRecord(value.env)) + return `server "${name}": env must map names to strings`; + if (value.cwd !== undefined && !Predicate.isString(value.cwd)) return `server "${name}": cwd must be a string`; + return value as unknown as ServerConfig; + } + return `server "${name}" needs either "command" (stdio) or "url" (streamable HTTP)`; +}; + +/** The server whose name differs from `name` only in `-` and `_`, if there is one. */ +export const clash = (names: Iterable, name: string) => { + for (const other of names) if (other !== name && namespace(other) === namespace(name)) return other; + return undefined; +}; + +interface State { + readonly servers: Map; + readonly errors: string[]; +} + +const read = Effect.fnUntraced(function* (path: string, scope: "user" | "project", state: State) { + const source = yield* Effect.result(fileSystem.readFileString(path)); + if (Result.isFailure(source)) { + const reason = source.failure.reason._tag; + if (reason !== "NotFound") state.errors.push(`${path}: cannot be read: ${reason}`); + return; + } + const document = Jsonc.parse(source.success); + if ("error" in document) { + state.errors.push(`${path}: ${document.error}`); + return; + } + const parsed = document.value; + if (!isRecord(parsed) || (parsed.mcpServers !== undefined && !isRecord(parsed.mcpServers))) { + state.errors.push(`${path}: expected an object with an "mcpServers" object`); + return; + } + const { servers, errors: problems } = state; + for (const [name, value] of Object.entries(parsed.mcpServers ?? {})) { + if (scope === "project" && isRecord(value) && isOverride(value)) { + const base = servers.get(name); + const extra = Object.keys(value).filter((key) => !OVERRIDE_KEYS.includes(key)); + if (!base) problems.push(`${path}: server "${name}" needs "command" or "url", or a user server to override`); + else if (extra.length > 0) + problems.push(`${path}: server "${name}": an override can only set ${OVERRIDE_KEYS.join(", ")}`); + else { + const config = validate(name, { ...base.config, ...value }); + if (Predicate.isString(config)) problems.push(`${path}: ${config}`); + else servers.set(name, { ...base, config, override: path }); + } + continue; + } + const config = validate(name, value); + if (Predicate.isString(config)) { + problems.push(`${path}: ${config}`); + continue; + } + const other = clash(servers.keys(), name); + if (other !== undefined) { + problems.push(`${path}: server "${name}" conflicts with "${other}"`); + continue; + } + servers.set(name, { name, config, file: path }); + } +}); + +/** + * Both layers. `userDir` is where the user `settings.jsonc` is read from; `root` is the session's + * project root, absent for a session with none. + */ +export const load = Effect.fn("Mcp.loadConfig")(function* (input: { + readonly userDir: string; + readonly root?: string | undefined; +}) { + const state: State = { servers: new Map(), errors: [] }; + yield* read(hostPath.join(input.userDir, "mcp.json"), "user", state); + if (input.root !== undefined) + yield* read(hostPath.join(input.root, Global.appConfigDir, "mcp.json"), "project", state); + return { servers: [...state.servers.values()], errors: state.errors } satisfies McpSettings; +}); + +export * as McpConfig from "./config.ts"; diff --git a/packages/harness/src/mcp/mcp.ts b/packages/harness/src/mcp/mcp.ts new file mode 100644 index 00000000..39929729 --- /dev/null +++ b/packages/harness/src/mcp/mcp.ts @@ -0,0 +1,414 @@ +/** + * The MCP servers each session runs, kept connected across its exchanges. + * + * Every exchange hands over the session's servers as its `mcp` plugins left them ({@link + * Interface.reconcile}); servers whose connection settings changed restart, removed ones close, new + * ones connect in the background. The tools an exchange registers are the ones the servers offered + * at that moment, frozen with the rest of the exchange; the connection behind a tool is looked up + * when it is called: + * + * - A connection that drops fails the calls running on it, and the next call reconnects. + * - A Streamable HTTP session the server forgot is replaced, and calls that hit it run once more on + * the new one. The old connection closes when its last call finishes, never under one. + * - A tool call is never retried after the server may have run it. + * - A changed tool list takes effect at the next exchange; a call to a withdrawn tool says so. + * + * Each connection lives in its own scope under the service's, so closing it, or shutting the + * service down, stops the server process and ends the HTTP session. + */ + +import type { ServerConfig } from "@codeworksh/plugin/plugin/mcp"; +import type { CallToolResult, Tool } from "@modelcontextprotocol/client"; +import { Cause, Context, Deferred, Duration, Effect, Exit, FiberSet, Layer, Schema, Scope, Semaphore } from "effect"; +import type { ChildProcessSpawner } from "effect/process"; +import { NeedsAuthError } from "./error.ts"; +import type { ID as SessionId } from "../session/schema.ts"; +import type { RegisteredTool } from "../tool/tool.ts"; +import { McpClient } from "./client.ts"; +import { McpTool } from "./tool.ts"; +import { McpValue } from "./value.ts"; + +/** The first exchange of a session waits this long for servers whose tools are declared to the model. */ +const STARTUP_WAIT = Duration.seconds(10); +const DEFAULT_TIMEOUT_SECONDS = 60; + +export type State = "connecting" | "connected" | "disconnected" | "failed" | "needs-auth" | "disabled"; + +export interface Status { + readonly server: string; + readonly state: State; + readonly error?: string; + /** Tools the server offered when last listed. */ + readonly tools: number; +} + +export interface Input { + readonly sessionId: SessionId; + readonly servers: ReadonlyArray; + /** + * Where stdio servers start, and the root sent to servers: the session's directory when its + * sandbox is the host's own filesystem. Absent otherwise, since that directory does not exist + * here; servers then start in the session's host directory and are sent no roots. + */ + readonly directory?: string | undefined; + readonly hostDir?: string | undefined; + /** Configuration problems from `mcp.json`, reported once per session. */ + readonly errors?: ReadonlyArray; +} + +export interface Interface { + /** Settle a session's servers for its next exchange, and return the tools that exchange declares. */ + readonly reconcile: (input: Input) => Effect.Effect>; + readonly status: (sessionId: SessionId) => Effect.Effect>; +} + +export class Service extends Context.Service()("@codeworksh/harness/mcp/mcp/Service") {} + +interface Live { + readonly connection: McpClient.Connection; + readonly scope: Scope.Closeable; + inflight: number; + retired: boolean; +} + +interface Server { + readonly name: string; + config: ServerConfig; + /** Everything a connection is made from; a change restarts the server. */ + readonly key: string; + readonly cwd: string; + readonly roots: ReadonlyArray | undefined; + state: State; + error: string | undefined; + tools: ReadonlyArray; + live: Live | undefined; + /** Settles when the attempt in progress, if any, finishes. */ + ready: Deferred.Deferred | undefined; + closed: boolean; + readonly lock: Semaphore.Semaphore; +} + +interface Session { + readonly servers: Map; + readonly names: ReturnType; + readonly lock: Semaphore.Semaphore; + readonly reported: Set; + waited: boolean; +} + +/** The parts of a config that decide the connection; exposure and description do not. */ +const connectionKey = (config: ServerConfig, cwd: string, roots: ReadonlyArray | undefined) => { + const { exposure: _exposure, toolExposure: _tools, description: _description, enabled, ...rest } = config; + return JSON.stringify({ rest, enabled: enabled !== false, cwd, roots }); +}; + +const hasDirectTools = (config: ServerConfig) => + (config.exposure ?? "direct") === "direct" || + Object.values(config.toolExposure ?? {}).some((exposure) => exposure === "direct"); + +export const layer = (options: { readonly hostCwd: string }) => + Layer.effect( + Service, + Effect.gen(function* () { + const root = yield* Effect.scope; + const fork = yield* FiberSet.makeRuntime(); + const context = yield* Effect.context(); + const sessions = new Map(); + // Shared remote endpoints should not see a burst of concurrent startups. + const endpoints = new Map(); + const endpointLock = (url: string) => { + let lock = endpoints.get(url); + if (lock === undefined) endpoints.set(url, (lock = Semaphore.makeUnsafe(1))); + return lock; + }; + + const release = (live: Live) => + live.retired && live.inflight === 0 ? Scope.close(live.scope, Exit.void) : Effect.void; + + /** Stop handing out this connection; it closes once no call runs on it. */ + const retire = (server: Server, live: Live) => + Effect.suspend(() => { + live.retired = true; + if (server.live === live) server.live = undefined; + return release(live); + }); + + const endpoint = Effect.fnUntraced(function* (server: Server) { + const config = server.config; + const what = `MCP server "${server.name}"`; + if ("url" in config) + return { + _tag: "Http", + url: new URL(config.url), + headers: yield* McpValue.resolveAll(config.headers ?? {}, `${what} header`), + } satisfies McpClient.Http; + return { + _tag: "Stdio", + command: config.command, + args: config.args ?? [], + env: yield* McpValue.resolveAll(config.env ?? {}, `${what} env`), + cwd: server.cwd, + } satisfies McpClient.Stdio; + }); + + const watch = (server: Server, live: Live) => { + const current = (effect: Effect.Effect) => + Effect.suspend(() => (server.live === live ? effect : Effect.void)).pipe(server.lock.withPermits(1)); + live.connection.onClose((reason) => + current( + Effect.gen(function* () { + server.state = "disconnected"; + server.error = reason; + yield* retire(server, live); + }), + ), + ); + // Calls replace an expired session themselves; this covers a background request seeing it first. + live.connection.onSessionExpired(current(retire(server, live))); + live.connection.onToolsChanged( + current( + live.connection.tools().pipe( + Effect.tap((tools) => + Effect.sync(() => { + server.tools = tools; + }), + ), + Effect.catch((error) => + Effect.sync(() => { + server.error = `failed to refresh tools: ${error.message}`; + }), + ), + ), + ), + ); + }; + + /** The server's current connection, connecting first when it has none. */ + const open = (server: Server): Effect.Effect => + Effect.gen(function* () { + if (server.closed) return yield* Effect.fail(`MCP server "${server.name}" is shut down`); + const current = server.live; + // A drop is known here before its `onClose` handler has run. + const reason = current?.connection.dropped(); + if (current !== undefined && reason === undefined) return current; + if (current !== undefined) { + server.state = "disconnected"; + server.error = reason; + yield* retire(server, current); + } + server.state = "connecting"; + const scope = yield* Scope.fork(root, "sequential"); + const config = server.config; + const connect = Effect.gen(function* () { + const target = yield* endpoint(server); + const connection = yield* McpClient.connect({ + server: server.name, + endpoint: target, + ...(server.roots === undefined ? {} : { roots: server.roots }), + timeout: { request: Duration.seconds(config.timeout ?? DEFAULT_TIMEOUT_SECONDS) }, + }).pipe(Scope.provide(scope)); + const tools = yield* connection.tools(); + return { connection, tools }; + }); + const exit = yield* ("url" in config ? endpointLock(config.url).withPermit(connect) : connect).pipe( + // An interrupted exchange stops the attempt; the next call starts a fresh one. + Effect.onInterrupt(() => Scope.close(scope, Exit.void)), + Effect.exit, + ); + if (Exit.isFailure(exit)) { + yield* Scope.close(scope, Exit.void); + const error = Cause.squash(exit.cause); + const message = + typeof error === "string" ? error : error instanceof Error ? error.message : String(error); + server.state = Schema.is(NeedsAuthError)(error) ? "needs-auth" : "failed"; + server.error = message; + yield* Effect.logWarning("mcp connect failed", { server: server.name, error: message }); + return yield* Effect.fail(message); + } + const live: Live = { connection: exit.value.connection, scope, inflight: 0, retired: false }; + server.live = live; + server.tools = exit.value.tools; + server.state = "connected"; + server.error = undefined; + watch(server, live); + yield* Effect.logInfo("mcp connected", { server: server.name, tools: server.tools.length }); + return live; + }).pipe(server.lock.withPermits(1), Effect.provide(context)); + + const close = (server: Server) => + Effect.gen(function* () { + server.closed = true; + const live = server.live; + if (live !== undefined) yield* retire(server, live); + }).pipe(server.lock.withPermits(1)); + + /** One call of one of the server's tools, on whichever connection is current when it runs. */ + const call = + (session: Session, name: string, tool: string) => + (input: McpTool.Call): Effect.Effect => + Effect.gen(function* () { + for (let attempt = 1; ; attempt++) { + const server = session.servers.get(name); + if (server === undefined || server.closed || server.config.enabled === false) + return yield* Effect.fail(`MCP server "${name}" is disabled.`); + if ( + McpTool.exposure(server.config, tool) === "hidden" || + (server.live !== undefined && !server.tools.some((offered) => offered.name === tool)) + ) + return yield* Effect.fail(`MCP tool "${name}/${tool}" is no longer available.`); + const live = yield* open(server).pipe( + Effect.mapError((error) => `MCP server "${name}" is not connected: ${error}`), + ); + live.inflight += 1; + const exit = yield* live.connection + .callTool({ name: tool, args: input.args, onProgress: input.onProgress }) + .pipe( + Effect.exit, + Effect.ensuring( + Effect.suspend(() => { + live.inflight -= 1; + return release(live); + }), + ), + ); + if (Exit.isSuccess(exit)) return exit.value; + const error = Cause.findErrorOption(exit.cause); + if (error._tag === "None") return yield* Effect.failCause(exit.cause as Cause.Cause); + const failure = error.value; + // The server no longer knows the session, so it did not run the call. + if (failure._tag === "Mcp.SessionExpiredError" && attempt === 1) { + yield* retire(server, live).pipe(server.lock.withPermits(1)); + continue; + } + if (failure._tag === "Mcp.NeedsAuthError") { + yield* Effect.gen(function* () { + server.state = "needs-auth"; + server.error = failure.message; + yield* retire(server, live); + }).pipe(server.lock.withPermits(1)); + return yield* Effect.fail(`MCP server "${name}" needs sign-in: ${failure.message}`); + } + return yield* Effect.fail(`MCP tool "${name}/${tool}" failed: ${failure.message}`); + } + }); + + /** Connect in the background; `ready` settles when the attempt does. */ + const start = (server: Server) => { + if (server.config.enabled === false) { + server.state = "disabled"; + return; + } + const ready = Deferred.makeUnsafe(); + server.ready = ready; + fork(open(server).pipe(Effect.ignore, Effect.ensuring(Deferred.succeed(ready, undefined)))); + }; + + const sessionOf = (sessionId: SessionId) => { + let session = sessions.get(sessionId); + if (session === undefined) { + session = { + servers: new Map(), + names: McpTool.names(), + lock: Semaphore.makeUnsafe(1), + reported: new Set(), + waited: false, + }; + sessions.set(sessionId, session); + } + return session; + }; + + const reconcile = Effect.fn("Mcp.reconcile")(function* (input: Input) { + const session = sessionOf(input.sessionId); + for (const error of input.errors ?? []) { + if (session.reported.has(error)) continue; + session.reported.add(error); + yield* Effect.logWarning(`mcp config: ${error}`); + } + const cwd = input.directory ?? input.hostDir ?? options.hostCwd; + const roots = input.directory === undefined ? undefined : [input.directory]; + const wanted = new Map(input.servers); + yield* Effect.gen(function* () { + for (const [name, server] of session.servers) { + const config = wanted.get(name); + // Exposure and description apply to the next exchange without reconnecting. + if (config !== undefined && connectionKey(config, cwd, roots) === server.key) { + server.config = config; + continue; + } + session.servers.delete(name); + yield* close(server); + } + for (const [name, config] of wanted) { + if (session.servers.has(name)) continue; + const server: Server = { + name, + config, + key: connectionKey(config, cwd, roots), + cwd, + roots, + state: "connecting", + error: undefined, + tools: [], + live: undefined, + ready: undefined, + closed: false, + lock: Semaphore.makeUnsafe(1), + }; + session.servers.set(name, server); + start(server); + } + }).pipe(session.lock.withPermits(1)); + + // Wait so a slow server's tools are still declared in the first request, but never + // indefinitely: one hanging server must not hold up the prompt. + if (!session.waited) { + session.waited = true; + const pending = [...session.servers.values()].filter( + (server) => server.config.enabled !== false && hasDirectTools(server.config), + ); + const finished = yield* Effect.forEach(pending, (server) => + server.ready === undefined ? Effect.void : Deferred.await(server.ready), + ).pipe(Effect.timeoutOption(STARTUP_WAIT)); + if (finished._tag === "None") + yield* Effect.logInfo("mcp servers are still connecting; their tools appear once connected"); + } + + const tools: RegisteredTool[] = []; + for (const server of session.servers.values()) { + if (server.config.enabled === false || server.tools.length === 0) continue; + const names = session.names( + server.name, + server.tools.map((tool) => tool.name), + ); + for (const tool of server.tools) { + if (McpTool.exposure(server.config, tool.name) !== "direct") continue; + tools.push( + McpTool.make({ + server: server.name, + tool, + name: names.get(tool.name)!, + call: call(session, server.name, tool.name), + }), + ); + } + } + return tools; + }); + + return Service.of({ + reconcile, + status: (sessionId) => + Effect.sync(() => + [...(sessions.get(sessionId)?.servers.values() ?? [])].map((server) => ({ + server: server.name, + state: server.state, + ...(server.error === undefined ? {} : { error: server.error }), + tools: server.tools.length, + })), + ), + }); + }), + ); + +export * as Mcp from "./mcp.ts"; diff --git a/packages/harness/src/mcp/tool.ts b/packages/harness/src/mcp/tool.ts new file mode 100644 index 00000000..3ba56aef --- /dev/null +++ b/packages/harness/src/mcp/tool.ts @@ -0,0 +1,161 @@ +/** + * MCP tools as harness tools: their names, which of them the model sees, and how a call's result + * reaches it. + */ + +import type { Exposure, ServerConfig } from "@codeworksh/plugin/plugin/mcp"; +import type { CallToolResult, Tool as McpTool } from "@modelcontextprotocol/client"; +import { Effect, Schema } from "effect"; +import { ToolProgress } from "../tool/progress.ts"; +import * as Tool from "../tool/tool.ts"; +import { Hash } from "../util/hash.ts"; +import { McpContent } from "./content.ts"; +import type { Progress } from "./client.ts"; + +/** Provider tool names are limited to 64 characters of `[A-Za-z0-9_-]`. */ +const MAX_NAME_LENGTH = 64; + +/** + * `mcp____`, with everything but `[A-Za-z0-9_]` replaced by `_`. A name that is too + * long, or that `taken` reports as used by another MCP tool, ends in a hash of the server and tool + * instead: sanitizing can map two tools to one name (`a-b` and `a_b`). + */ +export const name = (server: string, tool: string, taken: (candidate: string) => boolean = () => false) => { + const plain = `mcp__${server}__${tool}`.replace(/[^A-Za-z0-9_]/g, "_"); + if (plain.length <= MAX_NAME_LENGTH && !taken(plain)) return plain; + const suffix = Hash.sha256(`${server}\0${tool}`).slice(0, 8); + return `${plain.slice(0, MAX_NAME_LENGTH - suffix.length - 1)}_${suffix}`; +}; + +/** + * The tool names of one session. A name, once given to a server's tool, stays with it for the + * session, so a reconnect or a changed tool list never moves a name to a different tool. + */ +export const names = () => { + const owners = new Map(); + /** Names for `tools`, every tool of `server` the server offers right now. */ + return (server: string, tools: ReadonlyArray): ReadonlyMap => { + const assigned = new Map(); + const used = new Set(); + // Every tool whose sanitized name collides gets the suffix, so which one would keep the plain + // name does not depend on the order of the list. + const plain = [...new Set(tools)].map((tool) => name(server, tool)); + for (const tool of new Set(tools)) { + const owner = `${server}\0${tool}`; + const given = name(server, tool, (candidate) => { + const existing = owners.get(candidate); + return ( + (existing !== undefined && existing !== owner) || + used.has(candidate) || + plain.indexOf(candidate) !== plain.lastIndexOf(candidate) + ); + }); + owners.set(given, owner); + used.add(given); + assigned.set(tool, given); + } + return assigned; + }; +}; + +const pattern = (glob: string) => + new RegExp( + `^${glob + .split("*") + .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")) + .join(".*")}$`, + ); + +/** One tool's exposure: its `toolExposure` entry (exact name first, then patterns), else the server's. */ +export const exposure = (config: ServerConfig, tool: string): Exposure => { + const overrides = config.toolExposure ?? {}; + const exact = overrides[tool]; + if (exact !== undefined) return exact; + for (const [glob, value] of Object.entries(overrides)) { + if (glob.includes("*") && pattern(glob).test(tool)) return value; + } + return config.exposure ?? "direct"; +}; + +/** What the model reads, kept the same for a result and for a failure. */ +const Outcome = Schema.Struct({ + content: Schema.Array(Schema.Unknown), + server: Schema.String, + tool: Schema.String, + structuredContent: Schema.optional(Schema.Unknown), +}); +type Outcome = typeof Outcome.Type; + +/** Tool input schemas must be objects; servers may omit `type`, and some providers want `properties`. */ +const parameters = (schema: McpTool["inputSchema"]): Record => ({ + ...schema, + type: schema.type ?? "object", + ...(schema.properties === undefined ? { properties: {} } : {}), +}); + +export interface Call { + readonly args: Record; + readonly onProgress: (progress: Progress) => void; +} + +/** + * A server tool as a harness tool. `call` resolves the server's current connection when it runs, so + * the definition can outlive the connection it was listed on. A failed call and a result the server + * flags `isError` both reach the model as an error result. + */ +export const make = (input: { + readonly server: string; + readonly tool: McpTool; + readonly name: string; + readonly call: (call: Call) => Effect.Effect; +}) => { + const { server, tool } = input; + const title = tool.title ?? tool.annotations?.title; + const failed = (message: string): Outcome => ({ + content: [{ type: "text", text: message }], + server, + tool: tool.name, + }); + return Tool.register( + Tool.make({ + name: input.name, + label: `${server}/${tool.name}`, + description: tool.description?.trim() || title || `MCP tool ${tool.name} from server ${server}`, + parameters: Tool.fromJsonSchema(parameters(tool.inputSchema)), + success: Outcome, + failure: Outcome, + encodeContent: (outcome) => outcome.content as McpContent.Content[], + encodeFailureContent: (outcome) => outcome.content as McpContent.Content[], + encodeDetails: ({ content: _content, ...details }) => details, + // The arguments as the model wrote them: validation only checks them, and the server, not + // a translation of its schema, decides what they mean. + handler: (_params, ctx) => + Effect.gen(function* () { + const progress = yield* ToolProgress; + const report = Effect.runForkWith(yield* Effect.context()); + const result = yield* input + .call({ + args: ctx.rawArgs, + onProgress: (update) => { + const total = update.total === undefined ? "" : `/${update.total}`; + const text = update.message ?? `Progress ${update.progress}${total}`; + report(progress.report({ content: [{ type: "text", text }] })); + }, + }) + .pipe(Effect.mapError(failed)); + const content = McpContent.fromResult(result); + if (result.isError && !content.some((block) => block.type === "text" && block.text !== "")) + content.push({ type: "text", text: `MCP tool ${server}/${tool.name} returned an error` }); + const outcome: Outcome = { + content, + server, + tool: tool.name, + ...(result.structuredContent === undefined ? {} : { structuredContent: result.structuredContent }), + }; + return result.isError ? yield* Effect.fail(outcome) : outcome; + }), + }), + ); +}; + +export * as McpTool from "./tool.ts"; diff --git a/packages/harness/src/mcp/value.ts b/packages/harness/src/mcp/value.ts new file mode 100644 index 00000000..5db1946a --- /dev/null +++ b/packages/harness/src/mcp/value.ts @@ -0,0 +1,113 @@ +/** + * Values of a server's `env` and `headers`, resolved when the server connects: + * + * - `!command` runs the rest through `/bin/sh` and uses its trimmed stdout. + * - Otherwise `$NAME` and `${NAME}` are replaced by host environment variables; `$$` is a literal + * `$` and `$!` a literal `!`. Anything else is literal. + */ + +import { Config, Duration, Effect, Option, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; + +const COMMAND_TIMEOUT = Duration.seconds(10); +const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; +const NAME_PREFIX = /^[A-Za-z_][A-Za-z0-9_]*/; + +type Part = { readonly type: "literal"; readonly value: string } | { readonly type: "env"; readonly name: string }; + +const template = (value: string): ReadonlyArray => { + const parts: Part[] = []; + const literal = (text: string) => { + if (!text) return; + const last = parts.at(-1); + if (last?.type === "literal") parts[parts.length - 1] = { type: "literal", value: last.value + text }; + else parts.push({ type: "literal", value: text }); + }; + let index = 0; + while (index < value.length) { + const dollar = value.indexOf("$", index); + if (dollar < 0) { + literal(value.slice(index)); + break; + } + literal(value.slice(index, dollar)); + const next = value[dollar + 1]; + if (next === "$" || next === "!") { + literal(next); + index = dollar + 2; + continue; + } + if (next === "{") { + const end = value.indexOf("}", dollar + 2); + if (end < 0) { + literal("$"); + index = dollar + 1; + continue; + } + const name = value.slice(dollar + 2, end); + if (NAME.test(name)) parts.push({ type: "env", name }); + else literal(value.slice(dollar, end + 1)); + index = end + 1; + continue; + } + const match = NAME_PREFIX.exec(value.slice(dollar + 1)); + if (match) { + parts.push({ type: "env", name: match[0] }); + index = dollar + 1 + match[0].length; + continue; + } + literal("$"); + index = dollar + 1; + } + return parts; +}; + +/** stdout of `command`, or nothing when it fails, exits non-zero, prints nothing or times out. */ +const run = (command: string) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const handle = yield* spawner.spawn(ChildProcess.make("/bin/sh", ["-c", command], { stderr: "ignore" })); + const [output, code] = yield* Effect.all([Stream.mkString(Stream.decodeText(handle.stdout)), handle.exitCode], { + concurrency: 2, + }); + const trimmed = output.trim(); + return code === 0 && trimmed ? trimmed : undefined; + }).pipe( + Effect.scoped, + Effect.timeoutOption(COMMAND_TIMEOUT), + Effect.map((value) => (value._tag === "Some" ? value.value : undefined)), + Effect.orElseSucceed(() => undefined), + ); + +/** One value. `what` names it in the failure, for example `MCP server "docs" header "Authorization"`. */ +export const resolve = Effect.fnUntraced(function* (value: string, what: string) { + if (value.startsWith("!")) { + const output = yield* run(value.slice(1)); + if (output === undefined) + return yield* Effect.fail(`Failed to resolve ${what} from shell command: ${value.slice(1)}`); + return output; + } + const parts = template(value); + const names = [...new Set(parts.flatMap((part) => (part.type === "env" ? [part.name] : [])))]; + // An empty variable counts as unset. + const env = new Map(); + for (const name of names) { + const found = yield* Config.option(Config.String(name)).pipe(Effect.orElseSucceed(() => Option.none())); + if (Option.isSome(found) && found.value !== "") env.set(name, found.value); + } + const missing = names.filter((name) => !env.has(name)); + if (missing.length > 0) + return yield* Effect.fail( + `Failed to resolve ${what} from environment variable${missing.length === 1 ? "" : "s"}: ${missing.join(", ")}`, + ); + return parts.map((part) => (part.type === "literal" ? part.value : env.get(part.name))).join(""); +}); + +/** Every value of a record, failing on the first that does not resolve. */ +export const resolveAll = Effect.fnUntraced(function* (values: Readonly>, what: string) { + const resolved: Record = {}; + for (const [key, value] of Object.entries(values)) resolved[key] = yield* resolve(value, `${what} "${key}"`); + return resolved; +}); + +export * as McpValue from "./value.ts"; diff --git a/packages/harness/src/plugin/host.ts b/packages/harness/src/plugin/host.ts index f57a0153..c81770d5 100644 --- a/packages/harness/src/plugin/host.ts +++ b/packages/harness/src/plugin/host.ts @@ -1,4 +1,6 @@ +import type { ServerConfig } from "@codeworksh/plugin/plugin/mcp"; import { Cause, Effect, Schema } from "effect"; +import type { RegisteredTool } from "../tool/tool.ts"; import type { Prepared } from "./catalog.ts"; import type { SharedPluginContext } from "./context.ts"; import { make } from "./registry.ts"; @@ -12,11 +14,21 @@ export class SetupError extends Schema.TaggedError()("Plugin.SetupEr export const run = Effect.fn("PluginHost.run")(function* ( selection: ReadonlyArray, input: Omit, + /** The tools of the MCP servers the `mcp` domain settled on, added before the `tool` domain runs. */ + mcp: (servers: ReadonlyArray) => Effect.Effect>, ) { - const buckets = make(); + const buckets = make(input.settings.mcp.servers); const ctx = Object.freeze({ ...input, plugin: buckets.registry }); + let settled = false; + const settle = Effect.gen(function* () { + if (settled) return; + settled = true; + const tools = yield* mcp(buckets.settle()); + for (const tool of tools) buckets.registry.tools.add(tool); + }); const setup = Effect.gen(function* () { for (const { plugin, options } of selection) { + if (plugin.kind !== "mcp") yield* settle; yield* Effect.suspend(() => { const result = plugin.setup(ctx, options); if (Effect.isEffect(result)) @@ -56,6 +68,7 @@ export const run = Effect.fn("PluginHost.run")(function* ( }), ); } + yield* settle; return yield* Effect.try({ try: () => buckets.freeze(input.location.directory), catch: (cause) => diff --git a/packages/harness/src/plugin/mcp/registry.ts b/packages/harness/src/plugin/mcp/registry.ts new file mode 100644 index 00000000..56a219c3 --- /dev/null +++ b/packages/harness/src/plugin/mcp/registry.ts @@ -0,0 +1,57 @@ +import type { McpRegistry, ServerConfig, ServerPatch } from "@codeworksh/plugin/plugin/mcp"; +import type { McpServer } from "@codeworksh/plugin/settings"; +import { Predicate } from "effect"; +import { McpConfig } from "../../mcp/config.ts"; + +const PATCH_KEYS: ReadonlyArray = ["enabled", "exposure", "toolExposure", "timeout", "description"]; + +/** + * The session's MCP servers for one exchange, seeded from `mcp.json`. A plugin written in plain + * JavaScript can hand in anything, so every write is checked here with the rules `mcp.json` follows, + * and a bad one fails that plugin's `setup` rather than a connection later. + */ +export const make = (seed: ReadonlyArray) => { + let open = true; + const servers = new Map(seed.map((server) => [server.name, server.config])); + const writable = () => { + if (!open) throw new Error("mcp registry is closed: servers are settled before the tool domain runs"); + }; + const checked = (name: string, config: unknown) => { + const result = McpConfig.validate(name, config); + if (Predicate.isString(result)) throw new Error(result); + return Object.freeze(result); + }; + const registry: McpRegistry = Object.freeze({ + list: () => Object.freeze([...servers].map((entry) => Object.freeze(entry))), + get: (name: string) => servers.get(name), + set: (name: string, config: ServerConfig) => { + writable(); + const other = McpConfig.clash(servers.keys(), name); + if (other !== undefined) throw new Error(`MCP server "${name}" conflicts with "${other}"`); + servers.set(name, checked(name, config)); + }, + update: (name: string, patch: ServerPatch) => { + writable(); + const current = servers.get(name); + if (current === undefined) throw new Error(`Unknown MCP server: ${name}`); + const extra = Predicate.isObject(patch) ? Object.keys(patch).filter((key) => !PATCH_KEYS.includes(key)) : []; + if (extra.length > 0) + throw new Error( + `MCP server "${name}": an update can only set ${PATCH_KEYS.join(", ")}; use set to replace it`, + ); + servers.set(name, checked(name, { ...current, ...patch })); + }, + remove: (name: string) => { + writable(); + servers.delete(name); + }, + }); + return { + registry, + /** Close the bucket and return the servers it settled on. */ + settle: () => { + open = false; + return Object.freeze([...servers]); + }, + }; +}; diff --git a/packages/harness/src/plugin/registry.ts b/packages/harness/src/plugin/registry.ts index e7bc56da..33d61f47 100644 --- a/packages/harness/src/plugin/registry.ts +++ b/packages/harness/src/plugin/registry.ts @@ -1,5 +1,7 @@ import type { PluginRegistry } from "@codeworksh/plugin/plugin"; +import type { McpServer } from "@codeworksh/plugin/settings"; import { make as makeCatalog } from "../tool/registry.ts"; +import { make as makeMcp } from "./mcp/registry.ts"; import { make as makePrompt } from "./prompt/registry.ts"; import { render } from "./prompt/render.ts"; import { make as makeTools } from "./tool/registry.ts"; @@ -7,15 +9,20 @@ import { make as makeTools } from "./tool/registry.ts"; /** The buckets a plugin writes into during `setup`. Declared in `@codeworksh/plugin`. */ export type { PluginRegistry }; -export const make = () => { +/** `servers` seeds the MCP bucket: the session's `mcp.json` servers, before any plugin edits them. */ +export const make = (servers: ReadonlyArray) => { + const mcp = makeMcp(servers); const tools = makeTools(); const prompt = makePrompt(); const close = () => { + mcp.settle(); tools.close(); prompt.close(); }; return { - registry: Object.freeze({ tools: tools.registry, prompt: prompt.registry }), + registry: Object.freeze({ mcp: mcp.registry, tools: tools.registry, prompt: prompt.registry }), + /** Close the MCP bucket and return the servers the `mcp` domain settled on. */ + settle: mcp.settle, close, freeze: (directory: string) => { close(); diff --git a/packages/harness/src/settings/settings.ts b/packages/harness/src/settings/settings.ts index 0465dee4..573b111f 100644 --- a/packages/harness/src/settings/settings.ts +++ b/packages/harness/src/settings/settings.ts @@ -38,10 +38,11 @@ */ import { Context, Effect, Layer, Predicate, Result, Schema, SchemaIssue } from "effect"; -import { type ParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"; import { Global } from "../global.ts"; +import { McpConfig } from "../mcp/config.ts"; import { fileSystem, hostPath } from "../host.ts"; import { expandTilde } from "../util/home.ts"; +import * as Jsonc from "../util/jsonc.ts"; import { merge, normalize } from "./merge.ts"; import { defaults, Patch, type Declared, type DeclaredModel, type Info, type PluginEntry } from "./schema.ts"; @@ -159,12 +160,6 @@ export class SettingsError extends Schema.TaggedError()("Settings } } -/** `line:column`, 1-based, for a parser offset into the source. */ -const at = (source: string, offset: number): string => { - const before = source.slice(0, offset).split("\n"); - return `${before.length}:${(before.at(-1)?.length ?? 0) + 1}`; -}; - /** * Decode failures name the field, and explain it only with a message a schema check wrote. Effect's * defaults quote the rejected value, and in a settings file that value can be a secret, so any @@ -178,19 +173,10 @@ const decodeFormatter = SchemaIssue.makeFormatterStandardSchemaV1({ }); export const parse = Effect.fn("Settings.parse")(function* (path: string, source: string) { - // JSONC: a settings file is written by hand, so comments and a trailing comma are part of - // the format rather than mistakes. The parser reports offsets, which become `line:column` - // below; Schema validates the normalized value after that. - const errors: ParseError[] = []; - const json: unknown = parseJsonc(source, errors, { allowTrailingComma: true, disallowComments: false }); - const first = errors[0]; - if (first !== undefined) { - return yield* new SettingsError({ - path, - reason: "parse", - detail: `${printParseErrorCode(first.error)} at ${at(source, first.offset)}`, - }); - } + // Syntax first; Schema validates the normalized value after that. + const parsed = Jsonc.parse(source); + if ("error" in parsed) return yield* new SettingsError({ path, reason: "parse", detail: parsed.error }); + const json = parsed.value; // Normalization reads a `null` as "absent", which is right for a settings patch and wrong // inside a plugin's `options`: that block is opaque, and `{ "endpoint": null }` is a value // its plugin may need. The `plugins` array is therefore decoded exactly as written. @@ -310,7 +296,10 @@ export const load = Effect.fn("Settings.load")(function* (options: Options & { r return yield* error; } } - return { ...settings, plugins: declared.map((one) => one.entry), declared, models }; + // `mcp.json` sits next to each settings file, but a broken one is reported rather than failing + // the read: one bad server must not keep a session from running. + const mcp = yield* McpConfig.load({ userDir: options.userConfigDir ?? options.home, root }); + return { ...settings, plugins: declared.map((one) => one.entry), declared, models, mcp }; }); /** diff --git a/packages/harness/src/state/state.ts b/packages/harness/src/state/state.ts index 6b460f39..a801ee58 100644 --- a/packages/harness/src/state/state.ts +++ b/packages/harness/src/state/state.ts @@ -22,6 +22,7 @@ import { select, type Origin, type PluginRef, type Pool } from "../plugin/catalo import { makeEvents, type PromptResolver } from "../plugin/context.ts"; import { run as setup } from "../plugin/host.ts"; import type { Plugin } from "../plugin/plugin.ts"; +import { Mcp } from "../mcp/mcp.ts"; import { LLM } from "../runner/llm.ts"; import type { Runner } from "../runner/run.ts"; import { SandboxIO } from "../sandbox/io.ts"; @@ -251,6 +252,7 @@ export const layer = ( const eventService = yield* Event.Service; const events = makeEvents(eventService); const global = yield* Global.Service; + const mcp = yield* Mcp.Service; /* * One load pass at a time, for the whole process. * @@ -457,19 +459,33 @@ export const layer = ( }); } - const contributions = yield* setup(chosen.selection, { - sessionId, - sandbox, - paths: global, - location, - settings: loadedSettings, - model: resolvedModel, - events, - config: { - ...(promptCustom === undefined ? {} : { promptCustom: resolver(promptCustom) }), - ...(promptSystemAppend === undefined ? {} : { promptSystemAppend: resolver(promptSystemAppend) }), + const contributions = yield* setup( + chosen.selection, + { + sessionId, + sandbox, + paths: global, + location, + settings: loadedSettings, + model: resolvedModel, + events, + config: { + ...(promptCustom === undefined ? {} : { promptCustom: resolver(promptCustom) }), + ...(promptSystemAppend === undefined + ? {} + : { promptSystemAppend: resolver(promptSystemAppend) }), + }, }, - }).pipe(Effect.mapError((cause) => new SnapshotError({ sessionId, reason: cause.message, cause }))); + (servers) => + mcp.reconcile({ + sessionId, + servers, + // MCP servers run on the host, so only a host sandbox's directory exists for them. + directory: sandbox.kind === "local" ? location.directory : undefined, + hostDir, + errors: loadedSettings.mcp.errors, + }), + ).pipe(Effect.mapError((cause) => new SnapshotError({ sessionId, reason: cause.message, cause }))); return { sessionId, diff --git a/packages/harness/src/util/jsonc.ts b/packages/harness/src/util/jsonc.ts new file mode 100644 index 00000000..15dd4d33 --- /dev/null +++ b/packages/harness/src/util/jsonc.ts @@ -0,0 +1,20 @@ +import { type ParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"; + +/** `line:column`, 1-based, for a parser offset into the source. */ +const at = (source: string, offset: number): string => { + const before = source.slice(0, offset).split("\n"); + return `${before.length}:${(before.at(-1)?.length ?? 0) + 1}`; +}; + +/** + * A hand-written config file: comments and a trailing comma are part of the format rather than + * mistakes. A syntax error is reported once, as the parser's reason at `line:column`. + */ +export const parse = (source: string): { readonly value: unknown } | { readonly error: string } => { + const errors: ParseError[] = []; + const value: unknown = parseJsonc(source, errors, { allowTrailingComma: true, disallowComments: false }); + const first = errors[0]; + return first === undefined + ? { value } + : { error: `${printParseErrorCode(first.error)} at ${at(source, first.offset)}` }; +}; diff --git a/packages/harness/test/__artifacts__/mcp.session.json b/packages/harness/test/__artifacts__/mcp.session.json new file mode 100644 index 00000000..e5cea1d5 --- /dev/null +++ b/packages/harness/test/__artifacts__/mcp.session.json @@ -0,0 +1,135 @@ +{ + "requests": 6, + "tools": [ + "mcp__e2e__echo", + "mcp__e2e__fail", + "mcp__e2e__pid", + "mcp__e2e__crash", + "mcp__e2e__my_tool_v2" + ], + "echoTool": { + "name": "mcp__e2e__echo", + "description": "Echo text back, prefixed with the configured greeting.", + "parameters": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "What to echo." + } + }, + "required": [ + "text" + ] + } + }, + "plugins": { + "mcpDomain": [ + [ + [ + "e2e", + true + ], + [ + "off", + false + ], + [ + "my-srv", + false + ] + ], + [ + [ + "e2e", + true + ], + [ + "off", + false + ], + [ + "my-srv", + false + ] + ] + ], + "toolDomain": [ + "mcp registry is closed: servers are settled before the tool domain runs", + "mcp registry is closed: servers are settled before the tool domain runs" + ] + }, + "calls": { + "echo": { + "status": "completed", + "isError": false, + "content": [ + "hello: hi" + ] + }, + "fail": { + "status": "error", + "isError": true, + "content": [ + "the fixture refused" + ] + }, + "structured": { + "status": "completed", + "isError": false, + "content": [ + "{\n \"version\": 2\n}" + ] + }, + "pid-before": { + "status": "completed", + "isError": false, + "content": [ + "" + ] + }, + "crash": { + "status": "error", + "isError": true, + "content": [ + "MCP tool \"e2e/crash\" failed: Connection closed: mcp server process exited with code 3: fatal: e2e fixture crashed on purpose" + ] + }, + "pid-after": { + "status": "completed", + "isError": false, + "content": [ + "" + ] + }, + "invalid": { + "status": "error", + "isError": true, + "content": [ + "Invalid arguments for mcp__e2e__echo: Missing key\n at [\"text\"]" + ] + } + }, + "status": [ + { + "server": "e2e", + "state": "connected", + "tools": 6 + }, + { + "server": "off", + "state": "disabled", + "tools": 0 + }, + { + "server": "my-srv", + "state": "disabled", + "tools": 0 + } + ], + "configErrors": [ + "/home/mcp.json: server \"my_srv\" conflicts with \"my-srv\"", + "/home/mcp.json: server \"broken\": url must be an http or https URL", + "/project/.codework/mcp.json: server \"ghost\" needs \"command\" or \"url\", or a user server to override" + ] +} diff --git a/packages/harness/test/fixtures/mcp/session.mjs b/packages/harness/test/fixtures/mcp/session.mjs new file mode 100644 index 00000000..f87b17d0 --- /dev/null +++ b/packages/harness/test/fixtures/mcp/session.mjs @@ -0,0 +1,43 @@ +// Stdio MCP server for the session test; run as `node session.mjs`. +import { Server } from "@modelcontextprotocol/server"; +import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; + +const object = (properties = {}, required = []) => ({ type: "object", properties, required }); +const text = (value) => ({ content: [{ type: "text", text: String(value) }] }); + +const server = new Server({ name: "session-fixture", version: "1.0.0" }, { capabilities: { tools: {} } }); +server.setRequestHandler("tools/list", () => ({ + tools: [ + { + name: "echo", + description: "Echo text back, prefixed with the configured greeting.", + inputSchema: object({ text: { type: "string", description: "What to echo." } }, ["text"]), + }, + { name: "fail", description: "Answer with an error result.", inputSchema: object() }, + { name: "pid", description: "The server's process id.", inputSchema: object() }, + { name: "crash", description: "Exit while the call is running.", inputSchema: object() }, + { name: "my-tool.v2", inputSchema: { type: "object" } }, + { name: "secret", description: "Never declared.", inputSchema: object() }, + ], +})); +server.setRequestHandler("tools/call", (request) => { + const args = request.params.arguments ?? {}; + switch (request.params.name) { + case "echo": + return text(`${process.env.E2E_GREETING}: ${String(args.text)}`); + case "fail": + return { isError: true, content: [{ type: "text", text: "the fixture refused" }] }; + case "pid": + return text(process.pid); + case "crash": + process.stderr.write("fatal: e2e fixture crashed on purpose\n"); + setTimeout(() => process.exit(3), 20); + return new Promise(() => {}); + case "my-tool.v2": + return { content: [], structuredContent: { version: 2 } }; + default: + throw new Error(`unknown tool ${request.params.name}`); + } +}); + +await server.connect(new StdioServerTransport()); diff --git a/packages/harness/test/fixtures/runner.cycle.spec.ts b/packages/harness/test/fixtures/runner.cycle.spec.ts index 174ec4dc..0dae06f6 100644 --- a/packages/harness/test/fixtures/runner.cycle.spec.ts +++ b/packages/harness/test/fixtures/runner.cycle.spec.ts @@ -29,6 +29,8 @@ import { Session } from "../../src/session/session.ts"; import { builtins } from "../../src/plugin/builtin.ts"; import { State } from "../../src/state/state.ts"; import { pooled } from "./pool.ts"; +import { Mcp } from "../../src/mcp/mcp.ts"; +import { Process } from "../../src/sandbox/utils/process.ts"; const database = Database.layer(":memory:"); const vercel = VercelSandboxDriver.make(); @@ -42,6 +44,7 @@ const runtime = (root: string, custom: string) => Layer.provideMerge( ((seeded) => State.layer({}, seeded.ref, seeded.references, seeded.follow, seeded.rebuild))(pooled(builtins)), ), + Layer.provideMerge(Mcp.layer({ hostCwd: "/" }).pipe(Layer.provide(Process.host))), Layer.provideMerge(SessionRuntime.layer), Layer.provideMerge( Settings.layer({ userConfigDir: custom }).pipe( diff --git a/packages/harness/test/mcp.session.test.ts b/packages/harness/test/mcp.session.test.ts new file mode 100644 index 00000000..767990ab --- /dev/null +++ b/packages/harness/test/mcp.session.test.ts @@ -0,0 +1,188 @@ +import "./utils/env.ts"; +import { createAssistantMessageEventStream, type Message } from "@codeworksh/aikit"; +import { Effect } from "effect"; +import fs from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, it } from "vite-plus/test"; +import { ContextCodec } from "../src/context/codec.ts"; +import { Harness } from "../src/effect/harness.ts"; +import { Session } from "../src/effect/session.ts"; +import { Mcp } from "../src/mcp/mcp.ts"; +import { defaultPromptPlugin } from "../src/plugin/builtin/prompt/default.ts"; +import { define } from "../src/plugin/plugin.ts"; +import type { LLM } from "../src/runner/llm.ts"; +import { Settings } from "../src/settings/settings.ts"; +import { assistant } from "./fixtures/llm.ts"; +import { tmpdir } from "./fixtures/tempdir.ts"; +import { pendingCall } from "./tools.fixture.ts"; + +const fixture = fileURLToPath(new URL("./fixtures/mcp/session.mjs", import.meta.url)); +const stdio = (extra: Record = {}) => ({ + command: process.execPath, + args: [fixture], + env: { E2E_GREETING: "${MCP_E2E_GREETING}" }, + ...extra, +}); + +/** One reply per model request, in order; an empty one ends the exchange. */ +const scripted = + (replies: ReadonlyArray>, requests: LLM.Input[]): LLM.Open => + (input) => + Effect.sync(() => { + const index = requests.push(input); + const calls = replies[index - 1] ?? []; + const message = assistant(input, index, calls.length > 0 ? { stopReason: "toolUse", parts: [...calls] } : {}); + const stream = createAssistantMessageEventStream(); + stream.push({ type: "start", partial: message }); + stream.push({ type: "done", reason: calls.length > 0 ? "toolUse" : "stop", message }); + return stream; + }); + +const write = async (file: string, value: unknown) => { + await fs.mkdir(join(file, ".."), { recursive: true }); + await fs.writeFile(file, JSON.stringify(value, null, 2)); +}; + +it("mcp: config layers, plugin bucket, direct tools, a crash mid-exchange and the next call reconnecting", async () => { + await using dir = await tmpdir(); + const home = join(dir.path, "home"); + const project = join(dir.path, "project"); + const work = join(dir.path, "work"); + await fs.mkdir(work, { recursive: true }); + process.env.MCP_E2E_GREETING = "hello"; + + await write(join(home, "mcp.json"), { + mcpServers: { + e2e: stdio({ toolExposure: { secret: "hidden" } }), + off: stdio(), + "my-srv": stdio({ enabled: false }), + my_srv: stdio(), + broken: { url: "ftp://example.com/mcp" }, + }, + }); + // The project turns a user server off without restating it, and cannot add an override with no base. + await write(join(project, ".codework", "mcp.json"), { + mcpServers: { off: { enabled: false }, ghost: { enabled: false } }, + }); + + const observed: { readonly mcpDomain: unknown[]; readonly toolDomain: string[] } = { mcpDomain: [], toolDomain: [] }; + const observer = define({ + id: "test.mcp.observer", + kind: "mcp", + setup: (ctx) => { + observed.mcpDomain.push(ctx.plugin.mcp.list().map(([name, config]) => [name, config.enabled ?? true])); + ctx.plugin.mcp.update("e2e", { description: "The E2E fixture." }); + }, + }); + const latecomer = define({ + id: "test.mcp.latecomer", + kind: "tool", + setup: (ctx) => { + try { + ctx.plugin.mcp.remove("e2e"); + } catch (error) { + observed.toolDomain.push((error as Error).message); + } + }, + }); + + const requests: LLM.Input[] = []; + const llm = scripted( + [ + [ + pendingCall("mcp__e2e__echo", { text: "hi" }, "echo"), + pendingCall("mcp__e2e__fail", {}, "fail"), + pendingCall("mcp__e2e__my_tool_v2", {}, "structured"), + ], + [pendingCall("mcp__e2e__pid", {}, "pid-before"), pendingCall("mcp__e2e__crash", {}, "crash")], + // Same exchange: the server is gone, so this call reconnects first. + [pendingCall("mcp__e2e__pid", {}, "pid-after")], + [], + // Next exchange: arguments the server's schema rejects never reach it. + [pendingCall("mcp__e2e__echo", {}, "invalid")], + [], + ], + requests, + ); + + const result = await Effect.gen(function* () { + const session = yield* Session.create({ + directory: work, + hostDir: project, + model: { provider: "openai", id: "gpt-5.6-luna" }, + }); + yield* session.run("Use the fixture."); + yield* session.run("Once more."); + const path = yield* session.path(); + const messages = yield* Effect.forEach(path, ContextCodec.decodeMessage); + const status = yield* Mcp.Service.use((mcp) => mcp.status(session.id)); + const settings = yield* Settings.Service.use((loader) => loader.load(project)); + return { messages, status, errors: settings.mcp.errors }; + }).pipe( + Effect.provide( + Harness.layer({ + home, + hostCwd: dir.path, + database: ":memory:", + plugins: [observer, latecomer, defaultPromptPlugin], + llm, + }), + ), + Effect.scoped, + Effect.runPromise, + ); + + const calls = new Map( + result.messages + .flatMap((message) => message.parts) + .flatMap((part) => (part.type === "toolCall" && "result" in part ? [[part.callID, part] as const] : [])), + ); + const outcome = (id: string) => { + const part = calls.get(id); + if (part === undefined) return undefined; + return { + status: part.status, + isError: part.result.isError, + content: part.result.content.map((block) => (block.type === "text" ? block.text : `[${block.type}]`)), + }; + }; + + const pidBefore = outcome("pid-before")?.content[0]; + const pidAfter = outcome("pid-after")?.content[0]; + expect(pidBefore).toMatch(/^\d+$/); + expect(pidAfter).toMatch(/^\d+$/); + expect(pidAfter).not.toBe(pidBefore); + + const firstTools = requests[0]?.context.tools ?? []; + expect(firstTools.map((tool) => tool.name)).not.toContain("mcp__e2e__secret"); + // The tool set is frozen per exchange and stable across them, so every request declares the same tools. + for (const request of requests) expect(request.context.tools).toEqual(firstTools); + // The server's JSON Schema reaches the provider as written. + expect(firstTools.find((tool) => tool.name === "mcp__e2e__echo")?.parameters).toEqual({ + type: "object", + properties: { text: { type: "string", description: "What to echo." } }, + required: ["text"], + }); + + const normalise = (value: unknown) => + JSON.parse( + JSON.stringify(value) + .replaceAll(dir.path, "") + .replaceAll(process.execPath, "") + .replaceAll(`"${pidBefore}"`, '""') + .replaceAll(`"${pidAfter}"`, '""'), + ); + const artifact = normalise({ + requests: requests.length, + tools: firstTools.map((tool) => tool.name), + echoTool: firstTools.find((tool) => tool.name === "mcp__e2e__echo"), + plugins: observed, + calls: Object.fromEntries( + ["echo", "fail", "structured", "pid-before", "crash", "pid-after", "invalid"].map((id) => [id, outcome(id)]), + ), + status: result.status, + configErrors: result.errors, + }); + await expect(`${JSON.stringify(artifact, null, "\t")}\n`).toMatchFileSnapshot("./__artifacts__/mcp.session.json"); +}, 60_000); diff --git a/packages/harness/test/plugin.host.test.ts b/packages/harness/test/plugin.host.test.ts index a176dc1e..e1d8f56b 100644 --- a/packages/harness/test/plugin.host.test.ts +++ b/packages/harness/test/plugin.host.test.ts @@ -27,7 +27,7 @@ const echo = (value: string) => describe("plugin domains and exchange host", () => { it("replaces tool and hooks together, patches prose, and closes retained buckets", async () => { - const buckets = make(); + const buckets = make([]); const tools = buckets.registry.tools; let stale = 0; tools.add(echo("first"), { diff --git a/packages/harness/test/runner.loop.test.ts b/packages/harness/test/runner.loop.test.ts index 09ae6c5c..0a363aaa 100644 --- a/packages/harness/test/runner.loop.test.ts +++ b/packages/harness/test/runner.loop.test.ts @@ -36,6 +36,8 @@ import * as Tool from "../src/tool/tool.ts"; import { assistant, immediateOpen } from "./fixtures/llm.ts"; import { seedSpace } from "./fixtures/space.ts"; import { testEffect } from "./utils/effect.ts"; +import { Mcp } from "../src/mcp/mcp.ts"; +import { Process } from "../src/sandbox/utils/process.ts"; const runtime = ( options: { @@ -61,6 +63,7 @@ const runtime = ( pooled(options.plugins ?? plugins), ), ), + Layer.provideMerge(Mcp.layer({ hostCwd: "/" }).pipe(Layer.provide(Process.host))), Layer.provideMerge(SessionRuntime.layer), Layer.provideMerge( Layer.succeed(Settings.Service, { diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 319d976f..bea43e25 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -53,6 +53,12 @@ "import": "./dist/pack/plugin/prompt.mjs", "default": "./dist/pack/plugin/prompt.mjs" }, + "./plugin/mcp": { + "development": "./src/plugin/mcp.ts", + "types": "./dist/pack/plugin/mcp.d.mts", + "import": "./dist/pack/plugin/mcp.mjs", + "default": "./dist/pack/plugin/mcp.mjs" + }, "./plugin/section": { "development": "./src/plugin/section.ts", "types": "./dist/pack/plugin/section.d.mts", diff --git a/packages/plugin/src/plugin.ts b/packages/plugin/src/plugin.ts index 4c285b27..70df15a6 100644 --- a/packages/plugin/src/plugin.ts +++ b/packages/plugin/src/plugin.ts @@ -8,6 +8,7 @@ */ export type { Config, Events, Paths, PromptResolver, SharedPluginContext } from "./plugin/context.ts"; export { define, domains, type Mount, type Plugin, type PluginKind, rank } from "./plugin/plugin.ts"; +export * as Mcp from "./plugin/mcp.ts"; export * as Prompt from "./plugin/prompt.ts"; export * as Section from "./plugin/section.ts"; export type { PluginOptions, PluginPatch, PluginRef, PluginSpec } from "./plugin/ref.ts"; diff --git a/packages/plugin/src/plugin/mcp.ts b/packages/plugin/src/plugin/mcp.ts new file mode 100644 index 00000000..742d8290 --- /dev/null +++ b/packages/plugin/src/plugin/mcp.ts @@ -0,0 +1,69 @@ +/** + * MCP servers a session runs, as plugins see and edit them. + * + * The harness seeds the bucket from `mcp.json` (the user file, then the project's), `mcp` plugins + * edit it, and it freezes before the `tool` domain runs: the servers are settled before any tool + * exists. Only configuration lives here. Whether a server is connected changes on its own, at any + * moment, so it is never part of what a plugin reads during setup. + */ + +/** + * How a server's tools reach the model. + * + * - `direct`: declared to the model like any other tool. + * - `hidden`: never registered. + */ +export type Exposure = "direct" | "hidden"; + +interface Base { + /** Default: `direct`. */ + readonly exposure?: Exposure; + /** + * Exposure of single tools, overriding `exposure`. Keys are tool names as the server offers them, + * or patterns where `*` matches any characters. An exact name wins over patterns; among patterns + * the first match wins. `"exposure": "hidden"` with a few entries here exposes only those tools. + */ + readonly toolExposure?: Readonly>; + /** `false` keeps the entry without connecting. Default: `true`. */ + readonly enabled?: boolean; + /** Per-request timeout in seconds. Progress notifications from the server reset it. Default: 60. */ + readonly timeout?: number; + /** What the server offers, in a sentence. */ + readonly description?: string; +} + +/** + * A server the harness starts as a process on the host. `env` values may name environment + * variables (`${NAME}`) or be a command whose output is the value (`!command`). + */ +export interface StdioServer extends Base { + readonly type?: "stdio"; + /** One executable, not a shell command line. */ + readonly command: string; + readonly args?: ReadonlyArray; + readonly env?: Readonly>; + /** Relative to the session's directory. */ + readonly cwd?: string; +} + +/** A Streamable HTTP server. `headers` values resolve like a stdio server's `env`. */ +export interface HttpServer extends Base { + readonly type?: "http" | "streamable-http"; + readonly url: string; + readonly headers?: Readonly>; +} + +export type ServerConfig = StdioServer | HttpServer; + +/** The settings a plugin can change on a server without restating where it runs. */ +export type ServerPatch = Pick; + +export interface McpRegistry { + readonly list: () => ReadonlyArray; + readonly get: (name: string) => ServerConfig | undefined; + /** Add a server or replace one. Throws on an invalid name or config. */ + readonly set: (name: string, config: ServerConfig) => void; + /** Throws when no server has this name. */ + readonly update: (name: string, patch: ServerPatch) => void; + readonly remove: (name: string) => void; +} diff --git a/packages/plugin/src/plugin/plugin.ts b/packages/plugin/src/plugin/plugin.ts index 8c99ebbd..b36bbfe4 100644 --- a/packages/plugin/src/plugin/plugin.ts +++ b/packages/plugin/src/plugin/plugin.ts @@ -19,10 +19,13 @@ import type { PluginOptions } from "./ref.ts"; * composition actually happens -- a plugin patching another's tool, or appending to the prompt a * previous one rendered, is written next to it and deliberately after it. * + * `mcp` runs first: the MCP servers a session runs are settled before any tool exists, because + * their tools join the tool registry before the `tool` domain starts. + * * Kept as data so a later release can add a domain, or make the order configurable, without * touching the loader: `{ tool: 1, skill: 2, prompt: 3 }` needs no code change here. */ -export const domains = { tool: 1, prompt: 2 } as const; +export const domains = { mcp: 1, tool: 2, prompt: 3 } as const; export type PluginKind = keyof typeof domains; @@ -33,9 +36,9 @@ export type Mount = SandboxIO.Provides | LocationService; export interface Plugin { readonly id: string; /** - * The domain this plugin extends, which decides when it runs: every `tool` plugin is set up - * before any `prompt` plugin, whichever settings layer each came from, so a prompt plugin - * always sees the complete tool set. Plugins within one domain run in the order they were + * The domain this plugin extends, which decides when it runs: every `mcp` plugin is set up + * before any `tool` plugin, and every `tool` plugin before any `prompt` plugin, whichever + * settings layer each came from, so a prompt plugin always sees the complete tool set. Plugins within one domain run in the order they were * written. */ readonly kind: PluginKind; diff --git a/packages/plugin/src/plugin/registry.ts b/packages/plugin/src/plugin/registry.ts index 0cef377c..ece204f9 100644 --- a/packages/plugin/src/plugin/registry.ts +++ b/packages/plugin/src/plugin/registry.ts @@ -1,3 +1,4 @@ +import type { McpRegistry } from "./mcp.ts"; import type { PromptRegistry } from "./prompt.ts"; import type { ToolRegistry } from "./tool.ts"; @@ -9,6 +10,8 @@ import type { ToolRegistry } from "./tool.ts"; * effective tool catalog and the final system prompt — is the harness's job, not the contract's. */ export interface PluginRegistry { + /** Writable during the `mcp` domain only; later domains read the servers it settled. */ + readonly mcp: McpRegistry; readonly tools: ToolRegistry; readonly prompt: PromptRegistry; } diff --git a/packages/plugin/src/settings.ts b/packages/plugin/src/settings.ts index b16df1c2..dd26b16a 100644 --- a/packages/plugin/src/settings.ts +++ b/packages/plugin/src/settings.ts @@ -1,5 +1,6 @@ import { Model as AikitModel, type Protocol } from "@codeworksh/aikit"; import { Effect, Schema, type SchemaAST, SchemaGetter, SchemaIssue } from "effect"; +import type { ServerConfig } from "./plugin/mcp.ts"; import { isAikitModelInfo, NonNegativeInt, validateAikitModelInfo } from "./schema.ts"; export const ThinkingLevel = Schema.Literals(Object.values(AikitModel.ThinkingLevelEnum)); @@ -203,6 +204,22 @@ export interface DeclaredModel { readonly file: string; } +/** One server from an `mcp.json`, and the file that defined it. */ +export interface McpServer { + readonly name: string; + readonly config: ServerConfig; + readonly file: string; + /** The project `mcp.json` whose entry overrides this user server's `enabled` or exposure. */ + readonly override?: string; +} + +/** The `mcp.json` layers: the user file, then the project's, whose entries replace same-named ones. */ +export interface McpSettings { + readonly servers: ReadonlyArray; + /** Files and entries that could not be used. Each is skipped; everything else still loads. */ + readonly errors: ReadonlyArray; +} + export interface Info { /** * Plugin entries added to the harness selection, in order: every layer's entries, lowest @@ -226,6 +243,7 @@ export interface Info { readonly toolExecution: ToolExecution; }; readonly retry: RetryPolicy; + readonly mcp: McpSettings; } /** Let aikit supply model-aware generation defaults. */ @@ -241,4 +259,5 @@ export const defaults: Info = { options: { timeoutMs: 3_600_000, idleTimeoutMs: 300_000, maxRetries: 0 }, }, retry: { maxRetries: 3, baseDelayMs: 2_000, maxDelayMs: 60_000 }, + mcp: { servers: [], errors: [] }, }; diff --git a/packages/plugin/src/tool.ts b/packages/plugin/src/tool.ts index a1b4f437..eddb250c 100644 --- a/packages/plugin/src/tool.ts +++ b/packages/plugin/src/tool.ts @@ -1,5 +1,5 @@ import type { Message } from "@codeworksh/aikit"; -import { Effect, type Layer, Schema, SchemaAST } from "effect"; +import { Effect, JsonSchema, type Layer, Schema, SchemaAST, SchemaRepresentation } from "effect"; import type { TSchema } from "typebox"; import { ToolExecutionError } from "./tool/error.ts"; import type { ToolProgress } from "./tool/progress.ts"; @@ -233,12 +233,37 @@ export const provide = < ToolProgress >); +const providerJsonSchema = "codework/providerJsonSchema"; + +/** + * Parameters from a JSON Schema someone else wrote, such as an MCP server's `inputSchema`. The + * provider receives it exactly as written; arguments are validated against its Effect translation. + * A schema the translation cannot express (a `pattern`, an external `$ref`) validates nothing, + * since rejecting arguments the schema allows would be worse than letting the server check them. + */ +export const fromJsonSchema = (schema: Record): Schema.Top => { + const validator = (() => { + try { + const draft07 = + (typeof schema.$schema === "string" && schema.$schema.includes("draft-07")) || "definitions" in schema; + return SchemaRepresentation.fromJsonSchemaDocument( + draft07 ? JsonSchema.fromSchemaDraft07(schema) : JsonSchema.fromSchemaDraft2020_12(schema), + ); + } catch { + return Schema.Unknown; + } + })(); + return validator.annotate({ [providerJsonSchema]: schema }); +}; + /** * Derive a provider-safe JSON Schema object from an Effect Schema. This is the * single boundary that crosses into the provider wire; if a provider rejects a * construct, fix it here. Draft-07 inlines the root (no top-level `$ref`). */ export const toProviderJsonSchema = (schema: Schema.Top): Record => { + const written = SchemaAST.resolveAt>(providerJsonSchema)(schema.ast); + if (written !== undefined) return written; // Effect reads `Struct({})` as TS `{}` (any non-null) and emits `{ not: { type: "null" } }`; // providers require an object, so emit one shaped like a non-empty struct's. const encoded = SchemaAST.toEncoded(schema.ast); diff --git a/packages/plugin/vite.config.ts b/packages/plugin/vite.config.ts index e1bbb3e0..d0454f7c 100644 --- a/packages/plugin/vite.config.ts +++ b/packages/plugin/vite.config.ts @@ -28,6 +28,7 @@ export default defineConfig({ "src/index.ts", "src/location.ts", "src/plugin.ts", + "src/plugin/mcp.ts", "src/plugin/prompt.ts", "src/plugin/section.ts", "src/plugin/tool.ts", From 0e82590c9f454740d5d0564f691540c2676a7089 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Thu, 15 Oct 2026 00:38:06 +0530 Subject: [PATCH 10/17] fix(harness): start stdio MCP servers in their configured cwd A server's own relative cwd now resolves against the session's base directory, and a leading ~/ is expanded in command, args and cwd. Co-Authored-By: Claude Opus 5.5 --- packages/harness/src/mcp/mcp.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/harness/src/mcp/mcp.ts b/packages/harness/src/mcp/mcp.ts index 39929729..4f5341a6 100644 --- a/packages/harness/src/mcp/mcp.ts +++ b/packages/harness/src/mcp/mcp.ts @@ -27,6 +27,8 @@ import type { RegisteredTool } from "../tool/tool.ts"; import { McpClient } from "./client.ts"; import { McpTool } from "./tool.ts"; import { McpValue } from "./value.ts"; +import { hostPath } from "../host.ts"; +import { expandTilde } from "../util/home.ts"; /** The first exchange of a session waits this long for servers whose tools are declared to the model. */ const STARTUP_WAIT = Duration.seconds(10); @@ -46,9 +48,10 @@ export interface Input { readonly sessionId: SessionId; readonly servers: ReadonlyArray; /** - * Where stdio servers start, and the root sent to servers: the session's directory when its - * sandbox is the host's own filesystem. Absent otherwise, since that directory does not exist - * here; servers then start in the session's host directory and are sent no roots. + * Where stdio servers start (a server's own relative `cwd` resolves against it), and the root + * sent to servers: the session's directory when its sandbox is the host's own filesystem. Absent + * otherwise, since that directory does not exist here; servers then start in the session's host + * directory and are sent no roots. */ readonly directory?: string | undefined; readonly hostDir?: string | undefined; @@ -144,10 +147,11 @@ export const layer = (options: { readonly hostCwd: string }) => } satisfies McpClient.Http; return { _tag: "Stdio", - command: config.command, - args: config.args ?? [], + // One executable and its arguments, not a command line: only a leading `~/` is expanded. + command: expandTilde(config.command, hostPath), + args: (config.args ?? []).map((arg) => expandTilde(arg, hostPath)), env: yield* McpValue.resolveAll(config.env ?? {}, `${what} env`), - cwd: server.cwd, + cwd: hostPath.resolve(server.cwd, expandTilde(config.cwd ?? ".", hostPath)), } satisfies McpClient.Stdio; }); From e0cea060d243fd6e87ccea9cae951333be733810 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Thu, 15 Oct 2026 09:15:43 +0530 Subject: [PATCH 11/17] fix(harness): keep MCP connections recoverable and server configs settled - Connection callbacks run in the service's scope. A server exiting while idle used to retire its connection from inside the connection's own scope, which waited on itself and left the server locked for good. - Connect attempts run on their own and are shared by callers; closing a server interrupts one in progress instead of waiting behind it. - An exchange waits up to 10 s for every server it starts, not only on the first exchange, so a restarted server keeps its tools declared. - Server configs in ctx.plugin.mcp are copied and deep-frozen, so a plugin cannot change a settled server afterwards. - A hashed tool name grows until it is free, so a server tool named like the hash cannot replace another tool. - Server log messages go to /mcp.log. Co-Authored-By: Claude Opus 5.5 --- packages/harness/src/effect/harness.ts | 2 +- packages/harness/src/mcp/mcp.ts | 120 +++++++++++----- packages/harness/src/mcp/tool.ts | 12 +- packages/harness/src/plugin/mcp/registry.ts | 16 ++- .../test/__artifacts__/mcp.session.json | 5 +- .../__artifacts__/mcp.session.lifecycle.json | 136 ++++++++++++++++++ .../harness/test/fixtures/mcp/session.mjs | 4 + .../test/fixtures/runner.cycle.spec.ts | 2 +- packages/harness/test/mcp.session.test.ts | 117 +++++++++++++++ packages/harness/test/runner.loop.test.ts | 2 +- 10 files changed, 374 insertions(+), 42 deletions(-) create mode 100644 packages/harness/test/__artifacts__/mcp.session.lifecycle.json diff --git a/packages/harness/src/effect/harness.ts b/packages/harness/src/effect/harness.ts index e0522354..173d7d83 100644 --- a/packages/harness/src/effect/harness.ts +++ b/packages/harness/src/effect/harness.ts @@ -255,7 +255,7 @@ export const layer = (options: Options = {}) => return Control.layer.pipe( Layer.provideMerge(RunnerExecute.layer.pipe(Layer.provide(loop))), Layer.provideMerge(State.layer({}, pool, references, followStore, rebuild, viewOf)), - Layer.provideMerge(Mcp.layer({ hostCwd }).pipe(Layer.provide(Process.host))), + Layer.provideMerge(Mcp.layer({ hostCwd, home: paths.home }).pipe(Layer.provide(Process.host))), Layer.provideMerge(Settings.layer(settingsOptions)), Layer.provideMerge(SessionRuntime.layer), Layer.provideMerge(sandboxes), diff --git a/packages/harness/src/mcp/mcp.ts b/packages/harness/src/mcp/mcp.ts index 4f5341a6..33d39292 100644 --- a/packages/harness/src/mcp/mcp.ts +++ b/packages/harness/src/mcp/mcp.ts @@ -19,18 +19,32 @@ import type { ServerConfig } from "@codeworksh/plugin/plugin/mcp"; import type { CallToolResult, Tool } from "@modelcontextprotocol/client"; -import { Cause, Context, Deferred, Duration, Effect, Exit, FiberSet, Layer, Schema, Scope, Semaphore } from "effect"; +import { + Cause, + Context, + Deferred, + Duration, + Effect, + Exit, + Fiber, + FiberSet, + Layer, + Schema, + Scope, + Semaphore, +} from "effect"; import type { ChildProcessSpawner } from "effect/process"; import { NeedsAuthError } from "./error.ts"; import type { ID as SessionId } from "../session/schema.ts"; import type { RegisteredTool } from "../tool/tool.ts"; import { McpClient } from "./client.ts"; +import { McpLog } from "./log.ts"; import { McpTool } from "./tool.ts"; import { McpValue } from "./value.ts"; import { hostPath } from "../host.ts"; import { expandTilde } from "../util/home.ts"; -/** The first exchange of a session waits this long for servers whose tools are declared to the model. */ +/** An exchange waits this long for the servers it started whose tools are declared to the model. */ const STARTUP_WAIT = Duration.seconds(10); const DEFAULT_TIMEOUT_SECONDS = 60; @@ -85,7 +99,9 @@ interface Server { error: string | undefined; tools: ReadonlyArray; live: Live | undefined; - /** Settles when the attempt in progress, if any, finishes. */ + /** The connect attempt in progress. Callers share it; closing the server interrupts it. */ + attempt: Fiber.Fiber | undefined; + /** Settles when the first background attempt finishes. */ ready: Deferred.Deferred | undefined; closed: boolean; readonly lock: Semaphore.Semaphore; @@ -96,7 +112,6 @@ interface Session { readonly names: ReturnType; readonly lock: Semaphore.Semaphore; readonly reported: Set; - waited: boolean; } /** The parts of a config that decide the connection; exposure and description do not. */ @@ -109,13 +124,15 @@ const hasDirectTools = (config: ServerConfig) => (config.exposure ?? "direct") === "direct" || Object.values(config.toolExposure ?? {}).some((exposure) => exposure === "direct"); -export const layer = (options: { readonly hostCwd: string }) => +export const layer = (options: { readonly hostCwd: string; readonly home: string }) => Layer.effect( Service, Effect.gen(function* () { const root = yield* Effect.scope; const fork = yield* FiberSet.makeRuntime(); + const forkAttempt = yield* FiberSet.makeRuntime(); const context = yield* Effect.context(); + const log = yield* McpLog.make(hostPath.join(options.home, "mcp.log")); const sessions = new Map(); // Shared remote endpoints should not see a burst of concurrent startups. const endpoints = new Map(); @@ -156,8 +173,16 @@ export const layer = (options: { readonly hostCwd: string }) => }); const watch = (server: Server, live: Live) => { + // Callbacks run in the connection's own scope, and retiring closes that scope; run them + // in the service's instead, or closing would wait on the callback doing the closing. const current = (effect: Effect.Effect) => - Effect.suspend(() => (server.live === live ? effect : Effect.void)).pipe(server.lock.withPermits(1)); + Effect.sync(() => { + fork( + Effect.suspend(() => (server.live === live ? effect : Effect.void)).pipe( + server.lock.withPermits(1), + ), + ); + }); live.connection.onClose((reason) => current( Effect.gen(function* () { @@ -185,22 +210,12 @@ export const layer = (options: { readonly hostCwd: string }) => ), ), ); + live.connection.onLog((entry) => log.write(server.name, entry)); }; - /** The server's current connection, connecting first when it has none. */ - const open = (server: Server): Effect.Effect => + /** One connect attempt, run apart from any caller so that closing the server can interrupt it. */ + const attempt = (server: Server): Effect.Effect => Effect.gen(function* () { - if (server.closed) return yield* Effect.fail(`MCP server "${server.name}" is shut down`); - const current = server.live; - // A drop is known here before its `onClose` handler has run. - const reason = current?.connection.dropped(); - if (current !== undefined && reason === undefined) return current; - if (current !== undefined) { - server.state = "disconnected"; - server.error = reason; - yield* retire(server, current); - } - server.state = "connecting"; const scope = yield* Scope.fork(root, "sequential"); const config = server.config; const connect = Effect.gen(function* () { @@ -215,7 +230,6 @@ export const layer = (options: { readonly hostCwd: string }) => return { connection, tools }; }); const exit = yield* ("url" in config ? endpointLock(config.url).withPermit(connect) : connect).pipe( - // An interrupted exchange stops the attempt; the next call starts a fresh one. Effect.onInterrupt(() => Scope.close(scope, Exit.void)), Effect.exit, ); @@ -237,14 +251,54 @@ export const layer = (options: { readonly hostCwd: string }) => watch(server, live); yield* Effect.logInfo("mcp connected", { server: server.name, tools: server.tools.length }); return live; - }).pipe(server.lock.withPermits(1), Effect.provide(context)); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + server.attempt = undefined; + }), + ), + Effect.provide(context), + ); + /** The server's current connection, connecting first when it has none. */ + const open = (server: Server): Effect.Effect => + Effect.gen(function* () { + const next = yield* Effect.gen(function* () { + if (server.closed) return yield* Effect.fail(`MCP server "${server.name}" is shut down`); + const current = server.live; + // A drop is known here before its `onClose` handler has run. + const reason = current?.connection.dropped(); + if (current !== undefined && reason === undefined) return current; + if (current !== undefined) { + server.state = "disconnected"; + server.error = reason; + yield* retire(server, current); + } + if (server.attempt === undefined) { + server.state = "connecting"; + server.attempt = forkAttempt(attempt(server)); + } + return server.attempt; + }).pipe(server.lock.withPermits(1)); + if (!Fiber.isFiber(next)) return next; + // An interrupted caller leaves the attempt running for whoever needs the server next. + const exit = yield* Fiber.await(next); + if (Exit.isSuccess(exit)) return exit.value; + return yield* Cause.hasInterruptsOnly(exit.cause) + ? Effect.fail(`MCP server "${server.name}" is shut down`) + : Effect.failCause(exit.cause); + }); + + /** Stop the server: interrupt a connect in progress, and retire the connection it has. */ const close = (server: Server) => Effect.gen(function* () { server.closed = true; - const live = server.live; - if (live !== undefined) yield* retire(server, live); - }).pipe(server.lock.withPermits(1)); + const pending = server.attempt; + if (pending !== undefined) yield* Fiber.interrupt(pending); + yield* Effect.suspend(() => + server.live === undefined ? Effect.void : retire(server, server.live), + ).pipe(server.lock.withPermits(1)); + }); /** One call of one of the server's tools, on whichever connection is current when it runs. */ const call = @@ -315,7 +369,6 @@ export const layer = (options: { readonly hostCwd: string }) => names: McpTool.names(), lock: Semaphore.makeUnsafe(1), reported: new Set(), - waited: false, }; sessions.set(sessionId, session); } @@ -332,6 +385,7 @@ export const layer = (options: { readonly hostCwd: string }) => const cwd = input.directory ?? input.hostDir ?? options.hostCwd; const roots = input.directory === undefined ? undefined : [input.directory]; const wanted = new Map(input.servers); + const started: Server[] = []; yield* Effect.gen(function* () { for (const [name, server] of session.servers) { const config = wanted.get(name); @@ -355,22 +409,24 @@ export const layer = (options: { readonly hostCwd: string }) => error: undefined, tools: [], live: undefined, + attempt: undefined, ready: undefined, closed: false, lock: Semaphore.makeUnsafe(1), }; session.servers.set(name, server); start(server); + started.push(server); } }).pipe(session.lock.withPermits(1)); - // Wait so a slow server's tools are still declared in the first request, but never - // indefinitely: one hanging server must not hold up the prompt. - if (!session.waited) { - session.waited = true; - const pending = [...session.servers.values()].filter( - (server) => server.config.enabled !== false && hasDirectTools(server.config), - ); + // Wait for servers this exchange started (all of them at first, then new or restarted ones) so + // their tools are declared in its first request, but never indefinitely: one hanging server + // must not hold up the prompt. + const pending = started.filter( + (server) => server.config.enabled !== false && hasDirectTools(server.config), + ); + if (pending.length > 0) { const finished = yield* Effect.forEach(pending, (server) => server.ready === undefined ? Effect.void : Deferred.await(server.ready), ).pipe(Effect.timeoutOption(STARTUP_WAIT)); diff --git a/packages/harness/src/mcp/tool.ts b/packages/harness/src/mcp/tool.ts index 3ba56aef..412efd9b 100644 --- a/packages/harness/src/mcp/tool.ts +++ b/packages/harness/src/mcp/tool.ts @@ -18,13 +18,19 @@ const MAX_NAME_LENGTH = 64; /** * `mcp____`, with everything but `[A-Za-z0-9_]` replaced by `_`. A name that is too * long, or that `taken` reports as used by another MCP tool, ends in a hash of the server and tool - * instead: sanitizing can map two tools to one name (`a-b` and `a_b`). + * instead: sanitizing can map two tools to one name (`a-b` and `a_b`). A server can also offer a tool + * already named like that hash, so the hash grows until the name is free. */ export const name = (server: string, tool: string, taken: (candidate: string) => boolean = () => false) => { const plain = `mcp__${server}__${tool}`.replace(/[^A-Za-z0-9_]/g, "_"); if (plain.length <= MAX_NAME_LENGTH && !taken(plain)) return plain; - const suffix = Hash.sha256(`${server}\0${tool}`).slice(0, 8); - return `${plain.slice(0, MAX_NAME_LENGTH - suffix.length - 1)}_${suffix}`; + const hash = Hash.sha256(`${server}\0${tool}`); + let candidate = plain; + for (let length = 8; length <= hash.length; length += 8) { + candidate = `${plain.slice(0, MAX_NAME_LENGTH - length - 1)}_${hash.slice(0, length)}`; + if (!taken(candidate)) return candidate; + } + return candidate; }; /** diff --git a/packages/harness/src/plugin/mcp/registry.ts b/packages/harness/src/plugin/mcp/registry.ts index 56a219c3..d2329fd1 100644 --- a/packages/harness/src/plugin/mcp/registry.ts +++ b/packages/harness/src/plugin/mcp/registry.ts @@ -5,6 +5,18 @@ import { McpConfig } from "../../mcp/config.ts"; const PATCH_KEYS: ReadonlyArray = ["enabled", "exposure", "toolExposure", "timeout", "description"]; +/** A copy no one else holds, frozen all the way down, so a settled server cannot change behind the service. */ +const frozen = (value: A): A => { + const freeze = (node: unknown) => { + if (!Predicate.isObject(node) || Object.isFrozen(node)) return; + for (const child of Object.values(node)) freeze(child); + Object.freeze(node); + }; + const copy = structuredClone(value); + freeze(copy); + return copy; +}; + /** * The session's MCP servers for one exchange, seeded from `mcp.json`. A plugin written in plain * JavaScript can hand in anything, so every write is checked here with the rules `mcp.json` follows, @@ -12,14 +24,14 @@ const PATCH_KEYS: ReadonlyArray = ["enabled", "exposure", "toolExposure" */ export const make = (seed: ReadonlyArray) => { let open = true; - const servers = new Map(seed.map((server) => [server.name, server.config])); + const servers = new Map(seed.map((server) => [server.name, frozen(server.config)])); const writable = () => { if (!open) throw new Error("mcp registry is closed: servers are settled before the tool domain runs"); }; const checked = (name: string, config: unknown) => { const result = McpConfig.validate(name, config); if (Predicate.isString(result)) throw new Error(result); - return Object.freeze(result); + return frozen(result); }; const registry: McpRegistry = Object.freeze({ list: () => Object.freeze([...servers].map((entry) => Object.freeze(entry))), diff --git a/packages/harness/test/__artifacts__/mcp.session.json b/packages/harness/test/__artifacts__/mcp.session.json index e5cea1d5..2f75c305 100644 --- a/packages/harness/test/__artifacts__/mcp.session.json +++ b/packages/harness/test/__artifacts__/mcp.session.json @@ -5,7 +5,8 @@ "mcp__e2e__fail", "mcp__e2e__pid", "mcp__e2e__crash", - "mcp__e2e__my_tool_v2" + "mcp__e2e__my_tool_v2", + "mcp__e2e__bye" ], "echoTool": { "name": "mcp__e2e__echo", @@ -114,7 +115,7 @@ { "server": "e2e", "state": "connected", - "tools": 6 + "tools": 7 }, { "server": "off", diff --git a/packages/harness/test/__artifacts__/mcp.session.lifecycle.json b/packages/harness/test/__artifacts__/mcp.session.lifecycle.json new file mode 100644 index 00000000..8de071bc --- /dev/null +++ b/packages/harness/test/__artifacts__/mcp.session.lifecycle.json @@ -0,0 +1,136 @@ +{ + "calls": { + "pid-1": { + "isError": false, + "content": [ + "" + ] + }, + "remote-1": { + "isError": false, + "content": [ + "called" + ] + }, + "remote-retried": { + "isError": false, + "content": [ + "called" + ] + }, + "bye": { + "isError": false, + "content": [ + "bye" + ] + }, + "pid-2": { + "isError": false, + "content": [ + "" + ] + }, + "pid-3": { + "isError": false, + "content": [ + "" + ] + }, + "remote-expired-twice": { + "isError": true, + "content": [ + "MCP tool \"remote/echo\" failed: mcp server session expired: remote" + ] + }, + "pid-4": { + "isError": false, + "content": [ + "" + ] + }, + "echo-changed": { + "isError": false, + "content": [ + "changed: x" + ] + } + }, + "declared": [ + [ + "mcp__e2e__echo", + "mcp__e2e__fail", + "mcp__e2e__pid", + "mcp__e2e__crash", + "mcp__e2e__my_tool_v2", + "mcp__e2e__secret", + "mcp__e2e__bye", + "mcp__remote__echo" + ], + [ + "mcp__e2e__echo", + "mcp__e2e__fail", + "mcp__e2e__pid", + "mcp__e2e__crash", + "mcp__e2e__my_tool_v2", + "mcp__e2e__secret", + "mcp__e2e__bye", + "mcp__remote__echo" + ], + [ + "mcp__e2e__echo", + "mcp__e2e__fail", + "mcp__e2e__pid", + "mcp__e2e__crash", + "mcp__e2e__my_tool_v2", + "mcp__e2e__secret", + "mcp__e2e__bye", + "mcp__remote__echo" + ], + [ + "mcp__e2e__echo", + "mcp__e2e__fail", + "mcp__e2e__pid", + "mcp__e2e__crash", + "mcp__e2e__my_tool_v2", + "mcp__e2e__secret", + "mcp__remote__echo" + ], + [ + "mcp__remote__echo", + "mcp__e2e__echo", + "mcp__e2e__fail", + "mcp__e2e__pid", + "mcp__e2e__crash", + "mcp__e2e__my_tool_v2", + "mcp__e2e__secret" + ] + ], + "remote": [ + "POST initialize", + "GET", + "POST tools/list", + "POST tools/call", + "POST tools/call", + "POST initialize", + "GET", + "POST tools/list", + "POST tools/call", + "POST tools/call", + "POST initialize", + "GET", + "POST tools/list", + "POST tools/call" + ], + "status": [ + { + "server": "remote", + "state": "connected", + "tools": 1 + }, + { + "server": "e2e", + "state": "connected", + "tools": 7 + } + ] +} diff --git a/packages/harness/test/fixtures/mcp/session.mjs b/packages/harness/test/fixtures/mcp/session.mjs index f87b17d0..5ebe1f52 100644 --- a/packages/harness/test/fixtures/mcp/session.mjs +++ b/packages/harness/test/fixtures/mcp/session.mjs @@ -18,6 +18,7 @@ server.setRequestHandler("tools/list", () => ({ { name: "crash", description: "Exit while the call is running.", inputSchema: object() }, { name: "my-tool.v2", inputSchema: { type: "object" } }, { name: "secret", description: "Never declared.", inputSchema: object() }, + { name: "bye", description: "Answer, then exit while idle.", inputSchema: object() }, ], })); server.setRequestHandler("tools/call", (request) => { @@ -33,6 +34,9 @@ server.setRequestHandler("tools/call", (request) => { process.stderr.write("fatal: e2e fixture crashed on purpose\n"); setTimeout(() => process.exit(3), 20); return new Promise(() => {}); + case "bye": + setTimeout(() => process.exit(0), 20); + return text("bye"); case "my-tool.v2": return { content: [], structuredContent: { version: 2 } }; default: diff --git a/packages/harness/test/fixtures/runner.cycle.spec.ts b/packages/harness/test/fixtures/runner.cycle.spec.ts index 0dae06f6..c0439425 100644 --- a/packages/harness/test/fixtures/runner.cycle.spec.ts +++ b/packages/harness/test/fixtures/runner.cycle.spec.ts @@ -44,7 +44,7 @@ const runtime = (root: string, custom: string) => Layer.provideMerge( ((seeded) => State.layer({}, seeded.ref, seeded.references, seeded.follow, seeded.rebuild))(pooled(builtins)), ), - Layer.provideMerge(Mcp.layer({ hostCwd: "/" }).pipe(Layer.provide(Process.host))), + Layer.provideMerge(Mcp.layer({ hostCwd: "/", home: "/unused/home" }).pipe(Layer.provide(Process.host))), Layer.provideMerge(SessionRuntime.layer), Layer.provideMerge( Settings.layer({ userConfigDir: custom }).pipe( diff --git a/packages/harness/test/mcp.session.test.ts b/packages/harness/test/mcp.session.test.ts index 767990ab..71dba235 100644 --- a/packages/harness/test/mcp.session.test.ts +++ b/packages/harness/test/mcp.session.test.ts @@ -14,6 +14,7 @@ import { define } from "../src/plugin/plugin.ts"; import type { LLM } from "../src/runner/llm.ts"; import { Settings } from "../src/settings/settings.ts"; import { assistant } from "./fixtures/llm.ts"; +import { listen, raw } from "./fixtures/mcp/http.ts"; import { tmpdir } from "./fixtures/tempdir.ts"; import { pendingCall } from "./tools.fixture.ts"; @@ -186,3 +187,119 @@ it("mcp: config layers, plugin bucket, direct tools, a crash mid-exchange and th }); await expect(`${JSON.stringify(artifact, null, "\t")}\n`).toMatchFileSnapshot("./__artifacts__/mcp.session.json"); }, 60_000); + +it("mcp: a server exiting while idle, reconfiguration, and an expired HTTP session", async () => { + await using dir = await tmpdir(); + const home = join(dir.path, "home"); + const work = join(dir.path, "work"); + await fs.mkdir(work, { recursive: true }); + process.env.MCP_E2E_GREETING = "hello"; + + // Expires the HTTP session on the next `expire` tool calls: the server answers 404 to the session. + let expire = 0; + const remote = raw({ + script: (request) => { + if (request !== "POST tools/call" || expire === 0) return undefined; + expire -= 1; + return { status: 404, body: "session not found" }; + }, + }); + const configure = (url: URL, e2e: Record) => + write(join(home, "mcp.json"), { mcpServers: { e2e: stdio(e2e), remote: { url: url.href } } }); + + const runs: ReadonlyArray> = [ + [pendingCall("mcp__e2e__pid", {}, "pid-1"), pendingCall("mcp__remote__echo", {}, "remote-1")], + [pendingCall("mcp__remote__echo", {}, "remote-retried"), pendingCall("mcp__e2e__bye", {}, "bye")], + // The server exited while idle; the next call starts it again. + [pendingCall("mcp__e2e__pid", {}, "pid-2")], + // Only exposure changed: the same process, with `bye` no longer declared. + [pendingCall("mcp__e2e__pid", {}, "pid-3"), pendingCall("mcp__remote__echo", {}, "remote-expired-twice")], + // The environment changed: a new process. + [pendingCall("mcp__e2e__pid", {}, "pid-4"), pendingCall("mcp__e2e__echo", { text: "x" }, "echo-changed")], + ]; + const requests: LLM.Input[] = []; + const llm = scripted( + runs.flatMap((calls) => [calls, []]), + requests, + ); + + const result = await Effect.gen(function* () { + const url = yield* listen(remote.handler); + yield* Effect.promise(() => configure(url, {})); + const session = yield* Session.create({ directory: work, model: { provider: "openai", id: "gpt-5.6-luna" } }); + const status = Mcp.Service.use((mcp) => mcp.status(session.id)); + const state = (server: string) => + status.pipe(Effect.map((all) => all.find((one) => one.server === server)?.state)); + + yield* session.run("one"); + expire = 1; + yield* session.run("two"); + // Wait for the idle exit to be noticed, so the next call meets a dropped connection. + for (let tries = 0; tries < 250 && (yield* state("e2e")) !== "disconnected"; tries++) + yield* Effect.sleep("20 millis"); + const afterBye = yield* state("e2e"); + yield* session.run("three"); + yield* Effect.promise(() => configure(url, { toolExposure: { bye: "hidden" } })); + expire = 2; + yield* session.run("four"); + yield* Effect.promise(() => + configure(url, { toolExposure: { bye: "hidden" }, env: { E2E_GREETING: "changed" } }), + ); + yield* session.run("five"); + + const messages = yield* Effect.forEach(yield* session.path(), ContextCodec.decodeMessage); + return { messages, afterBye, status: yield* status }; + }).pipe( + Effect.provide( + Harness.layer({ home, hostCwd: dir.path, database: ":memory:", plugins: [defaultPromptPlugin], llm }), + ), + Effect.scoped, + Effect.runPromise, + ); + + const texts = new Map( + result.messages + .flatMap((message) => message.parts) + .flatMap((part) => + part.type === "toolCall" && "result" in part + ? [ + [ + part.callID, + { + isError: part.result.isError, + content: part.result.content.map((block) => (block.type === "text" ? block.text : "")), + }, + ] as const, + ] + : [], + ), + ); + const pid = (id: string) => texts.get(id)?.content[0]; + expect(result.afterBye).toBe("disconnected"); + expect(pid("pid-2")).not.toBe(pid("pid-1")); + expect(pid("pid-3")).toBe(pid("pid-2")); + expect(pid("pid-4")).not.toBe(pid("pid-3")); + + // The first request of each exchange: exposure changes apply at the next exchange. + const declared = runs.map((_, index) => requests[index * 2]?.context.tools?.map((tool) => tool.name) ?? []); + expect(declared[2]).toContain("mcp__e2e__bye"); + expect(declared[3]).not.toContain("mcp__e2e__bye"); + + const pids = [...new Set(["pid-1", "pid-2", "pid-3", "pid-4"].map(pid))]; + let serialized = JSON.stringify({ + calls: Object.fromEntries(texts), + declared, + // One expiry: a second initialize and the call once more. Two in a row: the call fails. + remote: remote.seen.filter((request) => request !== "POST notifications/initialized"), + status: result.status, + }); + pids.forEach((value, index) => { + serialized = serialized.replaceAll(`"${value}"`, `""`); + }); + const artifact = JSON.parse( + serialized.replaceAll(dir.path, "").replace(/127\.0\.0\.1:\d+/g, "127.0.0.1:"), + ); + await expect(`${JSON.stringify(artifact, null, "\t")}\n`).toMatchFileSnapshot( + "./__artifacts__/mcp.session.lifecycle.json", + ); +}, 60_000); diff --git a/packages/harness/test/runner.loop.test.ts b/packages/harness/test/runner.loop.test.ts index 0a363aaa..c6336bf2 100644 --- a/packages/harness/test/runner.loop.test.ts +++ b/packages/harness/test/runner.loop.test.ts @@ -63,7 +63,7 @@ const runtime = ( pooled(options.plugins ?? plugins), ), ), - Layer.provideMerge(Mcp.layer({ hostCwd: "/" }).pipe(Layer.provide(Process.host))), + Layer.provideMerge(Mcp.layer({ hostCwd: "/", home: "/unused/home" }).pipe(Layer.provide(Process.host))), Layer.provideMerge(SessionRuntime.layer), Layer.provideMerge( Layer.succeed(Settings.Service, { From bdac30c1914c03e73b615d46d5f3bf5e480d3efa Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Sat, 24 Oct 2026 19:17:38 +0530 Subject: [PATCH 12/17] fix(harness): harden MCP config values and send servers clean arguments - Refuse env and header values with a line break or NUL without quoting them. - Run a !command with stdin closed, and reuse its output for the life of the process. - Report auth/oauth entries as unsupported instead of ignoring the keys. - Drop the nulls strict sampling writes for omitted optional arguments before the call. - Validate JSON Schema patterns instead of skipping the whole schema. - Refresh a changed tool list without holding the server lock. - Retry a server whose connect failed at the next exchange, in the background. - Stop removed or changed servers without making the prompt wait. - Keep hashed tool names within 64 characters. Co-Authored-By: Claude Opus 5.5 --- packages/harness/src/mcp/config.ts | 3 ++ packages/harness/src/mcp/mcp.ts | 31 +++++++++------- packages/harness/src/mcp/tool.ts | 20 +++++++--- packages/harness/src/mcp/value.ts | 37 ++++++++++++++----- .../test/__artifacts__/mcp.session.json | 32 +++++++++++++++- .../__artifacts__/mcp.session.lifecycle.json | 7 +++- .../harness/test/fixtures/mcp/session.mjs | 7 ++++ packages/harness/test/mcp.session.test.ts | 14 ++++++- packages/plugin/src/tool.ts | 8 ++-- 9 files changed, 124 insertions(+), 35 deletions(-) diff --git a/packages/harness/src/mcp/config.ts b/packages/harness/src/mcp/config.ts index 9a5fe536..fad45097 100644 --- a/packages/harness/src/mcp/config.ts +++ b/packages/harness/src/mcp/config.ts @@ -55,6 +55,9 @@ export const validate = (name: string, value: unknown): ServerConfig | string => return `server "${name}": description must be a string`; if (timeout !== undefined && !(Predicate.isNumber(timeout) && timeout > 0)) return `server "${name}": timeout must be a positive number of seconds`; + // Until sign-in exists, an entry relying on it could only fail; say so instead of ignoring the key. + for (const key of ["auth", "oauth"]) + if (value[key] !== undefined) return `server "${name}": "${key}" is not supported yet`; if (type === "sse") return `server "${name}": legacy SSE transport is not supported; use the streamable HTTP URL`; if (Predicate.isString(value.url) && (type === undefined || type === "http" || type === "streamable-http")) { diff --git a/packages/harness/src/mcp/mcp.ts b/packages/harness/src/mcp/mcp.ts index 33d39292..6dac4db4 100644 --- a/packages/harness/src/mcp/mcp.ts +++ b/packages/harness/src/mcp/mcp.ts @@ -194,21 +194,22 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string ); // Calls replace an expired session themselves; this covers a background request seeing it first. live.connection.onSessionExpired(current(retire(server, live))); + // Listed without the server's lock, which calls and closing need: the listing can take a while. live.connection.onToolsChanged( - current( - live.connection.tools().pipe( - Effect.tap((tools) => - Effect.sync(() => { - server.tools = tools; - }), - ), - Effect.catch((error) => - Effect.sync(() => { - server.error = `failed to refresh tools: ${error.message}`; + Effect.sync(() => { + fork( + live.connection.tools().pipe( + Effect.match({ + onSuccess: (tools) => { + if (server.live === live) server.tools = tools; + }, + onFailure: (error) => { + if (server.live === live) server.error = `failed to refresh tools: ${error.message}`; + }, }), ), - ), - ), + ); + }), ); live.connection.onLog((entry) => log.write(server.name, entry)); }; @@ -392,10 +393,14 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string // Exposure and description apply to the next exchange without reconnecting. if (config !== undefined && connectionKey(config, cwd, roots) === server.key) { server.config = config; + // A server that could not connect declares no tools, so no call would retry it. + // Retry in the background; waiting for it would hold up every prompt while it is down. + if (server.state === "failed" && server.attempt === undefined) start(server); continue; } session.servers.delete(name); - yield* close(server); + // Stopping can take seconds; the prompt does not wait for it. + fork(close(server)); } for (const [name, config] of wanted) { if (session.servers.has(name)) continue; diff --git a/packages/harness/src/mcp/tool.ts b/packages/harness/src/mcp/tool.ts index 412efd9b..41e3561b 100644 --- a/packages/harness/src/mcp/tool.ts +++ b/packages/harness/src/mcp/tool.ts @@ -3,6 +3,7 @@ * reaches it. */ +import { normalizeOptionalNulls } from "@codeworksh/aikit"; import type { Exposure, ServerConfig } from "@codeworksh/plugin/plugin/mcp"; import type { CallToolResult, Tool as McpTool } from "@modelcontextprotocol/client"; import { Effect, Schema } from "effect"; @@ -26,7 +27,8 @@ export const name = (server: string, tool: string, taken: (candidate: string) => if (plain.length <= MAX_NAME_LENGTH && !taken(plain)) return plain; const hash = Hash.sha256(`${server}\0${tool}`); let candidate = plain; - for (let length = 8; length <= hash.length; length += 8) { + // The executor refuses two tools of one name, so the hash grows, while `mcp__` still fits before it. + for (let length = 8; length <= MAX_NAME_LENGTH - "mcp__".length - 1; length += 8) { candidate = `${plain.slice(0, MAX_NAME_LENGTH - length - 1)}_${hash.slice(0, length)}`; if (!taken(candidate)) return candidate; } @@ -99,6 +101,12 @@ const parameters = (schema: McpTool["inputSchema"]): Record => ...(schema.properties === undefined ? { properties: {} } : {}), }); +const withoutOptionalNulls = (args: Record, schema: Record) => { + const normalized = structuredClone(args); + normalizeOptionalNulls(normalized, schema); + return normalized; +}; + export interface Call { readonly args: Record; readonly onProgress: (progress: Progress) => void; @@ -122,26 +130,28 @@ export const make = (input: { server, tool: tool.name, }); + const schema = parameters(tool.inputSchema); return Tool.register( Tool.make({ name: input.name, label: `${server}/${tool.name}`, description: tool.description?.trim() || title || `MCP tool ${tool.name} from server ${server}`, - parameters: Tool.fromJsonSchema(parameters(tool.inputSchema)), + parameters: Tool.fromJsonSchema(schema), success: Outcome, failure: Outcome, encodeContent: (outcome) => outcome.content as McpContent.Content[], encodeFailureContent: (outcome) => outcome.content as McpContent.Content[], encodeDetails: ({ content: _content, ...details }) => details, - // The arguments as the model wrote them: validation only checks them, and the server, not - // a translation of its schema, decides what they mean. + // The arguments as the model wrote them, minus the `null`s strict sampling writes for omitted + // optional ones: validation only checks them, and the server, not a translation of its schema, + // decides what they mean. handler: (_params, ctx) => Effect.gen(function* () { const progress = yield* ToolProgress; const report = Effect.runForkWith(yield* Effect.context()); const result = yield* input .call({ - args: ctx.rawArgs, + args: withoutOptionalNulls(ctx.rawArgs, schema), onProgress: (update) => { const total = update.total === undefined ? "" : `/${update.total}`; const text = update.message ?? `Progress ${update.progress}${total}`; diff --git a/packages/harness/src/mcp/value.ts b/packages/harness/src/mcp/value.ts index 5db1946a..7b9ba932 100644 --- a/packages/harness/src/mcp/value.ts +++ b/packages/harness/src/mcp/value.ts @@ -1,9 +1,13 @@ /** * Values of a server's `env` and `headers`, resolved when the server connects: * - * - `!command` runs the rest through `/bin/sh` and uses its trimmed stdout. + * - `!command` runs the rest through `/bin/sh` and uses its trimmed stdout, once per process: later + * connects reuse it. A command that fails runs again next time, since the host outlives sessions. * - Otherwise `$NAME` and `${NAME}` are replaced by host environment variables; `$$` is a literal * `$` and `$!` a literal `!`. Anything else is literal. + * + * A value holding a line break or NUL is refused without being shown: it cannot be sent as a header + * or set as a variable, and the error the runtime would raise quotes the value, often a secret. */ import { Config, Duration, Effect, Option, Stream } from "effect"; @@ -12,6 +16,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; const COMMAND_TIMEOUT = Duration.seconds(10); const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; const NAME_PREFIX = /^[A-Za-z_][A-Za-z0-9_]*/; +const UNSAFE = /[\r\n\0]/; +const commands = new Map(); type Part = { readonly type: "literal"; readonly value: string } | { readonly type: "env"; readonly name: string }; @@ -66,7 +72,9 @@ const template = (value: string): ReadonlyArray => { const run = (command: string) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const handle = yield* spawner.spawn(ChildProcess.make("/bin/sh", ["-c", command], { stderr: "ignore" })); + const handle = yield* spawner.spawn( + ChildProcess.make("/bin/sh", ["-c", command], { stdin: "ignore", stderr: "ignore" }), + ); const [output, code] = yield* Effect.all([Stream.mkString(Stream.decodeText(handle.stdout)), handle.exitCode], { concurrency: 2, }); @@ -79,14 +87,16 @@ const run = (command: string) => Effect.orElseSucceed(() => undefined), ); -/** One value. `what` names it in the failure, for example `MCP server "docs" header "Authorization"`. */ -export const resolve = Effect.fnUntraced(function* (value: string, what: string) { - if (value.startsWith("!")) { - const output = yield* run(value.slice(1)); - if (output === undefined) - return yield* Effect.fail(`Failed to resolve ${what} from shell command: ${value.slice(1)}`); - return output; - } +const command = Effect.fnUntraced(function* (line: string, what: string) { + const cached = commands.get(line); + if (cached !== undefined) return cached; + const output = yield* run(line); + if (output === undefined) return yield* Effect.fail(`Failed to resolve ${what} from shell command: ${line}`); + commands.set(line, output); + return output; +}); + +const variables = Effect.fnUntraced(function* (value: string, what: string) { const parts = template(value); const names = [...new Set(parts.flatMap((part) => (part.type === "env" ? [part.name] : [])))]; // An empty variable counts as unset. @@ -103,6 +113,13 @@ export const resolve = Effect.fnUntraced(function* (value: string, what: string) return parts.map((part) => (part.type === "literal" ? part.value : env.get(part.name))).join(""); }); +/** One value. `what` names it in the failure, for example `MCP server "docs" header "Authorization"`. */ +export const resolve = Effect.fnUntraced(function* (value: string, what: string) { + const resolved = yield* value.startsWith("!") ? command(value.slice(1), what) : variables(value, what); + if (UNSAFE.test(resolved)) return yield* Effect.fail(`${what} contains a line break or NUL character`); + return resolved; +}); + /** Every value of a record, failing on the first that does not resolve. */ export const resolveAll = Effect.fnUntraced(function* (values: Readonly>, what: string) { const resolved: Record = {}; diff --git a/packages/harness/test/__artifacts__/mcp.session.json b/packages/harness/test/__artifacts__/mcp.session.json index 2f75c305..78cba2a7 100644 --- a/packages/harness/test/__artifacts__/mcp.session.json +++ b/packages/harness/test/__artifacts__/mcp.session.json @@ -2,6 +2,7 @@ "requests": 6, "tools": [ "mcp__e2e__echo", + "mcp__e2e__args", "mcp__e2e__fail", "mcp__e2e__pid", "mcp__e2e__crash", @@ -38,6 +39,10 @@ [ "my-srv", false + ], + [ + "badheader", + true ] ], [ @@ -52,6 +57,10 @@ [ "my-srv", false + ], + [ + "badheader", + true ] ] ], @@ -82,6 +91,13 @@ "{\n \"version\": 2\n}" ] }, + "nulls": { + "status": "completed", + "isError": false, + "content": [ + "{\"word\":\"abc\"}" + ] + }, "pid-before": { "status": "completed", "isError": false, @@ -109,13 +125,20 @@ "content": [ "Invalid arguments for mcp__e2e__echo: Missing key\n at [\"text\"]" ] + }, + "pattern": { + "status": "error", + "isError": true, + "content": [ + "Invalid arguments for mcp__e2e__args: Expected a string matching the RegExp ^[a-z]+$\n at [\"word\"]" + ] } }, "status": [ { "server": "e2e", "state": "connected", - "tools": 7 + "tools": 8 }, { "server": "off", @@ -126,11 +149,18 @@ "server": "my-srv", "state": "disabled", "tools": 0 + }, + { + "server": "badheader", + "state": "failed", + "error": "MCP server \"badheader\" header \"Authorization\" contains a line break or NUL character", + "tools": 0 } ], "configErrors": [ "/home/mcp.json: server \"my_srv\" conflicts with \"my-srv\"", "/home/mcp.json: server \"broken\": url must be an http or https URL", + "/home/mcp.json: server \"signin\": \"oauth\" is not supported yet", "/project/.codework/mcp.json: server \"ghost\" needs \"command\" or \"url\", or a user server to override" ] } diff --git a/packages/harness/test/__artifacts__/mcp.session.lifecycle.json b/packages/harness/test/__artifacts__/mcp.session.lifecycle.json index 8de071bc..0c74d0ca 100644 --- a/packages/harness/test/__artifacts__/mcp.session.lifecycle.json +++ b/packages/harness/test/__artifacts__/mcp.session.lifecycle.json @@ -58,6 +58,7 @@ "declared": [ [ "mcp__e2e__echo", + "mcp__e2e__args", "mcp__e2e__fail", "mcp__e2e__pid", "mcp__e2e__crash", @@ -68,6 +69,7 @@ ], [ "mcp__e2e__echo", + "mcp__e2e__args", "mcp__e2e__fail", "mcp__e2e__pid", "mcp__e2e__crash", @@ -78,6 +80,7 @@ ], [ "mcp__e2e__echo", + "mcp__e2e__args", "mcp__e2e__fail", "mcp__e2e__pid", "mcp__e2e__crash", @@ -88,6 +91,7 @@ ], [ "mcp__e2e__echo", + "mcp__e2e__args", "mcp__e2e__fail", "mcp__e2e__pid", "mcp__e2e__crash", @@ -98,6 +102,7 @@ [ "mcp__remote__echo", "mcp__e2e__echo", + "mcp__e2e__args", "mcp__e2e__fail", "mcp__e2e__pid", "mcp__e2e__crash", @@ -130,7 +135,7 @@ { "server": "e2e", "state": "connected", - "tools": 7 + "tools": 8 } ] } diff --git a/packages/harness/test/fixtures/mcp/session.mjs b/packages/harness/test/fixtures/mcp/session.mjs index 5ebe1f52..669c8620 100644 --- a/packages/harness/test/fixtures/mcp/session.mjs +++ b/packages/harness/test/fixtures/mcp/session.mjs @@ -13,6 +13,11 @@ server.setRequestHandler("tools/list", () => ({ description: "Echo text back, prefixed with the configured greeting.", inputSchema: object({ text: { type: "string", description: "What to echo." } }, ["text"]), }, + { + name: "args", + description: "The arguments as received.", + inputSchema: object({ word: { type: "string", pattern: "^[a-z]+$" }, limit: { type: "integer" } }, ["word"]), + }, { name: "fail", description: "Answer with an error result.", inputSchema: object() }, { name: "pid", description: "The server's process id.", inputSchema: object() }, { name: "crash", description: "Exit while the call is running.", inputSchema: object() }, @@ -26,6 +31,8 @@ server.setRequestHandler("tools/call", (request) => { switch (request.params.name) { case "echo": return text(`${process.env.E2E_GREETING}: ${String(args.text)}`); + case "args": + return text(JSON.stringify(args)); case "fail": return { isError: true, content: [{ type: "text", text: "the fixture refused" }] }; case "pid": diff --git a/packages/harness/test/mcp.session.test.ts b/packages/harness/test/mcp.session.test.ts index 71dba235..8d226daf 100644 --- a/packages/harness/test/mcp.session.test.ts +++ b/packages/harness/test/mcp.session.test.ts @@ -52,6 +52,8 @@ it("mcp: config layers, plugin bucket, direct tools, a crash mid-exchange and th const work = join(dir.path, "work"); await fs.mkdir(work, { recursive: true }); process.env.MCP_E2E_GREETING = "hello"; + // A `!command` printing a notice after the token, say: never sent, and never shown. + process.env.MCP_E2E_BAD_HEADER = "Bearer sk-e2e-secret\nnotice"; await write(join(home, "mcp.json"), { mcpServers: { @@ -60,6 +62,8 @@ it("mcp: config layers, plugin bucket, direct tools, a crash mid-exchange and th "my-srv": stdio({ enabled: false }), my_srv: stdio(), broken: { url: "ftp://example.com/mcp" }, + signin: { url: "https://example.com/mcp", oauth: { clientId: "e2e" } }, + badheader: { url: "http://127.0.0.1:9/mcp", headers: { Authorization: "${MCP_E2E_BAD_HEADER}" } }, }, }); // The project turns a user server off without restating it, and cannot add an override with no base. @@ -95,13 +99,15 @@ it("mcp: config layers, plugin bucket, direct tools, a crash mid-exchange and th pendingCall("mcp__e2e__echo", { text: "hi" }, "echo"), pendingCall("mcp__e2e__fail", {}, "fail"), pendingCall("mcp__e2e__my_tool_v2", {}, "structured"), + // Strict sampling writes `null` for an omitted optional argument; the server never sees it. + pendingCall("mcp__e2e__args", { word: "abc", limit: null }, "nulls"), ], [pendingCall("mcp__e2e__pid", {}, "pid-before"), pendingCall("mcp__e2e__crash", {}, "crash")], // Same exchange: the server is gone, so this call reconnects first. [pendingCall("mcp__e2e__pid", {}, "pid-after")], [], // Next exchange: arguments the server's schema rejects never reach it. - [pendingCall("mcp__e2e__echo", {}, "invalid")], + [pendingCall("mcp__e2e__echo", {}, "invalid"), pendingCall("mcp__e2e__args", { word: "ABC" }, "pattern")], [], ], requests, @@ -180,11 +186,15 @@ it("mcp: config layers, plugin bucket, direct tools, a crash mid-exchange and th echoTool: firstTools.find((tool) => tool.name === "mcp__e2e__echo"), plugins: observed, calls: Object.fromEntries( - ["echo", "fail", "structured", "pid-before", "crash", "pid-after", "invalid"].map((id) => [id, outcome(id)]), + ["echo", "fail", "structured", "nulls", "pid-before", "crash", "pid-after", "invalid", "pattern"].map((id) => [ + id, + outcome(id), + ]), ), status: result.status, configErrors: result.errors, }); + expect(JSON.stringify(result)).not.toContain("sk-e2e-secret"); await expect(`${JSON.stringify(artifact, null, "\t")}\n`).toMatchFileSnapshot("./__artifacts__/mcp.session.json"); }, 60_000); diff --git a/packages/plugin/src/tool.ts b/packages/plugin/src/tool.ts index eddb250c..6a136f9f 100644 --- a/packages/plugin/src/tool.ts +++ b/packages/plugin/src/tool.ts @@ -237,9 +237,10 @@ const providerJsonSchema = "codework/providerJsonSchema"; /** * Parameters from a JSON Schema someone else wrote, such as an MCP server's `inputSchema`. The - * provider receives it exactly as written; arguments are validated against its Effect translation. - * A schema the translation cannot express (a `pattern`, an external `$ref`) validates nothing, - * since rejecting arguments the schema allows would be worse than letting the server check them. + * provider receives it exactly as written; arguments are validated against its Effect translation, + * `pattern`s included: the user chose the server. A schema the translation cannot express (an + * external `$ref`, a pattern invalid in Unicode mode) validates nothing, since rejecting arguments + * the schema allows would be worse than letting the server check them. */ export const fromJsonSchema = (schema: Record): Schema.Top => { const validator = (() => { @@ -248,6 +249,7 @@ export const fromJsonSchema = (schema: Record): Schema.Top => { (typeof schema.$schema === "string" && schema.$schema.includes("draft-07")) || "definitions" in schema; return SchemaRepresentation.fromJsonSchemaDocument( draft07 ? JsonSchema.fromSchemaDraft07(schema) : JsonSchema.fromSchemaDraft2020_12(schema), + { patterns: "apply" }, ); } catch { return Schema.Unknown; From 2237d9e234a8ac0e079a0867586316b4b06b595a Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Sat, 24 Oct 2026 21:06:21 +0530 Subject: [PATCH 13/17] fix(harness): keep MCP retries, shutdown and tool lists right under races - Start a connect attempt after it is recorded, so one that fails at once (an unset variable, say) no longer blocks every later retry. - Close server connections in parallel on shutdown, after interrupting connect attempts. - Keep only the latest tool listing when list_changed notifications overlap. - Write server log messages from the handshake on. - Kill a !command that ignores SIGTERM, and share one run between concurrent connects. - Allow line breaks in env values (a PEM key); headers still refuse them. - Reject arguments a closed JSON Schema object does not declare, instead of passing them on. Co-Authored-By: Claude Opus 5.5 --- packages/harness/src/mcp/mcp.ts | 32 +++++++--- packages/harness/src/mcp/value.ts | 58 +++++++++++++------ packages/harness/src/tool/executor.ts | 13 ++++- .../test/__artifacts__/mcp.session.json | 7 +++ .../harness/test/fixtures/mcp/session.mjs | 5 +- packages/harness/test/mcp.session.test.ts | 29 ++++++++-- packages/plugin/src/tool.ts | 8 +++ 7 files changed, 118 insertions(+), 34 deletions(-) diff --git a/packages/harness/src/mcp/mcp.ts b/packages/harness/src/mcp/mcp.ts index 6dac4db4..1f9c3b6f 100644 --- a/packages/harness/src/mcp/mcp.ts +++ b/packages/harness/src/mcp/mcp.ts @@ -86,6 +86,8 @@ interface Live { readonly scope: Scope.Closeable; inflight: number; retired: boolean; + /** Bumped by every `tools/list_changed`, so only the latest listing is kept. */ + refreshes: number; } interface Server { @@ -129,6 +131,9 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string Service, Effect.gen(function* () { const root = yield* Effect.scope; + // Connections close in parallel on shutdown, so one slow server's stop grace does not hold up + // the rest. Made before the fibers below, so connect attempts are interrupted before it closes. + const servers = yield* Scope.fork(root, "parallel"); const fork = yield* FiberSet.makeRuntime(); const forkAttempt = yield* FiberSet.makeRuntime(); const context = yield* Effect.context(); @@ -160,14 +165,14 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string return { _tag: "Http", url: new URL(config.url), - headers: yield* McpValue.resolveAll(config.headers ?? {}, `${what} header`), + headers: yield* McpValue.resolveAll(config.headers ?? {}, "header", `${what} header`), } satisfies McpClient.Http; return { _tag: "Stdio", // One executable and its arguments, not a command line: only a leading `~/` is expanded. command: expandTilde(config.command, hostPath), args: (config.args ?? []).map((arg) => expandTilde(arg, hostPath)), - env: yield* McpValue.resolveAll(config.env ?? {}, `${what} env`), + env: yield* McpValue.resolveAll(config.env ?? {}, "env", `${what} env`), cwd: hostPath.resolve(server.cwd, expandTilde(config.cwd ?? ".", hostPath)), } satisfies McpClient.Stdio; }); @@ -195,29 +200,31 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string // Calls replace an expired session themselves; this covers a background request seeing it first. live.connection.onSessionExpired(current(retire(server, live))); // Listed without the server's lock, which calls and closing need: the listing can take a while. + // Listings can finish out of order; only the latest one counts. live.connection.onToolsChanged( Effect.sync(() => { + const refresh = ++live.refreshes; + const latest = () => server.live === live && live.refreshes === refresh; fork( live.connection.tools().pipe( Effect.match({ onSuccess: (tools) => { - if (server.live === live) server.tools = tools; + if (latest()) server.tools = tools; }, onFailure: (error) => { - if (server.live === live) server.error = `failed to refresh tools: ${error.message}`; + if (latest()) server.error = `failed to refresh tools: ${error.message}`; }, }), ), ); }), ); - live.connection.onLog((entry) => log.write(server.name, entry)); }; /** One connect attempt, run apart from any caller so that closing the server can interrupt it. */ const attempt = (server: Server): Effect.Effect => Effect.gen(function* () { - const scope = yield* Scope.fork(root, "sequential"); + const scope = yield* Scope.fork(servers, "sequential"); const config = server.config; const connect = Effect.gen(function* () { const target = yield* endpoint(server); @@ -226,6 +233,7 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string endpoint: target, ...(server.roots === undefined ? {} : { roots: server.roots }), timeout: { request: Duration.seconds(config.timeout ?? DEFAULT_TIMEOUT_SECONDS) }, + onLog: (entry) => log.write(server.name, entry), }).pipe(Scope.provide(scope)); const tools = yield* connection.tools(); return { connection, tools }; @@ -244,7 +252,13 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string yield* Effect.logWarning("mcp connect failed", { server: server.name, error: message }); return yield* Effect.fail(message); } - const live: Live = { connection: exit.value.connection, scope, inflight: 0, retired: false }; + const live: Live = { + connection: exit.value.connection, + scope, + inflight: 0, + retired: false, + refreshes: 0, + }; server.live = live; server.tools = exit.value.tools; server.state = "connected"; @@ -277,7 +291,9 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string } if (server.attempt === undefined) { server.state = "connecting"; - server.attempt = forkAttempt(attempt(server)); + // Started after the assignment: an attempt that fails at once would otherwise clear + // `attempt` before it is set, and the finished fiber would stand in for every retry. + server.attempt = forkAttempt(Effect.andThen(Effect.yieldNow, attempt(server))); } return server.attempt; }).pipe(server.lock.withPermits(1)); diff --git a/packages/harness/src/mcp/value.ts b/packages/harness/src/mcp/value.ts index 7b9ba932..9b415c0a 100644 --- a/packages/harness/src/mcp/value.ts +++ b/packages/harness/src/mcp/value.ts @@ -2,22 +2,28 @@ * Values of a server's `env` and `headers`, resolved when the server connects: * * - `!command` runs the rest through `/bin/sh` and uses its trimmed stdout, once per process: later - * connects reuse it. A command that fails runs again next time, since the host outlives sessions. + * and concurrent connects share it. A command that fails runs again next time, since the host + * outlives sessions. * - Otherwise `$NAME` and `${NAME}` are replaced by host environment variables; `$$` is a literal * `$` and `$!` a literal `!`. Anything else is literal. * - * A value holding a line break or NUL is refused without being shown: it cannot be sent as a header - * or set as a variable, and the error the runtime would raise quotes the value, often a secret. + * A header holding a line break or NUL, or a variable holding NUL, is refused without being shown: it + * cannot be sent or set, and the error the runtime would raise quotes the value, often a secret. A + * variable may span lines (a PEM key, say). */ -import { Config, Duration, Effect, Option, Stream } from "effect"; +import { Config, Duration, Effect, Fiber, Option, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; const COMMAND_TIMEOUT = Duration.seconds(10); const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; const NAME_PREFIX = /^[A-Za-z_][A-Za-z0-9_]*/; -const UNSAFE = /[\r\n\0]/; -const commands = new Map(); +const COMMAND_KILL_AFTER = Duration.seconds(2); +const UNSAFE = { + header: { pattern: /[\r\n\0]/, what: "a line break or NUL character" }, + env: { pattern: /\0/, what: "a NUL character" }, +} as const; +const commands = new Map>(); type Part = { readonly type: "literal"; readonly value: string } | { readonly type: "env"; readonly name: string }; @@ -73,7 +79,12 @@ const run = (command: string) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const handle = yield* spawner.spawn( - ChildProcess.make("/bin/sh", ["-c", command], { stdin: "ignore", stderr: "ignore" }), + // A helper that ignores SIGTERM must not outlast the timeout. + ChildProcess.make("/bin/sh", ["-c", command], { + stdin: "ignore", + stderr: "ignore", + forceKillAfter: COMMAND_KILL_AFTER, + }), ); const [output, code] = yield* Effect.all([Stream.mkString(Stream.decodeText(handle.stdout)), handle.exitCode], { concurrency: 2, @@ -88,11 +99,17 @@ const run = (command: string) => ); const command = Effect.fnUntraced(function* (line: string, what: string) { - const cached = commands.get(line); - if (cached !== undefined) return cached; - const output = yield* run(line); - if (output === undefined) return yield* Effect.fail(`Failed to resolve ${what} from shell command: ${line}`); - commands.set(line, output); + let running = commands.get(line); + if (running === undefined) { + // Detached, so a caller giving up does not stop it for the others waiting on it. + running = yield* Effect.forkDetach(run(line)); + commands.set(line, running); + } + const output = yield* Fiber.join(running); + if (output === undefined) { + if (commands.get(line) === running) commands.delete(line); + return yield* Effect.fail(`Failed to resolve ${what} from shell command: ${line}`); + } return output; }); @@ -113,17 +130,24 @@ const variables = Effect.fnUntraced(function* (value: string, what: string) { return parts.map((part) => (part.type === "literal" ? part.value : env.get(part.name))).join(""); }); -/** One value. `what` names it in the failure, for example `MCP server "docs" header "Authorization"`. */ -export const resolve = Effect.fnUntraced(function* (value: string, what: string) { +/** + * One value, used as `kind`. `what` names it in the failure, for example + * `MCP server "docs" header "Authorization"`. + */ +export const resolve = Effect.fnUntraced(function* (value: string, kind: keyof typeof UNSAFE, what: string) { const resolved = yield* value.startsWith("!") ? command(value.slice(1), what) : variables(value, what); - if (UNSAFE.test(resolved)) return yield* Effect.fail(`${what} contains a line break or NUL character`); + if (UNSAFE[kind].pattern.test(resolved)) return yield* Effect.fail(`${what} contains ${UNSAFE[kind].what}`); return resolved; }); /** Every value of a record, failing on the first that does not resolve. */ -export const resolveAll = Effect.fnUntraced(function* (values: Readonly>, what: string) { +export const resolveAll = Effect.fnUntraced(function* ( + values: Readonly>, + kind: keyof typeof UNSAFE, + what: string, +) { const resolved: Record = {}; - for (const [key, value] of Object.entries(values)) resolved[key] = yield* resolve(value, `${what} "${key}"`); + for (const [key, value] of Object.entries(values)) resolved[key] = yield* resolve(value, kind, `${what} "${key}"`); return resolved; }); diff --git a/packages/harness/src/tool/executor.ts b/packages/harness/src/tool/executor.ts index 21a2c843..9c881617 100644 --- a/packages/harness/src/tool/executor.ts +++ b/packages/harness/src/tool/executor.ts @@ -25,7 +25,14 @@ import { isAikitToolCallTerminalPart } from "../schema.ts"; import { errorMessage } from "../util/error.ts"; import { ToolExecutionError } from "./error.ts"; import { ToolProgress, type ToolProgressPartial } from "./progress.ts"; -import { type AnyToolDef, type ModelContent, type RegisteredTool, toAikitTool, type ToolCallContext } from "./tool.ts"; +import { + type AnyToolDef, + type ModelContent, + type RegisteredTool, + isFromJsonSchema, + toAikitTool, + type ToolCallContext, +} from "./tool.ts"; /** * `ToolExecutor` — the uniform pipeline run for every tool call: @@ -284,8 +291,10 @@ export const make = (tools: ReadonlyArray): E // does not allow are dropped first. const decoders = new Map( wire.map((tool) => { + const parameters = impls.get(tool.name)!.tool.definition.parameters; const decode = Schema.decodeUnknownEffect( - Schema.toCodecJson(asCodec(impls.get(tool.name)!.tool.definition.parameters)), + Schema.toCodecJson(asCodec(parameters)), + isFromJsonSchema(parameters) ? { onExcessProperty: "error" } : undefined, ); const parse = (args: unknown) => { const normalized = structuredClone(args); diff --git a/packages/harness/test/__artifacts__/mcp.session.json b/packages/harness/test/__artifacts__/mcp.session.json index 78cba2a7..5f44bea5 100644 --- a/packages/harness/test/__artifacts__/mcp.session.json +++ b/packages/harness/test/__artifacts__/mcp.session.json @@ -132,6 +132,13 @@ "content": [ "Invalid arguments for mcp__e2e__args: Expected a string matching the RegExp ^[a-z]+$\n at [\"word\"]" ] + }, + "excess": { + "status": "error", + "isError": true, + "content": [ + "Invalid arguments for mcp__e2e__args: Expected no excess property\n at [\"extra\"]" + ] } }, "status": [ diff --git a/packages/harness/test/fixtures/mcp/session.mjs b/packages/harness/test/fixtures/mcp/session.mjs index 669c8620..845eb900 100644 --- a/packages/harness/test/fixtures/mcp/session.mjs +++ b/packages/harness/test/fixtures/mcp/session.mjs @@ -16,7 +16,10 @@ server.setRequestHandler("tools/list", () => ({ { name: "args", description: "The arguments as received.", - inputSchema: object({ word: { type: "string", pattern: "^[a-z]+$" }, limit: { type: "integer" } }, ["word"]), + inputSchema: { + ...object({ word: { type: "string", pattern: "^[a-z]+$" }, limit: { type: "integer" } }, ["word"]), + additionalProperties: false, + }, }, { name: "fail", description: "Answer with an error result.", inputSchema: object() }, { name: "pid", description: "The server's process id.", inputSchema: object() }, diff --git a/packages/harness/test/mcp.session.test.ts b/packages/harness/test/mcp.session.test.ts index 8d226daf..4782bc2c 100644 --- a/packages/harness/test/mcp.session.test.ts +++ b/packages/harness/test/mcp.session.test.ts @@ -54,10 +54,15 @@ it("mcp: config layers, plugin bucket, direct tools, a crash mid-exchange and th process.env.MCP_E2E_GREETING = "hello"; // A `!command` printing a notice after the token, say: never sent, and never shown. process.env.MCP_E2E_BAD_HEADER = "Bearer sk-e2e-secret\nnotice"; + // Unlike a header, a variable may span lines. + process.env.MCP_E2E_PEM = "-----BEGIN KEY-----\nabc\n-----END KEY-----"; await write(join(home, "mcp.json"), { mcpServers: { - e2e: stdio({ toolExposure: { secret: "hidden" } }), + e2e: stdio({ + toolExposure: { secret: "hidden" }, + env: { E2E_GREETING: "${MCP_E2E_GREETING}", E2E_PEM: "${MCP_E2E_PEM}" }, + }), off: stdio(), "my-srv": stdio({ enabled: false }), my_srv: stdio(), @@ -107,7 +112,11 @@ it("mcp: config layers, plugin bucket, direct tools, a crash mid-exchange and th [pendingCall("mcp__e2e__pid", {}, "pid-after")], [], // Next exchange: arguments the server's schema rejects never reach it. - [pendingCall("mcp__e2e__echo", {}, "invalid"), pendingCall("mcp__e2e__args", { word: "ABC" }, "pattern")], + [ + pendingCall("mcp__e2e__echo", {}, "invalid"), + pendingCall("mcp__e2e__args", { word: "ABC" }, "pattern"), + pendingCall("mcp__e2e__args", { word: "abc", extra: true }, "excess"), + ], [], ], requests, @@ -186,10 +195,18 @@ it("mcp: config layers, plugin bucket, direct tools, a crash mid-exchange and th echoTool: firstTools.find((tool) => tool.name === "mcp__e2e__echo"), plugins: observed, calls: Object.fromEntries( - ["echo", "fail", "structured", "nulls", "pid-before", "crash", "pid-after", "invalid", "pattern"].map((id) => [ - id, - outcome(id), - ]), + [ + "echo", + "fail", + "structured", + "nulls", + "pid-before", + "crash", + "pid-after", + "invalid", + "pattern", + "excess", + ].map((id) => [id, outcome(id)]), ), status: result.status, configErrors: result.errors, diff --git a/packages/plugin/src/tool.ts b/packages/plugin/src/tool.ts index 6a136f9f..4457a717 100644 --- a/packages/plugin/src/tool.ts +++ b/packages/plugin/src/tool.ts @@ -258,6 +258,14 @@ export const fromJsonSchema = (schema: Record): Schema.Top => { return validator.annotate({ [providerJsonSchema]: schema }); }; +/** + * Whether `schema` came from {@link fromJsonSchema}. Its arguments are checked as JSON Schema checks + * them: a key a closed object does not declare is an error, not dropped, since the tool receives the + * arguments as written. + */ +export const isFromJsonSchema = (schema: Schema.Top) => + SchemaAST.resolveAt>(providerJsonSchema)(schema.ast) !== undefined; + /** * Derive a provider-safe JSON Schema object from an Effect Schema. This is the * single boundary that crosses into the provider wire; if a provider rejects a From 4cd6ee1f3fc8b47b9af712dc63d4e45a35491864 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Sat, 24 Oct 2026 22:23:03 +0530 Subject: [PATCH 14/17] fix(harness): keep MCP startup logs and forget failed commands on time - Write server log messages on the service's fibers, so a failed handshake closing the connection no longer drops the messages that explain it. - Forget a failed !command as soon as it fails, so the next connect runs it again even when every caller waiting on it gave up. Co-Authored-By: Claude Opus 5.5 --- packages/harness/src/mcp/mcp.ts | 4 +++- packages/harness/src/mcp/value.ts | 14 ++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/harness/src/mcp/mcp.ts b/packages/harness/src/mcp/mcp.ts index 1f9c3b6f..0fc27170 100644 --- a/packages/harness/src/mcp/mcp.ts +++ b/packages/harness/src/mcp/mcp.ts @@ -233,7 +233,9 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string endpoint: target, ...(server.roots === undefined ? {} : { roots: server.roots }), timeout: { request: Duration.seconds(config.timeout ?? DEFAULT_TIMEOUT_SECONDS) }, - onLog: (entry) => log.write(server.name, entry), + // Written on the service's fibers: a failed handshake closes the connection's, and + // its last messages usually say why. + onLog: (entry) => Effect.sync(() => fork(log.write(server.name, entry))), }).pipe(Scope.provide(scope)); const tools = yield* connection.tools(); return { connection, tools }; diff --git a/packages/harness/src/mcp/value.ts b/packages/harness/src/mcp/value.ts index 9b415c0a..5dbf85ea 100644 --- a/packages/harness/src/mcp/value.ts +++ b/packages/harness/src/mcp/value.ts @@ -101,15 +101,17 @@ const run = (command: string) => const command = Effect.fnUntraced(function* (line: string, what: string) { let running = commands.get(line); if (running === undefined) { - // Detached, so a caller giving up does not stop it for the others waiting on it. - running = yield* Effect.forkDetach(run(line)); + // Detached, so a caller giving up does not stop it for the others waiting on it. A failure is + // forgotten when it happens, whether or not anyone still waits. + const forget = (output: string | undefined) => + Effect.sync(() => { + if (output === undefined && commands.get(line) === running) commands.delete(line); + }); + running = yield* Effect.forkDetach(Effect.andThen(Effect.yieldNow, run(line).pipe(Effect.tap(forget)))); commands.set(line, running); } const output = yield* Fiber.join(running); - if (output === undefined) { - if (commands.get(line) === running) commands.delete(line); - return yield* Effect.fail(`Failed to resolve ${what} from shell command: ${line}`); - } + if (output === undefined) return yield* Effect.fail(`Failed to resolve ${what} from shell command: ${line}`); return output; }); From d1bfd477bcda95ca1d52dbb0710fef176e624ac4 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Fri, 30 Oct 2026 14:45:22 +0530 Subject: [PATCH 15/17] feat(harness): keep MCP state per thread and report server status Each thread now owns its MCP servers: reconcile, status and the new connect and disconnect take the thread, so concurrent threads of one session, or threads at different placements, no longer overwrite each other. A thread's record is dropped once it has no servers left. A failed server is no longer retried by every turn; it recovers through a config change or an explicit connect. Disconnect closes a server and keeps it closed until connect or a config change. A dropped connection still reconnects on the next call. Every tool a server offers is registered with its exposure, so deferred tools are found by toolsearch, codemode and hidden ones are registered but not offered. mcp.json accepts codemode and deferred exposure, with codemode-deferred read as codemode. A loaded deferred tool makes the turn wait for its server like a direct one. State changes are published as the ephemeral mcp.server.status event to the owning thread, and the needs-auth state is now authenticate. Co-Authored-By: Claude Opus 5.5 --- packages/harness/src/event/list.ts | 20 + packages/harness/src/mcp/config.ts | 15 +- packages/harness/src/mcp/error.ts | 9 + packages/harness/src/mcp/mcp.ts | 281 +++++++--- packages/harness/src/mcp/tool.ts | 2 + packages/harness/src/plugin/mcp/registry.ts | 2 +- packages/harness/src/state/state.ts | 2 + .../test/__artifacts__/mcp.session.json | 43 +- .../__artifacts__/mcp.session.lifecycle.json | 42 +- .../test/__artifacts__/mcp.thread.json | 522 ++++++++++++++++++ .../test/__artifacts__/mcp.thread.loaded.json | 54 ++ .../harness/test/fixtures/mcp/session.mjs | 10 + packages/harness/test/mcp.session.test.ts | 16 +- packages/harness/test/mcp.thread.test.ts | 399 +++++++++++++ packages/plugin/src/plugin/mcp.ts | 9 +- 15 files changed, 1320 insertions(+), 106 deletions(-) create mode 100644 packages/harness/test/__artifacts__/mcp.thread.json create mode 100644 packages/harness/test/__artifacts__/mcp.thread.loaded.json create mode 100644 packages/harness/test/mcp.thread.test.ts diff --git a/packages/harness/src/event/list.ts b/packages/harness/src/event/list.ts index a96a7f46..72681a10 100644 --- a/packages/harness/src/event/list.ts +++ b/packages/harness/src/event/list.ts @@ -339,6 +339,25 @@ export const PluginUpdated = EventSchema.define({ }); export type PluginUpdated = typeof PluginUpdated.Type; +/** + * One MCP server's state as a thread runs it. `tools` names every tool the server offered, as the + * server names them, whatever their exposure. + */ +export const McpServerState = Schema.Union([ + Schema.Struct({ server: Schema.String, state: Schema.Literal("connected"), tools: Schema.Array(Schema.String) }), + Schema.Struct({ server: Schema.String, state: Schema.Literals(["failed", "authenticate"]), error: Schema.String }), + Schema.Struct({ server: Schema.String, state: Schema.Literal("disconnected"), error: optional(Schema.String) }), + Schema.Struct({ server: Schema.String, state: Schema.Literals(["connecting", "disabled"]) }), +]); +export type McpServerState = typeof McpServerState.Type; + +/** An MCP server a thread uses changed state. */ +export const McpServerStatus = EventSchema.define({ + type: "mcp.server.status", + schema: { ...baseOptions, status: McpServerState }, +}); +export type McpServerStatus = typeof McpServerStatus.Type; + /** * Everything the kernel can publish. `PublicDefinitions` is the narrower set * that reaches clients -- adding an event here does not publish it. @@ -374,6 +393,7 @@ export const Definitions = EventSchema.inventory( ToolProgress, ToolSettled, PluginUpdated, + McpServerStatus, ); /** diff --git a/packages/harness/src/mcp/config.ts b/packages/harness/src/mcp/config.ts index fad45097..80db70c7 100644 --- a/packages/harness/src/mcp/config.ts +++ b/packages/harness/src/mcp/config.ts @@ -20,7 +20,10 @@ import { fileSystem, hostPath } from "../host.ts"; import * as Jsonc from "../util/jsonc.ts"; const SERVER_NAME = /^[A-Za-z0-9_-]+$/; -const EXPOSURES: ReadonlyArray = ["direct", "hidden"]; +const EXPOSURES: ReadonlyArray = ["codemode", "deferred", "direct", "hidden"]; +/** Older exposure names, read as their current one. */ +const EXPOSURE_ALIASES: Readonly> = { "codemode-deferred": "codemode" }; +const alias = (value: unknown) => (Predicate.isString(value) ? (EXPOSURE_ALIASES[value] ?? value) : value); const OVERRIDE_KEYS: ReadonlyArray = ["enabled", "exposure", "toolExposure"]; /** Names that differ only in `-` and `_` would give their tools the same names. */ @@ -36,9 +39,15 @@ const isOverride = (value: Record) => value.command === undefined && value.url === undefined && value.type === undefined; /** One `mcpServers` entry, checked. Returns the config, or what is wrong with it. */ -export const validate = (name: string, value: unknown): ServerConfig | string => { +export const validate = (name: string, raw: unknown): ServerConfig | string => { if (!SERVER_NAME.test(name)) return `invalid server name "${name}" (use letters, digits, "_" and "-")`; - if (!isRecord(value)) return `server "${name}" must be an object`; + if (!isRecord(raw)) return `server "${name}" must be an object`; + const value: Record = { ...raw }; + if (raw.exposure !== undefined) value.exposure = alias(raw.exposure); + if (isRecord(raw.toolExposure)) + value.toolExposure = Object.fromEntries( + Object.entries(raw.toolExposure).map(([tool, one]) => [tool, alias(one)]), + ); const { type, exposure, enabled, timeout, toolExposure, description } = value; const exposures = EXPOSURES.map((one) => `"${one}"`).join(", "); if (exposure !== undefined && !EXPOSURES.includes(exposure as string)) diff --git a/packages/harness/src/mcp/error.ts b/packages/harness/src/mcp/error.ts index a0b1aa6e..12f3a49e 100644 --- a/packages/harness/src/mcp/error.ts +++ b/packages/harness/src/mcp/error.ts @@ -21,6 +21,15 @@ export class SessionExpiredError extends Schema.TaggedError } } +/** The thread runs no MCP server of this name. */ +export class UnknownServerError extends Schema.TaggedError()("Mcp.UnknownServerError", { + server: Schema.String, +}) { + override get message(): string { + return `unknown MCP server: ${this.server}`; + } +} + /** A request on a connected server failed. */ export class RequestError extends Schema.TaggedError()("Mcp.RequestError", { server: Schema.String, diff --git a/packages/harness/src/mcp/mcp.ts b/packages/harness/src/mcp/mcp.ts index 0fc27170..ae8fe680 100644 --- a/packages/harness/src/mcp/mcp.ts +++ b/packages/harness/src/mcp/mcp.ts @@ -1,27 +1,32 @@ /** - * The MCP servers each session runs, kept connected across its exchanges. + * The MCP servers each thread runs, kept connected across its turns. * - * Every exchange hands over the session's servers as its `mcp` plugins left them ({@link + * Every turn hands over the thread's servers as its `mcp` plugins left them ({@link * Interface.reconcile}); servers whose connection settings changed restart, removed ones close, new - * ones connect in the background. The tools an exchange registers are the ones the servers offered - * at that moment, frozen with the rest of the exchange; the connection behind a tool is looked up - * when it is called: + * ones connect in the background. The tools a turn registers are the ones the servers offered at + * that moment, each with its exposure, frozen with the rest of the turn; the connection behind a + * tool is looked up when it is called: * * - A connection that drops fails the calls running on it, and the next call reconnects. + * - A server that failed to connect stays failed until its config changes or {@link + * Interface.connect} runs; turns never retry it. * - A Streamable HTTP session the server forgot is replaced, and calls that hit it run once more on * the new one. The old connection closes when its last call finishes, never under one. * - A tool call is never retried after the server may have run it. - * - A changed tool list takes effect at the next exchange; a call to a withdrawn tool says so. + * - A changed tool list takes effect at the next turn; a call to a withdrawn tool says so. + * + * Every state change is published to the owning thread as an ephemeral `mcp.server.status` event. * * Each connection lives in its own scope under the service's, so closing it, or shutting the * service down, stops the server process and ends the HTTP session. */ -import type { ServerConfig } from "@codeworksh/plugin/plugin/mcp"; +import type { Exposure, ServerConfig } from "@codeworksh/plugin/plugin/mcp"; import type { CallToolResult, Tool } from "@modelcontextprotocol/client"; import { Cause, Context, + DateTime, Deferred, Duration, Effect, @@ -34,32 +39,37 @@ import { Semaphore, } from "effect"; import type { ChildProcessSpawner } from "effect/process"; -import { NeedsAuthError } from "./error.ts"; +import { NeedsAuthError, UnknownServerError } from "./error.ts"; +import { Event } from "../event/event.ts"; +import { EventList } from "../event/list.ts"; import type { ID as SessionId } from "../session/schema.ts"; +import type { ID as ThreadId } from "../thread/schema.ts"; import type { RegisteredTool } from "../tool/tool.ts"; import { McpClient } from "./client.ts"; +import { namespace } from "./config.ts"; import { McpLog } from "./log.ts"; import { McpTool } from "./tool.ts"; import { McpValue } from "./value.ts"; import { hostPath } from "../host.ts"; import { expandTilde } from "../util/home.ts"; -/** An exchange waits this long for the servers it started whose tools are declared to the model. */ +/** A turn waits this long for the servers it started whose tools it declares or has loaded. */ const STARTUP_WAIT = Duration.seconds(10); const DEFAULT_TIMEOUT_SECONDS = 60; -export type State = "connecting" | "connected" | "disconnected" | "failed" | "needs-auth" | "disabled"; +export type State = EventList.McpServerState["state"]; export interface Status { readonly server: string; readonly state: State; readonly error?: string; - /** Tools the server offered when last listed. */ - readonly tools: number; + /** Tools the server offered when last listed, each with its exposure. */ + readonly tools: ReadonlyArray<{ readonly name: string; readonly exposure: Exposure }>; } export interface Input { readonly sessionId: SessionId; + readonly threadId: ThreadId; readonly servers: ReadonlyArray; /** * Where stdio servers start (a server's own relative `cwd` resolves against it), and the root @@ -69,14 +79,20 @@ export interface Input { */ readonly directory?: string | undefined; readonly hostDir?: string | undefined; - /** Configuration problems from `mcp.json`, reported once per session. */ + /** Configuration problems from `mcp.json`, reported once per thread. */ readonly errors?: ReadonlyArray; + /** Deferred tools the thread has loaded (its `modelTools.add`); their servers are waited for like direct ones. */ + readonly loaded?: ReadonlyArray | undefined; } export interface Interface { - /** Settle a session's servers for its next exchange, and return the tools that exchange declares. */ + /** Settle a thread's servers for its next turn, and return every tool they offer, with its exposure. */ readonly reconcile: (input: Input) => Effect.Effect>; - readonly status: (sessionId: SessionId) => Effect.Effect>; + readonly status: (threadId: ThreadId) => Effect.Effect>; + /** Close the server's connection or attempt and connect it afresh, returning its state after that attempt. */ + readonly connect: (threadId: ThreadId, server: string) => Effect.Effect; + /** Close the server and keep it disconnected until {@link connect} or a config change. */ + readonly disconnect: (threadId: ThreadId, server: string) => Effect.Effect; } export class Service extends Context.Service()("@codeworksh/harness/mcp/mcp/Service") {} @@ -92,6 +108,7 @@ interface Live { interface Server { readonly name: string; + readonly thread: Thread; config: ServerConfig; /** Everything a connection is made from; a change restarts the server. */ readonly key: string; @@ -106,10 +123,16 @@ interface Server { /** Settles when the first background attempt finishes. */ ready: Deferred.Deferred | undefined; closed: boolean; + /** Disconnected on request: nothing connects it until `connect` or a config change. */ + stopped: boolean; + /** The last status published, so a no-op change publishes nothing. */ + published: string | undefined; readonly lock: Semaphore.Semaphore; } -interface Session { +interface Thread { + readonly sessionId: SessionId; + readonly threadId: ThreadId; readonly servers: Map; readonly names: ReturnType; readonly lock: Semaphore.Semaphore; @@ -126,11 +149,39 @@ const hasDirectTools = (config: ServerConfig) => (config.exposure ?? "direct") === "direct" || Object.values(config.toolExposure ?? {}).some((exposure) => exposure === "direct"); +/** Whether a loaded tool name could belong to the server; its tools are not known before it connects. */ +const loadsFrom = (server: string, loaded: ReadonlyArray) => + loaded.some((name) => name.startsWith(`${namespace(server)}__`)); + +const statusOf = (server: Server): Status => ({ + server: server.name, + state: server.state, + ...(server.error === undefined ? {} : { error: server.error }), + tools: server.tools.map((tool) => ({ name: tool.name, exposure: McpTool.exposure(server.config, tool.name) })), +}); + +const eventStatus = (server: Server): EventList.McpServerState => { + const { name, state } = server; + switch (state) { + case "connected": + return { server: name, state, tools: server.tools.map((tool) => tool.name) }; + case "failed": + case "authenticate": + return { server: name, state, error: server.error ?? state }; + case "disconnected": + return { server: name, state, ...(server.error === undefined ? {} : { error: server.error }) }; + case "connecting": + case "disabled": + return { server: name, state }; + } +}; + export const layer = (options: { readonly hostCwd: string; readonly home: string }) => Layer.effect( Service, Effect.gen(function* () { const root = yield* Effect.scope; + const events = yield* Event.Service; // Connections close in parallel on shutdown, so one slow server's stop grace does not hold up // the rest. Made before the fibers below, so connect attempts are interrupted before it closes. const servers = yield* Scope.fork(root, "parallel"); @@ -138,7 +189,7 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string const forkAttempt = yield* FiberSet.makeRuntime(); const context = yield* Effect.context(); const log = yield* McpLog.make(hostPath.join(options.home, "mcp.log")); - const sessions = new Map(); + const threads = new Map(); // Shared remote endpoints should not see a burst of concurrent startups. const endpoints = new Map(); const endpointLock = (url: string) => { @@ -147,6 +198,34 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string return lock; }; + /** Publish the server's status to its thread, unless it is the one published last. */ + const publish = (server: Server) => + Effect.suspend(() => { + const status = eventStatus(server); + const key = [ + status.state, + "error" in status ? (status.error ?? "") : "", + ...("tools" in status ? status.tools : []), + ].join("\0"); + if (key === server.published) return Effect.void; + server.published = key; + const { sessionId, threadId } = server.thread; + return DateTime.now.pipe( + Effect.flatMap((timestamp) => + events.publish(EventList.McpServerStatus, { timestamp, sessionId, threadId, status }), + ), + Effect.asVoid, + ); + }); + + /** The one place a server's state changes. */ + const setState = (server: Server, state: State, error?: string) => + Effect.suspend(() => { + server.state = state; + server.error = error; + return publish(server); + }); + const release = (live: Live) => live.retired && live.inflight === 0 ? Scope.close(live.scope, Exit.void) : Effect.void; @@ -191,8 +270,7 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string live.connection.onClose((reason) => current( Effect.gen(function* () { - server.state = "disconnected"; - server.error = reason; + yield* setState(server, "disconnected", reason); yield* retire(server, live); }), ), @@ -207,13 +285,17 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string const latest = () => server.live === live && live.refreshes === refresh; fork( live.connection.tools().pipe( - Effect.match({ - onSuccess: (tools) => { - if (latest()) server.tools = tools; - }, - onFailure: (error) => { - if (latest()) server.error = `failed to refresh tools: ${error.message}`; - }, + Effect.matchEffect({ + onSuccess: (tools) => + Effect.suspend(() => { + if (!latest()) return Effect.void; + server.tools = tools; + return publish(server); + }), + onFailure: (error) => + Effect.sync(() => { + if (latest()) server.error = `failed to refresh tools: ${error.message}`; + }), }), ), ); @@ -249,8 +331,7 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string const error = Cause.squash(exit.cause); const message = typeof error === "string" ? error : error instanceof Error ? error.message : String(error); - server.state = Schema.is(NeedsAuthError)(error) ? "needs-auth" : "failed"; - server.error = message; + yield* setState(server, Schema.is(NeedsAuthError)(error) ? "authenticate" : "failed", message); yield* Effect.logWarning("mcp connect failed", { server: server.name, error: message }); return yield* Effect.fail(message); } @@ -263,8 +344,7 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string }; server.live = live; server.tools = exit.value.tools; - server.state = "connected"; - server.error = undefined; + yield* setState(server, "connected"); watch(server, live); yield* Effect.logInfo("mcp connected", { server: server.name, tools: server.tools.length }); return live; @@ -282,17 +362,17 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string Effect.gen(function* () { const next = yield* Effect.gen(function* () { if (server.closed) return yield* Effect.fail(`MCP server "${server.name}" is shut down`); + if (server.stopped) return yield* Effect.fail(`MCP server "${server.name}" is disconnected`); const current = server.live; // A drop is known here before its `onClose` handler has run. const reason = current?.connection.dropped(); if (current !== undefined && reason === undefined) return current; if (current !== undefined) { - server.state = "disconnected"; - server.error = reason; + yield* setState(server, "disconnected", reason); yield* retire(server, current); } if (server.attempt === undefined) { - server.state = "connecting"; + yield* setState(server, "connecting"); // Started after the assignment: an attempt that fails at once would otherwise clear // `attempt` before it is set, and the finished fiber would stand in for every retry. server.attempt = forkAttempt(Effect.andThen(Effect.yieldNow, attempt(server))); @@ -308,10 +388,9 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string : Effect.failCause(exit.cause); }); - /** Stop the server: interrupt a connect in progress, and retire the connection it has. */ - const close = (server: Server) => + /** Interrupt a connect in progress, and retire the connection the server has. */ + const stop = (server: Server) => Effect.gen(function* () { - server.closed = true; const pending = server.attempt; if (pending !== undefined) yield* Fiber.interrupt(pending); yield* Effect.suspend(() => @@ -319,13 +398,20 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string ).pipe(server.lock.withPermits(1)); }); + /** Stop the server for good: it was removed, or its connection settings changed. */ + const close = (server: Server) => + Effect.suspend(() => { + server.closed = true; + return stop(server); + }); + /** One call of one of the server's tools, on whichever connection is current when it runs. */ const call = - (session: Session, name: string, tool: string) => + (thread: Thread, name: string, tool: string) => (input: McpTool.Call): Effect.Effect => Effect.gen(function* () { for (let attempt = 1; ; attempt++) { - const server = session.servers.get(name); + const server = thread.servers.get(name); if (server === undefined || server.closed || server.config.enabled === false) return yield* Effect.fail(`MCP server "${name}" is disabled.`); if ( @@ -359,8 +445,7 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string } if (failure._tag === "Mcp.NeedsAuthError") { yield* Effect.gen(function* () { - server.state = "needs-auth"; - server.error = failure.message; + yield* setState(server, "authenticate", failure.message); yield* retire(server, live); }).pipe(server.lock.withPermits(1)); return yield* Effect.fail(`MCP server "${name}" needs sign-in: ${failure.message}`); @@ -370,35 +455,43 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string }); /** Connect in the background; `ready` settles when the attempt does. */ - const start = (server: Server) => { - if (server.config.enabled === false) { - server.state = "disabled"; - return; - } - const ready = Deferred.makeUnsafe(); - server.ready = ready; - fork(open(server).pipe(Effect.ignore, Effect.ensuring(Deferred.succeed(ready, undefined)))); - }; + const start = (server: Server) => + Effect.suspend(() => { + if (server.config.enabled === false) return setState(server, "disabled"); + const ready = Deferred.makeUnsafe(); + server.ready = ready; + fork(open(server).pipe(Effect.ignore, Effect.ensuring(Deferred.succeed(ready, undefined)))); + return Effect.void; + }); - const sessionOf = (sessionId: SessionId) => { - let session = sessions.get(sessionId); - if (session === undefined) { - session = { + const threadOf = (input: Input) => { + let thread = threads.get(input.threadId); + if (thread === undefined) { + thread = { + sessionId: input.sessionId, + threadId: input.threadId, servers: new Map(), names: McpTool.names(), lock: Semaphore.makeUnsafe(1), reported: new Set(), }; - sessions.set(sessionId, session); + threads.set(input.threadId, thread); } - return session; + return thread; + }; + + const serverOf = (threadId: ThreadId, name: string) => { + const server = threads.get(threadId)?.servers.get(name); + return server === undefined + ? Effect.fail(new UnknownServerError({ server: name })) + : Effect.succeed(server); }; const reconcile = Effect.fn("Mcp.reconcile")(function* (input: Input) { - const session = sessionOf(input.sessionId); + const thread = threadOf(input); for (const error of input.errors ?? []) { - if (session.reported.has(error)) continue; - session.reported.add(error); + if (thread.reported.has(error)) continue; + thread.reported.add(error); yield* Effect.logWarning(`mcp config: ${error}`); } const cwd = input.directory ?? input.hostDir ?? options.hostCwd; @@ -406,24 +499,22 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string const wanted = new Map(input.servers); const started: Server[] = []; yield* Effect.gen(function* () { - for (const [name, server] of session.servers) { + for (const [name, server] of thread.servers) { const config = wanted.get(name); - // Exposure and description apply to the next exchange without reconnecting. + // Exposure and description apply to the next turn without reconnecting. if (config !== undefined && connectionKey(config, cwd, roots) === server.key) { server.config = config; - // A server that could not connect declares no tools, so no call would retry it. - // Retry in the background; waiting for it would hold up every prompt while it is down. - if (server.state === "failed" && server.attempt === undefined) start(server); continue; } - session.servers.delete(name); + thread.servers.delete(name); // Stopping can take seconds; the prompt does not wait for it. fork(close(server)); } for (const [name, config] of wanted) { - if (session.servers.has(name)) continue; + if (thread.servers.has(name)) continue; const server: Server = { name, + thread, config, key: connectionKey(config, cwd, roots), cwd, @@ -435,19 +526,26 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string attempt: undefined, ready: undefined, closed: false, + stopped: false, + published: undefined, lock: Semaphore.makeUnsafe(1), }; - session.servers.set(name, server); - start(server); + thread.servers.set(name, server); + yield* start(server); started.push(server); } - }).pipe(session.lock.withPermits(1)); + // A thread with no servers and nothing reported has nothing worth keeping. + if (thread.servers.size === 0 && thread.reported.size === 0 && threads.get(thread.threadId) === thread) + threads.delete(thread.threadId); + }).pipe(thread.lock.withPermits(1)); - // Wait for servers this exchange started (all of them at first, then new or restarted ones) so - // their tools are declared in its first request, but never indefinitely: one hanging server + // Wait for servers this turn started (all of them at first, then new or restarted ones) so + // their tools are offered in its first request, but never indefinitely: one hanging server // must not hold up the prompt. + const loaded = input.loaded ?? []; const pending = started.filter( - (server) => server.config.enabled !== false && hasDirectTools(server.config), + (server) => + server.config.enabled !== false && (hasDirectTools(server.config) || loadsFrom(server.name, loaded)), ); if (pending.length > 0) { const finished = yield* Effect.forEach(pending, (server) => @@ -457,21 +555,22 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string yield* Effect.logInfo("mcp servers are still connecting; their tools appear once connected"); } + // Every tool is registered with its exposure; the registry decides which ones are offered. const tools: RegisteredTool[] = []; - for (const server of session.servers.values()) { - if (server.config.enabled === false || server.tools.length === 0) continue; - const names = session.names( + for (const server of thread.servers.values()) { + if (server.config.enabled === false || server.stopped || server.tools.length === 0) continue; + const names = thread.names( server.name, server.tools.map((tool) => tool.name), ); for (const tool of server.tools) { - if (McpTool.exposure(server.config, tool.name) !== "direct") continue; tools.push( McpTool.make({ server: server.name, tool, name: names.get(tool.name)!, - call: call(session, server.name, tool.name), + exposure: McpTool.exposure(server.config, tool.name), + call: call(thread, server.name, tool.name), }), ); } @@ -479,17 +578,29 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string return tools; }); + const connect = Effect.fn("Mcp.connect")(function* (threadId: ThreadId, name: string) { + const server = yield* serverOf(threadId, name); + if (server.config.enabled === false) return statusOf(server); + server.stopped = false; + yield* stop(server); + yield* open(server).pipe(Effect.ignore); + return statusOf(server); + }); + + const disconnect = Effect.fn("Mcp.disconnect")(function* (threadId: ThreadId, name: string) { + const server = yield* serverOf(threadId, name); + if (server.config.enabled === false) return statusOf(server); + server.stopped = true; + yield* stop(server); + yield* setState(server, "disconnected"); + return statusOf(server); + }); + return Service.of({ reconcile, - status: (sessionId) => - Effect.sync(() => - [...(sessions.get(sessionId)?.servers.values() ?? [])].map((server) => ({ - server: server.name, - state: server.state, - ...(server.error === undefined ? {} : { error: server.error }), - tools: server.tools.length, - })), - ), + status: (threadId) => Effect.sync(() => [...(threads.get(threadId)?.servers.values() ?? [])].map(statusOf)), + connect, + disconnect, }); }), ); diff --git a/packages/harness/src/mcp/tool.ts b/packages/harness/src/mcp/tool.ts index 41e3561b..e3e8b119 100644 --- a/packages/harness/src/mcp/tool.ts +++ b/packages/harness/src/mcp/tool.ts @@ -121,6 +121,7 @@ export const make = (input: { readonly server: string; readonly tool: McpTool; readonly name: string; + readonly exposure: Exposure; readonly call: (call: Call) => Effect.Effect; }) => { const { server, tool } = input; @@ -135,6 +136,7 @@ export const make = (input: { Tool.make({ name: input.name, label: `${server}/${tool.name}`, + exposure: input.exposure, description: tool.description?.trim() || title || `MCP tool ${tool.name} from server ${server}`, parameters: Tool.fromJsonSchema(schema), success: Outcome, diff --git a/packages/harness/src/plugin/mcp/registry.ts b/packages/harness/src/plugin/mcp/registry.ts index d2329fd1..8468d0f3 100644 --- a/packages/harness/src/plugin/mcp/registry.ts +++ b/packages/harness/src/plugin/mcp/registry.ts @@ -18,7 +18,7 @@ const frozen = (value: A): A => { }; /** - * The session's MCP servers for one exchange, seeded from `mcp.json`. A plugin written in plain + * The session's MCP servers for one turn, seeded from `mcp.json`. A plugin written in plain * JavaScript can hand in anything, so every write is checked here with the rules `mcp.json` follows, * and a bad one fails that plugin's `setup` rather than a connection later. */ diff --git a/packages/harness/src/state/state.ts b/packages/harness/src/state/state.ts index c60813d8..8542e157 100644 --- a/packages/harness/src/state/state.ts +++ b/packages/harness/src/state/state.ts @@ -501,11 +501,13 @@ export const layer = ( (servers) => mcp.reconcile({ sessionId, + threadId, servers, // MCP servers run on the host, so only a host sandbox's directory exists for them. directory: sandbox.kind === "local" ? location.directory : undefined, hostDir, errors: loadedSettings.mcp.errors, + loaded: agent.modelTools?.add, }), agent.modelTools, ).pipe(Effect.mapError((cause) => new SnapshotError({ threadId, reason: cause.message, cause }))); diff --git a/packages/harness/test/__artifacts__/mcp.session.json b/packages/harness/test/__artifacts__/mcp.session.json index 04280f0e..dd5fd699 100644 --- a/packages/harness/test/__artifacts__/mcp.session.json +++ b/packages/harness/test/__artifacts__/mcp.session.json @@ -221,23 +221,56 @@ { "server": "e2e", "state": "connected", - "tools": 8 + "tools": [ + { + "name": "echo", + "exposure": "direct" + }, + { + "name": "args", + "exposure": "direct" + }, + { + "name": "fail", + "exposure": "direct" + }, + { + "name": "pid", + "exposure": "direct" + }, + { + "name": "crash", + "exposure": "direct" + }, + { + "name": "my-tool.v2", + "exposure": "direct" + }, + { + "name": "secret", + "exposure": "hidden" + }, + { + "name": "bye", + "exposure": "direct" + } + ] }, { "server": "off", "state": "disabled", - "tools": 0 + "tools": [] }, { "server": "my-srv", "state": "disabled", - "tools": 0 + "tools": [] }, { "server": "badheader", - "state": "connecting", + "state": "failed", "error": "MCP server \"badheader\" header \"Authorization\" contains a line break or NUL character", - "tools": 0 + "tools": [] } ], "configErrors": [ diff --git a/packages/harness/test/__artifacts__/mcp.session.lifecycle.json b/packages/harness/test/__artifacts__/mcp.session.lifecycle.json index 0c74d0ca..1fcd7319 100644 --- a/packages/harness/test/__artifacts__/mcp.session.lifecycle.json +++ b/packages/harness/test/__artifacts__/mcp.session.lifecycle.json @@ -130,12 +130,50 @@ { "server": "remote", "state": "connected", - "tools": 1 + "tools": [ + { + "name": "echo", + "exposure": "direct" + } + ] }, { "server": "e2e", "state": "connected", - "tools": 8 + "tools": [ + { + "name": "echo", + "exposure": "direct" + }, + { + "name": "args", + "exposure": "direct" + }, + { + "name": "fail", + "exposure": "direct" + }, + { + "name": "pid", + "exposure": "direct" + }, + { + "name": "crash", + "exposure": "direct" + }, + { + "name": "my-tool.v2", + "exposure": "direct" + }, + { + "name": "secret", + "exposure": "direct" + }, + { + "name": "bye", + "exposure": "hidden" + } + ] } ] } diff --git a/packages/harness/test/__artifacts__/mcp.thread.json b/packages/harness/test/__artifacts__/mcp.thread.json new file mode 100644 index 00000000..f7ad4097 --- /dev/null +++ b/packages/harness/test/__artifacts__/mcp.thread.json @@ -0,0 +1,522 @@ +{ + "firstDeclared": [ + "mcp__e2e__echo", + "mcp__e2e__pid", + "mcp__e2e__crash", + "mcp__e2e__my_tool_v2", + "mcp__e2e__bye", + "toolsearch" + ], + "registeredMain": [ + { + "name": "mcp__e2e__echo", + "exposure": "direct" + }, + { + "name": "mcp__e2e__args", + "exposure": "deferred" + }, + { + "name": "mcp__e2e__fail", + "exposure": "codemode" + }, + { + "name": "mcp__e2e__pid", + "exposure": "direct" + }, + { + "name": "mcp__e2e__crash", + "exposure": "direct" + }, + { + "name": "mcp__e2e__my_tool_v2", + "exposure": "direct" + }, + { + "name": "mcp__e2e__secret", + "exposure": "hidden" + }, + { + "name": "mcp__e2e__bye", + "exposure": "direct" + } + ], + "forkRegistered": [ + { + "name": "mcp__e2e__echo", + "exposure": "direct" + }, + { + "name": "mcp__e2e__args", + "exposure": "deferred" + }, + { + "name": "mcp__e2e__fail", + "exposure": "codemode" + }, + { + "name": "mcp__e2e__pid", + "exposure": "direct" + }, + { + "name": "mcp__e2e__crash", + "exposure": "direct" + }, + { + "name": "mcp__e2e__my_tool_v2", + "exposure": "direct" + }, + { + "name": "mcp__e2e__secret", + "exposure": "hidden" + }, + { + "name": "mcp__e2e__bye", + "exposure": "direct" + }, + { + "name": "mcp__extra__echo", + "exposure": "deferred" + }, + { + "name": "mcp__extra__args", + "exposure": "deferred" + }, + { + "name": "mcp__extra__fail", + "exposure": "deferred" + }, + { + "name": "mcp__extra__pid", + "exposure": "deferred" + }, + { + "name": "mcp__extra__crash", + "exposure": "deferred" + }, + { + "name": "mcp__extra__my_tool_v2", + "exposure": "deferred" + }, + { + "name": "mcp__extra__secret", + "exposure": "deferred" + }, + { + "name": "mcp__extra__bye", + "exposure": "deferred" + } + ], + "mainStatus": { + "e2e": { + "state": "connected", + "tools": [ + { + "name": "echo", + "exposure": "direct" + }, + { + "name": "args", + "exposure": "deferred" + }, + { + "name": "fail", + "exposure": "codemode" + }, + { + "name": "pid", + "exposure": "direct" + }, + { + "name": "crash", + "exposure": "direct" + }, + { + "name": "my-tool.v2", + "exposure": "direct" + }, + { + "name": "secret", + "exposure": "hidden" + }, + { + "name": "bye", + "exposure": "direct" + } + ] + }, + "gated": { + "state": "failed", + "error": "Connection closed: mcp server process exited with code 1: gate closed", + "tools": [] + }, + "off": { + "state": "disabled", + "tools": [] + } + }, + "forkStatus": { + "e2e": { + "state": "connected", + "tools": [ + { + "name": "echo", + "exposure": "direct" + }, + { + "name": "args", + "exposure": "deferred" + }, + { + "name": "fail", + "exposure": "codemode" + }, + { + "name": "pid", + "exposure": "direct" + }, + { + "name": "crash", + "exposure": "direct" + }, + { + "name": "my-tool.v2", + "exposure": "direct" + }, + { + "name": "secret", + "exposure": "hidden" + }, + { + "name": "bye", + "exposure": "direct" + } + ] + }, + "gated": { + "state": "failed", + "error": "Connection closed: mcp server process exited with code 1: gate closed", + "tools": [] + }, + "off": { + "state": "disabled", + "tools": [] + }, + "extra": { + "state": "connected", + "tools": [ + { + "name": "echo", + "exposure": "deferred" + }, + { + "name": "args", + "exposure": "deferred" + }, + { + "name": "fail", + "exposure": "deferred" + }, + { + "name": "pid", + "exposure": "deferred" + }, + { + "name": "crash", + "exposure": "deferred" + }, + { + "name": "my-tool.v2", + "exposure": "deferred" + }, + { + "name": "secret", + "exposure": "deferred" + }, + { + "name": "bye", + "exposure": "deferred" + } + ] + } + }, + "failedAfterTurns": "failed", + "startsWhileFailed": 2, + "connected": { + "server": "gated", + "state": "connected", + "tools": [ + { + "name": "echo", + "exposure": "hidden" + }, + { + "name": "args", + "exposure": "hidden" + }, + { + "name": "fail", + "exposure": "hidden" + }, + { + "name": "pid", + "exposure": "direct" + }, + { + "name": "crash", + "exposure": "hidden" + }, + { + "name": "my-tool.v2", + "exposure": "hidden" + }, + { + "name": "secret", + "exposure": "hidden" + }, + { + "name": "bye", + "exposure": "hidden" + } + ] + }, + "declaredAfterConnect": [ + "mcp__e2e__echo", + "mcp__e2e__pid", + "mcp__e2e__crash", + "mcp__e2e__my_tool_v2", + "mcp__e2e__bye", + "mcp__gated__pid", + "toolsearch" + ], + "disconnected": { + "server": "gated", + "state": "disconnected", + "tools": [ + { + "name": "echo", + "exposure": "hidden" + }, + { + "name": "args", + "exposure": "hidden" + }, + { + "name": "fail", + "exposure": "hidden" + }, + { + "name": "pid", + "exposure": "direct" + }, + { + "name": "crash", + "exposure": "hidden" + }, + { + "name": "my-tool.v2", + "exposure": "hidden" + }, + { + "name": "secret", + "exposure": "hidden" + }, + { + "name": "bye", + "exposure": "hidden" + } + ] + }, + "declaredAfterDisconnect": [ + "mcp__e2e__echo", + "mcp__e2e__pid", + "mcp__e2e__crash", + "mcp__e2e__my_tool_v2", + "mcp__e2e__bye", + "toolsearch" + ], + "stillDisconnected": "disconnected", + "startsAfterDisconnect": 3, + "calls": { + "main": { + "main-pid": "", + "codemode": "Tool \"mcp__e2e__fail\" is not available in this thread.", + "hidden": "Tool \"mcp__e2e__secret\" is not available in this thread.", + "deferred-unloaded": "Tool \"mcp__e2e__args\" is not available in this thread.", + "search": "Loaded 1 tool. They are available from your next call:\n- mcp__e2e__args: The arguments as received.", + "deferred-loaded": "{\"word\":\"abc\"}", + "main-pid-2": "", + "gated-pid": "", + "bye": "bye", + "echo-after-drop": "hello: back" + }, + "fork": { + "main-pid": "", + "codemode": "Tool \"mcp__e2e__fail\" is not available in this thread.", + "hidden": "Tool \"mcp__e2e__secret\" is not available in this thread.", + "deferred-unloaded": "Tool \"mcp__e2e__args\" is not available in this thread.", + "search": "Loaded 1 tool. They are available from your next call:\n- mcp__e2e__args: The arguments as received.", + "deferred-loaded": "{\"word\":\"abc\"}", + "fork-pid": "" + } + }, + "changes": [ + { + "added": [ + "mcp__e2e__args" + ], + "removed": [] + } + ], + "loaded": { + "add": [ + "mcp__e2e__args" + ] + }, + "unknown": "Cause([Fail(Mcp.UnknownServerError: unknown MCP server: nope)])", + "events": { + "main": { + "e2e": [ + { + "server": "e2e", + "state": "connecting" + }, + { + "server": "e2e", + "state": "connected", + "tools": [ + "echo", + "args", + "fail", + "pid", + "crash", + "my-tool.v2", + "secret", + "bye" + ] + }, + { + "server": "e2e", + "state": "disconnected", + "error": "connection closed: mcp server process exited with code 0" + }, + { + "server": "e2e", + "state": "connecting" + }, + { + "server": "e2e", + "state": "connected", + "tools": [ + "echo", + "args", + "fail", + "pid", + "crash", + "my-tool.v2", + "secret", + "bye" + ] + } + ], + "gated": [ + { + "server": "gated", + "state": "connecting" + }, + { + "server": "gated", + "state": "failed", + "error": "Connection closed: mcp server process exited with code 1: gate closed" + }, + { + "server": "gated", + "state": "connecting" + }, + { + "server": "gated", + "state": "connected", + "tools": [ + "echo", + "args", + "fail", + "pid", + "crash", + "my-tool.v2", + "secret", + "bye" + ] + }, + { + "server": "gated", + "state": "disconnected" + } + ], + "off": [ + { + "server": "off", + "state": "disabled" + } + ] + }, + "fork": { + "e2e": [ + { + "server": "e2e", + "state": "connecting" + }, + { + "server": "e2e", + "state": "connected", + "tools": [ + "echo", + "args", + "fail", + "pid", + "crash", + "my-tool.v2", + "secret", + "bye" + ] + } + ], + "gated": [ + { + "server": "gated", + "state": "connecting" + }, + { + "server": "gated", + "state": "failed", + "error": "Connection closed: mcp server process exited with code 1: gate closed" + } + ], + "off": [ + { + "server": "off", + "state": "disabled" + } + ], + "extra": [ + { + "server": "extra", + "state": "connecting" + }, + { + "server": "extra", + "state": "connected", + "tools": [ + "echo", + "args", + "fail", + "pid", + "crash", + "my-tool.v2", + "secret", + "bye" + ] + } + ] + } + } +} diff --git a/packages/harness/test/__artifacts__/mcp.thread.loaded.json b/packages/harness/test/__artifacts__/mcp.thread.loaded.json new file mode 100644 index 00000000..bc01304b --- /dev/null +++ b/packages/harness/test/__artifacts__/mcp.thread.loaded.json @@ -0,0 +1,54 @@ +{ + "notAwaited": "connecting", + "awaitedChanges": [ + { + "added": [ + "mcp__slow__echo" + ], + "removed": [] + } + ], + "whileAway": [ + { + "added": [ + "mcp__slow__echo" + ], + "removed": [] + }, + { + "added": [], + "removed": [ + "mcp__slow__echo" + ] + } + ], + "loadedWhileAway": { + "add": [ + "mcp__slow__echo" + ] + }, + "back": [ + { + "added": [ + "mcp__slow__echo" + ], + "removed": [] + }, + { + "added": [], + "removed": [ + "mcp__slow__echo" + ] + }, + { + "added": [ + "mcp__slow__echo" + ], + "removed": [] + } + ], + "calls": { + "search": "Loaded 1 tool. They are available from your next call:\n- mcp__slow__echo: Echo text back, prefixed with the configured greeting.", + "awaited": "hello: awaited" + } +} diff --git a/packages/harness/test/fixtures/mcp/session.mjs b/packages/harness/test/fixtures/mcp/session.mjs index 845eb900..0f0724b6 100644 --- a/packages/harness/test/fixtures/mcp/session.mjs +++ b/packages/harness/test/fixtures/mcp/session.mjs @@ -1,6 +1,16 @@ // Stdio MCP server for the session test; run as `node session.mjs`. import { Server } from "@modelcontextprotocol/server"; import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; +import { appendFileSync, existsSync } from "node:fs"; + +// E2E_STARTS: a file that gets one line per start. E2E_GATE: while this file exists, exit at once. +// E2E_DELAY: milliseconds to wait before answering the handshake. +if (process.env.E2E_STARTS) appendFileSync(process.env.E2E_STARTS, "start\n"); +if (process.env.E2E_GATE && existsSync(process.env.E2E_GATE)) { + process.stderr.write("gate closed\n"); + process.exit(1); +} +if (process.env.E2E_DELAY) await new Promise((resolve) => setTimeout(resolve, Number(process.env.E2E_DELAY))); const object = (properties = {}, required = []) => ({ type: "object", properties, required }); const text = (value) => ({ content: [{ type: "text", text: String(value) }] }); diff --git a/packages/harness/test/mcp.session.test.ts b/packages/harness/test/mcp.session.test.ts index 1fcaa3f4..fb1d4d6d 100644 --- a/packages/harness/test/mcp.session.test.ts +++ b/packages/harness/test/mcp.session.test.ts @@ -26,7 +26,7 @@ const stdio = (extra: Record = {}) => ({ ...extra, }); -/** One reply per model request, in order; an empty one ends the exchange. */ +/** One reply per model request, in order; an empty one ends the turn. */ const scripted = (replies: ReadonlyArray>, requests: LLM.Input[]): LLM.Open => (input) => @@ -51,7 +51,7 @@ const write = async (file: string, value: unknown) => { await fs.writeFile(file, JSON.stringify(value, null, 2)); }; -it("mcp: config layers, plugin bucket, direct tools, a crash mid-exchange and the next call reconnecting", async () => { +it("mcp: config layers, plugin bucket, direct tools, a crash mid-turn and the next call reconnecting", async () => { await using dir = await tmpdir(); const home = join(dir.path, "home"); const project = join(dir.path, "project"); @@ -114,10 +114,10 @@ it("mcp: config layers, plugin bucket, direct tools, a crash mid-exchange and th pendingCall("mcp__e2e__args", { word: "abc", limit: null }, "nulls"), ], [pendingCall("mcp__e2e__pid", {}, "pid-before"), pendingCall("mcp__e2e__crash", {}, "crash")], - // Same exchange: the server is gone, so this call reconnects first. + // Same turn: the server is gone, so this call reconnects first. [pendingCall("mcp__e2e__pid", {}, "pid-after")], [], - // Next exchange: arguments the server's schema rejects never reach it. + // Next turn: arguments the server's schema rejects never reach it. [ pendingCall("mcp__e2e__echo", {}, "invalid"), pendingCall("mcp__e2e__args", { word: "ABC" }, "pattern"), @@ -137,7 +137,7 @@ it("mcp: config layers, plugin bucket, direct tools, a crash mid-exchange and th yield* session.main.run("Use the fixture."); yield* session.main.run("Once more."); const messages = yield* Effect.forEach(yield* messageEntries(session.main), ContextCodec.decodeMessage); - const status = yield* Mcp.Service.use((mcp) => mcp.status(session.id)); + const status = yield* Mcp.Service.use((mcp) => mcp.status(session.main.id)); const settings = yield* Settings.Service.use((loader) => loader.load(project)); return { messages, status, errors: settings.mcp.errors }; }).pipe( @@ -177,7 +177,7 @@ it("mcp: config layers, plugin bucket, direct tools, a crash mid-exchange and th const firstTools = requests[0]?.context.tools ?? []; expect(firstTools.map((tool) => tool.name)).not.toContain("mcp__e2e__secret"); - // The tool set is frozen per exchange and stable across them, so every request declares the same tools. + // The tool set is frozen per turn and stable across them, so every request declares the same tools. for (const request of requests) expect(request.context.tools).toEqual(firstTools); // The server's JSON Schema reaches the provider as written. expect(firstTools.find((tool) => tool.name === "mcp__e2e__echo")?.parameters).toEqual({ @@ -259,7 +259,7 @@ it("mcp: a server exiting while idle, reconfiguration, and an expired HTTP sessi const url = yield* listen(remote.handler); yield* Effect.promise(() => configure(url, {})); const session = yield* Session.create({ directory: work, model: { provider: "openai", id: "gpt-5.6-luna" } }); - const status = Mcp.Service.use((mcp) => mcp.status(session.id)); + const status = Mcp.Service.use((mcp) => mcp.status(session.main.id)); const state = (server: string) => status.pipe(Effect.map((all) => all.find((one) => one.server === server)?.state)); @@ -312,7 +312,7 @@ it("mcp: a server exiting while idle, reconfiguration, and an expired HTTP sessi expect(pid("pid-3")).toBe(pid("pid-2")); expect(pid("pid-4")).not.toBe(pid("pid-3")); - // The first request of each exchange: exposure changes apply at the next exchange. + // The first request of each turn: exposure changes apply at the next turn. const declared = runs.map((_, index) => requests[index * 2]?.context.tools?.map((tool) => tool.name) ?? []); expect(declared[2]).toContain("mcp__e2e__bye"); expect(declared[3]).not.toContain("mcp__e2e__bye"); diff --git a/packages/harness/test/mcp.thread.test.ts b/packages/harness/test/mcp.thread.test.ts new file mode 100644 index 00000000..30793409 --- /dev/null +++ b/packages/harness/test/mcp.thread.test.ts @@ -0,0 +1,399 @@ +import "./utils/env.ts"; +import { createAssistantMessageEventStream, type Message } from "@codeworksh/aikit"; +import { Effect, Exit } from "effect"; +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { expect, it } from "vite-plus/test"; +import { ContextCodec } from "../src/context/codec.ts"; +import { Document } from "../src/document/document.ts"; +import { Harness } from "../src/effect/harness.ts"; +import { Session } from "../src/effect/session.ts"; +import { Event } from "../src/event/event.ts"; +import { EventList } from "../src/event/list.ts"; +import { Mcp } from "../src/mcp/mcp.ts"; +import { defaultPromptPlugin } from "../src/plugin/builtin/prompt/default.ts"; +import { TOOL_SEARCH_NAME, toolSearchPlugin } from "../src/plugin/builtin/tool/toolsearch.ts"; +import { define } from "../src/plugin/plugin.ts"; +import type { LLM } from "../src/runner/llm.ts"; +import { assistant } from "./fixtures/llm.ts"; +import { tmpdir } from "./fixtures/tempdir.ts"; +import { pendingCall } from "./tools.fixture.ts"; + +const fixture = fileURLToPath(new URL("./fixtures/mcp/session.mjs", import.meta.url)); +const stdio = (extra: Record = {}, env: Record = {}) => ({ + command: process.execPath, + args: [fixture], + env: { E2E_GREETING: "hello", ...env }, + ...extra, +}); + +const git = (cwd: string, ...args: string[]) => + promisify(execFile)("git", ["-c", "commit.gpgsign=false", ...args], { cwd }).then(() => undefined); + +const write = async (file: string, value: unknown) => { + await fs.mkdir(join(file, ".."), { recursive: true }); + await fs.writeFile(file, JSON.stringify(value, null, 2)); +}; + +/** `toolsearch` ships off; the entry after it turns it on. */ +const toolSearch = [toolSearchPlugin, { plugin: toolSearchPlugin.id, enabled: true }] as const; + +/** A scripted model: per thread, one reply per request, in order; an empty one ends the turn. */ +const scripted = () => { + const script = new Map>>(); + const requests: Array = []; + let responses = 0; + const open: LLM.Open = (input) => + Effect.sync(() => { + requests.push(input); + const calls = script.get(input.threadId)?.shift() ?? []; + responses += 1; + const message = assistant( + input, + responses, + calls.length > 0 ? { stopReason: "toolUse", parts: [...calls] } : {}, + ); + const stream = createAssistantMessageEventStream(); + stream.push({ type: "start", partial: message }); + stream.push({ type: "done", reason: calls.length > 0 ? "toolUse" : "stop", message }); + return stream; + }); + /** Queue a turn on a thread: tool calls for each request, then a closing reply. */ + const turn = (threadId: string, ...requests: ReadonlyArray>) => { + const queue = script.get(threadId) ?? []; + queue.push(...requests, []); + script.set(threadId, queue); + }; + return { open, turn, requests }; +}; + +/** Every `mcp.server.status` event, by thread label and server, in order. */ +const statusEvents = (label: (threadId: string) => string) => + Effect.gen(function* () { + const seen: Record>> = {}; + const events = yield* Event.Service; + yield* events.listen((event) => + Effect.sync(() => { + if (event.type !== EventList.McpServerStatus.type) return; + const { threadId, status } = (event as unknown as EventList.McpServerStatus).data; + const byServer = (seen[label(threadId)] ??= {}); + (byServer[status.server] ??= []).push(status); + }), + ); + return seen; + }); + +/** The text of each settled tool call of a thread, by call id. */ +const results = (thread: Session.ThreadHandle) => + Effect.gen(function* () { + const line = yield* thread.history(); + const out: Record = {}; + for (const hydrated of line) { + if (hydrated.entry.type !== "assistant") continue; + const message = yield* ContextCodec.decodeMessage(hydrated); + if (message.role !== "assistant") continue; + for (const part of message.parts) + if (part.type === "toolCall" && "result" in part) + out[part.callID] = part.result.content + .map((block) => (block.type === "text" ? block.text : "")) + .join(""); + } + return out; + }); + +/** Tool changes the transcript declared, as names. */ +const changes = (messages: ReadonlyArray) => + messages.flatMap((message) => + message.role === "system" + ? [ + { + added: (message.toolsAdded ?? []).map((tool) => tool.name), + removed: (message.toolsRemoved ?? []).map((tool) => tool.name), + }, + ] + : [], + ); + +const waitFor = (read: Effect.Effect, done: (value: A) => boolean) => + Effect.gen(function* () { + for (let tries = 0; tries < 250; tries++) { + const value = yield* read; + if (done(value)) return value; + yield* Effect.sleep("20 millis"); + } + return yield* read; + }); + +it("mcp: state per thread, no retry of a failed server, connect and disconnect, every exposure registered", async () => { + await using dir = await tmpdir(); + const home = join(dir.path, "home"); + const work = join(dir.path, "work"); + const elsewhere = join(work, "sub"); + await fs.mkdir(work, { recursive: true }); + await fs.mkdir(elsewhere, { recursive: true }); + // One project, so a thread can move to another directory of it. + await git(work, "init", "-q", "-b", "main"); + await git( + work, + "-c", + "user.email=test@codework.sh", + "-c", + "user.name=Test", + "commit", + "-q", + "--allow-empty", + "-m", + "init", + ); + const gate = join(dir.path, "gate"); + const starts = join(dir.path, "starts"); + await fs.writeFile(gate, "closed"); + + await write(join(home, "mcp.json"), { + mcpServers: { + e2e: stdio({ toolExposure: { args: "deferred", fail: "codemode", secret: "hidden" } }), + gated: stdio( + { exposure: "direct", toolExposure: { "*": "hidden", pid: "direct" } }, + { + E2E_GATE: gate, + E2E_STARTS: starts, + }, + ), + off: stdio({ enabled: false }), + }, + }); + + // Only the forked thread runs `extra`: MCP config may differ per thread. + let forkId: string | undefined; + const perThread = define({ + id: "test.mcp.per-thread", + kind: "mcp", + setup: (ctx) => { + if (ctx.threadId === forkId) ctx.plugin.mcp.set("extra", stdio({ exposure: "deferred" })); + }, + }); + const registered: Record> = {}; + const observer = define({ + id: "test.mcp.registered", + kind: "tool", + setup: (ctx) => { + registered[ctx.threadId] = ctx.plugin.tools + .list() + .filter((def) => def.name.startsWith("mcp__")) + .map((def) => ({ name: def.name, exposure: def.exposure ?? "direct" })); + }, + }); + + const model = scripted(); + const startCount = () => fs.readFile(starts, "utf8").then((text) => text.split("\n").filter(Boolean).length); + + const result = await Effect.gen(function* () { + const session = yield* Session.create({ directory: work, model: { provider: "openai", id: "gpt-5.6-luna" } }); + const main = session.main; + const label = (threadId: string) => (threadId === main.id ? "main" : threadId === forkId ? "fork" : threadId); + const seen = yield* statusEvents(label); + const mcp = yield* Mcp.Service; + const status = (threadId: string) => + mcp.status(threadId as typeof main.id).pipe( + Effect.map((all) => + Object.fromEntries( + all.map((one) => [ + one.server, + { + state: one.state, + ...(one.error === undefined ? {} : { error: one.error }), + tools: one.tools, + }, + ]), + ), + ), + ); + const stateOf = (threadId: string, server: string) => + status(threadId).pipe(Effect.map((all) => all[server]?.state)); + const declared = () => (model.requests.at(-1)?.context.tools ?? []).map((tool) => tool.name); + + // Turn one: codemode, hidden and unloaded deferred tools are registered but not offered. + model.turn( + main.id, + [ + pendingCall("mcp__e2e__pid", {}, "main-pid"), + pendingCall("mcp__e2e__fail", {}, "codemode"), + pendingCall("mcp__e2e__secret", {}, "hidden"), + pendingCall("mcp__e2e__args", { word: "abc" }, "deferred-unloaded"), + ], + [pendingCall(TOOL_SEARCH_NAME, { query: "arguments as received" }, "search")], + [pendingCall("mcp__e2e__args", { word: "abc" }, "deferred-loaded")], + ); + yield* main.run("one"); + const firstDeclared = (model.requests[0]?.context.tools ?? []).map((tool) => tool.name); + + // A second thread at another placement, with its own config: its own servers and processes. + const line = yield* main.history(); + const forked = yield* main.fork({ entryId: line.at(-1)!.entry.id }); + forkId = forked.thread.id; + const fork = forked.thread; + yield* Session.relink({ sessionId: session.id, threadId: fork.id, directory: elsewhere }); + model.turn(main.id, [pendingCall("mcp__e2e__pid", {}, "main-pid-2")]); + model.turn(fork.id, [pendingCall("mcp__e2e__pid", {}, "fork-pid")]); + yield* Effect.all([main.run("two"), fork.run("two, forked")], { concurrency: "unbounded" }); + const mainStatus = yield* status(main.id); + const forkStatus = yield* status(fork.id); + const forkRegistered = registered[fork.id]; + + // The failed server stays failed across turns, even once it could start: nothing retries it. + yield* main.run("three"); + yield* Effect.promise(() => fs.rm(gate)); + yield* main.run("four"); + const failedAfterTurns = yield* stateOf(main.id, "gated"); + const startsWhileFailed = yield* Effect.promise(startCount); + + // An explicit connect recovers it, and its tools are offered from the next turn. + const connected = yield* mcp.connect(main.id, "gated"); + model.turn(main.id, [pendingCall("mcp__gated__pid", {}, "gated-pid")]); + yield* main.run("five"); + const declaredAfterConnect = declared(); + + // Disconnect closes it and keeps it closed: no tools, no restart by later turns. + const disconnected = yield* mcp.disconnect(main.id, "gated"); + yield* main.run("six"); + const declaredAfterDisconnect = declared(); + const stillDisconnected = yield* stateOf(main.id, "gated"); + const startsAfterDisconnect = yield* Effect.promise(startCount); + + // A connection that drops is reported as disconnected, and the next call reconnects. + model.turn(main.id, [pendingCall("mcp__e2e__bye", {}, "bye")]); + yield* main.run("seven"); + yield* waitFor(stateOf(main.id, "e2e"), (state) => state === "disconnected"); + model.turn(main.id, [pendingCall("mcp__e2e__echo", { text: "back" }, "echo-after-drop")]); + yield* main.run("eight"); + + const unknown = yield* Effect.exit(mcp.connect(main.id, "nope")); + return { + firstDeclared, + registeredMain: registered[main.id], + forkRegistered, + mainStatus, + forkStatus, + failedAfterTurns, + startsWhileFailed, + connected, + declaredAfterConnect, + disconnected, + declaredAfterDisconnect, + stillDisconnected, + startsAfterDisconnect, + calls: { main: yield* results(main), fork: yield* results(fork) }, + changes: changes(model.requests.at(-1)?.context.messages ?? []), + loaded: (yield* Document.Service.use((documents) => documents.agent(main.id))).modelTools, + unknown: Exit.isFailure(unknown) ? String(unknown.cause) : "succeeded", + events: seen, + }; + }).pipe( + Effect.provide( + Harness.layer({ + home, + hostCwd: dir.path, + database: ":memory:", + plugins: [perThread, observer, defaultPromptPlugin, ...toolSearch], + llm: model.open, + }), + ), + Effect.scoped, + Effect.runPromise, + ); + + expect(result.firstDeclared).toContain("mcp__e2e__pid"); + for (const name of ["mcp__e2e__args", "mcp__e2e__fail", "mcp__e2e__secret"]) + expect(result.firstDeclared).not.toContain(name); + expect(result.calls.main["main-pid"]).toMatch(/^\d+$/); + expect(result.calls.fork["fork-pid"]).toMatch(/^\d+$/); + // Each thread runs its own process. + expect(result.calls.fork["fork-pid"]).not.toBe(result.calls.main["main-pid-2"]); + expect(result.calls.main["main-pid-2"]).toBe(result.calls.main["main-pid"]); + expect(result.mainStatus.extra).toBeUndefined(); + expect(result.forkStatus.extra?.state).toBe("connected"); + expect(result.failedAfterTurns).toBe("failed"); + // One start for each thread's first turn; turns three and four never retried. + expect(result.startsWhileFailed).toBe(2); + expect(result.connected.state).toBe("connected"); + expect(result.declaredAfterConnect).toContain("mcp__gated__pid"); + expect(result.declaredAfterDisconnect).not.toContain("mcp__gated__pid"); + expect(result.stillDisconnected).toBe("disconnected"); + expect(result.startsAfterDisconnect).toBe(3); + + const pids = [ + ...new Set([result.calls.main["main-pid"], result.calls.fork["fork-pid"], result.calls.main["gated-pid"]]), + ]; + let serialized = JSON.stringify(result).replaceAll(dir.path, "").replaceAll(process.execPath, ""); + pids.forEach((pid, index) => { + serialized = serialized.replaceAll(`"${pid}"`, `""`); + }); + const artifact = JSON.parse(serialized.replaceAll(forkId ?? "\0", "")); + await expect(`${JSON.stringify(artifact, null, "\t")}\n`).toMatchFileSnapshot("./__artifacts__/mcp.thread.json"); +}, 60_000); + +it("mcp: a loaded deferred tool's server is awaited up front, and its name stays loaded while the server is away", async () => { + await using dir = await tmpdir(); + const home = join(dir.path, "home"); + const work = join(dir.path, "work"); + await fs.mkdir(work, { recursive: true }); + const configure = (delay: string) => + write(join(home, "mcp.json"), { + mcpServers: { slow: stdio({ exposure: "deferred" }, { E2E_DELAY: delay }) }, + }); + await configure("300"); + + const model = scripted(); + const result = await Effect.gen(function* () { + const session = yield* Session.create({ directory: work, model: { provider: "openai", id: "gpt-5.6-luna" } }); + const main = session.main; + const mcp = yield* Mcp.Service; + const stateOf = mcp.status(main.id).pipe(Effect.map((all) => all.find((one) => one.server === "slow")?.state)); + + // Nothing loaded: the turn does not wait for a server with only deferred tools. + yield* main.run("one"); + const notAwaited = yield* stateOf; + yield* waitFor(stateOf, (state) => state === "connected"); + + model.turn(main.id, [pendingCall(TOOL_SEARCH_NAME, { query: "echo text greeting" }, "search")]); + yield* main.run("two"); + + // A config change restarts the server; the loaded tool makes the turn wait for it. + yield* Effect.promise(() => configure("500")); + model.turn(main.id, [pendingCall("mcp__slow__echo", { text: "awaited" }, "awaited")]); + yield* main.run("three"); + const awaitedChanges = changes(model.requests.at(-1)?.context.messages ?? []); + + // While the server is away its loaded tool is withdrawn, and declared again once it is back. + yield* mcp.disconnect(main.id, "slow"); + yield* main.run("four"); + const whileAway = changes(model.requests.at(-1)?.context.messages ?? []); + const loadedWhileAway = (yield* Document.Service.use((documents) => documents.agent(main.id))).modelTools; + yield* mcp.connect(main.id, "slow"); + yield* main.run("five"); + const back = changes(model.requests.at(-1)?.context.messages ?? []); + return { notAwaited, awaitedChanges, whileAway, loadedWhileAway, back, calls: yield* results(main) }; + }).pipe( + Effect.provide( + Harness.layer({ + home, + hostCwd: dir.path, + database: ":memory:", + plugins: [defaultPromptPlugin, ...toolSearch], + llm: model.open, + }), + ), + Effect.scoped, + Effect.runPromise, + ); + + expect(result.notAwaited).toBe("connecting"); + expect(result.calls.awaited).toBe("hello: awaited"); + expect(result.loadedWhileAway?.add).toContain("mcp__slow__echo"); + await expect(`${JSON.stringify(result, null, "\t")}\n`).toMatchFileSnapshot( + "./__artifacts__/mcp.thread.loaded.json", + ); +}, 60_000); diff --git a/packages/plugin/src/plugin/mcp.ts b/packages/plugin/src/plugin/mcp.ts index 742d8290..19c45d31 100644 --- a/packages/plugin/src/plugin/mcp.ts +++ b/packages/plugin/src/plugin/mcp.ts @@ -11,9 +11,14 @@ * How a server's tools reach the model. * * - `direct`: declared to the model like any other tool. - * - `hidden`: never registered. + * - `codemode`: called from codemode scripts, never declared to the model. + * - `deferred`: declared once `toolsearch` loads them. + * - `hidden`: never offered to the model. + * + * Until codemode exists, `codemode` tools cannot be called. `codemode-deferred` in `mcp.json` is + * read as `codemode`. */ -export type Exposure = "direct" | "hidden"; +export type Exposure = "direct" | "codemode" | "deferred" | "hidden"; interface Base { /** Default: `direct`. */ From 118fa109ce0479a44fca5b06f0562880bc522c64 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Fri, 30 Oct 2026 14:58:11 +0530 Subject: [PATCH 16/17] fix(harness): serialize MCP connect and disconnect per server Connect and disconnect run one at a time per server and return the status read under that lock; a server a turn replaced meanwhile is reported unknown. Stopping reads and interrupts the connect attempt under the server lock, and an attempt that finishes after its server was closed or disconnected closes its connection instead of installing it. An explicit connect always publishes a fresh connecting status. Co-Authored-By: Claude Opus 5.5 --- packages/harness/src/mcp/mcp.ts | 74 +++++++---- .../test/__artifacts__/mcp.thread.json | 122 +++++++++++++++++- .../harness/test/fixtures/mcp/session.mjs | 9 +- packages/harness/test/mcp.thread.test.ts | 78 +++++++++-- 4 files changed, 247 insertions(+), 36 deletions(-) diff --git a/packages/harness/src/mcp/mcp.ts b/packages/harness/src/mcp/mcp.ts index ae8fe680..b33c5375 100644 --- a/packages/harness/src/mcp/mcp.ts +++ b/packages/harness/src/mcp/mcp.ts @@ -8,8 +8,8 @@ * tool is looked up when it is called: * * - A connection that drops fails the calls running on it, and the next call reconnects. - * - A server that failed to connect stays failed until its config changes or {@link - * Interface.connect} runs; turns never retry it. + * - Reconcile never restarts a failed server; only a config change or {@link Interface.connect} + * does. A server whose tools were declared still reconnects on its next call. * - A Streamable HTTP session the server forgot is replaced, and calls that hit it run once more on * the new one. The old connection closes when its last call finishes, never under one. * - A tool call is never retried after the server may have run it. @@ -63,7 +63,7 @@ export interface Status { readonly server: string; readonly state: State; readonly error?: string; - /** Tools the server offered when last listed, each with its exposure. */ + /** Tools the server offered when last listed, each with its exposure; kept while it is not connected. */ readonly tools: ReadonlyArray<{ readonly name: string; readonly exposure: Exposure }>; } @@ -128,6 +128,8 @@ interface Server { /** The last status published, so a no-op change publishes nothing. */ published: string | undefined; readonly lock: Semaphore.Semaphore; + /** Runs `connect` and `disconnect` one at a time. */ + readonly control: Semaphore.Semaphore; } interface Thread { @@ -331,10 +333,18 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string const error = Cause.squash(exit.cause); const message = typeof error === "string" ? error : error instanceof Error ? error.message : String(error); + if (server.closed || server.stopped) return yield* Effect.fail(message); yield* setState(server, Schema.is(NeedsAuthError)(error) ? "authenticate" : "failed", message); yield* Effect.logWarning("mcp connect failed", { server: server.name, error: message }); return yield* Effect.fail(message); } + // Closed or disconnected while connecting: the connection must not outlive that. + if (server.closed || server.stopped) { + yield* Scope.close(scope, Exit.void); + return yield* Effect.fail( + `MCP server "${server.name}" is ${server.closed ? "shut down" : "disconnected"}`, + ); + } const live: Live = { connection: exit.value.connection, scope, @@ -393,10 +403,8 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string Effect.gen(function* () { const pending = server.attempt; if (pending !== undefined) yield* Fiber.interrupt(pending); - yield* Effect.suspend(() => - server.live === undefined ? Effect.void : retire(server, server.live), - ).pipe(server.lock.withPermits(1)); - }); + if (server.live !== undefined) yield* retire(server, server.live); + }).pipe(server.lock.withPermits(1)); /** Stop the server for good: it was removed, or its connection settings changed. */ const close = (server: Server) => @@ -529,6 +537,7 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string stopped: false, published: undefined, lock: Semaphore.makeUnsafe(1), + control: Semaphore.makeUnsafe(1), }; thread.servers.set(name, server); yield* start(server); @@ -578,23 +587,42 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string return tools; }); - const connect = Effect.fn("Mcp.connect")(function* (threadId: ThreadId, name: string) { - const server = yield* serverOf(threadId, name); - if (server.config.enabled === false) return statusOf(server); - server.stopped = false; - yield* stop(server); - yield* open(server).pipe(Effect.ignore); - return statusOf(server); - }); + /** + * Run `operation` on the thread's server, one operation per server at a time. A turn that + * replaced or removed the server meanwhile makes it unknown, so a stale status is never returned. + */ + const control = (threadId: ThreadId, name: string, operation: (server: Server) => Effect.Effect) => + Effect.gen(function* () { + const server = yield* serverOf(threadId, name); + return yield* Effect.gen(function* () { + if (server.config.enabled !== false) yield* operation(server); + if (threads.get(threadId)?.servers.get(name) !== server) + return yield* new UnknownServerError({ server: name }); + return statusOf(server); + }).pipe(server.control.withPermits(1)); + }); - const disconnect = Effect.fn("Mcp.disconnect")(function* (threadId: ThreadId, name: string) { - const server = yield* serverOf(threadId, name); - if (server.config.enabled === false) return statusOf(server); - server.stopped = true; - yield* stop(server); - yield* setState(server, "disconnected"); - return statusOf(server); - }); + const connect = Effect.fn("Mcp.connect")((threadId: ThreadId, name: string) => + control(threadId, name, (server) => + Effect.gen(function* () { + server.stopped = false; + yield* stop(server); + // An explicit restart is reported even when an interrupted attempt left it `connecting`. + server.published = undefined; + yield* open(server).pipe(Effect.ignore); + }), + ), + ); + + const disconnect = Effect.fn("Mcp.disconnect")((threadId: ThreadId, name: string) => + control(threadId, name, (server) => + Effect.gen(function* () { + server.stopped = true; + yield* stop(server); + yield* setState(server, "disconnected"); + }), + ), + ); return Service.of({ reconcile, diff --git a/packages/harness/test/__artifacts__/mcp.thread.json b/packages/harness/test/__artifacts__/mcp.thread.json index f7ad4097..526f37f3 100644 --- a/packages/harness/test/__artifacts__/mcp.thread.json +++ b/packages/harness/test/__artifacts__/mcp.thread.json @@ -348,7 +348,9 @@ "main-pid-2": "", "gated-pid": "", "bye": "bye", - "echo-after-drop": "hello: back" + "echo-after-drop": "hello: back", + "pid-before-restart": "", + "pid-after-restart": "" }, "fork": { "main-pid": "", @@ -373,7 +375,65 @@ "mcp__e2e__args" ] }, - "unknown": "Cause([Fail(Mcp.UnknownServerError: unknown MCP server: nope)])", + "restarted": { + "server": "e2e", + "state": "connected", + "tools": [ + { + "name": "echo", + "exposure": "direct" + }, + { + "name": "args", + "exposure": "deferred" + }, + { + "name": "fail", + "exposure": "codemode" + }, + { + "name": "pid", + "exposure": "direct" + }, + { + "name": "crash", + "exposure": "direct" + }, + { + "name": "my-tool.v2", + "exposure": "direct" + }, + { + "name": "secret", + "exposure": "hidden" + }, + { + "name": "bye", + "exposure": "direct" + } + ] + }, + "connectThenDisconnect": { + "finished": [ + "connect", + "disconnect" + ], + "state": "disconnected", + "running": 0 + }, + "disconnectThenConnect": { + "finished": [ + "disconnect", + "connect" + ], + "state": "connected", + "running": 1 + }, + "startsAfterRaces": 5, + "unknown": { + "tag": "Mcp.UnknownServerError", + "message": "unknown MCP server: nope" + }, "events": { "main": { "e2e": [ @@ -404,6 +464,24 @@ "server": "e2e", "state": "connecting" }, + { + "server": "e2e", + "state": "connected", + "tools": [ + "echo", + "args", + "fail", + "pid", + "crash", + "my-tool.v2", + "secret", + "bye" + ] + }, + { + "server": "e2e", + "state": "connecting" + }, { "server": "e2e", "state": "connected", @@ -450,6 +528,46 @@ { "server": "gated", "state": "disconnected" + }, + { + "server": "gated", + "state": "connecting" + }, + { + "server": "gated", + "state": "connected", + "tools": [ + "echo", + "args", + "fail", + "pid", + "crash", + "my-tool.v2", + "secret", + "bye" + ] + }, + { + "server": "gated", + "state": "disconnected" + }, + { + "server": "gated", + "state": "connecting" + }, + { + "server": "gated", + "state": "connected", + "tools": [ + "echo", + "args", + "fail", + "pid", + "crash", + "my-tool.v2", + "secret", + "bye" + ] } ], "off": [ diff --git a/packages/harness/test/fixtures/mcp/session.mjs b/packages/harness/test/fixtures/mcp/session.mjs index 0f0724b6..158b1095 100644 --- a/packages/harness/test/fixtures/mcp/session.mjs +++ b/packages/harness/test/fixtures/mcp/session.mjs @@ -3,13 +3,16 @@ import { Server } from "@modelcontextprotocol/server"; import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; import { appendFileSync, existsSync } from "node:fs"; -// E2E_STARTS: a file that gets one line per start. E2E_GATE: while this file exists, exit at once. -// E2E_DELAY: milliseconds to wait before answering the handshake. -if (process.env.E2E_STARTS) appendFileSync(process.env.E2E_STARTS, "start\n"); +// E2E_STARTS: a file that gets each start's pid on a line. E2E_GATE: while this file exists, exit at +// once. E2E_HOLD: while this file exists, wait before answering the handshake. E2E_DELAY: milliseconds +// to wait before answering it. +if (process.env.E2E_STARTS) appendFileSync(process.env.E2E_STARTS, `${process.pid}\n`); if (process.env.E2E_GATE && existsSync(process.env.E2E_GATE)) { process.stderr.write("gate closed\n"); process.exit(1); } +while (process.env.E2E_HOLD && existsSync(process.env.E2E_HOLD)) + await new Promise((resolve) => setTimeout(resolve, 20)); if (process.env.E2E_DELAY) await new Promise((resolve) => setTimeout(resolve, Number(process.env.E2E_DELAY))); const object = (properties = {}, required = []) => ({ type: "object", properties, required }); diff --git a/packages/harness/test/mcp.thread.test.ts b/packages/harness/test/mcp.thread.test.ts index 30793409..b5826d2f 100644 --- a/packages/harness/test/mcp.thread.test.ts +++ b/packages/harness/test/mcp.thread.test.ts @@ -1,6 +1,6 @@ import "./utils/env.ts"; import { createAssistantMessageEventStream, type Message } from "@codeworksh/aikit"; -import { Effect, Exit } from "effect"; +import { Effect } from "effect"; import { execFile } from "node:child_process"; import fs from "node:fs/promises"; import { join } from "node:path"; @@ -188,7 +188,22 @@ it("mcp: state per thread, no retry of a failed server, connect and disconnect, }); const model = scripted(); - const startCount = () => fs.readFile(starts, "utf8").then((text) => text.split("\n").filter(Boolean).length); + const startedPids = () => fs.readFile(starts, "utf8").then((text) => text.split("\n").filter(Boolean).map(Number)); + const startCount = () => startedPids().then((pids) => pids.length); + const alive = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + }; + /** The gated server's processes still running, once the closed ones had time to exit. */ + const running = (expected: number) => + waitFor( + Effect.promise(startedPids).pipe(Effect.map((pids) => pids.filter(alive))), + (pids) => pids.length === expected, + ); const result = await Effect.gen(function* () { const session = yield* Session.create({ directory: work, model: { provider: "openai", id: "gpt-5.6-luna" } }); @@ -267,10 +282,32 @@ it("mcp: state per thread, no retry of a failed server, connect and disconnect, model.turn(main.id, [pendingCall("mcp__e2e__bye", {}, "bye")]); yield* main.run("seven"); yield* waitFor(stateOf(main.id, "e2e"), (state) => state === "disconnected"); - model.turn(main.id, [pendingCall("mcp__e2e__echo", { text: "back" }, "echo-after-drop")]); + model.turn(main.id, [ + pendingCall("mcp__e2e__echo", { text: "back" }, "echo-after-drop"), + pendingCall("mcp__e2e__pid", {}, "pid-before-restart"), + ]); yield* main.run("eight"); - const unknown = yield* Effect.exit(mcp.connect(main.id, "nope")); + // Connect on a connected server restarts it. + const restarted = yield* mcp.connect(main.id, "e2e"); + model.turn(main.id, [pendingCall("mcp__e2e__pid", {}, "pid-after-restart")]); + yield* main.run("nine"); + + // Concurrent connect and disconnect run one after the other: the last one wins, and no + // process outlives a disconnect. + const race = Effect.fnUntraced(function* (first: "connect" | "disconnect", second: "connect" | "disconnect") { + const finished: string[] = []; + const run = (operation: "connect" | "disconnect") => + mcp[operation](main.id, "gated").pipe(Effect.tap(() => Effect.sync(() => finished.push(operation)))); + yield* Effect.all([run(first), run(second)], { concurrency: "unbounded" }); + const state = yield* stateOf(main.id, "gated"); + return { finished, state, running: (yield* running(state === "connected" ? 1 : 0)).length }; + }); + const connectThenDisconnect = yield* race("connect", "disconnect"); + const disconnectThenConnect = yield* race("disconnect", "connect"); + const startsAfterRaces = yield* Effect.promise(startCount); + + const unknown = yield* Effect.flip(mcp.connect(main.id, "nope")); return { firstDeclared, registeredMain: registered[main.id], @@ -288,7 +325,11 @@ it("mcp: state per thread, no retry of a failed server, connect and disconnect, calls: { main: yield* results(main), fork: yield* results(fork) }, changes: changes(model.requests.at(-1)?.context.messages ?? []), loaded: (yield* Document.Service.use((documents) => documents.agent(main.id))).modelTools, - unknown: Exit.isFailure(unknown) ? String(unknown.cause) : "succeeded", + restarted, + connectThenDisconnect, + disconnectThenConnect, + startsAfterRaces, + unknown: { tag: unknown._tag, message: unknown.message }, events: seen, }; }).pipe( @@ -323,9 +364,26 @@ it("mcp: state per thread, no retry of a failed server, connect and disconnect, expect(result.declaredAfterDisconnect).not.toContain("mcp__gated__pid"); expect(result.stillDisconnected).toBe("disconnected"); expect(result.startsAfterDisconnect).toBe(3); + expect(result.calls.main["pid-after-restart"]).not.toBe(result.calls.main["pid-before-restart"]); + expect(result.connectThenDisconnect).toEqual({ + finished: ["connect", "disconnect"], + state: "disconnected", + running: 0, + }); + expect(result.disconnectThenConnect).toEqual({ + finished: ["disconnect", "connect"], + state: "connected", + running: 1, + }); const pids = [ - ...new Set([result.calls.main["main-pid"], result.calls.fork["fork-pid"], result.calls.main["gated-pid"]]), + ...new Set([ + result.calls.main["main-pid"], + result.calls.fork["fork-pid"], + result.calls.main["gated-pid"], + result.calls.main["pid-before-restart"], + result.calls.main["pid-after-restart"], + ]), ]; let serialized = JSON.stringify(result).replaceAll(dir.path, "").replaceAll(process.execPath, ""); pids.forEach((pid, index) => { @@ -340,11 +398,14 @@ it("mcp: a loaded deferred tool's server is awaited up front, and its name stays const home = join(dir.path, "home"); const work = join(dir.path, "work"); await fs.mkdir(work, { recursive: true }); + // The first start waits while `hold` exists, so the first turn finds it still connecting. + const hold = join(dir.path, "hold"); + await fs.writeFile(hold, "held"); const configure = (delay: string) => write(join(home, "mcp.json"), { - mcpServers: { slow: stdio({ exposure: "deferred" }, { E2E_DELAY: delay }) }, + mcpServers: { slow: stdio({ exposure: "deferred" }, { E2E_HOLD: hold, E2E_DELAY: delay }) }, }); - await configure("300"); + await configure("0"); const model = scripted(); const result = await Effect.gen(function* () { @@ -356,6 +417,7 @@ it("mcp: a loaded deferred tool's server is awaited up front, and its name stays // Nothing loaded: the turn does not wait for a server with only deferred tools. yield* main.run("one"); const notAwaited = yield* stateOf; + yield* Effect.promise(() => fs.rm(hold)); yield* waitFor(stateOf, (state) => state === "connected"); model.turn(main.id, [pendingCall(TOOL_SEARCH_NAME, { query: "echo text greeting" }, "search")]); From 3506b9af9725e28189c5d7dca0214fa7ce2304c3 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Fri, 30 Oct 2026 17:11:15 +0530 Subject: [PATCH 17/17] fix(harness): close a deleted session's MCP servers and report interrupted attempts by state Deleting a session now forgets its threads' MCP records and closes their servers. A caller waiting on a connect attempt that was interrupted is told whether the server was shut down, disconnected or is reconnecting, and connect-time close reasons read like the ones after connect. Co-Authored-By: Claude Opus 5.5 --- packages/harness/src/control.ts | 3 ++ packages/harness/src/mcp/client.ts | 2 +- packages/harness/src/mcp/mcp.ts | 36 ++++++++++++++----- packages/harness/src/plugin/mcp/registry.ts | 2 +- .../test/__artifacts__/mcp.client.json | 4 +-- .../test/__artifacts__/mcp.session.json | 2 +- .../test/__artifacts__/mcp.thread.json | 13 ++++--- packages/harness/test/control.prompt.test.ts | 4 +++ packages/harness/test/mcp.thread.test.ts | 14 +++++++- 9 files changed, 62 insertions(+), 18 deletions(-) diff --git a/packages/harness/src/control.ts b/packages/harness/src/control.ts index 4c180919..547c3bfc 100644 --- a/packages/harness/src/control.ts +++ b/packages/harness/src/control.ts @@ -16,6 +16,7 @@ import { Context, Effect, Layer, Option, Schema } from "effect"; import { Inbox } from "./inbox/inbox.ts"; +import { Mcp } from "./mcp/mcp.ts"; import type { Admitted } from "./inbox/schema.ts"; import { RunnerExecution } from "./runner/execution.ts"; import type { Runner } from "./runner/run.ts"; @@ -128,6 +129,7 @@ export const layer = Layer.effect( const threads = yield* Thread.Service; const inputs = yield* Inbox.make; const execution = yield* RunnerExecution.Service; + const mcp = yield* Mcp.Service; const isLifecycleConflict = Schema.is(Inbox.LifecycleConflict); // Marked before the wake, so a prompt accepted here survives a crash before its run starts. @@ -241,6 +243,7 @@ export const layer = Layer.effect( (id) => interrupt(id, { reason: "user", awaitSettlement: true }), { concurrency: "unbounded", discard: true }, ); + yield* mcp.remove(sessionId); return deleted; }); diff --git a/packages/harness/src/mcp/client.ts b/packages/harness/src/mcp/client.ts index 7ce84607..6d8cf1b6 100644 --- a/packages/harness/src/mcp/client.ts +++ b/packages/harness/src/mcp/client.ts @@ -198,7 +198,7 @@ export const connect = Effect.fn("McpClient.connect")(function* (input: ConnectI let lastError: string | undefined; const explain = (error: unknown) => error instanceof SdkError && error.code === SdkErrorCode.ConnectionClosed && lastError - ? `${describe(error)}: ${lastError}` + ? `connection closed: ${lastError}` : describe(error); // A server that wants (new) credentials. Without an auth provider a 401 is a configuration error. const needsAuth = (error: unknown) => diff --git a/packages/harness/src/mcp/mcp.ts b/packages/harness/src/mcp/mcp.ts index b33c5375..364e071a 100644 --- a/packages/harness/src/mcp/mcp.ts +++ b/packages/harness/src/mcp/mcp.ts @@ -93,6 +93,8 @@ export interface Interface { readonly connect: (threadId: ThreadId, server: string) => Effect.Effect; /** Close the server and keep it disconnected until {@link connect} or a config change. */ readonly disconnect: (threadId: ThreadId, server: string) => Effect.Effect; + /** Forget the threads of a deleted session and close their servers. */ + readonly remove: (sessionId: SessionId) => Effect.Effect; } export class Service extends Context.Service()("@codeworksh/harness/mcp/mcp/Service") {} @@ -341,9 +343,7 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string // Closed or disconnected while connecting: the connection must not outlive that. if (server.closed || server.stopped) { yield* Scope.close(scope, Exit.void); - return yield* Effect.fail( - `MCP server "${server.name}" is ${server.closed ? "shut down" : "disconnected"}`, - ); + return yield* Effect.fail(halted(server)); } const live: Live = { connection: exit.value.connection, @@ -367,12 +367,15 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string Effect.provide(context), ); + /** Why the server has no connection to offer: closed, disconnected, or between attempts. */ + const halted = (server: Server) => + `MCP server "${server.name}" is ${server.closed ? "shut down" : server.stopped ? "disconnected" : "reconnecting"}`; + /** The server's current connection, connecting first when it has none. */ const open = (server: Server): Effect.Effect => Effect.gen(function* () { const next = yield* Effect.gen(function* () { - if (server.closed) return yield* Effect.fail(`MCP server "${server.name}" is shut down`); - if (server.stopped) return yield* Effect.fail(`MCP server "${server.name}" is disconnected`); + if (server.closed || server.stopped) return yield* Effect.fail(halted(server)); const current = server.live; // A drop is known here before its `onClose` handler has run. const reason = current?.connection.dropped(); @@ -393,8 +396,9 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string // An interrupted caller leaves the attempt running for whoever needs the server next. const exit = yield* Fiber.await(next); if (Exit.isSuccess(exit)) return exit.value; + // Interrupted by `connect`, `disconnect` or closing; the server's state now says which. return yield* Cause.hasInterruptsOnly(exit.cause) - ? Effect.fail(`MCP server "${server.name}" is shut down`) + ? Effect.fail(halted(server)) : Effect.failCause(exit.cause); }); @@ -406,7 +410,7 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string if (server.live !== undefined) yield* retire(server, server.live); }).pipe(server.lock.withPermits(1)); - /** Stop the server for good: it was removed, or its connection settings changed. */ + /** Stop the server for good: it was removed, its connection settings changed, or its session was deleted. */ const close = (server: Server) => Effect.suspend(() => { server.closed = true; @@ -420,8 +424,9 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string Effect.gen(function* () { for (let attempt = 1; ; attempt++) { const server = thread.servers.get(name); - if (server === undefined || server.closed || server.config.enabled === false) + if (server === undefined || server.config.enabled === false) return yield* Effect.fail(`MCP server "${name}" is disabled.`); + if (server.closed) return yield* Effect.fail(`MCP server "${name}" is shut down.`); if ( McpTool.exposure(server.config, tool) === "hidden" || (server.live !== undefined && !server.tools.some((offered) => offered.name === tool)) @@ -624,11 +629,26 @@ export const layer = (options: { readonly hostCwd: string; readonly home: string ), ); + const remove = Effect.fn("Mcp.remove")(function* (sessionId: SessionId) { + for (const thread of threads.values()) { + if (thread.sessionId !== sessionId) continue; + const servers = yield* Effect.sync(() => { + if (threads.get(thread.threadId) === thread) threads.delete(thread.threadId); + const servers = [...thread.servers.values()]; + thread.servers.clear(); + return servers; + }).pipe(thread.lock.withPermits(1)); + // Stopping can take seconds; deleting the session does not wait for it. + for (const server of servers) fork(close(server)); + } + }); + return Service.of({ reconcile, status: (threadId) => Effect.sync(() => [...(threads.get(threadId)?.servers.values() ?? [])].map(statusOf)), connect, disconnect, + remove, }); }), ); diff --git a/packages/harness/src/plugin/mcp/registry.ts b/packages/harness/src/plugin/mcp/registry.ts index 8468d0f3..e19f4cf4 100644 --- a/packages/harness/src/plugin/mcp/registry.ts +++ b/packages/harness/src/plugin/mcp/registry.ts @@ -18,7 +18,7 @@ const frozen = (value: A): A => { }; /** - * The session's MCP servers for one turn, seeded from `mcp.json`. A plugin written in plain + * The thread's MCP servers for one turn, seeded from `mcp.json`. A plugin written in plain * JavaScript can hand in anything, so every write is checked here with the rules `mcp.json` follows, * and a bad one fails that plugin's `setup` rather than a connection later. */ diff --git a/packages/harness/test/__artifacts__/mcp.client.json b/packages/harness/test/__artifacts__/mcp.client.json index 397353d6..6563329e 100644 --- a/packages/harness/test/__artifacts__/mcp.client.json +++ b/packages/harness/test/__artifacts__/mcp.client.json @@ -5,7 +5,7 @@ }, "crash": { "error": { - "message": "Connection closed: mcp server process exited with code 3: fatal: fixture crashed on purpose", + "message": "connection closed: mcp server process exited with code 3: fatal: fixture crashed on purpose", "tag": "Mcp.RequestError", }, "method": "tools/call", @@ -42,7 +42,7 @@ }, "frameLimit": { "error": { - "message": "Connection closed: mcp stdio frame exceeded 16 MiB", + "message": "connection closed: mcp stdio frame exceeded 16 MiB", "tag": "Mcp.RequestError", }, "reason": "connection closed: mcp stdio frame exceeded 16 MiB", diff --git a/packages/harness/test/__artifacts__/mcp.session.json b/packages/harness/test/__artifacts__/mcp.session.json index dd5fd699..cab967bc 100644 --- a/packages/harness/test/__artifacts__/mcp.session.json +++ b/packages/harness/test/__artifacts__/mcp.session.json @@ -185,7 +185,7 @@ "status": "error", "isError": true, "content": [ - "MCP tool \"e2e/crash\" failed: Connection closed: mcp server process exited with code 3: fatal: e2e fixture crashed on purpose" + "MCP tool \"e2e/crash\" failed: connection closed: mcp server process exited with code 3: fatal: e2e fixture crashed on purpose" ] }, "pid-after": { diff --git a/packages/harness/test/__artifacts__/mcp.thread.json b/packages/harness/test/__artifacts__/mcp.thread.json index 526f37f3..0f406a30 100644 --- a/packages/harness/test/__artifacts__/mcp.thread.json +++ b/packages/harness/test/__artifacts__/mcp.thread.json @@ -147,7 +147,7 @@ }, "gated": { "state": "failed", - "error": "Connection closed: mcp server process exited with code 1: gate closed", + "error": "connection closed: mcp server process exited with code 1: gate closed", "tools": [] }, "off": { @@ -195,7 +195,7 @@ }, "gated": { "state": "failed", - "error": "Connection closed: mcp server process exited with code 1: gate closed", + "error": "connection closed: mcp server process exited with code 1: gate closed", "tools": [] }, "off": { @@ -505,7 +505,7 @@ { "server": "gated", "state": "failed", - "error": "Connection closed: mcp server process exited with code 1: gate closed" + "error": "connection closed: mcp server process exited with code 1: gate closed" }, { "server": "gated", @@ -606,7 +606,7 @@ { "server": "gated", "state": "failed", - "error": "Connection closed: mcp server process exited with code 1: gate closed" + "error": "connection closed: mcp server process exited with code 1: gate closed" } ], "off": [ @@ -636,5 +636,10 @@ } ] } + }, + "deleted": { + "running": 0, + "status": {}, + "events": 0 } } diff --git a/packages/harness/test/control.prompt.test.ts b/packages/harness/test/control.prompt.test.ts index ea1923cb..b1feba1c 100644 --- a/packages/harness/test/control.prompt.test.ts +++ b/packages/harness/test/control.prompt.test.ts @@ -8,7 +8,9 @@ import { Database } from "../src/db/db.ts"; import { Event } from "../src/event/event.ts"; import { EventList } from "../src/event/list.ts"; import { Inbox } from "../src/inbox/inbox.ts"; +import { Mcp } from "../src/mcp/mcp.ts"; import { RunnerExecution } from "../src/runner/execution.ts"; +import { Process } from "../src/sandbox/utils/process.ts"; import { SessionLive } from "../src/session/live.ts"; import { SessionMessageSchema } from "../src/session/message/schema.ts"; import { PromptSchema } from "../src/session/prompt/schema.ts"; @@ -27,6 +29,7 @@ const layer = Control.layer.pipe( // Admission is the whole subject here, so there is nothing to drain and the // wake has nowhere to go. Layer.provide(RunnerExecution.noopLayer), + Layer.provide(Mcp.layer({ hostCwd: "/", home: "/unused/home" }).pipe(Layer.provide(Process.host))), Layer.provideMerge(SessionLive.layer), Layer.provideMerge(Event.layer), Layer.provideMerge(Database.layer(":memory:")), @@ -398,6 +401,7 @@ const watched = Control.layer.pipe( }), ), ), + Layer.provide(Mcp.layer({ hostCwd: "/", home: "/unused/home" }).pipe(Layer.provide(Process.host))), Layer.provideMerge(SessionLive.layer), Layer.provideMerge(Event.layer), Layer.provideMerge(Database.layer(":memory:")), diff --git a/packages/harness/test/mcp.thread.test.ts b/packages/harness/test/mcp.thread.test.ts index b5826d2f..c6fcdade 100644 --- a/packages/harness/test/mcp.thread.test.ts +++ b/packages/harness/test/mcp.thread.test.ts @@ -308,7 +308,7 @@ it("mcp: state per thread, no retry of a failed server, connect and disconnect, const startsAfterRaces = yield* Effect.promise(startCount); const unknown = yield* Effect.flip(mcp.connect(main.id, "nope")); - return { + const outcome = { firstDeclared, registeredMain: registered[main.id], forkRegistered, @@ -332,6 +332,17 @@ it("mcp: state per thread, no retry of a failed server, connect and disconnect, unknown: { tag: unknown._tag, message: unknown.message }, events: seen, }; + + // Deleting the session closes its servers and reports nothing more to its threads. + const eventCount = () => Object.values(seen).flatMap((byServer) => Object.values(byServer).flat()).length; + const eventsBeforeDelete = eventCount(); + yield* Session.remove(session.id); + const deleted = { + running: (yield* running(0)).length, + status: yield* status(main.id), + events: eventCount() - eventsBeforeDelete, + }; + return { ...outcome, deleted }; }).pipe( Effect.provide( Harness.layer({ @@ -375,6 +386,7 @@ it("mcp: state per thread, no retry of a failed server, connect and disconnect, state: "connected", running: 1, }); + expect(result.deleted).toEqual({ running: 0, status: {}, events: 0 }); const pids = [ ...new Set([