fix: make mcp suggestions runtime-backed
This commit is contained in:
362
server.js
362
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) {
|
||||
|
||||
Reference in New Issue
Block a user