Some checks failed
CI / build-check-test (push) Has been cancelled
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>
246 lines
8.3 KiB
TypeScript
246 lines
8.3 KiB
TypeScript
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;
|
||
}
|