Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
4e41d45
feat(harness): add MCP client
sanchitrk Oct 14, 2026
1f048fe
feat(harness): log MCP server messages and condense validation errors
sanchitrk Oct 14, 2026
8719543
refactor(harness): trace MCP requests and keep their errors in Effect
sanchitrk Oct 14, 2026
3016387
fix(harness): close MCP connections fully on failure and cleanup
sanchitrk Oct 14, 2026
50909bd
test(harness): check MCP stderr grandchildren die before scope close
sanchitrk Oct 14, 2026
f64f0c1
fix(harness): retry only network failures and match MCP timeouts and …
sanchitrk Oct 24, 2026
19af2e9
fix(harness): receive MCP server log messages from the handshake on
sanchitrk Oct 24, 2026
d9bb7e9
fix(harness): report an MCP list change that arrives before its handler
sanchitrk Oct 24, 2026
82c416d
feat(harness): run MCP servers per session and declare their tools
sanchitrk Oct 14, 2026
0e82590
fix(harness): start stdio MCP servers in their configured cwd
sanchitrk Oct 14, 2026
e0cea06
fix(harness): keep MCP connections recoverable and server configs set…
sanchitrk Oct 15, 2026
bdac30c
fix(harness): harden MCP config values and send servers clean arguments
sanchitrk Oct 24, 2026
2237d9e
fix(harness): keep MCP retries, shutdown and tool lists right under r…
sanchitrk Oct 24, 2026
4cd6ee1
fix(harness): keep MCP startup logs and forget failed commands on time
sanchitrk Oct 24, 2026
66261d0
feat(harness): add MCP resource tools and keep oversized output on th…
sanchitrk Oct 14, 2026
938fd0b
fix(harness): page MCP resources with the SDK and pass cursors through
sanchitrk Oct 15, 2026
c6e7a8d
fix(harness): treat a blank MCP cursor as none and wait for connectin…
sanchitrk Oct 24, 2026
f815324
fix(harness): send MCP images through the shared inline image pipeline
sanchitrk Oct 24, 2026
58a00fa
feat(harness): share MCP servers by scope and close idle ones
sanchitrk Oct 15, 2026
319c72e
fix(harness): keep MCP lease counts exact across renames, aliases and…
sanchitrk Oct 15, 2026
8ceb467
refactor(harness): carry MCP failures as tagged errors
sanchitrk Oct 15, 2026
97f1a8a
test(codework): run an MCP tool through the real CLI
sanchitrk Oct 15, 2026
482e7de
feat(harness): accept codemode and deferred MCP exposure
sanchitrk Oct 15, 2026
3b8d270
fix(harness): retry failed shared MCP instances and keep resource lin…
sanchitrk Oct 24, 2026
33e5488
fix(harness): carry MCP race and shutdown fixes onto the scoped service
sanchitrk Oct 24, 2026
1a3073a
fix(harness): keep MCP startup logs on the scoped service
sanchitrk Oct 24, 2026
2941977
Merge canary into the MCP client branch
sanchitrk Oct 30, 2026
4f6395d
Merge canary into the MCP sessions branch
sanchitrk Oct 30, 2026
4478a32
Merge canary into the MCP resources branch
sanchitrk Oct 30, 2026
1ee4062
Merge canary into the MCP scopes branch
sanchitrk Oct 30, 2026
d1bfd47
feat(harness): keep MCP state per thread and report server status
sanchitrk Oct 30, 2026
118fa10
fix(harness): serialize MCP connect and disconnect per server
sanchitrk Oct 30, 2026
54d0a84
Merge MCP threads into the MCP resources branch
sanchitrk Oct 30, 2026
8fc89f2
Merge MCP threads into the MCP scopes branch
sanchitrk Oct 30, 2026
921d538
docs: describe MCP servers per thread, deferred tools and no retry
sanchitrk Oct 30, 2026
a0bcdd1
test(harness): sort MCP status events by thread and server in the art…
sanchitrk Oct 30, 2026
b489958
fix(harness): report idle close and keep MCP thread records with repo…
sanchitrk Oct 30, 2026
3506b9a
fix(harness): close a deleted session's MCP servers and report interr…
sanchitrk Oct 30, 2026
83381a7
Merge MCP threads into the MCP resources branch
sanchitrk Oct 30, 2026
5bfee57
Merge MCP threads into the MCP scopes branch
sanchitrk Oct 30, 2026
6def485
fix(harness): refuse MCP connect on an instance closed while idle
sanchitrk Oct 30, 2026
cef40d2
fix(harness): wait for a turn's starting MCP servers concurrently
sanchitrk Oct 30, 2026
33386fd
Merge canary into the MCP branch for #83
sanchitrk Oct 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/codework/test/__artifacts__/cli.mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"requests": 2,
"declared": {
"name": "mcp__fixture__echo",
"description": "Echo text back, with the directory the server runs in.",
"parameters": {
"type": "object",
"properties": {
"text": {
"type": "string"
}
},
"required": [
"text"
]
}
},
"answer": "echo: from the cli (pid <pid>, cwd <project>)"
}
157 changes: 157 additions & 0 deletions packages/codework/test/cli.mcp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/* @effect-diagnostics nodeBuiltinImport:off -- this suite spawns the CLI and serves a fake provider. */
/* @effect-diagnostics globalTimers:off -- the subprocess deadline runs outside any Effect. */
import { spawn } from "node:child_process";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { createServer, type IncomingMessage } 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 { expect, it, onTestFinished } from "vite-plus/test";

