feat(cli): fase 5.1 — mcp call com stream e mcp scopes

This commit is contained in:
diegosouzapw
2026-05-15 02:05:06 -03:00
parent 23b1bd1ffe
commit 3cfba85461
4 changed files with 585 additions and 1 deletions

View File

@@ -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 <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)
: {};
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 <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());
});
// 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 <s>", 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 <name>")
.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 <name>")
.description(t("mcp.tools.schema.description"))
.option("--io <kind>", 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 <n>", 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 <p>", 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 = {}) {

View File

@@ -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.01.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",

View File

@@ -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.01.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}",

View File

@@ -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<void>): Promise<string> {
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");
});