Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <grader>` 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

Expand All @@ -36,6 +37,9 @@ 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 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).
- **Capture.** Model calls no longer show impossible speeds such as 3750 tok/s. A hook reading a transcript from mid-response, or late Codex lines, gave a call a start time inside 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. [Adapters](https://github.com/jackulau/coding.sh/blob/main/docs/ADAPTERS.md#known-breakage-to-design-around)
Expand Down
43 changes: 42 additions & 1 deletion apps/site/src/pages/docs/mcp.astro
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,48 @@ const gemini = `// ~/.gemini/settings.json
</div>
<p>
Only <code>eval_from_session</code>, <code>eval_init</code>, <code>score_add</code> and <code>exp_session_note</code> write. Agents ask
you before <code>eval_from_session</code> and <code>eval_init</code>. Notes have secrets redacted.
you before <code>eval_from_session</code> and <code>eval_init</code>. Notes have secrets redacted. <code>eval_init</code> and
<code>score_add</code> are marked destructive, because <code>eval_init</code> with <code>force</code> replaces a suite and a new
score replaces the one stored for the same target, name, source and person.
</p>
<p>
A tool answer stays under 25,000 characters. When the rows asked for would be longer, the answer has fewer rows and a
<code>limitUsed</code> field. A tool with a <code>cursor</code> argument also returns <code>nextCursor</code>, which starts at the
first row left out. Rows are dropped, not cut.
</p>
<p>
<code>session_replay</code> pages the files of a step, of a range and of the step list with <code>limit</code> and
<code>cursor</code>. A diff or file over 16 KB is cut when the replay is built, and flagged <code>diffTruncated</code> or <code>contentTruncated</code>.
</p>
<p>
A page takes whole rows while they fit, so a short page has <code>limitUsed</code> and a <code>nextCursor</code>. It cuts only
when one row, or the rest of the answer, is still too long. Then the room is shared out: short text stays whole, the longest is
cut, and a long list keeps its first items. Ids and paths are never cut.
</p>
<p>
The answer then has <code>truncated: true</code>, a <code>note</code>, and a flag such as <code>diffTruncated</code> or
<code>filesTruncated</code> (with <code>filesTotal</code>, or <code>truncatedKeys</code> on an object) on what was cut. A cut inside a tool input is flagged as <code>inputTruncated</code> on the
object that holds the input (<code>detail</code>, or <code>action</code> in a fork answer), never inside the input.
</p>

<h2 id="protocol">Protocol</h2>
<p>
The server speaks MCP over stdio. It accepts the stateless 2026-07-28 revision (<code>server/discover</code>, no handshake) and
the <code>initialize</code> handshake for 2025-11-25, 2025-06-18 and 2025-03-26. An <code>initialize</code> 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.
</p>
<p>
In 2026-07-28, list and read results carry <code>ttlMs</code> and <code>cacheScope</code>: the tool, prompt and template lists
are <code>public</code> for an hour, <code>server/discover</code> for 5 minutes (<code>ttlMs</code> 300000, because its
instructions carry an update notice), and resources are <code>private</code> and stale at once.
</p>
<p>
A request cancelled with <code>notifications/cancelled</code> gets no reply, except <code>initialize</code>, which can't be
cancelled. Requests run one at a time, so a cancelled request that has not started never runs.
</p>
<p>
A message with a null <code>id</code> whose method starts with <code>notifications/</code> is a notification and gets no
reply. Any other request with a null <code>id</code> gets an invalid-request error.
</p>

<h2 id="prompts">Prompts and resources</h2>
Expand Down
141 changes: 116 additions & 25 deletions packages/coding.sh/src/mcp/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,48 @@ 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);
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. server/discover carries the
* instructions, which hold a per-call update notice, so it is cached for less time.
*/
const CACHE_HINTS: Record<string, { ttlMs: number; cacheScope: "public" | "private" }> = {
"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" },
"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);
Expand All @@ -172,18 +211,39 @@ export class McpServer<C> {
};
}

/** Handles one decoded message. Returns the response, or null for notifications and stray responses. */
async handle(msg: unknown): Promise<RpcResponse | null> {
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<RpcResponse | null>;
handle(msg: unknown): Promise<RpcResponse | RpcResponse[] | null>;
async handle(msg: unknown): Promise<RpcResponse | RpcResponse[] | null> {
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<RpcResponse | null> {
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.
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;
}
Expand All @@ -208,7 +268,7 @@ export class McpServer<C> {
}
}
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)}`);
Expand All @@ -218,12 +278,12 @@ export class McpServer<C> {

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<Obj> {
Expand All @@ -240,6 +300,7 @@ export class McpServer<C> {
case "ping":
return {};
case "tools/list":
noCursor(method, params);
return {
tools: this.o.tools.map((t) => ({
name: t.name,
Expand All @@ -261,17 +322,19 @@ export class McpServer<C> {
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": {
const r = this.o.resources;
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);
Expand Down Expand Up @@ -308,15 +371,15 @@ export class McpServer<C> {
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)}`);
}
}
}

function errorResult(text: string): Obj {
return { content: [{ type: "text", text }], isError: true };
return { content: [{ type: "text", text: clipText(text) }], isError: true };
}

// ------------------------------------------------------------ stdio
Expand All @@ -329,31 +392,59 @@ 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<C>(server: McpServer<C>, io: StdioStreams, log: (m: string) => void = () => {}, signal?: AbortSignal): Promise<void> {
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<string, { cancelled: boolean; method: string }>();
// 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<void>((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;
// 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, method: isObj(msg) && typeof msg.method === "string" ? msg.method : "" };
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");
Expand Down
Loading
Loading