diff --git a/src/agents/mcp-stdio.ts b/src/agents/mcp-stdio.ts new file mode 100644 index 00000000000..77ab6171ca7 --- /dev/null +++ b/src/agents/mcp-stdio.ts @@ -0,0 +1,79 @@ +type StdioMcpServerLaunchConfig = { + command: string; + args?: string[]; + env?: Record; + cwd?: string; +}; + +type StdioMcpServerLaunchResult = + | { ok: true; config: StdioMcpServerLaunchConfig } + | { ok: false; reason: string }; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function toStringRecord(value: unknown): Record | 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 }; diff --git a/src/agents/pi-bundle-mcp-tools.ts b/src/agents/pi-bundle-mcp-tools.ts index 87977f6c650..d10581ac8a2 100644 --- a/src/agents/pi-bundle-mcp-tools.ts +++ b/src/agents/pi-bundle-mcp-tools.ts @@ -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; - cwd?: string; -}; -type BundleMcpServerLaunchResult = - | { ok: true; config: BundleMcpServerLaunchConfig } - | { ok: false; reason: string }; - type BundleMcpToolRuntime = { tools: AnyAgentTool[]; dispose: () => Promise; @@ -33,69 +27,6 @@ function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } -function toStringRecord(value: unknown): Record | 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>["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); } diff --git a/src/agents/pi-project-mcp.test.ts b/src/agents/pi-project-mcp.test.ts new file mode 100644 index 00000000000..cd2e7e763d0 --- /dev/null +++ b/src/agents/pi-project-mcp.test.ts @@ -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 { + 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"); + }); + }); +}); diff --git a/src/agents/pi-project-mcp.ts b/src/agents/pi-project-mcp.ts new file mode 100644 index 00000000000..d47d6742f2a --- /dev/null +++ b/src/agents/pi-project-mcp.ts @@ -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>; + +type ProjectSettingsObject = Record; + +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 { + 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) }]), + ); +} + +function resolveProjectSettingsPath(workspaceDir: string): string { + return path.join(workspaceDir, ".pi", "settings.json"); +} + +async function loadProjectSettings(pathname: string): Promise { + 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 { + 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 { + 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 { + 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 { + return await loadProjectSettings(resolveProjectSettingsPath(params.workspaceDir)); +} diff --git a/src/auto-reply/commands-args.ts b/src/auto-reply/commands-args.ts index ab49b9ea68a..6f37414c053 100644 --- a/src/auto-reply/commands-args.ts +++ b/src/auto-reply/commands-args.ts @@ -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 = { config: formatConfigArgs, + mcp: formatMcpArgs, debug: formatDebugArgs, queue: formatQueueArgs, exec: formatExecArgs, diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index 58064473543..d4d4da530d3 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -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", diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index 93f8872e37b..8b0d7a5b5d6 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -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"); } diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts index 7a6cc36c05e..f969c9f5f24 100644 --- a/src/auto-reply/reply/commands-core.ts +++ b/src/auto-reply/reply/commands-core.ts @@ -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 { + 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"); + }); + }); +}); diff --git a/src/auto-reply/reply/commands-mcp.ts b/src/auto-reply/reply/commands-mcp.ts new file mode 100644 index 00000000000..6a5b9af020e --- /dev/null +++ b/src/auto-reply/reply/commands-mcp.ts @@ -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}.` }, + }; +}; diff --git a/src/auto-reply/reply/mcp-commands.ts b/src/auto-reply/reply/mcp-commands.ts new file mode 100644 index 00000000000..506efe015df --- /dev/null +++ b/src/auto-reply/reply/mcp-commands.ts @@ -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({ + 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 }), + }); +} diff --git a/src/cli/mcp-cli.test.ts b/src/cli/mcp-cli.test.ts new file mode 100644 index 00000000000..2131a5fbf58 --- /dev/null +++ b/src/cli/mcp-cli.test.ts @@ -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 { + 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"'), + ); + }); + }); +}); diff --git a/src/cli/mcp-cli.ts b/src/cli/mcp-cli.ts new file mode 100644 index 00000000000..6260b682408 --- /dev/null +++ b/src/cli/mcp-cli.ts @@ -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("", "MCP server name") + .argument("", '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("", "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}.`); + }); +} diff --git a/src/cli/program/command-registry.ts b/src/cli/program/command-registry.ts index 1955e851357..93c4616594e 100644 --- a/src/cli/program/command-registry.ts +++ b/src/cli/program/command-registry.ts @@ -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: [ { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 02103650589..fa99595f768 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1093,6 +1093,8 @@ export const FIELD_HELP: Record = { "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.", diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index a88cdc1ded5..964af8b55a7 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -503,6 +503,7 @@ export const FIELD_LABELS: Record = { "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", diff --git a/src/config/types.messages.ts b/src/config/types.messages.ts index 002a1200b8b..e6f976f2df2 100644 --- a/src/config/types.messages.ts +++ b/src/config/types.messages.ts @@ -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). */ diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index b8bb99b1b14..08a3af7c911 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -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(),