fix: 补全 MCP 快速选择并重新打包
This commit is contained in:
182
server.js
182
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);
|
||||
|
||||
Reference in New Issue
Block a user