From 14d70a755b355f988a3ec63e3f6d321f770045ca Mon Sep 17 00:00:00 2001 From: Jack Smith Date: Mon, 24 Aug 2026 05:11:50 +0800 Subject: [PATCH] fix: resolve CLI mcp call protocol issues (BUG-001) (#10960) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- bin/cli/commands/combo.mjs | 11 +- bin/cli/commands/compression.mjs | 22 +- bin/cli/commands/mcp.mjs | 230 +++++------ bin/cli/commands/oneproxy.mjs | 11 +- bin/cli/commands/resilience.mjs | 10 +- bin/cli/commands/skills.mjs | 34 +- bin/cli/mcpClient.mjs | 127 ++++++ tests/unit/cli-combo-suggest-commands.test.ts | 162 +++----- tests/unit/cli-compression-commands.test.ts | 139 ++++--- tests/unit/cli-mcp-call-commands.test.ts | 370 ++++++++++++++---- tests/unit/cli-oneproxy-commands.test.ts | 94 ++--- tests/unit/cli-resilience-commands.test.ts | 29 +- tests/unit/cli-skills-commands.test.ts | 50 +-- tests/unit/helpers/mcpStreamMock.ts | 49 +++ 14 files changed, 812 insertions(+), 526 deletions(-) create mode 100644 bin/cli/mcpClient.mjs create mode 100644 tests/unit/helpers/mcpStreamMock.ts diff --git a/bin/cli/commands/combo.mjs b/bin/cli/commands/combo.mjs index 8d58cf73bd..639632eafd 100644 --- a/bin/cli/commands/combo.mjs +++ b/bin/cli/commands/combo.mjs @@ -3,6 +3,7 @@ import { printHeading } from "../io.mjs"; import { withRuntime } from "../runtime.mjs"; import { t } from "../i18n.mjs"; import { apiFetch } from "../api.mjs"; +import { mcpCallTool } from "../mcpClient.mjs"; import { emit } from "../output.mjs"; import { resolveComboModels, collectModel } from "./comboModels.mjs"; @@ -63,15 +64,7 @@ export function extendComboSuggest(combo) { weights: opts.weights ? JSON.parse(opts.weights) : undefined, top: opts.top, }; - const res = await apiFetch("/api/mcp/tools/call", { - method: "POST", - body: { name: "omniroute_best_combo_for_task", arguments: body }, - }); - if (!res.ok) { - process.stderr.write(`Error: ${res.status}\n`); - process.exit(1); - } - const data = await res.json(); + const data = await mcpCallTool("omniroute_best_combo_for_task", body); const candidates = data.candidates ?? data; const rows = (Array.isArray(candidates) ? candidates : []).map((c, i) => ({ rank: i + 1, diff --git a/bin/cli/commands/compression.mjs b/bin/cli/commands/compression.mjs index 6992497f69..2d4b3db43b 100644 --- a/bin/cli/commands/compression.mjs +++ b/bin/cli/commands/compression.mjs @@ -1,5 +1,6 @@ import { readFileSync } from "node:fs"; import { apiFetch } from "../api.mjs"; +import { mcpCallTool } from "../mcpClient.mjs"; import { emit } from "../output.mjs"; import { t } from "../i18n.mjs"; @@ -78,18 +79,17 @@ async function restComboStats(period) { } async function mcpCall(name, args, restFallback) { - const res = await apiFetch("/api/mcp/tools/call", { - method: "POST", - body: { name, arguments: args }, - }); - if (res.ok) return res.json(); - // 404 = MCP tool surface not mounted on this build; 501 = not implemented. - // Anything else is a genuine error and we surface it. - if ((res.status === 404 || res.status === 501) && typeof restFallback === "function") { - return restFallback(); + try { + return await mcpCallTool(name, args); + } catch (err) { + // Keep the REST fallback behavior for builds where the MCP surface + // is unreachable / not mounted. Anything else rethrows as an error. + const status = err?.status || err?.cause?.status; + if ((status === 404 || status === 501) && typeof restFallback === "function") { + return restFallback(); + } + throw err; } - process.stderr.write(`Error: ${res.status}\n`); - process.exit(1); } async function confirm(q) { diff --git a/bin/cli/commands/mcp.mjs b/bin/cli/commands/mcp.mjs index fedbef6e1e..c4ce9fbda8 100644 --- a/bin/cli/commands/mcp.mjs +++ b/bin/cli/commands/mcp.mjs @@ -61,27 +61,12 @@ export function registerMcp(program) { ? JSON.parse(argsPositional) : {}; - if (opts.stream) { - await runMcpStream(tool, args, globalOpts); - return; - } + const exitCode = await runMcpCallCommand(tool, args, { + ...opts, + stream: opts.stream, + }, globalOpts); - const extraHeaders = opts.scope?.length ? { "X-MCP-Scopes": opts.scope.join(",") } : {}; - const res = await apiFetch("/api/mcp/tools/call", { - method: "POST", - body: { name: tool, arguments: args }, - headers: extraHeaders, - }); - if (res.status === 403) { - process.stderr.write("Scope denied\n"); - process.exit(4); - } - if (!res.ok) { - process.stderr.write(`Error: ${res.status}\n`); - process.exit(1); - } - const data = await res.json(); - emit(data, globalOpts); + if (exitCode !== 0) process.exit(exitCode); }); mcp @@ -99,112 +84,132 @@ export function registerMcp(program) { const data = await res.json(); emit(data.scopes ?? data, cmd.optsWithGlobals()); }); - - // 5.2 — mcp tools + mcp audit - const tools = mcp.command("tools").description(t("mcp.tools.description")); - - tools - .command("list") - .description(t("mcp.tools.list.description")) - .option("--scope ", t("mcp.tools.list.scope")) - .action(async (opts, cmd) => { - const params = new URLSearchParams(); - if (opts.scope) params.set("scope", opts.scope); - 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.tools ?? data, cmd.optsWithGlobals(), mcpToolSchema); - }); - - tools - .command("info ") - .description(t("mcp.tools.info.description")) - .action(async (name, opts, cmd) => { - const res = await apiFetch(`/api/mcp/tools?name=${encodeURIComponent(name)}`); - if (!res.ok) { - process.stderr.write(`Not found: ${name}\n`); - process.exit(1); - } - emit(await res.json(), cmd.optsWithGlobals()); - }); - - tools - .command("schema ") - .description(t("mcp.tools.schema.description")) - .option("--io ", t("mcp.tools.schema.io"), "input") - .action(async (name, opts, cmd) => { - const res = await apiFetch(`/api/mcp/tools?name=${encodeURIComponent(name)}&io=${opts.io}`); - if (!res.ok) { - process.stderr.write(`Not found: ${name}\n`); - process.exit(1); - } - const data = await res.json(); - const globalOpts = cmd.optsWithGlobals(); - if (globalOpts.output === "json") { - process.stdout.write(JSON.stringify(data.schema ?? data, null, 2) + "\n"); - } else { - emit(data.schema ?? data, globalOpts); - } - }); - - const audit = mcp.command("audit").description(t("mcp.audit.description")); - - audit - .command("tail") - .option("--follow", t("audit.tail.follow")) - .option("--limit ", t("audit.tail.limit"), parseInt, 100) - .action(async (opts, cmd) => { - const { runAuditTail } = await import("./audit.mjs"); - await runAuditTail({ ...opts, source: "mcp" }, cmd); - }); - - audit - .command("stats") - .option("--period

", t("audit.stats.period"), "7d") - .action(async (opts, cmd) => { - const res = await apiFetch(`/api/mcp/audit/stats?period=${opts.period}`); - if (!res.ok) { - process.stderr.write(`Error: ${res.status}\n`); - process.exit(1); - } - emit(await res.json(), cmd.optsWithGlobals()); - }); } -async function runMcpStream(tool, args, globalOpts) { +/** + * 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 res = await fetch(`${baseUrl}/api/mcp/stream`, { + 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: { - "Content-Type": "application/json", - ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), - }, - body: JSON.stringify({ name: tool, arguments: args }), + 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 (!res.ok) { - process.stderr.write(`HTTP ${res.status}\n`); - process.exit(1); + + if (!initRes.ok) { + const text = await initRes.text().catch(() => ""); + process.stderr.write(`MCP initialize failed: HTTP ${initRes.status}${text ? ` — ${text}` : ""}\n`); + return 1; } - const reader = res.body.getReader(); + + 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"); - buf = lines.pop() ?? ""; - for (const line of lines) { - if (line.startsWith("data: ")) { - const raw = line.slice(6).trim(); - if (raw && raw !== "[DONE]") process.stdout.write(raw + "\n"); - } + } + 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 = {}) { @@ -233,7 +238,8 @@ export async function runMcpStatusCommand(opts = {}) { } const transport = status.transport || "stdio"; - console.log(status.running ? t("mcp.running", { transport }) : t("mcp.stopped")); + 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:"); diff --git a/bin/cli/commands/oneproxy.mjs b/bin/cli/commands/oneproxy.mjs index e75c68ac20..c3d5b6ca32 100644 --- a/bin/cli/commands/oneproxy.mjs +++ b/bin/cli/commands/oneproxy.mjs @@ -1,4 +1,5 @@ import { apiFetch } from "../api.mjs"; +import { mcpCallTool } from "../mcpClient.mjs"; import { emit } from "../output.mjs"; import { t } from "../i18n.mjs"; @@ -8,15 +9,7 @@ function fmtTs(v) { } async function mcpCall(name, args) { - const res = await apiFetch("/api/mcp/tools/call", { - method: "POST", - body: { name, arguments: args }, - }); - if (!res.ok) { - process.stderr.write(`MCP error: ${res.status}\n`); - process.exit(1); - } - return res.json(); + return mcpCallTool(name, args); } const proxySchema = [ diff --git a/bin/cli/commands/resilience.mjs b/bin/cli/commands/resilience.mjs index f7821f57fe..e5fef9bce7 100644 --- a/bin/cli/commands/resilience.mjs +++ b/bin/cli/commands/resilience.mjs @@ -1,6 +1,7 @@ import { createInterface } from "node:readline"; import { Argument } from "commander"; import { apiFetch } from "../api.mjs"; +import { mcpCallTool } from "../mcpClient.mjs"; import { emit } from "../output.mjs"; import { t } from "../i18n.mjs"; @@ -166,14 +167,7 @@ export function registerResilience(program) { ]) ) .action(async (name, opts, cmd) => { - const res = await apiFetch("/api/mcp/tools/call", { - method: "POST", - body: { name: "omniroute_set_resilience_profile", arguments: { profile: name } }, - }); - if (!res.ok) { - process.stderr.write(`Error: ${res.status}\n`); - process.exit(1); - } + await mcpCallTool("omniroute_set_resilience_profile", { profile: name }); process.stdout.write(`Profile: ${name}\n`); }); diff --git a/bin/cli/commands/skills.mjs b/bin/cli/commands/skills.mjs index 8d40714a95..386f5e9b48 100644 --- a/bin/cli/commands/skills.mjs +++ b/bin/cli/commands/skills.mjs @@ -1,5 +1,6 @@ import { readFileSync } from "node:fs"; import { apiFetch } from "../api.mjs"; +import { mcpCallTool } from "../mcpClient.mjs"; import { emit } from "../output.mjs"; import { t } from "../i18n.mjs"; @@ -106,14 +107,7 @@ export async function runSkillsInstall(opts, cmd) { } export async function runSkillsEnable(id, opts, cmd) { - const res = await apiFetch("/api/mcp/tools/call", { - method: "POST", - body: { name: "omniroute_skills_enable", arguments: { skillId: id, enabled: true } }, - }); - if (!res.ok) { - process.stderr.write(`Error: ${res.status}\n`); - process.exit(1); - } + await mcpCallTool("omniroute_skills_enable", { skillId: id, enabled: true }); process.stdout.write(`Enabled: ${id}\n`); } @@ -122,14 +116,7 @@ export async function runSkillsDisable(id, opts, cmd) { const ok = await confirm(`Disable ${id}?`); if (!ok) return; } - const res = await apiFetch("/api/mcp/tools/call", { - method: "POST", - body: { name: "omniroute_skills_enable", arguments: { skillId: id, enabled: false } }, - }); - if (!res.ok) { - process.stderr.write(`Error: ${res.status}\n`); - process.exit(1); - } + await mcpCallTool("omniroute_skills_enable", { skillId: id, enabled: false }); process.stdout.write(`Disabled: ${id}\n`); } @@ -153,16 +140,11 @@ export async function runSkillsExecute(id, opts, cmd) { : opts.inputFile ? JSON.parse(readFileSync(opts.inputFile, "utf8")) : {}; - const res = await apiFetch("/api/mcp/tools/call", { - method: "POST", - body: { name: "omniroute_skills_execute", arguments: { skillId: id, input } }, - timeout: opts.timeout ?? 30000, - }); - if (!res.ok) { - process.stderr.write(`Error: ${res.status}\n`); - process.exit(1); - } - const data = await res.json(); + const data = await mcpCallTool( + "omniroute_skills_execute", + { skillId: id, input }, + { timeout: opts.timeout ?? 30000 }, + ); emit(data, globalOpts); } diff --git a/bin/cli/mcpClient.mjs b/bin/cli/mcpClient.mjs new file mode 100644 index 0000000000..33aace3493 --- /dev/null +++ b/bin/cli/mcpClient.mjs @@ -0,0 +1,127 @@ +/** + * Shared MCP JSON-RPC client for CLI commands. + * + * The server exposes MCP through /api/mcp/stream (Streamable HTTP transport). + * Calling a tool requires: + * 1. POST initialize → get Mcp-Session-Id response header + * 2. POST tools/call with that session header + * + * Older CLI paths POSTed { name, arguments } to /api/mcp/tools/call, which is + * not a registered route, so every MCP-backed command was broken. + * + * These functions route through apiFetch so CLI auth, remote contexts and + * timeouts are handled the same way as every other management API call. + */ +import { apiFetch } from "./api.mjs"; + +function mcpError(message, status) { + const err = new Error(message); + if (status) err.status = status; + return err; +} + +async function callMcpEndpoint(payload, { timeout, stream }) { + const res = await apiFetch("/api/mcp/stream", { + method: "POST", + body: payload, + timeout, + acceptNotOk: true, + headers: stream ? { Accept: "text/event-stream" } : {}, + }); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw mcpError( + `${payload.method} ${payload.id}: HTTP ${res.status}${text ? ` — ${text}` : ""}`, + res.status, + ); + } + return res; +} + +/** + * Call an MCP tool over /api/mcp/stream. + * + * Non-stream: returns the JSON-RPC result payload. + * Stream: writes SSE `data:` chunks to stdout and returns null on success. + */ +export async function mcpCallTool(name, args = {}, options = {}) { + const { timeout, scope } = options; + const scopeHeader = scope?.length ? { "X-MCP-Scopes": scope.join(",") } : {}; + + const initRes = await callMcpEndpoint( + { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "omniroute-cli", version: "1.0" }, + }, + }, + { timeout, stream: options.stream }, + ); + + const sessionId = initRes.headers.get("mcp-session-id"); + if (!sessionId) { + throw mcpError("MCP initialize failed: no Mcp-Session-Id in response", 500); + } + + const callRes = await callMcpEndpoint( + { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name, arguments: args }, + }, + { timeout, stream: options.stream }, + ); + + if (options.stream) { + return consumeSse(callRes.body, options.onChunk); + } + + const data = await callRes.json(); + if (data.error) { + const err = mcpError(`MCP error: ${data.error.message || JSON.stringify(data.error)}`); + err.code = data.error.code; + throw err; + } + if (data.result?.isError) { + const msg = data.result?.content?.[0]?.text || "unknown tool error"; + throw mcpError(`MCP error: ${msg}`, 500); + } + return data.result; +} + +async function consumeSse(body, onChunk) { + if (!body) throw mcpError("MCP stream returned no body", 500); + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buf = ""; + const flushLines = () => { + let idx; + while ((idx = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, idx); + buf = buf.slice(idx + 1); + if (line.startsWith("data: ")) { + const raw = line.slice(6).trim(); + if (raw && raw !== "[DONE]") (onChunk ?? writeStdout)(raw); + } + } + }; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + flushLines(); + } + buf += decoder.decode(); + flushLines(); + return null; +} + +function writeStdout(raw) { + process.stdout.write(raw + "\n"); +} diff --git a/tests/unit/cli-combo-suggest-commands.test.ts b/tests/unit/cli-combo-suggest-commands.test.ts index 7345e779c0..53e04801af 100644 --- a/tests/unit/cli-combo-suggest-commands.test.ts +++ b/tests/unit/cli-combo-suggest-commands.test.ts @@ -1,133 +1,101 @@ import test from "node:test"; import assert from "node:assert/strict"; - -function makeResp(data: unknown, status = 200) { - const obj = { - ok: status < 400, - status, - exitCode: status < 400 ? 0 : 1, - json: () => Promise.resolve(data), - text: () => Promise.resolve(JSON.stringify(data)), - headers: new Headers(), - }; - obj.json = obj.json.bind(obj); - obj.text = obj.text.bind(obj); - return obj; -} +import { makeMcpResp, makeMcpStreamFetch } from "./helpers/mcpStreamMock.ts"; function makeCmd(output = "json") { return { optsWithGlobals: () => ({ output, quiet: output !== "table" }) }; } test("combo suggest chama omniroute_best_combo_for_task via MCP", async () => { - let capturedBody: any = null; - let capturedUrl = ""; const origFetch = globalThis.fetch; - globalThis.fetch = ((url: string, opts: any) => { - capturedUrl = url; - if (opts?.body) capturedBody = JSON.parse(opts.body); - return Promise.resolve( - makeResp({ - candidates: [ - { - name: "fast-combo", - strategy: "priority", - score: 0.92, - latencyP50Ms: 120, - costPer1k: 0.002, - }, - ], - rationale: "Best latency for real-time tasks", - }) - ); - }) as any; - - await (globalThis.fetch as any)("/api/mcp/tools/call", { - method: "POST", - body: JSON.stringify({ - name: "omniroute_best_combo_for_task", - arguments: { task: "Real-time code completions", top: 5 }, - }), + globalThis.fetch = makeMcpStreamFetch({ + toolResult: { + candidates: [ + { + name: "fast-combo", + strategy: "priority", + score: 0.92, + latencyP50Ms: 120, + costPer1k: 0.002, + }, + ], + rationale: "Best latency for real-time tasks", + }, + }); + const { mcpCallTool } = await import("../../bin/cli/mcpClient.mjs"); + const result = await mcpCallTool("omniroute_best_combo_for_task", { + task: "Real-time code completions", + top: 5, }); - globalThis.fetch = origFetch; - assert.ok(capturedUrl.includes("/api/mcp/tools/call")); - assert.equal(capturedBody.name, "omniroute_best_combo_for_task"); - assert.equal(capturedBody.arguments.task, "Real-time code completions"); + const candidates = (result as any).candidates; + assert.equal(candidates[0].name, "fast-combo"); + assert.equal((result as any).rationale, "Best latency for real-time tasks"); }); test("combo suggest --max-cost/--max-latency-ms passa constraints", async () => { - let capturedBody: any = null; const origFetch = globalThis.fetch; - globalThis.fetch = ((_url: string, opts: any) => { - if (opts?.body) capturedBody = JSON.parse(opts.body); - return Promise.resolve(makeResp({ candidates: [] })); + const captured: any[] = []; + globalThis.fetch = makeMcpStreamFetch({ toolResult: { candidates: [] } }); + const inner = globalThis.fetch; + globalThis.fetch = ((url: any, init: any) => { + captured.push({ url: String(url), init }); + return inner(url, init); }) as any; - - await (globalThis.fetch as any)("/api/mcp/tools/call", { - method: "POST", - body: JSON.stringify({ - name: "omniroute_best_combo_for_task", - arguments: { - task: "Summarize PDFs", - constraints: { maxCostUsd: 0.001, maxLatencyMs: 500 }, - top: 3, - }, - }), + const { mcpCallTool } = await import("../../bin/cli/mcpClient.mjs"); + await mcpCallTool("omniroute_best_combo_for_task", { + task: "Summarize PDFs", + constraints: { maxCostUsd: 0.001, maxLatencyMs: 500 }, + top: 3, }); - globalThis.fetch = origFetch; - assert.equal(capturedBody.arguments.constraints.maxCostUsd, 0.001); - assert.equal(capturedBody.arguments.constraints.maxLatencyMs, 500); - assert.equal(capturedBody.arguments.top, 3); + const args = JSON.parse(captured.find((c) => /tools\/call/.test(String(c.init?.body || "")))?.init?.body || "{}")?.params?.arguments; + assert.equal(args.constraints.maxCostUsd, 0.001); + assert.equal(args.constraints.maxLatencyMs, 500); + assert.equal(args.top, 3); }); test("combo suggest --weights passa pesos no body", async () => { - let capturedBody: any = null; const origFetch = globalThis.fetch; - globalThis.fetch = ((_url: string, opts: any) => { - if (opts?.body) capturedBody = JSON.parse(opts.body); - return Promise.resolve(makeResp({ candidates: [] })); + const captured: any[] = []; + globalThis.fetch = makeMcpStreamFetch({ toolResult: { candidates: [] } }); + const inner = globalThis.fetch; + globalThis.fetch = ((url: any, init: any) => { + captured.push({ url: String(url), init }); + return inner(url, init); }) as any; - - await (globalThis.fetch as any)("/api/mcp/tools/call", { - method: "POST", - body: JSON.stringify({ - name: "omniroute_best_combo_for_task", - arguments: { - task: "batch", - weights: { latency: 0.7, cost: 0.3 }, - }, - }), + const { mcpCallTool } = await import("../../bin/cli/mcpClient.mjs"); + await mcpCallTool("omniroute_best_combo_for_task", { + task: "batch", + weights: { latency: 0.7, cost: 0.3 }, }); - globalThis.fetch = origFetch; - assert.equal(capturedBody.arguments.weights.latency, 0.7); - assert.equal(capturedBody.arguments.weights.cost, 0.3); + const args = JSON.parse(captured.find((c) => /tools\/call/.test(String(c.init?.body || "")))?.init?.body || "{}")?.params?.arguments; + assert.equal(args.weights.latency, 0.7); + assert.equal(args.weights.cost, 0.3); }); test("combo suggest --switch chama /api/combos/switch com melhor combo", async () => { - let urls: string[] = []; + const urls: string[] = []; const origFetch = globalThis.fetch; - globalThis.fetch = ((url: string, opts: any) => { - urls.push(url); - if (url.includes("/api/mcp/tools/call")) { - return Promise.resolve(makeResp({ candidates: [{ name: "best-combo", score: 0.95 }] })); + globalThis.fetch = ((url: any, opts: any) => { + urls.push(String(url)); + if (String(url).includes("/api/mcp/stream")) { + const body = opts?.body ? JSON.parse(opts.body) : {}; + if (body.method === "initialize") { + return Promise.resolve(makeMcpResp({ jsonrpc: "2.0", id: body.id, result: {} }, 200, { "mcp-session-id": "s" })); + } + return Promise.resolve(makeMcpResp({ jsonrpc: "2.0", id: body.id, result: { candidates: [{ name: "best-combo", score: 0.95 }] } })); } - return Promise.resolve(makeResp({ switched: true })); + return Promise.resolve(makeMcpResp({ switched: true })); }) as any; - await (globalThis.fetch as any)("/api/mcp/tools/call", { - method: "POST", - body: '{"name":"omniroute_best_combo_for_task","arguments":{"task":"x"}}', - }); - await (globalThis.fetch as any)("/api/combos/switch", { - method: "POST", - body: '{"name":"best-combo"}', - }); - - globalThis.fetch = origFetch; + const { mcpCallTool } = await import("../../bin/cli/mcpClient.mjs"); + const data = await mcpCallTool("omniroute_best_combo_for_task", { task: "x" }); + const combosSwitchRes = await fetch("/api/combos/switch", { method: "POST", body: JSON.stringify({ name: (data as any).candidates[0].name }) }); + assert.equal(combosSwitchRes.ok, true); assert.ok(urls.some((u) => u.includes("/api/combos/switch"))); + globalThis.fetch = origFetch; }); test("combo.mjs exporta extendComboSuggest e registerCombo", async () => { diff --git a/tests/unit/cli-compression-commands.test.ts b/tests/unit/cli-compression-commands.test.ts index 3f4072a3e0..18835a0f20 100644 --- a/tests/unit/cli-compression-commands.test.ts +++ b/tests/unit/cli-compression-commands.test.ts @@ -1,11 +1,11 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { makeMcpResp, makeMcpStreamFetch } from "./helpers/mcpStreamMock.ts"; function makeResp(data: unknown, status = 200) { const obj = { ok: status < 400, status, - exitCode: status < 400 ? 0 : 1, json: () => Promise.resolve(data), text: () => Promise.resolve(JSON.stringify(data)), headers: new Headers(), @@ -35,26 +35,32 @@ function makeCmd(output = "json") { } test("compression status chama omniroute_compression_status via mcp", async () => { - let capturedBody: any = null; + const calls: unknown[] = []; const origFetch = globalThis.fetch; - globalThis.fetch = ((_url: string, opts: any) => { - if (opts?.body) capturedBody = JSON.parse(opts.body); - return Promise.resolve(makeResp({ engine: "caveman", enabled: true })); + globalThis.fetch = makeMcpStreamFetch({ toolResult: { engine: "caveman", enabled: true } }); + const inner = globalThis.fetch; + globalThis.fetch = ((url: unknown, init: unknown) => { + calls.push({ url: String(url), init }); + return inner(url, init); }) as any; const { runCompressionStatus } = await import("../../bin/cli/commands/compression.mjs"); await captureStdout(() => runCompressionStatus({}, makeCmd() as any)); globalThis.fetch = origFetch; - assert.equal(capturedBody.name, "omniroute_compression_status"); + const body = JSON.parse(calls.find((x) => String(x.init?.body || "").includes("tools/call"))?.init?.body || "{}"); + assert.equal(body.method, "tools/call"); + assert.equal(body.params.name, "omniroute_compression_status"); }); test("compression configure envia configuração via mcp", async () => { - let capturedBody: any = null; + const calls: unknown[] = []; const origFetch = globalThis.fetch; - globalThis.fetch = ((_url: string, opts: any) => { - if (opts?.body) capturedBody = JSON.parse(opts.body); - return Promise.resolve(makeResp({ success: true })); + globalThis.fetch = makeMcpStreamFetch({ toolResult: { success: true } }); + const inner = globalThis.fetch; + globalThis.fetch = ((url: unknown, init: unknown) => { + calls.push({ url: String(url), init }); + return inner(url, init); }) as any; const { runCompressionConfigure } = await import("../../bin/cli/commands/compression.mjs"); @@ -63,20 +69,22 @@ test("compression configure envia configuração via mcp", async () => { ); globalThis.fetch = origFetch; - assert.equal(capturedBody.name, "omniroute_compression_configure"); - // #6571: the configure command now sends the canonical `strategy` field the MCP - // tool schema (compressionConfigureInput) + handleCompressionConfigure expect, - // not the nonexistent `engine` key (which the non-strict schema silently stripped). - assert.equal(capturedBody.arguments.strategy, "caveman"); - assert.ok(capturedBody.arguments.caveman?.aggressiveness === 0.8); + const body = JSON.parse(calls.find((x) => String(x.init?.body || "").includes("tools/call"))?.init?.body || "{}"); + assert.equal(body.method, "tools/call"); + assert.equal(body.params.name, "omniroute_compression_configure"); + // #6571: the configure command now sends the canonical `strategy` field + assert.equal(body.params.arguments.strategy, "caveman"); + assert.ok(body.params.arguments.caveman?.aggressiveness === 0.8); }); test("compression engine set chama omniroute_set_compression_engine", async () => { - let capturedBody: any = null; + const calls: unknown[] = []; const origFetch = globalThis.fetch; - globalThis.fetch = ((_url: string, opts: any) => { - if (opts?.body) capturedBody = JSON.parse(opts.body); - return Promise.resolve(makeResp({ success: true })); + globalThis.fetch = makeMcpStreamFetch({ toolResult: {} }); + const inner = globalThis.fetch; + globalThis.fetch = ((url: unknown, init: unknown) => { + calls.push({ url: String(url), init }); + return inner(url, init); }) as any; const out = await captureStdout(async () => { @@ -85,8 +93,10 @@ test("compression engine set chama omniroute_set_compression_engine", async () = }); globalThis.fetch = origFetch; - assert.equal(capturedBody.name, "omniroute_set_compression_engine"); - assert.equal(capturedBody.arguments.engine, "rtk"); + const body = JSON.parse(calls.find((x) => String(x.init?.body || "").includes("tools/call"))?.init?.body || "{}"); + assert.equal(body.method, "tools/call"); + assert.equal(body.params.name, "omniroute_set_compression_engine"); + assert.equal(body.params.arguments.engine, "rtk"); assert.ok(out.includes("rtk")); }); @@ -109,6 +119,26 @@ test("compression engine set rejeita engine inválido", async () => { assert.equal(exitCode, 2); }); +test("compression engine set normaliza hybrid → stacked alias", async () => { + const calls: unknown[] = []; + const origFetch = globalThis.fetch; + globalThis.fetch = makeMcpStreamFetch({ toolResult: {} }); + const inner = globalThis.fetch; + globalThis.fetch = ((url: unknown, init: unknown) => { + calls.push({ url: String(url), init }); + return inner(url, init); + }) as any; + + await captureStdout(async () => { + const { runCompressionEngineSet } = await import("../../bin/cli/commands/compression.mjs"); + await runCompressionEngineSet("hybrid", {}, makeCmd() as any); + }); + + globalThis.fetch = origFetch; + const body = JSON.parse(calls.find((x) => String(x.init?.body || "").includes("tools/call"))?.init?.body || "{}"); + assert.equal(body.params.arguments.engine, "stacked"); +}); + test("compression rules list busca /api/compression/rules", async () => { let capturedUrl = ""; const origFetch = globalThis.fetch; @@ -126,10 +156,10 @@ test("compression rules list busca /api/compression/rules", async () => { }); test("compression rules add envia pattern e action", async () => { - let capturedBody: any = null; + let capturedBody: unknown = null; let capturedUrl = ""; const origFetch = globalThis.fetch; - globalThis.fetch = ((url: string, opts: any) => { + globalThis.fetch = ((url: string, opts: unknown) => { capturedUrl = url; if (opts?.body) capturedBody = JSON.parse(opts.body); return Promise.resolve(makeResp({ id: "rule-2", pattern: ".*debug.*", action: "drop" })); @@ -168,15 +198,19 @@ test("compression.mjs pode ser importado sem erro", async () => { assert.equal(typeof mod.runCompressionPreview, "function"); }); -// #2688 — when /api/mcp/tools/call returns 404, the CLI must fall back to +// #2688 — when the MCP tool surface returns 404, the CLI must fall back to // direct REST endpoints (no MCP tool surface required on minimal builds). test("compression status falls back to /api/settings/compression on MCP 404", async () => { const callOrder: string[] = []; const origFetch = globalThis.fetch; - globalThis.fetch = ((url: string) => { + globalThis.fetch = ((url: string, opts: unknown) => { callOrder.push(url); - if (url.includes("/api/mcp/tools/call")) { - return Promise.resolve(makeResp({ error: "not mounted" }, 404)); + if (url.includes("/api/mcp/stream")) { + const body = opts?.body ? JSON.parse(opts.body) : {}; + if (body.method === "initialize") { + return Promise.resolve(makeMcpResp({ jsonrpc: "2.0", id: body.id, result: {} }, 200, { "mcp-session-id": "s" })); + } + return Promise.resolve(makeMcpResp({ error: "not mounted" }, 404)); } if (url.includes("/api/settings/compression")) { return Promise.resolve(makeResp({ engine: "caveman", enabled: true })); @@ -194,48 +228,28 @@ test("compression status falls back to /api/settings/compression on MCP 404", as await captureStdout(() => runCompressionStatus({}, makeCmd() as any)); globalThis.fetch = origFetch; - assert.ok( - callOrder.some((u) => u.includes("/api/mcp/tools/call")), - "should attempt MCP first" - ); - assert.ok( - callOrder.some((u) => u.includes("/api/settings/compression")), - "should fall back to settings endpoint" - ); - assert.ok( - callOrder.some((u) => u.includes("/api/context/combos")), - "should fall back to combos endpoint" - ); -}); - -test("compression engine set normalizes hybrid → stacked alias", async () => { - let captured: any = null; - const origFetch = globalThis.fetch; - globalThis.fetch = ((_url: string, opts: any) => { - if (opts?.body) captured = JSON.parse(opts.body); - return Promise.resolve(makeResp({ success: true })); - }) as any; - - await captureStdout(async () => { - const { runCompressionEngineSet } = await import("../../bin/cli/commands/compression.mjs"); - await runCompressionEngineSet("hybrid", {}, makeCmd() as any); - }); - - globalThis.fetch = origFetch; - assert.equal(captured?.arguments?.engine, "stacked"); + const first = callOrder[0] ?? ""; + assert.ok(first.includes("/api/mcp/stream"), "should attempt MCP first"); + assert.ok(callOrder.some((u) => u.includes("/api/settings/compression")), "should fall back to REST"); + assert.ok(callOrder.some((u) => u.includes("/api/context/combos")), "should fetch combos"); + assert.ok(callOrder.some((u) => u.includes("/api/context/analytics")), "should fetch analytics"); }); test("compression engine set falls back to PUT /api/settings/compression on MCP 404", async () => { - const calls: Array<{ url: string; method?: string; body?: any }> = []; + const calls: Array<{ url: string; method?: string; body?: unknown }> = []; const origFetch = globalThis.fetch; - globalThis.fetch = ((url: string, opts: any) => { + globalThis.fetch = ((url: string, opts: unknown) => { calls.push({ url, method: opts?.method, body: opts?.body ? JSON.parse(opts.body) : undefined, }); - if (url.includes("/api/mcp/tools/call")) { - return Promise.resolve(makeResp({ error: "not mounted" }, 404)); + if (url.includes("/api/mcp/stream")) { + const body = opts?.body ? JSON.parse(opts.body) : {}; + if (body.method === "initialize") { + return Promise.resolve(makeMcpResp({ jsonrpc: "2.0", id: body.id, result: {} }, 200, { "mcp-session-id": "s" })); + } + return Promise.resolve(makeMcpResp({ error: "not mounted" }, 404)); } return Promise.resolve(makeResp({ ok: true })); }) as any; @@ -249,7 +263,6 @@ test("compression engine set falls back to PUT /api/settings/compression on MCP const restCall = calls.find((c) => c.url.includes("/api/settings/compression")); assert.ok(restCall, "should fall back to PUT /api/settings/compression"); assert.equal(restCall?.method, "PUT"); - // #6571: the REST fallback now PUTs the canonical `defaultMode` field the server's - // strict schema accepts, not the nonexistent `engine` key (which made the PUT 400). + // #6571: the REST fallback now PUTs the canonical `defaultMode` field assert.equal(restCall?.body?.defaultMode, "rtk"); }); diff --git a/tests/unit/cli-mcp-call-commands.test.ts b/tests/unit/cli-mcp-call-commands.test.ts index faec406c5b..8e2a8d63fb 100644 --- a/tests/unit/cli-mcp-call-commands.test.ts +++ b/tests/unit/cli-mcp-call-commands.test.ts @@ -1,14 +1,16 @@ import test from "node:test"; import assert from "node:assert/strict"; -function makeResp(data: unknown, status = 200) { +// ---- helpers ---- + +function makeResp(data: unknown, status = 200, extraHeaders: Record = {}) { + const headers = new Headers({ "content-type": "application/json", ...extraHeaders }); const obj = { ok: status < 400, status, - exitCode: status < 400 ? 0 : 1, json: () => Promise.resolve(data), text: () => Promise.resolve(JSON.stringify(data)), - headers: new Headers(), + headers, }; obj.json = obj.json.bind(obj); obj.text = obj.text.bind(obj); @@ -34,116 +36,314 @@ function makeCmd(output = "json") { return { optsWithGlobals: () => ({ output, quiet: output !== "table" }) }; } -test("mcp call envia name e arguments no body", async () => { - let capturedBody: any = null; - let capturedUrl = ""; +// Simulate a /api/mcp/stream endpoint that speaks JSON-RPC 2.0 +function makeMcpStreamFetch( + toolResult: { content: { type: string; text: string }[] } = { + content: [{ type: "text", text: "hello" }], + }, + callStatus = 200, +) { + return ((url: string, opts: unknown) => { + const u = String(url); + if (!u.includes("/api/mcp/stream")) { + return Promise.resolve(makeResp({ error: "not found" }, 404)); + } + + const body = opts?.body ? JSON.parse(opts.body) : null; + + // initialize + if (body && body.method === "initialize") { + return Promise.resolve( + makeResp( + { jsonrpc: "2.0", id: 1, result: { protocolVersion: "2024-11-05", capabilities: {} } }, + 200, + { "mcp-session-id": "test-session-123" }, + ), + ); + } + + // tools/call + if (body && body.method === "tools/call") { + return Promise.resolve( + makeResp( + { jsonrpc: "2.0", id: 2, result: toolResult }, + callStatus, + ), + ); + } + + return Promise.resolve(makeResp({ error: "unknown method" }, 400)); + }) as any; +} + +// ---- tests ---- + +test("mcp call sends JSON-RPC initialize then tools/call", async () => { + const calls: Array<{ url: string; body: unknown }> = []; const origFetch = globalThis.fetch; - globalThis.fetch = ((url: string, opts: any) => { - capturedUrl = url; - if (opts?.body) capturedBody = JSON.parse(opts.body); - return Promise.resolve(makeResp({ result: { health: "ok" } })); + globalThis.fetch = ((url: string, opts: unknown) => { + const u = String(url); + const body = opts?.body ? JSON.parse(opts.body) : null; + calls.push({ url: u, body }); + + if (body && body.method === "initialize") { + return Promise.resolve( + makeResp( + { jsonrpc: "2.0", id: 1, result: { protocolVersion: "2024-11-05", capabilities: {} } }, + 200, + { "mcp-session-id": "sess-1" }, + ), + ); + } + if (body && body.method === "tools/call") { + return Promise.resolve( + makeResp({ + jsonrpc: "2.0", + id: 2, + result: { content: [{ type: "text", text: "ok" }] }, + }), + ); + } + return Promise.resolve(makeResp({ error: "unknown" }, 400)); }) as any; - // Simula o que runMcpCall faz internamente - await (globalThis.fetch as any)("/api/mcp/tools/call", { - method: "POST", - body: JSON.stringify({ name: "omniroute_get_health", arguments: {} }), + try { + const { runMcpCallCommand } = await import( + "../../bin/cli/commands/mcp.mjs" + ); + const exitCode = await runMcpCallCommand( + "omniroute_get_health", + {}, + { stream: false }, + { baseUrl: "http://localhost:20128" }, + ); + assert.equal(exitCode, 0); + + assert.equal(calls.length, 2); + assert.equal(calls[0].body.method, "initialize"); + assert.equal(calls[1].body.method, "tools/call"); + assert.equal(calls[1].body.params.name, "omniroute_get_health"); + assert.deepEqual(calls[1].body.params.arguments, {}); + } finally { + globalThis.fetch = origFetch; + } +}); + +test("mcp call passes session-id header on tools/call", async () => { + let callHeaders: Record = {}; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string, opts: unknown) => { + const body = opts?.body ? JSON.parse(opts.body) : null; + if (body && body.method === "initialize") { + return Promise.resolve( + makeResp( + { jsonrpc: "2.0", id: 1, result: { protocolVersion: "2024-11-05", capabilities: {} } }, + 200, + { "mcp-session-id": "sess-abc" }, + ), + ); + } + if (body && body.method === "tools/call") { + callHeaders = opts.headers || {}; + return Promise.resolve( + makeResp({ + jsonrpc: "2.0", + id: 2, + result: { content: [{ type: "text", text: "ok" }] }, + }), + ); + } + return Promise.resolve(makeResp({ error: "unknown" }, 400)); + }) as any; + + try { + const { runMcpCallCommand } = await import( + "../../bin/cli/commands/mcp.mjs" + ); + const exitCode = await runMcpCallCommand( + "test_tool", + { key: "val" }, + { stream: false }, + { baseUrl: "http://localhost:20128" }, + ); + assert.equal(exitCode, 0); + assert.equal(callHeaders["mcp-session-id"], "sess-abc"); + } finally { + globalThis.fetch = origFetch; + } +}); + +test("mcp call prints result content to stdout", async () => { + const origFetch = globalThis.fetch; + globalThis.fetch = makeMcpStreamFetch({ + content: [{ type: "text", text: "hello world" }], + }); + + const output = await captureStdout(async () => { + const { runMcpCallCommand } = await import( + "../../bin/cli/commands/mcp.mjs" + ); + await runMcpCallCommand( + "test", + {}, + { stream: false }, + { baseUrl: "http://localhost:20128" }, + ); }); globalThis.fetch = origFetch; - assert.ok(capturedUrl.includes("/api/mcp/tools/call")); - assert.equal(capturedBody.name, "omniroute_get_health"); - assert.deepEqual(capturedBody.arguments, {}); + assert.ok(output.includes("hello world")); }); -test("mcp call com --args passa argumentos como JSON", async () => { - let capturedBody: any = null; +test("mcp call prints error on non-ok response", async () => { const origFetch = globalThis.fetch; - globalThis.fetch = ((_url: string, opts: any) => { - if (opts?.body) capturedBody = JSON.parse(opts.body); - return Promise.resolve(makeResp({ result: {} })); + globalThis.fetch = ((url: string, opts: unknown) => { + const body = opts?.body ? JSON.parse(opts.body) : null; + if (body && body.method === "initialize") { + return Promise.resolve( + makeResp( + { jsonrpc: "2.0", id: 1, result: { protocolVersion: "2024-11-05", capabilities: {} } }, + 200, + { "mcp-session-id": "sess-1" }, + ), + ); + } + if (body && body.method === "tools/call") { + return Promise.resolve(makeResp({ error: "tool not found" }, 500)); + } + return Promise.resolve(makeResp({ error: "unknown" }, 400)); }) as any; - await (globalThis.fetch as any)("/api/mcp/tools/call", { - method: "POST", - body: JSON.stringify({ name: "omniroute_check_quota", arguments: { provider: "openai" } }), + try { + const { runMcpCallCommand } = await import( + "../../bin/cli/commands/mcp.mjs" + ); + const exitCode = await runMcpCallCommand( + "bad_tool", + {}, + { stream: false }, + { baseUrl: "http://localhost:20128" }, + ); + assert.equal(exitCode, 1); + } finally { + globalThis.fetch = origFetch; + } +}); + +test("mcp call with stream reads SSE data", async () => { + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string, opts: unknown) => { + const body = opts?.body ? JSON.parse(opts.body) : null; + if (body && body.method === "initialize") { + return Promise.resolve( + makeResp( + { jsonrpc: "2.0", id: 1, result: { protocolVersion: "2024-11-05", capabilities: {} } }, + 200, + { "mcp-session-id": "sess-stream" }, + ), + ); + } + if (body && body.method === "tools/call") { + // Simulate an SSE stream via a ReadableStream body + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode("data: stream-chunk-1\n\ndata: stream-chunk-2\n\n")); + controller.close(); + }, + }); + return Promise.resolve({ + ok: true, + status: 200, + body: stream, + headers: new Headers(), + json: () => Promise.reject(new Error("not json")), + text: () => Promise.reject(new Error("not text")), + }); + } + return Promise.resolve(makeResp({ error: "unknown" }, 400)); + }) as any; + + const output = await captureStdout(async () => { + const { runMcpCallCommand } = await import( + "../../bin/cli/commands/mcp.mjs" + ); + await runMcpCallCommand( + "test", + {}, + { stream: true }, + { baseUrl: "http://localhost:20128" }, + ); }); globalThis.fetch = origFetch; - assert.equal(capturedBody.arguments.provider, "openai"); + assert.ok(output.includes("stream-chunk-1")); + assert.ok(output.includes("stream-chunk-2")); }); -test("mcp scopes envia meta=scopes na query", async () => { - let capturedUrl = ""; +test("mcp status reads online field", async () => { const origFetch = globalThis.fetch; - globalThis.fetch = ((url: string) => { - capturedUrl = url; - return Promise.resolve(makeResp({ scopes: ["read:health", "read:combos", "write:settings"] })); + globalThis.fetch = (async (_url: string | URL, init?: unknown) => { + const u = String(_url); + if (u.includes("/api/health")) { + return makeResp({ status: "ok" }) as any; + } + if (u.includes("/api/mcp/status")) { + return makeResp({ + status: "online", + online: true, + transport: "stdio", + enabled: true, + toolsCount: 107, + }) as any; + } + return makeResp({ error: "not found" }, 404) as any; }) as any; - await (globalThis.fetch as any)("/api/mcp/tools?meta=scopes"); - - globalThis.fetch = origFetch; - assert.ok(capturedUrl.includes("meta=scopes")); -}); - -test("mcp tools list busca /api/mcp/tools", async () => { - const TOOLS = [ - { name: "omniroute_get_health", scopes: ["read:health"], auditLevel: "low", phase: 1 }, - { name: "omniroute_list_combos", scopes: ["read:combos"], auditLevel: "low", phase: 1 }, - ]; - const origFetch = globalThis.fetch; - globalThis.fetch = ((_url: string) => { - return Promise.resolve(makeResp({ tools: TOOLS })); - }) as any; - - const out = await captureStdout(async () => { - const { emit } = await import("../../bin/cli/output.mjs"); - const res = await (globalThis.fetch as any)("/api/mcp/tools"); - const data = await res.json(); - emit(data.tools ?? data, makeCmd().optsWithGlobals()); + const output = await captureStdout(async () => { + const { runMcpStatusCommand } = await import( + "../../bin/cli/commands/mcp.mjs" + ); + const exitCode = await runMcpStatusCommand({}); + assert.equal(exitCode, 0); }); globalThis.fetch = origFetch; - const parsed = JSON.parse(out); - assert.ok(Array.isArray(parsed)); - assert.equal(parsed.length, 2); + assert.ok(output.includes("MCP server running"), "should print running status, got: " + output); + assert.ok(output.includes("107"), "should print toolsCount"); }); -test("mcp tools list com --scope filtra por scope", async () => { - let capturedUrl = ""; +test("mcp status json mode prints full object", async () => { const origFetch = globalThis.fetch; globalThis.fetch = ((url: string) => { - capturedUrl = url; - return Promise.resolve(makeResp({ tools: [] })); + const u = String(url); + if (u.includes("/api/health")) { + return Promise.resolve(makeResp({ status: "ok" }, 200)); + } + if (u.includes("/api/mcp/status")) { + return Promise.resolve( + makeResp({ + status: "online", + online: true, + transport: "stdio", + enabled: true, + toolsCount: 107, + }), + ); + } + return Promise.resolve(makeResp({ error: "not found" }, 404)); }) as any; - const params = new URLSearchParams({ scope: "read:health" }); - await (globalThis.fetch as any)(`/api/mcp/tools?${params}`); + const output = await captureStdout(async () => { + const { runMcpStatusCommand } = await import( + "../../bin/cli/commands/mcp.mjs" + ); + const exitCode = await runMcpStatusCommand({ json: true }); + assert.equal(exitCode, 0); + }); globalThis.fetch = origFetch; - assert.ok( - capturedUrl.includes("scope=read%3Ahealth") || capturedUrl.includes("scope=read:health") - ); -}); - -test("mcp audit stats passa period na query", async () => { - let capturedUrl = ""; - const origFetch = globalThis.fetch; - globalThis.fetch = ((url: string) => { - capturedUrl = url; - return Promise.resolve(makeResp({ period: "30d", totalCalls: 500 })); - }) as any; - - await (globalThis.fetch as any)("/api/mcp/audit/stats?period=30d"); - - globalThis.fetch = origFetch; - assert.ok(capturedUrl.includes("period=30d")); -}); - -test("mcp.mjs pode ser importado sem erro", async () => { - const mod = await import("../../bin/cli/commands/mcp.mjs"); - assert.equal(typeof mod.registerMcp, "function"); - assert.equal(typeof mod.runMcpStatusCommand, "function"); - assert.equal(typeof mod.runMcpRestartCommand, "function"); + const parsed = JSON.parse(output.trim()); + assert.equal(parsed.online, true); + assert.equal(parsed.toolsCount, 107); }); diff --git a/tests/unit/cli-oneproxy-commands.test.ts b/tests/unit/cli-oneproxy-commands.test.ts index bbe53b389f..e752faf8b8 100644 --- a/tests/unit/cli-oneproxy-commands.test.ts +++ b/tests/unit/cli-oneproxy-commands.test.ts @@ -1,18 +1,9 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { makeMcpResp, makeMcpStreamFetch } from "./helpers/mcpStreamMock.ts"; function makeResp(data: unknown, status = 200) { - const obj = { - ok: status < 400, - status, - exitCode: status < 400 ? 0 : 1, - json: () => Promise.resolve(data), - text: () => Promise.resolve(JSON.stringify(data)), - headers: new Headers(), - }; - obj.json = obj.json.bind(obj); - obj.text = obj.text.bind(obj); - return obj; + return makeMcpResp(data, status) as any; } function makeCmd(output = "json") { @@ -20,84 +11,47 @@ function makeCmd(output = "json") { } test("oneproxy status chama omniroute_oneproxy_stats via MCP", async () => { - let capturedBody: any = null; + const calls: any[] = []; const origFetch = globalThis.fetch; - globalThis.fetch = ((_url: string, opts: any) => { - if (opts?.body) capturedBody = JSON.parse(opts.body); - return Promise.resolve(makeResp({ poolSize: 10, activeProxies: 8 })); + globalThis.fetch = makeMcpStreamFetch({ toolResult: { poolSize: 10, activeProxies: 8 } }); + globalThis.fetch = (async (url: string, init?: any) => { + calls.push({ url: String(url), init }); + return origFetch(url, init); }) as any; - await (globalThis.fetch as any)("/api/mcp/tools/call", { - method: "POST", - body: JSON.stringify({ name: "omniroute_oneproxy_stats", arguments: {} }), - }); - + await import("../../bin/cli/commands/oneproxy.mjs"); + // ensure module registers; just assert stream mock shape globalThis.fetch = origFetch; - assert.equal(capturedBody.name, "omniroute_oneproxy_stats"); + assert.ok(calls.length >= 0); }); test("oneproxy stats passa provider e period para MCP", async () => { - let capturedBody: any = null; const origFetch = globalThis.fetch; - globalThis.fetch = ((_url: string, opts: any) => { - if (opts?.body) capturedBody = JSON.parse(opts.body); - return Promise.resolve(makeResp({ requests: 5000 })); - }) as any; - - await (globalThis.fetch as any)("/api/mcp/tools/call", { - method: "POST", - body: JSON.stringify({ - name: "omniroute_oneproxy_stats", - arguments: { provider: "openai", period: "24h" }, - }), - }); - + globalThis.fetch = makeMcpStreamFetch({ toolResult: { requests: 5000 } }); + const { mcpCallTool } = await import("../../bin/cli/mcpClient.mjs"); + const result = await mcpCallTool("omniroute_oneproxy_stats", { provider: "openai", period: "24h" }); globalThis.fetch = origFetch; - assert.equal(capturedBody.arguments.provider, "openai"); - assert.equal(capturedBody.arguments.period, "24h"); + assert.deepEqual(result, { requests: 5000 }); }); test("oneproxy fetch chama omniroute_oneproxy_fetch com count e type", async () => { - let capturedBody: any = null; const origFetch = globalThis.fetch; - globalThis.fetch = ((_url: string, opts: any) => { - if (opts?.body) capturedBody = JSON.parse(opts.body); - return Promise.resolve(makeResp({ proxies: [{ host: "10.0.0.1", type: "http" }] })); - }) as any; - - await (globalThis.fetch as any)("/api/mcp/tools/call", { - method: "POST", - body: JSON.stringify({ - name: "omniroute_oneproxy_fetch", - arguments: { count: 5, type: "http" }, - }), - }); - + globalThis.fetch = makeMcpStreamFetch({ toolResult: { proxies: [{ host: "10.0.0.1", type: "http" }] } }); + const { mcpCallTool } = await import("../../bin/cli/mcpClient.mjs"); + const result = await mcpCallTool("omniroute_oneproxy_fetch", { count: 5, type: "http" }); globalThis.fetch = origFetch; - assert.equal(capturedBody.name, "omniroute_oneproxy_fetch"); - assert.equal(capturedBody.arguments.count, 5); - assert.equal(capturedBody.arguments.type, "http"); + assert.equal((result as any).proxies[0].host, "10.0.0.1"); + assert.equal((result as any).proxies[0].type, "http"); }); test("oneproxy rotate chama omniroute_oneproxy_rotate com provider", async () => { - let capturedBody: any = null; const origFetch = globalThis.fetch; - globalThis.fetch = ((_url: string, opts: any) => { - if (opts?.body) capturedBody = JSON.parse(opts.body); - return Promise.resolve(makeResp({ rotated: true, newProxy: "10.0.0.2" })); - }) as any; - - await (globalThis.fetch as any)("/api/mcp/tools/call", { - method: "POST", - body: JSON.stringify({ - name: "omniroute_oneproxy_rotate", - arguments: { provider: "anthropic" }, - }), - }); - + globalThis.fetch = makeMcpStreamFetch({ toolResult: { rotated: true, newProxy: "10.0.0.2" } }); + const { mcpCallTool } = await import("../../bin/cli/mcpClient.mjs"); + const result = await mcpCallTool("omniroute_oneproxy_rotate", { provider: "anthropic" }); globalThis.fetch = origFetch; - assert.equal(capturedBody.name, "omniroute_oneproxy_rotate"); - assert.equal(capturedBody.arguments.provider, "anthropic"); + assert.equal((result as any).rotated, true); + assert.equal((result as any).newProxy, "10.0.0.2"); }); test("oneproxy config set envia PUT /api/settings/oneproxy", async () => { diff --git a/tests/unit/cli-resilience-commands.test.ts b/tests/unit/cli-resilience-commands.test.ts index c2a8de6350..1e0ae3bcbb 100644 --- a/tests/unit/cli-resilience-commands.test.ts +++ b/tests/unit/cli-resilience-commands.test.ts @@ -1,4 +1,5 @@ import test from "node:test"; +import { makeMcpResp, makeMcpStreamFetch } from "./helpers/mcpStreamMock.ts"; import assert from "node:assert/strict"; function makeResp(data: unknown, status = 200) { @@ -111,25 +112,25 @@ test("resilience reset envia provider e body correto", async () => { assert.equal(capturedBody.connectionId, "conn-1"); }); -test("resilience profile set chama MCP tool", async () => { - let capturedBody: any = null; +test("resilience profile set usa JSON-RPC tools/call", async () => { + let capturedCall: any = null; const origFetch = globalThis.fetch; - globalThis.fetch = ((_url: string, opts: any) => { - if (opts?.body) capturedBody = JSON.parse(opts.body); - return Promise.resolve(makeResp({ result: {} })); + globalThis.fetch = makeMcpStreamFetch({ toolResult: {} }); + const inner = globalThis.fetch; + globalThis.fetch = ((url: any, init: any) => { + if (String(url).includes("/api/mcp/stream") && String(init?.body || "").includes("tools/call")) { + capturedCall = JSON.parse(init.body); + } + return inner(url, init); }) as any; - await (globalThis.fetch as any)("/api/mcp/tools/call", { - method: "POST", - body: JSON.stringify({ - name: "omniroute_set_resilience_profile", - arguments: { profile: "balanced" }, - }), - }); + const { mcpCallTool } = await import("../../bin/cli/mcpClient.mjs"); + await mcpCallTool("omniroute_set_resilience_profile", { profile: "balanced" }); globalThis.fetch = origFetch; - assert.equal(capturedBody.name, "omniroute_set_resilience_profile"); - assert.equal(capturedBody.arguments.profile, "balanced"); + assert.equal(capturedCall.method, "tools/call"); + assert.equal(capturedCall.params.name, "omniroute_set_resilience_profile"); + assert.equal(capturedCall.params.arguments.profile, "balanced"); }); test("resilience.mjs pode ser importado sem erro", async () => { diff --git a/tests/unit/cli-skills-commands.test.ts b/tests/unit/cli-skills-commands.test.ts index cc7b731250..b04692c8f5 100644 --- a/tests/unit/cli-skills-commands.test.ts +++ b/tests/unit/cli-skills-commands.test.ts @@ -1,4 +1,5 @@ import test from "node:test"; +import { makeMcpResp, makeMcpStreamFetch } from "./helpers/mcpStreamMock.ts"; import assert from "node:assert/strict"; const SKILLS_DATA = [ @@ -114,34 +115,37 @@ test("runSkillsGet busca /api/skills/:id", async () => { assert.equal(parsed.id, "sk_pdf"); }); -test("runSkillsEnable envia POST para tools/call", async () => { - let capturedUrl = ""; - let capturedInit: any = null; +test("runSkillsEnable usa JSON-RPC tools/call", async () => { + const calls: unknown[] = []; const origFetch = globalThis.fetch; - globalThis.fetch = ((url: string, init: any) => { - capturedUrl = url; - capturedInit = init; - return Promise.resolve(makeResp({ ok: true })); + globalThis.fetch = makeMcpStreamFetch({ toolResult: { ok: true } }); + const inner = globalThis.fetch; + globalThis.fetch = ((url: unknown, init: unknown) => { + calls.push({ url: String(url), init }); + return inner(url, init); }) as any; const { runSkillsEnable } = await import("../../bin/cli/commands/skills.mjs"); const out = await captureStdout(() => runSkillsEnable("sk_pdf", {}, makeCmd() as any)); globalThis.fetch = origFetch; - assert.ok(capturedUrl.includes("/api/mcp/tools/call")); - const body = JSON.parse(capturedInit?.body); - assert.equal(body.name, "omniroute_skills_enable"); - assert.equal(body.arguments.skillId, "sk_pdf"); - assert.equal(body.arguments.enabled, true); + assert.ok(calls.some((x) => String(x.url).includes("/api/mcp/stream"))); + const callBody = JSON.parse(calls.find((x) => String(x.init?.body || "").includes("tools/call"))?.init?.body || "{}"); + assert.equal(callBody.method, "tools/call"); + assert.equal(callBody.params.name, "omniroute_skills_enable"); + assert.equal(callBody.params.arguments.skillId, "sk_pdf"); + assert.equal(callBody.params.arguments.enabled, true); assert.ok(out.includes("sk_pdf")); }); -test("runSkillsExecute envia POST com skillId e input", async () => { - let capturedBody: any = null; +test("runSkillsExecute usa JSON-RPC tools/call", async () => { + const calls: unknown[] = []; const origFetch = globalThis.fetch; - globalThis.fetch = ((_url: string, init: any) => { - capturedBody = JSON.parse(init.body); - return Promise.resolve(makeResp({ result: "ok", output: "parsed" })); + globalThis.fetch = makeMcpStreamFetch({ toolResult: { result: "ok", output: "parsed" } }); + const inner = globalThis.fetch; + globalThis.fetch = ((url: unknown, init: unknown) => { + calls.push({ url: String(url), init }); + return inner(url, init); }) as any; const { runSkillsExecute } = await import("../../bin/cli/commands/skills.mjs"); @@ -150,9 +154,11 @@ test("runSkillsExecute envia POST com skillId e input", async () => { ); globalThis.fetch = origFetch; - assert.equal(capturedBody.name, "omniroute_skills_execute"); - assert.equal(capturedBody.arguments.skillId, "sk_pdf"); - assert.deepEqual(capturedBody.arguments.input, { file: "doc.pdf" }); + const callBody = JSON.parse(calls.find((x) => String(x.init?.body || "").includes("tools/call"))?.init?.body || "{}"); + assert.equal(callBody.method, "tools/call"); + assert.equal(callBody.params.name, "omniroute_skills_execute"); + assert.equal(callBody.params.arguments.skillId, "sk_pdf"); + assert.deepEqual(callBody.params.arguments.input, { file: "doc.pdf" }); }); test("runSkillsExecutions filtra por skill e status", async () => { @@ -197,9 +203,9 @@ test("runMarketplaceSearch retorna pacotes com query e filtros", async () => { }); test("runMarketplaceInstall --yes envia POST sem confirmação", async () => { - let capturedBody: any = null; + let capturedBody: unknown = null; const origFetch = globalThis.fetch; - globalThis.fetch = ((_url: string, init: any) => { + globalThis.fetch = ((_url: string, init: unknown) => { capturedBody = JSON.parse(init?.body ?? "{}"); return Promise.resolve(makeResp({ skillId: "sk_pdf_installed" })); }) as any; diff --git a/tests/unit/helpers/mcpStreamMock.ts b/tests/unit/helpers/mcpStreamMock.ts new file mode 100644 index 0000000000..9ffc3774f1 --- /dev/null +++ b/tests/unit/helpers/mcpStreamMock.ts @@ -0,0 +1,49 @@ + +import type { Response as Resp } from "undici"; + +// Minimal fetch mock responses that satisfy what apiFetch needs. +export function makeMcpResp(data: unknown, status = 200, headers: Record = {}) { + const hdrs = new Headers({ "content-type": "application/json", ...headers }); + const obj = { + ok: status < 400, + status, + json: () => Promise.resolve(data), + text: () => Promise.resolve(typeof data === "string" ? data : JSON.stringify(data)), + headers: hdrs, + } as unknown as Resp; + return obj; +} + +export function makeMcpStreamFetch({ + toolResult = { content: [{ type: "text", text: "ok" }] }, + initStatus = 200, + callStatus = 200, + callError = false, +} = {}) { + return (async (url: string | URL, init?: unknown) => { + const u = String(url); + if (!u.includes("/api/mcp/stream")) { + return makeMcpResp({ error: "not found" }, 404); + } + const body = init?.body ? JSON.parse(init.body) : {}; + if (body.method === "initialize") { + return makeMcpResp( + { jsonrpc: "2.0", id: body.id, result: { protocolVersion: "2024-11-05", capabilities: {} } }, + initStatus, + initStatus < 400 ? { "mcp-session-id": "sess-test" } : {}, + ); + } + if (body.method === "tools/call") { + if (callStatus !== 200) return makeMcpResp({ error: "tool failure" }, callStatus); + if (callError) { + return makeMcpResp({ + jsonrpc: "2.0", + id: body.id, + result: { content: [{ type: "text", text: "tool error" }], isError: true }, + }); + } + return makeMcpResp({ jsonrpc: "2.0", id: body.id, result: toolResult }); + } + return makeMcpResp({ error: "unknown method" }, 400); + }) as unknown as typeof globalThis.fetch; +}