diff --git a/bin/cli/commands/mcp.mjs b/bin/cli/commands/mcp.mjs index 29d1693a01..fedbef6e1e 100644 --- a/bin/cli/commands/mcp.mjs +++ b/bin/cli/commands/mcp.mjs @@ -1,6 +1,26 @@ +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")); @@ -22,6 +42,169 @@ export function registerMcp(program) { const exitCode = await runMcpRestartCommand({ ...opts, output: globalOpts.output }); if (exitCode !== 0) process.exit(exitCode); }); + + // 5.1 — mcp call + mcp scopes + mcp + .command("call [argsJson]") + .description(t("mcp.call.description")) + .option("--args ", t("mcp.call.args")) + .option("--args-file ", t("mcp.call.args_file")) + .option("--stream", t("mcp.call.stream")) + .option("--scope ", 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) + : {}; + + if (opts.stream) { + await runMcpStream(tool, args, globalOpts); + return; + } + + 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); + }); + + mcp + .command("scopes") + .description(t("mcp.scopes.description")) + .option("--tool ", 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()); + }); + + // 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) { + const baseUrl = globalOpts.baseUrl ?? "http://localhost:20128"; + const apiKey = globalOpts.apiKey ?? ""; + const res = await fetch(`${baseUrl}/api/mcp/stream`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + }, + body: JSON.stringify({ name: tool, arguments: args }), + }); + if (!res.ok) { + process.stderr.write(`HTTP ${res.status}\n`); + process.exit(1); + } + const reader = res.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"); + } + } + } } export async function runMcpStatusCommand(opts = {}) { diff --git a/bin/cli/locales/en.json b/bin/cli/locales/en.json index 5768c3948e..21452fecab 100644 --- a/bin/cli/locales/en.json +++ b/bin/cli/locales/en.json @@ -244,7 +244,133 @@ "title": "MCP Server", "running": "MCP server running ({transport})", "stopped": "MCP server stopped.", - "restarted": "MCP server restarted." + "restarted": "MCP server restarted.", + "call": { + "description": "Invoke an MCP tool directly", + "args": "JSON arguments object (inline)", + "args_file": "Path to JSON arguments file", + "stream": "Use streaming endpoint (/api/mcp/stream)", + "scope": "Required scope (repeatable, e.g. read:health)" + }, + "scopes": { + "description": "List available MCP scopes", + "tool": "Show scopes required by a specific tool" + }, + "tools": { + "description": "Inspect MCP tools", + "list": { + "description": "List all MCP tools", + "scope": "Filter by scope" + }, + "info": { + "description": "Show metadata for an MCP tool" + }, + "schema": { + "description": "Show input/output JSON schema for an MCP tool", + "io": "Schema kind: input|output (default: input)" + } + }, + "audit": { + "description": "MCP audit log (alias for audit --source mcp)" + } + }, + "a2a": { + "skills": { + "description": "List available A2A skills from the agent card" + }, + "invoke": { + "description": "Invoke an A2A skill and return the task ID", + "input": "JSON input object", + "input_file": "Path to JSON input file", + "wait": "Wait for task completion before returning", + "timeout": "Timeout waiting for completion in ms (default: 60000)" + }, + "tasks": { + "description": "Manage A2A tasks", + "list": { + "status": "Filter by status", + "skill": "Filter by skill ID" + }, + "watch": { + "description": "Poll task status until completion" + }, + "stream": { + "description": "Stream task execution events via SSE" + }, + "logs": { + "description": "Show task messages and artifacts" + } + } + }, + "policy": { + "description": "Manage OmniRoute authorization policies", + "list": { + "description": "List policies", + "kind": "Filter by kind (allow|deny|rate-limit|cost-cap)", + "scope": "Filter by scope (global|api-key|provider)" + }, + "get": { + "description": "Get policy details by ID" + }, + "create": { + "description": "Create a policy from a JSON file", + "file": "Path to policy JSON file" + }, + "update": { + "description": "Update a policy from a JSON file", + "file": "Path to policy JSON file" + }, + "delete": { + "description": "Delete a policy by ID", + "yes": "Skip confirmation prompt" + }, + "evaluate": { + "description": "Dry-run policy evaluation (exit 0=allowed, 4=denied)", + "api_key": "API key to evaluate", + "action": "Action to check (e.g. chat, embed, admin)", + "resource": "Resource path or identifier", + "context": "Additional context as JSON object" + }, + "export": { + "description": "Export all policies to a JSON file" + }, + "import": { + "description": "Import policies from a JSON file", + "overwrite": "Overwrite existing policies with same ID" + } + }, + "compression": { + "description": "Configure and inspect the OmniRoute compression pipeline", + "status": { + "description": "Show current compression status and settings" + }, + "configure": { + "description": "Configure compression settings", + "engine": "Compression engine (caveman|rtk|hybrid|none)", + "caveman_agg": "Caveman aggressiveness 0.0–1.0", + "rtk_budget": "RTK token budget", + "language_pack": "Language pack to activate" + }, + "engine": { + "description": "Get or set the active compression engine" + }, + "combos": { + "description": "Manage compression combo statistics" + }, + "rules": { + "description": "Manage compression rules", + "add": { + "pattern": "Pattern to match (regex or field:pattern)", + "action": "Action: drop|shrink|replace" + } + }, + "language_packs": { + "description": "List available compression language packs" + }, + "preview": { + "description": "Preview compression effect on a request", + "file": "Path to request JSON file" + } }, "tunnel": { "title": "Tunnels", diff --git a/bin/cli/locales/pt-BR.json b/bin/cli/locales/pt-BR.json index fcf15760e1..4396839a1a 100644 --- a/bin/cli/locales/pt-BR.json +++ b/bin/cli/locales/pt-BR.json @@ -242,10 +242,136 @@ }, "mcp": { "title": "Servidor MCP", + "call": { + "description": "Invocar uma ferramenta MCP diretamente", + "args": "Objeto JSON de argumentos (inline)", + "args_file": "Caminho para arquivo JSON de argumentos", + "stream": "Usar endpoint de streaming (/api/mcp/stream)", + "scope": "Escopo requerido (repetível, ex: read:health)" + }, + "scopes": { + "description": "Listar escopos MCP disponíveis", + "tool": "Mostrar escopos requeridos por uma ferramenta específica" + }, + "tools": { + "description": "Inspecionar ferramentas MCP", + "list": { + "description": "Listar todas as ferramentas MCP", + "scope": "Filtrar por escopo" + }, + "info": { + "description": "Exibir metadados de uma ferramenta MCP" + }, + "schema": { + "description": "Exibir schema JSON de entrada/saída de uma ferramenta MCP", + "io": "Tipo de schema: input|output (padrão: input)" + } + }, + "audit": { + "description": "Log de auditoria MCP (alias para audit --source mcp)" + }, "running": "Servidor MCP em execução ({transport})", "stopped": "Servidor MCP parado.", "restarted": "Servidor MCP reiniciado." }, + "a2a": { + "skills": { + "description": "Listar skills A2A disponíveis na agent card" + }, + "invoke": { + "description": "Invocar uma skill A2A e retornar o ID da task", + "input": "Objeto JSON de entrada", + "input_file": "Caminho para arquivo JSON de entrada", + "wait": "Aguardar conclusão da task antes de retornar", + "timeout": "Timeout aguardando conclusão em ms (padrão: 60000)" + }, + "tasks": { + "description": "Gerenciar tasks A2A", + "list": { + "status": "Filtrar por status", + "skill": "Filtrar por ID de skill" + }, + "watch": { + "description": "Monitorar status da task até conclusão" + }, + "stream": { + "description": "Transmitir eventos de execução da task via SSE" + }, + "logs": { + "description": "Exibir mensagens e artefatos da task" + } + } + }, + "policy": { + "description": "Gerenciar políticas de autorização do OmniRoute", + "list": { + "description": "Listar políticas", + "kind": "Filtrar por tipo (allow|deny|rate-limit|cost-cap)", + "scope": "Filtrar por escopo (global|api-key|provider)" + }, + "get": { + "description": "Obter detalhes de política por ID" + }, + "create": { + "description": "Criar política a partir de arquivo JSON", + "file": "Caminho para arquivo JSON de política" + }, + "update": { + "description": "Atualizar política a partir de arquivo JSON", + "file": "Caminho para arquivo JSON de política" + }, + "delete": { + "description": "Deletar política por ID", + "yes": "Pular confirmação" + }, + "evaluate": { + "description": "Dry-run de avaliação de política (saída 0=permitido, 4=negado)", + "api_key": "Chave de API a avaliar", + "action": "Ação a verificar (ex: chat, embed, admin)", + "resource": "Caminho ou identificador do recurso", + "context": "Contexto adicional como objeto JSON" + }, + "export": { + "description": "Exportar todas as políticas para arquivo JSON" + }, + "import": { + "description": "Importar políticas de arquivo JSON", + "overwrite": "Sobrescrever políticas existentes com mesmo ID" + } + }, + "compression": { + "description": "Configurar e inspecionar o pipeline de compressão do OmniRoute", + "status": { + "description": "Exibir status atual de compressão e configurações" + }, + "configure": { + "description": "Configurar as definições de compressão", + "engine": "Engine de compressão (caveman|rtk|hybrid|none)", + "caveman_agg": "Agressividade do Caveman 0.0–1.0", + "rtk_budget": "Budget de tokens RTK", + "language_pack": "Language pack a ativar" + }, + "engine": { + "description": "Obter ou definir o engine de compressão ativo" + }, + "combos": { + "description": "Gerenciar estatísticas de combos de compressão" + }, + "rules": { + "description": "Gerenciar regras de compressão", + "add": { + "pattern": "Padrão para corresponder (regex ou campo:padrão)", + "action": "Ação: drop|shrink|replace" + } + }, + "language_packs": { + "description": "Listar language packs de compressão disponíveis" + }, + "preview": { + "description": "Visualizar efeito de compressão em uma requisição", + "file": "Caminho para arquivo JSON da requisição" + } + }, "tunnel": { "title": "Túneis", "created": "Túnel criado: {url}", diff --git a/tests/unit/cli-mcp-call-commands.test.ts b/tests/unit/cli-mcp-call-commands.test.ts new file mode 100644 index 0000000000..34975ff75c --- /dev/null +++ b/tests/unit/cli-mcp-call-commands.test.ts @@ -0,0 +1,149 @@ +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; +} + +async function captureStdout(fn: () => Promise): Promise { + const chunks: string[] = []; + const orig = process.stdout.write.bind(process.stdout); + process.stdout.write = (c: string | Uint8Array) => { + chunks.push(typeof c === "string" ? c : c.toString()); + return true; + }; + try { + await fn(); + } finally { + process.stdout.write = orig; + } + return chunks.join(""); +} + +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 = ""; + 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" } })); + }) 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: {} }), + }); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("/api/mcp/tools/call")); + assert.equal(capturedBody.name, "omniroute_get_health"); + assert.deepEqual(capturedBody.arguments, {}); +}); + +test("mcp call com --args passa argumentos como JSON", 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({ result: {} })); + }) as any; + + await (globalThis.fetch as any)("/api/mcp/tools/call", { + method: "POST", + body: JSON.stringify({ name: "omniroute_check_quota", arguments: { provider: "openai" } }), + }); + + globalThis.fetch = origFetch; + assert.equal(capturedBody.arguments.provider, "openai"); +}); + +test("mcp scopes envia meta=scopes na query", async () => { + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + capturedUrl = url; + return Promise.resolve(makeResp({ scopes: ["read:health", "read:combos", "write:settings"] })); + }) 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()); + }); + + globalThis.fetch = origFetch; + const parsed = JSON.parse(out); + assert.ok(Array.isArray(parsed)); + assert.equal(parsed.length, 2); +}); + +test("mcp tools list com --scope filtra por scope", async () => { + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + capturedUrl = url; + return Promise.resolve(makeResp({ tools: [] })); + }) as any; + + const params = new URLSearchParams({ scope: "read:health" }); + await (globalThis.fetch as any)(`/api/mcp/tools?${params}`); + + 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"); +});