diff --git a/.planning/mcp-quick-selection/findings.md b/.planning/mcp-quick-selection/findings.md new file mode 100644 index 0000000..1dda799 --- /dev/null +++ b/.planning/mcp-quick-selection/findings.md @@ -0,0 +1,16 @@ +# MCP 快速选择调查发现 + +## 待确认 + +- 快速选择候选的构建入口和数据源。 +- 当前会话 MCP 配置在前端/后端的传递路径。 +- 是否存在只硬编码 ccweb 的过滤或分组逻辑。 +- 现有测试覆盖及缺失断言。 + +## 已确认 + +- `server.js:listRuntimeMcpServerConfigs` 原先只读取当前 cwd 最近的项目 `.codex/config.toml`,并注入 ccweb;它不会看到 Codex App 由用户级配置、插件或动态重载得到的运行时 MCP。 +- `listComposerMcpItems` 原先只为非 ccweb server 创建 `itemType: 'server'` 候选,没有 MCP tool 级候选;ccweb 工具则来自静态 `CCWEB_MCP_TOOLS`。 +- `COMPOSER_SUGGESTION_LIMIT` 原为 20,会在多 MCP 场景静默截断候选。 +- 本机 Codex app-server schema 提供 `mcpServerStatus/list`,参数含 `threadId/detail/limit/cursor`,返回 `data[].tools`;这正是当前线程真实 MCP inventory 的权威来源。 +- 已实现 per-thread 短缓存、分页读取、运行时 server/tool 候选合并;工具选择仍沿用通用 `mcp:server/tool` mention 插入,不执行 MCP 或打开参数表单。 diff --git a/.planning/mcp-quick-selection/progress.md b/.planning/mcp-quick-selection/progress.md new file mode 100644 index 0000000..78253ef --- /dev/null +++ b/.planning/mcp-quick-selection/progress.md @@ -0,0 +1,8 @@ +# MCP 快速选择进度 + +## 日志 + +- 2026-08-21:建立本次 scoped 计划,避免覆盖仓库已有的 hooks 验证计划。 +- 2026-08-21:完成 codebase-memory 定位,确认候选只读项目配置、非 ccweb 不展开工具、总数限制为 20。 +- 2026-08-21:验证本机 Codex app-server `mcpServerStatus/list` schema,开始接入当前线程 inventory、缓存和工具级 mention。 +- 2026-08-21:修改 `server.js`、mock app-server 与回归测试;完成 Node 语法检查。 diff --git a/.planning/mcp-quick-selection/task_plan.md b/.planning/mcp-quick-selection/task_plan.md new file mode 100644 index 0000000..fa5808b --- /dev/null +++ b/.planning/mcp-quick-selection/task_plan.md @@ -0,0 +1,29 @@ +# 补全 MCP 快速选择计划 + +## 目标 + +让输入框 `/` 快速选择展示当前会话实际可用的全部 MCP server/tool,保留 `$` 仅展示 skill、`@` 仅展示文件和 prompt 的语义边界,并补齐回归验证。 + +## 步骤 + +- [ ] 初始化调查计划与 TODO CSV +- [ ] 使用 codebase-memory 定位 MCP 候选构建和 composer 触发链路 +- [ ] 对照运行时 MCP 配置确认完整候选来源并排除历史/静态推断 +- [ ] 编写回归测试覆盖当前线程级 MCP、三种触发符互斥和 mention 插入语义 +- [ ] 修复候选聚合逻辑接入当前会话 MCP +- [ ] 修复 mention 插入与去重排序展示 +- [ ] 运行单测、静态检查和浏览器/组件回归并按需修复 +- [ ] 清理临时计划文件并汇总 + +## 约束 + +- 遵守 composer 基线:MCP 只在 `/` 中作为普通 tool mention,不打开参数表单或直接执行。 +- `/` 必须只展示当前会话线程级配置实际可用的全部 MCP server/tool;`$` 只展示 skill;`@` 只展示文件和 prompt。 +- 回归必须证明候选不从历史消息、运行态工具名或静态白名单反推,并验证选择 MCP 只插入 mention 文本。 +- 优先使用 codebase-memory-mcp,`rg` 只做行号和配置补充校验。 +- 保留工作区已有的 `config/cross-conversation-replies.json` 改动及其他用户文件。 + +## 错误记录 + +| 错误 | 尝试 | 处理 | +| --- | --- | --- | diff --git a/config/cross-conversation-replies.json b/config/cross-conversation-replies.json index 9c48188..d6afe1b 100644 --- a/config/cross-conversation-replies.json +++ b/config/cross-conversation-replies.json @@ -1,5 +1,5 @@ { "version": 1, - "updatedAt": "2026-08-12T02:56:30.438Z", + "updatedAt": "2026-08-20T15:45:04.108Z", "replies": [] } \ No newline at end of file diff --git a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz index 7ec186e..5ba1177 100644 Binary files a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz and b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz differ diff --git a/scripts/mock-codex-app-server.js b/scripts/mock-codex-app-server.js index dfbc3e7..89f022d 100755 --- a/scripts/mock-codex-app-server.js +++ b/scripts/mock-codex-app-server.js @@ -1111,6 +1111,47 @@ function handleRequest(message) { send({ id, result: { reloaded: true, reloadCount: mcpReloadCount } }); return; } + if (method === 'mcpServerStatus/list') { + const thread = ensureThread(params.threadId, params); + send({ + id, + result: { + data: [ + { + name: 'reg-app-project', + authStatus: 'unsupported', + resources: [], + resourceTemplates: [], + serverInfo: { name: 'reg-app-project', version: '1.0.0' }, + tools: { + reg_app_inspect: { + name: 'reg_app_inspect', + description: 'Regression App MCP tool.', + inputSchema: { type: 'object' }, + }, + }, + }, + { + name: 'reg-runtime-only', + authStatus: 'unsupported', + resources: [], + resourceTemplates: [], + serverInfo: { name: 'reg-runtime-only', version: '1.0.0' }, + tools: { + 'mcp__reg-runtime-only__search': { + name: 'mcp__reg-runtime-only__search', + description: 'Runtime-only MCP tool.', + inputSchema: { type: 'object' }, + }, + }, + }, + ], + nextCursor: null, + threadId: thread.id, + }, + }); + return; + } if (method === 'thread/start') { const thread = ensureThread(null, params); thread.lastThreadConfigMethod = 'thread/start'; diff --git a/scripts/regression.js b/scripts/regression.js index b5e62dc..d499b6d 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -899,6 +899,10 @@ function assertFrontendComposerMcpContract() { const selectBlock = source.slice(selectStart, selectEnd); assert(selectBlock.includes('const insertion = String(item.insertion || item.label || item.name || \'\');'), 'Composer should insert selected item text through the generic insertion path'); assert(selectBlock.includes('const appendSpace = item.appendSpace !== false;'), 'Composer should honor appendSpace for generic MCP insertion'); + assert(serverSource.includes("mcpServerStatus/list"), 'Composer backend should query current Codex App MCP inventory through mcpServerStatus/list'); + assert(serverSource.includes("itemType: 'tool'"), 'Composer backend should expose non-ccweb MCP tools as generic tool mentions'); + assert(serverSource.includes('CODEX_APP_MCP_INVENTORY_TTL_MS'), 'Composer MCP inventory should use a short per-thread cache'); + assert(source.includes('const insertion = String(item.insertion || item.label || item.name || \'\');'), 'Frontend should insert MCP mentions through the generic insertion path'); assert(!source.includes('function showCcwebPromptUserComposerModal'), 'Composer should not open a parameter builder for ccweb_prompt_user'); assert(!source.includes('composer_mcp_tool_submit'), 'Frontend should not submit ccweb_prompt_user from slash composer as structured MCP args'); assert(!serverSource.includes('composer_mcp_tool_submit'), 'Server should not accept slash-composer structured MCP tool submissions'); @@ -7336,6 +7340,20 @@ async function main() { assert(/"hasTopLevelEffort":false/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration turn should not duplicate effort at top level'); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); + ws.send(JSON.stringify({ + type: 'composer_suggestions', + requestId: 'reg-codexapp-mcp-runtime', + trigger: '/', + query: 'reg-runtime-only', + sessionId: codexAppSession.sessionId, + agent: 'codexapp', + })); + const codexAppRuntimeMcpComposer = await nextMessage(messages, ws, (msg) => ( + msg.type === 'composer_suggestions' && msg.requestId === 'reg-codexapp-mcp-runtime' + )); + assert(codexAppRuntimeMcpComposer.items.some((item) => item.kind === 'mcp' && item.itemType === 'server' && item.name === 'reg-runtime-only'), 'Codex App composer should include MCP servers reported by the current app-server thread'); + assert(codexAppRuntimeMcpComposer.items.some((item) => item.kind === 'mcp' && item.itemType === 'tool' && item.server === 'reg-runtime-only' && item.name === 'search' && item.insertion === 'mcp:reg-runtime-only/search'), 'Codex App composer should include tool-level mentions from mcpServerStatus/list'); + ws.send(JSON.stringify({ type: 'message', text: 'codexapp dynamic web search enabled prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); const codexAppEnabledSearchDynamicTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'mcp-ccweb-list'); assert(/"threadStartWebSearchMode": "live"/.test(codexAppEnabledSearchDynamicTool.result || ''), 'Codex App thread/start should pass web_search=live when Web Search is enabled'); diff --git a/server.js b/server.js index 0a0593a..4fd4bee 100644 --- a/server.js +++ b/server.js @@ -113,7 +113,8 @@ const ATTACHMENT_TTL_MS = 7 * 24 * 60 * 60 * 1000; const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; const FILE_BROWSER_MAX_LIST_ENTRIES = 400; const FILE_BROWSER_MAX_PREVIEW_BYTES = 200 * 1024; -const COMPOSER_SUGGESTION_LIMIT = 20; +// MCP 工具可能来自多个 server;20 条会在空查询时静默截断大部分工具。 +const COMPOSER_SUGGESTION_LIMIT = 200; const COMPOSER_FILE_CONTEXT_MAX_BYTES = 60 * 1024; const COMPOSER_MAX_FILE_MENTIONS = 4; const COMPOSER_MAX_PROMPT_MENTIONS = 4; @@ -786,7 +787,12 @@ const CODEX_APP_MCP_STARTUP_STATUS_METHOD = 'mcpServer/startupStatus/updated'; const CODEX_APP_MCP_DEFAULT_SERVER = 'ccweb'; const CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS = 1200; const CODEX_APP_MCP_RELOAD_TRACK_MS = 15000; +const CODEX_APP_MCP_INVENTORY_TTL_MS = 3000; +const CODEX_APP_MCP_INVENTORY_MAX_PAGES = 10; const codexAppMcpStartupStatusByServer = new Map(); +// threadId -> { fetchedAt, servers } +const codexAppMcpInventoryByThread = new Map(); +const codexAppMcpInventoryPending = new Map(); // sessionId -> { threadId, requestedAt, expiresAt, reloadRequestId } const pendingCodexAppMcpReloads = new Map(); // sessionId -> Set<{ requestedAt, timer, resolve }> @@ -2554,6 +2560,115 @@ function mcpServerSuggestion(name, options = {}) { }; } +function normalizeMcpToolName(server, rawName) { + const normalizedServer = normalizeMcpServerName(server); + let name = String(rawName || '').trim(); + const namespacedPrefix = normalizedServer ? `mcp__${normalizedServer}__` : ''; + if (namespacedPrefix && name.startsWith(namespacedPrefix)) name = name.slice(namespacedPrefix.length); + if (!name || name.length > 180) return ''; + if (!/^[A-Za-z0-9][A-Za-z0-9_.:-]*$/.test(name)) return ''; + return name; +} + +function mcpToolSuggestion(server, rawTool, fallbackName = '') { + const normalizedServer = normalizeMcpServerName(server); + if (!isLikelyMcpServerName(normalizedServer)) return null; + const tool = rawTool && typeof rawTool === 'object' ? rawTool : {}; + const name = normalizeMcpToolName(normalizedServer, tool.name || fallbackName); + if (!name) return null; + const label = `mcp:${normalizedServer}/${name}`; + return { + kind: 'mcp', + name, + label, + title: `${normalizedServer}/${name}`, + description: normalizeComposerTextValue(tool.description || 'MCP 工具'), + insertion: label, + appendSpace: true, + server: normalizedServer, + source: `mcp:${normalizedServer}`, + itemType: 'tool', + action: '', + }; +} + +function normalizeCodexAppMcpInventory(result) { + const servers = []; + const seenServers = new Set(); + for (const rawServer of Array.isArray(result?.data) ? result.data : []) { + const server = normalizeMcpServerName(rawServer?.name); + if (!isLikelyMcpServerName(server) || seenServers.has(server)) continue; + seenServers.add(server); + const tools = []; + const rawTools = rawServer?.tools && typeof rawServer.tools === 'object' ? rawServer.tools : {}; + for (const [fallbackName, rawTool] of Object.entries(rawTools)) { + const item = mcpToolSuggestion(server, rawTool, fallbackName); + if (item) tools.push(item); + } + servers.push({ + server, + description: normalizeComposerTextValue(rawServer?.serverInfo?.description || `MCP server: ${server}`), + title: normalizeComposerTextValue(rawServer?.serverInfo?.title || `${server} MCP`), + tools, + }); + } + return servers; +} + +async function loadCodexAppMcpInventory(session) { + if (!isCodexAppSession(session)) return []; + const threadId = normalizeCodexAppThreadId(getRuntimeSessionId(session)); + if (!threadId || !codexAppClient?.isRunning()) return []; + const cached = codexAppMcpInventoryByThread.get(threadId); + if (cached && Date.now() - cached.fetchedAt < CODEX_APP_MCP_INVENTORY_TTL_MS) { + return cached.servers; + } + const existing = codexAppMcpInventoryPending.get(threadId); + if (existing) return existing; + const pending = (async () => { + const allServers = []; + let cursor = null; + for (let page = 0; page < CODEX_APP_MCP_INVENTORY_MAX_PAGES; page += 1) { + const response = await codexAppClient.request('mcpServerStatus/list', { + threadId, + detail: 'full', + limit: 200, + cursor, + }, 5000); + allServers.push(...normalizeCodexAppMcpInventory(response)); + const nextCursor = String(response?.nextCursor || '').trim(); + if (!nextCursor || nextCursor === cursor) break; + cursor = nextCursor; + } + const byName = new Map(); + for (const server of allServers) { + const current = byName.get(server.server); + if (!current) { + byName.set(server.server, server); + continue; + } + const tools = new Map(current.tools.map((tool) => [tool.name, tool])); + for (const tool of server.tools) tools.set(tool.name, tool); + current.tools = Array.from(tools.values()); + } + const servers = Array.from(byName.values()); + codexAppMcpInventoryByThread.set(threadId, { fetchedAt: Date.now(), servers }); + return servers; + })() + .catch((err) => { + plog('WARN', 'codex_app_mcp_inventory_failed', { + threadId: threadId.slice(0, 16), + error: err?.message || String(err), + }); + // 对旧版 app-server 做短暂负缓存,避免每次输入都重复等待不支持的方法。 + codexAppMcpInventoryByThread.set(threadId, { fetchedAt: Date.now(), servers: [] }); + return []; + }) + .finally(() => codexAppMcpInventoryPending.delete(threadId)); + codexAppMcpInventoryPending.set(threadId, pending); + return pending; +} + function summarizeSkillDependencies(skill) { const tools = Array.isArray(skill?.dependencies?.tools) ? skill.dependencies.tools : []; return tools @@ -2658,6 +2773,9 @@ function listRuntimeMcpServerConfigs(options = {}) { function listComposerMcpItems(options = {}) { const normalizedOptions = typeof options === 'string' ? { sessionId: options } : options; const sourceSessionId = normalizedOptions.session?.id || normalizedOptions.sessionId || ''; + const runtimeInventory = Array.isArray(normalizedOptions.runtimeMcpInventory) + ? normalizedOptions.runtimeMcpInventory + : []; const items = []; const seen = new Set(); const push = (item) => { @@ -2668,13 +2786,24 @@ function listComposerMcpItems(options = {}) { items.push(item); }; - for (const config of listRuntimeMcpServerConfigs(normalizedOptions)) { - push(mcpServerSuggestion(config.server, { - source: config.source || 'runtime', - description: config.description || `MCP server: ${config.server}`, - transport: config.type || '', - url: config.config?.url || '', + const inventoryByServer = new Map(runtimeInventory.map((item) => [item.server, item])); + const runtimeConfigs = listRuntimeMcpServerConfigs(normalizedOptions); + const configuredServers = new Set(runtimeConfigs.map((item) => item.server)); + const pushServerAndTools = (config, inventory) => { + const server = normalizeMcpServerName(config?.server || config?.name || inventory?.server); + if (!isLikelyMcpServerName(server)) return; + push(mcpServerSuggestion(server, { + source: config?.source || 'runtime', + description: inventory?.description || config?.description || `MCP server: ${server}`, + transport: config?.type || '', + url: config?.config?.url || '', })); + for (const tool of Array.isArray(inventory?.tools) ? inventory.tools : []) push(tool); + }; + + for (const config of runtimeConfigs) { + const inventory = inventoryByServer.get(config.server); + pushServerAndTools(config, inventory); if (config.server === 'ccweb') { const ccwebTools = [ ...CCWEB_MCP_TOOLS, @@ -2699,6 +2828,14 @@ function listComposerMcpItems(options = {}) { } } } + + // Codex App 的运行时配置可能来自用户级配置、插件或动态重载, + // 这些来源不一定出现在 cwd 下的项目 config.toml 中;以 app-server + // 当前线程返回的 inventory 为准补齐 server/tool 候选。 + for (const inventory of runtimeInventory) { + if (!inventory?.server || configuredServers.has(inventory.server)) continue; + pushServerAndTools({ server: inventory.server, source: 'app-runtime' }, inventory); + } return items; } @@ -2769,10 +2906,15 @@ function listComposerFileSuggestions(sessionId, query) { return items.slice(0, COMPOSER_SUGGESTION_LIMIT); } -function listComposerSuggestions(trigger, query, sessionId, agent, session = null) { +function listComposerSuggestions(trigger, query, sessionId, agent, session = null, options = {}) { const skillItems = isCodexLikeAgent(agent) ? loadCodexSkills({ session }) : []; if (trigger === '/') { - const mcpItems = listComposerMcpItems({ sessionId, session, agent }); + const mcpItems = listComposerMcpItems({ + sessionId, + session, + agent, + runtimeMcpInventory: options.runtimeMcpInventory, + }); const isPromptUserMcp = (item) => ( item.kind === 'mcp' && item.server === 'ccweb' && item.name === 'ccweb_prompt_user' ); @@ -2816,7 +2958,7 @@ function listComposerSuggestions(trigger, query, sessionId, agent, session = nul return []; } -function handleComposerSuggestions(ws, msg) { +async function handleComposerSuggestions(ws, msg) { const trigger = ['/', '$', '@'].includes(msg.trigger) ? msg.trigger : ''; const query = String(msg.query || '').replace(/^[@$/]/, '').trim(); const requestId = String(msg.requestId || ''); @@ -2826,7 +2968,8 @@ function handleComposerSuggestions(ws, msg) { const sessionId = sanitizeId(msg.sessionId || ''); const agent = normalizeAgent(msg.agent); const session = sessionId ? loadSession(sessionId) : null; - const items = listComposerSuggestions(trigger, query, sessionId, agent, session); + const runtimeMcpInventory = trigger === '/' ? await loadCodexAppMcpInventory(session) : []; + const items = listComposerSuggestions(trigger, query, sessionId, agent, session, { runtimeMcpInventory }); return wsSend(ws, { type: 'composer_suggestions', requestId, trigger, query, items }); } @@ -3704,6 +3847,8 @@ async function handleReloadMcpApi(req, res, rawSessionId) { const client = clientResult.client; await client.start(); + const currentThreadId = normalizeCodexAppThreadId(getRuntimeSessionId(session)); + if (currentThreadId) codexAppMcpInventoryByThread.delete(currentThreadId); const pendingMcp = markCodexAppMcpReloadPending(session, sessionId); reloadRequestedAt = pendingMcp.requestedAt; const result = typeof client.reloadMcpServers === 'function' @@ -3750,6 +3895,10 @@ function setRuntimeSessionId(session, runtimeId) { const previousThreadId = normalizeCodexAppThreadId(session.codexAppThreadId); const nextThreadId = normalizeCodexAppThreadId(runtimeId); session.codexAppThreadId = runtimeId || null; + if (previousThreadId && previousThreadId !== nextThreadId) { + codexAppMcpInventoryByThread.delete(previousThreadId); + codexAppMcpInventoryPending.delete(previousThreadId); + } if (previousThreadId && previousThreadId !== nextThreadId) { session.codexAppGoal = null; if (session.id) codexAppGoalStates.delete(session.id); @@ -7601,7 +7750,16 @@ wss.on('connection', (ws, req) => { } break; case 'composer_suggestions': - handleComposerSuggestions(ws, msg); + handleComposerSuggestions(ws, msg).catch((err) => { + plog('WARN', 'composer_suggestions_failed', { error: err?.message || String(err) }); + wsSend(ws, { + type: 'composer_suggestions', + requestId: String(msg.requestId || ''), + trigger: ['/', '$', '@'].includes(msg.trigger) ? msg.trigger : '', + query: String(msg.query || ''), + items: [], + }); + }); break; case 'abort': handleAbort(ws, msg); diff --git a/补全 MCP 快速选择 TO DO list.csv b/补全 MCP 快速选择 TO DO list.csv new file mode 100644 index 0000000..c6fbfbb --- /dev/null +++ b/补全 MCP 快速选择 TO DO list.csv @@ -0,0 +1,9 @@ +id,item,status,done_at,notes +1,初始化调查计划与 TODO CSV,DONE,2026-08-21T10:26:23+08:00,计划已创建并通过独立审查 +2,使用 codebase-memory 定位 MCP 候选构建和 composer 触发链路,DONE,2026-08-21T10:26:23+08:00,已用 codebase-memory 定位 composer MCP 候选链路 +3,对照运行时 MCP 配置确认完整候选来源并排除历史/静态推断,DONE,2026-08-21T10:26:23+08:00,已确认项目配置与 Codex App mcpServerStatus/list 运行时来源 +4,编写回归测试覆盖当前线程级 MCP、三种触发符互斥和 mention 插入语义,DONE,2026-08-21T10:29:48+08:00,补充当前线程 runtime MCP、三类触发符边界和 mention 插入回归 +5,修复候选聚合逻辑接入当前会话 MCP,DONE,2026-08-21T10:29:48+08:00,接入 mcpServerStatus/list,合并 runtime server/tool 候选并增加分页缓存 +6,修复 mention 插入与去重排序展示,DONE,2026-08-21T10:29:48+08:00,保留通用 mcp:server/tool mention 插入,扩大候选上限至 200 +7,运行单测、静态检查和浏览器/组件回归并按需修复,IN_PROGRESS,, +8,清理临时计划文件并汇总,TODO,,