Commands: add embedded Pi MCP management

This commit is contained in:
Vincent Koc 2026-03-16 21:13:46 -07:00
parent 320c718c7e
commit 590a5483be
18 changed files with 871 additions and 76 deletions

79
src/agents/mcp-stdio.ts Normal file
View File

@ -0,0 +1,79 @@
type StdioMcpServerLaunchConfig = {
command: string;
args?: string[];
env?: Record<string, string>;
cwd?: string;
};
type StdioMcpServerLaunchResult =
| { ok: true; config: StdioMcpServerLaunchConfig }
| { ok: false; reason: string };
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function toStringRecord(value: unknown): Record<string, string> | undefined {
if (!isRecord(value)) {
return undefined;
}
const entries = Object.entries(value)
.map(([key, entry]) => {
if (typeof entry === "string") {
return [key, entry] as const;
}
if (typeof entry === "number" || typeof entry === "boolean") {
return [key, String(entry)] as const;
}
return null;
})
.filter((entry): entry is readonly [string, string] => entry !== null);
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
function toStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const entries = value.filter((entry): entry is string => typeof entry === "string");
return entries.length > 0 ? entries : [];
}
export function resolveStdioMcpServerLaunchConfig(raw: unknown): StdioMcpServerLaunchResult {
if (!isRecord(raw)) {
return { ok: false, reason: "server config must be an object" };
}
if (typeof raw.command !== "string" || raw.command.trim().length === 0) {
if (typeof raw.url === "string" && raw.url.trim().length > 0) {
return {
ok: false,
reason: "only stdio MCP servers are supported right now",
};
}
return { ok: false, reason: "its command is missing" };
}
const cwd =
typeof raw.cwd === "string" && raw.cwd.trim().length > 0
? raw.cwd
: typeof raw.workingDirectory === "string" && raw.workingDirectory.trim().length > 0
? raw.workingDirectory
: undefined;
return {
ok: true,
config: {
command: raw.command,
args: toStringArray(raw.args),
env: toStringRecord(raw.env),
cwd,
},
};
}
export function describeStdioMcpServerLaunchConfig(config: StdioMcpServerLaunchConfig): string {
const args =
Array.isArray(config.args) && config.args.length > 0 ? ` ${config.args.join(" ")}` : "";
const cwd = config.cwd ? ` (cwd=${config.cwd})` : "";
return `${config.command}${args}${cwd}`;
}
export type { StdioMcpServerLaunchConfig, StdioMcpServerLaunchResult };

View File