const cli = fileURLToPath(new URL("../src/index.ts", import.meta.url));
const models = fileURLToPath(new URL("../../../models.gen.json", import.meta.url));
const fixture = fileURLToPath(new URL("./fixtures/mcp-echo.mjs", import.meta.url));

interface ChatRequest {
readonly tools?: ReadonlyArray<{ readonly function: { readonly name: string; readonly parameters: unknown } }>;
readonly messages: ReadonlyArray<{ readonly role: string; readonly content?: unknown }>;
}

const read = (request: IncomingMessage) =>
new Promise<string>((resolve, reject) => {
const chunks: Buffer[] = [];
request.on("data", (chunk: Buffer) => chunks.push(chunk));
request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
request.on("error", reject);
});

const sse = (chunks: ReadonlyArray<object>) =>
`${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")}data: [DONE]\n\n`;
const chunk = (choice: object) => ({
id: "chatcmpl-mcp",
object: "chat.completion.chunk",
created: 1,
model: "mcp-model",
choices: [{ index: 0, ...choice }],
});

/** The first request calls the MCP tool; the one carrying its result repeats that result as the answer. */
const provider = async () => {
const requests: ChatRequest[] = [];
const server = createServer(async (request, response) => {
const body = JSON.parse(await read(request)) as ChatRequest;
requests.push(body);
response.writeHead(200, { "content-type": "text/event-stream" });
const result = body.messages.find((message) => message.role === "tool");
if (result !== undefined) {
const text = typeof result.content === "string" ? result.content : JSON.stringify(result.content);
response.end(
sse([
chunk({ delta: { role: "assistant", content: `Tool said: ${text}` } }),
chunk({ delta: {}, finish_reason: "stop" }),
]),
);
return;
}
response.end(
sse([
chunk({
delta: {
role: "assistant",
tool_calls: [
{
index: 0,
id: "call_mcp",
type: "function",
function: { name: "mcp__fixture__echo", arguments: '{"text":"from the cli"}' },
},
],
},
}),
chunk({ delta: {}, finish_reason: "tool_calls" }),
]),
);
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
onTestFinished(() => new Promise<void>((resolve) => server.close(() => resolve())));
return { url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`, requests };
};

const runCli = (args: ReadonlyArray<string>, options: { readonly cwd: string; readonly env: NodeJS.ProcessEnv }) =>
new Promise<{ readonly status: number | null; readonly stdout: string; readonly stderr: string }>(
(resolve, reject) => {
const child = spawn(process.execPath, ["--conditions=development", cli, ...args], options);
let stdout = "";
let stderr = "";
child.stdout.on("data", (data: Buffer) => (stdout += data.toString("utf8")));
child.stderr.on("data", (data: Buffer) => (stderr += data.toString("utf8")));
const deadline = setTimeout(() => child.kill("SIGKILL"), 45_000);
child.on("error", reject);
child.on("close", (status) => {
clearTimeout(deadline);
resolve({ status, stdout, stderr });
});
},
);

it("codework run calls a tool of a stdio MCP server from mcp.json, and stops the server on exit", async () => {
const home = mkdtempSync(join(tmpdir(), "codework-cli-mcp-"));
onTestFinished(() => rmSync(home, { recursive: true, force: true }));
const project = join(home, "project");
const settings = join(home, "settings");
mkdirSync(project);
mkdirSync(settings);
const fake = await provider();
writeFileSync(
join(settings, "settings.jsonc"),
JSON.stringify({
model: { provider: "mcp", id: "mcp-model" },
models: [
{
id: "mcp-model",
name: "mcp-model",
provider: { id: "mcp", name: "Mcp", source: "config", env: [] },
baseUrl: `${fake.url}/v1`,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 32768,
maxTokens: 8192,
protocol: "openai-compatible",
},
],
}),
);
// `mcp.json` sits next to the user settings the run reads.
writeFileSync(
join(settings, "mcp.json"),
JSON.stringify({ mcpServers: { fixture: { command: process.execPath, args: [fixture] } } }),
);

const result = await runCli(
["--home", home, "--database", ":memory:", "--user-config-dir", settings, "run", "Echo something."],
{ cwd: project, env: { ...process.env, HOME: home, CODEWORK_MODELS_FILE: models } },
);
expect(result.status, result.stderr).toBe(0);

const answer = /echo: from the cli \(pid (\d+), cwd ([^)]+)\)/.exec(result.stdout);
expect(answer, result.stdout).not.toBeNull();
const [, pid, cwd] = answer!;
// The server ran on this machine, in the session's directory, and is gone once the CLI exits.
expect(cwd).toBe(realpathSync(project));
expect(() => process.kill(Number(pid), 0)).toThrow();

const declared = fake.requests[0]?.tools?.find((tool) => tool.function.name === "mcp__fixture__echo");
await expect(
`${JSON.stringify(
{
requests: fake.requests.length,
declared: declared?.function,
answer: answer![0].replace(pid!, "<pid>").replace(cwd!, "<project>"),
},
null,
"\t",
)}\n`,
).toMatchFileSnapshot("./__artifacts__/cli.mcp.json");
}, 60_000);
42 changes: 42 additions & 0 deletions packages/codework/test/fixtures/mcp-echo.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// A minimal stdio MCP server, written against the wire protocol rather than an SDK: one JSON-RPC
// message per line in, one per line out. It offers one tool, `echo`.
import { createInterface } from "node:readline";

const send = (message) => process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`);

const answer = (method, params) => {
switch (method) {
case "initialize":
return {
protocolVersion: params.protocolVersion,
capabilities: { tools: {} },
serverInfo: { name: "cli-fixture", version: "1.0.0" },
};
case "tools/list":
return {
tools: [
{
name: "echo",
description: "Echo text back, with the directory the server runs in.",
inputSchema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] },
},
],
};
case "tools/call":
return {
content: [
{ type: "text", text: `echo: ${params.arguments.text} (pid ${process.pid}, cwd ${process.cwd()})` },
],
};
default:
return {};
}
};

for await (const line of createInterface({ input: process.stdin })) {
if (!line.trim()) continue;
const message = JSON.parse(line);
// Notifications carry no id and get no answer.
if (message.id === undefined) continue;
send({ id: message.id, result: answer(message.method, message.params ?? {}) });
}
10 changes: 8 additions & 2 deletions packages/harness/src/effect/harness.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Effect, Layer, Option, Ref } from "effect";
import { type Duration, Effect, Layer, Option, Ref } from "effect";
import { Context } from "../context/context.ts";
import { Control } from "../control.ts";
import { Database } from "../db/db.ts";
Expand Down Expand Up @@ -64,6 +64,8 @@ export interface Options {
* Otherwise it is checked once, at boot.
*/
readonly watchModels?: boolean;
/** How long an MCP server no running session holds stays open. Default: 10 minutes. */
readonly mcpIdleTimeout?: Duration.Input;
}

