import { minimatch } from "minimatch"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import type { McpItem } from "./types.js"; import { readJsoncFile, updateJsoncTopLevel } from "./jsonc.js"; import { opencodeConfigPath } from "./workspace-files.js"; import { validateMcpConfig, validateMcpName } from "./validators.js"; function globalOpenCodeConfigPath(): string { const base = join(homedir(), ".config", "opencode"); const jsonc = join(base, "opencode.jsonc"); const json = join(base, "opencode.json"); if (existsSync(jsonc)) return jsonc; if (existsSync(json)) return json; return jsonc; // fall back to jsonc (readJsoncFile handles missing files gracefully) } function getMcpConfig(config: Record): Record> { const mcp = config.mcp; if (!mcp || typeof mcp !== "object") return {}; return mcp as Record>; } function getDeniedToolPatterns(config: Record): string[] { const tools = config.tools; if (!tools || typeof tools !== "object") return []; const deny = (tools as { deny?: unknown }).deny; if (!Array.isArray(deny)) return []; return deny.filter((item) => typeof item === "string") as string[]; } function isMcpDisabledByTools(config: Record, name: string): boolean { const patterns = getDeniedToolPatterns(config); if (patterns.length === 0) return false; const candidates = [`mcp.${name}`, `mcp.${name}.*`, `mcp:${name}`, `mcp:${name}:*`, "mcp.*", "mcp:*"]; return patterns.some((pattern) => candidates.some((candidate) => minimatch(candidate, pattern))); } export async function listMcp(workspaceRoot: string): Promise { const { data: config } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record); const { data: globalConfig } = await readJsoncFile(globalOpenCodeConfigPath(), {} as Record); const projectMcpMap = getMcpConfig(config); const globalMcpMap = getMcpConfig(globalConfig); const items: McpItem[] = []; // Global MCPs first; project-level entries override global ones with the same name. for (const [name, entry] of Object.entries(globalMcpMap)) { if (Object.prototype.hasOwnProperty.call(projectMcpMap, name)) continue; items.push({ name, config: entry, source: "config.global", disabledByTools: (isMcpDisabledByTools(globalConfig, name) || isMcpDisabledByTools(config, name)) || undefined, }); } // Project MCPs (highest priority). for (const [name, entry] of Object.entries(projectMcpMap)) { items.push({ name, config: entry, source: "config.project", disabledByTools: isMcpDisabledByTools(config, name) || undefined, }); } return items; } export async function addMcp( workspaceRoot: string, name: string, config: Record, ): Promise<{ action: "added" | "updated" }> { validateMcpName(name); validateMcpConfig(config); const { data } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record); const mcpMap = getMcpConfig(data); const existed = Object.prototype.hasOwnProperty.call(mcpMap, name); mcpMap[name] = config; await updateJsoncTopLevel(opencodeConfigPath(workspaceRoot), { mcp: mcpMap }); return { action: existed ? "updated" : "added" }; } export async function removeMcp(workspaceRoot: string, name: string): Promise { const { data } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record); const mcpMap = getMcpConfig(data); if (!Object.prototype.hasOwnProperty.call(mcpMap, name)) return false; delete mcpMap[name]; await updateJsoncTopLevel(opencodeConfigPath(workspaceRoot), { mcp: mcpMap }); return true; }