pennyrouter 0.2.14 → 0.2.15
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.
- package/package.json +1 -1
- package/src/cli.js +5 -1
- package/src/mcp-register.js +9 -4
- package/src/mcp-register.test.js +2 -0
- package/src/mcp.js +22 -7
- package/src/mcp.test.js +13 -4
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -690,7 +690,11 @@ async function serveMcp(flags) {
|
|
|
690
690
|
|| process.env.PENNYROUTER_GATEWAY_BASE_URL
|
|
691
691
|
|| "https://api.pennyrouter.com";
|
|
692
692
|
|
|
693
|
-
await runMcpServer({
|
|
693
|
+
await runMcpServer({
|
|
694
|
+
gatewayBaseUrl,
|
|
695
|
+
apiKey,
|
|
696
|
+
harness: process.env.PENNYROUTER_MCP_HARNESS,
|
|
697
|
+
});
|
|
694
698
|
}
|
|
695
699
|
|
|
696
700
|
// Refresh the managed local files to this package version WITHOUT re-running any of the
|
package/src/mcp-register.js
CHANGED
|
@@ -30,8 +30,12 @@ const MANAGED_START = "# --- PennyRouter managed MCP server ---";
|
|
|
30
30
|
const MANAGED_END = "# --- end PennyRouter managed MCP server ---";
|
|
31
31
|
|
|
32
32
|
/** What we register. Shared by both harnesses so the two configs cannot drift apart. */
|
|
33
|
-
export function serverEntry() {
|
|
34
|
-
return {
|
|
33
|
+
export function serverEntry(harness = "claude-code") {
|
|
34
|
+
return {
|
|
35
|
+
command: "npx",
|
|
36
|
+
args: ["-y", "pennyrouter", "mcp"],
|
|
37
|
+
env: { PENNYROUTER_MCP_HARNESS: harness },
|
|
38
|
+
};
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
/** True when an entry is one of ours, so re-installing updates rather than duplicating and
|
|
@@ -59,7 +63,7 @@ export function applyClaudeRegistry(config, { remove = false } = {}) {
|
|
|
59
63
|
"write. Rename it before installing so your configuration is not overwritten.",
|
|
60
64
|
);
|
|
61
65
|
}
|
|
62
|
-
servers[SERVER_NAME] = serverEntry();
|
|
66
|
+
servers[SERVER_NAME] = serverEntry("claude-code");
|
|
63
67
|
}
|
|
64
68
|
|
|
65
69
|
if (Object.keys(servers).length) next.mcpServers = servers;
|
|
@@ -96,12 +100,13 @@ export function renderCodexMcp(text, { remove = false } = {}) {
|
|
|
96
100
|
);
|
|
97
101
|
}
|
|
98
102
|
|
|
99
|
-
const entry = serverEntry();
|
|
103
|
+
const entry = serverEntry("codex");
|
|
100
104
|
const block = [
|
|
101
105
|
MANAGED_START,
|
|
102
106
|
`[mcp_servers.${SERVER_NAME}]`,
|
|
103
107
|
`command = "${entry.command}"`,
|
|
104
108
|
`args = [${entry.args.map((a) => `"${a}"`).join(", ")}]`,
|
|
109
|
+
`env = { PENNYROUTER_MCP_HARNESS = "${entry.env.PENNYROUTER_MCP_HARNESS}" }`,
|
|
105
110
|
MANAGED_END,
|
|
106
111
|
].join("\n");
|
|
107
112
|
|
package/src/mcp-register.test.js
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
const entry = serverEntry();
|
|
18
18
|
assert.equal(entry.command, "npx");
|
|
19
19
|
assert.deepEqual(entry.args, ["-y", "pennyrouter", "mcp"]);
|
|
20
|
+
assert.equal(entry.env.PENNYROUTER_MCP_HARNESS, "claude-code");
|
|
20
21
|
assert.ok(!JSON.stringify(entry).includes("/"), "no absolute paths");
|
|
21
22
|
assert.ok(!/pennyrouter@/.test(JSON.stringify(entry)), "not version-pinned");
|
|
22
23
|
}
|
|
@@ -85,6 +86,7 @@ assert.equal(isManagedEntry({}), false);
|
|
|
85
86
|
assert.ok(written.includes(`[mcp_servers.${SERVER_NAME}]`));
|
|
86
87
|
assert.ok(written.includes('command = "npx"'));
|
|
87
88
|
assert.ok(written.includes('args = ["-y", "pennyrouter", "mcp"]'));
|
|
89
|
+
assert.ok(written.includes('PENNYROUTER_MCP_HARNESS = "codex"'));
|
|
88
90
|
assert.ok(written.includes("[mcp_servers.node_repl]"), "existing servers survive");
|
|
89
91
|
|
|
90
92
|
// A TOML table header captures every key after it, so our block must be appended — splicing
|
package/src/mcp.js
CHANGED
|
@@ -30,6 +30,13 @@ import { writeSession, VERIFIED_MIN, VERIFIED_MAX } from "./thread-session.js";
|
|
|
30
30
|
import { writeSession as writeCodexSession } from "./thread-session-codex.js";
|
|
31
31
|
|
|
32
32
|
const PROTOCOL_VERSION = "2024-11-05";
|
|
33
|
+
const SERVER_INSTRUCTIONS =
|
|
34
|
+
"When the user provides an AI share link and asks to resume, continue, import, or build from " +
|
|
35
|
+
"that conversation in a coding agent, call a PennyRouter thread tool immediately. For the exact " +
|
|
36
|
+
"command '@penny import session <link>', use thread_read when it is the first user turn so the " +
|
|
37
|
+
"conversation enters the current session; otherwise use thread_resume to create a fresh native " +
|
|
38
|
+
"session. Do not search the local workspace, fetch the link directly, or look for another resume " +
|
|
39
|
+
"utility first.";
|
|
33
40
|
|
|
34
41
|
const TOOLS = [
|
|
35
42
|
{
|
|
@@ -39,6 +46,8 @@ const TOOLS = [
|
|
|
39
46
|
"return its transcript. Use when the user wants to discuss, summarize or ask about a " +
|
|
40
47
|
"past conversation. The transcript enters your context — for long threads that are " +
|
|
41
48
|
"meant to be continued as work, prefer thread_resume. " +
|
|
49
|
+
"For `@penny import session <link>` on the FIRST user turn, call this tool immediately: " +
|
|
50
|
+
"the desired result is to continue in the current session. " +
|
|
42
51
|
"This tool is the only supported way to read a share link: those pages are " +
|
|
43
52
|
"JavaScript-rendered shells that return markup, not conversation, to a plain fetch. Never " +
|
|
44
53
|
"fall back to fetching the URL directly, and never guess at the contents. If this call " +
|
|
@@ -57,8 +66,13 @@ const TOOLS = [
|
|
|
57
66
|
description:
|
|
58
67
|
"Fetch an AI conversation from a share link and write it as a resumable coding-agent " +
|
|
59
68
|
"session (Claude Code or Codex), so the user can resume into it and continue coding with " +
|
|
60
|
-
"that conversation as history.
|
|
61
|
-
"
|
|
69
|
+
"that conversation as history. When the user asks to RESUME, CONTINUE, IMPORT, or BUILD " +
|
|
70
|
+
"FROM a share link, call this tool immediately — do not search the workspace or look for " +
|
|
71
|
+
"another resume utility first. Use this instead of thread_read when they want to work from " +
|
|
72
|
+
"the conversation rather than discuss it. For `@penny import session <link>` AFTER the " +
|
|
73
|
+
"first user turn, call this tool: a new native session avoids injecting a large transcript " +
|
|
74
|
+
"into an active conversation. The configured harness is the default; do not guess the other " +
|
|
75
|
+
"one. Returns a session id and path; the transcript is " +
|
|
62
76
|
"deliberately NOT returned, so importing a long thread costs you no context. That is by " +
|
|
63
77
|
"design and is not a failure — do not read the thread another way to 'check' it, and do " +
|
|
64
78
|
"not fetch the share URL directly. Report the resume command to the user. If this call " +
|
|
@@ -77,8 +91,8 @@ const TOOLS = [
|
|
|
77
91
|
type: "string",
|
|
78
92
|
enum: ["claude-code", "codex"],
|
|
79
93
|
description:
|
|
80
|
-
"Which agent should be able to resume it. Defaults to
|
|
81
|
-
"
|
|
94
|
+
"Which agent should be able to resume it. Defaults to the harness that launched this " +
|
|
95
|
+
"MCP server. Supply this only to override that active harness.",
|
|
82
96
|
},
|
|
83
97
|
},
|
|
84
98
|
required: ["url"],
|
|
@@ -153,7 +167,7 @@ async function callTool(name, args, context) {
|
|
|
153
167
|
|
|
154
168
|
if (name === "thread_resume") {
|
|
155
169
|
const cwd = String(args?.directory || process.cwd());
|
|
156
|
-
const harness = String(args?.harness || "claude-code").toLowerCase();
|
|
170
|
+
const harness = String(args?.harness || context.harness || "claude-code").toLowerCase();
|
|
157
171
|
if (harness !== "claude-code" && harness !== "codex") {
|
|
158
172
|
throw new Error(`unknown harness "${harness}"; expected claude-code or codex`);
|
|
159
173
|
}
|
|
@@ -204,7 +218,7 @@ function toolError(id, error) {
|
|
|
204
218
|
});
|
|
205
219
|
}
|
|
206
220
|
|
|
207
|
-
export async function runMcpServer({ gatewayBaseUrl, apiKey }) {
|
|
221
|
+
export async function runMcpServer({ gatewayBaseUrl, apiKey, harness = process.env.PENNYROUTER_MCP_HARNESS }) {
|
|
208
222
|
if (!apiKey) {
|
|
209
223
|
process.stderr.write(
|
|
210
224
|
"pennyrouter mcp: no API key. Run `npx pennyrouter install` first.\n",
|
|
@@ -213,7 +227,7 @@ export async function runMcpServer({ gatewayBaseUrl, apiKey }) {
|
|
|
213
227
|
return;
|
|
214
228
|
}
|
|
215
229
|
|
|
216
|
-
const context = { gatewayBaseUrl, apiKey };
|
|
230
|
+
const context = { gatewayBaseUrl, apiKey, harness };
|
|
217
231
|
const rl = createInterface({ input: process.stdin });
|
|
218
232
|
|
|
219
233
|
for await (const line of rl) {
|
|
@@ -233,6 +247,7 @@ export async function runMcpServer({ gatewayBaseUrl, apiKey }) {
|
|
|
233
247
|
protocolVersion: PROTOCOL_VERSION,
|
|
234
248
|
capabilities: { tools: {} },
|
|
235
249
|
serverInfo: { name: "pennyrouter", version: "1.0.0" },
|
|
250
|
+
instructions: SERVER_INSTRUCTIONS,
|
|
236
251
|
});
|
|
237
252
|
} else if (method === "tools/list") {
|
|
238
253
|
reply(id, { tools: TOOLS });
|
package/src/mcp.test.js
CHANGED
|
@@ -41,12 +41,15 @@ function stubGateway(handler) {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/** Drive the server over stdio and collect the responses to `requests`. */
|
|
44
|
-
function rpc(requests, { port, home }) {
|
|
44
|
+
function rpc(requests, { port, home, harness }) {
|
|
45
45
|
return new Promise((resolve, reject) => {
|
|
46
46
|
const child = spawn(
|
|
47
47
|
process.execPath,
|
|
48
48
|
[binary, "mcp", "--penny-key", "pr-test", "--gateway-base-url", `http://127.0.0.1:${port}`],
|
|
49
|
-
{
|
|
49
|
+
{
|
|
50
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
51
|
+
env: { ...process.env, HOME: home, ...(harness ? { PENNYROUTER_MCP_HARNESS: harness } : {}) },
|
|
52
|
+
},
|
|
50
53
|
);
|
|
51
54
|
let out = "";
|
|
52
55
|
let err = "";
|
|
@@ -108,6 +111,8 @@ const workspace = mkdtempSync(join(tmpdir(), "pr-mcp-ws-"));
|
|
|
108
111
|
|
|
109
112
|
assert.equal(byId.get(1).result.protocolVersion, "2024-11-05");
|
|
110
113
|
assert.equal(byId.get(1).result.serverInfo.name, "pennyrouter");
|
|
114
|
+
assert.match(byId.get(1).result.instructions, /thread_read when it is the first user turn/i);
|
|
115
|
+
assert.match(byId.get(1).result.instructions, /otherwise use thread_resume/i);
|
|
111
116
|
|
|
112
117
|
assert.deepEqual(
|
|
113
118
|
byId.get(2).result.tools.map((t) => t.name),
|
|
@@ -122,6 +127,10 @@ const workspace = mkdtempSync(join(tmpdir(), "pr-mcp-ws-"));
|
|
|
122
127
|
assert.match(tool.description, /not fetch|Never fall back to fetching/i, tool.name);
|
|
123
128
|
assert.match(tool.description, /blocked/i, tool.name);
|
|
124
129
|
}
|
|
130
|
+
assert.match(
|
|
131
|
+
byId.get(2).result.tools.find((tool) => tool.name === "thread_resume").description,
|
|
132
|
+
/do not search the workspace/i,
|
|
133
|
+
);
|
|
125
134
|
|
|
126
135
|
// The URL is forwarded to the gateway under the caller's key; no provider logic runs here.
|
|
127
136
|
assert.equal(JSON.parse(requestBody).url, "https://www.perplexity.ai/search/x");
|
|
@@ -161,7 +170,7 @@ const workspace = mkdtempSync(join(tmpdir(), "pr-mcp-ws-"));
|
|
|
161
170
|
method: "tools/call",
|
|
162
171
|
params: {
|
|
163
172
|
name: "thread_resume",
|
|
164
|
-
arguments: { url: "https://x/y", directory: workspace
|
|
173
|
+
arguments: { url: "https://x/y", directory: workspace },
|
|
165
174
|
},
|
|
166
175
|
},
|
|
167
176
|
{
|
|
@@ -174,7 +183,7 @@ const workspace = mkdtempSync(join(tmpdir(), "pr-mcp-ws-"));
|
|
|
174
183
|
},
|
|
175
184
|
},
|
|
176
185
|
],
|
|
177
|
-
{ port, home },
|
|
186
|
+
{ port, home, harness: "codex" },
|
|
178
187
|
);
|
|
179
188
|
server.close();
|
|
180
189
|
|