mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-30 00:01:23 +08:00
🐛 fix(mcp): 修复外部 MCP 读取连接不完整
- 启动时自动同步旧本地连接到后端安全仓库 - 启动 HTTP MCP 或安装外部 MCP 前执行连接同步 - 增加安全配置同步回归测试 Fixes #591
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user