From 751beb26ab759a27ab84d3737c2bf1b823bd004a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 21:38:16 -0700 Subject: [PATCH] Config: move MCP management to top-level config --- src/agents/embedded-pi-mcp.ts | 29 +++ src/agents/pi-bundle-mcp-tools.test.ts | 43 +++++ src/agents/pi-bundle-mcp-tools.ts | 6 +- src/agents/pi-project-mcp.test.ts | 94 ---------- src/agents/pi-project-mcp.ts | 172 ------------------ src/agents/pi-project-settings.bundle.test.ts | 52 ++++++ src/agents/pi-project-settings.ts | 10 +- src/auto-reply/reply/commands-mcp.test.ts | 6 +- src/auto-reply/reply/commands-mcp.ts | 24 +-- src/cli/mcp-cli.test.ts | 4 +- src/cli/mcp-cli.ts | 47 ++--- src/config/mcp-config.test.ts | 56 ++++++ src/config/mcp-config.ts | 150 +++++++++++++++ src/config/schema.help.ts | 5 +- src/config/schema.labels.ts | 2 + src/config/types.mcp.ts | 14 ++ src/config/types.openclaw.ts | 2 + src/config/types.ts | 1 + src/config/zod-schema.ts | 19 ++ 19 files changed, 411 insertions(+), 325 deletions(-) create mode 100644 src/agents/embedded-pi-mcp.ts delete mode 100644 src/agents/pi-project-mcp.test.ts delete mode 100644 src/agents/pi-project-mcp.ts create mode 100644 src/config/mcp-config.test.ts create mode 100644 src/config/mcp-config.ts create mode 100644 src/config/types.mcp.ts diff --git a/src/agents/embedded-pi-mcp.ts b/src/agents/embedded-pi-mcp.ts new file mode 100644 index 00000000000..82d4d0e486c --- /dev/null +++ b/src/agents/embedded-pi-mcp.ts @@ -0,0 +1,29 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { normalizeConfiguredMcpServers } from "../config/mcp-config.js"; +import type { BundleMcpDiagnostic, BundleMcpServerConfig } from "../plugins/bundle-mcp.js"; +import { loadEnabledBundleMcpConfig } from "../plugins/bundle-mcp.js"; + +export type EmbeddedPiMcpConfig = { + mcpServers: Record; + diagnostics: BundleMcpDiagnostic[]; +}; + +export function loadEmbeddedPiMcpConfig(params: { + workspaceDir: string; + cfg?: OpenClawConfig; +}): EmbeddedPiMcpConfig { + const bundleMcp = loadEnabledBundleMcpConfig({ + workspaceDir: params.workspaceDir, + cfg: params.cfg, + }); + const configuredMcp = normalizeConfiguredMcpServers(params.cfg?.mcp?.servers); + + return { + // OpenClaw config is the owner-managed layer, so it overrides bundle defaults. + mcpServers: { + ...bundleMcp.config.mcpServers, + ...configuredMcp, + }, + diagnostics: bundleMcp.diagnostics, + }; +} diff --git a/src/agents/pi-bundle-mcp-tools.test.ts b/src/agents/pi-bundle-mcp-tools.test.ts index 76c5ec0d153..69b2839eb94 100644 --- a/src/agents/pi-bundle-mcp-tools.test.ts +++ b/src/agents/pi-bundle-mcp-tools.test.ts @@ -138,4 +138,47 @@ describe("createBundleMcpToolRuntime", () => { await runtime.dispose(); } }); + + it("loads configured stdio MCP tools without a bundle", async () => { + const workspaceDir = await makeTempDir("openclaw-bundle-mcp-tools-"); + const serverScriptPath = path.join(workspaceDir, "servers", "configured-probe.mjs"); + await writeBundleProbeMcpServer(serverScriptPath); + + const runtime = await createBundleMcpToolRuntime({ + workspaceDir, + cfg: { + mcp: { + servers: { + configuredProbe: { + command: "node", + args: [serverScriptPath], + env: { + BUNDLE_PROBE_TEXT: "FROM-CONFIG", + }, + }, + }, + }, + }, + }); + + try { + expect(runtime.tools.map((tool) => tool.name)).toEqual(["bundle_probe"]); + const result = await runtime.tools[0].execute( + "call-configured-probe", + {}, + undefined, + undefined, + ); + expect(result.content[0]).toMatchObject({ + type: "text", + text: "FROM-CONFIG", + }); + expect(result.details).toEqual({ + mcpServer: "configuredProbe", + mcpTool: "bundle_probe", + }); + } finally { + await runtime.dispose(); + } + }); }); diff --git a/src/agents/pi-bundle-mcp-tools.ts b/src/agents/pi-bundle-mcp-tools.ts index d10581ac8a2..159cd8bfe12 100644 --- a/src/agents/pi-bundle-mcp-tools.ts +++ b/src/agents/pi-bundle-mcp-tools.ts @@ -4,7 +4,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" 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 { loadEmbeddedPiMcpConfig } from "./embedded-pi-mcp.js"; import { describeStdioMcpServerLaunchConfig, resolveStdioMcpServerLaunchConfig, @@ -124,7 +124,7 @@ export async function createBundleMcpToolRuntime(params: { cfg?: OpenClawConfig; reservedToolNames?: Iterable; }): Promise { - const loaded = loadEnabledBundleMcpConfig({ + const loaded = loadEmbeddedPiMcpConfig({ workspaceDir: params.workspaceDir, cfg: params.cfg, }); @@ -139,7 +139,7 @@ export async function createBundleMcpToolRuntime(params: { const tools: AnyAgentTool[] = []; try { - for (const [serverName, rawServer] of Object.entries(loaded.config.mcpServers)) { + for (const [serverName, rawServer] of Object.entries(loaded.mcpServers)) { const launch = resolveStdioMcpServerLaunchConfig(rawServer); if (!launch.ok) { logWarn(`bundle-mcp: skipped server "${serverName}" because ${launch.reason}.`); diff --git a/src/agents/pi-project-mcp.test.ts b/src/agents/pi-project-mcp.test.ts deleted file mode 100644 index cd2e7e763d0..00000000000 --- a/src/agents/pi-project-mcp.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -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 deleted file mode 100644 index d47d6742f2a..00000000000 --- a/src/agents/pi-project-mcp.ts +++ /dev/null @@ -1,172 +0,0 @@ -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/agents/pi-project-settings.bundle.test.ts b/src/agents/pi-project-settings.bundle.test.ts index d7cb16c1739..5859e18ac6e 100644 --- a/src/agents/pi-project-settings.bundle.test.ts +++ b/src/agents/pi-project-settings.bundle.test.ts @@ -127,6 +127,58 @@ describe("loadEnabledBundlePiSettingsSnapshot", () => { }); }); + it("lets top-level MCP config override bundle MCP defaults", async () => { + const workspaceDir = await tempDirs.make("openclaw-workspace-"); + const pluginRoot = await tempDirs.make("openclaw-bundle-"); + await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true }); + await fs.writeFile( + path.join(pluginRoot, ".claude-plugin", "plugin.json"), + JSON.stringify({ + name: "claude-bundle", + }), + "utf-8", + ); + await fs.writeFile( + path.join(pluginRoot, ".mcp.json"), + JSON.stringify({ + mcpServers: { + sharedServer: { + command: "node", + args: ["./servers/bundle.mjs"], + }, + }, + }), + "utf-8", + ); + hoisted.loadPluginManifestRegistry.mockReturnValue( + buildRegistry({ pluginRoot, settingsFiles: [] }), + ); + + const snapshot = loadEnabledBundlePiSettingsSnapshot({ + cwd: workspaceDir, + cfg: { + mcp: { + servers: { + sharedServer: { + url: "https://example.com/mcp", + }, + }, + }, + plugins: { + entries: { + "claude-bundle": { enabled: true }, + }, + }, + }, + }); + + expect(snapshot.mcpServers).toEqual({ + sharedServer: { + url: "https://example.com/mcp", + }, + }); + }); + it("ignores disabled bundle plugins", async () => { const workspaceDir = await tempDirs.make("openclaw-workspace-"); const pluginRoot = await tempDirs.make("openclaw-bundle-"); diff --git a/src/agents/pi-project-settings.ts b/src/agents/pi-project-settings.ts index ccc8f969d95..fd66a6ee393 100644 --- a/src/agents/pi-project-settings.ts +++ b/src/agents/pi-project-settings.ts @@ -5,10 +5,10 @@ import type { OpenClawConfig } from "../config/config.js"; import { applyMergePatch } from "../config/merge-patch.js"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { loadEnabledBundleMcpConfig } from "../plugins/bundle-mcp.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "../plugins/config-state.js"; import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; import { isRecord } from "../utils.js"; +import { loadEmbeddedPiMcpConfig } from "./embedded-pi-mcp.js"; import { applyPiCompactionSettingsFromConfig } from "./pi-settings.js"; const log = createSubsystemLogger("embedded-pi-settings"); @@ -108,16 +108,16 @@ export function loadEnabledBundlePiSettingsSnapshot(params: { } } - const bundleMcp = loadEnabledBundleMcpConfig({ + const embeddedPiMcp = loadEmbeddedPiMcpConfig({ workspaceDir, cfg: params.cfg, }); - for (const diagnostic of bundleMcp.diagnostics) { + for (const diagnostic of embeddedPiMcp.diagnostics) { log.warn(`bundle MCP skipped for ${diagnostic.pluginId}: ${diagnostic.message}`); } - if (Object.keys(bundleMcp.config.mcpServers).length > 0) { + if (Object.keys(embeddedPiMcp.mcpServers).length > 0) { snapshot = applyMergePatch(snapshot, { - mcpServers: bundleMcp.config.mcpServers, + mcpServers: embeddedPiMcp.mcpServers, }) as PiSettingsSnapshot; } diff --git a/src/auto-reply/reply/commands-mcp.test.ts b/src/auto-reply/reply/commands-mcp.test.ts index c0b3134c7c6..24d7f15f34b 100644 --- a/src/auto-reply/reply/commands-mcp.test.ts +++ b/src/auto-reply/reply/commands-mcp.test.ts @@ -31,7 +31,7 @@ describe("handleCommands /mcp", () => { ); }); - it("writes project MCP config and shows it back", async () => { + it("writes MCP config and shows it back", async () => { await withTempHome("openclaw-command-mcp-home-", async () => { const workspaceDir = await createWorkspace(); const setParams = buildCommandTestParams( @@ -75,7 +75,7 @@ describe("handleCommands /mcp", () => { }); }); - it("reports invalid stdio config", async () => { + it("accepts non-stdio MCP config at the config layer", async () => { await withTempHome("openclaw-command-mcp-home-", async () => { const workspaceDir = await createWorkspace(); const params = buildCommandTestParams( @@ -87,7 +87,7 @@ describe("handleCommands /mcp", () => { params.command.senderIsOwner = true; const result = await handleCommands(params); - expect(result.reply?.text).toContain("only stdio MCP servers are supported right now"); + expect(result.reply?.text).toContain('MCP server "remote" saved'); }); }); }); diff --git a/src/auto-reply/reply/commands-mcp.ts b/src/auto-reply/reply/commands-mcp.ts index 6a5b9af020e..ff805a9b878 100644 --- a/src/auto-reply/reply/commands-mcp.ts +++ b/src/auto-reply/reply/commands-mcp.ts @@ -1,8 +1,8 @@ import { - listProjectMcpServers, - setProjectMcpServer, - unsetProjectMcpServer, -} from "../../agents/pi-project-mcp.js"; + listConfiguredMcpServers, + setConfiguredMcpServer, + unsetConfiguredMcpServer, +} from "../../config/mcp-config.js"; import { isInternalMessageChannel } from "../../utils/message-channel.js"; import { rejectNonOwnerCommand, @@ -50,9 +50,7 @@ export const handleMcpCommand: CommandHandler = async (params, allowTextCommands } if (mcpCommand.action === "show") { - const loaded = await listProjectMcpServers({ - workspaceDir: params.workspaceDir, - }); + const loaded = await listConfiguredMcpServers(); if (!loaded.ok) { return { shouldContinue: false, @@ -77,13 +75,13 @@ export const handleMcpCommand: CommandHandler = async (params, allowTextCommands if (Object.keys(loaded.mcpServers).length === 0) { return { shouldContinue: false, - reply: { text: `🔌 No project MCP servers configured in ${loaded.path}.` }, + reply: { text: `🔌 No MCP servers configured in ${loaded.path}.` }, }; } return { shouldContinue: false, reply: { - text: renderJsonBlock(`🔌 Project MCP servers (${loaded.path})`, loaded.mcpServers), + text: renderJsonBlock(`🔌 MCP servers (${loaded.path})`, loaded.mcpServers), }, }; } @@ -98,8 +96,7 @@ export const handleMcpCommand: CommandHandler = async (params, allowTextCommands } if (mcpCommand.action === "set") { - const result = await setProjectMcpServer({ - workspaceDir: params.workspaceDir, + const result = await setConfiguredMcpServer({ name: mcpCommand.name, server: mcpCommand.value, }); @@ -117,10 +114,7 @@ export const handleMcpCommand: CommandHandler = async (params, allowTextCommands }; } - const result = await unsetProjectMcpServer({ - workspaceDir: params.workspaceDir, - name: mcpCommand.name, - }); + const result = await unsetConfiguredMcpServer({ name: mcpCommand.name }); if (!result.ok) { return { shouldContinue: false, diff --git a/src/cli/mcp-cli.test.ts b/src/cli/mcp-cli.test.ts index 2131a5fbf58..299406d5f31 100644 --- a/src/cli/mcp-cli.test.ts +++ b/src/cli/mcp-cli.test.ts @@ -41,7 +41,7 @@ describe("mcp cli", () => { sharedProgram = new Command(); sharedProgram.exitOverride(); registerMcpCli(sharedProgram); - }); + }, 300_000); beforeEach(() => { vi.clearAllMocks(); @@ -55,7 +55,7 @@ describe("mcp cli", () => { ); }); - it("sets and shows a project MCP server", async () => { + it("sets and shows a configured MCP server", async () => { await withTempHome("openclaw-cli-mcp-home-", async () => { const workspaceDir = await createWorkspace(); process.chdir(workspaceDir); diff --git a/src/cli/mcp-cli.ts b/src/cli/mcp-cli.ts index 6260b682408..62831ee827d 100644 --- a/src/cli/mcp-cli.ts +++ b/src/cli/mcp-cli.ts @@ -1,10 +1,10 @@ import { Command } from "commander"; -import { - listProjectMcpServers, - setProjectMcpServer, - unsetProjectMcpServer, -} from "../agents/pi-project-mcp.js"; import { parseConfigValue } from "../auto-reply/reply/config-value.js"; +import { + listConfiguredMcpServers, + setConfiguredMcpServer, + unsetConfiguredMcpServer, +} from "../config/mcp-config.js"; import { defaultRuntime } from "../runtime.js"; function fail(message: string): never { @@ -17,18 +17,14 @@ function printJson(value: unknown): void { } export function registerMcpCli(program: Command) { - const mcp = program - .command("mcp") - .description("Manage embedded Pi MCP servers in project .pi/settings.json"); + const mcp = program.command("mcp").description("Manage OpenClaw MCP server config"); mcp .command("list") - .description("List project MCP servers") + .description("List configured MCP servers") .option("--json", "Print JSON") .action(async (opts: { json?: boolean }) => { - const loaded = await listProjectMcpServers({ - workspaceDir: process.cwd(), - }); + const loaded = await listConfiguredMcpServers(); if (!loaded.ok) { fail(loaded.error); } @@ -38,10 +34,10 @@ export function registerMcpCli(program: Command) { } const names = Object.keys(loaded.mcpServers).toSorted(); if (names.length === 0) { - defaultRuntime.log(`No project MCP servers configured in ${loaded.path}.`); + defaultRuntime.log(`No MCP servers configured in ${loaded.path}.`); return; } - defaultRuntime.log(`Project MCP servers (${loaded.path}):`); + defaultRuntime.log(`MCP servers (${loaded.path}):`); for (const name of names) { defaultRuntime.log(`- ${name}`); } @@ -49,13 +45,11 @@ export function registerMcpCli(program: Command) { mcp .command("show") - .description("Show one project MCP server or the full MCP config") + .description("Show one configured 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(), - }); + const loaded = await listConfiguredMcpServers(); if (!loaded.ok) { fail(loaded.error); } @@ -70,14 +64,14 @@ export function registerMcpCli(program: Command) { if (name) { defaultRuntime.log(`MCP server "${name}" (${loaded.path}):`); } else { - defaultRuntime.log(`Project MCP servers (${loaded.path}):`); + defaultRuntime.log(`MCP servers (${loaded.path}):`); } printJson(value ?? {}); }); mcp .command("set") - .description("Set one project MCP server from a JSON object") + .description("Set one configured 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) => { @@ -85,11 +79,7 @@ export function registerMcpCli(program: Command) { if (parsed.error) { fail(parsed.error); } - const result = await setProjectMcpServer({ - workspaceDir: process.cwd(), - name, - server: parsed.value, - }); + const result = await setConfiguredMcpServer({ name, server: parsed.value }); if (!result.ok) { fail(result.error); } @@ -98,13 +88,10 @@ export function registerMcpCli(program: Command) { mcp .command("unset") - .description("Remove one project MCP server") + .description("Remove one configured MCP server") .argument("", "MCP server name") .action(async (name: string) => { - const result = await unsetProjectMcpServer({ - workspaceDir: process.cwd(), - name, - }); + const result = await unsetConfiguredMcpServer({ name }); if (!result.ok) { fail(result.error); } diff --git a/src/config/mcp-config.test.ts b/src/config/mcp-config.test.ts new file mode 100644 index 00000000000..bd7032fb8a4 --- /dev/null +++ b/src/config/mcp-config.test.ts @@ -0,0 +1,56 @@ +import fs from "node:fs/promises"; +import { describe, expect, it } from "vitest"; +import { + listConfiguredMcpServers, + setConfiguredMcpServer, + unsetConfiguredMcpServer, +} from "./mcp-config.js"; +import { withTempHomeConfig } from "./test-helpers.js"; + +describe("config mcp config", () => { + it("writes and removes top-level mcp servers", async () => { + await withTempHomeConfig({}, async () => { + const setResult = await setConfiguredMcpServer({ + name: "context7", + server: { + command: "uvx", + args: ["context7-mcp"], + }, + }); + + expect(setResult.ok).toBe(true); + const loaded = await listConfiguredMcpServers(); + expect(loaded.ok).toBe(true); + if (!loaded.ok) { + throw new Error("expected MCP config to load"); + } + expect(loaded.mcpServers.context7).toEqual({ + command: "uvx", + args: ["context7-mcp"], + }); + + const unsetResult = await unsetConfiguredMcpServer({ name: "context7" }); + expect(unsetResult.ok).toBe(true); + + const reloaded = await listConfiguredMcpServers(); + expect(reloaded.ok).toBe(true); + if (!reloaded.ok) { + throw new Error("expected MCP config to reload"); + } + expect(reloaded.mcpServers).toEqual({}); + }); + }); + + it("fails closed when the config file is invalid", async () => { + await withTempHomeConfig({}, async ({ configPath }) => { + await fs.writeFile(configPath, "{", "utf-8"); + + const loaded = await listConfiguredMcpServers(); + expect(loaded.ok).toBe(false); + if (loaded.ok) { + throw new Error("expected invalid config to fail"); + } + expect(loaded.path).toBe(configPath); + }); + }); +}); diff --git a/src/config/mcp-config.ts b/src/config/mcp-config.ts new file mode 100644 index 00000000000..eb24e3c0ae4 --- /dev/null +++ b/src/config/mcp-config.ts @@ -0,0 +1,150 @@ +import { readConfigFileSnapshot, writeConfigFile } from "./io.js"; +import type { OpenClawConfig } from "./types.openclaw.js"; +import { validateConfigObjectWithPlugins } from "./validation.js"; + +export type ConfigMcpServers = Record>; + +type ConfigMcpReadResult = + | { ok: true; path: string; config: OpenClawConfig; mcpServers: ConfigMcpServers } + | { ok: false; path: string; error: string }; + +type ConfigMcpWriteResult = + | { + ok: true; + path: string; + config: OpenClawConfig; + mcpServers: ConfigMcpServers; + removed?: boolean; + } + | { ok: false; path: string; error: string }; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +export function normalizeConfiguredMcpServers(value: unknown): ConfigMcpServers { + if (!isRecord(value)) { + return {}; + } + return Object.fromEntries( + Object.entries(value) + .filter(([, server]) => isRecord(server)) + .map(([name, server]) => [name, { ...(server as Record) }]), + ); +} + +export async function listConfiguredMcpServers(): Promise { + const snapshot = await readConfigFileSnapshot(); + if (!snapshot.valid) { + return { + ok: false, + path: snapshot.path, + error: "Config file is invalid; fix it before using MCP config commands.", + }; + } + return { + ok: true, + path: snapshot.path, + config: structuredClone(snapshot.resolved), + mcpServers: normalizeConfiguredMcpServers(snapshot.resolved.mcp?.servers), + }; +} + +export async function setConfiguredMcpServer(params: { + name: string; + server: unknown; +}): Promise { + const name = params.name.trim(); + if (!name) { + return { ok: false, path: "", error: "MCP server name is required." }; + } + if (!isRecord(params.server)) { + return { ok: false, path: "", error: "MCP server config must be a JSON object." }; + } + + const loaded = await listConfiguredMcpServers(); + if (!loaded.ok) { + return loaded; + } + + const next = structuredClone(loaded.config); + const servers = normalizeConfiguredMcpServers(next.mcp?.servers); + servers[name] = { ...params.server }; + next.mcp = { + ...next.mcp, + servers, + }; + + const validated = validateConfigObjectWithPlugins(next); + if (!validated.ok) { + const issue = validated.issues[0]; + return { + ok: false, + path: loaded.path, + error: `Config invalid after MCP set (${issue.path}: ${issue.message}).`, + }; + } + await writeConfigFile(validated.config); + return { + ok: true, + path: loaded.path, + config: validated.config, + mcpServers: servers, + }; +} + +export async function unsetConfiguredMcpServer(params: { + name: string; +}): Promise { + const name = params.name.trim(); + if (!name) { + return { ok: false, path: "", error: "MCP server name is required." }; + } + + const loaded = await listConfiguredMcpServers(); + if (!loaded.ok) { + return loaded; + } + if (!Object.hasOwn(loaded.mcpServers, name)) { + return { + ok: true, + path: loaded.path, + config: loaded.config, + mcpServers: loaded.mcpServers, + removed: false, + }; + } + + const next = structuredClone(loaded.config); + const servers = normalizeConfiguredMcpServers(next.mcp?.servers); + delete servers[name]; + if (Object.keys(servers).length > 0) { + next.mcp = { + ...next.mcp, + servers, + }; + } else if (next.mcp) { + delete next.mcp.servers; + if (Object.keys(next.mcp).length === 0) { + delete next.mcp; + } + } + + const validated = validateConfigObjectWithPlugins(next); + if (!validated.ok) { + const issue = validated.issues[0]; + return { + ok: false, + path: loaded.path, + error: `Config invalid after MCP unset (${issue.path}: ${issue.message}).`, + }; + } + await writeConfigFile(validated.config); + return { + ok: true, + path: loaded.path, + config: validated.config, + mcpServers: servers, + removed: true, + }; +} diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index fa99595f768..02d9ea5f6c9 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1094,7 +1094,7 @@ export const FIELD_HELP: Record = { "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).", + "Allow /mcp chat command to manage OpenClaw MCP server config under mcp.servers (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.", @@ -1106,6 +1106,9 @@ export const FIELD_HELP: Record = { "Optional secret used to HMAC hash owner IDs when ownerDisplay=hash. Prefer env substitution.", "commands.allowFrom": "Defines elevated command allow rules by channel and sender for owner-level command surfaces. Use narrow provider-specific identities so privileged commands are not exposed to broad chat audiences.", + mcp: "Global MCP server definitions managed by OpenClaw. Embedded Pi and other runtime adapters can consume these servers without storing them inside Pi-owned project settings.", + "mcp.servers": + "Named MCP server definitions. OpenClaw stores them in its own config and runtime adapters decide which transports are supported at execution time.", session: "Global session routing, reset, delivery policy, and maintenance controls for conversation history behavior. Keep defaults unless you need stricter isolation, retention, or delivery constraints.", "session.scope": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 964af8b55a7..f00b9fd9226 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -511,6 +511,8 @@ export const FIELD_LABELS: Record = { "commands.ownerDisplay": "Owner ID Display", "commands.ownerDisplaySecret": "Owner ID Hash Secret", // pragma: allowlist secret "commands.allowFrom": "Command Elevated Access Rules", + mcp: "MCP", + "mcp.servers": "MCP Servers", ui: "UI", "ui.seamColor": "Accent Color", "ui.assistant": "Assistant Appearance", diff --git a/src/config/types.mcp.ts b/src/config/types.mcp.ts new file mode 100644 index 00000000000..9d6b5e5a1d6 --- /dev/null +++ b/src/config/types.mcp.ts @@ -0,0 +1,14 @@ +export type McpServerConfig = { + command?: string; + args?: string[]; + env?: Record; + cwd?: string; + workingDirectory?: string; + url?: string; + [key: string]: unknown; +}; + +export type McpConfig = { + /** Named MCP server definitions managed by OpenClaw. */ + servers?: Record; +}; diff --git a/src/config/types.openclaw.ts b/src/config/types.openclaw.ts index 3d1f0a90080..9997ecc6f84 100644 --- a/src/config/types.openclaw.ts +++ b/src/config/types.openclaw.ts @@ -14,6 +14,7 @@ import type { TalkConfig, } from "./types.gateway.js"; import type { HooksConfig } from "./types.hooks.js"; +import type { McpConfig } from "./types.mcp.js"; import type { MemoryConfig } from "./types.memory.js"; import type { AudioConfig, @@ -120,6 +121,7 @@ export type OpenClawConfig = { talk?: TalkConfig; gateway?: GatewayConfig; memory?: MemoryConfig; + mcp?: McpConfig; }; export type ConfigValidationIssue = { diff --git a/src/config/types.ts b/src/config/types.ts index 52e45b32aaf..47c46e48c68 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -33,3 +33,4 @@ export * from "./types.tts.js"; export * from "./types.tools.js"; export * from "./types.whatsapp.js"; export * from "./types.memory.js"; +export * from "./types.mcp.js"; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 817183cab5d..b32a86dc68f 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -203,6 +203,24 @@ const TalkSchema = z } }); +const McpServerSchema = z + .object({ + command: z.string().optional(), + args: z.array(z.string()).optional(), + env: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(), + cwd: z.string().optional(), + workingDirectory: z.string().optional(), + url: HttpUrlSchema.optional(), + }) + .catchall(z.unknown()); + +const McpConfigSchema = z + .object({ + servers: z.record(z.string(), McpServerSchema).optional(), + }) + .strict() + .optional(); + export const OpenClawSchema = z .object({ $schema: z.string().optional(), @@ -851,6 +869,7 @@ export const OpenClawSchema = z }) .optional(), memory: MemorySchema, + mcp: McpConfigSchema, skills: z .object({ allowBundled: z.array(z.string()).optional(),