mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-05 20:02:54 +00:00
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.
223 lines
7.0 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
});
|
|
});
|