Config: move MCP management to top-level config
This commit is contained in:
parent
590a5483be
commit
751beb26ab
29
src/agents/embedded-pi-mcp.ts
Normal file
29
src/agents/embedded-pi-mcp.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -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}.`);
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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));
|
||||
}
|
||||
@ -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-");
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
56
src/config/mcp-config.test.ts
Normal file
56
src/config/mcp-config.test.ts
Normal 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
150
src/config/mcp-config.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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":
|
||||
|
||||
@ -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
14
src/config/types.mcp.ts
Normal 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>;
|
||||
};
|
||||
@ -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 = {
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user