From 059e1b5550123f292730bc7ac1b66aafa3819d82 Mon Sep 17 00:00:00 2001
From: Jack <72348727+Jack-GitHub12@users.noreply.github.com>
Date: Wed, 11 Nov 2026 21:27:16 -0600
Subject: [PATCH 01/11] fix(mcp): follow the stdio and 2026-07-28 rules clients
check
Results of server/discover and the list and read methods carry ttlMs and
cacheScope. A request cancelled with notifications/cancelled gets no reply,
also when a synchronous read held the event loop while the cancel arrived.
A cursor on tools/list, prompts/list or resources/templates/list is -32602,
a request with a null id gets -32600 instead of no answer, a 2025-03-26
session accepts batches, and error text that quotes a huge argument is cut
to 4,000 characters around the middle.
---
packages/coding.sh/src/mcp/protocol.ts | 128 +++++++--
.../coding.sh/test/mcp/_blocking-server.ts | 29 +++
packages/coding.sh/test/mcp/protocol.test.ts | 243 ++++++++++++++++++
3 files changed, 376 insertions(+), 24 deletions(-)
create mode 100644 packages/coding.sh/test/mcp/_blocking-server.ts
create mode 100644 packages/coding.sh/test/mcp/protocol.test.ts
diff --git a/packages/coding.sh/src/mcp/protocol.ts b/packages/coding.sh/src/mcp/protocol.ts
index d635435a..1701129f 100644
--- a/packages/coding.sh/src/mcp/protocol.ts
+++ b/packages/coding.sh/src/mcp/protocol.ts
@@ -144,9 +144,39 @@ export interface RpcResponse {
const errorResponse = (id: RpcId | null, code: number, message: string, data?: unknown): RpcResponse => ({
jsonrpc: "2.0",
id,
- error: data === undefined ? { code, message } : { code, message, data },
+ error: data === undefined ? { code, message: clipText(message) } : { code, message: clipText(message), data },
});
+/** Longest error text sent back: an error that quotes a huge argument keeps its start and its advice. */
+export const ERROR_TEXT_MAX = 4000;
+
+/** `s` within `max` characters: its start and end, with how much was cut between them. */
+export function clipText(s: string, max = ERROR_TEXT_MAX): string {
+ if (s.length <= max) return s;
+ const keep = Math.floor((max - 40) / 2);
+ return `${s.slice(0, keep)} … [${s.length - 2 * keep} characters cut] … ${s.slice(-keep)}`;
+}
+
+/**
+ * Caching hints on 2026-07-28 results (https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching):
+ * the tool, prompt and template lists are fixed for the life of the process and hold no user data;
+ * resources are the user's sessions and change with every capture.
+ */
+const CACHE_HINTS: Record = {
+ "server/discover": { ttlMs: 3_600_000, cacheScope: "public" },
+ "tools/list": { ttlMs: 3_600_000, cacheScope: "public" },
+ "prompts/list": { ttlMs: 3_600_000, cacheScope: "public" },
+ "resources/templates/list": { ttlMs: 3_600_000, cacheScope: "public" },
+ "resources/list": { ttlMs: 0, cacheScope: "private" },
+ "resources/read": { ttlMs: 0, cacheScope: "private" },
+};
+
+/** These lists come back in one page, so any cursor is one this server never issued. */
+function noCursor(method: string, params: Obj): void {
+ if (params.cursor === undefined || params.cursor === null || params.cursor === "") return;
+ throw new RpcFailure(RPC.INVALID_PARAMS, `Invalid cursor: ${method} returns everything in one page; call it without a cursor.`);
+}
+
/** Compact text for a structured tool result (the spec's backward-compatible TextContent). */
export function toolText(structured: unknown): string {
return JSON.stringify(structured);
@@ -172,11 +202,31 @@ export class McpServer {
};
}
- /** Handles one decoded message. Returns the response, or null for notifications and stray responses. */
- async handle(msg: unknown): Promise {
- if (Array.isArray(msg)) return errorResponse(null, RPC.INVALID_REQUEST, "JSON-RPC batches are not supported; send one message per line");
+ /**
+ * Handles one decoded message. Returns the response, or null for notifications and stray responses.
+ * A batch (an array) is served only in a 2025-03-26 session, the one version whose spec has them;
+ * its reply is the array of its responses, or null when it held only notifications.
+ */
+ handle(msg: Obj): Promise;
+ handle(msg: unknown): Promise;
+ async handle(msg: unknown): Promise {
+ if (Array.isArray(msg)) {
+ if (this.legacyVersion !== "2025-03-26") return errorResponse(null, RPC.INVALID_REQUEST, "JSON-RPC batches are not supported; send one message per line");
+ if (!msg.length) return errorResponse(null, RPC.INVALID_REQUEST, "Invalid request: empty batch");
+ const replies: RpcResponse[] = [];
+ for (const m of msg) {
+ const r = Array.isArray(m) ? errorResponse(null, RPC.INVALID_REQUEST, "Invalid request: a batch holds JSON-RPC objects") : await this.handleOne(m);
+ if (r) replies.push(r);
+ }
+ return replies.length ? replies : null;
+ }
+ return this.handleOne(msg);
+ }
+
+ private async handleOne(msg: unknown): Promise {
if (!isObj(msg)) return errorResponse(null, RPC.INVALID_REQUEST, "Invalid request: expected a JSON-RPC object");
- const hasId = "id" in msg && msg.id !== undefined && msg.id !== null;
+ // A null id is still a request (JSON-RPC 2.0) and gets an answer; MCP ids are strings or numbers.
+ const hasId = "id" in msg && msg.id !== undefined;
const idOk = typeof msg.id === "string" || (typeof msg.id === "number" && Number.isFinite(msg.id));
if (typeof msg.method !== "string") {
// A response to a server→client request (we send none) or garbage.
@@ -208,7 +258,7 @@ export class McpServer {
}
}
const result = await this.dispatch(msg.method, params, modern);
- return { jsonrpc: "2.0", id, result: modern ? this.modernResult(result) : result };
+ return { jsonrpc: "2.0", id, result: modern ? this.modernResult(msg.method, result) : result };
} catch (e) {
if (e instanceof RpcFailure) return errorResponse(id, e.code, e.message, e.data);
this.o.log?.(`internal error in ${msg.method}: ${e instanceof Error ? (e.stack ?? e.message) : String(e)}`);
@@ -218,12 +268,12 @@ export class McpServer {
private notification(method: string): void {
if (method === "notifications/initialized") this.initialized = true;
- // notifications/cancelled, roots/list_changed…: requests finish quickly; nothing to do.
+ // notifications/cancelled is handled by the transport (serveStdio); roots/list_changed…: nothing to do.
}
- private modernResult(r: Obj): Obj {
+ private modernResult(method: string, r: Obj): Obj {
const meta = isObj(r._meta) ? r._meta : {};
- return { resultType: "complete", ...r, _meta: { ...meta, [`${META}serverInfo`]: this.o.serverInfo } };
+ return { resultType: "complete", ...CACHE_HINTS[method], ...r, _meta: { ...meta, [`${META}serverInfo`]: this.o.serverInfo } };
}
private async dispatch(method: string, params: Obj, modern: boolean): Promise {
@@ -240,6 +290,7 @@ export class McpServer {
case "ping":
return {};
case "tools/list":
+ noCursor(method, params);
return {
tools: this.o.tools.map((t) => ({
name: t.name,
@@ -261,6 +312,7 @@ export class McpServer {
case "resources/templates/list": {
const r = this.o.resources;
if (!r) throw new RpcFailure(RPC.METHOD_NOT_FOUND, "Method not found: resources/templates/list");
+ noCursor(method, params);
return { resourceTemplates: r.templates() };
}
case "resources/read": {
@@ -268,10 +320,11 @@ export class McpServer {
if (!r) throw new RpcFailure(RPC.METHOD_NOT_FOUND, "Method not found: resources/read");
if (typeof params.uri !== "string" || !params.uri) throw new RpcFailure(RPC.INVALID_PARAMS, "resources/read needs a uri");
const c = await r.read(params.uri);
- if (!c) throw new RpcFailure(modern ? RPC.INVALID_PARAMS : RPC.RESOURCE_NOT_FOUND, `Resource not found: ${params.uri}`, { uri: params.uri });
+ if (!c) throw new RpcFailure(modern ? RPC.INVALID_PARAMS : RPC.RESOURCE_NOT_FOUND, `Resource not found: ${params.uri}`, { uri: clipText(params.uri, 1000) });
return { contents: [c] };
}
case "prompts/list":
+ noCursor(method, params);
return { prompts: (this.o.prompts ?? []).map((p) => ({ name: p.name, ...(p.title ? { title: p.title } : {}), description: p.description, ...(p.arguments ? { arguments: p.arguments } : {}) })) };
case "prompts/get":
return this.getPrompt(params);
@@ -308,7 +361,7 @@ export class McpServer {
const structured = await tool.run(args, this.o.context());
return { content: [{ type: "text", text: toolText(structured) }], structuredContent: structured };
} catch (e) {
- if (e instanceof ToolError) return e.error ? { ...errorResult(e.message), structuredContent: { error: { message: e.message, ...e.error } } } : errorResult(e.message);
+ if (e instanceof ToolError) return e.error ? { ...errorResult(e.message), structuredContent: { error: { message: clipText(e.message), ...e.error } } } : errorResult(e.message);
this.o.log?.(`tool ${name} failed: ${e instanceof Error ? (e.stack ?? e.message) : String(e)}`);
return errorResult(`${name} failed: ${e instanceof Error ? e.message : String(e)}`);
}
@@ -316,7 +369,7 @@ export class McpServer {
}
function errorResult(text: string): Obj {
- return { content: [{ type: "text", text }], isError: true };
+ return { content: [{ type: "text", text: clipText(text) }], isError: true };
}
// ------------------------------------------------------------ stdio
@@ -329,31 +382,58 @@ export interface StdioStreams {
/**
* Serves newline-delimited JSON-RPC on the streams until input ends. Requests
* run one at a time in arrival order (handlers share one store connection).
+ * `notifications/cancelled` takes effect on arrival: a waiting request is
+ * skipped and a running one's reply is dropped, since the stdio transport says
+ * a cancelled request gets no further messages
+ * (https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio).
*/
export function serveStdio(server: McpServer, io: StdioStreams, log: (m: string) => void = () => {}, signal?: AbortSignal): Promise {
return new Promise((resolve) => {
let buf = "";
let chain = Promise.resolve();
let ended = false;
- const write = (m: RpcResponse) => {
+ const write = (m: RpcResponse | RpcResponse[]) => {
try {
io.output.write(JSON.stringify(m) + "\n");
} catch (e) {
log(`write failed: ${(e as Error).message}`);
}
};
+ // Requests waiting or running, by id. A cancel marks the entry; a marked request is never answered.
+ const pending = new Map();
+ // A synchronous store read holds the event loop, so a cancel sent meanwhile is still unread in the
+ // pipe. Two turns of the loop include one poll for input, which reads it before we start or answer.
+ const readInput = () => new Promise((r) => setImmediate(() => setImmediate(r)));
const onLine = (line: string) => {
- chain = chain.then(async () => {
- let msg: unknown;
- try {
- msg = JSON.parse(line);
- } catch {
- write(errorResponse(null, RPC.PARSE_ERROR, "Parse error: each line must be one JSON-RPC message"));
- return;
- }
- const r = await server.handle(msg);
- if (r) write(r);
- });
+ let msg: unknown;
+ let parsed = true;
+ try {
+ msg = JSON.parse(line);
+ } catch {
+ parsed = false;
+ }
+ if (parsed && isObj(msg) && msg.method === "notifications/cancelled" && msg.id === undefined) {
+ const id = isObj(msg.params) ? msg.params.requestId : undefined;
+ const entry = typeof id === "string" || typeof id === "number" ? pending.get(JSON.stringify(id)) : undefined;
+ if (entry) entry.cancelled = true;
+ return;
+ }
+ const key = parsed && isObj(msg) && typeof msg.method === "string" && (typeof msg.id === "string" || typeof msg.id === "number") ? JSON.stringify(msg.id) : undefined;
+ const entry = { cancelled: false };
+ if (key) pending.set(key, entry);
+ chain = chain
+ .then(async () => {
+ if (!parsed) return write(errorResponse(null, RPC.PARSE_ERROR, "Parse error: each line must be one JSON-RPC message"));
+ if (key) await readInput();
+ if (entry.cancelled) return;
+ const r = await server.handle(msg);
+ if (key && r) await readInput();
+ if (r && !entry.cancelled) write(r);
+ })
+ .catch((e: unknown) => log(`request failed: ${e instanceof Error ? e.message : String(e)}`))
+ .finally(() => {
+ if (key && pending.get(key) === entry) pending.delete(key);
+ });
};
io.output.on?.("error", (e: Error) => log(`stdout: ${e.message}`));
io.input.setEncoding?.("utf8");
diff --git a/packages/coding.sh/test/mcp/_blocking-server.ts b/packages/coding.sh/test/mcp/_blocking-server.ts
new file mode 100644
index 00000000..6da4a05e
--- /dev/null
+++ b/packages/coding.sh/test/mcp/_blocking-server.ts
@@ -0,0 +1,29 @@
+// An MCP server on stdio whose `block` tool holds the event loop for `ms`
+// milliseconds, the way a synchronous SQLite read does. protocol.test.ts sends
+// a cancel while it blocks.
+import { McpServer, serveStdio } from "../../src/mcp/protocol.ts";
+
+const server = new McpServer({
+ serverInfo: { name: "blocking", version: "0" },
+ instructions: () => "",
+ tools: [
+ {
+ name: "block",
+ title: "Block",
+ description: "Busy-waits for ms milliseconds.",
+ inputSchema: { type: "object", properties: { ms: { type: "number" } }, additionalProperties: false },
+ outputSchema: { type: "object" },
+ annotations: { readOnlyHint: true },
+ run: async (a) => {
+ const end = Date.now() + Number(a.ms ?? 0);
+ while (Date.now() < end) {
+ // hold the event loop
+ }
+ return { done: true };
+ },
+ },
+ ],
+ context: () => undefined,
+});
+
+await serveStdio(server, { input: process.stdin, output: process.stdout });
diff --git a/packages/coding.sh/test/mcp/protocol.test.ts b/packages/coding.sh/test/mcp/protocol.test.ts
new file mode 100644
index 00000000..bcf34224
--- /dev/null
+++ b/packages/coding.sh/test/mcp/protocol.test.ts
@@ -0,0 +1,243 @@
+// The MCP core against the spec rules a client can observe: caching hints on
+// 2026-07-28 list and read results, cancellation, invalid cursors, a null id,
+// 2025-03-26 batches and error text kept short when an argument is huge.
+import assert from "node:assert/strict";
+import { spawn } from "node:child_process";
+import { PassThrough } from "node:stream";
+import { describe, it } from "node:test";
+import { McpServer, serveStdio, ToolError, type ToolDef } from "../../src/mcp/protocol.ts";
+
+type Msg = { jsonrpc: string; id: unknown; result?: Record; error?: { code: number; message: string; data?: Record } };
+
+const META = "io.modelcontextprotocol/";
+const modern = { [`${META}protocolVersion`]: "2026-07-28", [`${META}clientCapabilities`]: {} };
+
+/** A server with a `slow` tool that waits for release(), an `echo` tool that fails with its input, and one resource. */
+function world() {
+ const gates: (() => void)[] = [];
+ const slow: ToolDef = {
+ name: "slow",
+ title: "Slow",
+ description: "Waits until the test releases it.",
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
+ outputSchema: { type: "object" },
+ annotations: { readOnlyHint: true },
+ run: () => new Promise((r) => gates.push(() => r({ done: true }))),
+ };
+ const echo: ToolDef = {
+ name: "echo",
+ title: "Echo",
+ description: "Fails with a message that quotes its input.",
+ inputSchema: { type: "object", properties: { text: { type: "string" } }, additionalProperties: false },
+ outputSchema: { type: "object" },
+ annotations: { readOnlyHint: true },
+ run: (a) => Promise.reject(new ToolError(`No session with id ${JSON.stringify(a.text)}. Find ids with sessions_list.`)),
+ };
+ const server = new McpServer({
+ serverInfo: { name: "t", version: "0" },
+ instructions: () => "test",
+ tools: [slow, echo],
+ resources: {
+ list: async () => ({ resources: [{ uri: "t://one", name: "one" }] }),
+ templates: () => [{ uriTemplate: "t://{id}", name: "by id" }],
+ read: async (uri) => (uri === "t://one" ? { uri, mimeType: "text/plain", text: "one" } : undefined),
+ },
+ prompts: [{ name: "p", description: "A prompt.", render: () => "hi" }],
+ context: () => undefined,
+ });
+ const input = new PassThrough();
+ const output = new PassThrough();
+ const out: Msg[] = [];
+ /** Batch replies: one array line each. */
+ const batches: Msg[][] = [];
+ let buf = "";
+ output.setEncoding("utf8");
+ output.on("data", (c: string) => {
+ buf += c;
+ let i: number;
+ while ((i = buf.indexOf("\n")) !== -1) {
+ const m = JSON.parse(buf.slice(0, i)) as Msg | Msg[];
+ if (Array.isArray(m)) batches.push(m);
+ else out.push(m);
+ buf = buf.slice(i + 1);
+ }
+ });
+ const done = serveStdio(server, { input, output });
+ const send = (m: unknown) => input.write((typeof m === "string" ? m : JSON.stringify(m)) + "\n");
+ const tick = () => new Promise((r) => setTimeout(r, 20));
+ /** Waits until a reply with this id arrives (or 2 s pass). */
+ const reply = async (id: unknown): Promise => {
+ for (let i = 0; i < 100; i++) {
+ const m = out.find((x) => JSON.stringify(x.id) === JSON.stringify(id));
+ if (m) return m;
+ await tick();
+ }
+ return undefined;
+ };
+ const release = async () => {
+ for (let i = 0; i < 100 && !gates.length; i++) await tick();
+ gates.shift()!();
+ };
+ return { server, send, out, batches, reply, tick, release, close: async () => (input.end(), done) };
+}
+
+describe("caching hints (2026-07-28)", () => {
+ it("every list, read and discover result carries ttlMs and cacheScope", async () => {
+ const w = world();
+ const cases: [string, Record, string][] = [
+ ["server/discover", {}, "public"],
+ ["tools/list", {}, "public"],
+ ["prompts/list", {}, "public"],
+ ["resources/templates/list", {}, "public"],
+ ["resources/list", {}, "private"],
+ ["resources/read", { uri: "t://one" }, "private"],
+ ];
+ let id = 0;
+ for (const [method, params, scope] of cases) {
+ w.send({ jsonrpc: "2.0", id: ++id, method, params: { ...params, _meta: modern } });
+ const r = await w.reply(id);
+ assert.equal(r?.result?.resultType, "complete", method);
+ assert.equal(typeof r?.result?.ttlMs, "number", `${method} ttlMs`);
+ assert.ok((r!.result!.ttlMs as number) >= 0, `${method} ttlMs >= 0`);
+ assert.equal(r?.result?.cacheScope, scope, `${method} cacheScope`);
+ }
+ // Legacy results stay as they were.
+ w.send({ jsonrpc: "2.0", id: "legacy", method: "tools/list" });
+ const legacy = await w.reply("legacy");
+ assert.equal(legacy?.result?.ttlMs, undefined);
+ await w.close();
+ });
+});
+
+describe("cancellation (notifications/cancelled)", () => {
+ it("a request cancelled before it runs gets no reply", async () => {
+ const w = world();
+ w.send({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "slow" } });
+ w.send({ jsonrpc: "2.0", id: 2, method: "ping" });
+ w.send({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: 2, reason: "test" } });
+ await w.release();
+ assert.ok(await w.reply(1), "the running request is answered");
+ w.send({ jsonrpc: "2.0", id: 3, method: "ping" });
+ assert.ok(await w.reply(3), "later requests are answered");
+ assert.equal(w.out.find((m) => m.id === 2), undefined, "the cancelled request got no reply");
+ await w.close();
+ });
+
+ it("a request cancelled while it runs gets no reply", async () => {
+ const w = world();
+ w.send({ jsonrpc: "2.0", id: "a", method: "tools/call", params: { name: "slow" } });
+ await w.tick();
+ w.send({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: "a" } });
+ await w.tick();
+ await w.release();
+ w.send({ jsonrpc: "2.0", id: "b", method: "ping" });
+ assert.ok(await w.reply("b"));
+ assert.equal(w.out.find((m) => m.id === "a"), undefined, "the cancelled request got no reply");
+ // A cancel for an unknown or finished request is ignored.
+ w.send({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: "b" } });
+ w.send({ jsonrpc: "2.0", id: "c", method: "ping" });
+ assert.ok(await w.reply("c"));
+ await w.close();
+ });
+});
+
+describe("cancellation while a tool holds the event loop", () => {
+ // A synchronous store read blocks the loop, so a cancel sits unread in the pipe until it ends.
+ it("reads the cancel before it answers or starts the next request", async () => {
+ const child = spawn(process.execPath, ["--no-warnings", new URL("./_blocking-server.ts", import.meta.url).pathname], { stdio: ["pipe", "pipe", "inherit"] });
+ const ids: unknown[] = [];
+ let buf = "";
+ child.stdout.setEncoding("utf8");
+ child.stdout.on("data", (c: string) => {
+ buf += c;
+ let i: number;
+ while ((i = buf.indexOf("\n")) !== -1) {
+ ids.push((JSON.parse(buf.slice(0, i)) as Msg).id);
+ buf = buf.slice(i + 1);
+ }
+ });
+ const send = (m: unknown) => child.stdin.write(JSON.stringify(m) + "\n");
+ const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
+ const exited = new Promise((r) => child.on("exit", r));
+ send({ jsonrpc: "2.0", id: "warm", method: "ping" });
+ for (let i = 0; i < 200 && !ids.includes("warm"); i++) await wait(10);
+ // "run" is cancelled while it blocks; "queued" is cancelled while it waits behind "first".
+ send({ jsonrpc: "2.0", id: "run", method: "tools/call", params: { name: "block", arguments: { ms: 400 } } });
+ await wait(100);
+ send({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: "run" } });
+ await wait(500);
+ send({ jsonrpc: "2.0", id: "first", method: "tools/call", params: { name: "block", arguments: { ms: 400 } } });
+ await wait(100);
+ send({ jsonrpc: "2.0", id: "queued", method: "ping" });
+ await wait(50);
+ send({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: "queued" } });
+ await wait(500);
+ send({ jsonrpc: "2.0", id: "last", method: "ping" });
+ for (let i = 0; i < 200 && !ids.includes("last"); i++) await wait(10);
+ child.stdin.end();
+ await exited;
+ assert.deepEqual(ids, ["warm", "first", "last"]);
+ });
+});
+
+describe("JSON-RPC edges", () => {
+ it("rejects a cursor on lists that have one page", async () => {
+ const w = world();
+ for (const [i, method] of ["tools/list", "prompts/list", "resources/templates/list"].entries()) {
+ w.send({ jsonrpc: "2.0", id: i, method, params: { cursor: "not-a-cursor" } });
+ const r = await w.reply(i);
+ assert.equal(r?.error?.code, -32602, `${method}: ${JSON.stringify(r)}`);
+ }
+ await w.close();
+ });
+
+ it("answers a request whose id is null with an invalid-request error", async () => {
+ const w = world();
+ w.send({ jsonrpc: "2.0", id: null, method: "tools/list" });
+ const r = await w.reply(null);
+ assert.equal(r?.error?.code, -32600);
+ await w.close();
+ });
+
+ it("accepts batches after a 2025-03-26 handshake, and only then", async () => {
+ const w = world();
+ w.send([{ jsonrpc: "2.0", id: "x", method: "ping" }]);
+ const before = await w.reply(null);
+ assert.equal(before?.error?.code, -32600, "no batches before the handshake");
+ w.send({ jsonrpc: "2.0", id: 0, method: "initialize", params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "t", version: "0" } } });
+ assert.equal((await w.reply(0))?.result?.protocolVersion, "2025-03-26");
+ const n = w.out.length;
+ w.send([{ jsonrpc: "2.0", id: 1, method: "ping" }, { jsonrpc: "2.0", method: "notifications/initialized" }, { jsonrpc: "2.0", id: 2, method: "no/such" }]);
+ for (let i = 0; i < 100 && !w.batches.length; i++) await w.tick();
+ // The reply is one array line with a response per request, none for the notification.
+ const batch = w.batches[0]!;
+ assert.ok(batch, "a batch reply");
+ assert.deepEqual(batch.map((m) => m.id), [1, 2]);
+ assert.deepEqual(batch[0]!.result, {});
+ assert.equal(batch[1]!.error!.code, -32601);
+ w.send([]);
+ for (let i = 0; i < 100 && w.out.length === n; i++) await w.tick();
+ assert.equal(w.out[n]!.error?.code, -32600, "an empty batch is invalid");
+ await w.close();
+ });
+
+ it("keeps error text short when an argument is huge", async () => {
+ const w = world();
+ const huge = "h".repeat(1_000_000);
+ w.send({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "echo", arguments: { text: huge } } });
+ const tool = await w.reply(1);
+ const text = (tool!.result!.content as { text: string }[])[0]!.text;
+ assert.equal(tool!.result!.isError, true);
+ assert.ok(text.length < 5000, `tool error is ${text.length} chars`);
+ assert.match(text, /^No session with id "hhh/);
+ assert.match(text, /Find ids with sessions_list\.$/, "the advice at the end survives");
+ w.send({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: huge } });
+ const rpc = await w.reply(2);
+ assert.equal(rpc!.error!.code, -32602);
+ assert.ok(rpc!.error!.message.length < 5000, `rpc error is ${rpc!.error!.message.length} chars`);
+ w.send({ jsonrpc: "2.0", id: 3, method: "resources/read", params: { uri: `t://${huge}` } });
+ const res = await w.reply(3);
+ assert.ok(JSON.stringify(res).length < 10_000, `resource error is ${JSON.stringify(res).length} chars`);
+ await w.close();
+ });
+});
From ad896cb8597ba95ebbd2749e0e01290fc0bcf5d6 Mon Sep 17 00:00:00 2001
From: Jack <72348727+Jack-GitHub12@users.noreply.github.com>
Date: Wed, 11 Nov 2026 21:27:55 -0600
Subject: [PATCH 02/11] fix(mcp): keep tool answers under 25,000 characters
A read tool with a limit runs again with fewer rows while its text is over
25,000 characters, and the answer carries limitUsed. Rows are dropped, never
cut, so nextCursor still starts at the first row left out. session_get at
limit 200 was 40,000 characters on a long session and session_replay's step
list 227,000; session_replay now shows 16 KB of a file or diff (was 64 KB),
and eval_candidates returns at most limit rejected sessions.
---
packages/coding.sh/src/mcp/server.ts | 1 +
packages/coding.sh/src/mcp/tools.ts | 50 ++++++-
packages/coding.sh/test/mcp/limits.test.ts | 127 ++++++++++++++++++
.../coding.sh/test/mcp/session-tree.test.ts | 13 +-
4 files changed, 181 insertions(+), 10 deletions(-)
create mode 100644 packages/coding.sh/test/mcp/limits.test.ts
diff --git a/packages/coding.sh/src/mcp/server.ts b/packages/coding.sh/src/mcp/server.ts
index b22c73b0..fe0df128 100644
--- a/packages/coding.sh/src/mcp/server.ts
+++ b/packages/coding.sh/src/mcp/server.ts
@@ -44,6 +44,7 @@ const BASE_INSTRUCTIONS = [
"To build evals for the user, call eval_init (a suite in evals// from failed sessions, a skill's trigger prompts, or cases you write), then tell them its run command.",
"eval_from_session, eval_init and exp_session_note write; ask the user before calling eval_from_session or eval_init with confirm: true.",
'A result with pending: {files, indexing} was answered before every capture was indexed: it covers what is indexed so far; call again shortly for the rest.',
+ "Answers stay under 25,000 characters: one that would be longer has fewer rows and limitUsed; follow nextCursor for the rest.",
].join(" ");
/** Server instructions, with a one-line notice when the update checker found a newer release. */
diff --git a/packages/coding.sh/src/mcp/tools.ts b/packages/coding.sh/src/mcp/tools.ts
index 54a00451..6f391411 100644
--- a/packages/coding.sh/src/mcp/tools.ts
+++ b/packages/coding.sh/src/mcp/tools.ts
@@ -39,7 +39,7 @@ import {
import { sessionDigest } from "./digest.ts";
import { facetCounts, searchWhere } from "../query/run.ts";
import { andQuery, FACET_KEYS, parseWhere, QueryError, QueryUnsupported, type Query } from "../query/where.ts";
-import { ToolError, type ToolDef } from "./protocol.ts";
+import { toolText, ToolError, type ToolDef } from "./protocol.ts";
import { scoreTools } from "../scores/mcp.ts";
type Obj = Record;
@@ -131,6 +131,42 @@ function treeNodeJson(n: TreeNode, top = false): Obj {
// ------------------------------------------------------------ helpers
+/** The longest answer a tool sends, in characters of its text (mcp-builder's CHARACTER_LIMIT). */
+export const CHARACTER_LIMIT = 25_000;
+
+/**
+ * The page `render(limit)` makes, with fewer rows while its text is over CHARACTER_LIMIT. Rows are
+ * dropped, never cut, so a cursor of offset + n still points at the first row left out. A shortened
+ * page carries `limitUsed`. When no smaller limit makes it shorter (the size is not in the rows, or
+ * one row is that long), the shortest page comes back whole.
+ */
+export async function fitPage(limit: number, render: (n: number) => Obj | Promise): Promise {
+ let page = await render(limit);
+ let len = toolText(page).length;
+ let used: number | undefined;
+ // n can start above the rows left (the last page), so a try that is no shorter only halves n.
+ for (let n = limit; len > CHARACTER_LIMIT && n > 1; ) {
+ n = Math.max(1, Math.min(Math.floor(n / 2), Math.floor((n * CHARACTER_LIMIT * 0.9) / len)));
+ const smaller = await render(n);
+ const smallerLen = toolText(smaller).length;
+ if (smallerLen < len) [page, len, used] = [smaller, smallerLen, n];
+ }
+ return used === undefined ? page : { ...page, limitUsed: used };
+}
+
+/** A read-only tool with a `limit` argument, run again with a smaller limit when its answer is too long. */
+function withCharacterLimit(t: ToolDef): ToolDef {
+ const limit = (t.inputSchema.properties as Record | undefined)?.limit;
+ if (!limit || t.annotations.readOnlyHint !== true || t.annotations.idempotentHint !== true) return t;
+ return {
+ ...t,
+ run(a, c) {
+ const asked = typeof a.limit === "number" ? a.limit : typeof limit.default === "number" ? limit.default : undefined;
+ return asked === undefined ? t.run(a, c) : fitPage(asked, (n) => t.run(n === asked ? a : { ...a, limit: n }, c));
+ },
+ };
+}
+
const READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } as const;
function strArg(a: Obj, k: string): string | undefined {
@@ -300,6 +336,10 @@ function lastSpoolError(file: string): Obj | undefined {
// ------------------------------------------------------------ tools
export function buildTools(): ToolDef[] {
+ return toolDefs().map(withCharacterLimit);
+}
+
+function toolDefs(): ToolDef[] {
return [
{
name: "sessions_list",
@@ -428,7 +468,8 @@ export function buildTools(): ToolDef[] {
const ctx = await c.store();
const s = await resolveSession(ctx, strArg(a, "sessionId")!);
const redactor = createRedactor({ redact: ctx.config.capture.redact, patterns: ctx.config.capture.redactPatterns, env: ctx.env });
- const replay = await loadReplay(ctx, s, { baseline: snapshotBaseline(s, ctx.env, redactor), maxContentBytes: 64 * 1024, maxDiffBytes: 64 * 1024 });
+ // 16 KB of a file and of each diff keeps a step's view near CHARACTER_LIMIT; contentTruncated and diffTruncated say when they were cut.
+ const replay = await loadReplay(ctx, s, { baseline: snapshotBaseline(s, ctx.env, redactor), maxContentBytes: 16 * 1024, maxDiffBytes: 16 * 1024 });
const total = replay.steps.length;
const stepArg = (k: string): number | undefined => {
const v = a[k];
@@ -453,8 +494,7 @@ export function buildTools(): ToolDef[] {
const index = replay.index();
const offset = cursorArg(a);
const limit = numArg(a, "limit", 100);
- const page = index.steps.slice(offset, offset + limit);
- return { ...index, steps: page, total, ...(offset + limit < total ? { nextCursor: String(offset + limit) } : {}) };
+ return fitPage(limit, (n) => ({ ...index, steps: index.steps.slice(offset, offset + n), total, ...(offset + n < total ? { nextCursor: String(offset + n) } : {}) }));
},
},
{
@@ -898,7 +938,7 @@ export function buildTools(): ToolDef[] {
const ranked = await rankSessions(ctx.index, pool, { now });
const eligible = ranked.filter((x) => x.eligible);
const limit = numArg(a, "limit", 10);
- const shown = [...eligible.slice(0, limit), ...(a.includeRejected === true ? ranked.filter((x) => !x.eligible) : [])];
+ const shown = [...eligible.slice(0, limit), ...(a.includeRejected === true ? ranked.filter((x) => !x.eligible).slice(0, limit) : [])];
return {
candidates: shown.map((x) => ({ ...x, title: clip(x.title, 100), startedAt: new Date(x.startedAt).toISOString() })),
considered: ranked.length,
diff --git a/packages/coding.sh/test/mcp/limits.test.ts b/packages/coding.sh/test/mcp/limits.test.ts
new file mode 100644
index 00000000..e4231a84
--- /dev/null
+++ b/packages/coding.sh/test/mcp/limits.test.ts
@@ -0,0 +1,127 @@
+// Tool answers stay under the 25,000-character limit by returning fewer rows
+// (never by cutting text), the cursor still walks every row, and errors that
+// quote a huge argument stay short.
+import assert from "node:assert/strict";
+import { after, before, describe, it } from "node:test";
+import { openContext } from "../../src/core/context.ts";
+import { buildSession } from "../../src/core/sessions.ts";
+import { CHARACTER_LIMIT } from "../../src/mcp/tools.ts";
+import type { AgentEvent } from "../../src/schema/index.ts";
+import { RawClient, cleanup, makeWorld, seed, type World } from "./helpers.ts";
+
+const BIG = "sess-big";
+const CALLS = 600;
+
+let w: World;
+let c: RawClient;
+
+type Result = { structuredContent?: Record; content: { type: string; text: string }[]; isError?: boolean };
+
+async function call(name: string, args: Record): Promise {
+ const r = await c.call(name, args);
+ assert.notEqual(r.isError, true, `${name}: ${r.content[0]?.text.slice(0, 300)}`);
+ return r;
+}
+
+before(async () => {
+ w = makeWorld();
+ await seed(w);
+ // One long session: CALLS Bash calls with 4 KB outputs.
+ const ctx = await openContext({ env: w.env, cwd: w.root });
+ const t0 = w.now - 3 * 3600e3;
+ const base = { v: 0 as const, sessionId: BIG, agent: "claude-code", cwd: w.repo, model: "claude-opus-4-5" };
+ const evs: AgentEvent[] = [
+ { ...base, id: "big-0", ts: t0, kind: "session.start", payload: {} },
+ { ...base, id: "big-1", ts: t0 + 1, kind: "user.message", payload: { text: "Run the migration" } },
+ ];
+ for (let i = 0; i < CALLS; i++) {
+ evs.push({ ...base, id: `big-s${i}`, ts: t0 + 10 + i * 10, kind: "tool.start", payload: { tool: "Bash", toolUseId: `b${i}`, input: { command: `echo needle-${i} ${"y".repeat(2000)}` } } });
+ evs.push({ ...base, id: `big-e${i}`, ts: t0 + 15 + i * 10, kind: "tool.end", payload: { tool: "Bash", toolUseId: `b${i}`, ok: true, output: `needle out ${i} ${"z".repeat(4000)}` }, durationMs: 5 });
+ }
+ evs.push({ ...base, id: "big-end", ts: t0 + 20 * CALLS, kind: "session.end", payload: {} });
+ await ctx.index.putEvents(evs);
+ await ctx.index.upsertSession(buildSession(evs));
+ await ctx.close();
+ c = new RawClient(w.env, w.repo);
+ await c.initialize();
+});
+
+after(async () => {
+ await c?.close();
+ cleanup();
+});
+
+describe("the 25,000-character limit", () => {
+ it("session_get returns fewer timeline entries, and the cursor walks all of them", async () => {
+ const seen: number[] = [];
+ let cursor: string | undefined;
+ for (let page = 0; page < 200; page++) {
+ const args: Record = { sessionId: BIG, limit: 200 };
+ if (cursor) args.cursor = cursor;
+ const r = await call("session_get", args);
+ assert.ok(r.content[0]!.text.length <= CHARACTER_LIMIT, `page ${page} is ${r.content[0]!.text.length} chars`);
+ const sc = r.structuredContent!;
+ for (const e of sc.timeline as { at: number }[]) seen.push(e.at);
+ cursor = sc.nextCursor as string | undefined;
+ if (!cursor) break;
+ assert.ok((sc.limitUsed as number) < 200, "a shortened page says which limit it used");
+ }
+ assert.equal(cursor, undefined, "the cursor reached the end");
+ assert.equal(new Set(seen).size, seen.length, "no entry is repeated");
+ const all = await call("session_get", { sessionId: BIG, limit: 200, tail: true });
+ assert.ok(all.content[0]!.text.length <= CHARACTER_LIMIT);
+ assert.ok(seen.length > CALLS, `every tool call is in the walk (${seen.length})`);
+ });
+
+ it("session_replay pages its step list under the limit", async () => {
+ let cursor: string | undefined;
+ let steps = 0;
+ let total = 0;
+ for (let page = 0; page < 200; page++) {
+ const args: Record = { sessionId: BIG, limit: 1000 };
+ if (cursor) args.cursor = cursor;
+ const r = await call("session_replay", args);
+ assert.ok(r.content[0]!.text.length <= CHARACTER_LIMIT, `page ${page} is ${r.content[0]!.text.length} chars`);
+ const sc = r.structuredContent!;
+ steps += (sc.steps as unknown[]).length;
+ total = sc.total as number;
+ cursor = sc.nextCursor as string | undefined;
+ if (!cursor) break;
+ }
+ assert.ok(total > 0);
+ assert.equal(steps, total, "the pages hold every step once");
+ });
+
+ it("search, sessions_list and session_current stay under the limit", async () => {
+ for (const [name, args] of [
+ ["search", { query: "needle", limit: 50 }],
+ ["sessions_list", { limit: 100, includeSubagents: true }],
+ ["session_current", { limit: 100 }],
+ ] as const) {
+ const r = await call(name, args);
+ assert.ok(r.content[0]!.text.length <= CHARACTER_LIMIT, `${name} is ${r.content[0]!.text.length} chars`);
+ }
+ });
+
+ it("eval_candidates returns at most limit rejected sessions", async () => {
+ const r = await call("eval_candidates", { limit: 1, includeRejected: true, repo: "all", since: "30d" });
+ const sc = r.structuredContent as { candidates: { eligible: boolean }[]; considered: number; eligible: number };
+ assert.ok(sc.considered - sc.eligible > 1, `the seed has more than one rejected session (${sc.considered - sc.eligible})`);
+ assert.ok(sc.candidates.filter((x) => !x.eligible).length <= 1, `${sc.candidates.filter((x) => !x.eligible).length} rejected sessions for limit 1`);
+ });
+});
+
+describe("tool errors", () => {
+ it("quote a huge argument in a short message", async () => {
+ const huge = "h".repeat(1_000_000);
+ for (const [name, args] of [
+ ["session_get", { sessionId: huge }],
+ ["benchmarks", { name: huge }],
+ ["harness_compare", { experiment: huge }],
+ ] as const) {
+ const r = await c.call(name, args);
+ assert.equal(r.isError, true, name);
+ assert.ok(r.content[0]!.text.length < 5000, `${name} error is ${r.content[0]!.text.length} chars`);
+ }
+ });
+});
diff --git a/packages/coding.sh/test/mcp/session-tree.test.ts b/packages/coding.sh/test/mcp/session-tree.test.ts
index 911f7020..fea679dc 100644
--- a/packages/coding.sh/test/mcp/session-tree.test.ts
+++ b/packages/coding.sh/test/mcp/session-tree.test.ts
@@ -10,6 +10,7 @@ import { openContext } from "../../src/core/context.ts";
import { buildSession } from "../../src/core/sessions.ts";
import { pageTree, type TreeNode } from "../../src/core/tree.ts";
import { createCodingShMcp, type CodingShMcp } from "../../src/mcp/server.ts";
+import { CHARACTER_LIMIT } from "../../src/mcp/tools.ts";
import type { AgentEvent, Session } from "../../src/schema/index.ts";
import { schemaViolations } from "../../src/toolevals/jsonschema.ts";
import { PKG, cleanup, makeWorld, type World } from "./helpers.ts";
@@ -58,14 +59,14 @@ let w: World;
let mcp: CodingShMcp;
let outputSchema: Record;
-async function tree(args: Record): Promise<{ page: Page; bytes: number }> {
+async function tree(args: Record): Promise<{ page: Page; bytes: number; chars: number }> {
const r = await mcp.server.handle({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "session_tree", arguments: args } });
const res = r!.result as { structuredContent: Page; isError?: boolean; content: { text: string }[] };
assert.notEqual(res.isError, true, res.content?.[0]?.text);
const page = JSON.parse(JSON.stringify(res.structuredContent)) as Page;
assert.deepEqual(schemaViolations(page, outputSchema), [], "session_tree output matches its outputSchema");
// What the agent reads: the whole tool result, text and structured content.
- return { page, bytes: Buffer.byteLength(JSON.stringify(r!.result)) };
+ return { page, bytes: Buffer.byteLength(JSON.stringify(r!.result)), chars: res.content[0]!.text.length };
}
before(async () => {
@@ -114,15 +115,17 @@ describe("session_tree on a run with 800 subagents", () => {
let cursor: string | undefined;
let calls = 0;
do {
- const { page } = await tree(cursor ? { sessionId: ROOT, limit: 200, cursor } : { sessionId: ROOT, limit: 200 });
+ const { page, chars } = await tree(cursor ? { sessionId: ROOT, limit: 200, cursor } : { sessionId: ROOT, limit: 200 });
calls++;
+ assert.ok(chars <= CHARACTER_LIMIT, `page ${calls} is ${chars} characters`);
assert.equal(page.totals.sessions, NODES);
if (cursor) for (const top of page.tree) assert.deepEqual({ parent: top.parentSessionId, depth: top.depth }, { parent: ROOT, depth: 1 });
seen.push(...flat(page.tree).map((x) => x.sessionId!));
cursor = page.nextCursor;
assert.equal(page.truncated, cursor ? true : undefined);
- } while (cursor && calls < 10);
- assert.equal(calls, 5, "802 nodes at 200 a page");
+ } while (cursor && calls < 50);
+ // 200 nodes are over the 25,000-character limit, so each page holds fewer.
+ assert.ok(calls > 5, `802 nodes in ${calls} pages`);
assert.equal(seen.length, NODES);
assert.equal(new Set(seen).size, NODES, "no node twice");
assert.deepEqual(seen.slice(0, 3), [ROOT, child(0), GRAND], "depth-first, as coding.sh tree prints it");
From ac0f36afec03568cc444eb9f1a1acbe64bcda259 Mon Sep 17 00:00:00 2001
From: Jack <72348727+Jack-GitHub12@users.noreply.github.com>
Date: Wed, 11 Nov 2026 21:28:05 -0600
Subject: [PATCH 03/11] fix(mcp): mark eval_init and score_add destructive
eval_init with force replaces a suite, and score_add replaces the score
stored for the same target, name, source and person, so both now say
destructiveHint: true. score_list's error for a bad since names the
argument instead of the CLI's --since.
---
packages/coding.sh/src/mcp/tools.ts | 3 ++-
packages/coding.sh/src/scores/mcp.ts | 7 ++++---
packages/coding.sh/test/mcp/server.test.ts | 3 ++-
packages/coding.sh/test/scores/cli.test.ts | 3 ++-
4 files changed, 10 insertions(+), 6 deletions(-)
diff --git a/packages/coding.sh/src/mcp/tools.ts b/packages/coding.sh/src/mcp/tools.ts
index 6f391411..3795a49a 100644
--- a/packages/coding.sh/src/mcp/tools.ts
+++ b/packages/coding.sh/src/mcp/tools.ts
@@ -847,7 +847,8 @@ function toolDefs(): ToolDef[] {
},
["name", "dir", "kind", "files", "run"],
),
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
+ // destructive: force replaces an existing suite.
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
async run(a, c) {
if (a.confirm !== true) throw new ToolError("Not written: this writes an eval suite into the user's project. Ask the user, then call again with confirm: true.");
const { initSuite, InitError } = await import("../eval/init.ts");
diff --git a/packages/coding.sh/src/scores/mcp.ts b/packages/coding.sh/src/scores/mcp.ts
index 9ff502f5..a6cc3b2b 100644
--- a/packages/coding.sh/src/scores/mcp.ts
+++ b/packages/coding.sh/src/scores/mcp.ts
@@ -60,8 +60,8 @@ export function scoreTools(): ToolDef[] {
if (typeof a.since === "string" && a.since) {
try {
f.since = parseSince(a.since, c.now());
- } catch (e) {
- throw new ToolError(e instanceof Error ? e.message : String(e));
+ } catch {
+ throw new ToolError(`Invalid since ${JSON.stringify(a.since)}: use a duration like 30m, 12h, 7d, today, yesterday or a date like 2026-01-31 (local time).`);
}
}
return { items: await listScores(ctx.index, f).catch((e) => Promise.reject(toolError(e))) };
@@ -88,7 +88,8 @@ export function scoreTools(): ToolDef[] {
additionalProperties: false,
},
outputSchema: { type: "object", properties: { score: SCORE_OUT, created: B, changed: B }, required: ["score"] },
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
+ // destructive: a score for the same target, name, source and person replaces the one stored.
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
async run(a: Obj, c: ToolCtx) {
const ctx = await c.store();
const body: Obj = { ...a };
diff --git a/packages/coding.sh/test/mcp/server.test.ts b/packages/coding.sh/test/mcp/server.test.ts
index da39cd89..43afd68e 100644
--- a/packages/coding.sh/test/mcp/server.test.ts
+++ b/packages/coding.sh/test/mcp/server.test.ts
@@ -104,7 +104,8 @@ describe("handshake (legacy era)", () => {
for (const [k, p] of Object.entries((t.inputSchema.properties ?? {}) as Record)) assert.ok(p.description, `${t.name}.${k} has a description`);
assert.equal(t.outputSchema.type, "object", `${t.name} outputSchema`);
assert.equal(t.annotations.readOnlyHint, !["eval_from_session", "eval_init", "exp_session_note", "score_add"].includes(t.name), `${t.name} readOnlyHint`);
- assert.equal(t.annotations.destructiveHint, false);
+ // eval_init's force replaces a suite; score_add replaces the score for the same target and name.
+ assert.equal(t.annotations.destructiveHint, ["eval_init", "score_add"].includes(t.name), `${t.name} destructiveHint`);
// session_replay's fork calls the model API.
assert.equal(t.annotations.openWorldHint, t.name === "session_replay", `${t.name} openWorldHint`);
}
diff --git a/packages/coding.sh/test/scores/cli.test.ts b/packages/coding.sh/test/scores/cli.test.ts
index 88241393..dfd94b6b 100644
--- a/packages/coding.sh/test/scores/cli.test.ts
+++ b/packages/coding.sh/test/scores/cli.test.ts
@@ -149,6 +149,7 @@ describe("MCP score tools", () => {
const items = ((await run(list!, { sessionId: "latest", name: "helpfulness", source: "api" })) as { items: Score[] }).items;
assert.deepEqual(items.map((s) => s.value), [3]);
assert.equal(((await run(list!, { limit: 1 })) as { items: Score[] }).items.length, 1);
- await assert.rejects(run(list!, { since: "yesterday-ish" }), ToolError);
+ // The agent passes since, not the CLI's --since.
+ await assert.rejects(run(list!, { since: "yesterday-ish" }), (e) => e instanceof ToolError && /^Invalid since "yesterday-ish"/.test(e.message) && !e.message.includes("--since"));
});
});
From bba99b54f320d1b701fa1f9f3d9e2db5bd088bf3 Mon Sep 17 00:00:00 2001
From: Jack <72348727+Jack-GitHub12@users.noreply.github.com>
Date: Wed, 11 Nov 2026 21:28:06 -0600
Subject: [PATCH 04/11] docs(mcp): the protocol versions, caching hints,
cancellation and the answer size limit
---
CHANGELOG.md | 3 +++
apps/site/src/pages/docs/mcp.astro | 22 +++++++++++++++++++++-
2 files changed, 24 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a2d50201..2e4d329b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,9 @@ grouped by area, with links to the docs for the details.
### Fixed
+- **MCP.** In 2026-07-28 requests, list, read and discover results carry `ttlMs` and `cacheScope`. A request cancelled with `notifications/cancelled` gets no reply, a cursor on `tools/list`, `prompts/list` or `resources/templates/list` is an error, a request with a null id gets an error instead of no answer, and a 2025-03-26 session accepts batches. [MCP server](https://coding.sh/docs/mcp#protocol)
+- **MCP.** Tool answers stay under 25,000 characters. A page that would be longer has fewer rows and `limitUsed`, and `nextCursor` goes on from the first row left out: `session_get` at limit 200 was 40,000 characters and `session_replay`'s step list 227,000. An error that quotes a huge argument is cut to 4,000 characters, and `eval_candidates` returns at most `limit` rejected sessions. [MCP server](https://coding.sh/docs/mcp#tools)
+- **MCP.** `eval_init` and `score_add` are marked destructive, since both can replace stored data, and `score_list`'s error names its `since` argument instead of the CLI's `--since`. [MCP server](https://coding.sh/docs/mcp#tools)
- **Capture.** Model calls no longer show impossible speeds such as 3750 tok/s. A Claude Code hook that read a transcript from the middle of a response, or Codex lines written seconds late, gave a call a start time during the response. Such a call now has no duration, time to first token or speed. A Claude Code call gets them back when the import reads the whole transcript, and the re-parse after the upgrade repairs calls older versions stored. [Adapters](https://github.com/jackulau/coding.sh/blob/main/docs/ADAPTERS.md#known-breakage-to-design-around)
- **Tests.** The viewer's browser tests no longer hang when Chrome closes its DevTools connection before it answers: a waiting call fails at once, and any call fails after 30 s. The helper also lets go of Chrome's pipe after closing it, and the CI runner makes each test file exit once its tests are done, so a handle left open can no longer hold a Windows job until its time limit. [Contributing](https://github.com/jackulau/coding.sh/blob/main/CONTRIBUTING.md)
- **Site.** The docs sidebar and the phone menu open on the current page, and the phone menu fits the screen. A terminal block no longer touches the paragraph above it, wide code blocks scroll by keyboard, code copy selects the code when the browser blocks the clipboard, and search results open without a redirect. [Docs](https://coding.sh/docs)
diff --git a/apps/site/src/pages/docs/mcp.astro b/apps/site/src/pages/docs/mcp.astro
index a4007766..9d6654e3 100644
--- a/apps/site/src/pages/docs/mcp.astro
+++ b/apps/site/src/pages/docs/mcp.astro
@@ -109,7 +109,27 @@ const gemini = `// ~/.gemini/settings.json
Only eval_from_session, eval_init, score_add and exp_session_note write. Agents ask
- you before eval_from_session and eval_init. Notes have secrets redacted.
+ you before eval_from_session and eval_init. Notes have secrets redacted. eval_init and
+ score_add are marked destructive, because eval_init with force replaces a suite and a new
+ score replaces the one stored for the same target, name, source and person.
+
+
+ A tool answer stays under 25,000 characters. When the rows asked for would be longer, the answer has fewer rows and a
+ limitUsed field, and nextCursor starts at the first row left out. The limit drops rows; it does not cut text.
+ session_replay shows at most 16 KB of a file and of each diff, and says when it cut one.
+
+
+
Protocol
+
+ The server speaks MCP over stdio. It accepts the stateless 2026-07-28 revision (server/discover, no handshake) and
+ the initialize handshake for 2025-11-25, 2025-06-18 and 2025-03-26. An initialize that asks for another
+ version gets 2025-11-25. In a 2025-03-26 session it accepts JSON-RPC batches; in later versions batches are an error.
+
+
+ In 2026-07-28, list and read results carry ttlMs and cacheScope: the tool, prompt and template lists
+ are public for an hour, and resources are private and stale at once. A request cancelled with
+ notifications/cancelled gets no reply. Requests run one at a time, so a cancelled request that has not started
+ never runs.
Prompts and resources
From 5d8692106373916c92fae361deb7a35e5ee6c07d Mon Sep 17 00:00:00 2001
From: Jack <72348727+Jack-GitHub12@users.noreply.github.com>
Date: Wed, 11 Nov 2026 22:03:30 -0600
Subject: [PATCH 05/11] fix(mcp): treat a null-id notification as one, ignore
cancel for initialize, cache discover for 5 minutes, cut text on a character
boundary, and wait on signals in the cancel test
---
packages/coding.sh/src/mcp/protocol.ts | 25 +++++--
.../coding.sh/test/mcp/_blocking-server.ts | 5 +-
packages/coding.sh/test/mcp/protocol.test.ts | 74 ++++++++++++++++---
3 files changed, 84 insertions(+), 20 deletions(-)
diff --git a/packages/coding.sh/src/mcp/protocol.ts b/packages/coding.sh/src/mcp/protocol.ts
index 1701129f..b1382f1c 100644
--- a/packages/coding.sh/src/mcp/protocol.ts
+++ b/packages/coding.sh/src/mcp/protocol.ts
@@ -154,16 +154,25 @@ export const ERROR_TEXT_MAX = 4000;
export function clipText(s: string, max = ERROR_TEXT_MAX): string {
if (s.length <= max) return s;
const keep = Math.floor((max - 40) / 2);
- return `${s.slice(0, keep)} … [${s.length - 2 * keep} characters cut] … ${s.slice(-keep)}`;
+ const head = cutChars(s, keep);
+ const tail = s.slice(-keep).replace(/^[\uDC00-\uDFFF]/, "");
+ return `${head} … [${s.length - head.length - tail.length} characters cut] … ${tail}`;
+}
+
+/** The first `max` characters of `s`, without half of a surrogate pair at the cut. */
+export function cutChars(s: string, max: number): string {
+ if (s.length <= max) return s;
+ return s.slice(0, max).replace(/[\uD800-\uDBFF]$/, "");
}
/**
* Caching hints on 2026-07-28 results (https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching):
* the tool, prompt and template lists are fixed for the life of the process and hold no user data;
- * resources are the user's sessions and change with every capture.
+ * resources are the user's sessions and change with every capture. server/discover carries the
+ * instructions, which hold a per-call update notice, so it is cached for less time.
*/
const CACHE_HINTS: Record = {
- "server/discover": { ttlMs: 3_600_000, cacheScope: "public" },
+ "server/discover": { ttlMs: 300_000, cacheScope: "public" },
"tools/list": { ttlMs: 3_600_000, cacheScope: "public" },
"prompts/list": { ttlMs: 3_600_000, cacheScope: "public" },
"resources/templates/list": { ttlMs: 3_600_000, cacheScope: "public" },
@@ -233,7 +242,8 @@ export class McpServer {
if ("result" in msg || "error" in msg) return null;
return errorResponse(idOk ? (msg.id as RpcId) : null, RPC.INVALID_REQUEST, "Invalid request: missing method");
}
- if (!hasId) {
+ // A null id on a notifications/ method is still a notification: no reply.
+ if (!hasId || (msg.id === null && msg.method.startsWith("notifications/"))) {
this.notification(msg.method);
return null;
}
@@ -400,7 +410,7 @@ export function serveStdio(server: McpServer, io: StdioStreams, log: (m: s
}
};
// Requests waiting or running, by id. A cancel marks the entry; a marked request is never answered.
- const pending = new Map();
+ const pending = new Map();
// A synchronous store read holds the event loop, so a cancel sent meanwhile is still unread in the
// pipe. Two turns of the loop include one poll for input, which reads it before we start or answer.
const readInput = () => new Promise((r) => setImmediate(() => setImmediate(r)));
@@ -415,11 +425,12 @@ export function serveStdio(server: McpServer, io: StdioStreams, log: (m: s
if (parsed && isObj(msg) && msg.method === "notifications/cancelled" && msg.id === undefined) {
const id = isObj(msg.params) ? msg.params.requestId : undefined;
const entry = typeof id === "string" || typeof id === "number" ? pending.get(JSON.stringify(id)) : undefined;
- if (entry) entry.cancelled = true;
+ // initialize can not be cancelled (lifecycle spec).
+ if (entry && entry.method !== "initialize") entry.cancelled = true;
return;
}
const key = parsed && isObj(msg) && typeof msg.method === "string" && (typeof msg.id === "string" || typeof msg.id === "number") ? JSON.stringify(msg.id) : undefined;
- const entry = { cancelled: false };
+ const entry = { cancelled: false, method: isObj(msg) && typeof msg.method === "string" ? msg.method : "" };
if (key) pending.set(key, entry);
chain = chain
.then(async () => {
diff --git a/packages/coding.sh/test/mcp/_blocking-server.ts b/packages/coding.sh/test/mcp/_blocking-server.ts
index 6da4a05e..7aa0c86a 100644
--- a/packages/coding.sh/test/mcp/_blocking-server.ts
+++ b/packages/coding.sh/test/mcp/_blocking-server.ts
@@ -1,6 +1,7 @@
// An MCP server on stdio whose `block` tool holds the event loop for `ms`
// milliseconds, the way a synchronous SQLite read does. protocol.test.ts sends
-// a cancel while it blocks.
+// a cancel while it blocks. It writes "block-start" to stderr as it starts blocking and
+// "block-end" when it stops, so the test waits on those lines, not on timers.
import { McpServer, serveStdio } from "../../src/mcp/protocol.ts";
const server = new McpServer({
@@ -16,9 +17,11 @@ const server = new McpServer({
annotations: { readOnlyHint: true },
run: async (a) => {
const end = Date.now() + Number(a.ms ?? 0);
+ process.stderr.write("block-start\n");
while (Date.now() < end) {
// hold the event loop
}
+ process.stderr.write("block-end\n");
return { done: true };
},
},
diff --git a/packages/coding.sh/test/mcp/protocol.test.ts b/packages/coding.sh/test/mcp/protocol.test.ts
index bcf34224..925071d5 100644
--- a/packages/coding.sh/test/mcp/protocol.test.ts
+++ b/packages/coding.sh/test/mcp/protocol.test.ts
@@ -4,8 +4,9 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { PassThrough } from "node:stream";
+import { fileURLToPath } from "node:url";
import { describe, it } from "node:test";
-import { McpServer, serveStdio, ToolError, type ToolDef } from "../../src/mcp/protocol.ts";
+import { clipText, cutChars, McpServer, serveStdio, ToolError, type ToolDef } from "../../src/mcp/protocol.ts";
type Msg = { jsonrpc: string; id: unknown; result?: Record; error?: { code: number; message: string; data?: Record } };
@@ -100,6 +101,7 @@ describe("caching hints (2026-07-28)", () => {
assert.equal(typeof r?.result?.ttlMs, "number", `${method} ttlMs`);
assert.ok((r!.result!.ttlMs as number) >= 0, `${method} ttlMs >= 0`);
assert.equal(r?.result?.cacheScope, scope, `${method} cacheScope`);
+ if (method === "server/discover") assert.equal(r?.result?.ttlMs, 300_000, "discover carries a per-call update notice, so it is cached for 5 minutes");
}
// Legacy results stay as they were.
w.send({ jsonrpc: "2.0", id: "legacy", method: "tools/list" });
@@ -144,9 +146,11 @@ describe("cancellation (notifications/cancelled)", () => {
describe("cancellation while a tool holds the event loop", () => {
// A synchronous store read blocks the loop, so a cancel sits unread in the pipe until it ends.
it("reads the cancel before it answers or starts the next request", async () => {
- const child = spawn(process.execPath, ["--no-warnings", new URL("./_blocking-server.ts", import.meta.url).pathname], { stdio: ["pipe", "pipe", "inherit"] });
+ const child = spawn(process.execPath, ["--no-warnings", fileURLToPath(new URL("./_blocking-server.ts", import.meta.url))], { stdio: ["pipe", "pipe", "pipe"] });
const ids: unknown[] = [];
+ const marks: string[] = [];
let buf = "";
+ let errBuf = "";
child.stdout.setEncoding("utf8");
child.stdout.on("data", (c: string) => {
buf += c;
@@ -156,24 +160,37 @@ describe("cancellation while a tool holds the event loop", () => {
buf = buf.slice(i + 1);
}
});
+ child.stderr.setEncoding("utf8");
+ child.stderr.on("data", (c: string) => {
+ errBuf += c;
+ let i: number;
+ while ((i = errBuf.indexOf("\n")) !== -1) {
+ marks.push(errBuf.slice(0, i));
+ errBuf = errBuf.slice(i + 1);
+ }
+ });
const send = (m: unknown) => child.stdin.write(JSON.stringify(m) + "\n");
- const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
+ // Waits for the child's signal, not for a time: the machine may be loaded.
+ const until = async (what: string, done: () => boolean) => {
+ for (let i = 0; i < 3000 && !done(); i++) await new Promise((r) => setTimeout(r, 10));
+ assert.ok(done(), `timed out waiting for ${what}`);
+ };
const exited = new Promise((r) => child.on("exit", r));
send({ jsonrpc: "2.0", id: "warm", method: "ping" });
- for (let i = 0; i < 200 && !ids.includes("warm"); i++) await wait(10);
+ await until("warm", () => ids.includes("warm"));
// "run" is cancelled while it blocks; "queued" is cancelled while it waits behind "first".
- send({ jsonrpc: "2.0", id: "run", method: "tools/call", params: { name: "block", arguments: { ms: 400 } } });
- await wait(100);
+ send({ jsonrpc: "2.0", id: "run", method: "tools/call", params: { name: "block", arguments: { ms: 1500 } } });
+ await until("run to block", () => marks.length === 1);
send({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: "run" } });
- await wait(500);
- send({ jsonrpc: "2.0", id: "first", method: "tools/call", params: { name: "block", arguments: { ms: 400 } } });
- await wait(100);
+ await until("run to end", () => marks.length === 2);
+ send({ jsonrpc: "2.0", id: "first", method: "tools/call", params: { name: "block", arguments: { ms: 1500 } } });
+ await until("first to block", () => marks.length === 3);
send({ jsonrpc: "2.0", id: "queued", method: "ping" });
- await wait(50);
send({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: "queued" } });
- await wait(500);
+ await until("first to end", () => marks.length === 4);
+ await until("first's reply", () => ids.includes("first"));
send({ jsonrpc: "2.0", id: "last", method: "ping" });
- for (let i = 0; i < 200 && !ids.includes("last"); i++) await wait(10);
+ await until("last", () => ids.includes("last"));
child.stdin.end();
await exited;
assert.deepEqual(ids, ["warm", "first", "last"]);
@@ -199,6 +216,39 @@ describe("JSON-RPC edges", () => {
await w.close();
});
+ it("handles a null id on a notifications/ method as a notification", async () => {
+ const w = world();
+ const n = w.out.length;
+ w.send({ jsonrpc: "2.0", id: null, method: "notifications/initialized" });
+ w.send({ jsonrpc: "2.0", id: null, method: "notifications/cancelled", params: { requestId: 9 } });
+ w.send({ jsonrpc: "2.0", id: "after", method: "ping" });
+ assert.ok(await w.reply("after"));
+ assert.equal(w.out.length, n + 1, "the notifications got no reply");
+ assert.equal(w.server.initialized, true);
+ w.send({ jsonrpc: "2.0", id: null, method: "ping" });
+ assert.equal((await w.reply(null))?.error?.code, -32600, "other null-id requests stay invalid");
+ await w.close();
+ });
+
+ it("ignores a cancel for initialize", async () => {
+ const w = world();
+ w.send({ jsonrpc: "2.0", id: "init", method: "initialize", params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "t", version: "0" } } });
+ w.send({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: "init" } });
+ assert.ok((await w.reply("init"))?.result?.protocolVersion, "initialize is still answered");
+ await w.close();
+ });
+
+ it("clips text without splitting a surrogate pair", () => {
+ const e = "\u{1F600}";
+ for (let pad = 0; pad < 2; pad++) {
+ const s = "a".repeat(pad) + e.repeat(100);
+ const clipped = clipText(s, 100);
+ assert.ok(!/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? {
const w = world();
w.send([{ jsonrpc: "2.0", id: "x", method: "ping" }]);
From edc12825341b01c0cba8cfc3a112d451b6abfddb Mon Sep 17 00:00:00 2001
From: Jack <72348727+Jack-GitHub12@users.noreply.github.com>
Date: Wed, 11 Nov 2026 22:22:48 -0600
Subject: [PATCH 06/11] fix(mcp): keep every session_replay view under 25,000
characters
The step list, a step, a step with a file, a range and a fork page their files with limit and cursor and cut the text that does not fit, saying so with truncated. fitPage returns the largest page that fits, or the page whole with truncated when the fixed part is the long one. benchmarks has a default limit, and every tool that can return limitUsed declares it.
---
packages/coding.sh/src/mcp/replay-page.ts | 135 ++++++++++++++++++
packages/coding.sh/src/mcp/server.ts | 4 +-
packages/coding.sh/src/mcp/tools.ts | 75 ++++++----
packages/coding.sh/test/mcp/fit-page.test.ts | 65 +++++++++
packages/coding.sh/test/mcp/limits.test.ts | 24 +++-
.../coding.sh/test/mcp/replay-limit.test.ts | 117 +++++++++++++++
packages/coding.sh/test/mcp/toolevals.test.ts | 6 +-
7 files changed, 395 insertions(+), 31 deletions(-)
create mode 100644 packages/coding.sh/src/mcp/replay-page.ts
create mode 100644 packages/coding.sh/test/mcp/fit-page.test.ts
create mode 100644 packages/coding.sh/test/mcp/replay-limit.test.ts
diff --git a/packages/coding.sh/src/mcp/replay-page.ts b/packages/coding.sh/src/mcp/replay-page.ts
new file mode 100644
index 00000000..2cc955a9
--- /dev/null
+++ b/packages/coding.sh/src/mcp/replay-page.ts
@@ -0,0 +1,135 @@
+// session_replay's answers kept under the character limit. The file lists (`tree`, `changes`, `files`,
+// `unchanged`) are paged with limit and cursor; the text in them (diffs, file content, tool output) is
+// cut to fit what is left, and the answer says so (`truncated`, plus the field's own flag).
+import type { RangeView, StepView } from "../core/replay.ts";
+import { cutChars, toolText } from "./protocol.ts";
+
+type Obj = Record;
+
+/** Text is never cut below this many characters when it sits in a row of a list, or outside one. */
+const ROW_FLOOR = 600;
+const FIXED_FLOOR = 2000;
+/** The most a diff or file shows (the replay is loaded with the same cap). */
+export const TEXT_MAX = 16 * 1024;
+
+const FLAG: Record = { diff: "diffTruncated", content: "contentTruncated", output: "outputTruncated", text: "textTruncated" };
+
+const NOTE = "Text was cut to keep the answer under 25,000 characters (the fields marked truncated). A smaller limit gives each diff more room (limit 1: up to 16 KB); step with file gives one file's content.";
+
+interface Slot {
+ owner: Obj;
+ key: string;
+ floor: number;
+}
+
+function collect(v: unknown, inRow: boolean, out: Slot[], depth = 0): void {
+ if (depth > 8 || v === null || typeof v !== "object") return;
+ if (Array.isArray(v)) {
+ for (const x of v) collect(x, true, out, depth + 1);
+ return;
+ }
+ const o = v as Obj;
+ for (const [k, x] of Object.entries(o)) {
+ if (typeof x === "string") {
+ const floor = inRow ? ROW_FLOOR : FIXED_FLOOR;
+ if (x.length > floor) out.push({ owner: o, key: k, floor });
+ } else collect(x, inRow, out, depth + 1);
+ }
+}
+
+/** Whether any field in `v` is flagged as cut (`diffTruncated`, `contentTruncated`, …). */
+function hasFlag(v: unknown, depth = 0): boolean {
+ if (depth > 8 || v === null || typeof v !== "object") return false;
+ if (Array.isArray(v)) return v.some((x) => hasFlag(x, depth + 1));
+ return Object.entries(v as Obj).some(([k, x]) => (k.endsWith("Truncated") && x === true) || hasFlag(x, depth + 1));
+}
+
+/**
+ * Cuts the longest strings in `page` (in place) until its text is at most `limit` characters, or no string
+ * is left above its floor. The longest are cut down to a common length, so short text stays whole.
+ * Sets `truncated` and `note` on the page, and the field's own flag (`diffTruncated`, …) beside a cut one.
+ */
+export function fitTexts(page: Obj, limit: number): Obj {
+ let len = toolText(page).length;
+ if (len <= limit) return hasFlag(page) ? { ...page, truncated: true, note: NOTE } : page;
+ const slots: Slot[] = [];
+ collect(page, false, slots);
+ let margin = 1.1;
+ for (let round = 0; round < 6 && len > limit && slots.length; round++, margin += 0.4) {
+ const need = Math.ceil((len - limit) * margin) + 64;
+ const size = (s: Slot) => (s.owner[s.key] as string).length;
+ const after = (s: Slot, level: number) => Math.max(s.floor, Math.min(size(s), level));
+ const saved = (level: number) => slots.reduce((sum, s) => sum + size(s) - after(s, level), 0);
+ let lo = 0;
+ let hi = Math.max(...slots.map(size));
+ // The highest level that still saves `need` characters (or the lowest there is).
+ while (lo < hi) {
+ const mid = Math.ceil((lo + hi) / 2);
+ if (saved(mid) >= need) lo = mid;
+ else hi = mid - 1;
+ }
+ let cut = false;
+ for (const s of slots) {
+ const to = after(s, lo);
+ if (to >= size(s)) continue;
+ s.owner[s.key] = cutChars(s.owner[s.key] as string, to);
+ const flag = FLAG[s.key];
+ if (flag) s.owner[flag] = true;
+ cut = true;
+ }
+ if (!cut) break;
+ len = toolText({ ...page, truncated: true, note: NOTE }).length;
+ }
+ return { ...page, truncated: true, note: NOTE };
+}
+
+/** A diff cut to `max` characters at a line end, flagged when cut. */
+function capDiff(o: { diff?: string; diffTruncated?: boolean }, max: number): void {
+ if (o.diff === undefined || o.diff.length <= max) return;
+ let cut = cutChars(o.diff, max);
+ const nl = cut.lastIndexOf("\n");
+ if (nl > 0) cut = cut.slice(0, nl + 1);
+ o.diff = cut;
+ o.diffTruncated = true;
+}
+
+/** Characters each row's diff may use on a page of n rows. */
+const perRow = (n: number) => Math.min(TEXT_MAX, Math.max(ROW_FLOOR, Math.floor(18_000 / n)));
+
+/** A page of a step's view: the file lists from `offset`; the step's own detail and file only on the first page. */
+export function pageStep(view: StepView, offset: number, n: number, limit: number): Obj {
+ const first: Obj = {};
+ if (offset === 0) {
+ first.detail = view.detail;
+ if (view.file) first.file = view.file;
+ }
+ const page = structuredClone({
+ sessionId: view.sessionId,
+ steps: view.steps,
+ step: view.step,
+ ...first,
+ tree: view.tree.slice(offset, offset + n),
+ changes: view.changes.slice(offset, offset + n),
+ treeTotal: view.tree.length,
+ changesTotal: view.changes.length,
+ }) as Obj & { changes: { diff?: string; diffTruncated?: boolean }[]; nextCursor?: string };
+ for (const c of page.changes) capDiff(c, perRow(n));
+ if (offset + n < Math.max(view.tree.length, view.changes.length)) page.nextCursor = String(offset + n);
+ return fitTexts(page, limit);
+}
+
+/** A page of a range's view. */
+export function pageRange(view: RangeView, offset: number, n: number, limit: number): Obj {
+ const page = structuredClone({
+ sessionId: view.sessionId,
+ from: view.from,
+ to: view.to,
+ files: view.files.slice(offset, offset + n),
+ unchanged: view.unchanged.slice(offset, offset + n),
+ filesTotal: view.files.length,
+ unchangedTotal: view.unchanged.length,
+ }) as Obj & { files: { diff?: string; diffTruncated?: boolean }[]; nextCursor?: string };
+ for (const f of page.files) capDiff(f, perRow(n));
+ if (offset + n < Math.max(view.files.length, view.unchanged.length)) page.nextCursor = String(offset + n);
+ return fitTexts(page, limit);
+}
diff --git a/packages/coding.sh/src/mcp/server.ts b/packages/coding.sh/src/mcp/server.ts
index 150d44f1..a2613ebe 100644
--- a/packages/coding.sh/src/mcp/server.ts
+++ b/packages/coding.sh/src/mcp/server.ts
@@ -42,9 +42,9 @@ const BASE_INSTRUCTIONS = [
"For trends use failures (raw tool errors and flags), latency, usage (tokens/cost) and subagents. experiments_list and experiment_compare read eval results.",
"In an experiment session, call exp_session_note with the hypothesis before each run and the reading after it; exp_session_summary returns the timeline.",
"To build evals for the user, call eval_init (a suite in evals// from failed sessions, a skill's trigger prompts, or cases you write), then tell them its run command.",
- "eval_from_session, eval_init and exp_session_note write; ask the user before calling eval_from_session or eval_init with confirm: true.",
+ "eval_from_session, eval_init, score_add and exp_session_note write; ask the user before calling eval_from_session or eval_init with confirm: true.",
'A result with pending: {files, indexing} was answered before every capture was indexed: it covers what is indexed so far; call again shortly for the rest.',
- "Answers stay under 25,000 characters: one that would be longer has fewer rows and limitUsed; follow nextCursor for the rest.",
+ "Answers stay under 25,000 characters. A tool that pages returns nextCursor for the rest; the others return fewer rows, with limitUsed.",
].join(" ");
/** Server instructions, with a one-line notice when the update checker found a newer release. */
diff --git a/packages/coding.sh/src/mcp/tools.ts b/packages/coding.sh/src/mcp/tools.ts
index 3795a49a..90c6332a 100644
--- a/packages/coding.sh/src/mcp/tools.ts
+++ b/packages/coding.sh/src/mcp/tools.ts
@@ -39,6 +39,7 @@ import {
import { sessionDigest } from "./digest.ts";
import { facetCounts, searchWhere } from "../query/run.ts";
import { andQuery, FACET_KEYS, parseWhere, QueryError, QueryUnsupported, type Query } from "../query/where.ts";
+import { fitTexts, pageRange, pageStep } from "./replay-page.ts";
import { toolText, ToolError, type ToolDef } from "./protocol.ts";
import { scoreTools } from "../scores/mcp.ts";
@@ -135,38 +136,57 @@ function treeNodeJson(n: TreeNode, top = false): Obj {
export const CHARACTER_LIMIT = 25_000;
/**
- * The page `render(limit)` makes, with fewer rows while its text is over CHARACTER_LIMIT. Rows are
- * dropped, never cut, so a cursor of offset + n still points at the first row left out. A shortened
- * page carries `limitUsed`. When no smaller limit makes it shorter (the size is not in the rows, or
- * one row is that long), the shortest page comes back whole.
+ * The page `render(limit)` makes, with fewer rows while its text is over CHARACTER_LIMIT: the largest
+ * n whose page fits. Rows are dropped, never cut, so a cursor of offset + n still points at the first
+ * row left out. A shortened page carries `limitUsed`; one that already fit is returned as it is. When
+ * not even one row fits (the size is in the fixed part of the answer), the page for `limit` comes back
+ * whole with `truncated: true`.
*/
export async function fitPage(limit: number, render: (n: number) => Obj | Promise): Promise {
- let page = await render(limit);
- let len = toolText(page).length;
- let used: number | undefined;
- // n can start above the rows left (the last page), so a try that is no shorter only halves n.
- for (let n = limit; len > CHARACTER_LIMIT && n > 1; ) {
- n = Math.max(1, Math.min(Math.floor(n / 2), Math.floor((n * CHARACTER_LIMIT * 0.9) / len)));
- const smaller = await render(n);
- const smallerLen = toolText(smaller).length;
- if (smallerLen < len) [page, len, used] = [smaller, smallerLen, n];
+ const first = await render(limit);
+ const len = toolText(first).length;
+ if (len <= CHARACTER_LIMIT) return first;
+ const tooLong = (): Obj => ({ ...first, truncated: true, note: `This answer is over ${CHARACTER_LIMIT.toLocaleString("en-US")} characters even with one row: most of it is not in the rows.` });
+ if (limit <= 1) return tooLong();
+ const fits = async (n: number): Promise => {
+ const page = { ...(await render(n)), limitUsed: n };
+ return toolText(page).length <= CHARACTER_LIMIT ? page : undefined;
+ };
+ // One row first: when it does not fit, no page does.
+ let best = await fits(1);
+ if (!best) return tooLong();
+ // The length grows with n: bisect for the largest n that fits, probing an estimate first.
+ let lo = 1;
+ let hi = limit;
+ for (let n = Math.min(limit - 1, Math.max(2, Math.floor((limit * CHARACTER_LIMIT * 0.9) / len))); hi - lo > 1; n = lo + Math.floor((hi - lo) / 2)) {
+ const page = await fits(n);
+ if (page) [lo, best] = [n, page];
+ else hi = n;
}
- return used === undefined ? page : { ...page, limitUsed: used };
+ return best;
}
+/** The rows a tool with a `limit` returns when none is passed, where the schema's default does not say. */
+const LIMIT_DEFAULTS: Record number> = { benchmarks: (a) => (a.metric ? 30 : 5) };
+
/** A read-only tool with a `limit` argument, run again with a smaller limit when its answer is too long. */
function withCharacterLimit(t: ToolDef): ToolDef {
const limit = (t.inputSchema.properties as Record | undefined)?.limit;
if (!limit || t.annotations.readOnlyHint !== true || t.annotations.idempotentHint !== true) return t;
+ const outProps = (t.outputSchema.properties ?? {}) as Obj;
return {
...t,
+ outputSchema: { ...t.outputSchema, properties: { ...outProps, limitUsed: I } },
run(a, c) {
- const asked = typeof a.limit === "number" ? a.limit : typeof limit.default === "number" ? limit.default : undefined;
- return asked === undefined ? t.run(a, c) : fitPage(asked, (n) => t.run(n === asked ? a : { ...a, limit: n }, c));
+ const asked = typeof a.limit === "number" ? a.limit : typeof limit.default === "number" ? limit.default : LIMIT_DEFAULTS[t.name]?.(a);
+ return asked === undefined ? t.run(a, c) : fitPage(asked, (n) => t.run(n === a.limit ? a : { ...a, limit: n }, c));
},
};
}
+/** What session_replay's text may take of CHARACTER_LIMIT: the rest is for `limitUsed` and the page's own keys. */
+const REPLAY_TEXT_LIMIT = CHARACTER_LIMIT - 60;
+
const READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } as const;
function strArg(a: Obj, k: string): string | undefined {
@@ -448,7 +468,7 @@ function toolDefs(): ToolDef[] {
name: "session_replay",
title: "Replay a session",
description:
- "A session as numbered steps, with the files the agent touched rebuilt from its tool calls as of any step. No step: the step list.",
+ "A session as numbered steps, with the files the agent touched rebuilt from its tool calls as of any step. No step: the step list. Paged by files; cut text says truncated.",
inputSchema: input(
{
sessionId: SESSION_ID,
@@ -457,12 +477,12 @@ function toolDefs(): ToolDef[] {
from: { type: "integer", minimum: 1, description: "With to: the first step of a range." },
to: { type: "integer", minimum: 1, description: "With from: the last step (included); each file's diff over the range." },
fork: { type: "object", description: 'With step: send its model call again with these files ({"AGENTS.md": text}); recorded vs forked next action. Calls the model.' },
- limit: LIMIT(100, 1000),
+ limit: { ...LIMIT(100, 1000), description: "Max files per page; fewer give each diff more room." },
cursor: CURSOR,
},
["sessionId"],
),
- outputSchema: out({ sessionId: S, steps: { description: "The step list, or (with step) how many steps there are." }, files: { type: "array" }, total: I, step: { type: "object" }, changes: { type: "array" }, tree: { type: "array" }, file: { type: "object" }, from: I, to: I, nextCursor: S, trials: { type: "array" } }, ["sessionId"]),
+ outputSchema: out({ sessionId: S, steps: { description: "The step list, or (with step) how many steps there are." }, files: { type: "array" }, total: I, treeTotal: I, changesTotal: I, filesTotal: I, unchanged: arr(S), unchangedTotal: I, step: { type: "object" }, changes: { type: "array" }, tree: { type: "array" }, file: { type: "object" }, from: I, to: I, nextCursor: S, limitUsed: I, truncated: { type: "boolean" }, note: S, trials: { type: "array" } }, ["sessionId"]),
annotations: { ...READ, idempotentHint: false, openWorldHint: true },
async run(a, c) {
const ctx = await c.store();
@@ -480,21 +500,26 @@ function toolDefs(): ToolDef[] {
const step = stepArg("step");
const from = stepArg("from");
const to = stepArg("to");
+ const offset = cursorArg(a);
+ const limit = numArg(a, "limit", 100);
if (from !== undefined || to !== undefined) {
if (from === undefined || to === undefined) throw new ToolError("Pass both from and to for a range.");
- return { ...(await replay.range(from, to)) };
+ const range = await replay.range(from, to);
+ return fitPage(limit, (n) => pageRange(range, offset, n, REPLAY_TEXT_LIMIT));
}
if (a.fork !== undefined) {
if (step === undefined) throw new ToolError("fork needs a step.");
- return { ...(await forkTool(ctx, c.env, s, replay, step, a.fork)) };
+ return fitTexts({ ...(await forkTool(ctx, c.env, s, replay, step, a.fork)) }, REPLAY_TEXT_LIMIT);
}
const file = strArg(a, "file");
- if (step !== undefined) return { ...(await replay.at(step, { file })) };
+ if (step !== undefined) {
+ const view = await replay.at(step, { file });
+ return fitPage(limit, (n) => pageStep(view, offset, n, REPLAY_TEXT_LIMIT));
+ }
if (file) throw new ToolError("file needs a step.");
const index = replay.index();
- const offset = cursorArg(a);
- const limit = numArg(a, "limit", 100);
- return fitPage(limit, (n) => ({ ...index, steps: index.steps.slice(offset, offset + n), total, ...(offset + n < total ? { nextCursor: String(offset + n) } : {}) }));
+ const more = (n: number) => offset + n < Math.max(total, index.files.length);
+ return fitPage(limit, (n) => ({ ...index, steps: index.steps.slice(offset, offset + n), files: index.files.slice(offset, offset + n), total, filesTotal: index.files.length, ...(more(n) ? { nextCursor: String(offset + n) } : {}) }));
},
},
{
diff --git a/packages/coding.sh/test/mcp/fit-page.test.ts b/packages/coding.sh/test/mcp/fit-page.test.ts
new file mode 100644
index 00000000..d4a3a1c9
--- /dev/null
+++ b/packages/coding.sh/test/mcp/fit-page.test.ts
@@ -0,0 +1,65 @@
+// fitPage: the largest page that fits CHARACTER_LIMIT, `limitUsed` only when it shrank, and a
+// `truncated` page when the fixed part of the answer is the part that is too long.
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import { CHARACTER_LIMIT, fitPage } from "../../src/mcp/tools.ts";
+import { toolText } from "../../src/mcp/protocol.ts";
+
+type Obj = Record;
+
+/** A page of `n` rows of `rowLen` characters out of `total`, after `fixed` characters that are always there. */
+const pager =
+ (total: number, rowLen: number, fixed = 0, tail = false) =>
+ (n: number): Obj => {
+ const rows = Array.from({ length: Math.min(n, total) }, (_, i) => `${tail ? total - Math.min(n, total) + i : i}`.padEnd(rowLen, "x"));
+ const page: Obj = { head: "h".repeat(fixed), rows };
+ if (n < total) page.nextCursor = String(n);
+ return page;
+ };
+
+describe("fitPage", () => {
+ it("leaves a page that fits alone, without limitUsed, and calls render once", async () => {
+ let calls = 0;
+ const render = pager(10, 100);
+ const page = await fitPage(10, (n) => (calls++, render(n)));
+ assert.equal(calls, 1);
+ assert.equal("limitUsed" in page, false);
+ assert.equal((page.rows as string[]).length, 10);
+ });
+
+ it("returns the largest page that fits, and says which limit it used", async () => {
+ const render = pager(500, 1000);
+ const page = await fitPage(500, render);
+ const n = page.limitUsed as number;
+ assert.ok(toolText(page).length <= CHARACTER_LIMIT);
+ assert.equal((page.rows as string[]).length, n);
+ assert.ok(toolText({ ...render(n + 1), limitUsed: n + 1 }).length > CHARACTER_LIMIT, "one more row would not fit");
+ assert.ok(n >= 20 && n < 26, `about 25 rows of 1,000 characters (${n})`);
+ });
+
+ it("works on a page taken from the tail", async () => {
+ const render = pager(500, 1000, 0, true);
+ const page = await fitPage(500, render);
+ const rows = page.rows as string[];
+ assert.ok(toolText(page).length <= CHARACTER_LIMIT);
+ assert.equal(rows.length, page.limitUsed);
+ assert.ok(rows[0]!.startsWith(String(500 - rows.length)), "the newest rows stay");
+ });
+
+ it("a last page shorter than the limit still counts as fitting", async () => {
+ const page = await fitPage(1000, pager(30, 100));
+ assert.equal("limitUsed" in page, false);
+ });
+
+ it("returns the page whole with truncated when the fixed part is over the limit", async () => {
+ let calls = 0;
+ const render = pager(100, 10, CHARACTER_LIMIT + 5000);
+ const page = await fitPage(100, (n) => (calls++, render(n)));
+ assert.equal(page.truncated, true);
+ assert.equal("limitUsed" in page, false);
+ assert.equal((page.rows as string[]).length, 100, "the page that was asked for");
+ assert.ok(calls <= 2, `no pointless retries (${calls} calls)`);
+ const one = await fitPage(1, render);
+ assert.equal(one.truncated, true);
+ });
+});
diff --git a/packages/coding.sh/test/mcp/limits.test.ts b/packages/coding.sh/test/mcp/limits.test.ts
index e4231a84..bbaae65d 100644
--- a/packages/coding.sh/test/mcp/limits.test.ts
+++ b/packages/coding.sh/test/mcp/limits.test.ts
@@ -41,6 +41,18 @@ before(async () => {
evs.push({ ...base, id: "big-end", ts: t0 + 20 * CALLS, kind: "session.end", payload: {} });
await ctx.index.putEvents(evs);
await ctx.index.upsertSession(buildSession(evs));
+ // Sixty sessions with long ids and long prompts: a search for "needle" finds one hit in each, with rows
+ // too long for 50 of them to fit.
+ for (let k = 0; k < 60; k++) {
+ const sid = `sess-long-${k}-${"q".repeat(150)}`;
+ const lb = { ...base, sessionId: sid };
+ const lev: AgentEvent[] = [
+ { ...lb, id: `l${k}-0`, ts: t0 + k, kind: "session.start", payload: {} },
+ { ...lb, id: `l${k}-1`, ts: t0 + k + 1, kind: "user.message", payload: { text: `needle ${"w".repeat(900)}` } },
+ ];
+ await ctx.index.putEvents(lev);
+ await ctx.index.upsertSession(buildSession(lev));
+ }
await ctx.close();
c = new RawClient(w.env, w.repo);
await c.initialize();
@@ -92,9 +104,17 @@ describe("the 25,000-character limit", () => {
assert.equal(steps, total, "the pages hold every step once");
});
- it("search, sessions_list and session_current stay under the limit", async () => {
+ it("search returns fewer hits when the rows are long, and the cursor walks all of them", async () => {
+ const first = await call("search", { query: "needle", limit: 50 });
+ const sc = first.structuredContent!;
+ assert.ok(first.content[0]!.text.length <= CHARACTER_LIMIT, `search is ${first.content[0]!.text.length} chars`);
+ assert.ok((sc.limitUsed as number) < 50, `the page was shortened (limitUsed ${String(sc.limitUsed)})`);
+ assert.equal((sc.hits as unknown[]).length, sc.limitUsed, "limitUsed is the number of hits");
+ assert.equal(typeof sc.nextCursor, "string", "the rest is behind nextCursor");
+ });
+
+ it("sessions_list and session_current stay under the limit", async () => {
for (const [name, args] of [
- ["search", { query: "needle", limit: 50 }],
["sessions_list", { limit: 100, includeSubagents: true }],
["session_current", { limit: 100 }],
] as const) {
diff --git a/packages/coding.sh/test/mcp/replay-limit.test.ts b/packages/coding.sh/test/mcp/replay-limit.test.ts
new file mode 100644
index 00000000..99bc1a15
--- /dev/null
+++ b/packages/coding.sh/test/mcp/replay-limit.test.ts
@@ -0,0 +1,117 @@
+// session_replay stays under the 25,000-character limit in every view, with a session that touched
+// 300 files of about 50 KB each: the step list, a step, a step with a file, and ranges. Pages by
+// limit and cursor reach every file exactly once, and cut text says so.
+import assert from "node:assert/strict";
+import { after, before, describe, it } from "node:test";
+import { openContext } from "../../src/core/context.ts";
+import { buildSession } from "../../src/core/sessions.ts";
+import { CHARACTER_LIMIT } from "../../src/mcp/tools.ts";
+import type { AgentEvent } from "../../src/schema/index.ts";
+import { RawClient, cleanup, makeWorld, seed, type World } from "./helpers.ts";
+
+const ID = "sess-files";
+const FILES = 300;
+const path = (i: number) => `src/gen/file-${String(i).padStart(3, "0")}.ts`;
+
+let w: World;
+let c: RawClient;
+
+type Sc = Record;
+
+async function call(args: Record): Promise<{ sc: Sc; len: number }> {
+ const r = await c.call("session_replay", { sessionId: ID, ...args });
+ assert.notEqual(r.isError, true, r.content[0]?.text.slice(0, 300));
+ return { sc: r.structuredContent as Sc, len: r.content[0]!.text.length };
+}
+
+/** Every page of a view, following nextCursor. Each page is checked against the limit. */
+async function pages(args: Record): Promise {
+ const out: Sc[] = [];
+ let cursor: string | undefined;
+ for (let i = 0; i < 400; i++) {
+ const next = { ...args };
+ if (cursor) next.cursor = cursor;
+ const { sc, len } = await call(next);
+ assert.ok(len <= CHARACTER_LIMIT, `page ${i} of ${JSON.stringify(args)} is ${len} characters`);
+ out.push(sc);
+ cursor = sc.nextCursor;
+ if (!cursor) return out;
+ }
+ throw new Error("the cursor never ended");
+}
+
+const once = (xs: string[], n: number, what: string) => {
+ assert.equal(xs.length, n, `${what}: ${xs.length} entries`);
+ assert.equal(new Set(xs).size, n, `${what}: each exactly once`);
+};
+
+before(async () => {
+ w = makeWorld();
+ await seed(w);
+ const ctx = await openContext({ env: w.env, cwd: w.root });
+ const t0 = w.now - 3 * 3600e3;
+ const base = { v: 0 as const, sessionId: ID, agent: "claude-code", cwd: w.repo, model: "claude-opus-4-5" };
+ const evs: AgentEvent[] = [{ ...base, id: "f-0", ts: t0, kind: "session.start", payload: {} }, { ...base, id: "f-1", ts: t0 + 1, kind: "user.message", payload: { text: "Generate the files" } }];
+ for (let i = 0; i < FILES; i++) {
+ // About 50 KB of distinct lines per file.
+ const content = Array.from({ length: 1500 }, (_, l) => `export const v${i}_${l} = ${l * 7919 + i};`).join("\n") + "\n";
+ evs.push({ ...base, id: `f-s${i}`, ts: t0 + 10 + i * 10, kind: "tool.start", payload: { tool: "Write", toolUseId: `w${i}`, input: { file_path: path(i), content } } });
+ evs.push({ ...base, id: `f-e${i}`, ts: t0 + 15 + i * 10, kind: "tool.end", payload: { tool: "Write", toolUseId: `w${i}`, ok: true }, durationMs: 5 });
+ evs.push({ ...base, id: `f-c${i}`, ts: t0 + 16 + i * 10, kind: "file.change", payload: { path: path(i), op: "add" } });
+ }
+ evs.push({ ...base, id: "f-end", ts: t0 + 20 * FILES, kind: "session.end", payload: {} });
+ await ctx.index.putEvents(evs);
+ await ctx.index.upsertSession(buildSession(evs));
+ await ctx.close();
+ c = new RawClient(w.env, w.repo);
+ await c.initialize();
+});
+
+after(async () => {
+ await c?.close();
+ cleanup();
+});
+
+describe("session_replay under the 25,000-character limit", () => {
+ it("pages the step list, with its files, once each", async () => {
+ const all = await pages({});
+ const steps = all.flatMap((p) => (p.steps as { n: number }[]).map((s) => String(s.n)));
+ const first = all[0]!;
+ once(steps, first.total, "steps");
+ once(all.flatMap((p) => p.files as string[]), FILES, "files");
+ assert.ok(all.length > 1, "a long list takes more than one page");
+ });
+
+ it("keeps a step, and a step with a file, under the limit and pages its tree", async () => {
+ const total = (await call({})).sc.total as number;
+ assert.equal(total, FILES + 1, "a prompt, then one step per file");
+ const step = (await pages({ step: 5 }))[0]!;
+ assert.ok(step.step, "the step is there");
+ const last = await pages({ step: total, file: path(FILES - 1) });
+ assert.ok(last[0]!.file, "the first page has the file");
+ assert.equal(last[0]!.file.path, path(FILES - 1));
+ assert.equal(last[1]?.file, undefined, "later pages hold only the next files");
+ once(last.flatMap((p) => (p.tree as { path: string }[]).map((t) => t.path)), FILES, "tree");
+ for (const p of last) assert.equal(p.treeTotal, FILES);
+ assert.ok(last[0]!.file.content.length > 1000, "the file keeps a useful start");
+ assert.equal(last[0]!.truncated, true, "cut text is reported");
+ assert.equal(last[0]!.file.contentTruncated, true);
+ });
+
+ it("keeps ranges under the limit and reaches every changed file once", async () => {
+ const few = await pages({ from: 1, to: 20 });
+ once(few.flatMap((p) => (p.files as { path: string }[]).map((f) => f.path)), 19, "files of steps 1..20 (step 1 is the prompt)");
+ const wide = await pages({ from: 1, to: FILES + 1 });
+ once(wide.flatMap((p) => (p.files as { path: string }[]).map((f) => f.path)), FILES, "files of 1..300");
+ assert.ok(wide.every((p) => p.filesTotal === FILES));
+ assert.ok(wide[0]!.truncated === true && wide[0]!.files[0].diffTruncated === true, "cut diffs are flagged");
+ assert.ok(wide[0]!.files[0].diff.length > 100, "a diff keeps its start");
+ });
+
+ it("gives one file's diff more room with limit 1", async () => {
+ const one = await call({ from: 1, to: FILES + 1, limit: 1 });
+ assert.ok(one.len <= CHARACTER_LIMIT);
+ assert.ok(one.sc.files[0].diff.length > 10_000, `a lone diff is ${one.sc.files[0].diff.length} characters`);
+ assert.equal(one.sc.nextCursor, "1");
+ });
+});
diff --git a/packages/coding.sh/test/mcp/toolevals.test.ts b/packages/coding.sh/test/mcp/toolevals.test.ts
index c37d25aa..3afb152d 100644
--- a/packages/coding.sh/test/mcp/toolevals.test.ts
+++ b/packages/coding.sh/test/mcp/toolevals.test.ts
@@ -71,8 +71,10 @@ describe("tool evals against the coding.sh MCP server", () => {
// session_tree's paging (limit, cursor, nextCursor, truncated and a page-top node's
// parentSessionId) adds about 0.2 KB within the same budget; without it an 800-subagent
// tree was one 679 KB reply.
- assert.ok(s.surfaceBytes < 42_900, `surface is ${s.surfaceBytes} bytes`);
- assert.ok(s.descriptionBytes < 11_750, `descriptions are ${s.descriptionBytes} bytes`);
+ // limitUsed in the output schema of the 21 tools that can return it, and session_replay's paging
+ // fields (treeTotal, changesTotal, filesTotal, unchanged, truncated, note), add about 0.95 KB, 0.1 KB of it descriptions.
+ assert.ok(s.surfaceBytes < 43_900, `surface is ${s.surfaceBytes} bytes`);
+ assert.ok(s.descriptionBytes < 11_850, `descriptions are ${s.descriptionBytes} bytes`);
for (const it of s.items) assert.ok(it.bytes < 4_000, `${it.name} is ${it.bytes} bytes`);
});
From b4996711d20f47f32deef25f3139b8706e07b0ad Mon Sep 17 00:00:00 2001
From: Jack <72348727+Jack-GitHub12@users.noreply.github.com>
Date: Wed, 11 Nov 2026 22:22:48 -0600
Subject: [PATCH 07/11] docs(mcp): say which tools page and which return fewer
rows, and list score_add as a writing tool
---
CHANGELOG.md | 5 +++--
apps/site/src/pages/docs/mcp.astro | 11 ++++++++---
2 files changed, 11 insertions(+), 5 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 847215dd..f467265f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,7 @@ grouped by area, with links to the docs for the details.
- **Doctor.** `coding.sh doctor` counts recent model calls past a physical limit (speed, time to first token, duration) and names the session with the most. [Troubleshooting](https://github.com/jackulau/coding.sh/blob/main/docs/troubleshooting.md)
- **SDK.** `coding_sh.weights_edit()` in the Python SDK records each weight edit a script makes as one `notification` event with `payload.kind: "weights.edit"`: tensor name, shape, weights changed, L2 and max-abs of the change, and the base and edited sha256. It adds no event kind, and `coding_sh.weights_delta()` computes the counts and norms for small arrays without numpy. [Docs](https://coding.sh/docs/api)
- **Evals.** `coding.sh exp compare A B --given ` compares only the cases where that grader passes in both runs, and prints how many it kept and dropped. With a `terminated` grader this separates "finished more answers" from "got more of the finished answers right". [Compare an edited model with its base](https://github.com/jackulau/coding.sh/blob/main/docs/guides/evals.md#compare-an-edited-model-with-its-base)
+- **MCP.** In 2026-07-28 requests, list, read and discover results carry `ttlMs` and `cacheScope`: the tool, prompt and template lists are cached for an hour, `server/discover` for five minutes, and sessions are not cached. [MCP server](https://coding.sh/docs/mcp#protocol)
### Changed
@@ -36,8 +37,8 @@ grouped by area, with links to the docs for the details.
### Fixed
-- **MCP.** In 2026-07-28 requests, list, read and discover results carry `ttlMs` and `cacheScope`. A request cancelled with `notifications/cancelled` gets no reply, a cursor on `tools/list`, `prompts/list` or `resources/templates/list` is an error, a request with a null id gets an error instead of no answer, and a 2025-03-26 session accepts batches. [MCP server](https://coding.sh/docs/mcp#protocol)
-- **MCP.** Tool answers stay under 25,000 characters: a longer page returns fewer rows with `limitUsed`, and `nextCursor` goes on from the first row left out. `session_replay`'s step list was 227,000. An error quoting a huge argument is cut to 4,000 characters, and `eval_candidates` returns at most `limit` rejected sessions. [MCP server](https://coding.sh/docs/mcp#tools)
+- **MCP.** A request cancelled with `notifications/cancelled` gets no reply (a cancel for `initialize` is ignored), a cursor on `tools/list`, `prompts/list` or `resources/templates/list` is an error, a request with a null id gets an error instead of no answer (a null-id `notifications/` message is still a notification), and a 2025-03-26 session accepts batches. [MCP server](https://coding.sh/docs/mcp#protocol)
+- **MCP.** Tool answers stay under 25,000 characters. A tool that pages returns `nextCursor`; the others return fewer rows with `limitUsed`. `session_replay` pages its files, cuts diff and file text that does not fit and says so with `truncated` (a range was 5 million characters). `benchmarks` has a default limit. [MCP server](https://coding.sh/docs/mcp#tools)
- **MCP.** `eval_init` and `score_add` are marked destructive, since both can replace stored data, and `score_list`'s error names its `since` argument instead of the CLI's `--since`. [MCP server](https://coding.sh/docs/mcp#tools)
- **Site.** The changelog and quickstart pages no longer shift on a phone while loading: release area chips sit in a fixed grid and the quickstart tab list is laid out from first paint. Layout shift fell from 0.052 to 0 and from 0.023 to 0. [Site speed](https://github.com/jackulau/coding.sh/blob/main/docs/perf.md#font-swap-and-image-sizes)
- **Stores.** A `file:///C:/...` blob URL and a `sqlite://user:password@/path` index URL open the path they name on Windows, and the user and password are no longer read as a folder. See [stores](https://github.com/jackulau/coding.sh/blob/main/docs/reference/stores.md).
diff --git a/apps/site/src/pages/docs/mcp.astro b/apps/site/src/pages/docs/mcp.astro
index 9d6654e3..6d4e47ee 100644
--- a/apps/site/src/pages/docs/mcp.astro
+++ b/apps/site/src/pages/docs/mcp.astro
@@ -114,9 +114,14 @@ const gemini = `// ~/.gemini/settings.json
score replaces the one stored for the same target, name, source and person.
- A tool answer stays under 25,000 characters. When the rows asked for would be longer, the answer has fewer rows and a
- limitUsed field, and nextCursor starts at the first row left out. The limit drops rows; it does not cut text.
- session_replay shows at most 16 KB of a file and of each diff, and says when it cut one.
+ A tool answer stays under 25,000 characters. A tool that pages (it has a cursor argument) returns
+ nextCursor, which starts at the first row left out. The others return fewer rows when the answer would be longer, with a
+ limitUsed field. Rows are dropped, not cut.
+
+
+ session_replay pages the files of a step, of a range and of the step list with limit and
+ cursor. It cuts the text of a diff, a file or a tool output that does not fit, and says so with
+ truncated: true and the field's own flag. limit: 1 gives one file's diff up to 16 KB.
Protocol
From d0a25918e50b464c0bce89571449e12bb1557232 Mon Sep 17 00:00:00 2001
From: Jack <72348727+Jack-GitHub12@users.noreply.github.com>
Date: Wed, 11 Nov 2026 22:58:12 -0600
Subject: [PATCH 08/11] test(mcp): wait longer for replies when the machine is
loaded
---
packages/coding.sh/test/mcp/protocol.test.ts | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/packages/coding.sh/test/mcp/protocol.test.ts b/packages/coding.sh/test/mcp/protocol.test.ts
index 925071d5..362159b1 100644
--- a/packages/coding.sh/test/mcp/protocol.test.ts
+++ b/packages/coding.sh/test/mcp/protocol.test.ts
@@ -66,9 +66,9 @@ function world() {
const done = serveStdio(server, { input, output });
const send = (m: unknown) => input.write((typeof m === "string" ? m : JSON.stringify(m)) + "\n");
const tick = () => new Promise((r) => setTimeout(r, 20));
- /** Waits until a reply with this id arrives (or 2 s pass). */
+ /** Waits until a reply with this id arrives (or 20 s pass: the machine may be loaded). */
const reply = async (id: unknown): Promise => {
- for (let i = 0; i < 100; i++) {
+ for (let i = 0; i < 1000; i++) {
const m = out.find((x) => JSON.stringify(x.id) === JSON.stringify(id));
if (m) return m;
await tick();
@@ -76,7 +76,7 @@ function world() {
return undefined;
};
const release = async () => {
- for (let i = 0; i < 100 && !gates.length; i++) await tick();
+ for (let i = 0; i < 1000 && !gates.length; i++) await tick();
gates.shift()!();
};
return { server, send, out, batches, reply, tick, release, close: async () => (input.end(), done) };
From 8f25477a53e0cc04a144ea8c9281fa3dc378ff18 Mon Sep 17 00:00:00 2001
From: Jack <72348727+Jack-GitHub12@users.noreply.github.com>
Date: Wed, 11 Nov 2026 23:43:24 -0600
Subject: [PATCH 09/11] fix(mcp): fit session_replay answers with one pass over
the final answer
Cut only when the answer does not fit: unpaged lists (a step's own files, edits in a tool input) keep their first items with a Total, the longest strings are cut to a common length, and the note and flags are counted in the length. A page takes rows by their size, so a small diff is not cut by the default limit. A cut inside a tool input is flagged on detail.
---
CHANGELOG.md | 2 +-
apps/site/src/pages/docs/mcp.astro | 31 ++-
packages/coding.sh/src/mcp/replay-page.ts | 225 +++++++++++-------
packages/coding.sh/src/mcp/server.ts | 2 +-
packages/coding.sh/src/mcp/tools.ts | 11 +-
packages/coding.sh/test/mcp/protocol.test.ts | 13 +-
.../coding.sh/test/mcp/replay-limit.test.ts | 96 +++++++-
scripts/lint/allowlist.json | 2 +-
8 files changed, 274 insertions(+), 108 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f467265f..d0c7ba07 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -38,7 +38,7 @@ grouped by area, with links to the docs for the details.
### Fixed
- **MCP.** A request cancelled with `notifications/cancelled` gets no reply (a cancel for `initialize` is ignored), a cursor on `tools/list`, `prompts/list` or `resources/templates/list` is an error, a request with a null id gets an error instead of no answer (a null-id `notifications/` message is still a notification), and a 2025-03-26 session accepts batches. [MCP server](https://coding.sh/docs/mcp#protocol)
-- **MCP.** Tool answers stay under 25,000 characters. A tool that pages returns `nextCursor`; the others return fewer rows with `limitUsed`. `session_replay` pages its files, cuts diff and file text that does not fit and says so with `truncated` (a range was 5 million characters). `benchmarks` has a default limit. [MCP server](https://coding.sh/docs/mcp#tools)
+- **MCP.** Tool answers stay under 25,000 characters: a longer page has fewer rows and `limitUsed`, and a tool with a `cursor` also returns `nextCursor`. `session_replay` pages its files and cuts text and lists only when an answer would not fit, saying so with `truncated` (a range was 5 million characters). [MCP server](https://coding.sh/docs/mcp#tools)
- **MCP.** `eval_init` and `score_add` are marked destructive, since both can replace stored data, and `score_list`'s error names its `since` argument instead of the CLI's `--since`. [MCP server](https://coding.sh/docs/mcp#tools)
- **Site.** The changelog and quickstart pages no longer shift on a phone while loading: release area chips sit in a fixed grid and the quickstart tab list is laid out from first paint. Layout shift fell from 0.052 to 0 and from 0.023 to 0. [Site speed](https://github.com/jackulau/coding.sh/blob/main/docs/perf.md#font-swap-and-image-sizes)
- **Stores.** A `file:///C:/...` blob URL and a `sqlite://user:password@/path` index URL open the path they name on Windows, and the user and password are no longer read as a folder. See [stores](https://github.com/jackulau/coding.sh/blob/main/docs/reference/stores.md).
diff --git a/apps/site/src/pages/docs/mcp.astro b/apps/site/src/pages/docs/mcp.astro
index 6d4e47ee..82f773ff 100644
--- a/apps/site/src/pages/docs/mcp.astro
+++ b/apps/site/src/pages/docs/mcp.astro
@@ -114,14 +114,22 @@ const gemini = `// ~/.gemini/settings.json
score replaces the one stored for the same target, name, source and person.
- A tool answer stays under 25,000 characters. A tool that pages (it has a cursor argument) returns
- nextCursor, which starts at the first row left out. The others return fewer rows when the answer would be longer, with a
- limitUsed field. Rows are dropped, not cut.
+ A tool answer stays under 25,000 characters. When the rows asked for would be longer, the answer has fewer rows and a
+ limitUsed field. A tool with a cursor argument also returns nextCursor, which starts at the
+ first row left out. Rows are dropped, not cut.
session_replay pages the files of a step, of a range and of the step list with limit and
- cursor. It cuts the text of a diff, a file or a tool output that does not fit, and says so with
- truncated: true and the field's own flag. limit: 1 gives one file's diff up to 16 KB.
+ cursor. limit: 1 gives one file's diff up to 16 KB.
+
+
+ It cuts only when an answer would still be too long. A list that is not paged (a step's own files, the edits in a tool input)
+ keeps its first items, and the longest text is cut to a common length.
+
+
+ The answer then has truncated: true, a note, and a flag such as diffTruncated or
+ filesTruncated (with filesTotal) on what was cut. A cut inside a tool input is flagged on
+ detail as inputTruncated.
In 2026-07-28, list and read results carry ttlMs and cacheScope: the tool, prompt and template lists
- are public for an hour, and resources are private and stale at once. A request cancelled with
- notifications/cancelled gets no reply. Requests run one at a time, so a cancelled request that has not started
- never runs.
+ are public for an hour, server/discover for 5 minutes (ttlMs 300000, because its
+ instructions carry an update notice), and resources are private and stale at once.
+
+
+ A request cancelled with notifications/cancelled gets no reply, except initialize, which can't be
+ cancelled. Requests run one at a time, so a cancelled request that has not started never runs.
+
+
+ A message with a null id whose method starts with notifications/ is a notification and gets no
+ reply. Any other request with a null id gets an invalid-request error.
Prompts and resources
diff --git a/packages/coding.sh/src/mcp/replay-page.ts b/packages/coding.sh/src/mcp/replay-page.ts
index 2cc955a9..935104a6 100644
--- a/packages/coding.sh/src/mcp/replay-page.ts
+++ b/packages/coding.sh/src/mcp/replay-page.ts
@@ -1,135 +1,184 @@
// session_replay's answers kept under the character limit. The file lists (`tree`, `changes`, `files`,
-// `unchanged`) are paged with limit and cursor; the text in them (diffs, file content, tool output) is
-// cut to fit what is left, and the answer says so (`truncated`, plus the field's own flag).
+// `unchanged`, the step list) are paged with limit and cursor. Then one pass over the final answer
+// cuts what is still too long: arrays that are not paged are cut to the length that fits (with a
+// `Total`), and the longest strings are cut to a common length. Each cut is flagged on the
+// object that holds it (`diffTruncated`, `filesTruncated`, …); a cut inside a recorded tool input is
+// flagged on `detail` as `inputTruncated`, never inside the input. Nothing is cut when the answer fits.
import type { RangeView, StepView } from "../core/replay.ts";
import { cutChars, toolText } from "./protocol.ts";
type Obj = Record;
-/** Text is never cut below this many characters when it sits in a row of a list, or outside one. */
-const ROW_FLOOR = 600;
-const FIXED_FLOOR = 2000;
-/** The most a diff or file shows (the replay is loaded with the same cap). */
-export const TEXT_MAX = 16 * 1024;
+/** Text is never cut below this many characters. */
+const FLOOR = 64;
+/** A page takes rows until they add up to this many characters, each counted at most ROW_SHARE. */
+const PAGE_BUDGET = 20_000;
+const ROW_SHARE = 2_500;
-const FLAG: Record = { diff: "diffTruncated", content: "contentTruncated", output: "outputTruncated", text: "textTruncated" };
+const NOTE = "Text and lists were cut to keep the answer under 25,000 characters (the fields marked truncated, with their Total). A smaller limit gives each diff more room; step with file gives one file's content.";
-const NOTE = "Text was cut to keep the answer under 25,000 characters (the fields marked truncated). A smaller limit gives each diff more room (limit 1: up to 16 KB); step with file gives one file's content.";
+const jlen = (v: unknown): number => JSON.stringify(v)?.length ?? 0;
-interface Slot {
+interface Hit {
owner: Obj;
key: string;
- floor: number;
+ /** The object that gets the flag, and its name. */
+ flagObj: Obj;
+ flagKey: string;
+ /** False inside a recorded tool input: no `Total` is written there. */
+ total: boolean;
}
-function collect(v: unknown, inRow: boolean, out: Slot[], depth = 0): void {
- if (depth > 8 || v === null || typeof v !== "object") return;
- if (Array.isArray(v)) {
- for (const x of v) collect(x, true, out, depth + 1);
- return;
- }
- const o = v as Obj;
- for (const [k, x] of Object.entries(o)) {
- if (typeof x === "string") {
- const floor = inRow ? ROW_FLOOR : FIXED_FLOOR;
- if (x.length > floor) out.push({ owner: o, key: k, floor });
- } else collect(x, inRow, out, depth + 1);
- }
+/** Strings and arrays in `v` that may be cut: everything but the paged arrays at the top. */
+function collect(root: Obj, exempt: ReadonlySet): { strings: Hit[]; arrays: Hit[] } {
+ const strings: Hit[] = [];
+ const arrays: Hit[] = [];
+ const walk = (v: unknown, input: Obj | undefined, top: boolean, depth: number): void => {
+ if (depth > 10 || v === null || typeof v !== "object") return;
+ if (Array.isArray(v)) {
+ for (const x of v) walk(x, input, false, depth + 1);
+ return;
+ }
+ const o = v as Obj;
+ for (const [k, x] of Object.entries(o)) {
+ const inInput = input ?? (k === "input" ? o : undefined);
+ const hit = { owner: o, key: k, flagObj: inInput ?? o, flagKey: inInput ? "inputTruncated" : `${k}Truncated`, total: !inInput };
+ if (typeof x === "string") strings.push(hit);
+ else if (Array.isArray(x)) {
+ if (!(top && exempt.has(k))) arrays.push(hit);
+ walk(x, inInput, false, depth + 1);
+ } else walk(x, inInput, false, depth + 1);
+ }
+ };
+ walk(root, undefined, true, 0);
+ return { strings, arrays };
}
-/** Whether any field in `v` is flagged as cut (`diffTruncated`, `contentTruncated`, …). */
-function hasFlag(v: unknown, depth = 0): boolean {
- if (depth > 8 || v === null || typeof v !== "object") return false;
- if (Array.isArray(v)) return v.some((x) => hasFlag(x, depth + 1));
- return Object.entries(v as Obj).some(([k, x]) => (k.endsWith("Truncated") && x === true) || hasFlag(x, depth + 1));
+/** `a`'s array cut to its first `keep` items. */
+function capArray(a: Hit, keep: number): void {
+ const list = a.owner[a.key] as unknown[];
+ if (a.total && a.owner[`${a.key}Total`] === undefined) a.owner[`${a.key}Total`] = list.length;
+ a.owner[a.key] = list.slice(0, keep);
+ a.flagObj[a.flagKey] = true;
+}
+
+/** The most items of `list` whose JSON fits `budget` characters. */
+function itemsWithin(list: unknown[], budget: number): number {
+ let used = 2;
+ let k = 0;
+ while (k < list.length && used + jlen(list[k]) + 1 <= budget) used += jlen(list[k++]) + 1;
+ return k;
}
/**
- * Cuts the longest strings in `page` (in place) until its text is at most `limit` characters, or no string
- * is left above its floor. The longest are cut down to a common length, so short text stays whole.
- * Sets `truncated` and `note` on the page, and the field's own flag (`diffTruncated`, …) beside a cut one.
+ * Cuts `page` (in place) until its text is at most `limit` characters: arrays over a quarter of the
+ * limit first, then the longest strings down to a common length (not below FLOOR), then the longest
+ * arrays by halves. `exempt` names the top-level arrays that are already paged. `truncated` and
+ * `note` are set only when this pass cut something, and are counted in the length.
*/
-export function fitTexts(page: Obj, limit: number): Obj {
- let len = toolText(page).length;
- if (len <= limit) return hasFlag(page) ? { ...page, truncated: true, note: NOTE } : page;
- const slots: Slot[] = [];
- collect(page, false, slots);
- let margin = 1.1;
- for (let round = 0; round < 6 && len > limit && slots.length; round++, margin += 0.4) {
- const need = Math.ceil((len - limit) * margin) + 64;
- const size = (s: Slot) => (s.owner[s.key] as string).length;
- const after = (s: Slot, level: number) => Math.max(s.floor, Math.min(size(s), level));
+export function fitAnswer(page: Obj, limit: number, exempt: readonly string[] = []): Obj {
+ if (jlen(page) <= limit) return page;
+ const ex = new Set(exempt);
+ page.truncated = true;
+ page.note = NOTE;
+ let cut = false;
+ const over = () => jlen(page) - limit;
+ const quarter = Math.floor(limit / 4);
+ for (const a of collect(page, ex).arrays) {
+ const list = a.owner[a.key] as unknown[];
+ if (jlen(list) > quarter) {
+ capArray(a, itemsWithin(list, quarter));
+ cut = true;
+ }
+ }
+ for (let round = 0, margin = 1.1; round < 6 && over() > 0; round++, margin += 0.4) {
+ const slots = collect(page, ex).strings.filter((s) => (s.owner[s.key] as string).length > FLOOR);
+ if (!slots.length) break;
+ const need = Math.ceil(over() * margin) + 64;
+ const size = (s: Hit) => (s.owner[s.key] as string).length;
+ const after = (s: Hit, level: number) => Math.max(FLOOR, Math.min(size(s), level));
const saved = (level: number) => slots.reduce((sum, s) => sum + size(s) - after(s, level), 0);
- let lo = 0;
+ let lo = FLOOR;
let hi = Math.max(...slots.map(size));
- // The highest level that still saves `need` characters (or the lowest there is).
+ // The highest common length that still saves `need` characters (or the lowest there is).
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
if (saved(mid) >= need) lo = mid;
else hi = mid - 1;
}
- let cut = false;
for (const s of slots) {
const to = after(s, lo);
if (to >= size(s)) continue;
s.owner[s.key] = cutChars(s.owner[s.key] as string, to);
- const flag = FLAG[s.key];
- if (flag) s.owner[flag] = true;
+ s.flagObj[s.flagKey] = true;
cut = true;
}
- if (!cut) break;
- len = toolText({ ...page, truncated: true, note: NOTE }).length;
}
- return { ...page, truncated: true, note: NOTE };
+ while (over() > 0) {
+ const arrays = collect(page, ex).arrays.filter((a) => (a.owner[a.key] as unknown[]).length > 0);
+ if (!arrays.length) break;
+ const big = arrays.reduce((m, a) => (jlen(a.owner[a.key]) > jlen(m.owner[m.key]) ? a : m));
+ capArray(big, Math.floor((big.owner[big.key] as unknown[]).length / 2));
+ cut = true;
+ }
+ if (!cut) {
+ delete page.truncated;
+ delete page.note;
+ }
+ return page;
+}
+
+/** How many rows a page takes from `offset`: up to n, while their text adds up to PAGE_BUDGET (at least one). */
+function takeRows(n: number, offset: number, total: number, cost: (i: number) => number): number {
+ let used = 0;
+ let k = 0;
+ for (let i = offset; i < total && k < n; i++) {
+ const c = cost(i);
+ if (k > 0 && used + c > PAGE_BUDGET) break;
+ used += c;
+ k++;
+ }
+ return k;
}
-/** A diff cut to `max` characters at a line end, flagged when cut. */
-function capDiff(o: { diff?: string; diffTruncated?: boolean }, max: number): void {
- if (o.diff === undefined || o.diff.length <= max) return;
- let cut = cutChars(o.diff, max);
- const nl = cut.lastIndexOf("\n");
- if (nl > 0) cut = cut.slice(0, nl + 1);
- o.diff = cut;
- o.diffTruncated = true;
+const costs = new WeakMap