@chatpanel/bridge 0.9.0 → 0.10.1
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 +2 -1
- package/src/engines/cli-agents.js +10 -1
- package/src/engines/codex.js +24 -6
- package/src/engines/custom.js +159 -5
- package/src/server.js +32 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Local bridge that exposes the AI coding agents installed on your machine — Claude Code (CLI), Codex (CLI), and Gemini CLI — to the ChatPanel Chrome extension over a localhost SSE endpoint. Bring your own agent.",
|
|
6
6
|
"keywords": [
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"scripts": {
|
|
35
35
|
"start": "node src/server.js",
|
|
36
36
|
"dev": "node --watch src/server.js",
|
|
37
|
+
"test": "node --test tests/*.test.mjs",
|
|
37
38
|
"build:bin": "bash scripts/build-binaries.sh"
|
|
38
39
|
},
|
|
39
40
|
"dependencies": {},
|
|
@@ -45,6 +45,7 @@ export const pi = makeCliAgent(
|
|
|
45
45
|
promptVia: 'arg',
|
|
46
46
|
modelArg: '--model {model}',
|
|
47
47
|
imageArg: '@{path}',
|
|
48
|
+
toolAdapter: 'pi-extension',
|
|
48
49
|
listModelsArgs: '--list-models',
|
|
49
50
|
label: 'Pi',
|
|
50
51
|
},
|
|
@@ -54,10 +55,18 @@ export const pi = makeCliAgent(
|
|
|
54
55
|
export const opencode = makeCliAgent(
|
|
55
56
|
'opencode',
|
|
56
57
|
{
|
|
57
|
-
|
|
58
|
+
// `--format json` → clean NDJSON events (the default emits a TUI that's
|
|
59
|
+
// garbage when piped). --dangerously-skip-permissions so headless tool use
|
|
60
|
+
// (incl. our relayed browser tools) doesn't block on an approval prompt.
|
|
61
|
+
args: 'run --format json --dangerously-skip-permissions',
|
|
58
62
|
promptVia: 'arg',
|
|
59
63
|
modelArg: '-m {model}',
|
|
60
64
|
imageArg: '-f {path}',
|
|
65
|
+
format: 'opencode-json',
|
|
66
|
+
// Browser tools come via the bridge's STABLE /mcp endpoint, registered once
|
|
67
|
+
// with `opencode mcp add chatpanel --url http://127.0.0.1:4319/mcp` (opencode
|
|
68
|
+
// only loads MCP from its global config, not a per-run file).
|
|
69
|
+
requiresStableMcp: true,
|
|
61
70
|
listModelsArgs: 'models',
|
|
62
71
|
label: 'OpenCode',
|
|
63
72
|
},
|
package/src/engines/codex.js
CHANGED
|
@@ -110,6 +110,29 @@ function buildPrompt(messages, system) {
|
|
|
110
110
|
return p;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
export function codexMcpConfigArgs(mcp) {
|
|
114
|
+
if (!mcp?.url) return [];
|
|
115
|
+
const name = mcp.serverName || 'chatpanel_browser';
|
|
116
|
+
const { command, args: pargs } = selfMcpStdio(mcp.url);
|
|
117
|
+
const args = [
|
|
118
|
+
'-c',
|
|
119
|
+
`mcp_servers.${name}.command=${JSON.stringify(command)}`,
|
|
120
|
+
'-c',
|
|
121
|
+
`mcp_servers.${name}.args=${JSON.stringify(pargs)}`,
|
|
122
|
+
'-c',
|
|
123
|
+
`mcp_servers.${name}.default_tools_approval_mode="approve"`,
|
|
124
|
+
'-c',
|
|
125
|
+
`mcp_servers.${name}.startup_timeout_sec=30`,
|
|
126
|
+
'-c',
|
|
127
|
+
`mcp_servers.${name}.tool_timeout_sec=120`,
|
|
128
|
+
];
|
|
129
|
+
const toolNames = [...new Set((mcp.specs || []).map((s) => s?.name).filter(Boolean))];
|
|
130
|
+
if (toolNames.length) {
|
|
131
|
+
args.push('-c', `mcp_servers.${name}.enabled_tools=${JSON.stringify(toolNames)}`);
|
|
132
|
+
}
|
|
133
|
+
return args;
|
|
134
|
+
}
|
|
135
|
+
|
|
113
136
|
// Write base64 data-URL images to temp files so `codex exec -i <file>` can
|
|
114
137
|
// attach them to the prompt as vision input. Returns the paths (caller cleans up).
|
|
115
138
|
async function writeImages(images, tag) {
|
|
@@ -149,12 +172,7 @@ export async function chat({ messages, system, options, images }, emit) {
|
|
|
149
172
|
// Browser tools: register the bridge's MCP server as a stdio MCP server (the
|
|
150
173
|
// bridge binary in --mcp-stdio mode), so Codex can call our page-action tools.
|
|
151
174
|
// `-c key=value` parses value as TOML; JSON.stringify yields valid TOML here.
|
|
152
|
-
|
|
153
|
-
const name = options.mcp.serverName || 'chatpanel_browser';
|
|
154
|
-
const { command, args: pargs } = selfMcpStdio(options.mcp.url);
|
|
155
|
-
args.push('-c', `mcp_servers.${name}.command=${JSON.stringify(command)}`);
|
|
156
|
-
args.push('-c', `mcp_servers.${name}.args=${JSON.stringify(pargs)}`);
|
|
157
|
-
}
|
|
175
|
+
args.push(...codexMcpConfigArgs(options.mcp));
|
|
158
176
|
if (options.model) args.push('-m', options.model);
|
|
159
177
|
if (options.extraArgs) args.push(...String(options.extraArgs).split(/\s+/).filter(Boolean));
|
|
160
178
|
for (const f of imageFiles) args.push('-i', f); // attach images to the initial prompt
|
package/src/engines/custom.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// speak it), reusing the Claude engine's parser.
|
|
17
17
|
|
|
18
18
|
import { spawn } from 'node:child_process';
|
|
19
|
-
import { writeFile, unlink } from 'node:fs/promises';
|
|
19
|
+
import { readFile, writeFile, unlink } from 'node:fs/promises';
|
|
20
20
|
import os from 'node:os';
|
|
21
21
|
import path from 'node:path';
|
|
22
22
|
import { resolveCommand, buildSpawnSpec, selfMcpStdio } from '../env.js';
|
|
@@ -64,6 +64,7 @@ const IDLE_MS = Number(process.env.CHATPANEL_CUSTOM_TIMEOUT_MS) || 180_000;
|
|
|
64
64
|
// also set NO_COLOR on the child env, but this is the robust backstop.)
|
|
65
65
|
const ANSI_RE = /\u001b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
66
66
|
const stripAnsi = (s) => s.replace(ANSI_RE, '');
|
|
67
|
+
const OPENCODE_STABLE_MCP_URL = 'http://127.0.0.1:4319/mcp';
|
|
67
68
|
|
|
68
69
|
export async function available() {
|
|
69
70
|
// The engine ships in every bridge; individual custom agents are user-defined
|
|
@@ -138,6 +139,120 @@ function buildPrompt(messages, system) {
|
|
|
138
139
|
return p;
|
|
139
140
|
}
|
|
140
141
|
|
|
142
|
+
function mcpToolSpecs(mcp) {
|
|
143
|
+
return (mcp?.specs || []).filter((s) => s?.name);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function jsIdentifier(name, index) {
|
|
147
|
+
const id = String(name).replace(/[^A-Za-z0-9_$]/g, '_');
|
|
148
|
+
return /^[A-Za-z_$]/.test(id) ? id : `tool_${index}_${id}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function piToolArgs(extensionFile, mcp) {
|
|
152
|
+
return ['--extension', extensionFile];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function buildPiExtensionSource(mcp) {
|
|
156
|
+
const specs = mcpToolSpecs(mcp);
|
|
157
|
+
const declarations = specs.map((spec, index) => {
|
|
158
|
+
const id = jsIdentifier(spec.name, index);
|
|
159
|
+
const schema = spec.parameters || { type: 'object', properties: {} };
|
|
160
|
+
return `const ${id}Tool = {
|
|
161
|
+
name: ${JSON.stringify(spec.name)},
|
|
162
|
+
label: ${JSON.stringify(spec.name)},
|
|
163
|
+
description: ${JSON.stringify(spec.description || spec.name)},
|
|
164
|
+
parameters: ${JSON.stringify(schema)},
|
|
165
|
+
async execute(toolCallId, params, signal) {
|
|
166
|
+
return callMcpTool(${JSON.stringify(spec.name)}, toolCallId, params, signal);
|
|
167
|
+
},
|
|
168
|
+
};`;
|
|
169
|
+
}).join('\n\n');
|
|
170
|
+
const registrations = specs.map((spec, index) => {
|
|
171
|
+
const id = jsIdentifier(spec.name, index);
|
|
172
|
+
return ` pi.registerTool(${id}Tool);`;
|
|
173
|
+
}).join('\n');
|
|
174
|
+
|
|
175
|
+
return `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
176
|
+
|
|
177
|
+
const MCP_URL = ${JSON.stringify(mcp.url)};
|
|
178
|
+
|
|
179
|
+
function contentFromDataUrl(dataUrl) {
|
|
180
|
+
const match = /^data:([^;]+);base64,(.+)$/s.exec(String(dataUrl || ""));
|
|
181
|
+
return match ? { type: "image", data: match[2], mimeType: match[1] } : null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function normalizeContent(content) {
|
|
185
|
+
const out = [];
|
|
186
|
+
for (const item of Array.isArray(content) ? content : []) {
|
|
187
|
+
if (item?.type === "text") {
|
|
188
|
+
out.push({ type: "text", text: String(item.text ?? "") });
|
|
189
|
+
} else if (item?.type === "image" && item.data) {
|
|
190
|
+
out.push({ type: "image", data: String(item.data), mimeType: String(item.mimeType || "image/png") });
|
|
191
|
+
} else if (typeof item?.image === "string") {
|
|
192
|
+
const img = contentFromDataUrl(item.image);
|
|
193
|
+
if (img) out.push(img);
|
|
194
|
+
if (item.text) out.push({ type: "text", text: String(item.text) });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return out.length ? out : [{ type: "text", text: "ok" }];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function callMcpTool(toolName, toolCallId, params, signal) {
|
|
201
|
+
const response = await fetch(MCP_URL, {
|
|
202
|
+
method: "POST",
|
|
203
|
+
headers: { "Content-Type": "application/json" },
|
|
204
|
+
body: JSON.stringify({
|
|
205
|
+
jsonrpc: "2.0",
|
|
206
|
+
id: toolCallId || String(Date.now()),
|
|
207
|
+
method: "tools/call",
|
|
208
|
+
params: { name: toolName, arguments: params || {} },
|
|
209
|
+
}),
|
|
210
|
+
signal,
|
|
211
|
+
});
|
|
212
|
+
const message = await response.json();
|
|
213
|
+
if (message.error) {
|
|
214
|
+
return {
|
|
215
|
+
content: [{ type: "text", text: \`error: \${message.error.message || JSON.stringify(message.error)}\` }],
|
|
216
|
+
details: { error: message.error },
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
content: normalizeContent(message.result?.content),
|
|
221
|
+
details: message.result ?? {},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
${declarations}
|
|
226
|
+
|
|
227
|
+
export default function (pi: ExtensionAPI) {
|
|
228
|
+
${registrations}
|
|
229
|
+
}
|
|
230
|
+
`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function writePiExtension(mcp, tag) {
|
|
234
|
+
const file = path.join(os.tmpdir(), `chatpanel-pi-tools-${tag}.ts`);
|
|
235
|
+
await writeFile(file, buildPiExtensionSource(mcp));
|
|
236
|
+
return file;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function opencodeHasStableMcpConfig() {
|
|
240
|
+
const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
241
|
+
const files = [
|
|
242
|
+
path.join(configHome, 'opencode', 'opencode.jsonc'),
|
|
243
|
+
path.join(os.homedir(), 'Library', 'Application Support', 'opencode', 'opencode.jsonc'),
|
|
244
|
+
];
|
|
245
|
+
for (const file of files) {
|
|
246
|
+
try {
|
|
247
|
+
const text = await readFile(file, 'utf8');
|
|
248
|
+
if (text.includes(OPENCODE_STABLE_MCP_URL)) return true;
|
|
249
|
+
} catch {
|
|
250
|
+
/* missing config is fine */
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
|
|
141
256
|
export async function chat({ messages, system, options, images }, emit) {
|
|
142
257
|
// Pro gate — verified, not just UI. No valid signed entitlement → no run.
|
|
143
258
|
if (!(await isProEntitled(options.entitlement))) {
|
|
@@ -158,9 +273,9 @@ export async function runSpec(spec, { messages, system, options = {}, images },
|
|
|
158
273
|
}
|
|
159
274
|
|
|
160
275
|
const prompt = buildPrompt(messages, system);
|
|
161
|
-
|
|
276
|
+
let cwd = options.workingDir ? path.resolve(options.workingDir) : null;
|
|
162
277
|
const label = spec.label || spec.command;
|
|
163
|
-
const fmt =
|
|
278
|
+
const fmt = ['claude-stream-json', 'opencode-json'].includes(spec.format) ? spec.format : 'text';
|
|
164
279
|
|
|
165
280
|
// Args: either a real array or a space-split string. With promptVia:'arg' we
|
|
166
281
|
// substitute {prompt} (or append it if there's no placeholder); otherwise the
|
|
@@ -184,7 +299,10 @@ export async function runSpec(spec, { messages, system, options = {}, images },
|
|
|
184
299
|
const injected = tmpl.includes('{model}')
|
|
185
300
|
? tmpl.replaceAll('{model}', options.model).split(/\s+/).filter(Boolean)
|
|
186
301
|
: [...tmpl.split(/\s+/).filter(Boolean), options.model];
|
|
187
|
-
|
|
302
|
+
// APPEND (not prepend): subcommand CLIs (opencode `run`, kiro `chat`) must
|
|
303
|
+
// keep the subcommand first — `opencode -m X run` makes `run` look like a
|
|
304
|
+
// project path, so it never loads opencode.json / its MCP servers.
|
|
305
|
+
args = [...args, ...injected];
|
|
188
306
|
}
|
|
189
307
|
// Images: write to temp files, expand the agent's imageArg template, then place
|
|
190
308
|
// the tokens. An explicit {images} placeholder in args wins; otherwise they go
|
|
@@ -198,7 +316,11 @@ export async function runSpec(spec, { messages, system, options = {}, images },
|
|
|
198
316
|
// config file (spec.mcpArg, e.g. "--mcp-config {file}"), write a standard
|
|
199
317
|
// mcpServers JSON pointing at the bridge's stdio MCP proxy and inject the flag.
|
|
200
318
|
// Covers any CLI that reads the de-facto {mcpServers:{name:{command,args}}} shape.
|
|
201
|
-
if (options.mcp?.url && spec.
|
|
319
|
+
if (options.mcp?.url && spec.toolAdapter === 'pi-extension') {
|
|
320
|
+
const extensionFile = await writePiExtension(options.mcp, tag);
|
|
321
|
+
mcpFiles.push(extensionFile);
|
|
322
|
+
args = [...piToolArgs(extensionFile, options.mcp), ...args];
|
|
323
|
+
} else if (options.mcp?.url && spec.mcpArg) {
|
|
202
324
|
const name = options.mcp.serverName || 'chatpanel_browser';
|
|
203
325
|
const { command, args: pargs } = selfMcpStdio(options.mcp.url);
|
|
204
326
|
const cfgFile = path.join(os.tmpdir(), `chatpanel-mcp-${tag}.json`);
|
|
@@ -210,6 +332,16 @@ export async function runSpec(spec, { messages, system, options = {}, images },
|
|
|
210
332
|
: [...tmpl.split(/\s+/).filter(Boolean), cfgFile];
|
|
211
333
|
args = [...tokens, ...args];
|
|
212
334
|
}
|
|
335
|
+
// NOTE: opencode only loads MCP from its GLOBAL config (~/.config/opencode),
|
|
336
|
+
// never a per-run/project file — so we can't inject it here. opencode reaches
|
|
337
|
+
// the browser tools via the bridge's STABLE /mcp endpoint, registered once with
|
|
338
|
+
// `opencode mcp add chatpanel --url http://127.0.0.1:4319/mcp`.
|
|
339
|
+
if (options.mcp?.url && spec.requiresStableMcp && !(await opencodeHasStableMcpConfig())) {
|
|
340
|
+
emit({
|
|
341
|
+
type: 'status',
|
|
342
|
+
text: `OpenCode needs one-time browser-tool setup: opencode mcp add chatpanel --url ${OPENCODE_STABLE_MCP_URL}`,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
213
345
|
|
|
214
346
|
const imageTokens = imageTokensFor(spec.imageArg, imageFiles);
|
|
215
347
|
let placedImages = false;
|
|
@@ -289,6 +421,28 @@ export async function runSpec(spec, { messages, system, options = {}, images },
|
|
|
289
421
|
if (r.streamed) streamedAny = true;
|
|
290
422
|
if (r.result != null) resultText = r.result;
|
|
291
423
|
}
|
|
424
|
+
} else if (fmt === 'opencode-json') {
|
|
425
|
+
// opencode `run --format json` emits newline-delimited events: text parts,
|
|
426
|
+
// tool/tool_use, and errors. Extract the answer text + surface tools/errors.
|
|
427
|
+
jsonBuf += s;
|
|
428
|
+
let nl;
|
|
429
|
+
while ((nl = jsonBuf.indexOf('\n')) >= 0) {
|
|
430
|
+
const line = jsonBuf.slice(0, nl).trim();
|
|
431
|
+
jsonBuf = jsonBuf.slice(nl + 1);
|
|
432
|
+
if (!line.startsWith('{')) continue;
|
|
433
|
+
let ev;
|
|
434
|
+
try { ev = JSON.parse(line); } catch { continue; }
|
|
435
|
+
if (ev.type === 'text' && ev.part?.text) {
|
|
436
|
+
streamedAny = true;
|
|
437
|
+
emit({ type: 'delta', text: ev.part.text });
|
|
438
|
+
} else if (ev.type === 'tool' || ev.type === 'tool_use') {
|
|
439
|
+
const p = ev.part || {};
|
|
440
|
+
emit({ type: 'tool', name: p.tool || p.name || p.type || 'tool', summary: '' });
|
|
441
|
+
} else if (ev.type === 'error') {
|
|
442
|
+
const msg = ev.error?.data?.message || ev.error?.message || ev.error?.name || 'error';
|
|
443
|
+
emit({ type: 'status', text: String(msg).slice(0, 300) });
|
|
444
|
+
}
|
|
445
|
+
}
|
|
292
446
|
} else {
|
|
293
447
|
streamedAny = true;
|
|
294
448
|
emit({ type: 'delta', text: stripAnsi(s) });
|
package/src/server.js
CHANGED
|
@@ -30,7 +30,7 @@ import { callLocalMcp } from './mcp-local.js';
|
|
|
30
30
|
// Hardcoded (not read from package.json) so it survives Bun's single-file
|
|
31
31
|
// --compile, where package.json isn't on a readable FS. CI fails the publish if
|
|
32
32
|
// this drifts from package.json, so the two can't silently diverge.
|
|
33
|
-
const VERSION = '0.
|
|
33
|
+
const VERSION = '0.10.1';
|
|
34
34
|
const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
|
|
35
35
|
const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
|
|
36
36
|
|
|
@@ -55,11 +55,13 @@ const ENGINES = {
|
|
|
55
55
|
// result POSTed back to /tool-result. The bridge itself never touches the page.
|
|
56
56
|
// --------------------------------------------------------------------------
|
|
57
57
|
const sessions = new Map(); // sessionId -> { id, emit, specs, pending: Map, nextId }
|
|
58
|
+
let latestSessionId = null; // for the stable /mcp endpoint (CLIs configured once)
|
|
58
59
|
|
|
59
60
|
function createSession(emit, specs) {
|
|
60
61
|
const id = randomUUID();
|
|
61
62
|
const s = { id, emit, specs, pending: new Map(), nextId: 0 };
|
|
62
63
|
sessions.set(id, s);
|
|
64
|
+
latestSessionId = id;
|
|
63
65
|
return s;
|
|
64
66
|
}
|
|
65
67
|
|
|
@@ -68,6 +70,17 @@ function deleteSession(id) {
|
|
|
68
70
|
if (!s) return;
|
|
69
71
|
for (const p of s.pending.values()) p.reject(new Error('chat ended'));
|
|
70
72
|
sessions.delete(id);
|
|
73
|
+
if (latestSessionId === id) {
|
|
74
|
+
// fall back to the most-recently-created surviving session, if any
|
|
75
|
+
const ids = [...sessions.keys()];
|
|
76
|
+
latestSessionId = ids.length ? ids[ids.length - 1] : null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The session a sessionless /mcp request maps to (CLIs configured once with a
|
|
81
|
+
// stable URL — e.g. `opencode mcp add chatpanel --url …/mcp`). The active chat.
|
|
82
|
+
function activeSession() {
|
|
83
|
+
return (latestSessionId && sessions.get(latestSessionId)) || null;
|
|
71
84
|
}
|
|
72
85
|
|
|
73
86
|
// Ask the extension to run a tool and await its result. Resolves to MCP content.
|
|
@@ -228,8 +241,9 @@ async function handleChat(req, res) {
|
|
|
228
241
|
}
|
|
229
242
|
}
|
|
230
243
|
|
|
231
|
-
// POST /mcp/<session>
|
|
232
|
-
//
|
|
244
|
+
// POST /mcp/<session> (per-run, bridge-injected) OR POST /mcp (stable: routes to
|
|
245
|
+
// the active chat — for CLIs configured once, e.g. `opencode mcp add … …/mcp`).
|
|
246
|
+
// JSON-RPC; tools/call relays to the extension and waits for /tool-result.
|
|
233
247
|
async function handleMcp(req, res, sessionId) {
|
|
234
248
|
let msg;
|
|
235
249
|
try {
|
|
@@ -237,9 +251,10 @@ async function handleMcp(req, res, sessionId) {
|
|
|
237
251
|
} catch {
|
|
238
252
|
return json(res, 200, { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
|
|
239
253
|
}
|
|
240
|
-
|
|
254
|
+
// Explicit session id (per-run URL) or the active chat (stable /mcp).
|
|
255
|
+
const session = sessionId ? sessions.get(sessionId) : activeSession();
|
|
241
256
|
const reply = (result) => {
|
|
242
|
-
if (
|
|
257
|
+
if (session) res.setHeader('Mcp-Session-Id', session.id);
|
|
243
258
|
json(res, 200, { jsonrpc: '2.0', id: msg.id ?? null, result });
|
|
244
259
|
};
|
|
245
260
|
const fail = (code, message) => json(res, 200, { jsonrpc: '2.0', id: msg.id ?? null, error: { code, message } });
|
|
@@ -254,7 +269,12 @@ async function handleMcp(req, res, sessionId) {
|
|
|
254
269
|
serverInfo: { name: 'chatpanel-browser', version: VERSION },
|
|
255
270
|
});
|
|
256
271
|
}
|
|
257
|
-
|
|
272
|
+
// No active chat → advertise zero tools rather than erroring, so a CLI with a
|
|
273
|
+
// standing /mcp config (run outside ChatPanel) starts cleanly instead of failing.
|
|
274
|
+
if (!session) {
|
|
275
|
+
if (msg.method === 'tools/list') return reply({ tools: [] });
|
|
276
|
+
return fail(-32001, 'No active ChatPanel session — open a chat with “Act on page” on.');
|
|
277
|
+
}
|
|
258
278
|
if (msg.method === 'tools/list') {
|
|
259
279
|
return reply({
|
|
260
280
|
tools: session.specs.map((s) => ({
|
|
@@ -424,6 +444,12 @@ const server = createServer(async (req, res) => {
|
|
|
424
444
|
});
|
|
425
445
|
}
|
|
426
446
|
if (req.method === 'POST' && url.pathname === '/chat') return handleChat(req, res);
|
|
447
|
+
// Stable endpoint: routes to the active chat. For CLIs configured once with a
|
|
448
|
+
// fixed URL (e.g. `opencode mcp add chatpanel --url http://127.0.0.1:4319/mcp`).
|
|
449
|
+
if (url.pathname === '/mcp') {
|
|
450
|
+
if (req.method === 'POST') return handleMcp(req, res, null);
|
|
451
|
+
if (req.method === 'GET') { res.writeHead(405); return res.end(); }
|
|
452
|
+
}
|
|
427
453
|
if (url.pathname.startsWith('/mcp/')) {
|
|
428
454
|
const sid = decodeURIComponent(url.pathname.slice(5));
|
|
429
455
|
if (req.method === 'POST') return handleMcp(req, res, sid);
|