Config: move MCP management to top-level config

This commit is contained in:
Vincent Koc 2026-03-16 21:38:16 -07:00
parent 590a5483be
commit 751beb26ab
19 changed files with 411 additions and 325 deletions

View File

@ -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<string, BundleMcpServerConfig>;
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,
};
}

View File

@ -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();
}
});
});

View File

@ -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<string>;
}): Promise<BundleMcpToolRuntime> {
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}.`);

View File

@ -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<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

@ -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<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

@ -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-");

View File

@ -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;
}

View File

@ -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');
});
});
});

View File

@ -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,

View File

@ -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);

View File

@ -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("<name>", "MCP server name")
.argument("<value>", '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("<name>", "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);
}

View File

@ -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);
});
});
});

150
src/config/mcp-config.ts Normal file
View File

@ -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<string, Record<string, unknown>>;
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<string, unknown> {
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<string, unknown>) }]),
);
}
export async function listConfiguredMcpServers(): Promise<ConfigMcpReadResult> {
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<ConfigMcpWriteResult> {
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<ConfigMcpWriteResult> {
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,
};
}

View File

@ -1094,7 +1094,7 @@ export const FIELD_HELP: Record<string, string> = {
"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<string, string> = {
"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":

View File

@ -511,6 +511,8 @@ export const FIELD_LABELS: Record<string, string> = {
"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",

14
src/config/types.mcp.ts Normal file
View File

@ -0,0 +1,14 @@
export type McpServerConfig = {
command?: string;
args?: string[];
env?: Record<string, string | number | boolean>;
cwd?: string;
workingDirectory?: string;
url?: string;
[key: string]: unknown;
};
export type McpConfig = {
/** Named MCP server definitions managed by OpenClaw. */
servers?: Record<string, McpServerConfig>;
};

View File

@ -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 = {

View File

@ -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";

View File

@ -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(),