Files
sproutclaw/.pi/agent/extensions/webui/backend/settings/mcp-settings.ts
root cf5edd6394
Some checks failed
CI / build-check-test (push) Has been cancelled
feat(sproutclaw): modularize webui, extensions, and agent config layout
Restructure local extensions into per-feature directories, split WebUI
into backend modules with slash commands and systemd support, and track
prompts/skills under .pi/agent for portable Gitea deployment.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 16:57:08 +08:00

246 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
const DISABLED_SERVERS_KEY = "mcpServersDisabled";
type McpServerEntry = Record<string, unknown>;
export interface McpToolSettingsEntry {
server: string;
name: string;
description: string;
parameters: string[];
required: string[];
enabled: boolean;
}
export interface McpServerSettingsEntry {
name: string;
configured: boolean;
enabled: boolean;
cached: boolean;
toolCount: number;
enabledToolCount: number;
resourceCount: number;
cachedAt: string;
tools: McpToolSettingsEntry[];
}
function readRawConfig(filePath: string): Record<string, unknown> {
if (!existsSync(filePath)) return { mcpServers: {} };
try {
const raw = JSON.parse(readFileSync(filePath, "utf8"));
return raw && typeof raw === "object" && !Array.isArray(raw) ? raw : { mcpServers: {} };
} catch {
return { mcpServers: {} };
}
}
function writeRawConfig(filePath: string, raw: Record<string, unknown>): void {
mkdirSync(dirname(filePath), { recursive: true });
const tmpPath = `${filePath}.${process.pid}.tmp`;
writeFileSync(tmpPath, `${JSON.stringify(raw, null, 2)}\n`, "utf8");
renameSync(tmpPath, filePath);
}
function getServersObject(raw: Record<string, unknown>): Record<string, McpServerEntry> {
const existing = raw.mcpServers ?? raw["mcp-servers"];
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
return {};
}
return existing as Record<string, McpServerEntry>;
}
function getDisabledServersObject(raw: Record<string, unknown>): Record<string, McpServerEntry> {
const existing = raw[DISABLED_SERVERS_KEY];
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
return {};
}
return existing as Record<string, McpServerEntry>;
}
function setServersObject(raw: Record<string, unknown>, servers: Record<string, McpServerEntry>): void {
delete raw["mcp-servers"];
raw.mcpServers = servers;
}
function setDisabledServersObject(raw: Record<string, unknown>, servers: Record<string, McpServerEntry>): void {
if (Object.keys(servers).length === 0) {
delete raw[DISABLED_SERVERS_KEY];
return;
}
raw[DISABLED_SERVERS_KEY] = servers;
}
function normalizeToolName(value: string): string {
return value.replace(/-/g, "_");
}
function isToolExcluded(toolName: string, serverName: string, excludeTools: unknown): boolean {
if (!Array.isArray(excludeTools) || excludeTools.length === 0) return false;
const candidates = new Set<string>([
normalizeToolName(toolName),
normalizeToolName(`${serverName}_${toolName}`),
normalizeToolName(`${serverName.replace(/-/g, "_")}_${toolName}`),
]);
for (const excluded of excludeTools) {
if (typeof excluded !== "string") continue;
if (candidates.has(normalizeToolName(excluded))) {
return true;
}
}
return false;
}
function removeToolFromExcludeList(excludeTools: string[], toolName: string, serverName: string): string[] {
return excludeTools.filter((entry) => !isToolExcluded(toolName, serverName, [entry]));
}
function readJsonFile(filePath: string): any | null {
if (!existsSync(filePath)) return null;
try {
return JSON.parse(readFileSync(filePath, "utf8"));
} catch {
return null;
}
}
function normalizeMcpTool(serverName: string, tool: any, serverEntry: McpServerEntry): McpToolSettingsEntry {
const schema = tool?.inputSchema && typeof tool.inputSchema === "object" ? tool.inputSchema : {};
const properties = schema && typeof schema.properties === "object" ? Object.keys(schema.properties) : [];
const required = Array.isArray(schema.required) ? schema.required.map(String) : [];
const name = String(tool?.name || "");
return {
server: serverName,
name,
description: String(tool?.description || ""),
parameters: properties,
required,
enabled: name ? !isToolExcluded(name, serverName, serverEntry.excludeTools) : false,
};
}
export function listMcpSettings(configPath: string, cachePath: string): McpServerSettingsEntry[] {
const raw = readRawConfig(configPath);
const activeServers = getServersObject(raw);
const disabledServers = getDisabledServersObject(raw);
const cache = readJsonFile(cachePath);
const cachedServers = cache?.servers && typeof cache.servers === "object" ? cache.servers : {};
const serverNames = Array.from(
new Set([...Object.keys(activeServers), ...Object.keys(disabledServers), ...Object.keys(cachedServers)]),
).sort();
return serverNames.map((serverName) => {
const enabled = Object.prototype.hasOwnProperty.call(activeServers, serverName);
const serverEntry = (enabled ? activeServers[serverName] : disabledServers[serverName]) || {};
const entry = cachedServers[serverName] || {};
const tools = Array.isArray(entry.tools)
? entry.tools
.map((tool: any) => normalizeMcpTool(serverName, tool, serverEntry))
.filter((tool: McpToolSettingsEntry) => tool.name)
.sort((a: McpToolSettingsEntry, b: McpToolSettingsEntry) => a.name.localeCompare(b.name))
: [];
const enabledTools = enabled ? tools.filter((tool) => tool.enabled) : [];
const resources = Array.isArray(entry.resources) ? entry.resources : [];
return {
name: serverName,
configured: enabled || Object.prototype.hasOwnProperty.call(disabledServers, serverName),
enabled,
cached: Boolean(cachedServers[serverName]),
toolCount: tools.length,
enabledToolCount: enabledTools.length,
resourceCount: resources.length,
cachedAt: entry.cachedAt ? new Date(entry.cachedAt).toISOString() : "",
tools,
};
});
}
export function setMcpServerEnabled(
configPath: string,
cachePath: string,
serverName: string,
enabled: boolean,
): McpServerSettingsEntry {
const raw = readRawConfig(configPath);
const activeServers = getServersObject(raw);
const disabledServers = getDisabledServersObject(raw);
if (enabled) {
const entry = disabledServers[serverName];
if (!entry) {
const current = listMcpSettings(configPath, cachePath).find((server) => server.name === serverName);
if (current?.enabled) return current;
throw new Error(`未找到已禁用的 MCP Server${serverName}`);
}
activeServers[serverName] = entry;
delete disabledServers[serverName];
} else {
const entry = activeServers[serverName];
if (!entry) {
const current = listMcpSettings(configPath, cachePath).find((server) => server.name === serverName);
if (current && !current.enabled) return current;
throw new Error(`未找到 MCP Server${serverName}`);
}
disabledServers[serverName] = entry;
delete activeServers[serverName];
}
setServersObject(raw, activeServers);
setDisabledServersObject(raw, disabledServers);
writeRawConfig(configPath, raw);
const updated = listMcpSettings(configPath, cachePath).find((server) => server.name === serverName);
if (!updated) {
throw new Error(`更新 MCP Server 状态失败:${serverName}`);
}
return updated;
}
export function setMcpToolEnabled(
configPath: string,
cachePath: string,
serverName: string,
toolName: string,
enabled: boolean,
): McpToolSettingsEntry {
const raw = readRawConfig(configPath);
const activeServers = getServersObject(raw);
const serverEntry = activeServers[serverName];
if (!serverEntry) {
throw new Error(`MCP Server 未启用:${serverName}`);
}
const excludeTools = Array.isArray(serverEntry.excludeTools)
? serverEntry.excludeTools.filter((value): value is string => typeof value === "string")
: [];
let nextExclude = excludeTools;
if (enabled) {
nextExclude = removeToolFromExcludeList(excludeTools, toolName, serverName);
} else if (!isToolExcluded(toolName, serverName, excludeTools)) {
nextExclude = [...excludeTools, toolName];
}
if (nextExclude.length > 0) {
serverEntry.excludeTools = nextExclude;
} else {
delete serverEntry.excludeTools;
}
activeServers[serverName] = serverEntry;
setServersObject(raw, activeServers);
writeRawConfig(configPath, raw);
const server = listMcpSettings(configPath, cachePath).find((item) => item.name === serverName);
const tool = server?.tools.find((item) => item.name === toolName);
if (!tool) {
throw new Error(`未找到 MCP工具${serverName}/${toolName}`);
}
return tool;
}