export const layer = (options: Options = {}) =>
Expand Down Expand Up @@ -256,7 +258,11 @@ 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, home: paths.home }).pipe(Layer.provide(Process.host))),
Layer.provideMerge(
Mcp.layer({ hostCwd, home: paths.home, idleTimeout: options.mcpIdleTimeout }).pipe(
Layer.provide(Process.host),
),
),
Layer.provideMerge(Settings.layer(settingsOptions)),
Layer.provideMerge(SessionRuntime.layer),
Layer.provideMerge(sandboxes),
Expand Down
5 changes: 4 additions & 1 deletion packages/harness/src/mcp/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const EXPOSURES: ReadonlyArray<string> = ["codemode", "deferred", "direct", "hid
/** Older exposure names, read as their current one. */
const EXPOSURE_ALIASES: Readonly<Record<string, string>> = { "codemode-deferred": "codemode" };
const alias = (value: unknown) => (Predicate.isString(value) ? (EXPOSURE_ALIASES[value] ?? value) : value);
const SCOPES: ReadonlyArray<string> = ["session", "space", "app"];
const OVERRIDE_KEYS: ReadonlyArray<string> = ["enabled", "exposure", "toolExposure"];

/** Names that differ only in `-` and `_` would give their tools the same names. */
Expand All @@ -48,7 +49,7 @@ export const validate = (name: string, raw: unknown): ServerConfig | string => {
value.toolExposure = Object.fromEntries(
Object.entries(raw.toolExposure).map(([tool, one]) => [tool, alias(one)]),
);
const { type, exposure, enabled, timeout, toolExposure, description } = value;
const { type, exposure, enabled, timeout, toolExposure, description, scope } = 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}`;
Expand All @@ -60,6 +61,8 @@ export const validate = (name: string, raw: unknown): ServerConfig | string => {
}
}
if (enabled !== undefined && !Predicate.isBoolean(enabled)) return `server "${name}": enabled must be a boolean`;
if (scope !== undefined && !SCOPES.includes(scope as string))
return `server "${name}": scope must be one of ${SCOPES.map((one) => `"${one}"`).join(", ")}`;
if (description !== undefined && !Predicate.isString(description))
return `server "${name}": description must be a string`;
if (timeout !== undefined && !(Predicate.isNumber(timeout) && timeout > 0))
Expand Down
9 changes: 5 additions & 4 deletions packages/harness/src/mcp/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { CallToolResult, ContentBlock } from "@modelcontextprotocol/client"
import { Effect } from "effect";
import { Image } from "../tool/image/index.ts";
import { formatSize, truncateMiddle } from "../tool/truncate.ts";
import type { OutputError } from "./error.ts";

export type Content = Message.TextContent | Message.ImageContent;

Expand All @@ -13,7 +14,7 @@ export const MAX_BYTES = 20 * 1024;
export const READ_RESOURCE_TOOL = "read_mcp_resource";

/** Keeps data the model is not shown in full in a host file; `extension` includes the dot. */
export type Save = (data: string | Uint8Array, extension: string) => Effect.Effect<string, string>;
export type Save = (data: string | Uint8Array, extension: string) => Effect.Effect<string, OutputError>;

export interface Options {
readonly save: Save;
Expand Down Expand Up @@ -97,8 +98,8 @@ const fromBlock = Effect.fnUntraced(function* (
onSuccess: (path): ReadonlyArray<Content> => [
{ type: "text", text: `[Binary resource ${uri} (${kind}) saved to ${path}]` },
],
onFailure: (reason): ReadonlyArray<Content> => [
{ type: "text", text: `[Binary resource ${uri} (${kind}) could not be saved: ${reason}]` },
onFailure: (error): ReadonlyArray<Content> => [
{ type: "text", text: `[Binary resource ${uri} (${kind}) could not be saved: ${error.message}]` },
],
}),
);
Expand All @@ -117,7 +118,7 @@ export const limit = Effect.fnUntraced(function* (content: ReadonlyArray<Content
const where = yield* options.save(combined, ".txt").pipe(
Effect.match({
onSuccess: (path) => `[Full output: ${path} (read it with offset/limit)]`,
onFailure: (reason) => `[Could not save the full output: ${reason}]`,
onFailure: (error) => `[Could not save the full output: ${error.message}]`,
}),
);
const tokens = Math.ceil(cut.totalBytes / 4);
Expand Down
49 changes: 40 additions & 9 deletions packages/harness/src/mcp/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,53 @@ export class SessionExpiredError extends Schema.TaggedError<SessionExpiredError>
}
}

/** The thread runs no MCP server of this name. */
export class UnknownServerError extends Schema.TaggedError<UnknownServerError>()("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<RequestError>()("Mcp.RequestError", {
server: Schema.String,
method: Schema.String,
message: Schema.String,
}) {}

/**
* The server cannot take the request: disabled, shut down, not connected, waiting for sign-in, or no
* longer offering the tool. `message` is what the model reads.
*/
export class UnavailableError extends Schema.TaggedError<UnavailableError>()("Mcp.UnavailableError", {
server: Schema.String,
message: Schema.String,
}) {}

/** The thread has no MCP server of this name, or a turn replaced it meanwhile. */
export class UnknownServerError extends Schema.TaggedError<UnknownServerError>()("Mcp.UnknownServerError", {
server: Schema.String,
message: Schema.String,
}) {}

/** A tool call or resource request reached the server and failed. `message` is what the model reads. */
export class ToolCallError extends Schema.TaggedError<ToolCallError>()("Mcp.ToolCallError", {
server: Schema.String,
tool: Schema.String,
message: Schema.String,
}) {}

/** A resource tool was called with arguments it cannot use, or for a server without resources. */
export class ResourceError extends Schema.TaggedError<ResourceError>()("Mcp.ResourceError", {
message: Schema.String,
}) {}

/** A server's `env` or `headers` value could not be resolved. */
export class ValueError extends Schema.TaggedError<ValueError>()("Mcp.ValueError", {
message: Schema.String,
}) {}

/** Output the model is not shown in full could not be saved to a file. */
export class OutputError extends Schema.TaggedError<OutputError>()("Mcp.OutputError", {
message: Schema.String,
}) {}

/** What an MCP-backed tool can fail with; each says in `message` what happened. */
export type Failure = UnavailableError | ToolCallError | ResourceError;

/** A failure inside the stdio transport, reported to the SDK through `onerror` or a rejected promise. */
export class StdioError extends Schema.TaggedError<StdioError>()("Mcp.StdioError", {
message: Schema.String,
Expand Down
Loading
Loading