Files
openclaw/src/config/mcp-config.ts
2026-04-06 23:08:46 +01:00

163 lines
4.1 KiB
TypeScript

import { isRecord } from "../utils.js";
import { readSourceConfigSnapshot } from "./io.js";
import { replaceConfigFile } from "./mutate.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;
baseHash?: string;
}
| { ok: false; path: string; error: string };
type ConfigMcpWriteResult =
| {
ok: true;
path: string;
config: OpenClawConfig;
mcpServers: ConfigMcpServers;
removed?: boolean;
}
| { ok: false; path: string; error: string };
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 readSourceConfigSnapshot();
if (!snapshot.valid) {
return {
ok: false,
path: snapshot.path,
error: "Config file is invalid; fix it before using MCP config commands.",
};
}
const sourceConfig = snapshot.sourceConfig ?? snapshot.resolved;
return {
ok: true,
path: snapshot.path,
config: structuredClone(sourceConfig),
mcpServers: normalizeConfiguredMcpServers(sourceConfig.mcp?.servers),
baseHash: snapshot.hash,
};
}
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 replaceConfigFile({
nextConfig: validated.config,
baseHash: loaded.baseHash,
});
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 replaceConfigFile({
nextConfig: validated.config,
baseHash: loaded.baseHash,
});
return {
ok: true,
path: loaded.path,
config: validated.config,
mcpServers: servers,
removed: true,
};
}