274 lines
8.4 KiB
JavaScript
274 lines
8.4 KiB
JavaScript
import { readFileSync } from "node:fs";
|
|
import { apiFetch, isServerUp } from "../api.mjs";
|
|
import { emit } from "../output.mjs";
|
|
import { t } from "../i18n.mjs";
|
|
|
|
function truncate(v, len = 60) {
|
|
if (v == null) return "-";
|
|
const s = String(v);
|
|
return s.length > len ? s.slice(0, len - 1) + "…" : s;
|
|
}
|
|
|
|
const mcpToolSchema = [
|
|
{ key: "name", header: "Tool", width: 36 },
|
|
{
|
|
key: "scopes",
|
|
header: "Scopes",
|
|
formatter: (v) => (Array.isArray(v) ? v.join(",") : (v ?? "-")),
|
|
},
|
|
{ key: "auditLevel", header: "Audit", width: 10 },
|
|
{ key: "phase", header: "Phase", width: 6 },
|
|
{ key: "description", header: "Description", formatter: truncate },
|
|
];
|
|
|
|
export function registerMcp(program) {
|
|
const mcp = program.command("mcp").description(t("mcp.title"));
|
|
|
|
mcp
|
|
.command("status")
|
|
.description("Show MCP server status")
|
|
.option("--json", "Output as JSON")
|
|
.action(async (opts, cmd) => {
|
|
const globalOpts = cmd.parent.optsWithGlobals();
|
|
const exitCode = await runMcpStatusCommand({ ...opts, output: globalOpts.output });
|
|
if (exitCode !== 0) process.exit(exitCode);
|
|
});
|
|
|
|
mcp
|
|
.command("restart")
|
|
.description("Restart the MCP server")
|
|
.action(async (opts, cmd) => {
|
|
const globalOpts = cmd.parent.optsWithGlobals();
|
|
const exitCode = await runMcpRestartCommand({ ...opts, output: globalOpts.output });
|
|
if (exitCode !== 0) process.exit(exitCode);
|
|
});
|
|
|
|
// 5.1 — mcp call + mcp scopes
|
|
mcp
|
|
.command("call <tool> [argsJson]")
|
|
.description(t("mcp.call.description"))
|
|
.option("--args <json>", t("mcp.call.args"))
|
|
.option("--args-file <path>", t("mcp.call.args_file"))
|
|
.option("--stream", t("mcp.call.stream"))
|
|
.option("--scope <s>", t("mcp.call.scope"), (v, prev = []) => [...prev, v], [])
|
|
.action(async (tool, argsPositional, opts, cmd) => {
|
|
const globalOpts = cmd.optsWithGlobals();
|
|
const args = opts.args
|
|
? JSON.parse(opts.args)
|
|
: opts.argsFile
|
|
? JSON.parse(readFileSync(opts.argsFile, "utf8"))
|
|
: argsPositional
|
|
? JSON.parse(argsPositional)
|
|
: {};
|
|
|
|
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 = {}) {
|
|
const serverUp = await isServerUp();
|
|
if (!serverUp) {
|
|
console.error(t("common.serverOffline"));
|
|
return 1;
|
|
}
|
|
|
|
try {
|
|
const res = await apiFetch("/api/mcp/status", {
|
|
retry: false,
|
|
timeout: 5000,
|
|
acceptNotOk: true,
|
|
});
|
|
if (!res.ok) {
|
|
console.log(t("mcp.stopped"));
|
|
return 0;
|
|
}
|
|
|
|
const status = await res.json();
|
|
|
|
if (opts.json || opts.output === "json") {
|
|
console.log(JSON.stringify(status, null, 2));
|
|
return 0;
|
|
}
|
|
|
|
const transport = status.transport || "stdio";
|
|
console.log(status.running ? t("mcp.running", { transport }) : t("mcp.stopped"));
|
|
if (status.toolsCount !== undefined) console.log(` Tools: ${status.toolsCount}`);
|
|
if (status.scopes?.length) {
|
|
console.log(" Scopes:");
|
|
for (const scope of status.scopes) console.log(` - ${scope}`);
|
|
}
|
|
return 0;
|
|
} catch (err) {
|
|
console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) }));
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
export async function runMcpRestartCommand(opts = {}) {
|
|
const serverUp = await isServerUp();
|
|
if (!serverUp) {
|
|
console.error(t("common.serverOffline"));
|
|
return 1;
|
|
}
|
|
|
|
try {
|
|
const res = await apiFetch("/api/mcp/restart", {
|
|
method: "POST",
|
|
retry: false,
|
|
timeout: 10000,
|
|
acceptNotOk: true,
|
|
});
|
|
if (res.ok) {
|
|
console.log(t("mcp.restarted"));
|
|
return 0;
|
|
}
|
|
console.error(t("common.error", { message: `HTTP ${res.status}` }));
|
|
return 1;
|
|
} catch (err) {
|
|
console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) }));
|
|
return 1;
|
|
}
|
|
}
|