@jrwoodcock/modelmux 2.0.0

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/src/server.js ADDED
@@ -0,0 +1,686 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @file server.js
4
+ * @description modelmux — Model Context Protocol (MCP) server
5
+ *
6
+ * Registers four tools that let Claude Code and Codex call each other,
7
+ * and both call Perplexity, directly from within a terminal session.
8
+ * Files (code, text, images, PDFs) can be attached by local path and
9
+ * are forwarded to each AI using the appropriate API format.
10
+ *
11
+ * Tools provided:
12
+ * ask_claude — query Anthropic's Claude API
13
+ * ask_codex — query OpenAI's GPT API
14
+ * ask_perplexity — query Perplexity's web-grounded search API
15
+ * broker — query multiple AIs in parallel, then synthesize results
16
+ *
17
+ * File support:
18
+ * Text / code → all three APIs (content inlined as a fenced block)
19
+ * Images → Claude and OpenAI only (base64 vision format)
20
+ * PDFs → Claude and OpenAI only (base64 document format)
21
+ * Perplexity → text and code only; binary files receive a plain-text note
22
+ *
23
+ * Transport: stdio (newline-delimited JSON-RPC 2.0), as required by MCP.
24
+ * No npm packages are required — only Node.js built-ins and the native fetch API.
25
+ * Node.js 18 or higher is needed for fetch support.
26
+ *
27
+ * @author Jason R. Woodcock
28
+ * @version 2.0.0
29
+ * @license Apache-2.0 — see the LICENSE and NOTICE files.
30
+ */
31
+
32
+ import { readFileSync, existsSync, statSync } from "fs";
33
+ import { extname, resolve, basename } from "path";
34
+ import { createInterface } from "readline";
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Configuration
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /**
41
+ * Central configuration object built from environment variables.
42
+ * All API keys and model names are read at startup so missing values
43
+ * surface early rather than at the point of a tool call.
44
+ *
45
+ * Override any model by setting the corresponding environment variable
46
+ * before launching Claude Code or Codex, e.g.:
47
+ * export ANTHROPIC_MODEL="claude-opus-4-6"
48
+ */
49
+ const CONFIG = {
50
+ anthropic: {
51
+ apiKey: process.env.ANTHROPIC_API_KEY,
52
+ model: process.env.ANTHROPIC_MODEL || "claude-sonnet-4-6",
53
+ baseUrl: "https://api.anthropic.com/v1/messages",
54
+ },
55
+ openai: {
56
+ apiKey: process.env.OPENAI_API_KEY,
57
+ model: process.env.OPENAI_MODEL || "gpt-4o",
58
+ baseUrl: "https://api.openai.com/v1/chat/completions",
59
+ },
60
+ perplexity: {
61
+ apiKey: process.env.PERPLEXITY_API_KEY,
62
+ model: process.env.PERPLEXITY_MODEL || "sonar-pro",
63
+ baseUrl: "https://api.perplexity.ai/chat/completions",
64
+ },
65
+ };
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // File handling
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /** Extensions treated as plain text and inlined into the prompt. */
72
+ const TEXT_EXTS = new Set([
73
+ ".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs",
74
+ ".py", ".rb", ".go", ".rs", ".java", ".kt", ".swift", ".c", ".cpp", ".h",
75
+ ".php", ".html", ".css", ".scss", ".sql",
76
+ ".json", ".yaml", ".yml", ".toml", ".env", ".ini", ".conf",
77
+ ".md", ".txt", ".csv", ".xml", ".sh", ".bash", ".zsh",
78
+ ]);
79
+
80
+ /** Extensions treated as images and sent via vision APIs. */
81
+ const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
82
+
83
+ /** The only binary document type with direct API support across providers. */
84
+ const PDF_EXT = ".pdf";
85
+
86
+ /**
87
+ * Maps file extensions to their canonical MIME types.
88
+ * Used when building base64 payloads for vision and document API calls.
89
+ */
90
+ const MIME_TYPES = {
91
+ ".png": "image/png",
92
+ ".jpg": "image/jpeg",
93
+ ".jpeg": "image/jpeg",
94
+ ".gif": "image/gif",
95
+ ".webp": "image/webp",
96
+ ".pdf": "application/pdf",
97
+ };
98
+
99
+ /**
100
+ * Determines how a file should be handled based on its extension.
101
+ *
102
+ * @param {string} filePath - Path to the file (used only for extension lookup).
103
+ * @returns {"text" | "image" | "pdf"} The handling category for this file.
104
+ */
105
+ function fileType(filePath) {
106
+ const ext = extname(filePath).toLowerCase();
107
+ if (IMAGE_EXTS.has(ext)) return "image";
108
+ if (ext === PDF_EXT) return "pdf";
109
+ // Everything else — including unknown extensions — is attempted as UTF-8 text.
110
+ return "text";
111
+ }
112
+
113
+ /**
114
+ * Reads a file from disk and returns a normalised descriptor object
115
+ * that the AI caller functions can consume without further filesystem access.
116
+ *
117
+ * Text files are read as UTF-8 strings. Binary files (images, PDFs) are
118
+ * read as Buffers and converted to base64 for API transmission.
119
+ *
120
+ * @param {string} filePath - Absolute or relative path to the file.
121
+ * @returns {{ type: string, name: string, size: number, text?: string, base64?: string, mimeType?: string }}
122
+ * @throws {Error} If the file does not exist or exceeds the 20 MB size limit.
123
+ */
124
+ function loadFile(filePath) {
125
+ const absolutePath = resolve(filePath);
126
+
127
+ if (!existsSync(absolutePath)) {
128
+ throw new Error(`File not found: ${absolutePath}`);
129
+ }
130
+
131
+ const stats = statSync(absolutePath);
132
+ const MAX_MB = 20;
133
+ const MAX_BYTES = MAX_MB * 1024 * 1024;
134
+
135
+ if (stats.size > MAX_BYTES) {
136
+ const sizeMB = (stats.size / 1024 / 1024).toFixed(1);
137
+ throw new Error(`File too large (${sizeMB} MB). The limit is ${MAX_MB} MB.`);
138
+ }
139
+
140
+ const ext = extname(absolutePath).toLowerCase();
141
+ const type = fileType(absolutePath);
142
+ const name = basename(absolutePath);
143
+
144
+ if (type === "text") {
145
+ const text = readFileSync(absolutePath, "utf8");
146
+ // Guard against binary files with unrecognised extensions being inlined as
147
+ // mojibake. Known text extensions are trusted as-is; for anything else, a
148
+ // NUL byte is a reliable signal that the file is binary, not source text.
149
+ if (!TEXT_EXTS.has(ext) && text.includes("\u0000")) {
150
+ throw new Error(
151
+ `"${name}" looks like a binary file (unrecognised extension "${ext || "none"}"). ` +
152
+ `Supported types are code/text files, images (.png .jpg .jpeg .gif .webp), and PDFs.`
153
+ );
154
+ }
155
+ return { type, name, size: stats.size, text };
156
+ }
157
+
158
+ // Binary file — encode as base64 for API transmission.
159
+ const buffer = readFileSync(absolutePath);
160
+ const base64 = buffer.toString("base64");
161
+ const mimeType = MIME_TYPES[ext] || "application/octet-stream";
162
+ return { type, name, size: stats.size, base64, mimeType };
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // MCP transport helpers
167
+ // ---------------------------------------------------------------------------
168
+
169
+ /**
170
+ * Writes a JSON-RPC 2.0 response object to stdout.
171
+ * MCP uses newline-delimited JSON over stdio, so each message ends with \n.
172
+ *
173
+ * @param {object} obj - A valid JSON-RPC 2.0 message object.
174
+ */
175
+ function send(obj) {
176
+ process.stdout.write(JSON.stringify(obj) + "\n");
177
+ }
178
+
179
+ /**
180
+ * Sends a JSON-RPC 2.0 error response for protocol-level failures
181
+ * (e.g. unknown method). Tool execution errors are returned as
182
+ * successful responses with isError: true, per MCP convention.
183
+ *
184
+ * @param {string|number|null} id - The request ID from the client.
185
+ * @param {number} code - A JSON-RPC error code.
186
+ * @param {string} message - Human-readable error description.
187
+ */
188
+ function mcpError(id, code, message) {
189
+ send({ jsonrpc: "2.0", id, error: { code, message } });
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // AI caller: Claude (Anthropic)
194
+ // ---------------------------------------------------------------------------
195
+
196
+ /**
197
+ * Sends a prompt — and optionally a file — to the Anthropic Messages API.
198
+ *
199
+ * The content format varies by file type:
200
+ * - No file: plain string prompt.
201
+ * - Text: single text block with the file inlined as a fenced code block.
202
+ * - Image: image block followed by a text block (vision format).
203
+ * - PDF: document block followed by a text block (document format).
204
+ *
205
+ * @param {string} prompt - The user's question or instruction.
206
+ * @param {string} [systemPrompt=""] - Optional system-level instruction.
207
+ * @param {object|null} [file=null] - File descriptor from loadFile(), or null.
208
+ * @returns {Promise<string>} The text content of Claude's response.
209
+ * @throws {Error} If the API key is missing or the API returns a non-2xx status.
210
+ */
211
+ async function callClaude(prompt, systemPrompt = "", file = null) {
212
+ const { apiKey, model, baseUrl } = CONFIG.anthropic;
213
+ if (!apiKey) throw new Error("ANTHROPIC_API_KEY is not set in your environment.");
214
+
215
+ // Build the content block(s) for the user message.
216
+ let content;
217
+ if (!file) {
218
+ content = prompt;
219
+ } else if (file.type === "text") {
220
+ // Inline the file as a labelled fenced block so the model can reference it.
221
+ content = [{
222
+ type: "text",
223
+ text: `${prompt}\n\n---\n**File: ${file.name}**\n\`\`\`\n${file.text}\n\`\`\``,
224
+ }];
225
+ } else if (file.type === "image") {
226
+ // Vision format: image block first, then the text prompt.
227
+ content = [
228
+ { type: "image", source: { type: "base64", media_type: file.mimeType, data: file.base64 } },
229
+ { type: "text", text: prompt },
230
+ ];
231
+ } else if (file.type === "pdf") {
232
+ // Document format: PDF block first, then the text prompt.
233
+ content = [
234
+ { type: "document", source: { type: "base64", media_type: "application/pdf", data: file.base64 } },
235
+ { type: "text", text: prompt },
236
+ ];
237
+ }
238
+
239
+ const requestBody = { model, max_tokens: 4096, messages: [{ role: "user", content }] };
240
+ if (systemPrompt) requestBody.system = systemPrompt;
241
+
242
+ const response = await fetch(baseUrl, {
243
+ method: "POST",
244
+ headers: {
245
+ "Content-Type": "application/json",
246
+ "x-api-key": apiKey,
247
+ "anthropic-version": "2023-06-01",
248
+ },
249
+ body: JSON.stringify(requestBody),
250
+ });
251
+
252
+ if (!response.ok) {
253
+ const body = await response.text();
254
+ throw new Error(`Anthropic API returned ${response.status}: ${body}`);
255
+ }
256
+
257
+ const data = await response.json();
258
+ return data.content?.[0]?.text ?? "(no response)";
259
+ }
260
+
261
+ // ---------------------------------------------------------------------------
262
+ // AI caller: OpenAI (Codex / GPT)
263
+ // ---------------------------------------------------------------------------
264
+
265
+ /**
266
+ * Sends a prompt — and optionally a file — to the OpenAI Chat Completions API.
267
+ *
268
+ * The user message content varies by file type:
269
+ * - No file: plain string.
270
+ * - Text: string with the file inlined as a fenced code block.
271
+ * - Image: array with a text block and an image_url block (base64 data URI).
272
+ * - PDF: array with a text block and a file block (base64 data URI).
273
+ *
274
+ * @param {string} prompt - The user's question or instruction.
275
+ * @param {string} [systemPrompt=""] - Optional system message prepended to the conversation.
276
+ * @param {object|null} [file=null] - File descriptor from loadFile(), or null.
277
+ * @returns {Promise<string>} The text content of the model's response.
278
+ * @throws {Error} If the API key is missing or the API returns a non-2xx status.
279
+ */
280
+ async function callOpenAI(prompt, systemPrompt = "", file = null) {
281
+ const { apiKey, model, baseUrl } = CONFIG.openai;
282
+ if (!apiKey) throw new Error("OPENAI_API_KEY is not set in your environment.");
283
+
284
+ const messages = [];
285
+ if (systemPrompt) messages.push({ role: "system", content: systemPrompt });
286
+
287
+ let userContent;
288
+ if (!file) {
289
+ userContent = prompt;
290
+ } else if (file.type === "text") {
291
+ userContent = `${prompt}\n\n---\n**File: ${file.name}**\n\`\`\`\n${file.text}\n\`\`\``;
292
+ } else if (file.type === "image") {
293
+ // OpenAI vision format: text and image_url blocks in an array.
294
+ userContent = [
295
+ { type: "text", text: prompt },
296
+ { type: "image_url", image_url: { url: `data:${file.mimeType};base64,${file.base64}` } },
297
+ ];
298
+ } else if (file.type === "pdf") {
299
+ // GPT-4o accepts PDFs as inline base64 file blocks.
300
+ userContent = [
301
+ { type: "text", text: prompt },
302
+ { type: "file", file: { filename: file.name, file_data: `data:application/pdf;base64,${file.base64}` } },
303
+ ];
304
+ }
305
+
306
+ messages.push({ role: "user", content: userContent });
307
+
308
+ const response = await fetch(baseUrl, {
309
+ method: "POST",
310
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
311
+ body: JSON.stringify({ model, messages, max_tokens: 4096 }),
312
+ });
313
+
314
+ if (!response.ok) {
315
+ const body = await response.text();
316
+ throw new Error(`OpenAI API returned ${response.status}: ${body}`);
317
+ }
318
+
319
+ const data = await response.json();
320
+ return data.choices?.[0]?.message?.content ?? "(no response)";
321
+ }
322
+
323
+ // ---------------------------------------------------------------------------
324
+ // AI caller: Perplexity
325
+ // ---------------------------------------------------------------------------
326
+
327
+ /**
328
+ * Sends a prompt — and optionally a text file — to the Perplexity Chat API.
329
+ *
330
+ * Perplexity does not offer a vision or document API. When a binary file
331
+ * (image or PDF) is supplied, a plain-text note is appended to the prompt
332
+ * explaining that the file was not forwarded, so the model can still
333
+ * provide a useful response rather than receiving an unexpected request.
334
+ *
335
+ * @param {string} prompt - The user's question or research query.
336
+ * @param {string} [systemPrompt=""] - Optional system message.
337
+ * @param {object|null} [file=null] - File descriptor from loadFile(), or null.
338
+ * @returns {Promise<string>} The text content of Perplexity's response.
339
+ * @throws {Error} If the API key is missing or the API returns a non-2xx status.
340
+ */
341
+ async function callPerplexity(prompt, systemPrompt = "", file = null) {
342
+ const { apiKey, model, baseUrl } = CONFIG.perplexity;
343
+ if (!apiKey) throw new Error("PERPLEXITY_API_KEY is not set in your environment.");
344
+
345
+ let fullPrompt = prompt;
346
+
347
+ if (file) {
348
+ if (file.type === "text") {
349
+ // Text and code files can be inlined just like the other providers.
350
+ fullPrompt = `${prompt}\n\n---\n**File: ${file.name}**\n\`\`\`\n${file.text}\n\`\`\``;
351
+ } else {
352
+ // Binary file — notify the model rather than sending an incompatible payload.
353
+ fullPrompt =
354
+ `${prompt}\n\n---\n` +
355
+ `Note: A ${file.type} file named "${file.name}" was attached to this request, ` +
356
+ `but this API does not support vision or document inputs. ` +
357
+ `Please answer based on the text prompt alone, or describe what you would ` +
358
+ `look for in a file of this type if you were able to inspect it.`;
359
+ }
360
+ }
361
+
362
+ const messages = [];
363
+ if (systemPrompt) messages.push({ role: "system", content: systemPrompt });
364
+ messages.push({ role: "user", content: fullPrompt });
365
+
366
+ const response = await fetch(baseUrl, {
367
+ method: "POST",
368
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
369
+ body: JSON.stringify({ model, messages, max_tokens: 4096 }),
370
+ });
371
+
372
+ if (!response.ok) {
373
+ const body = await response.text();
374
+ throw new Error(`Perplexity API returned ${response.status}: ${body}`);
375
+ }
376
+
377
+ const data = await response.json();
378
+ return data.choices?.[0]?.message?.content ?? "(no response)";
379
+ }
380
+
381
+ // ---------------------------------------------------------------------------
382
+ // MCP tool definitions
383
+ // ---------------------------------------------------------------------------
384
+
385
+ /**
386
+ * Shared schema fragment for the optional file parameter.
387
+ * Spread into each tool's inputSchema.properties to avoid repetition.
388
+ */
389
+ const FILE_PARAM = {
390
+ file: {
391
+ type: "string",
392
+ description:
393
+ "Optional path to a local file. Absolute paths are recommended to avoid " +
394
+ "ambiguity about the working directory. " +
395
+ "Supported types: code and text files (.js .ts .php .py .md .json etc.), " +
396
+ "images (.png .jpg .jpeg .gif .webp), and PDFs (.pdf). " +
397
+ "Example: /Users/you/project/auth.php",
398
+ },
399
+ };
400
+
401
+ /**
402
+ * The four tools exposed to MCP clients (Claude Code, Codex, etc.).
403
+ * Each entry follows the MCP tool schema: name, description, and inputSchema.
404
+ * The descriptions are written so the host agent can decide when to call each tool
405
+ * without additional instruction.
406
+ */
407
+ const TOOLS = [
408
+ {
409
+ name: "ask_claude",
410
+ description:
411
+ "Send a prompt to Claude (Anthropic). Optionally attach a local file — " +
412
+ "code, text, image, or PDF — for Claude to read and include in its analysis. " +
413
+ "Use this when you want Claude's perspective on a question, piece of code, " +
414
+ "document, or design.",
415
+ inputSchema: {
416
+ type: "object",
417
+ properties: {
418
+ prompt: { type: "string", description: "The question or instruction for Claude." },
419
+ system: { type: "string", description: "Optional system prompt that sets Claude's role, e.g. 'You are a security auditor'." },
420
+ ...FILE_PARAM,
421
+ },
422
+ required: ["prompt"],
423
+ },
424
+ },
425
+ {
426
+ name: "ask_codex",
427
+ description:
428
+ "Send a prompt to OpenAI (GPT-4o by default). Optionally attach a local file. " +
429
+ "Use this when you want a second opinion on code, an alternative implementation, " +
430
+ "or OpenAI's perspective on an architecture or design decision.",
431
+ inputSchema: {
432
+ type: "object",
433
+ properties: {
434
+ prompt: { type: "string", description: "The question or instruction for OpenAI." },
435
+ system: { type: "string", description: "Optional system prompt." },
436
+ ...FILE_PARAM,
437
+ },
438
+ required: ["prompt"],
439
+ },
440
+ },
441
+ {
442
+ name: "ask_perplexity",
443
+ description:
444
+ "Send a research question to Perplexity for a web-grounded answer with citations. " +
445
+ "Text and code files can be attached. Images and PDFs cannot (Perplexity has no vision API). " +
446
+ "Use this for current information, documentation lookups, or questions that benefit " +
447
+ "from live web search.",
448
+ inputSchema: {
449
+ type: "object",
450
+ properties: {
451
+ prompt: { type: "string", description: "The research question for Perplexity." },
452
+ system: { type: "string", description: "Optional system prompt." },
453
+ ...FILE_PARAM,
454
+ },
455
+ required: ["prompt"],
456
+ },
457
+ },
458
+ {
459
+ name: "broker",
460
+ description:
461
+ "Send a prompt to multiple AIs in parallel and receive a synthesized comparison. " +
462
+ "Attach a file to have all selected AIs analyse the same document, image, or code. " +
463
+ "Perplexity only receives text/code files; images and PDFs are forwarded to Claude " +
464
+ "and OpenAI only. When synthesize is true (the default), Claude produces a final " +
465
+ "summary identifying agreements, differences, and a recommended course of action.",
466
+ inputSchema: {
467
+ type: "object",
468
+ properties: {
469
+ prompt: {
470
+ type: "string",
471
+ description: "The question or instruction sent to all selected AIs.",
472
+ },
473
+ targets: {
474
+ type: "array",
475
+ items: { type: "string", enum: ["claude", "codex", "perplexity"] },
476
+ description: "Which AIs to query. Defaults to ['claude', 'codex'] if omitted.",
477
+ },
478
+ system: {
479
+ type: "string",
480
+ description: "Optional system prompt applied to all targets.",
481
+ },
482
+ synthesize: {
483
+ type: "boolean",
484
+ description:
485
+ "When true (default), Claude synthesizes all responses into one summary. " +
486
+ "When false, each AI's raw response is returned side by side.",
487
+ },
488
+ ...FILE_PARAM,
489
+ },
490
+ required: ["prompt"],
491
+ },
492
+ },
493
+ ];
494
+
495
+ // ---------------------------------------------------------------------------
496
+ // Tool handler
497
+ // ---------------------------------------------------------------------------
498
+
499
+ /**
500
+ * Dispatches an incoming tool call to the appropriate AI caller function(s).
501
+ *
502
+ * Files are loaded once here and the resulting descriptor is passed to each
503
+ * caller, avoiding repeated disk reads in parallel broker calls.
504
+ *
505
+ * @param {string} name - The tool name as registered in TOOLS.
506
+ * @param {object} args - The arguments object from the MCP tool call.
507
+ * @returns {Promise<string>} Markdown-formatted response text.
508
+ * @throws {Error} For unknown tool names, missing API keys, or file load failures.
509
+ */
510
+ async function handleTool(name, args) {
511
+ // Load the file once, if one was provided, so all parallel callers share it.
512
+ const file = args.file ? loadFile(args.file) : null;
513
+ const system = args.system || "";
514
+
515
+ switch (name) {
516
+
517
+ case "ask_claude":
518
+ return await callClaude(args.prompt, system, file);
519
+
520
+ case "ask_codex":
521
+ return await callOpenAI(args.prompt, system, file);
522
+
523
+ case "ask_perplexity":
524
+ return await callPerplexity(args.prompt, system, file);
525
+
526
+ case "broker": {
527
+ const targets = args.targets || ["claude", "codex"];
528
+ const synthesize = args.synthesize !== false; // default true
529
+
530
+ // Describe the attached file in the header if one was provided.
531
+ const fileNote = file
532
+ ? `\n📎 File: **${file.name}** (${file.type}, ${(file.size / 1024).toFixed(1)} KB)`
533
+ : "";
534
+
535
+ // Fire all target calls concurrently. Individual failures are captured
536
+ // per-target rather than rejecting the entire broker call.
537
+ const calls = targets.map(async (target) => {
538
+ try {
539
+ let response;
540
+ if (target === "claude") response = await callClaude(args.prompt, system, file);
541
+ else if (target === "codex") response = await callOpenAI(args.prompt, system, file);
542
+ else if (target === "perplexity") response = await callPerplexity(args.prompt, system, file);
543
+ else response = `Unknown target: ${target}`;
544
+ return { target, response, error: null };
545
+ } catch (err) {
546
+ return { target, response: null, error: err.message };
547
+ }
548
+ });
549
+
550
+ const results = await Promise.all(calls);
551
+
552
+ // Format each result as a headed section.
553
+ const responseSections = results
554
+ .map((r) =>
555
+ r.error
556
+ ? `### ${r.target.toUpperCase()}\n\n⚠️ Error: ${r.error}`
557
+ : `### ${r.target.toUpperCase()}\n\n${r.response}`
558
+ )
559
+ .join("\n\n---\n\n");
560
+
561
+ // Return raw responses side by side if synthesis was disabled.
562
+ if (!synthesize) {
563
+ return `# Broker Results${fileNote}\n\n${responseSections}`;
564
+ }
565
+
566
+ // Ask Claude to synthesize all responses into a single actionable summary.
567
+ const synthesisPrompt =
568
+ `You are synthesizing responses from multiple AI systems about the same question.\n\n` +
569
+ `Original question:\n${args.prompt}\n` +
570
+ (file ? `Attached file: ${file.name} (${file.type})\n` : "") +
571
+ `\nResponses from each AI:\n${responseSections}\n\n` +
572
+ `Please provide:\n` +
573
+ `1. **Key agreements** — what all or most AIs agreed on\n` +
574
+ `2. **Notable differences** — where they diverged and why it matters\n` +
575
+ `3. **Recommended action** — the best path forward based on all input\n\n` +
576
+ `Be concise and actionable.`;
577
+
578
+ // Synthesis is performed by Claude. If it fails — most commonly because
579
+ // no Anthropic key is set when Claude was not itself a target — fall back
580
+ // to the raw side-by-side responses rather than discarding work already done.
581
+ let synthesis;
582
+ try {
583
+ synthesis = await callClaude(synthesisPrompt);
584
+ } catch (err) {
585
+ return (
586
+ `# Broker Results${fileNote}\n\n` +
587
+ `_Synthesis was skipped: ${err.message}_\n\n` +
588
+ `${responseSections}`
589
+ );
590
+ }
591
+
592
+ return (
593
+ `# Broker Synthesis${fileNote}\n\n` +
594
+ `${synthesis}\n\n` +
595
+ `---\n\n` +
596
+ `# Raw Responses\n\n` +
597
+ `${responseSections}`
598
+ );
599
+ }
600
+
601
+ default:
602
+ throw new Error(`Unknown tool name: "${name}"`);
603
+ }
604
+ }
605
+
606
+ // ---------------------------------------------------------------------------
607
+ // MCP message loop
608
+ // ---------------------------------------------------------------------------
609
+
610
+ /**
611
+ * Reads newline-delimited JSON-RPC 2.0 messages from stdin and dispatches them.
612
+ *
613
+ * Handled methods:
614
+ * initialize — capability handshake required by all MCP clients
615
+ * notifications/initialized — acknowledgement from the client (no reply needed)
616
+ * tools/list — returns the TOOLS array
617
+ * tools/call — executes a tool and returns the result
618
+ *
619
+ * Any unrecognised method receives a JSON-RPC -32601 (Method Not Found) error.
620
+ * Non-JSON lines (e.g. blank lines or debug output) are silently ignored.
621
+ */
622
+ const rl = createInterface({ input: process.stdin, terminal: false });
623
+
624
+ rl.on("line", async (line) => {
625
+ // Ignore blank or non-JSON lines without crashing.
626
+ let message;
627
+ try {
628
+ message = JSON.parse(line.trim());
629
+ } catch {
630
+ return;
631
+ }
632
+
633
+ const { id, method, params } = message;
634
+
635
+ // Capability handshake — the client sends this first before any tool calls.
636
+ if (method === "initialize") {
637
+ send({
638
+ jsonrpc: "2.0",
639
+ id,
640
+ result: {
641
+ protocolVersion: "2024-11-05",
642
+ serverInfo: { name: "modelmux", version: "2.0.0" },
643
+ capabilities: { tools: {} },
644
+ },
645
+ });
646
+ return;
647
+ }
648
+
649
+ // Client acknowledgement — no response required by the MCP spec.
650
+ if (method === "notifications/initialized") return;
651
+
652
+ // Return the list of available tools so the client can discover them.
653
+ if (method === "tools/list") {
654
+ send({ jsonrpc: "2.0", id, result: { tools: TOOLS } });
655
+ return;
656
+ }
657
+
658
+ // Execute the requested tool and return its output.
659
+ if (method === "tools/call") {
660
+ const toolName = params?.name;
661
+ const toolArgs = params?.arguments ?? {};
662
+ try {
663
+ const result = await handleTool(toolName, toolArgs);
664
+ send({
665
+ jsonrpc: "2.0",
666
+ id,
667
+ result: { content: [{ type: "text", text: result }] },
668
+ });
669
+ } catch (err) {
670
+ // Tool errors are returned as successful MCP responses with isError: true,
671
+ // so the host agent can surface the message to the user in context.
672
+ send({
673
+ jsonrpc: "2.0",
674
+ id,
675
+ result: {
676
+ content: [{ type: "text", text: `Error: ${err.message}` }],
677
+ isError: true,
678
+ },
679
+ });
680
+ }
681
+ return;
682
+ }
683
+
684
+ // Anything else is an unsupported protocol method.
685
+ mcpError(id, -32601, `Method not found: ${method}`);
686
+ });