🐛 fix(mcp): 修复外部 MCP 读取连接不完整

- 启动时自动同步旧本地连接到后端安全仓库

- 启动 HTTP MCP 或安装外部 MCP 前执行连接同步

- 增加安全配置同步回归测试

Fixes #591
This commit is contained in:
Syngnat
2026-06-24 21:36:40 +08:00
parent 69b6072e37
commit 5725e78931
5 changed files with 221 additions and 9 deletions

View File

@@ -56,6 +56,7 @@ import {
bootstrapSecureConfig,
finalizeSecurityUpdateStatus,
mergeSecurityUpdateStatusWithLegacySource,
prepareSecureConfigForExternalMCP,
startSecurityUpdateFromBootstrap,
} from './utils/secureConfigBootstrap';
import { bootstrapSavedQueries } from './utils/savedQueryPersistence';
@@ -562,6 +563,7 @@ function App() {
try {
const result = await bootstrapSecureConfig({
backend: (window as any).go?.app?.App,
autoStartLegacySecurityUpdate: true,
replaceConnections,
replaceGlobalProxy,
t,
@@ -1298,6 +1300,51 @@ function App() {
const handleStartSecurityUpdate = useCallback(() => {
void runSecurityUpdateRound('start');
}, [runSecurityUpdateRound]);
const handlePrepareExternalMCPUse = useCallback(async () => {
const backendApp = (window as any).go?.app?.App;
const result = await prepareSecureConfigForExternalMCP({
backend: backendApp,
replaceConnections,
replaceGlobalProxy,
t,
});
if (result.error) {
throw result.error;
}
if (!result.status) {
return;
}
const nextStatus = normalizeSecurityUpdateStatus(result.status);
const shouldOpenSettings = nextStatus.overallStatus === 'needs_attention' || nextStatus.overallStatus === 'rolled_back';
applySecurityUpdateStatus(nextStatus, {
openSettings: shouldOpenSettings,
refreshFocus: shouldOpenSettings,
});
if (nextStatus.overallStatus === 'completed') {
setSecurityUpdateHasLegacySensitiveItems(false);
setSecurityUpdateRawPayload(null);
setIsSecurityUpdateSettingsOpen(false);
return;
}
const hasConnectionIssue = nextStatus.issues.some((issue) =>
issue.scope === 'connection' && issue.status !== 'updated',
);
if (nextStatus.overallStatus === 'rolled_back' || hasConnectionIssue) {
throw new Error(t('app.security_update.message.needs_attention'));
}
if (nextStatus.overallStatus === 'needs_attention') {
void message.warning(t('app.security_update.message.needs_attention'));
}
}, [
applySecurityUpdateStatus,
normalizeSecurityUpdateStatus,
replaceConnections,
replaceGlobalProxy,
t,
]);
const handleRetrySecurityUpdate = useCallback(() => {
void runSecurityUpdateRound('retry');
}, [runSecurityUpdateRound]);
@@ -4051,6 +4098,7 @@ function App() {
darkMode={darkMode}
overlayTheme={overlayTheme}
focusProviderId={focusedAIProviderId}
onBeforeExternalMCPUse={handlePrepareExternalMCPUse}
/>
)}
<ConnectionPackagePasswordModal

View File

@@ -41,6 +41,7 @@ interface AISettingsModalProps {
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
focusProviderId?: string;
onBeforeExternalMCPUse?: () => Promise<void>;
}
const DEFAULT_MCP_HTTP_SERVER_STATUS: AIMCPHTTPServerStatus = {
@@ -79,7 +80,7 @@ const normalizeMCPHTTPAuthorizationToken = (value: string): string => {
return withoutHeaderName.replace(/^Bearer\s+/i, '').trim();
};
const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMode, overlayTheme, focusProviderId }) => {
const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMode, overlayTheme, focusProviderId, onBeforeExternalMCPUse }) => {
const { t } = useI18n();
const defaultMCPHTTPServerStatus = useMemo<AIMCPHTTPServerStatus>(() => ({
...DEFAULT_MCP_HTTP_SERVER_STATUS,
@@ -181,7 +182,10 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
resolveAIService,
messageApi,
copyTextToClipboard,
onBeforeInstall: () => setLoading(true),
onBeforeInstall: async () => {
setLoading(true);
await onBeforeExternalMCPUse?.();
},
onAfterInstall: () => setLoading(false),
onConfigChanged: () => window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed')),
translate: t,
@@ -532,6 +536,9 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
if (!checked && typeof Service.AIStopMCPHTTPServer !== 'function') {
throw new Error(t('ai_settings.mcp_http.error.stop_unsupported_version'));
}
if (checked) {
await onBeforeExternalMCPUse?.();
}
const nextStatus = checked
? await Service.AIStartMCPHTTPServer({
addr: mcpHTTPServerDraft.addr || DEFAULT_MCP_HTTP_SERVER_STATUS.addr,

View File

@@ -40,7 +40,7 @@ interface UseAIMCPClientInstallerOptions {
copyTextToClipboard: (text: string, successMessage: string) => Promise<void>;
messageApi: MCPClientMessageApi;
onAfterInstall?: () => void;
onBeforeInstall?: () => void;
onBeforeInstall?: () => void | Promise<void>;
onConfigChanged?: () => void;
resolveAIService: () => Promise<AIMCPClientInstallerService | null>;
translate?: MCPClientInstallTranslator;
@@ -124,7 +124,7 @@ export const useAIMCPClientInstaller = ({
const targetLabel = selectedMCPClientStatus?.displayName || (targetClient === 'codex' ? 'Codex' : 'Claude Code');
if (remoteClient) {
try {
onBeforeInstall?.();
await onBeforeInstall?.();
setMCPClientSelectionTouched(true);
await copyTextToClipboard(
buildRemoteMCPClientGuide(selectedMCPClientStatus),
@@ -138,11 +138,18 @@ export const useAIMCPClientInstaller = ({
return;
}
if (selectedMCPClientStatus?.matchesCurrent) {
void messageApi.success(copy('ai_chat.mcp_client.install.message.already_connected', '{{label}} is already connected to current GoNavi MCP. No repeated write is needed.', { label: targetLabel }));
try {
await onBeforeInstall?.();
void messageApi.success(copy('ai_chat.mcp_client.install.message.already_connected', '{{label}} is already connected to current GoNavi MCP. No repeated write is needed.', { label: targetLabel }));
} catch (error: any) {
void messageApi.error(error?.message || copy('ai_chat.mcp_client.install.message.install_failed', 'Failed to install {{label}} MCP', { label: targetLabel }));
} finally {
onAfterInstall?.();
}
return;
}
try {
onBeforeInstall?.();
await onBeforeInstall?.();
setMCPClientSelectionTouched(true);
const service = await resolveAIService();
if (targetClient === 'codex') {

View File

@@ -7,6 +7,7 @@ import {
bootstrapSecureConfig,
finalizeSecurityUpdateStatus,
mergeSecurityUpdateStatusWithLegacySource,
prepareSecureConfigForExternalMCP,
startSecurityUpdateFromBootstrap,
} from './secureConfigBootstrap';
import { stripLegacyPersistedConnectionById } from './legacyConnectionStorage';
@@ -165,6 +166,53 @@ describe('secureConfigBootstrap', () => {
);
});
it('auto-starts legacy security update on bootstrap when requested so MCP sees backend connections', async () => {
const args = createBaseArgs();
const StartSecurityUpdate = vi.fn().mockResolvedValue({
overallStatus: 'completed',
summary: { total: 2, updated: 2, pending: 0, skipped: 0, failed: 0 },
issues: [],
});
const result = await bootstrapSecureConfig({
...args,
autoStartLegacySecurityUpdate: true,
backend: {
GetSecurityUpdateStatus: vi.fn().mockResolvedValue({
overallStatus: 'pending',
summary: { total: 0, updated: 0, pending: 0, skipped: 0, failed: 0 },
issues: [],
}),
StartSecurityUpdate,
GetSavedConnections: vi.fn().mockResolvedValue([
{
id: 'legacy-1',
name: 'Legacy',
config: {
id: 'legacy-1',
type: 'postgres',
host: 'db.local',
port: 5432,
user: 'postgres',
},
hasPrimaryPassword: true,
},
]),
},
});
expect(StartSecurityUpdate).toHaveBeenCalledWith(expect.objectContaining({
sourceType: 'current_app_saved_config',
rawPayload: legacyPayload,
}));
expect(result.status.overallStatus).toBe('completed');
expect(result.hasLegacySensitiveItems).toBe(false);
expect(args.storage.getItem(LEGACY_PERSIST_KEY)).not.toContain('"password":"secret"');
expect(args.replaceConnections).toHaveBeenLastCalledWith(
expect.arrayContaining([expect.objectContaining({ id: 'legacy-1' })]),
);
});
it('keeps banner flow without intro when backend status is postponed', async () => {
const args = createBaseArgs();
@@ -485,6 +533,62 @@ describe('secureConfigBootstrap', () => {
expect(args.storage.getItem(LEGACY_PERSIST_KEY)).toContain('"password":"secret"');
});
it('prepares secure backend connections before external MCP use', async () => {
const args = createBaseArgs();
const StartSecurityUpdate = vi.fn().mockResolvedValue({
overallStatus: 'completed',
summary: { total: 2, updated: 2, pending: 0, skipped: 0, failed: 0 },
issues: [],
});
const result = await prepareSecureConfigForExternalMCP({
...args,
backend: {
StartSecurityUpdate,
GetSavedConnections: vi.fn().mockResolvedValue([
{
id: 'legacy-1',
name: 'Legacy',
config: {
id: 'legacy-1',
type: 'postgres',
host: 'db.local',
port: 5432,
user: 'postgres',
},
hasPrimaryPassword: true,
},
]),
},
});
expect(result.attempted).toBe(true);
expect(result.error).toBeNull();
expect(result.status?.overallStatus).toBe('completed');
expect(StartSecurityUpdate).toHaveBeenCalledWith(expect.objectContaining({
rawPayload: legacyPayload,
}));
expect(args.storage.getItem(LEGACY_PERSIST_KEY)).not.toContain('"password":"secret"');
});
it('skips external MCP preparation when no legacy connection source exists', async () => {
const storage = createMemoryStorage();
const StartSecurityUpdate = vi.fn();
const result = await prepareSecureConfigForExternalMCP({
storage,
replaceConnections: vi.fn(),
replaceGlobalProxy: vi.fn(),
backend: {
StartSecurityUpdate,
},
});
expect(result.attempted).toBe(false);
expect(result.status).toBeNull();
expect(result.error).toBeNull();
expect(StartSecurityUpdate).not.toHaveBeenCalled();
});
it('starts security update even when rawPayload is empty but backend supports AI-only update', async () => {
const storage = createMemoryStorage();
const replaceConnections = vi.fn();

View File

@@ -38,6 +38,7 @@ type SecurityUpdateBackend = {
type SecureConfigBootstrapArgs = {
backend?: SecurityUpdateBackend;
storage?: StorageLike;
autoStartLegacySecurityUpdate?: boolean;
replaceConnections: (connections: SavedConnection[]) => void;
replaceGlobalProxy: (proxy: GlobalProxyConfig) => void;
t?: SecureConfigBootstrapTranslator;
@@ -56,6 +57,10 @@ type StartSecurityUpdateResult = {
error: Error | null;
};
type PrepareExternalMCPResult = StartSecurityUpdateResult & {
attempted: boolean;
};
type MergeSecurityUpdateStatusOptions = {
previousStatus?: Partial<SecurityUpdateStatus> | null;
t?: SecureConfigBootstrapTranslator;
@@ -332,6 +337,13 @@ const cleanupLegacySourceIfCompleted = (
}
};
const shouldAutoStartLegacySecurityUpdate = (status: SecurityUpdateStatus): boolean => {
if (String(status.migrationId || '').trim() !== '') {
return false;
}
return status.overallStatus === 'not_detected' || status.overallStatus === 'pending';
};
export async function finalizeSecurityUpdateStatus(
args: SecureConfigBootstrapArgs,
rawStatus: Partial<SecurityUpdateStatus> | undefined,
@@ -350,15 +362,29 @@ export async function finalizeSecurityUpdateStatus(
export async function bootstrapSecureConfig(args: SecureConfigBootstrapArgs): Promise<SecureConfigBootstrapResult> {
const storage = resolveStorage(args.storage);
const rawPayload = storage?.getItem(LEGACY_PERSIST_KEY) ?? null;
const hasLegacySensitiveItems = hasLegacyMigratableSensitiveItems(rawPayload);
let rawPayload = storage?.getItem(LEGACY_PERSIST_KEY) ?? null;
let hasLegacySensitiveItems = hasLegacyMigratableSensitiveItems(rawPayload);
applyLegacyVisibleConfig(rawPayload, args.replaceConnections, args.replaceGlobalProxy);
const backendStatus = typeof args.backend?.GetSecurityUpdateStatus === 'function'
? await args.backend.GetSecurityUpdateStatus()
: undefined;
const status = mergeSecurityUpdateStatusWithLegacySource(backendStatus, rawPayload, { t: args.t });
let status = mergeSecurityUpdateStatusWithLegacySource(backendStatus, rawPayload, { t: args.t });
if (
hasLegacySensitiveItems
&& args.autoStartLegacySecurityUpdate === true
&& typeof args.backend?.StartSecurityUpdate === 'function'
&& shouldAutoStartLegacySecurityUpdate(status)
) {
const startResult = await startSecurityUpdateFromBootstrap(args);
if (!startResult.error && startResult.status) {
status = startResult.status;
rawPayload = storage?.getItem(LEGACY_PERSIST_KEY) ?? rawPayload;
hasLegacySensitiveItems = hasLegacyMigratableSensitiveItems(rawPayload);
}
}
if (!hasLegacySensitiveItems) {
await refreshVisibleConfigFromBackend(args.backend, args.replaceConnections, args.replaceGlobalProxy, true);
@@ -376,6 +402,25 @@ export async function bootstrapSecureConfig(args: SecureConfigBootstrapArgs): Pr
};
}
export async function prepareSecureConfigForExternalMCP(args: SecureConfigBootstrapArgs): Promise<PrepareExternalMCPResult> {
const storage = resolveStorage(args.storage);
const rawPayload = storage?.getItem(LEGACY_PERSIST_KEY) ?? null;
if (!hasLegacyMigratableSensitiveItems(rawPayload)) {
return {
attempted: false,
status: null,
error: null,
};
}
const result = await startSecurityUpdateFromBootstrap(args);
return {
attempted: true,
status: result.status,
error: result.error,
};
}
export async function startSecurityUpdateFromBootstrap(args: SecureConfigBootstrapArgs): Promise<StartSecurityUpdateResult> {
const storage = resolveStorage(args.storage);
const rawPayload = storage?.getItem(LEGACY_PERSIST_KEY) ?? null;
@@ -419,6 +464,7 @@ export async function startSecurityUpdateFromBootstrap(args: SecureConfigBootstr
export type {
BackendGlobalProxyResult,
MergeSecurityUpdateStatusOptions,
PrepareExternalMCPResult,
SecurityUpdateBackend,
SecureConfigBootstrapArgs,
SecureConfigBootstrapResult,