From 602b034e4e06980c4e453edf629112274f766e91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?opencode=20=E4=BB=A4=E7=8B=90=E5=86=B2?= Date: Thu, 5 Feb 2026 14:41:38 +0800 Subject: [PATCH 1/2] fix: merge global MCP config with workspace config with deduplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The listMcp function was only reading workspace-level opencode.jsonc, ignoring global MCP servers configured in ~/.config/opencode/opencode.json. Changes: - Merge both global and workspace MCP configurations - Workspace config takes precedence over global for same-name MCPs - Deduplicate MCPs with different names but same configuration: - Remote MCPs: deduplicate by URL - Local MCPs: deduplicate by command (ignoring flags like -y) - Each MCP item is tagged with its source (config.global or config.project) - Check disabled status against the appropriate config source Fixes #448 Co-authored-by: opencode 令狐冲 --- packages/server/src/mcp.ts | 110 ++++++++++++++++++++++++++++++++++--- 1 file changed, 102 insertions(+), 8 deletions(-) diff --git a/packages/server/src/mcp.ts b/packages/server/src/mcp.ts index 145a6932..608c104d 100644 --- a/packages/server/src/mcp.ts +++ b/packages/server/src/mcp.ts @@ -1,9 +1,15 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; import { minimatch } from "minimatch"; 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 getGlobalOpencodeConfigPath(): string { + return join(homedir(), ".config", "opencode", "opencode.json"); +} + function getMcpConfig(config: Record): Record> { const mcp = config.mcp; if (!mcp || typeof mcp !== "object") return {}; @@ -25,15 +31,103 @@ function isMcpDisabledByTools(config: Record, name: string): bo return patterns.some((pattern) => candidates.some((candidate) => minimatch(candidate, pattern))); } +// 生成 MCP 配置的唯一标识(用于去重) +function getMcpConfigId(config: Record): string | null { + const type = config.type; + + if (type === "remote") { + // 远程 MCP:使用 URL 作为标识 + const url = config.url; + if (typeof url === "string") return `remote:${url}`; + } else if (type === "local") { + // 本地 MCP:使用 command 数组作为标识 + const command = config.command; + if (Array.isArray(command)) { + // 过滤掉 -y 等选项,只保留核心命令 + const coreCmd = command.filter((c) => typeof c === "string" && c !== "-y" && !c.startsWith("--")); + if (coreCmd.length > 0) return `local:${coreCmd.join(" ")}`; + } + } + + return null; +} + +// 检查两个 MCP 配置是否是同一个(基于内容去重) +function isSameMcpConfig(config1: Record, config2: Record): boolean { + const id1 = getMcpConfigId(config1); + const id2 = getMcpConfigId(config2); + + // 如果都能生成 ID,比较 ID + if (id1 && id2) return id1 === id2; + + // 如果无法生成 ID,比较整个配置对象(排除 enabled 等运行时字段) + const keys1 = Object.keys(config1).filter((k) => !["enabled", "environment"].includes(k)).sort(); + const keys2 = Object.keys(config2).filter((k) => !["enabled", "environment"].includes(k)).sort(); + + if (keys1.length !== keys2.length) return false; + + return keys1.every((key) => JSON.stringify(config1[key]) === JSON.stringify(config2[key])); +} + export async function listMcp(workspaceRoot: string): Promise { - const { data: config } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record); - const mcpMap = getMcpConfig(config); - return Object.entries(mcpMap).map(([name, entry]) => ({ - name, - config: entry, - source: "config.project", - disabledByTools: isMcpDisabledByTools(config, name) || undefined, - })); + // 读取全局配置 + const globalPath = getGlobalOpencodeConfigPath(); + const { data: globalConfig } = await readJsoncFile(globalPath, {} as Record); + const globalMcpMap = getMcpConfig(globalConfig); + + // 读取工作区配置 + const { data: projectConfig } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record); + const projectMcpMap = getMcpConfig(projectConfig); + + // 收集所有已存在的配置 ID(用于去重) + const existingConfigIds = new Set(); + const existingConfigs: Array<{ name: string; config: Record }> = []; + + // 先处理工作区配置(优先级高) + for (const [name, entry] of Object.entries(projectMcpMap)) { + const configId = getMcpConfigId(entry); + if (configId) existingConfigIds.add(configId); + existingConfigs.push({ name, config: entry }); + } + + // 再处理全局配置,过滤掉与工作区重复的内容 + for (const [name, entry] of Object.entries(globalMcpMap)) { + // 检查是否同名(情况 1:已处理,工作区优先) + if (Object.prototype.hasOwnProperty.call(projectMcpMap, name)) continue; + + // 检查是否内容重复(情况 2:不同名但相同配置) + const configId = getMcpConfigId(entry); + if (configId && existingConfigIds.has(configId)) continue; + + // 检查是否与任何已存在的配置内容相同(回退方案) + const isDuplicate = existingConfigs.some(({ config }) => isSameMcpConfig(entry, config)); + if (isDuplicate) continue; + + // 添加到结果 + if (configId) existingConfigIds.add(configId); + existingConfigs.push({ name, config: entry }); + } + + return existingConfigs.map(({ name, config }) => { + const source = Object.prototype.hasOwnProperty.call(projectMcpMap, name) + ? "config.project" + : "config.global"; + + // 检查是否被工作区配置禁用 + let disabledByTools = isMcpDisabledByTools(projectConfig, name) || undefined; + + // 如果工作区没禁用,检查全局配置是否禁用(仅对全局来源的 MCP) + if (!disabledByTools && source === "config.global") { + disabledByTools = isMcpDisabledByTools(globalConfig, name) || undefined; + } + + return { + name, + config, + source, + disabledByTools, + }; + }); } export async function addMcp( From bea111951b2af24bf4d946049e3456e48441a6dd Mon Sep 17 00:00:00 2001 From: lujiax Date: Sun, 8 Feb 2026 23:52:17 +0800 Subject: [PATCH 2/2] Address review feedback: security fixes for MCP config deduplication - Keep all command arguments (including --flags) in dedup ID to avoid merging configs with different security settings - Include environment in config comparison to prevent merging configs with different credentials - Translate all Chinese comments to English --- packages/server/src/mcp.ts | 57 +++++++++++++++++++------------------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/packages/server/src/mcp.ts b/packages/server/src/mcp.ts index 608c104d..b9e0171e 100644 --- a/packages/server/src/mcp.ts +++ b/packages/server/src/mcp.ts @@ -31,79 +31,80 @@ function isMcpDisabledByTools(config: Record, name: string): bo return patterns.some((pattern) => candidates.some((candidate) => minimatch(candidate, pattern))); } -// 生成 MCP 配置的唯一标识(用于去重) +// Generate a unique identifier for MCP config (for deduplication) function getMcpConfigId(config: Record): string | null { const type = config.type; - + if (type === "remote") { - // 远程 MCP:使用 URL 作为标识 + // Remote MCP: use URL as identifier const url = config.url; if (typeof url === "string") return `remote:${url}`; } else if (type === "local") { - // 本地 MCP:使用 command 数组作为标识 + // Local MCP: use full command array as identifier + // Keep all arguments including --flags for security-sensitive deduplication const command = config.command; if (Array.isArray(command)) { - // 过滤掉 -y 等选项,只保留核心命令 - const coreCmd = command.filter((c) => typeof c === "string" && c !== "-y" && !c.startsWith("--")); - if (coreCmd.length > 0) return `local:${coreCmd.join(" ")}`; + const cmdStr = command.filter((c) => typeof c === "string").join(" "); + if (cmdStr.length > 0) return `local:${cmdStr}`; } } - + return null; } -// 检查两个 MCP 配置是否是同一个(基于内容去重) +// Check if two MCP configs are the same (for deduplication) function isSameMcpConfig(config1: Record, config2: Record): boolean { const id1 = getMcpConfigId(config1); const id2 = getMcpConfigId(config2); - - // 如果都能生成 ID,比较 ID + + // If both can generate IDs, compare IDs if (id1 && id2) return id1 === id2; - - // 如果无法生成 ID,比较整个配置对象(排除 enabled 等运行时字段) - const keys1 = Object.keys(config1).filter((k) => !["enabled", "environment"].includes(k)).sort(); - const keys2 = Object.keys(config2).filter((k) => !["enabled", "environment"].includes(k)).sort(); - + + // If unable to generate ID, compare the entire config object (excluding runtime fields like enabled) + // Note: environment is now included in comparison to avoid merging configs with different credentials + const keys1 = Object.keys(config1).filter((k) => k !== "enabled").sort(); + const keys2 = Object.keys(config2).filter((k) => k !== "enabled").sort(); + if (keys1.length !== keys2.length) return false; - + return keys1.every((key) => JSON.stringify(config1[key]) === JSON.stringify(config2[key])); } export async function listMcp(workspaceRoot: string): Promise { - // 读取全局配置 + // Read global config const globalPath = getGlobalOpencodeConfigPath(); const { data: globalConfig } = await readJsoncFile(globalPath, {} as Record); const globalMcpMap = getMcpConfig(globalConfig); - // 读取工作区配置 + // Read workspace config const { data: projectConfig } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record); const projectMcpMap = getMcpConfig(projectConfig); - // 收集所有已存在的配置 ID(用于去重) + // Collect all existing config IDs (for deduplication) const existingConfigIds = new Set(); const existingConfigs: Array<{ name: string; config: Record }> = []; - // 先处理工作区配置(优先级高) + // Process workspace config first (higher priority) for (const [name, entry] of Object.entries(projectMcpMap)) { const configId = getMcpConfigId(entry); if (configId) existingConfigIds.add(configId); existingConfigs.push({ name, config: entry }); } - // 再处理全局配置,过滤掉与工作区重复的内容 + // Then process global config, filtering out duplicates from workspace for (const [name, entry] of Object.entries(globalMcpMap)) { - // 检查是否同名(情况 1:已处理,工作区优先) + // Check for same name (case 1: already handled, workspace takes priority) if (Object.prototype.hasOwnProperty.call(projectMcpMap, name)) continue; - // 检查是否内容重复(情况 2:不同名但相同配置) + // Check for duplicate content (case 2: different name but same config) const configId = getMcpConfigId(entry); if (configId && existingConfigIds.has(configId)) continue; - // 检查是否与任何已存在的配置内容相同(回退方案) + // Check if content matches any existing config (fallback) const isDuplicate = existingConfigs.some(({ config }) => isSameMcpConfig(entry, config)); if (isDuplicate) continue; - // 添加到结果 + // Add to results if (configId) existingConfigIds.add(configId); existingConfigs.push({ name, config: entry }); } @@ -113,10 +114,10 @@ export async function listMcp(workspaceRoot: string): Promise { ? "config.project" : "config.global"; - // 检查是否被工作区配置禁用 + // Check if disabled by workspace config let disabledByTools = isMcpDisabledByTools(projectConfig, name) || undefined; - // 如果工作区没禁用,检查全局配置是否禁用(仅对全局来源的 MCP) + // If not disabled by workspace, check global config (only for global-sourced MCPs) if (!disabledByTools && source === "config.global") { disabledByTools = isMcpDisabledByTools(globalConfig, name) || undefined; }