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(',')}]`;
|
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 = {}) {
|
function createCcwebMcpEnv(session, options = {}) {
|
||||||
if (!ccwebMcpServerArg || !internalMcpUrl || !internalMcpToken || !session?.id) return null;
|
if (!ccwebMcpServerArg || !internalMcpUrl || !internalMcpToken || !session?.id) return null;
|
||||||
const rawHopCount = Number.parseInt(String(options.mcpContext?.hopCount || 0), 10);
|
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 = {}) {
|
function buildClaudeSpawnSpec(session, options = {}) {
|
||||||
const hasAttachments = Array.isArray(options.attachments) && options.attachments.length > 0;
|
const hasAttachments = Array.isArray(options.attachments) && options.attachments.length > 0;
|
||||||
const args = ['-p', '--output-format', 'stream-json', '--verbose'];
|
const args = ['-p', '--output-format', 'stream-json', '--verbose'];
|
||||||
@@ -114,6 +148,7 @@ function createAgentRuntime(deps) {
|
|||||||
args.push('--json', '--skip-git-repo-check');
|
args.push('--json', '--skip-git-repo-check');
|
||||||
const ccwebMcpEnv = createCcwebMcpEnv(session, options);
|
const ccwebMcpEnv = createCcwebMcpEnv(session, options);
|
||||||
appendCcwebMcpConfig(args, ccwebMcpEnv);
|
appendCcwebMcpConfig(args, ccwebMcpEnv);
|
||||||
|
appendProjectMcpConfigs(args, options.projectMcpConfigs);
|
||||||
|
|
||||||
const permMode = session.permissionMode || 'yolo';
|
const permMode = session.permissionMode || 'yolo';
|
||||||
// `-s/--sandbox` is an option for `codex exec`, but not for `codex exec resume`.
|
// `-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) {
|
function completeMcpToolTurn(thread, turnId) {
|
||||||
const itemId = 'mcp-ccweb-list';
|
const itemId = 'mcp-ccweb-list';
|
||||||
const ccwebConfig = thread.config?.['mcp_servers.ccweb'] || null;
|
const ccwebConfig = thread.config?.['mcp_servers.ccweb'] || null;
|
||||||
|
const projectConfig = thread.config?.['mcp_servers.reg-app-project'] || null;
|
||||||
const env = ccwebConfig?.env || {};
|
const env = ccwebConfig?.env || {};
|
||||||
const payload = {
|
const payload = {
|
||||||
ok: true,
|
ok: true,
|
||||||
currentConversationId: env.CC_WEB_SOURCE_SESSION_ID || null,
|
currentConversationId: env.CC_WEB_SOURCE_SESSION_ID || null,
|
||||||
sourceHopCount: env.CC_WEB_CROSS_HOP_COUNT || null,
|
sourceHopCount: env.CC_WEB_CROSS_HOP_COUNT || null,
|
||||||
hasCcwebMcpConfig: Boolean(ccwebConfig),
|
hasCcwebMcpConfig: Boolean(ccwebConfig),
|
||||||
|
hasProjectMcpConfig: Boolean(projectConfig),
|
||||||
};
|
};
|
||||||
const itemBase = {
|
const itemBase = {
|
||||||
id: itemId,
|
id: itemId,
|
||||||
|
|||||||
@@ -686,6 +686,24 @@ async function main() {
|
|||||||
'',
|
'',
|
||||||
'Use this only in regression tests.',
|
'Use this only in regression tests.',
|
||||||
].join('\n'));
|
].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.');
|
fs.writeFileSync(path.join(codexInitCwd, 'context.txt'), 'Composer file context body.');
|
||||||
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: codexInitCwd, mode: 'plan' }));
|
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);
|
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');
|
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');
|
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');
|
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'));
|
const storedComposerFixture = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
|
||||||
storedComposerFixture.messages.push({
|
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' }));
|
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');
|
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 === '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 include MCP servers from mcp:server labels');
|
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' }));
|
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');
|
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' }));
|
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');
|
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' }));
|
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');
|
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' }));
|
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');
|
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)));
|
.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 && 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');
|
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' }));
|
ws.send(JSON.stringify({ type: 'list_cwd_suggestions' }));
|
||||||
const cwdSuggestions = await nextMessage(messages, ws, (msg) => msg.type === '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');
|
const codexAppCwd = path.join(tempRoot, 'codexapp-space');
|
||||||
mkdirp(codexAppCwd);
|
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' }));
|
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);
|
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');
|
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(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(/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(/"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);
|
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' }));
|
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;
|
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) {
|
function parseTomlBareKeyPath(pathText) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
let current = '';
|
let current = '';
|
||||||
@@ -789,26 +855,107 @@ function parseTomlBareKeyPath(pathText) {
|
|||||||
return parts.filter(Boolean);
|
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 {
|
try {
|
||||||
const configPath = getLocalCodexConfigTomlPath();
|
const stat = fs.statSync(candidate);
|
||||||
if (!configPath || !fs.existsSync(configPath)) return [];
|
if (!stat.isFile()) continue;
|
||||||
const names = [];
|
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 seen = new Set();
|
||||||
const text = fs.readFileSync(configPath, 'utf8');
|
while (dir && !seen.has(dir)) {
|
||||||
for (const line of text.split(/\r?\n/)) {
|
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();
|
const trimmed = line.trim();
|
||||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||||
const match = trimmed.match(/^\[([^\]]+)\]$/);
|
const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/);
|
||||||
if (!match) continue;
|
if (sectionMatch) {
|
||||||
const parts = parseTomlBareKeyPath(match[1]);
|
const parts = parseTomlBareKeyPath(sectionMatch[1]);
|
||||||
if (parts[0] !== 'mcp_servers' || !parts[1]) continue;
|
current = parts[0] === 'mcp_servers' && parts[1]
|
||||||
const name = String(parts[1]).trim();
|
? { name: String(parts[1]).trim(), config: {} }
|
||||||
if (!name || seen.has(name)) continue;
|
: null;
|
||||||
seen.add(name);
|
if (current?.name) servers.push(current);
|
||||||
names.push(name);
|
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 {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -2038,44 +2185,6 @@ function isLikelyMcpServerName(value) {
|
|||||||
]).has(name);
|
]).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 = {}) {
|
function mcpServerSuggestion(name, options = {}) {
|
||||||
const server = normalizeMcpServerName(name);
|
const server = normalizeMcpServerName(name);
|
||||||
if (!isLikelyMcpServerName(server)) return null;
|
if (!isLikelyMcpServerName(server)) return null;
|
||||||
@@ -2090,7 +2199,7 @@ function mcpServerSuggestion(name, options = {}) {
|
|||||||
server,
|
server,
|
||||||
source: options.source || 'mcp',
|
source: options.source || 'mcp',
|
||||||
itemType: 'server',
|
itemType: 'server',
|
||||||
state: options.state || (options.source === 'skill-dependency' ? 'declared' : 'configured'),
|
state: options.state || 'configured',
|
||||||
dependencyOf: options.dependencyOf || '',
|
dependencyOf: options.dependencyOf || '',
|
||||||
transport: options.transport || '',
|
transport: options.transport || '',
|
||||||
url: options.url || '',
|
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 = {}) {
|
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 items = [];
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
const configured = new Set();
|
|
||||||
const push = (item) => {
|
const push = (item) => {
|
||||||
if (!item?.server && !item?.name) return;
|
if (!item?.server && !item?.name) return;
|
||||||
const key = `${item.kind || ''}:${item.server || ''}:${item.name || item.label || item.insertion || ''}`;
|
const key = `${item.kind || ''}:${item.server || ''}:${item.name || item.label || item.insertion || ''}`;
|
||||||
if (seen.has(key)) return;
|
if (seen.has(key)) return;
|
||||||
seen.add(key);
|
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);
|
items.push(item);
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const name of loadComposerMcpServerNamesFromSession(sessionId)) {
|
for (const config of listRuntimeMcpServerConfigs(typeof options === 'string' ? { sessionId: options } : options)) {
|
||||||
push(mcpServerSuggestion(name, { source: 'session' }));
|
push(mcpServerSuggestion(config.server, {
|
||||||
}
|
source: config.source || 'runtime',
|
||||||
for (const name of loadCodexMcpServerNamesFromToml()) {
|
description: config.description || `MCP server: ${config.server}`,
|
||||||
push(mcpServerSuggestion(name, { source: 'codex-config' }));
|
transport: config.type || '',
|
||||||
}
|
url: config.config?.url || '',
|
||||||
push(mcpServerSuggestion('ccweb', {
|
|
||||||
source: 'builtin',
|
|
||||||
description: 'ccweb 内置 MCP server,可用于跨会话协作。',
|
|
||||||
}));
|
}));
|
||||||
|
if (config.server === 'ccweb') {
|
||||||
for (const tool of CCWEB_MCP_TOOLS) {
|
for (const tool of CCWEB_MCP_TOOLS) {
|
||||||
const name = String(tool?.name || '').trim();
|
const name = String(tool?.name || '').trim();
|
||||||
const label = `mcp:ccweb/${name}`;
|
const label = `mcp:ccweb/${name}`;
|
||||||
@@ -2182,18 +2315,6 @@ function listComposerMcpItems(options = {}) {
|
|||||||
itemType: 'tool',
|
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;
|
return items;
|
||||||
@@ -2268,8 +2389,8 @@ function listComposerFileSuggestions(sessionId, query) {
|
|||||||
|
|
||||||
function listComposerSuggestions(trigger, query, sessionId, agent, session = null) {
|
function listComposerSuggestions(trigger, query, sessionId, agent, session = null) {
|
||||||
const skillItems = isCodexLikeAgent(agent) ? loadCodexSkills({ session }) : [];
|
const skillItems = isCodexLikeAgent(agent) ? loadCodexSkills({ session }) : [];
|
||||||
const mcpItems = filterComposerItems(listComposerMcpItems({ sessionId, agent, skills: skillItems }), query);
|
|
||||||
if (trigger === '/') {
|
if (trigger === '/') {
|
||||||
|
const mcpItems = filterComposerItems(listComposerMcpItems({ sessionId, session, agent }), query);
|
||||||
const commands = filterComposerItems(COMPOSER_COMMANDS.map((cmd) => ({
|
const commands = filterComposerItems(COMPOSER_COMMANDS.map((cmd) => ({
|
||||||
kind: 'command',
|
kind: 'command',
|
||||||
name: cmd.name,
|
name: cmd.name,
|
||||||
@@ -2281,7 +2402,7 @@ function listComposerSuggestions(trigger, query, sessionId, agent, session = nul
|
|||||||
}
|
}
|
||||||
if (trigger === '$') {
|
if (trigger === '$') {
|
||||||
const skills = filterComposerItems(skillItems, query);
|
const skills = filterComposerItems(skillItems, query);
|
||||||
return mergeComposerSuggestionGroups(mcpItems, skills);
|
return mergeComposerSuggestionGroups(skills);
|
||||||
}
|
}
|
||||||
if (trigger === '@') {
|
if (trigger === '@') {
|
||||||
const prompts = filterComposerItems(loadComposerPrompts().map((prompt) => ({
|
const prompts = filterComposerItems(loadComposerPrompts().map((prompt) => ({
|
||||||
@@ -6467,6 +6588,7 @@ function handleMessage(ws, msg, options = {}) {
|
|||||||
const runtimeOptions = {
|
const runtimeOptions = {
|
||||||
attachments: resolvedAttachments,
|
attachments: resolvedAttachments,
|
||||||
mcpContext: options.mcpContext || {},
|
mcpContext: options.mcpContext || {},
|
||||||
|
projectMcpConfigs: isCodexSession(session) ? loadProjectCodexMcpServerConfigs(session.cwd || getDefaultSessionCwd()) : [],
|
||||||
};
|
};
|
||||||
const spawnSpec = isClaudeSession(session)
|
const spawnSpec = isClaudeSession(session)
|
||||||
? buildClaudeSpawnSpec(session, runtimeOptions)
|
? buildClaudeSpawnSpec(session, runtimeOptions)
|
||||||
@@ -6622,7 +6744,8 @@ function sanitizeToolInput(toolName, input) {
|
|||||||
function redactSpawnArgs(argsText) {
|
function redactSpawnArgs(argsText) {
|
||||||
return String(argsText || '')
|
return String(argsText || '')
|
||||||
.replace(/CC_WEB_MCP_TOKEN[^\s,\]}]*/g, 'CC_WEB_MCP_TOKEN=****')
|
.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 {
|
const {
|
||||||
@@ -7617,17 +7740,12 @@ function codexAppCcwebMcpEnv(session, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function codexAppThreadConfig(session, options = {}) {
|
function codexAppThreadConfig(session, options = {}) {
|
||||||
const ccwebMcpEnv = codexAppCcwebMcpEnv(session, options);
|
const config = {};
|
||||||
if (!ccwebMcpEnv) return {};
|
for (const item of listRuntimeMcpServerConfigs({ ...options, session, agent: 'codexapp' })) {
|
||||||
return {
|
if (!item?.server || !item?.config) continue;
|
||||||
'mcp_servers.ccweb': {
|
config[`mcp_servers.${item.server}`] = item.config;
|
||||||
command: process.execPath,
|
}
|
||||||
args: [CCWEB_MCP_SERVER_ARG],
|
return config;
|
||||||
env: ccwebMcpEnv,
|
|
||||||
startup_timeout_sec: 10,
|
|
||||||
tool_timeout_sec: 60,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function codexAppCollaborationMode(session, modelSettings) {
|
function codexAppCollaborationMode(session, modelSettings) {
|
||||||
|
|||||||
Reference in New Issue
Block a user