pennyrouter 0.2.9 → 0.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,171 @@
1
+ import assert from "node:assert/strict";
2
+
3
+ import {
4
+ SERVER_NAME,
5
+ applyClaudeRegistry,
6
+ isManagedEntry,
7
+ removeManagedCodexMcp,
8
+ renderCodexMcp,
9
+ serverEntry,
10
+ } from "./mcp-register.js";
11
+
12
+ // -- what gets written ----------------------------------------------------
13
+ // Unpinned on purpose: an absolute path breaks on a node/nvm switch and the server then stops
14
+ // appearing silently, and a pinned version would freeze the session writers — wrong for a
15
+ // feature whose risk is that the harnesses change their undocumented formats.
16
+ {
17
+ const entry = serverEntry();
18
+ assert.equal(entry.command, "npx");
19
+ assert.deepEqual(entry.args, ["-y", "pennyrouter", "mcp"]);
20
+ assert.ok(!JSON.stringify(entry).includes("/"), "no absolute paths");
21
+ assert.ok(!/pennyrouter@/.test(JSON.stringify(entry)), "not version-pinned");
22
+ }
23
+
24
+ // -- Claude Code registry -------------------------------------------------
25
+ {
26
+ // Registering into an empty config.
27
+ const fresh = applyClaudeRegistry({});
28
+ assert.deepEqual(fresh.mcpServers[SERVER_NAME], serverEntry());
29
+
30
+ // Existing unrelated servers are preserved.
31
+ const withOthers = applyClaudeRegistry({
32
+ mcpServers: { railway: { command: "railway" } },
33
+ projects: { "/x": {} },
34
+ });
35
+ assert.ok(withOthers.mcpServers.railway, "other servers survive");
36
+ assert.ok(withOthers.projects, "unrelated top-level keys survive");
37
+
38
+ // Re-installing updates in place rather than duplicating.
39
+ const twice = applyClaudeRegistry(applyClaudeRegistry({}));
40
+ assert.equal(Object.keys(twice.mcpServers).length, 1);
41
+
42
+ // A same-named server we did not write is a hard stop, not a silent overwrite.
43
+ assert.throws(
44
+ () => applyClaudeRegistry({ mcpServers: { [SERVER_NAME]: { command: "/my/own/build" } } }),
45
+ /did not write/,
46
+ );
47
+
48
+ // Removal takes ours and leaves everything else.
49
+ const removed = applyClaudeRegistry(
50
+ { mcpServers: { railway: { command: "railway" }, [SERVER_NAME]: serverEntry() } },
51
+ { remove: true },
52
+ );
53
+ assert.ok(!removed.mcpServers[SERVER_NAME]);
54
+ assert.ok(removed.mcpServers.railway);
55
+
56
+ // A user who re-pointed the name at their own build keeps it through an uninstall.
57
+ const custom = { mcpServers: { [SERVER_NAME]: { command: "/my/own/build" } } };
58
+ assert.deepEqual(applyClaudeRegistry(custom, { remove: true }), custom);
59
+
60
+ // Removing the last server drops the key entirely rather than leaving `{}`.
61
+ const emptied = applyClaudeRegistry(
62
+ { mcpServers: { [SERVER_NAME]: serverEntry() } },
63
+ { remove: true },
64
+ );
65
+ assert.ok(!("mcpServers" in emptied));
66
+ }
67
+
68
+ // -- managed-entry detection ----------------------------------------------
69
+ assert.equal(isManagedEntry(serverEntry()), true);
70
+ assert.equal(isManagedEntry({ command: "npx", args: ["-y", "pennyrouter@1.2.3", "mcp"] }), true);
71
+ assert.equal(isManagedEntry({ command: "/my/own/build" }), false);
72
+ assert.equal(isManagedEntry(null), false);
73
+ assert.equal(isManagedEntry({}), false);
74
+
75
+ // -- Codex TOML -----------------------------------------------------------
76
+ {
77
+ const existing = [
78
+ 'model_provider = "pennyrouter"',
79
+ "",
80
+ "[mcp_servers.node_repl]",
81
+ 'command = "/Applications/Codex.app/node_repl"',
82
+ ].join("\n");
83
+
84
+ const written = renderCodexMcp(existing);
85
+ assert.ok(written.includes(`[mcp_servers.${SERVER_NAME}]`));
86
+ assert.ok(written.includes('command = "npx"'));
87
+ assert.ok(written.includes('args = ["-y", "pennyrouter", "mcp"]'));
88
+ assert.ok(written.includes("[mcp_servers.node_repl]"), "existing servers survive");
89
+
90
+ // A TOML table header captures every key after it, so our block must be appended — splicing
91
+ // it above an existing table would reparent that table's keys into ours.
92
+ const ourIndex = written.indexOf(`[mcp_servers.${SERVER_NAME}]`);
93
+ assert.ok(ourIndex > written.indexOf("[mcp_servers.node_repl]"), "appended, not spliced");
94
+ assert.ok(
95
+ written.indexOf('model_provider = "pennyrouter"') < written.indexOf("[mcp_servers."),
96
+ "root keys stay above every table",
97
+ );
98
+
99
+ // Re-installing is idempotent: the managed block is replaced, not stacked.
100
+ const again = renderCodexMcp(written);
101
+ assert.equal(again.match(new RegExp(`\\[mcp_servers\\.${SERVER_NAME}\\]`, "g")).length, 1);
102
+
103
+ // Removal restores the original.
104
+ assert.equal(renderCodexMcp(written, { remove: true }).trim(), existing.trim());
105
+ assert.equal(removeManagedCodexMcp(written).trim(), existing.trim());
106
+
107
+ // An unmanaged server of the same name is a hard stop.
108
+ assert.throws(
109
+ () => renderCodexMcp(`[mcp_servers.${SERVER_NAME}]\ncommand = "mine"`),
110
+ /outside PennyRouter's managed block/,
111
+ );
112
+
113
+ // Empty config in, valid config out.
114
+ assert.ok(renderCodexMcp("").includes(`[mcp_servers.${SERVER_NAME}]`));
115
+ assert.equal(renderCodexMcp("", { remove: true }), "");
116
+ }
117
+
118
+ // -- status reporting -----------------------------------------------------
119
+ // `mcp status` is how someone finds out they are in the state install cannot reach: opted out
120
+ // with --no-mcp, or blocked by a name conflict. Both must be distinguishable from "not set up".
121
+ {
122
+ const { mkdtempSync, writeFileSync, mkdirSync } = await import("node:fs");
123
+ const { tmpdir } = await import("node:os");
124
+ const { join } = await import("node:path");
125
+ const { mcpStatus } = await import("./mcp-register.js");
126
+
127
+ const home = mkdtempSync(join(tmpdir(), "pr-mcp-status-"));
128
+ const originalHome = process.env.HOME;
129
+ process.env.HOME = home;
130
+ try {
131
+ // No configs at all: reported as absent, not as "not registered".
132
+ let rows = await mcpStatus(["claude-code", "codex"]);
133
+ assert.deepEqual(rows.map((r) => r.configFound), [false, false]);
134
+
135
+ // Present but not registered — the --no-mcp state.
136
+ writeFileSync(join(home, ".claude.json"), JSON.stringify({ mcpServers: {} }));
137
+ mkdirSync(join(home, ".codex"), { recursive: true });
138
+ writeFileSync(join(home, ".codex", "config.toml"), 'model = "gpt-5"\n');
139
+ rows = await mcpStatus(["claude-code", "codex"]);
140
+ assert.deepEqual(rows.map((r) => r.registered), [false, false]);
141
+ assert.deepEqual(rows.map((r) => r.conflict), [false, false]);
142
+ assert.deepEqual(rows.map((r) => r.configFound), [true, true]);
143
+
144
+ // Someone else's server under our name.
145
+ writeFileSync(
146
+ join(home, ".claude.json"),
147
+ JSON.stringify({ mcpServers: { [SERVER_NAME]: { command: "/my/own/build" } } }),
148
+ );
149
+ writeFileSync(
150
+ join(home, ".codex", "config.toml"),
151
+ `[mcp_servers.${SERVER_NAME}]\ncommand = "mine"\n`,
152
+ );
153
+ rows = await mcpStatus(["claude-code", "codex"]);
154
+ assert.deepEqual(rows.map((r) => r.conflict), [true, true]);
155
+ assert.deepEqual(rows.map((r) => r.registered), [false, false]);
156
+
157
+ // Ours.
158
+ writeFileSync(
159
+ join(home, ".claude.json"),
160
+ JSON.stringify(applyClaudeRegistry({})),
161
+ );
162
+ writeFileSync(join(home, ".codex", "config.toml"), renderCodexMcp(""));
163
+ rows = await mcpStatus(["claude-code", "codex"]);
164
+ assert.deepEqual(rows.map((r) => r.registered), [true, true]);
165
+ assert.deepEqual(rows.map((r) => r.conflict), [false, false]);
166
+ } finally {
167
+ process.env.HOME = originalHome;
168
+ }
169
+ }
170
+
171
+ console.log("mcp-register tests passed");
package/src/mcp.js ADDED
@@ -0,0 +1,256 @@
1
+ /* PennyRouter MCP server (stdio).
2
+
3
+ Solves the last mile: a thread you can read in the browser is not a thread you can code from.
4
+ These two tools let an agent take a share link and either read it or *become* it.
5
+
6
+ thread_read fetch + parse, return the transcript to the model. For "what did we decide
7
+ in this chat?" — the body lands in the caller's context, which is the point.
8
+ thread_resume fetch + parse, write a resumable session for Claude Code OR Codex, return
9
+ only a path and a resume command. For "let's continue coding from this chat"
10
+ — the transcript never enters the caller's context, so moving a 200-turn
11
+ thread costs the caller nothing. That asymmetry is the whole reason for two
12
+ tools rather than one with a flag.
13
+
14
+ Because the destination is a parameter, "resume this Perplexity thread in Codex" and "…in
15
+ Claude Code" are the same call with a different `harness`. Each harness has its own writer:
16
+ the two on-disk formats share nothing structurally (see thread-session-codex.js).
17
+
18
+ ALL SCRAPING STAYS SERVER-SIDE. This process holds no adapters, no fetcher credentials and no
19
+ provider knowledge; it POSTs a URL to the gateway and gets a canonical thread back. What ships
20
+ to a user's machine is a client, not the machinery.
21
+
22
+ Hand-rolled JSON-RPC rather than @modelcontextprotocol/sdk: this package has zero dependencies
23
+ and is installed by `npx pennyrouter`, where every transitive dep is a supply-chain surface and
24
+ a slower cold start. The protocol surface actually needed here is four methods. */
25
+
26
+ import { createInterface } from "node:readline";
27
+ import { execFileSync } from "node:child_process";
28
+
29
+ import { writeSession, VERIFIED_MIN, VERIFIED_MAX } from "./thread-session.js";
30
+ import { writeSession as writeCodexSession } from "./thread-session-codex.js";
31
+
32
+ const PROTOCOL_VERSION = "2024-11-05";
33
+
34
+ const TOOLS = [
35
+ {
36
+ name: "thread_read",
37
+ description:
38
+ "Fetch an AI conversation from a share link (ChatGPT, Claude, Gemini, Perplexity) and " +
39
+ "return its transcript. Use when the user wants to discuss, summarize or ask about a " +
40
+ "past conversation. The transcript enters your context — for long threads that are " +
41
+ "meant to be continued as work, prefer thread_resume.",
42
+ inputSchema: {
43
+ type: "object",
44
+ properties: {
45
+ url: { type: "string", description: "The share link to read." },
46
+ },
47
+ required: ["url"],
48
+ },
49
+ },
50
+ {
51
+ name: "thread_resume",
52
+ description:
53
+ "Fetch an AI conversation from a share link and write it as a resumable coding-agent " +
54
+ "session (Claude Code or Codex), so the user can resume into it and continue coding with " +
55
+ "that conversation as history. Use when the user wants to CONTINUE or BUILD FROM a past " +
56
+ "chat rather than talk about it. Returns a session id and path; the transcript is " +
57
+ "deliberately NOT returned, so importing a long thread costs you no context.",
58
+ inputSchema: {
59
+ type: "object",
60
+ properties: {
61
+ url: { type: "string", description: "The share link to import." },
62
+ directory: {
63
+ type: "string",
64
+ description:
65
+ "Absolute path to the workspace the session should resume in. Defaults to the " +
66
+ "current working directory.",
67
+ },
68
+ harness: {
69
+ type: "string",
70
+ enum: ["claude-code", "codex"],
71
+ description:
72
+ "Which agent should be able to resume it. Defaults to claude-code. Use codex when " +
73
+ "the user asks to continue the conversation in Codex.",
74
+ },
75
+ },
76
+ required: ["url"],
77
+ },
78
+ },
79
+ ];
80
+
81
+ /** `<tool> --version`, or null when the tool is not installed. The writers version-gate on this
82
+ and refuse rather than write a session file that only fails at resume time. */
83
+ function toolVersion(binary) {
84
+ try {
85
+ return execFileSync(binary, ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ async function fetchCanonical(url, { gatewayBaseUrl, apiKey }) {
92
+ const response = await fetch(`${gatewayBaseUrl}/v1/penny/threads/canonical`, {
93
+ method: "POST",
94
+ headers: {
95
+ "content-type": "application/json",
96
+ authorization: `Bearer ${apiKey}`,
97
+ },
98
+ body: JSON.stringify({ url }),
99
+ });
100
+
101
+ if (!response.ok) {
102
+ let detail = `HTTP ${response.status}`;
103
+ try {
104
+ const body = await response.json();
105
+ // The gateway's 400s carry copy written for a person; surface it rather than a status.
106
+ if (body?.detail) detail = body.detail;
107
+ } catch {
108
+ /* non-JSON error body; the status is all we have */
109
+ }
110
+ throw new Error(detail);
111
+ }
112
+ return response.json();
113
+ }
114
+
115
+ function transcript(thread) {
116
+ const lines = [`# ${thread.title || "Imported thread"}`];
117
+ if (thread.source_url) lines.push(`Source: ${thread.source_url}`);
118
+ lines.push("");
119
+ for (const message of thread.messages || []) {
120
+ const body = (message.blocks || [])
121
+ .map((b) => {
122
+ if (b.type === "text") return b.text;
123
+ if (b.type === "file") return ["```" + (b.language || ""), b.text, "```"].join("\n");
124
+ if (b.type === "thinking") return null;
125
+ if (b.type === "tool_use") return `[used ${b.name}]`;
126
+ if (b.type === "tool_result") return `[tool result] ${b.output || ""}`;
127
+ return null;
128
+ })
129
+ .filter((t) => t && String(t).trim())
130
+ .join("\n\n");
131
+ if (!body.trim()) continue;
132
+ lines.push(`## ${message.role}`, body, "");
133
+ }
134
+ return lines.join("\n").trim();
135
+ }
136
+
137
+ async function callTool(name, args, context) {
138
+ const url = String(args?.url || "").trim();
139
+ if (!url) throw new Error("a share link is required");
140
+
141
+ if (name === "thread_read") {
142
+ const thread = await fetchCanonical(url, context);
143
+ return transcript(thread);
144
+ }
145
+
146
+ if (name === "thread_resume") {
147
+ const cwd = String(args?.directory || process.cwd());
148
+ const harness = String(args?.harness || "claude-code").toLowerCase();
149
+ if (harness !== "claude-code" && harness !== "codex") {
150
+ throw new Error(`unknown harness "${harness}"; expected claude-code or codex`);
151
+ }
152
+
153
+ const codex = harness === "codex";
154
+ const version = codex ? toolVersion("codex") : toolVersion("claude");
155
+ const thread = await fetchCanonical(url, context);
156
+ const write = codex ? writeCodexSession : writeSession;
157
+ const { sessionId, path, messageCount } = write(thread, { cwd, version });
158
+
159
+ return [
160
+ `Imported "${thread.title}" (${thread.messages?.length || 0} messages, ` +
161
+ `~${thread.token_estimate || 0} tokens) from ${thread.origin_app || thread.source}.`,
162
+ "",
163
+ `Wrote a resumable ${codex ? "Codex" : "Claude Code"} session: ${messageCount} records`,
164
+ ` ${path}`,
165
+ "",
166
+ "Resume it with:",
167
+ codex
168
+ ? ` cd ${cwd} && codex resume ${sessionId}`
169
+ : ` cd ${cwd} && claude --resume ${sessionId}`,
170
+ "",
171
+ codex
172
+ ? "Tool calls were rendered as prose: Codex and the source harness do not share a tool " +
173
+ "format, so this is a readable continuation rather than a replay."
174
+ : "The transcript was written to disk and deliberately not returned here, so it did not " +
175
+ "consume this session's context.",
176
+ ].join("\n");
177
+ }
178
+
179
+ throw new Error(`unknown tool: ${name}`);
180
+ }
181
+
182
+ function send(message) {
183
+ process.stdout.write(JSON.stringify(message) + "\n");
184
+ }
185
+
186
+ function reply(id, result) {
187
+ send({ jsonrpc: "2.0", id, result });
188
+ }
189
+
190
+ /** Tool failures are reported as results with isError, not as JSON-RPC errors: the model should
191
+ see what went wrong and be able to tell the user, not have the call vanish into the harness. */
192
+ function toolError(id, error) {
193
+ reply(id, {
194
+ content: [{ type: "text", text: `Failed: ${error?.message || String(error)}` }],
195
+ isError: true,
196
+ });
197
+ }
198
+
199
+ export async function runMcpServer({ gatewayBaseUrl, apiKey }) {
200
+ if (!apiKey) {
201
+ process.stderr.write(
202
+ "pennyrouter mcp: no API key. Run `npx pennyrouter install` first.\n",
203
+ );
204
+ process.exitCode = 1;
205
+ return;
206
+ }
207
+
208
+ const context = { gatewayBaseUrl, apiKey };
209
+ const rl = createInterface({ input: process.stdin });
210
+
211
+ for await (const line of rl) {
212
+ if (!line.trim()) continue;
213
+
214
+ let request;
215
+ try {
216
+ request = JSON.parse(line);
217
+ } catch {
218
+ continue; // a malformed frame has no id to answer to
219
+ }
220
+
221
+ const { id, method, params } = request;
222
+ try {
223
+ if (method === "initialize") {
224
+ reply(id, {
225
+ protocolVersion: PROTOCOL_VERSION,
226
+ capabilities: { tools: {} },
227
+ serverInfo: { name: "pennyrouter", version: "1.0.0" },
228
+ });
229
+ } else if (method === "tools/list") {
230
+ reply(id, { tools: TOOLS });
231
+ } else if (method === "tools/call") {
232
+ try {
233
+ const text = await callTool(params?.name, params?.arguments || {}, context);
234
+ reply(id, { content: [{ type: "text", text }] });
235
+ } catch (error) {
236
+ toolError(id, error);
237
+ }
238
+ } else if (method === "ping") {
239
+ reply(id, {});
240
+ } else if (id !== undefined) {
241
+ // Notifications (no id) are fire-and-forget and must never be answered.
242
+ send({ jsonrpc: "2.0", id, error: { code: -32601, message: `unknown method: ${method}` } });
243
+ }
244
+ } catch (error) {
245
+ if (id !== undefined) {
246
+ send({
247
+ jsonrpc: "2.0",
248
+ id,
249
+ error: { code: -32603, message: error?.message || String(error) },
250
+ });
251
+ }
252
+ }
253
+ }
254
+ }
255
+
256
+ export { VERIFIED_MIN, VERIFIED_MAX };
@@ -0,0 +1,237 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawn } from "node:child_process";
3
+ import { createServer } from "node:http";
4
+ import { mkdtempSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { fileURLToPath } from "node:url";
7
+ import { dirname, join } from "node:path";
8
+
9
+ const here = dirname(fileURLToPath(import.meta.url));
10
+ const binary = join(here, "..", "bin", "pennyrouter.js");
11
+
12
+ const thread = {
13
+ schema_version: 1,
14
+ source: "perplexity_share",
15
+ title: "Kickplate removal",
16
+ origin_app: "Perplexity",
17
+ source_url: "https://www.perplexity.ai/search/x",
18
+ token_estimate: 892,
19
+ warnings: [],
20
+ messages: [
21
+ { key: "m0", ordinal: 0, role: "user", blocks: [{ type: "text", text: "UNIQUE-QUESTION" }] },
22
+ {
23
+ key: "m1",
24
+ ordinal: 1,
25
+ role: "assistant",
26
+ blocks: [{ type: "text", text: "UNIQUE-ANSWER" }],
27
+ },
28
+ ],
29
+ };
30
+
31
+ /** Stand in for the gateway so no scraping or network access is involved. */
32
+ function stubGateway(handler) {
33
+ const server = createServer((req, res) => {
34
+ let body = "";
35
+ req.on("data", (c) => (body += c));
36
+ req.on("end", () => handler(req, body, res));
37
+ });
38
+ return new Promise((resolve) => {
39
+ server.listen(0, () => resolve({ server, port: server.address().port }));
40
+ });
41
+ }
42
+
43
+ /** Drive the server over stdio and collect the responses to `requests`. */
44
+ function rpc(requests, { port, home }) {
45
+ return new Promise((resolve, reject) => {
46
+ const child = spawn(
47
+ process.execPath,
48
+ [binary, "mcp", "--penny-key", "pr-test", "--gateway-base-url", `http://127.0.0.1:${port}`],
49
+ { stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, HOME: home } },
50
+ );
51
+ let out = "";
52
+ let err = "";
53
+ child.stdout.on("data", (d) => (out += d));
54
+ child.stderr.on("data", (d) => (err += d));
55
+ child.on("error", reject);
56
+ child.on("close", () => {
57
+ const messages = out
58
+ .trim()
59
+ .split("\n")
60
+ .filter(Boolean)
61
+ .map((line) => JSON.parse(line));
62
+ resolve({ messages, stderr: err });
63
+ });
64
+ for (const request of requests) child.stdin.write(JSON.stringify(request) + "\n");
65
+ setTimeout(() => child.stdin.end(), 900);
66
+ });
67
+ }
68
+
69
+ const home = mkdtempSync(join(tmpdir(), "pr-mcp-home-"));
70
+ const workspace = mkdtempSync(join(tmpdir(), "pr-mcp-ws-"));
71
+
72
+ // -- happy path -----------------------------------------------------------
73
+ {
74
+ let requestBody = null;
75
+ let authHeader = null;
76
+ const { server, port } = await stubGateway((req, body, res) => {
77
+ requestBody = body;
78
+ authHeader = req.headers.authorization;
79
+ res.setHeader("content-type", "application/json");
80
+ res.end(JSON.stringify(thread));
81
+ });
82
+
83
+ const { messages } = await rpc(
84
+ [
85
+ { jsonrpc: "2.0", id: 1, method: "initialize", params: {} },
86
+ { jsonrpc: "2.0", id: 2, method: "tools/list" },
87
+ {
88
+ jsonrpc: "2.0",
89
+ id: 3,
90
+ method: "tools/call",
91
+ params: { name: "thread_read", arguments: { url: "https://www.perplexity.ai/search/x" } },
92
+ },
93
+ {
94
+ jsonrpc: "2.0",
95
+ id: 4,
96
+ method: "tools/call",
97
+ params: {
98
+ name: "thread_resume",
99
+ arguments: { url: "https://www.perplexity.ai/search/x", directory: workspace },
100
+ },
101
+ },
102
+ ],
103
+ { port, home },
104
+ );
105
+ server.close();
106
+
107
+ const byId = new Map(messages.map((m) => [m.id, m]));
108
+
109
+ assert.equal(byId.get(1).result.protocolVersion, "2024-11-05");
110
+ assert.equal(byId.get(1).result.serverInfo.name, "pennyrouter");
111
+
112
+ assert.deepEqual(
113
+ byId.get(2).result.tools.map((t) => t.name),
114
+ ["thread_read", "thread_resume"],
115
+ );
116
+
117
+ // The URL is forwarded to the gateway under the caller's key; no provider logic runs here.
118
+ assert.equal(JSON.parse(requestBody).url, "https://www.perplexity.ai/search/x");
119
+ assert.equal(authHeader, "Bearer pr-test");
120
+
121
+ // thread_read puts the conversation in the caller's context — that is its whole purpose.
122
+ const read = byId.get(3).result.content[0].text;
123
+ assert.ok(read.includes("UNIQUE-QUESTION"));
124
+ assert.ok(read.includes("UNIQUE-ANSWER"));
125
+
126
+ // thread_resume must NOT: the asymmetry between the two tools is the reason both exist, and
127
+ // a regression here silently reintroduces the token cost the tool was built to avoid.
128
+ const resumed = byId.get(4).result.content[0].text;
129
+ assert.ok(!resumed.includes("UNIQUE-QUESTION"), "thread_resume must not return the transcript");
130
+ assert.ok(!resumed.includes("UNIQUE-ANSWER"), "thread_resume must not return the transcript");
131
+ assert.ok(resumed.includes("claude --resume"), "it must tell the user how to resume");
132
+ assert.ok(resumed.includes("Kickplate removal"));
133
+ assert.equal(byId.get(4).result.isError, undefined);
134
+ }
135
+
136
+ // -- harness routing ------------------------------------------------------
137
+ // "Resume this in Codex" is the same call with a different `harness`, so the tool must dispatch
138
+ // to the right writer and print the right resume command. Skipped when codex is not installed:
139
+ // the writer version-gates on `codex --version` and correctly refuses without it.
140
+ {
141
+ const { server, port } = await stubGateway((req, body, res) => {
142
+ res.setHeader("content-type", "application/json");
143
+ res.end(JSON.stringify(thread));
144
+ });
145
+
146
+ const { messages } = await rpc(
147
+ [
148
+ { jsonrpc: "2.0", id: 1, method: "tools/list" },
149
+ {
150
+ jsonrpc: "2.0",
151
+ id: 2,
152
+ method: "tools/call",
153
+ params: {
154
+ name: "thread_resume",
155
+ arguments: { url: "https://x/y", directory: workspace, harness: "codex" },
156
+ },
157
+ },
158
+ {
159
+ jsonrpc: "2.0",
160
+ id: 3,
161
+ method: "tools/call",
162
+ params: {
163
+ name: "thread_resume",
164
+ arguments: { url: "https://x/y", directory: workspace, harness: "nonsense" },
165
+ },
166
+ },
167
+ ],
168
+ { port, home },
169
+ );
170
+ server.close();
171
+
172
+ const byId = new Map(messages.map((m) => [m.id, m]));
173
+
174
+ const schema = byId.get(1).result.tools.find((t) => t.name === "thread_resume").inputSchema;
175
+ assert.deepEqual(schema.properties.harness.enum, ["claude-code", "codex"]);
176
+
177
+ const codex = byId.get(2).result;
178
+ if (codex.isError) {
179
+ assert.match(codex.content[0].text, /Codex .*outside the range|ENOENT|not.*install/i);
180
+ } else {
181
+ assert.ok(codex.content[0].text.includes("codex resume"), "prints the codex resume command");
182
+ assert.ok(!codex.content[0].text.includes("claude --resume"));
183
+ assert.ok(!codex.content[0].text.includes("UNIQUE-QUESTION"), "still returns no transcript");
184
+ }
185
+
186
+ // An unknown harness is refused rather than silently defaulting to the wrong format.
187
+ assert.equal(byId.get(3).result.isError, true);
188
+ assert.match(byId.get(3).result.content[0].text, /unknown harness/);
189
+ }
190
+
191
+ // -- gateway failures reach the model as readable text --------------------
192
+ {
193
+ const { server, port } = await stubGateway((req, body, res) => {
194
+ res.statusCode = 400;
195
+ res.setHeader("content-type", "application/json");
196
+ res.end(JSON.stringify({ detail: "That link returned 404. It may be private or deleted." }));
197
+ });
198
+
199
+ const { messages } = await rpc(
200
+ [
201
+ {
202
+ jsonrpc: "2.0",
203
+ id: 1,
204
+ method: "tools/call",
205
+ params: { name: "thread_read", arguments: { url: "https://chatgpt.com/share/gone" } },
206
+ },
207
+ ],
208
+ { port, home },
209
+ );
210
+ server.close();
211
+
212
+ const result = messages.find((m) => m.id === 1).result;
213
+ // Reported as an errored *result*, not a JSON-RPC error: the model should be able to relay
214
+ // what went wrong instead of the call vanishing inside the harness.
215
+ assert.equal(result.isError, true);
216
+ assert.ok(result.content[0].text.includes("may be private or deleted"));
217
+ }
218
+
219
+ // -- protocol hygiene -----------------------------------------------------
220
+ {
221
+ const { server, port } = await stubGateway((req, body, res) => res.end("{}"));
222
+ const { messages } = await rpc(
223
+ [
224
+ { jsonrpc: "2.0", method: "notifications/initialized" }, // no id: must not be answered
225
+ { jsonrpc: "2.0", id: 7, method: "ping" },
226
+ { jsonrpc: "2.0", id: 8, method: "no/such/method" },
227
+ ],
228
+ { port, home },
229
+ );
230
+ server.close();
231
+
232
+ assert.equal(messages.length, 2, "a notification must never get a response");
233
+ assert.deepEqual(messages.find((m) => m.id === 7).result, {});
234
+ assert.equal(messages.find((m) => m.id === 8).error.code, -32601);
235
+ }
236
+
237
+ console.log("mcp tests passed");