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

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