Files
openclaw/src/cli/mcp-cli.test.ts
Peter Steinberger 99ce71ddbb feat: improve MCP operability
Summary:
- Add MCP status, probe, and projected-tools CLI surfaces.
- Add per-server MCP tool filters plus resource/prompt utility projection.
- Harden MCP runtime discovery, listChanged invalidation, request-failure backoff, and metadata sanitization.
- Preserve current main type health by narrowing the shared future timestamp guard.

Verification:
- pnpm test src/shared/number-coercion.test.ts src/agents/auth-profiles/usage.test.ts src/cli/mcp-cli.test.ts src/agents/agent-bundle-mcp-runtime.test.ts src/agents/agent-bundle-mcp-tools.materialize.test.ts -- --reporter=verbose
- pnpm lint
- pnpm tsgo:prod
- pnpm build
- git diff --check origin/main...HEAD
- GitHub Actions: dependency-guard, real behavior proof, security high MCP boundary, build/lint/types/guards/docs, gateway/plugin/agent shards green on PR head.

Known proof gap:
- Existing checks-node-agentic-commands-doctor no-output watchdog reproduced locally outside touched paths.
2026-05-30 19:48:52 +01:00

223 lines
7.0 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome } from "../config/home-env.test-harness.js";
import { registerMcpCli } from "./mcp-cli.js";
const mocks = vi.hoisted(() => {
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number) => {
throw new Error(`__exit__:${code}`);
}),
writeJson: vi.fn((value: unknown, space = 2) => {
runtime.log(JSON.stringify(value, null, space > 0 ? space : undefined));
}),
};
return {
runtime,
serveOpenClawChannelMcp: vi.fn(),
};
});
const defaultRuntime = mocks.runtime;
const mockLog = defaultRuntime.log;
const mockError = defaultRuntime.error;
const serveOpenClawChannelMcp = mocks.serveOpenClawChannelMcp;
vi.mock("../runtime.js", () => ({
defaultRuntime: mocks.runtime,
}));
vi.mock("../mcp/channel-server.js", () => ({
serveOpenClawChannelMcp: mocks.serveOpenClawChannelMcp,
}));
const tempDirs: string[] = [];
async function createWorkspace(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-mcp-"));
tempDirs.push(dir);
return dir;
}
let sharedProgram: Command;
async function runMcpCommand(args: string[]) {
await sharedProgram.parseAsync(args, { from: "user" });
}
function lastLogLine(): string {
return lastRuntimeLine(mockLog);
}
function lastErrorLine(): string {
return lastRuntimeLine(mockError);
}
function lastRuntimeLine(mock: typeof mockLog): string {
const call = mock.mock.calls[mock.mock.calls.length - 1];
return String(call?.[0] ?? "");
}
describe("mcp cli", () => {
if (!sharedProgram) {
sharedProgram = new Command();
sharedProgram.exitOverride();
registerMcpCli(sharedProgram);
}
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(async () => {
vi.restoreAllMocks();
await Promise.all(
tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
);
});
it("sets and shows a configured MCP server", async () => {
await withTempHome("openclaw-cli-mcp-home-", async (home) => {
const workspaceDir = await createWorkspace();
const configPath = path.join(home, ".openclaw", "openclaw.json");
vi.spyOn(process, "cwd").mockReturnValue(workspaceDir);
await runMcpCommand(["mcp", "set", "context7", '{"command":"uvx","args":["context7-mcp"]}']);
expect(lastLogLine()).toBe(`Saved MCP server "context7" to ${configPath}.`);
mockLog.mockClear();
await runMcpCommand(["mcp", "show", "context7", "--json"]);
expect(JSON.parse(lastLogLine())).toEqual({ command: "uvx", args: ["context7-mcp"] });
});
});
it("updates per-server MCP tool filters", async () => {
await withTempHome("openclaw-cli-mcp-home-", async (home) => {
const workspaceDir = await createWorkspace();
const configPath = path.join(home, ".openclaw", "openclaw.json");
vi.spyOn(process, "cwd").mockReturnValue(workspaceDir);
await runMcpCommand(["mcp", "set", "docs", '{"command":"node","args":["server.mjs"]}']);
await runMcpCommand([
"mcp",
"tools",
"docs",
"--include",
"search,read_*",
"--exclude",
"admin_*",
]);
expect(lastLogLine()).toBe(`Updated MCP tool selection for "docs" in ${configPath}.`);
mockLog.mockClear();
await runMcpCommand(["mcp", "show", "docs", "--json"]);
expect(JSON.parse(lastLogLine()).toolFilter).toEqual({
include: ["read_*", "search"],
exclude: ["admin_*"],
});
});
});
it("requires an explicit MCP tool filter operation", async () => {
await withTempHome("openclaw-cli-mcp-home-", async () => {
const workspaceDir = await createWorkspace();
vi.spyOn(process, "cwd").mockReturnValue(workspaceDir);
await runMcpCommand(["mcp", "set", "docs", '{"command":"node","args":["server.mjs"]}']);
await expect(runMcpCommand(["mcp", "tools", "docs"])).rejects.toThrow("__exit__:1");
expect(lastErrorLine()).toBe("Specify --include, --exclude, or --clear.");
});
});
it("clears per-server MCP tool filters only when requested", async () => {
await withTempHome("openclaw-cli-mcp-home-", async () => {
const workspaceDir = await createWorkspace();
vi.spyOn(process, "cwd").mockReturnValue(workspaceDir);
await runMcpCommand(["mcp", "set", "docs", '{"command":"node","args":["server.mjs"]}']);
await runMcpCommand(["mcp", "tools", "docs", "--include", "search"]);
await runMcpCommand(["mcp", "tools", "docs", "--clear"]);
mockLog.mockClear();
await runMcpCommand(["mcp", "show", "docs", "--json"]);
expect(JSON.parse(lastLogLine())).not.toHaveProperty("toolFilter");
});
});
it("shows MCP transport status without connecting", async () => {
await withTempHome("openclaw-cli-mcp-home-", async () => {
const workspaceDir = await createWorkspace();
vi.spyOn(process, "cwd").mockReturnValue(workspaceDir);
await runMcpCommand([
"mcp",
"set",
"docs",
'{"url":"https://mcp.example.com","transport":"streamable-http"}',
]);
mockLog.mockClear();
await runMcpCommand(["mcp", "status", "--json"]);
expect(JSON.parse(lastLogLine()).servers).toEqual([
{
name: "docs",
configured: true,
ok: true,
transport: "streamable-http",
launch: "https://mcp.example.com",
},
]);
});
});
it("fails when removing an unknown MCP server", async () => {
await withTempHome("openclaw-cli-mcp-home-", async (home) => {
const workspaceDir = await createWorkspace();
const configPath = path.join(home, ".openclaw", "openclaw.json");
vi.spyOn(process, "cwd").mockReturnValue(workspaceDir);
await expect(runMcpCommand(["mcp", "unset", "missing"])).rejects.toThrow("__exit__:1");
expect(lastErrorLine()).toBe(
`No MCP server named "missing" in ${configPath}. Run openclaw mcp list to see configured servers.`,
);
});
});
it("starts the channel bridge with parsed serve options", async () => {
await withTempHome("openclaw-cli-mcp-home-", async () => {
const workspaceDir = await createWorkspace();
const tokenFile = path.join(workspaceDir, "gateway.token");
vi.spyOn(process, "cwd").mockReturnValue(workspaceDir);
await fs.writeFile(tokenFile, "secret-token\n", "utf-8");
await runMcpCommand([
"mcp",
"serve",
"--url",
"ws://127.0.0.1:18789",
"--token-file",
tokenFile,
"--claude-channel-mode",
"on",
"--verbose",
]);
expect(serveOpenClawChannelMcp).toHaveBeenCalledWith({
gatewayUrl: "ws://127.0.0.1:18789",
gatewayToken: "secret-token",
gatewayPassword: undefined,
claudeChannelMode: "on",
verbose: true,
});
});
});
});