fix: make mcp suggestions runtime-backed

This commit is contained in:
shiyue
2026-06-24 11:10:45 +08:00
parent 54edeec802
commit ca97d92a8d
4 changed files with 321 additions and 130 deletions

View File

@@ -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`.

View File

@@ -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,

View File

@@ -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' }));

362
server.js
View File

@@ -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) {