diff --git a/lib/agent-runtime.js b/lib/agent-runtime.js index 9f5f122..9b0c0cb 100644 --- a/lib/agent-runtime.js +++ b/lib/agent-runtime.js @@ -30,6 +30,24 @@ function createAgentRuntime(deps) { return `[${values.map((value) => tomlString(value)).join(',')}]`; } + function tomlKeySegment(value) { + const raw = String(value || '').trim(); + if (/^[A-Za-z0-9_-]+$/.test(raw)) return raw; + return tomlString(raw); + } + + function tomlValue(value) { + if (Array.isArray(value)) return `[${value.map((item) => tomlValue(item)).join(',')}]`; + if (value && typeof value === 'object') { + return `{${Object.entries(value) + .map(([key, item]) => `${tomlKeySegment(key)}=${tomlValue(item)}`) + .join(',')}}`; + } + if (typeof value === 'boolean') return value ? 'true' : 'false'; + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + return tomlString(value); + } + function createCcwebMcpEnv(session, options = {}) { if (!ccwebMcpServerArg || !internalMcpUrl || !internalMcpToken || !session?.id) return null; const rawHopCount = Number.parseInt(String(options.mcpContext?.hopCount || 0), 10); @@ -55,6 +73,22 @@ function createAgentRuntime(deps) { ); } + function appendProjectMcpConfig(args, mcpServer) { + const server = String(mcpServer?.server || mcpServer?.name || '').trim(); + const config = mcpServer?.config && typeof mcpServer.config === 'object' ? mcpServer.config : null; + if (!server || !config) return; + const prefix = `mcp_servers.${tomlKeySegment(server)}`; + for (const key of ['type', 'command', 'args', 'env', 'env_vars', 'url', 'startup_timeout_sec', 'tool_timeout_sec']) { + if (!Object.prototype.hasOwnProperty.call(config, key)) continue; + args.push('-c', `${prefix}.${key}=${tomlValue(config[key])}`); + } + } + + function appendProjectMcpConfigs(args, mcpServers) { + if (!Array.isArray(mcpServers)) return; + for (const mcpServer of mcpServers) appendProjectMcpConfig(args, mcpServer); + } + function buildClaudeSpawnSpec(session, options = {}) { const hasAttachments = Array.isArray(options.attachments) && options.attachments.length > 0; const args = ['-p', '--output-format', 'stream-json', '--verbose']; @@ -110,10 +144,11 @@ function createAgentRuntime(deps) { return { error: runtimeConfig.error }; } const runtimeId = getRuntimeSessionId(session); - const args = ['exec']; - args.push('--json', '--skip-git-repo-check'); + const args = ['exec']; + args.push('--json', '--skip-git-repo-check'); const ccwebMcpEnv = createCcwebMcpEnv(session, options); appendCcwebMcpConfig(args, ccwebMcpEnv); + appendProjectMcpConfigs(args, options.projectMcpConfigs); const permMode = session.permissionMode || 'yolo'; // `-s/--sandbox` is an option for `codex exec`, but not for `codex exec resume`. diff --git a/scripts/mock-codex-app-server.js b/scripts/mock-codex-app-server.js index de07be9..056f615 100755 --- a/scripts/mock-codex-app-server.js +++ b/scripts/mock-codex-app-server.js @@ -414,12 +414,14 @@ function completeDynamicToolTurn(thread, turnId, text) { function completeMcpToolTurn(thread, turnId) { const itemId = 'mcp-ccweb-list'; const ccwebConfig = thread.config?.['mcp_servers.ccweb'] || null; + const projectConfig = thread.config?.['mcp_servers.reg-app-project'] || null; const env = ccwebConfig?.env || {}; const payload = { ok: true, currentConversationId: env.CC_WEB_SOURCE_SESSION_ID || null, sourceHopCount: env.CC_WEB_CROSS_HOP_COUNT || null, hasCcwebMcpConfig: Boolean(ccwebConfig), + hasProjectMcpConfig: Boolean(projectConfig), }; const itemBase = { id: itemId, diff --git a/scripts/regression.js b/scripts/regression.js index 4c5e71f..93c9908 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -686,6 +686,24 @@ async function main() { '', 'Use this only in regression tests.', ].join('\n')); + const projectCodexConfigDir = path.join(codexInitCwd, '.codex'); + mkdirp(projectCodexConfigDir); + fs.writeFileSync(path.join(projectCodexConfigDir, 'config.toml'), [ + '[mcp_servers.reg-project]', + 'type = "stdio"', + `command = ${JSON.stringify(process.execPath)}`, + 'args = ["regression-mcp.js"]', + 'enabled = true', + '', + '[mcp_servers.reg-disabled]', + 'type = "stdio"', + `command = ${JSON.stringify(process.execPath)}`, + 'enabled = false', + '', + '[mcp_servers.reg-missing]', + 'type = "stdio"', + 'command = "definitely-missing-mcp-command"', + ].join('\n')); fs.writeFileSync(path.join(codexInitCwd, 'context.txt'), 'Composer file context body.'); ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: codexInitCwd, mode: 'plan' })); const codexSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === codexInitCwd); @@ -715,9 +733,12 @@ async function main() { const slashMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-slash-mcp'); assert(slashMcpComposer.items.some((item) => item.kind === 'mcp' && item.name === 'ccweb_list_conversations'), 'Composer slash suggestions should include ccweb MCP tools'); - ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-slash-mcp-config', trigger: '/', query: 'reg-config', sessionId: codexSession.sessionId, agent: 'codex' })); + ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-slash-mcp-config', trigger: '/', query: 'reg', sessionId: codexSession.sessionId, agent: 'codex' })); const slashMcpConfigComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-slash-mcp-config'); - assert(slashMcpConfigComposer.items.some((item) => item.kind === 'mcp' && item.itemType === 'server' && item.name === 'reg-config'), 'Composer slash suggestions should include MCP servers from Codex config'); + assert(slashMcpConfigComposer.items.some((item) => item.kind === 'mcp' && item.itemType === 'server' && item.name === 'reg-project'), 'Composer slash suggestions should include available project MCP servers from session cwd'); + assert(!slashMcpConfigComposer.items.some((item) => item.kind === 'mcp' && item.name === 'reg-config'), 'Composer slash suggestions should not include MCP servers from unrelated global Codex config'); + assert(!slashMcpConfigComposer.items.some((item) => item.kind === 'mcp' && item.name === 'reg-disabled'), 'Composer slash suggestions should not include disabled project MCP servers'); + assert(!slashMcpConfigComposer.items.some((item) => item.kind === 'mcp' && item.name === 'reg-missing'), 'Composer slash suggestions should not include project MCP servers with unavailable commands'); const storedComposerFixture = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8')); storedComposerFixture.messages.push({ @@ -729,8 +750,8 @@ async function main() { ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-slash-mcp-runtime', trigger: '/', query: 'reg', sessionId: codexSession.sessionId, agent: 'codex' })); const slashMcpRuntimeComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-slash-mcp-runtime'); - assert(slashMcpRuntimeComposer.items.some((item) => item.kind === 'mcp' && item.itemType === 'server' && item.name === 'regRuntime'), 'Composer slash suggestions should include MCP servers from session tool names'); - assert(slashMcpRuntimeComposer.items.some((item) => item.kind === 'mcp' && item.itemType === 'server' && item.name === 'reg-state'), 'Composer slash suggestions should include MCP servers from mcp:server labels'); + assert(!slashMcpRuntimeComposer.items.some((item) => item.kind === 'mcp' && item.itemType === 'server' && item.name === 'regRuntime'), 'Composer slash suggestions should not infer MCP servers from session tool names'); + assert(!slashMcpRuntimeComposer.items.some((item) => item.kind === 'mcp' && item.itemType === 'server' && item.name === 'reg-state'), 'Composer slash suggestions should not infer MCP servers from mcp:server labels'); ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-skill', trigger: '$', query: 'reg', sessionId: codexSession.sessionId, agent: 'codex' })); const skillComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-skill'); @@ -745,10 +766,10 @@ async function main() { ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-skill-mcp', trigger: '$', query: 'ccweb', sessionId: codexSession.sessionId, agent: 'codex' })); const skillMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-skill-mcp'); - assert(skillMcpComposer.items.some((item) => item.kind === 'mcp' && item.name === 'ccweb_list_conversations'), 'Composer skill trigger suggestions should include ccweb MCP tools'); + assert(!skillMcpComposer.items.some((item) => item.kind === 'mcp'), 'Composer skill trigger suggestions should not include MCP tools'); ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-skill-declared-mcp', trigger: '$', query: 'openai', sessionId: codexSession.sessionId, agent: 'codex' })); const skillDeclaredMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-skill-declared-mcp'); - assert(skillDeclaredMcpComposer.items.some((item) => item.kind === 'mcp' && item.name === 'openaiDeveloperDocs' && item.state === 'declared'), 'Composer skill trigger suggestions should include declared MCP dependencies from openai.yaml'); + assert(!skillDeclaredMcpComposer.items.some((item) => item.kind === 'mcp'), 'Composer skill trigger suggestions should not list declared MCP dependencies from openai.yaml as available tools'); ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-prompt', trigger: '@', query: 'ship', sessionId: codexSession.sessionId, agent: 'codex' })); const promptComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-prompt'); @@ -1100,6 +1121,11 @@ async function main() { .find((line) => line.includes(`"event":"process_spawn"`) && line.includes(crossTargetSession.sessionId.slice(0, 8))); assert(mcpSpawnLine && mcpSpawnLine.includes('mcp_servers.ccweb.command') && mcpSpawnLine.includes('mcp_servers.ccweb.env_vars'), 'Codex spawn should inject ccweb MCP config'); assert(!mcpSpawnLine.includes(internalMcpToken), 'Codex spawn log should not expose internal MCP token'); + const projectMcpSpawnLine = processLogAfterMcp + .trim() + .split('\n') + .find((line) => line.includes(`"event":"process_spawn"`) && line.includes(codexSession.sessionId.slice(0, 8))); + assert(projectMcpSpawnLine && projectMcpSpawnLine.includes('mcp_servers.reg-project.command'), 'Codex spawn should inject project MCP config from session cwd'); ws.send(JSON.stringify({ type: 'list_cwd_suggestions' })); const cwdSuggestions = await nextMessage(messages, ws, (msg) => msg.type === 'cwd_suggestions'); @@ -1209,6 +1235,15 @@ async function main() { const codexAppCwd = path.join(tempRoot, 'codexapp-space'); mkdirp(codexAppCwd); + const codexAppProjectConfigDir = path.join(codexAppCwd, '.codex'); + mkdirp(codexAppProjectConfigDir); + fs.writeFileSync(path.join(codexAppProjectConfigDir, 'config.toml'), [ + '[mcp_servers.reg-app-project]', + 'type = "stdio"', + `command = ${JSON.stringify(process.execPath)}`, + 'args = ["regression-app-mcp.js"]', + 'enabled = true', + ].join('\n')); ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: codexAppCwd, mode: 'yolo' })); const codexAppSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === codexAppCwd); assert(codexAppSession.model === 'gpt-5.5(xhigh)', 'Codex App new_session should read default Codex model'); @@ -1308,6 +1343,7 @@ async function main() { assert(codexAppDynamicTool.kind === 'mcp_tool_call', 'Codex App should surface ccweb MCP tool calls'); assert(/currentConversationId/.test(codexAppDynamicTool.result || ''), 'Codex App MCP tool should return ccweb conversation data'); assert(/"hasCcwebMcpConfig": true/.test(codexAppDynamicTool.result || ''), 'Codex App thread/start should pass ccweb MCP config'); + assert(/"hasProjectMcpConfig": true/.test(codexAppDynamicTool.result || ''), 'Codex App thread/start should pass project MCP config from session cwd'); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); ws.send(JSON.stringify({ type: 'message', text: 'codexapp subagent prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); diff --git a/server.js b/server.js index 13f59a9..bf7892c 100644 --- a/server.js +++ b/server.js @@ -751,6 +751,72 @@ function parseTomlStringValue(value) { return raw; } +function splitTomlTopLevelList(value) { + const parts = []; + let current = ''; + let quote = ''; + let escaped = false; + let depth = 0; + for (const ch of String(value || '')) { + if (quote) { + current += ch; + if (escaped) { + escaped = false; + } else if (ch === '\\' && quote === '"') { + escaped = true; + } else if (ch === quote) { + quote = ''; + } + continue; + } + if (ch === '"' || ch === '\'') { + quote = ch; + current += ch; + continue; + } + if (ch === '[' || ch === '{') depth += 1; + if (ch === ']' || ch === '}') depth = Math.max(0, depth - 1); + if (ch === ',' && depth === 0) { + if (current.trim()) parts.push(current.trim()); + current = ''; + continue; + } + current += ch; + } + if (current.trim()) parts.push(current.trim()); + return parts; +} + +function parseTomlValue(value) { + const raw = stripTomlInlineComment(value); + if (!raw) return ''; + if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith('\'') && raw.endsWith('\''))) { + return parseTomlStringValue(raw); + } + if (raw === 'true') return true; + if (raw === 'false') return false; + if (/^[+-]?\d+(?:\.\d+)?$/.test(raw)) return Number(raw); + if (raw.startsWith('[') && raw.endsWith(']')) { + const inner = raw.slice(1, -1).trim(); + if (!inner) return []; + return splitTomlTopLevelList(inner).map((item) => parseTomlValue(item)); + } + if (raw.startsWith('{') && raw.endsWith('}')) { + const inner = raw.slice(1, -1).trim(); + const parsed = {}; + if (!inner) return parsed; + for (const item of splitTomlTopLevelList(inner)) { + const eqIndex = item.indexOf('='); + if (eqIndex <= 0) continue; + const key = String(item.slice(0, eqIndex)).trim().replace(/^["']|["']$/g, ''); + if (!key) continue; + parsed[key] = parseTomlValue(item.slice(eqIndex + 1)); + } + return parsed; + } + return raw; +} + function parseTomlBareKeyPath(pathText) { const parts = []; let current = ''; @@ -789,26 +855,107 @@ function parseTomlBareKeyPath(pathText) { return parts.filter(Boolean); } -function loadCodexMcpServerNamesFromToml() { - try { - const configPath = getLocalCodexConfigTomlPath(); - if (!configPath || !fs.existsSync(configPath)) return []; - const names = []; - const seen = new Set(); - const text = fs.readFileSync(configPath, 'utf8'); - for (const line of text.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - const match = trimmed.match(/^\[([^\]]+)\]$/); - if (!match) continue; - const parts = parseTomlBareKeyPath(match[1]); - if (parts[0] !== 'mcp_servers' || !parts[1]) continue; - const name = String(parts[1]).trim(); - if (!name || seen.has(name)) continue; - seen.add(name); - names.push(name); +function commandExistsForMcp(command) { + const raw = String(command || '').trim(); + if (!raw) return false; + const hasPathSeparator = raw.includes('/') || raw.includes('\\'); + const candidates = hasPathSeparator + ? [path.resolve(raw)] + : String(process.env.PATH || '').split(path.delimiter).filter(Boolean).map((dir) => path.join(dir, raw)); + for (const candidate of candidates) { + try { + const stat = fs.statSync(candidate); + if (!stat.isFile()) continue; + fs.accessSync(candidate, fs.constants.X_OK); + return true; + } catch {} + } + return false; +} + +function getProjectCodexConfigTomlPath(cwd) { + const start = normalizeExistingDirPath(cwd); + if (!start) return ''; + const projectRoot = findNearestProjectRoot(start) || start; + let dir = start; + const seen = new Set(); + while (dir && !seen.has(dir)) { + seen.add(dir); + const configPath = path.join(dir, '.codex', 'config.toml'); + try { + if (fs.statSync(configPath).isFile()) return configPath; + } catch {} + if (dir === projectRoot) break; + const parent = path.dirname(dir); + if (!parent || parent === dir) break; + dir = parent; + } + return ''; +} + +function parseCodexMcpServerConfigToml(text, source) { + const servers = []; + let current = null; + for (const line of String(text || '').split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/); + if (sectionMatch) { + const parts = parseTomlBareKeyPath(sectionMatch[1]); + current = parts[0] === 'mcp_servers' && parts[1] + ? { name: String(parts[1]).trim(), config: {} } + : null; + if (current?.name) servers.push(current); + continue; } - return names; + if (!current) continue; + const eqIndex = trimmed.indexOf('='); + if (eqIndex <= 0) continue; + const key = String(trimmed.slice(0, eqIndex)).trim(); + if (!key) continue; + current.config[key] = parseTomlValue(trimmed.slice(eqIndex + 1)); + } + return servers + .map((server) => normalizeCodexMcpServerConfig(server.name, server.config, source)) + .filter(Boolean); +} + +function normalizeCodexMcpServerConfig(name, rawConfig, source) { + const server = normalizeMcpServerName(name); + if (!isLikelyMcpServerName(server) || !rawConfig || typeof rawConfig !== 'object') return null; + if (rawConfig.enabled === false) return null; + const type = String(rawConfig.type || (rawConfig.url ? 'streamable_http' : 'stdio')).trim(); + const config = { type }; + if (type === 'stdio') { + const command = String(rawConfig.command || '').trim(); + if (!command || !commandExistsForMcp(command)) return null; + config.command = command; + if (Array.isArray(rawConfig.args)) config.args = rawConfig.args.map((item) => String(item)); + if (rawConfig.env && typeof rawConfig.env === 'object' && !Array.isArray(rawConfig.env)) config.env = rawConfig.env; + if (Array.isArray(rawConfig.env_vars)) config.env_vars = rawConfig.env_vars.map((item) => String(item)); + } else { + const url = normalizeSkillMcpUrl(rawConfig.url || rawConfig.server_url || ''); + if (!url) return null; + config.url = url; + } + for (const key of ['startup_timeout_sec', 'tool_timeout_sec']) { + if (Number.isFinite(rawConfig[key])) config[key] = rawConfig[key]; + } + return { + server, + name: server, + source, + type, + config, + description: `MCP server: ${server}`, + }; +} + +function loadProjectCodexMcpServerConfigs(cwd) { + try { + const configPath = getProjectCodexConfigTomlPath(cwd); + if (!configPath || !fs.existsSync(configPath)) return []; + return parseCodexMcpServerConfigToml(fs.readFileSync(configPath, 'utf8'), path.relative(process.cwd(), configPath) || configPath); } catch { return []; } @@ -2038,44 +2185,6 @@ function isLikelyMcpServerName(value) { ]).has(name); } -function collectComposerMcpNamesFromText(text, names) { - const value = String(text || ''); - for (const match of value.matchAll(/mcp__([A-Za-z0-9_.-]+)__[A-Za-z0-9_.-]+/g)) { - const name = normalizeMcpServerName(match[1]); - if (isLikelyMcpServerName(name)) names.add(name); - } - for (const match of value.matchAll(/\bmcp:([A-Za-z0-9_.-]+)(?:\/[A-Za-z0-9_.-]+)?/g)) { - const name = normalizeMcpServerName(match[1]); - if (isLikelyMcpServerName(name)) names.add(name); - } -} - -function collectComposerMcpNamesFromValue(value, names, depth = 0) { - if (depth > 5 || !value) return; - if (typeof value === 'string') { - collectComposerMcpNamesFromText(value, names); - return; - } - if (Array.isArray(value)) { - for (const item of value.slice(0, 80)) collectComposerMcpNamesFromValue(item, names, depth + 1); - return; - } - if (typeof value !== 'object') return; - for (const [key, item] of Object.entries(value).slice(0, 120)) { - collectComposerMcpNamesFromText(key, names); - collectComposerMcpNamesFromValue(item, names, depth + 1); - } -} - -function loadComposerMcpServerNamesFromSession(sessionId) { - const names = new Set(); - const session = sessionId ? loadSession(sessionId) : null; - collectComposerMcpNamesFromValue(session?.messages || [], names); - const state = sessionId ? loadCodexAppTurnState(sessionId) : null; - if (state && !state.__invalid) collectComposerMcpNamesFromValue(state, names); - return [...names].filter((name) => isLikelyMcpServerName(name)); -} - function mcpServerSuggestion(name, options = {}) { const server = normalizeMcpServerName(name); if (!isLikelyMcpServerName(server)) return null; @@ -2090,7 +2199,7 @@ function mcpServerSuggestion(name, options = {}) { server, source: options.source || 'mcp', itemType: 'server', - state: options.state || (options.source === 'skill-dependency' ? 'declared' : 'configured'), + state: options.state || 'configured', dependencyOf: options.dependencyOf || '', transport: options.transport || '', url: options.url || '', @@ -2133,67 +2242,79 @@ function summarizeSkillForMention(skill, configuredMcpNames = new Set()) { }; } +function buildCcwebMcpRuntimeConfig(session, options = {}) { + const env = codexAppCcwebMcpEnv(session, options); + if (!env) return null; + return { + server: 'ccweb', + name: 'ccweb', + source: 'builtin', + type: 'stdio', + description: 'ccweb 内置 MCP server,可用于跨会话协作。', + config: { + command: process.execPath, + args: [CCWEB_MCP_SERVER_ARG], + env, + startup_timeout_sec: 10, + tool_timeout_sec: 60, + }, + }; +} + +function listRuntimeMcpServerConfigs(options = {}) { + const session = options.session || (options.sessionId ? loadSession(options.sessionId) : null); + const agent = normalizeAgent(options.agent || session?.agent || 'codex'); + if (!isCodexLikeAgent(agent)) return []; + const configs = []; + const seen = new Set(); + const push = (config) => { + const server = normalizeMcpServerName(config?.server || config?.name); + if (!server || seen.has(server)) return; + seen.add(server); + configs.push({ ...config, server, name: server }); + }; + for (const config of loadProjectCodexMcpServerConfigs(session?.cwd || options.cwd || getDefaultSessionCwd())) { + push({ ...config, source: 'project-config' }); + } + push(buildCcwebMcpRuntimeConfig(session, options)); + return configs; +} + function listComposerMcpItems(options = {}) { - const sessionId = typeof options === 'string' ? options : options.sessionId; - const agent = typeof options === 'object' ? options.agent : ''; - const skills = typeof options === 'object' && Array.isArray(options.skills) - ? options.skills - : isCodexLikeAgent(agent) - ? loadCodexSkills(options) - : []; const items = []; const seen = new Set(); - const configured = new Set(); const push = (item) => { if (!item?.server && !item?.name) return; const key = `${item.kind || ''}:${item.server || ''}:${item.name || item.label || item.insertion || ''}`; if (seen.has(key)) return; seen.add(key); - if (item.kind === 'mcp' && item.itemType === 'server' && item.state !== 'declared') { - const server = normalizeMcpServerName(item.server || item.name); - if (server) configured.add(server); - } items.push(item); }; - for (const name of loadComposerMcpServerNamesFromSession(sessionId)) { - push(mcpServerSuggestion(name, { source: 'session' })); - } - for (const name of loadCodexMcpServerNamesFromToml()) { - push(mcpServerSuggestion(name, { source: 'codex-config' })); - } - push(mcpServerSuggestion('ccweb', { - source: 'builtin', - description: 'ccweb 内置 MCP server,可用于跨会话协作。', - })); - for (const tool of CCWEB_MCP_TOOLS) { - const name = String(tool?.name || '').trim(); - const label = `mcp:ccweb/${name}`; - push({ - kind: 'mcp', - name, - label, - title: `ccweb/${name}`, - description: String(tool?.description || 'MCP 工具').trim(), - insertion: label, - appendSpace: true, - server: 'ccweb', - source: 'mcp:ccweb', - itemType: 'tool', - }); - } - for (const skill of skills) { - for (const tool of summarizeSkillDependencies(skill)) { - const server = normalizeMcpServerName(tool.value || tool.name || tool.server); - if (!server || configured.has(server)) continue; - push(mcpServerSuggestion(server, { - source: 'skill-dependency', - state: 'declared', - dependencyOf: skill.name, - description: tool.description || `MCP server: ${server}`, - transport: tool.transport || '', - url: tool.url || '', - })); + for (const config of listRuntimeMcpServerConfigs(typeof options === 'string' ? { sessionId: options } : options)) { + push(mcpServerSuggestion(config.server, { + source: config.source || 'runtime', + description: config.description || `MCP server: ${config.server}`, + transport: config.type || '', + url: config.config?.url || '', + })); + if (config.server === 'ccweb') { + for (const tool of CCWEB_MCP_TOOLS) { + const name = String(tool?.name || '').trim(); + const label = `mcp:ccweb/${name}`; + push({ + kind: 'mcp', + name, + label, + title: `ccweb/${name}`, + description: String(tool?.description || 'MCP 工具').trim(), + insertion: label, + appendSpace: true, + server: 'ccweb', + source: 'mcp:ccweb', + itemType: 'tool', + }); + } } } return items; @@ -2268,8 +2389,8 @@ function listComposerFileSuggestions(sessionId, query) { function listComposerSuggestions(trigger, query, sessionId, agent, session = null) { const skillItems = isCodexLikeAgent(agent) ? loadCodexSkills({ session }) : []; - const mcpItems = filterComposerItems(listComposerMcpItems({ sessionId, agent, skills: skillItems }), query); if (trigger === '/') { + const mcpItems = filterComposerItems(listComposerMcpItems({ sessionId, session, agent }), query); const commands = filterComposerItems(COMPOSER_COMMANDS.map((cmd) => ({ kind: 'command', name: cmd.name, @@ -2281,7 +2402,7 @@ function listComposerSuggestions(trigger, query, sessionId, agent, session = nul } if (trigger === '$') { const skills = filterComposerItems(skillItems, query); - return mergeComposerSuggestionGroups(mcpItems, skills); + return mergeComposerSuggestionGroups(skills); } if (trigger === '@') { const prompts = filterComposerItems(loadComposerPrompts().map((prompt) => ({ @@ -6467,6 +6588,7 @@ function handleMessage(ws, msg, options = {}) { const runtimeOptions = { attachments: resolvedAttachments, mcpContext: options.mcpContext || {}, + projectMcpConfigs: isCodexSession(session) ? loadProjectCodexMcpServerConfigs(session.cwd || getDefaultSessionCwd()) : [], }; const spawnSpec = isClaudeSession(session) ? buildClaudeSpawnSpec(session, runtimeOptions) @@ -6622,7 +6744,8 @@ function sanitizeToolInput(toolName, input) { function redactSpawnArgs(argsText) { return String(argsText || '') .replace(/CC_WEB_MCP_TOKEN[^\s,\]}]*/g, 'CC_WEB_MCP_TOKEN=****') - .replace(/mcp_servers\.ccweb\.env=\{[^}]*\}/g, 'mcp_servers.ccweb.env={****}'); + .replace(/mcp_servers\.ccweb\.env=\{[^}]*\}/g, 'mcp_servers.ccweb.env={****}') + .replace(/mcp_servers\.[^\s]+\.env=\{[^}]*\}/g, (match) => match.replace(/\{[^}]*\}/, '{****}')); } const { @@ -7617,17 +7740,12 @@ function codexAppCcwebMcpEnv(session, options = {}) { } function codexAppThreadConfig(session, options = {}) { - const ccwebMcpEnv = codexAppCcwebMcpEnv(session, options); - if (!ccwebMcpEnv) return {}; - return { - 'mcp_servers.ccweb': { - command: process.execPath, - args: [CCWEB_MCP_SERVER_ARG], - env: ccwebMcpEnv, - startup_timeout_sec: 10, - tool_timeout_sec: 60, - }, - }; + const config = {}; + for (const item of listRuntimeMcpServerConfigs({ ...options, session, agent: 'codexapp' })) { + if (!item?.server || !item?.config) continue; + config[`mcp_servers.${item.server}`] = item.config; + } + return config; } function codexAppCollaborationMode(session, modelSettings) {