Files
OmniRoute/bin/cli/commands/mcp.mjs
Jack Smith 14d70a755b fix: resolve CLI mcp call protocol issues (BUG-001) (#10960)
Validated on the combined 8-PR board: all seven CLI command suites green (combo-suggest, compression, mcp-call, oneproxy, resilience, skills + the shared mcpStreamMock helper) within the board's 88/88, typecheck:core clean, gates within baseline. The CLI mcp call protocol fixes (BUG-001) land with full regression coverage across the affected commands. Thank you @YunyunZhai — and thank you for your patience while this one waited for review.
2026-08-23 18:11:50 -03:00

280 lines
8.1 KiB
JavaScript

import { readFileSync } from "node:fs";
import { apiFetch, isServerUp } from "../api.mjs";
import { emit } from "../output.mjs";
import { t } from "../i18n.mjs";
function truncate(v, len = 60) {
if (v == null) return "-";
const s = String(v);
return s.length > len ? s.slice(0, len - 1) + "…" : s;
}
const mcpToolSchema = [
{ key: "name", header: "Tool", width: 36 },
{
key: "scopes",
header: "Scopes",
formatter: (v) => (Array.isArray(v) ? v.join(",") : (v ?? "-")),
},
{ key: "auditLevel", header: "Audit", width: 10 },
{ key: "phase", header: "Phase", width: 6 },
{ key: "description", header: "Description", formatter: truncate },
];
export function registerMcp(program) {
const mcp = program.command("mcp").description(t("mcp.title"));
mcp
.command("status")
.description("Show MCP server status")
.option("--json", "Output as JSON")
.action(async (opts, cmd) => {
const globalOpts = cmd.parent.optsWithGlobals();
const exitCode = await runMcpStatusCommand({ ...opts, output: globalOpts.output });
if (exitCode !== 0) process.exit(exitCode);
});
mcp
.command("restart")
.description("Restart the MCP server")
.action(async (opts, cmd) => {
const globalOpts = cmd.parent.optsWithGlobals();
const exitCode = await runMcpRestartCommand({ ...opts, output: globalOpts.output });
if (exitCode !== 0) process.exit(exitCode);
});
// 5.1 — mcp call + mcp scopes
mcp
.command("call <tool> [argsJson]")
.description(t("mcp.call.description"))
.option("--args <json>", t("mcp.call.args"))
.option("--args-file <path>", t("mcp.call.args_file"))
.option("--stream", t("mcp.call.stream"))
.option("--scope <s>", t("mcp.call.scope"), (v, prev = []) => [...prev, v], [])
.action(async (tool, argsPositional, opts, cmd) => {
const globalOpts = cmd.optsWithGlobals();
const args = opts.args
? JSON.parse(opts.args)
: opts.argsFile
? JSON.parse(readFileSync(opts.argsFile, "utf8"))
: argsPositional
? JSON.parse(argsPositional)
: {};
const exitCode = await runMcpCallCommand(tool, args, {
...opts,
stream: opts.stream,
}, globalOpts);
if (exitCode !== 0) process.exit(exitCode);
});
mcp
.command("scopes")
.description(t("mcp.scopes.description"))
.option("--tool <name>", t("mcp.scopes.tool"))
.action(async (opts, cmd) => {
const params = new URLSearchParams({ meta: "scopes" });
if (opts.tool) params.set("tool", opts.tool);
const res = await apiFetch(`/api/mcp/tools?${params}`);
if (!res.ok) {
process.stderr.write(`Error: ${res.status}\n`);
process.exit(1);
}
const data = await res.json();
emit(data.scopes ?? data, cmd.optsWithGlobals());
});
}
/**
* Shared JSON-RPC 2.0 MCP client used by both stream and non-stream `mcp call`.
*
* Protocol:
* 1. POST /api/mcp/stream with initialize → get Mcp-Session-Id header
* 2. POST /api/mcp/stream with tools/call + Mcp-Session-Id header
*
* When `stream` is true, writes SSE data chunks to stdout as they arrive.
* When `stream` is false, returns the parsed JSON-RPC result.
*
* Returns the exit code (0 = success, non-zero = failure).
*/
async function mcpJsonRpcCall(tool, args, { stream = false, globalOpts = {} } = {}) {
const baseUrl = globalOpts.baseUrl ?? "http://localhost:20128";
const apiKey = globalOpts.apiKey ?? "";
const streamUrl = `${baseUrl}/api/mcp/stream`;
const hdrs = {
"Content-Type": "application/json",
Accept: stream ? "text/event-stream" : "application/json",
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
};
// Step 1 — initialize
const initRes = await fetch(streamUrl, {
method: "POST",
headers: hdrs,
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "omniroute-cli", version: "1.0" },
},
}),
});
if (!initRes.ok) {
const text = await initRes.text().catch(() => "");
process.stderr.write(`MCP initialize failed: HTTP ${initRes.status}${text ? `${text}` : ""}\n`);
return 1;
}
const sessionId = initRes.headers.get("mcp-session-id");
if (!sessionId) {
process.stderr.write("MCP initialize failed: no Mcp-Session-Id in response\n");
return 1;
}
// Step 2 — tools/call
const callHeaders = {
...hdrs,
"mcp-session-id": sessionId,
};
const callRes = await fetch(streamUrl, {
method: "POST",
headers: callHeaders,
body: JSON.stringify({
jsonrpc: "2.0",
id: 2,
method: "tools/call",
params: { name: tool, arguments: args },
}),
});
if (!callRes.ok) {
const text = await callRes.text().catch(() => "");
process.stderr.write(`MCP call failed: HTTP ${callRes.status}${text ? `${text}` : ""}\n`);
return 1;
}
if (stream) {
return readMcpSseStream(callRes.body);
}
// Non-stream: parse JSON-RPC response
const data = await callRes.json();
if (data.error) {
process.stderr.write(`MCP error: ${data.error.message || JSON.stringify(data.error)}\n`);
return 1;
}
// Print the result content
const content = data.result?.content;
if (content) {
for (const item of content) {
if (item.type === "text") {
process.stdout.write(item.text + "\n");
} else if (item.type === "resource") {
process.stdout.write(JSON.stringify(item.resource) + "\n");
} else {
process.stdout.write(JSON.stringify(item) + "\n");
}
}
} else {
process.stdout.write(JSON.stringify(data.result, null, 2) + "\n");
}
return 0;
}
async function readMcpSseStream(body) {
if (!body) return 1;
const reader = body.getReader();
const dec = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
}
const lines = buf.split("\n");
for (const line of lines) {
if (line.startsWith("data: ")) {
const raw = line.slice(6).trim();
if (raw && raw !== "[DONE]") process.stdout.write(raw + "\n");
}
}
return 0;
}
export async function runMcpCallCommand(tool, args, opts = {}, globalOpts = {}) {
return mcpJsonRpcCall(tool, args, { stream: opts.stream, globalOpts });
}
export async function runMcpStatusCommand(opts = {}) {
const serverUp = await isServerUp();
if (!serverUp) {
console.error(t("common.serverOffline"));
return 1;
}
try {
const res = await apiFetch("/api/mcp/status", {
retry: false,
timeout: 5000,
acceptNotOk: true,
});
if (!res.ok) {
console.log(t("mcp.stopped"));
return 0;
}
const status = await res.json();
if (opts.json || opts.output === "json") {
console.log(JSON.stringify(status, null, 2));
return 0;
}
const transport = status.transport || "stdio";
const online = status.online ?? status.running;
console.log(online ? t("mcp.running", { transport }) : t("mcp.stopped"));
if (status.toolsCount !== undefined) console.log(` Tools: ${status.toolsCount}`);
if (status.scopes?.length) {
console.log(" Scopes:");
for (const scope of status.scopes) console.log(` - ${scope}`);
}
return 0;
} catch (err) {
console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) }));
return 1;
}
}
export async function runMcpRestartCommand(opts = {}) {
const serverUp = await isServerUp();
if (!serverUp) {
console.error(t("common.serverOffline"));
return 1;
}
try {
const res = await apiFetch("/api/mcp/restart", {
method: "POST",
retry: false,
timeout: 10000,
acceptNotOk: true,
});
if (res.ok) {
console.log(t("mcp.restarted"));
return 0;
}
console.error(t("common.error", { message: `HTTP ${res.status}` }));
return 1;
} catch (err) {
console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) }));
return 1;
}
}