🐛 fix(ai-mcp): 优化外部客户端默认选中逻辑

- 抽离 MCP 客户端状态归一化与命令格式化工具
- 优先默认选中已接入但需要更新的外部客户端
- 补充状态选择与启动命令格式化测试
This commit is contained in:
Syngnat
2026-06-08 18:15:08 +08:00
parent a54a357e4b
commit 92e9b0ef75
3 changed files with 180 additions and 75 deletions

View File

@@ -15,6 +15,7 @@ import { resolveProviderSecretDraft } from '../utils/providerSecretDraft';
import { buildAddProviderEditorSession, buildClosedProviderEditorSession, buildEditProviderEditorSession, type ProviderEditorSession } from '../utils/aiProviderEditorState';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { BUILTIN_AI_TOOL_INFO } from '../utils/aiToolRegistry';
import { EMPTY_MCP_CLIENT_STATUSES, formatMCPLaunchCommand, normalizeMCPClientStatuses, pickPreferredMCPClient } from '../utils/mcpClientInstallStatus';
import AIBuiltinToolsCatalog from './ai/AIBuiltinToolsCatalog';
import AISettingsMCPSection, { type MCPClientKey } from './ai/AISettingsMCPSection';
import AISettingsSidebar, { type AISettingsSectionKey } from './ai/AISettingsSidebar';
@@ -106,62 +107,6 @@ const EMPTY_MCP_SERVER = (seed?: Partial<AIMCPServerConfig>): AIMCPServerConfig
};
};
const EMPTY_MCP_CLIENT_STATUSES: AIMCPClientInstallStatus[] = [
{
client: 'claude-code',
displayName: 'Claude Code',
installed: false,
matchesCurrent: false,
message: '未检测到 Claude Code 用户级 GoNavi MCP 配置',
},
{
client: 'codex',
displayName: 'Codex',
installed: false,
matchesCurrent: false,
message: '未检测到 Codex 用户级 GoNavi MCP 配置',
},
];
const normalizeMCPClientStatuses = (items?: AIMCPClientInstallStatus[]): AIMCPClientInstallStatus[] => {
const baseMap = new Map<string, AIMCPClientInstallStatus>(
EMPTY_MCP_CLIENT_STATUSES.map((item) => [item.client, { ...item }]),
);
(Array.isArray(items) ? items : []).forEach((item) => {
if (!item || !item.client) {
return;
}
const base = baseMap.get(item.client) || {
client: item.client,
displayName: item.client,
installed: false,
matchesCurrent: false,
message: '',
};
baseMap.set(item.client, {
...base,
...item,
displayName: item.displayName || base.displayName,
message: item.message || base.message,
args: Array.isArray(item.args) ? item.args : (base.args || []),
});
});
return (['claude-code', 'codex'] as MCPClientKey[])
.map((client) => baseMap.get(client))
.filter((item): item is AIMCPClientInstallStatus => Boolean(item));
};
const pickPreferredMCPClient = (items: AIMCPClientInstallStatus[], current?: MCPClientKey): MCPClientKey => {
if (current && items.some((item) => item.client === current)) {
return current;
}
const pending = items.find((item) => !item.matchesCurrent);
if (pending?.client === 'claude-code' || pending?.client === 'codex') {
return pending.client;
}
return 'claude-code';
};
const waitFor = (delayMs: number) => new Promise<void>((resolve) => {
window.setTimeout(resolve, delayMs);
});
@@ -181,25 +126,6 @@ const waitForAIService = async (attempts = 6, delayMs = 80) => {
return readAIService();
};
const quoteMCPCommandPart = (value: string): string => {
const text = String(value || '').trim();
if (!text) {
return '';
}
return /[\s"]/u.test(text) ? `"${text.replace(/"/g, '\\"')}"` : text;
};
const formatMCPLaunchCommand = (input?: Pick<AIMCPClientInstallStatus, 'command' | 'args'> | Pick<MCPClientInstallResult, 'command' | 'args'> | null): string => {
const command = String(input?.command || '').trim();
if (!command) {
return '';
}
const args = Array.isArray(input?.args)
? input.args.map((item) => String(item || '').trim()).filter(Boolean)
: [];
return [command, ...args].map(quoteMCPCommandPart).filter(Boolean).join(' ');
};
const EMPTY_SKILL = (): AISkillConfig => ({
id: `skill-draft-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: '',

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import type { AIMCPClientInstallStatus } from '../types';
import {
EMPTY_MCP_CLIENT_STATUSES,
formatMCPLaunchCommand,
normalizeMCPClientStatuses,
pickPreferredMCPClient,
} from './mcpClientInstallStatus';
describe('mcpClientInstallStatus helpers', () => {
it('fills missing clients with default placeholder statuses', () => {
const statuses = normalizeMCPClientStatuses([
{
client: 'codex',
displayName: 'Codex',
installed: true,
matchesCurrent: true,
message: '已检测到 Codex 用户级 GoNavi MCP 配置,且与当前 GoNavi 安装路径一致',
},
]);
expect(statuses).toEqual([
EMPTY_MCP_CLIENT_STATUSES[0],
{
client: 'codex',
displayName: 'Codex',
installed: true,
matchesCurrent: true,
message: '已检测到 Codex 用户级 GoNavi MCP 配置,且与当前 GoNavi 安装路径一致',
args: [],
},
]);
});
it('prefers an already-installed but outdated client over a completely uninstalled one', () => {
const statuses: AIMCPClientInstallStatus[] = [
{
client: 'claude-code',
displayName: 'Claude Code',
installed: false,
matchesCurrent: false,
message: '未检测到 Claude Code 用户级 GoNavi MCP 配置',
},
{
client: 'codex',
displayName: 'Codex',
installed: true,
matchesCurrent: false,
message: '已检测到 Codex 中的 GoNavi MCP 记录,但与当前 GoNavi 安装路径不一致,建议更新',
},
];
expect(pickPreferredMCPClient(statuses)).toBe('codex');
});
it('keeps the user-selected client when it is still present in the latest status list', () => {
expect(pickPreferredMCPClient(EMPTY_MCP_CLIENT_STATUSES, 'codex')).toBe('codex');
});
it('formats quoted launch commands for display and clipboard use', () => {
expect(formatMCPLaunchCommand({
command: 'C:/Program Files/GoNavi/GoNavi.exe',
args: ['mcp-server', '--stdio'],
})).toBe('"C:/Program Files/GoNavi/GoNavi.exe" mcp-server --stdio');
});
});

View File

@@ -0,0 +1,112 @@
import type { AIMCPClientInstallStatus } from '../types';
type MCPClientKey = 'claude-code' | 'codex';
export const EMPTY_MCP_CLIENT_STATUSES: AIMCPClientInstallStatus[] = [
{
client: 'claude-code',
displayName: 'Claude Code',
installed: false,
matchesCurrent: false,
message: '未检测到 Claude Code 用户级 GoNavi MCP 配置',
},
{
client: 'codex',
displayName: 'Codex',
installed: false,
matchesCurrent: false,
message: '未检测到 Codex 用户级 GoNavi MCP 配置',
},
];
const MCP_CLIENT_ORDER: MCPClientKey[] = ['claude-code', 'codex'];
const quoteMCPCommandPart = (value: string): string => {
const text = String(value || '').trim();
if (!text) {
return '';
}
return /[\s"]/u.test(text) ? `"${text.replace(/"/g, '\\"')}"` : text;
};
const isActionableClient = (client: string): client is MCPClientKey =>
client === 'claude-code' || client === 'codex';
const hasStatusError = (status: AIMCPClientInstallStatus): boolean =>
/|||/u.test(String(status.message || ''));
const getMCPClientPriority = (status: AIMCPClientInstallStatus): number => {
if (hasStatusError(status)) {
return 0;
}
if (status.installed && !status.matchesCurrent) {
return 1;
}
if (status.matchesCurrent) {
return 2;
}
return 3;
};
export const normalizeMCPClientStatuses = (items?: AIMCPClientInstallStatus[]): AIMCPClientInstallStatus[] => {
const baseMap = new Map<string, AIMCPClientInstallStatus>(
EMPTY_MCP_CLIENT_STATUSES.map((item) => [item.client, { ...item }]),
);
(Array.isArray(items) ? items : []).forEach((item) => {
if (!item || !item.client) {
return;
}
const base = baseMap.get(item.client) || {
client: item.client,
displayName: item.client,
installed: false,
matchesCurrent: false,
message: '',
};
baseMap.set(item.client, {
...base,
...item,
displayName: item.displayName || base.displayName,
message: item.message || base.message,
args: Array.isArray(item.args) ? item.args : (base.args || []),
});
});
return MCP_CLIENT_ORDER
.map((client) => baseMap.get(client))
.filter((item): item is AIMCPClientInstallStatus => Boolean(item));
};
export const pickPreferredMCPClient = (
items: AIMCPClientInstallStatus[],
current?: MCPClientKey,
): MCPClientKey => {
if (current && items.some((item) => item.client === current)) {
return current;
}
const ranked = items
.filter((item): item is AIMCPClientInstallStatus & { client: MCPClientKey } => isActionableClient(item.client))
.slice()
.sort((left, right) => {
const priorityDiff = getMCPClientPriority(left) - getMCPClientPriority(right);
if (priorityDiff !== 0) {
return priorityDiff;
}
return MCP_CLIENT_ORDER.indexOf(left.client) - MCP_CLIENT_ORDER.indexOf(right.client);
});
return ranked[0]?.client || 'claude-code';
};
export const formatMCPLaunchCommand = (
input?: Pick<AIMCPClientInstallStatus, 'command' | 'args'> | { command?: string; args?: string[] } | null,
): string => {
const command = String(input?.command || '').trim();
if (!command) {
return '';
}
const args = Array.isArray(input?.args)
? input.args.map((item) => String(item || '').trim()).filter(Boolean)
: [];
return [command, ...args].map(quoteMCPCommandPart).filter(Boolean).join(' ');
};