fix: make mcp suggestions runtime-backed
This commit is contained in:
@@ -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'];
|
||||
@@ -114,6 +148,7 @@ function createAgentRuntime(deps) {
|
||||
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`.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' }));
|
||||
|
||||
320
server.js
320
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() {
|
||||
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 configPath = getLocalCodexConfigTomlPath();
|
||||
if (!configPath || !fs.existsSync(configPath)) return [];
|
||||
const names = [];
|
||||
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();
|
||||
const text = fs.readFileSync(configPath, 'utf8');
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
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 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);
|
||||
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,39 +2242,63 @@ 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 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}`;
|
||||
@@ -2182,18 +2315,6 @@ function listComposerMcpItems(options = {}) {
|
||||
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 || '',
|
||||
}));
|
||||
}
|
||||
}
|
||||
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