@ -5,18 +5,12 @@ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import type { OpenClawConfig } from "../config/config.js";
import { logDebug, logWarn } from "../logger.js";
import { loadEnabledBundleMcpConfig } from "../plugins/bundle-mcp.js";
import {
describeStdioMcpServerLaunchConfig,
resolveStdioMcpServerLaunchConfig,
} from "./mcp-stdio.js";
import type { AnyAgentTool } from "./tools/common.js";
type BundleMcpServerLaunchConfig = {
command: string;
args?: string[];
env?: Record<string, string>;
cwd?: string;
};
type BundleMcpServerLaunchResult =
| { ok: true; config: BundleMcpServerLaunchConfig }
| { ok: false; reason: string };
type BundleMcpToolRuntime = {
tools: AnyAgentTool[];
dispose: () => Promise<void>;
@ -33,69 +27,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function toStringRecord(value: unknown): Record<string, string> | undefined {
if (!isRecord(value)) {
return undefined;
}
const entries = Object.entries(value)
.map(([key, entry]) => {
if (typeof entry === "string") {
return [key, entry] as const;
}
if (typeof entry === "number" || typeof entry === "boolean") {
return [key, String(entry)] as const;
}
return null;
})
.filter((entry): entry is readonly [string, string] => entry !== null);
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
function toStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const entries = value.filter((entry): entry is string => typeof entry === "string");
return entries.length > 0 ? entries : [];
}
function resolveLaunchConfig(raw: unknown): BundleMcpServerLaunchResult {
if (!isRecord(raw)) {
return { ok: false, reason: "server config must be an object" };
}
if (typeof raw.command !== "string" || raw.command.trim().length === 0) {
if (typeof raw.url === "string" && raw.url.trim().length > 0) {
return {
ok: false,
reason: "only stdio bundle MCP servers are supported right now",
};
}
return { ok: false, reason: "its command is missing" };
}
const cwd =
typeof raw.cwd === "string" && raw.cwd.trim().length > 0
? raw.cwd
: typeof raw.workingDirectory === "string" && raw.workingDirectory.trim().length > 0
? raw.workingDirectory
: undefined;
return {
ok: true,
config: {
command: raw.command,
args: toStringArray(raw.args),
env: toStringRecord(raw.env),
cwd,
},
};
}
function describeServerLaunchConfig(config: BundleMcpServerLaunchConfig): string {
const args =
Array.isArray(config.args) && config.args.length > 0 ? ` ${config.args.join(" ")}` : "";
const cwd = config.cwd ? ` (cwd=${config.cwd})` : "";
return `${config.command}${args}${cwd}`;
}
async function listAllTools(client: Client) {
const tools: Awaited<ReturnType<Client["listTools"]>>["tools"] = [];
let cursor: string | undefined;
@ -209,7 +140,7 @@ export async function createBundleMcpToolRuntime(params: {
try {
for (const [serverName, rawServer] of Object.entries(loaded.config.mcpServers)) {
const launch = resolveLaunchConfig(rawServer);
const launch = resolveStdioMcpServerLaunchConfig(rawServer);
if (!launch.ok) {
logWarn(`bundle-mcp: skipped server "${serverName}" because ${launch.reason}.`);
continue;
@ -258,7 +189,7 @@ export async function createBundleMcpToolRuntime(params: {
label: tool.title ?? tool.name,
description:
tool.description?.trim() ||
`Provided by bundle MCP server "${serverName}" (${describeServerLaunchConfig(launchConfig)}).`,
`Provided by bundle MCP server "${serverName}" (${describeStdioMcpServerLaunchConfig(launchConfig)}).`,
parameters: tool.inputSchema,
execute: async (_toolCallId, input) => {
const result = (await client.callTool({
@ -275,7 +206,7 @@ export async function createBundleMcpToolRuntime(params: {
}
} catch (error) {
logWarn(
`bundle-mcp: failed to start server "${serverName}" (${describeServerLaunchConfig(launchConfig)}): ${String(error)}`,
`bundle-mcp: failed to start server "${serverName}" (${describeStdioMcpServerLaunchConfig(launchConfig)}): ${String(error)}`,
);
await disposeSession(session);
}

View File

@ -0,0 +1,94 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { withTempHome } from "../config/home-env.test-harness.js";
import {
listProjectMcpServers,
setProjectMcpServer,
unsetProjectMcpServer,
} from "./pi-project-mcp.js";
const tempDirs: string[] = [];
async function createWorkspace(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-project-mcp-"));
tempDirs.push(dir);
return dir;
}
describe("pi project mcp settings", () => {
afterEach(async () => {
await Promise.all(
tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
);
});
it("writes and removes project MCP servers in .pi/settings.json", async () => {
await withTempHome("openclaw-project-mcp-home-", async () => {
const workspaceDir = await createWorkspace();
const setResult = await setProjectMcpServer({
workspaceDir,
name: "context7",
server: {
command: "uvx",
args: ["context7-mcp"],
},
});
expect(setResult.ok).toBe(true);
const loaded = await listProjectMcpServers({ workspaceDir });
expect(loaded.ok).toBe(true);
if (!loaded.ok) {
throw new Error("expected project MCP config to load");
}
expect(loaded.mcpServers.context7).toEqual({
command: "uvx",
args: ["context7-mcp"],
});
const raw = await fs.readFile(path.join(workspaceDir, ".pi", "settings.json"), "utf-8");
expect(JSON.parse(raw)).toEqual({
mcpServers: {
context7: {
command: "uvx",
args: ["context7-mcp"],
},
},
});
const unsetResult = await unsetProjectMcpServer({
workspaceDir,
name: "context7",
});
expect(unsetResult.ok).toBe(true);
const reloaded = await listProjectMcpServers({ workspaceDir });
expect(reloaded.ok).toBe(true);
if (!reloaded.ok) {
throw new Error("expected project MCP config to reload");
}
expect(reloaded.mcpServers).toEqual({});
});
});
it("rejects unsupported non-stdio MCP configs", async () => {
await withTempHome("openclaw-project-mcp-home-", async () => {
const workspaceDir = await createWorkspace();
const result = await setProjectMcpServer({
workspaceDir,
name: "remote",
server: {
url: "https://example.com/mcp",
},
});
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error("expected invalid MCP config to fail");
}
expect(result.error).toContain("only stdio MCP servers are supported right now");
});
});
});

View File

@ -0,0 +1,172 @@
import fs from "node:fs/promises";
import path from "node:path";
import { resolveStdioMcpServerLaunchConfig } from "./mcp-stdio.js";
export type ProjectMcpServers = Record<string, Record<string, unknown>>;
type ProjectSettingsObject = Record<string, unknown>;
type ProjectMcpReadResult =
| {
ok: true;
path: string;
projectSettings: ProjectSettingsObject;
mcpServers: ProjectMcpServers;
}
| { ok: false; path: string; error: string };
type ProjectMcpWriteResult =
| { ok: true; path: string; mcpServers: ProjectMcpServers; removed?: boolean }
| { ok: false; path: string; error: string };
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function normalizeMcpServers(value: unknown): ProjectMcpServers {
if (!isRecord(value)) {
return {};
}
return Object.fromEntries(
Object.entries(value)
.filter(([, server]) => isRecord(server))
.map(([name, server]) => [name, { ...(server as Record<string, unknown>) }]),
);
}
function resolveProjectSettingsPath(workspaceDir: string): string {
return path.join(workspaceDir, ".pi", "settings.json");
}
async function loadProjectSettings(pathname: string): Promise<ProjectMcpReadResult> {
try {
const raw = await fs.readFile(pathname, "utf-8").catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") {
return null;
}
throw error;
});
if (raw === null) {
return {
ok: true,
path: pathname,
projectSettings: {},
mcpServers: {},
};
}
const parsed = JSON.parse(raw) as unknown;
if (!isRecord(parsed)) {
return {
ok: false,
path: pathname,
error: "Project Pi settings must contain a JSON object.",
};
}
return {
ok: true,
path: pathname,
projectSettings: parsed,
mcpServers: normalizeMcpServers(parsed.mcpServers),
};
} catch (error) {
return {
ok: false,
path: pathname,
error: `Project Pi settings are invalid: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
async function writeProjectSettings(
pathname: string,
projectSettings: ProjectSettingsObject,
): Promise<ProjectMcpWriteResult> {
try {
await fs.mkdir(path.dirname(pathname), { recursive: true });
await fs.writeFile(pathname, `${JSON.stringify(projectSettings, null, 2)}\n`, "utf-8");
return {
ok: true,
path: pathname,
mcpServers: normalizeMcpServers(projectSettings.mcpServers),
};
} catch (error) {
return {
ok: false,
path: pathname,
error: error instanceof Error ? error.message : String(error),
};
}
}
export async function setProjectMcpServer(params: {
workspaceDir: string;
name: string;
server: unknown;
}): Promise<ProjectMcpWriteResult> {
const name = params.name.trim();
const pathname = resolveProjectSettingsPath(params.workspaceDir);
if (!name) {
return { ok: false, path: pathname, error: "MCP server name is required." };
}
if (!isRecord(params.server)) {
return { ok: false, path: pathname, error: "MCP server config must be a JSON object." };
}
const launch = resolveStdioMcpServerLaunchConfig(params.server);
if (!launch.ok) {
return {
ok: false,
path: pathname,
error: `Invalid MCP server "${name}": ${launch.reason}.`,
};
}
const loaded = await loadProjectSettings(pathname);
if (!loaded.ok) {
return loaded;
}
const nextSettings = structuredClone(loaded.projectSettings);
const nextMcpServers = normalizeMcpServers(nextSettings.mcpServers);
nextMcpServers[name] = { ...params.server };
nextSettings.mcpServers = nextMcpServers;
return await writeProjectSettings(pathname, nextSettings);
}
export async function unsetProjectMcpServer(params: {
workspaceDir: string;
name: string;
}): Promise<ProjectMcpWriteResult> {
const name = params.name.trim();
const pathname = resolveProjectSettingsPath(params.workspaceDir);
if (!name) {
return { ok: false, path: pathname, error: "MCP server name is required." };
}
const loaded = await loadProjectSettings(pathname);
if (!loaded.ok) {
return loaded;
}
if (!Object.hasOwn(loaded.mcpServers, name)) {
return {
ok: true,
path: pathname,
mcpServers: loaded.mcpServers,
removed: false,
};
}
const nextSettings = structuredClone(loaded.projectSettings);
const nextMcpServers = normalizeMcpServers(nextSettings.mcpServers);
delete nextMcpServers[name];
if (Object.keys(nextMcpServers).length > 0) {
nextSettings.mcpServers = nextMcpServers;
} else {
delete nextSettings.mcpServers;
}
const written = await writeProjectSettings(pathname, nextSettings);
return written.ok ? { ...written, removed: true } : written;
}
export async function listProjectMcpServers(params: {
workspaceDir: string;
}): Promise<ProjectMcpReadResult> {
return await loadProjectSettings(resolveProjectSettingsPath(params.workspaceDir));
}

View File

@ -51,6 +51,16 @@ const formatConfigArgs: CommandArgsFormatter = (values) =>
},
});
const formatMcpArgs: CommandArgsFormatter = (values) =>
formatActionArgs(values, {
formatKnownAction: (action, path) => {
if (action === "show" || action === "get") {
return path ? `${action} ${path}` : action;
}
return undefined;
},
});
const formatDebugArgs: CommandArgsFormatter = (values) =>
formatActionArgs(values, {
formatKnownAction: (action) => {
@ -124,6 +134,7 @@ const formatExecArgs: CommandArgsFormatter = (values) => {
export const COMMAND_ARG_FORMATTERS: Record<string, CommandArgsFormatter> = {
config: formatConfigArgs,
mcp: formatMcpArgs,
debug: formatDebugArgs,
queue: formatQueueArgs,
exec: formatExecArgs,

View File

@ -452,6 +452,34 @@ function buildChatCommands(): ChatCommandDefinition[] {
argsParsing: "none",
formatArgs: COMMAND_ARG_FORMATTERS.config,
}),
defineChatCommand({
key: "mcp",
nativeName: "mcp",
description: "Show or set embedded Pi MCP servers.",
textAlias: "/mcp",
category: "management",
args: [
{
name: "action",
description: "show | get | set | unset",
type: "string",
choices: ["show", "get", "set", "unset"],
},
{
name: "path",
description: "MCP server name",
type: "string",
},
{
name: "value",
description: "JSON config for set",
type: "string",
captureRemaining: true,
},
],
argsParsing: "none",
formatArgs: COMMAND_ARG_FORMATTERS.mcp,
}),
defineChatCommand({
key: "debug",
nativeName: "debug",

View File

@ -99,6 +99,9 @@ export function isCommandEnabled(cfg: OpenClawConfig, commandKey: string): boole
if (commandKey === "config") {
return isCommandFlagEnabled(cfg, "config");
}
if (commandKey === "mcp") {
return isCommandFlagEnabled(cfg, "mcp");
}
if (commandKey === "debug") {
return isCommandFlagEnabled(cfg, "debug");
}

View File

@ -22,6 +22,7 @@ import {
handleStatusCommand,
handleWhoamiCommand,
} from "./commands-info.js";
import { handleMcpCommand } from "./commands-mcp.js";
import { handleModelsCommand } from "./commands-models.js";
import { handlePluginCommand } from "./commands-plugin.js";
import {
@ -194,6 +195,7 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
handleWhoamiCommand,
handleSubagentsCommand,
handleAcpCommand,
handleMcpCommand,
handleConfigCommand,
handleDebugCommand,
handleModelsCommand,

View File

@ -0,0 +1,93 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { withTempHome } from "../../config/home-env.test-harness.js";
import { handleCommands } from "./commands-core.js";
import { buildCommandTestParams } from "./commands.test-harness.js";
const tempDirs: string[] = [];
async function createWorkspace(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-command-mcp-"));
tempDirs.push(dir);
return dir;
}
function buildCfg(): OpenClawConfig {
return {
commands: {
text: true,
mcp: true,
},
};
}
describe("handleCommands /mcp", () => {
afterEach(async () => {
await Promise.all(
tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
);
});
it("writes project MCP config and shows it back", async () => {
await withTempHome("openclaw-command-mcp-home-", async () => {
const workspaceDir = await createWorkspace();
const setParams = buildCommandTestParams(
'/mcp set context7={"command":"uvx","args":["context7-mcp"]}',
buildCfg(),
undefined,
{ workspaceDir },
);
setParams.command.senderIsOwner = true;
const setResult = await handleCommands(setParams);
expect(setResult.reply?.text).toContain('MCP server "context7" saved');
const showParams = buildCommandTestParams("/mcp show context7", buildCfg(), undefined, {
workspaceDir,
});
showParams.command.senderIsOwner = true;
const showResult = await handleCommands(showParams);
expect(showResult.reply?.text).toContain('"command": "uvx"');
expect(showResult.reply?.text).toContain('"args": [');
});
});
it("rejects internal writes without operator.admin", async () => {
await withTempHome("openclaw-command-mcp-home-", async () => {
const workspaceDir = await createWorkspace();
const params = buildCommandTestParams(
'/mcp set context7={"command":"uvx","args":["context7-mcp"]}',
buildCfg(),
{
Provider: "webchat",
Surface: "webchat",
GatewayClientScopes: ["operator.write"],
},
{ workspaceDir },
);
params.command.senderIsOwner = true;
const result = await handleCommands(params);
expect(result.reply?.text).toContain("requires operator.admin");
});
});
it("reports invalid stdio config", async () => {
await withTempHome("openclaw-command-mcp-home-", async () => {
const workspaceDir = await createWorkspace();
const params = buildCommandTestParams(
'/mcp set remote={"url":"https://example.com/mcp"}',
buildCfg(),
undefined,
{ workspaceDir },
);
params.command.senderIsOwner = true;
const result = await handleCommands(params);
expect(result.reply?.text).toContain("only stdio MCP servers are supported right now");
});
});
});

View File

@ -0,0 +1,140 @@
import {
listProjectMcpServers,
setProjectMcpServer,
unsetProjectMcpServer,
} from "../../agents/pi-project-mcp.js";
import { isInternalMessageChannel } from "../../utils/message-channel.js";
import {
rejectNonOwnerCommand,
rejectUnauthorizedCommand,
requireCommandFlagEnabled,
requireGatewayClientScopeForInternalChannel,
} from "./command-gates.js";
import type { CommandHandler } from "./commands-types.js";
import { parseMcpCommand } from "./mcp-commands.js";
function renderJsonBlock(label: string, value: unknown): string {
return `${label}\n\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\``;
}
export const handleMcpCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) {
return null;
}
const mcpCommand = parseMcpCommand(params.command.commandBodyNormalized);
if (!mcpCommand) {
return null;
}
const unauthorized = rejectUnauthorizedCommand(params, "/mcp");
if (unauthorized) {
return unauthorized;
}
const allowInternalReadOnlyShow =
mcpCommand.action === "show" && isInternalMessageChannel(params.command.channel);
const nonOwner = allowInternalReadOnlyShow ? null : rejectNonOwnerCommand(params, "/mcp");
if (nonOwner) {
return nonOwner;
}
const disabled = requireCommandFlagEnabled(params.cfg, {
label: "/mcp",
configKey: "mcp",
});
if (disabled) {
return disabled;
}
if (mcpCommand.action === "error") {
return {
shouldContinue: false,
reply: { text: `⚠️ ${mcpCommand.message}` },
};
}
if (mcpCommand.action === "show") {
const loaded = await listProjectMcpServers({
workspaceDir: params.workspaceDir,
});
if (!loaded.ok) {
return {
shouldContinue: false,
reply: { text: `⚠️ ${loaded.error}` },
};
}
if (mcpCommand.name) {
const server = loaded.mcpServers[mcpCommand.name];
if (!server) {
return {
shouldContinue: false,
reply: { text: `🔌 No MCP server named "${mcpCommand.name}" in ${loaded.path}.` },
};
}
return {
shouldContinue: false,
reply: {
text: renderJsonBlock(`🔌 MCP server "${mcpCommand.name}" (${loaded.path})`, server),
},
};
}
if (Object.keys(loaded.mcpServers).length === 0) {
return {
shouldContinue: false,
reply: { text: `🔌 No project MCP servers configured in ${loaded.path}.` },
};
}
return {
shouldContinue: false,
reply: {
text: renderJsonBlock(`🔌 Project MCP servers (${loaded.path})`, loaded.mcpServers),
},
};
}
const missingAdminScope = requireGatewayClientScopeForInternalChannel(params, {
label: "/mcp write",
allowedScopes: ["operator.admin"],
missingText: "❌ /mcp set|unset requires operator.admin for gateway clients.",
});
if (missingAdminScope) {
return missingAdminScope;
}
if (mcpCommand.action === "set") {
const result = await setProjectMcpServer({
workspaceDir: params.workspaceDir,
name: mcpCommand.name,
server: mcpCommand.value,
});
if (!result.ok) {
return {
shouldContinue: false,
reply: { text: `⚠️ ${result.error}` },
};
}
return {
shouldContinue: false,
reply: {
text: `🔌 MCP server "${mcpCommand.name}" saved to ${result.path}.`,
},
};
}
const result = await unsetProjectMcpServer({
workspaceDir: params.workspaceDir,
name: mcpCommand.name,
});
if (!result.ok) {
return {
shouldContinue: false,
reply: { text: `⚠️ ${result.error}` },
};
}
if (!result.removed) {
return {
shouldContinue: false,
reply: { text: `🔌 No MCP server named "${mcpCommand.name}" in ${result.path}.` },
};
}
return {
shouldContinue: false,
reply: { text: `🔌 MCP server "${mcpCommand.name}" removed from ${result.path}.` },
};
};

View File

@ -0,0 +1,24 @@
import { parseStandardSetUnsetSlashCommand } from "./commands-setunset-standard.js";
export type McpCommand =
| { action: "show"; name?: string }
| { action: "set"; name: string; value: unknown }
| { action: "unset"; name: string }
| { action: "error"; message: string };
export function parseMcpCommand(raw: string): McpCommand | null {
return parseStandardSetUnsetSlashCommand<McpCommand>({
raw,
slash: "/mcp",
invalidMessage: "Invalid /mcp syntax.",
usageMessage: "Usage: /mcp show|set|unset",
onKnownAction: (action, args) => {
if (action === "show" || action === "get") {
return { action: "show", name: args || undefined };
}
return undefined;
},
onSet: (name, value) => ({ action: "set", name, value }),
onUnset: (name) => ({ action: "unset", name }),
});
}

83
src/cli/mcp-cli.test.ts Normal file
View File

@ -0,0 +1,83 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome } from "../config/home-env.test-harness.js";
const mockLog = vi.fn();
const mockError = vi.fn();
const mockExit = vi.fn((code: number) => {
throw new Error(`__exit__:${code}`);
});
vi.mock("../runtime.js", () => ({
defaultRuntime: {
log: (...args: unknown[]) => mockLog(...args),
error: (...args: unknown[]) => mockError(...args),
exit: (code: number) => mockExit(code),
},
}));
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 registerMcpCli: typeof import("./mcp-cli.js").registerMcpCli;
let sharedProgram: Command;
let previousCwd = process.cwd();
async function runMcpCommand(args: string[]) {
await sharedProgram.parseAsync(args, { from: "user" });
}
describe("mcp cli", () => {
beforeAll(async () => {
({ registerMcpCli } = await import("./mcp-cli.js"));
sharedProgram = new Command();
sharedProgram.exitOverride();
registerMcpCli(sharedProgram);
});
beforeEach(() => {
vi.clearAllMocks();
previousCwd = process.cwd();
});
afterEach(async () => {
process.chdir(previousCwd);
await Promise.all(
tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
);
});
it("sets and shows a project MCP server", async () => {
await withTempHome("openclaw-cli-mcp-home-", async () => {
const workspaceDir = await createWorkspace();
process.chdir(workspaceDir);
await runMcpCommand(["mcp", "set", "context7", '{"command":"uvx","args":["context7-mcp"]}']);
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Saved MCP server "context7"'));
mockLog.mockClear();
await runMcpCommand(["mcp", "show", "context7", "--json"]);
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('"command": "uvx"'));
});
});
it("fails when removing an unknown MCP server", async () => {
await withTempHome("openclaw-cli-mcp-home-", async () => {
const workspaceDir = await createWorkspace();
process.chdir(workspaceDir);
await expect(runMcpCommand(["mcp", "unset", "missing"])).rejects.toThrow("__exit__:1");
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining('No MCP server named "missing"'),
);
});
});
});

116
src/cli/mcp-cli.ts Normal file
View File

@ -0,0 +1,116 @@
import { Command } from "commander";
import {
listProjectMcpServers,
setProjectMcpServer,
unsetProjectMcpServer,
} from "../agents/pi-project-mcp.js";
import { parseConfigValue } from "../auto-reply/reply/config-value.js";
import { defaultRuntime } from "../runtime.js";
function fail(message: string): never {
defaultRuntime.error(message);
defaultRuntime.exit(1);
}
function printJson(value: unknown): void {
defaultRuntime.log(JSON.stringify(value, null, 2));
}
export function registerMcpCli(program: Command) {
const mcp = program
.command("mcp")
.description("Manage embedded Pi MCP servers in project .pi/settings.json");
mcp
.command("list")
.description("List project MCP servers")
.option("--json", "Print JSON")
.action(async (opts: { json?: boolean }) => {
const loaded = await listProjectMcpServers({
workspaceDir: process.cwd(),
});
if (!loaded.ok) {
fail(loaded.error);
}
if (opts.json) {
printJson(loaded.mcpServers);
return;
}
const names = Object.keys(loaded.mcpServers).toSorted();
if (names.length === 0) {
defaultRuntime.log(`No project MCP servers configured in ${loaded.path}.`);
return;
}
defaultRuntime.log(`Project MCP servers (${loaded.path}):`);
for (const name of names) {
defaultRuntime.log(`- ${name}`);
}
});
mcp
.command("show")
.description("Show one project MCP server or the full MCP config")
.argument("[name]", "MCP server name")
.option("--json", "Print JSON")
.action(async (name: string | undefined, opts: { json?: boolean }) => {
const loaded = await listProjectMcpServers({
workspaceDir: process.cwd(),
});
if (!loaded.ok) {
fail(loaded.error);
}
const value = name ? loaded.mcpServers[name] : loaded.mcpServers;
if (name && !value) {
fail(`No MCP server named "${name}" in ${loaded.path}.`);
}
if (opts.json) {
printJson(value ?? {});
return;
}
if (name) {
defaultRuntime.log(`MCP server "${name}" (${loaded.path}):`);
} else {
defaultRuntime.log(`Project MCP servers (${loaded.path}):`);
}
printJson(value ?? {});
});
mcp
.command("set")
.description("Set one project MCP server from a JSON object")
.argument("<name>", "MCP server name")
.argument("<value>", 'JSON object, for example {"command":"uvx","args":["context7-mcp"]}')
.action(async (name: string, rawValue: string) => {
const parsed = parseConfigValue(rawValue);
if (parsed.error) {
fail(parsed.error);
}
const result = await setProjectMcpServer({
workspaceDir: process.cwd(),
name,
server: parsed.value,
});
if (!result.ok) {
fail(result.error);
}
defaultRuntime.log(`Saved MCP server "${name}" to ${result.path}.`);
});
mcp
.command("unset")
.description("Remove one project MCP server")
.argument("<name>", "MCP server name")
.action(async (name: string) => {
const result = await unsetProjectMcpServer({
workspaceDir: process.cwd(),
name,
});
if (!result.ok) {
fail(result.error);
}
if (!result.removed) {
fail(`No MCP server named "${name}" in ${result.path}.`);
}
defaultRuntime.log(`Removed MCP server "${name}" from ${result.path}.`);
});
}

View File

@ -160,6 +160,19 @@ const coreEntries: CoreCliEntry[] = [
mod.registerMemoryCli(program);
},
},
{
commands: [
{
name: "mcp",
description: "Manage embedded Pi MCP servers",
hasSubcommands: true,
},
],
register: async ({ program }) => {
const mod = await import("../mcp-cli.js");
mod.registerMcpCli(program);
},
},
{
commands: [
{

View File

@ -1093,6 +1093,8 @@ export const FIELD_HELP: Record<string, string> = {
"commands.bashForegroundMs":
"How long bash waits before backgrounding (default: 2000; 0 backgrounds immediately).",
"commands.config": "Allow /config chat command to read/write config on disk (default: false).",
"commands.mcp":
"Allow /mcp chat command to manage embedded Pi MCP servers in .pi/settings.json (default: false).",
"commands.debug": "Allow /debug chat command for runtime-only overrides (default: false).",
"commands.restart": "Allow /restart and gateway restart tool actions (default: true).",
"commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.",

View File

@ -503,6 +503,7 @@ export const FIELD_LABELS: Record<string, string> = {
"commands.bash": "Allow Bash Chat Command",
"commands.bashForegroundMs": "Bash Foreground Window (ms)",
"commands.config": "Allow /config",
"commands.mcp": "Allow /mcp",
"commands.debug": "Allow /debug",
"commands.restart": "Allow Restart",
"commands.useAccessGroups": "Use Access Groups",

View File

@ -148,6 +148,8 @@ export type CommandsConfig = {
bashForegroundMs?: number;
/** Allow /config command (default: false). */
config?: boolean;
/** Allow /mcp command for project-local embedded Pi MCP settings (default: false). */
mcp?: boolean;
/** Allow /debug command (default: false). */
debug?: boolean;
/** Allow restart commands/tools (default: true). */

View File

@ -200,6 +200,7 @@ export const CommandsSchema = z
bash: z.boolean().optional(),
bashForegroundMs: z.number().int().min(0).max(30_000).optional(),
config: z.boolean().optional(),
mcp: z.boolean().optional(),
debug: z.boolean().optional(),
restart: z.boolean().optional().default(true),
useAccessGroups: z.boolean().optional(),