From 8216d25eace117f2439fb86558fadcbcac4c307d Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 11 Mar 2026 00:55:13 +0200 Subject: [PATCH] feat: integrate MCP health diagnostics functionality - Added McpHealthDiagnosticsService to manage health checks for MCP servers. - Implemented IPC channels for diagnosing MCP server health, including new MCP_REGISTRY_DIAGNOSE channel. - Enhanced UI components to display diagnostic status and results for installed MCP servers. - Updated state management to track MCP diagnostics loading state and errors. - Improved overall user experience with real-time feedback on MCP server connectivity and health status. --- src/main/index.ts | 3 + src/main/ipc/extensions.ts | 21 ++- src/main/ipc/handlers.ts | 10 +- src/main/services/extensions/index.ts | 1 + .../state/McpHealthDiagnosticsService.ts | 90 ++++++++++++ src/preload/constants/ipcChannels.ts | 3 + src/preload/index.ts | 3 + .../extensions/mcp/McpServerCard.tsx | 32 ++++- .../extensions/mcp/McpServerDetailDialog.tsx | 48 ++++++- .../extensions/mcp/McpServersPanel.tsx | 130 +++++++++++++++++- src/renderer/store/slices/extensionsSlice.ts | 70 ++++++++-- src/shared/types/extensions/api.ts | 2 + src/shared/types/extensions/index.ts | 2 + src/shared/types/extensions/mcp.ts | 11 ++ .../McpHealthDiagnosticsService.test.ts | 43 ++++++ test/renderer/store/extensionsSlice.test.ts | 9 +- 16 files changed, 461 insertions(+), 17 deletions(-) create mode 100644 src/main/services/extensions/state/McpHealthDiagnosticsService.ts create mode 100644 test/main/services/extensions/McpHealthDiagnosticsService.test.ts diff --git a/src/main/index.ts b/src/main/index.ts index 79e64197..505ee563 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -77,6 +77,7 @@ import { ExtensionFacadeService, GlamaMcpEnrichmentService, McpCatalogAggregator, + McpHealthDiagnosticsService, McpInstallationStateService, McpInstallService, OfficialMcpRegistryService, @@ -711,6 +712,7 @@ function initializeServices(): void { const glamaMcpService = new GlamaMcpEnrichmentService(); const mcpAggregator = new McpCatalogAggregator(officialMcpRegistry, glamaMcpService); const mcpStateService = new McpInstallationStateService(); + const mcpHealthDiagnosticsService = new McpHealthDiagnosticsService(null); const extensionFacadeService = new ExtensionFacadeService( pluginCatalogService, pluginStateService, @@ -783,6 +785,7 @@ function initializeServices(): void { pluginInstallService, mcpInstallService, apiKeyService, + mcpHealthDiagnosticsService, crossTeamService ); diff --git a/src/main/ipc/extensions.ts b/src/main/ipc/extensions.ts index 53411a2b..d2948f79 100644 --- a/src/main/ipc/extensions.ts +++ b/src/main/ipc/extensions.ts @@ -17,6 +17,7 @@ import type { McpCatalogItem, McpCustomInstallRequest, McpInstallRequest, + McpServerDiagnostic, McpSearchResult, OperationResult, PluginInstallRequest, @@ -27,6 +28,7 @@ import type { ExtensionFacadeService } from '../services/extensions/ExtensionFac import type { PluginInstallService } from '../services/extensions/install/PluginInstallService'; import type { McpInstallService } from '../services/extensions/install/McpInstallService'; import type { ApiKeyService } from '../services/extensions/apikeys/ApiKeyService'; +import type { McpHealthDiagnosticsService } from '../services/extensions/state/McpHealthDiagnosticsService'; import { API_KEYS_DELETE, @@ -36,6 +38,7 @@ import { API_KEYS_STORAGE_STATUS, MCP_GITHUB_STARS, MCP_REGISTRY_BROWSE, + MCP_REGISTRY_DIAGNOSE, MCP_REGISTRY_GET_BY_ID, MCP_REGISTRY_GET_INSTALLED, MCP_REGISTRY_INSTALL, @@ -63,6 +66,7 @@ let extensionFacade: ExtensionFacadeService | null = null; let pluginInstaller: PluginInstallService | null = null; let mcpInstaller: McpInstallService | null = null; let apiKeyService: ApiKeyService | null = null; +let mcpHealthDiagnostics: McpHealthDiagnosticsService | null = null; // ── Lifecycle ────────────────────────────────────────────────────────────── @@ -70,12 +74,14 @@ export function initializeExtensionHandlers( facade: ExtensionFacadeService, pluginInstall?: PluginInstallService, mcpInstall?: McpInstallService, - apiKeys?: ApiKeyService + apiKeys?: ApiKeyService, + mcpDiagnostics?: McpHealthDiagnosticsService ): void { extensionFacade = facade; pluginInstaller = pluginInstall ?? null; mcpInstaller = mcpInstall ?? null; apiKeyService = apiKeys ?? null; + mcpHealthDiagnostics = mcpDiagnostics ?? null; } export function registerExtensionHandlers(ipcMain: IpcMain): void { @@ -87,6 +93,7 @@ export function registerExtensionHandlers(ipcMain: IpcMain): void { ipcMain.handle(MCP_REGISTRY_BROWSE, handleMcpBrowse); ipcMain.handle(MCP_REGISTRY_GET_BY_ID, handleMcpGetById); ipcMain.handle(MCP_REGISTRY_GET_INSTALLED, handleMcpGetInstalled); + ipcMain.handle(MCP_REGISTRY_DIAGNOSE, handleMcpDiagnose); ipcMain.handle(MCP_REGISTRY_INSTALL, handleMcpInstall); ipcMain.handle(MCP_REGISTRY_INSTALL_CUSTOM, handleMcpInstallCustom); ipcMain.handle(MCP_REGISTRY_UNINSTALL, handleMcpUninstall); @@ -107,6 +114,7 @@ export function removeExtensionHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(MCP_REGISTRY_BROWSE); ipcMain.removeHandler(MCP_REGISTRY_GET_BY_ID); ipcMain.removeHandler(MCP_REGISTRY_GET_INSTALLED); + ipcMain.removeHandler(MCP_REGISTRY_DIAGNOSE); ipcMain.removeHandler(MCP_REGISTRY_INSTALL); ipcMain.removeHandler(MCP_REGISTRY_INSTALL_CUSTOM); ipcMain.removeHandler(MCP_REGISTRY_UNINSTALL); @@ -222,6 +230,17 @@ async function handleMcpGetInstalled( ); } +function getMcpHealthDiagnostics(): McpHealthDiagnosticsService { + if (!mcpHealthDiagnostics) { + throw new Error('MCP health diagnostics not initialized'); + } + return mcpHealthDiagnostics; +} + +async function handleMcpDiagnose(): Promise> { + return wrapHandler('mcpDiagnose', () => getMcpHealthDiagnostics().diagnose()); +} + // ── Install/Uninstall Handlers ──────────────────────────────────────────── function getPluginInstaller(): PluginInstallService { diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 0a733e85..5572e348 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -108,6 +108,7 @@ import type { ExtensionFacadeService } from '../services/extensions/ExtensionFac import type { McpInstallService } from '../services/extensions/install/McpInstallService'; import type { PluginInstallService } from '../services/extensions/install/PluginInstallService'; import type { ApiKeyService } from '../services/extensions/apikeys/ApiKeyService'; +import type { McpHealthDiagnosticsService } from '../services/extensions/state/McpHealthDiagnosticsService'; import type { SchedulerService } from '../services/schedule/SchedulerService'; /** @@ -141,6 +142,7 @@ export function initializeIpcHandlers( pluginInstaller?: PluginInstallService, mcpInstaller?: McpInstallService, apiKeyService?: ApiKeyService, + mcpHealthDiagnosticsService?: McpHealthDiagnosticsService, crossTeamService?: CrossTeamService ): void { // Initialize domain handlers with registry @@ -177,7 +179,13 @@ export function initializeIpcHandlers( initializeScheduleHandlers(schedulerService); } if (extensionFacade) { - initializeExtensionHandlers(extensionFacade, pluginInstaller, mcpInstaller, apiKeyService); + initializeExtensionHandlers( + extensionFacade, + pluginInstaller, + mcpInstaller, + apiKeyService, + mcpHealthDiagnosticsService + ); } if (crossTeamService) { initializeCrossTeamHandlers(crossTeamService); diff --git a/src/main/services/extensions/index.ts b/src/main/services/extensions/index.ts index 9e2517cb..d413f0aa 100644 --- a/src/main/services/extensions/index.ts +++ b/src/main/services/extensions/index.ts @@ -8,6 +8,7 @@ export { GlamaMcpEnrichmentService } from './catalog/GlamaMcpEnrichmentService'; export { McpCatalogAggregator } from './catalog/McpCatalogAggregator'; export { PluginInstallationStateService } from './state/PluginInstallationStateService'; export { McpInstallationStateService } from './state/McpInstallationStateService'; +export { McpHealthDiagnosticsService } from './state/McpHealthDiagnosticsService'; export { ExtensionFacadeService } from './ExtensionFacadeService'; export { PluginInstallService } from './install/PluginInstallService'; export { McpInstallService } from './install/McpInstallService'; diff --git a/src/main/services/extensions/state/McpHealthDiagnosticsService.ts b/src/main/services/extensions/state/McpHealthDiagnosticsService.ts new file mode 100644 index 00000000..2f6e0f47 --- /dev/null +++ b/src/main/services/extensions/state/McpHealthDiagnosticsService.ts @@ -0,0 +1,90 @@ +/** + * Runs `claude mcp list` and parses per-server health statuses. + */ + +import { execCli } from '@main/utils/childProcess'; +import { createLogger } from '@shared/utils/logger'; + +import type { McpServerDiagnostic, McpServerHealthStatus } from '@shared/types/extensions'; + +const logger = createLogger('Extensions:McpHealthDiagnostics'); + +const TIMEOUT_MS = 30_000; + +export class McpHealthDiagnosticsService { + constructor(private readonly claudeBinary: string | null) {} + + async diagnose(): Promise { + const { stdout, stderr } = await execCli(this.claudeBinary, ['mcp', 'list'], { + timeout: TIMEOUT_MS, + }); + + const output = [stdout, stderr].filter(Boolean).join('\n'); + const diagnostics = parseMcpDiagnosticsOutput(output); + + logger.info(`Parsed ${diagnostics.length} MCP diagnostic entries`); + return diagnostics; + } +} + +export function parseMcpDiagnosticsOutput(output: string): McpServerDiagnostic[] { + const checkedAt = Date.now(); + + return output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('Checking MCP server health')) + .map((line) => parseDiagnosticLine(line, checkedAt)) + .filter((entry): entry is McpServerDiagnostic => entry !== null); +} + +function parseDiagnosticLine(line: string, checkedAt: number): McpServerDiagnostic | null { + const statusSeparatorIdx = line.lastIndexOf(' - '); + if (statusSeparatorIdx === -1) { + return null; + } + + const descriptor = line.slice(0, statusSeparatorIdx).trim(); + const statusChunk = line.slice(statusSeparatorIdx + 3).trim(); + + const nameSeparatorIdx = descriptor.indexOf(': '); + if (nameSeparatorIdx === -1) { + return null; + } + + const name = descriptor.slice(0, nameSeparatorIdx).trim(); + const target = descriptor.slice(nameSeparatorIdx + 2).trim(); + if (!name || !target) { + return null; + } + + const { status, statusLabel } = parseStatusChunk(statusChunk); + + return { + name, + target, + status, + statusLabel, + rawLine: line, + checkedAt, + }; +} + +function parseStatusChunk(statusChunk: string): { + status: McpServerHealthStatus; + statusLabel: string; +} { + const symbol = statusChunk[0]; + const label = statusChunk.slice(1).trim() || 'Unknown'; + + switch (symbol) { + case '✓': + return { status: 'connected', statusLabel: label }; + case '!': + return { status: 'needs-authentication', statusLabel: label }; + case '✗': + return { status: 'failed', statusLabel: label }; + default: + return { status: 'unknown', statusLabel: statusChunk }; + } +} diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 48e543ab..5cad249e 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -594,6 +594,9 @@ export const MCP_REGISTRY_GET_BY_ID = 'mcpRegistry:getById'; /** Get installed MCP servers */ export const MCP_REGISTRY_GET_INSTALLED = 'mcpRegistry:getInstalled'; +/** Run Claude CLI MCP health diagnostics */ +export const MCP_REGISTRY_DIAGNOSE = 'mcpRegistry:diagnose'; + /** Install a plugin */ export const PLUGIN_INSTALL = 'plugin:install'; diff --git a/src/preload/index.ts b/src/preload/index.ts index aabd845e..6cb6f82a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -151,6 +151,7 @@ import { PLUGIN_UNINSTALL, MCP_REGISTRY_SEARCH, MCP_REGISTRY_BROWSE, + MCP_REGISTRY_DIAGNOSE, MCP_REGISTRY_GET_BY_ID, MCP_REGISTRY_GET_INSTALLED, MCP_REGISTRY_INSTALL, @@ -275,6 +276,7 @@ import type { McpCatalogItem, McpCustomInstallRequest, McpInstallRequest, + McpServerDiagnostic, McpSearchResult, OperationResult, PluginInstallRequest, @@ -1395,6 +1397,7 @@ const electronAPI: ElectronAPI = { invokeIpcWithResult(MCP_REGISTRY_GET_BY_ID, registryId), getInstalled: (projectPath?: string) => invokeIpcWithResult(MCP_REGISTRY_GET_INSTALLED, projectPath), + diagnose: () => invokeIpcWithResult(MCP_REGISTRY_DIAGNOSE), install: (request: McpInstallRequest) => invokeIpcWithResult(MCP_REGISTRY_INSTALL, request), installCustom: (request: McpCustomInstallRequest) => diff --git a/src/renderer/components/extensions/mcp/McpServerCard.tsx b/src/renderer/components/extensions/mcp/McpServerCard.tsx index d09e64b9..cc3e2dfd 100644 --- a/src/renderer/components/extensions/mcp/McpServerCard.tsx +++ b/src/renderer/components/extensions/mcp/McpServerCard.tsx @@ -19,7 +19,7 @@ import { Github as GithubIcon } from 'lucide-react'; import { InstallButton } from '../common/InstallButton'; import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers'; -import type { McpCatalogItem } from '@shared/types/extensions'; +import type { McpCatalogItem, McpServerDiagnostic } from '@shared/types/extensions'; /** Ribbon colors by source */ const RIBBON_STYLES: Record = { @@ -30,12 +30,16 @@ const RIBBON_STYLES: Record = { interface McpServerCardProps { server: McpCatalogItem; isInstalled: boolean; + diagnostic?: McpServerDiagnostic | null; + diagnosticsLoading?: boolean; onClick: (serverId: string) => void; } export const McpServerCard = ({ server, isInstalled, + diagnostic, + diagnosticsLoading, onClick, }: McpServerCardProps): React.JSX.Element => { const installProgress = useStore((s) => s.mcpInstallProgress[server.id] ?? 'idle'); @@ -53,6 +57,14 @@ export const McpServerCard = ({ (server.authHeaders?.length ?? 0) > 0; const [imgError, setImgError] = useState(false); const hasIcon = !!server.iconUrl && !imgError; + const diagnosticBadgeClass = + diagnostic?.status === 'connected' + ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400' + : diagnostic?.status === 'needs-authentication' + ? 'border-amber-500/30 bg-amber-500/10 text-amber-400' + : diagnostic?.status === 'failed' + ? 'border-red-500/30 bg-red-500/10 text-red-400' + : 'border-border bg-surface-raised text-text-muted'; return (
)} + {isInstalled && diagnosticsLoading && !diagnostic && ( + + Checking... + + )} + {diagnostic && ( + + {diagnostic.statusLabel} + + )}
@@ -110,6 +135,11 @@ export const McpServerCard = ({ {/* Description */}

{server.description}

+ {diagnostic?.target && ( +

+ {diagnostic.target} +

+ )} {/* Footer indicators + install button */}
diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx index c7b72d29..4af81ab8 100644 --- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx @@ -31,11 +31,13 @@ import { InstallButton } from '../common/InstallButton'; import { SourceBadge } from '../common/SourceBadge'; import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers'; -import type { McpCatalogItem, McpHeaderDef } from '@shared/types/extensions'; +import type { McpCatalogItem, McpHeaderDef, McpServerDiagnostic } from '@shared/types/extensions'; interface McpServerDetailDialogProps { server: McpCatalogItem | null; isInstalled: boolean; + diagnostic?: McpServerDiagnostic | null; + diagnosticsLoading?: boolean; open: boolean; onClose: () => void; } @@ -50,6 +52,8 @@ const SCOPE_OPTIONS: { value: Scope; label: string }[] = [ export const McpServerDetailDialog = ({ server, isInstalled, + diagnostic, + diagnosticsLoading, open, onClose, }: McpServerDetailDialogProps): React.JSX.Element => { @@ -166,6 +170,14 @@ export const McpServerDetailDialog = ({ (header) => header.isRequired && !header.value.trim() ); const installDisabled = !serverName.trim() || missingRequiredEnvVars || missingRequiredHeaders; + const diagnosticBadgeClass = + diagnostic?.status === 'connected' + ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400' + : diagnostic?.status === 'needs-authentication' + ? 'border-amber-500/30 bg-amber-500/10 text-amber-400' + : diagnostic?.status === 'failed' + ? 'border-red-500/30 bg-red-500/10 text-red-400' + : 'border-border bg-surface-raised text-text-muted'; const handleInstall = () => { installMcpServer({ @@ -315,6 +327,40 @@ export const McpServerDetailDialog = ({ does not describe them. If connection fails after install, check the provider docs.
)} + {(isInstalled || diagnosticsLoading) && ( +
+
+ Claude Status + {diagnosticsLoading && !diagnostic ? ( + + Checking... + + ) : diagnostic ? ( + + {diagnostic.statusLabel} + + ) : ( + + Not checked + + )} +
+ {diagnostic?.target && ( +
+

Launch Target

+ + {diagnostic.target} + +
+ )} +
+ )} {/* Install form */} {canAutoInstall && ( diff --git a/src/renderer/components/extensions/mcp/McpServersPanel.tsx b/src/renderer/components/extensions/mcp/McpServersPanel.tsx index e9e4ffb5..ef0bf85f 100644 --- a/src/renderer/components/extensions/mcp/McpServersPanel.tsx +++ b/src/renderer/components/extensions/mcp/McpServersPanel.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react'; +import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; import { Checkbox } from '@renderer/components/ui/checkbox'; import { Label } from '@renderer/components/ui/label'; @@ -15,14 +16,19 @@ import { SelectValue, } from '@renderer/components/ui/select'; import { useStore } from '@renderer/store'; -import { AlertTriangle, Search, Server } from 'lucide-react'; +import { formatRelativeTime } from '@renderer/utils/formatters'; +import { AlertTriangle, RefreshCw, Search, Server } from 'lucide-react'; import { SearchInput } from '../common/SearchInput'; import { McpServerCard } from './McpServerCard'; import { McpServerDetailDialog } from './McpServerDetailDialog'; -import type { McpCatalogItem } from '@shared/types/extensions'; +import type { + InstalledMcpEntry, + McpCatalogItem, + McpServerDiagnostic, +} from '@shared/types/extensions'; import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers'; type McpSortValue = 'name-asc' | 'name-desc' | 'tools-desc'; @@ -74,6 +80,11 @@ export const McpServersPanel = ({ const mcpBrowse = useStore((s) => s.mcpBrowse); const installedServers = useStore((s) => s.mcpInstalledServers); const fetchMcpGitHubStars = useStore((s) => s.fetchMcpGitHubStars); + const mcpDiagnostics = useStore((s) => s.mcpDiagnostics); + const mcpDiagnosticsLoading = useStore((s) => s.mcpDiagnosticsLoading); + const mcpDiagnosticsError = useStore((s) => s.mcpDiagnosticsError); + const mcpDiagnosticsLastCheckedAt = useStore((s) => s.mcpDiagnosticsLastCheckedAt); + const runMcpDiagnostics = useStore((s) => s.runMcpDiagnostics); const [mcpSort, setMcpSort] = useState('name-asc'); const [mcpInstalledOnly, setMcpInstalledOnly] = useState(false); @@ -85,6 +96,10 @@ export const McpServersPanel = ({ } }, [browseCatalog.length, browseLoading, mcpBrowse]); + useEffect(() => { + void runMcpDiagnostics(); + }, [runMcpDiagnostics]); + // Fetch GitHub stars after catalog loads (fire-and-forget) useEffect(() => { const urls = browseCatalog.map((s) => s.repositoryUrl).filter((u): u is string => !!u); @@ -105,10 +120,41 @@ export const McpServersPanel = ({ [installedServers] ); + const installedEntriesByName = useMemo( + () => new Map(installedServers.map((entry) => [entry.name.toLowerCase(), entry] as const)), + [installedServers] + ); + /** Check if a catalog server is installed by comparing sanitized names */ const isServerInstalled = (server: McpCatalogItem): boolean => installedNames.has(sanitizeMcpServerName(server.name)); + const getInstalledEntry = (server: McpCatalogItem): InstalledMcpEntry | null => + installedEntriesByName.get(sanitizeMcpServerName(server.name)) ?? null; + + const getDiagnostic = (server: McpCatalogItem): McpServerDiagnostic | null => { + const installedEntry = getInstalledEntry(server); + return installedEntry ? (mcpDiagnostics[installedEntry.name] ?? null) : null; + }; + + const allDiagnostics = useMemo( + () => Object.values(mcpDiagnostics).sort((a, b) => a.name.localeCompare(b.name)), + [mcpDiagnostics] + ); + + const getDiagnosticBadgeClass = (status: McpServerDiagnostic['status']): string => { + switch (status) { + case 'connected': + return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400'; + case 'needs-authentication': + return 'border-amber-500/30 bg-amber-500/10 text-amber-400'; + case 'failed': + return 'border-red-500/30 bg-red-500/10 text-red-400'; + default: + return 'border-border bg-surface-raised text-text-muted'; + } + }; + // Sort + filter const displayServers = useMemo(() => { let result = rawServers; @@ -131,6 +177,76 @@ export const McpServersPanel = ({ return (
+
+
+
+

MCP Health Status

+

+ {mcpDiagnosticsLoading ? ( + <> + Checking installed MCP servers via Claude CLI (claude mcp list) ... + + ) : mcpDiagnosticsLastCheckedAt ? ( + `Last checked ${formatRelativeTime(new Date(mcpDiagnosticsLastCheckedAt).toISOString())}` + ) : ( + <> + Run diagnostics (claude mcp list) to verify installed MCP + connectivity. + + )} +

+
+ +
+ + {(mcpDiagnosticsLoading || allDiagnostics.length > 0) && ( +
+
+

Claude MCP List Results

+ {allDiagnostics.length > 0 && ( + {allDiagnostics.length} servers + )} +
+ {allDiagnostics.length > 0 ? ( +
+ {allDiagnostics.map((diagnostic) => ( +
+
+

{diagnostic.name}

+

+ {diagnostic.target} +

+
+ + {diagnostic.statusLabel} + +
+ ))} +
+ ) : ( +

Waiting for `claude mcp list` results...

+ )} +
+ )} +
+ {/* Search + Sort + Installed only row */}
@@ -217,6 +333,12 @@ export const McpServersPanel = ({
)} + {mcpDiagnosticsError && ( +
+ {mcpDiagnosticsError} +
+ )} + {/* Empty state */} {!isLoading && displayServers.length === 0 && (
@@ -251,6 +373,8 @@ export const McpServersPanel = ({ key={server.id} server={server} isInstalled={isServerInstalled(server)} + diagnostic={getDiagnostic(server)} + diagnosticsLoading={mcpDiagnosticsLoading} onClick={setSelectedMcpServerId} /> ))} @@ -275,6 +399,8 @@ export const McpServersPanel = ({ setSelectedMcpServerId(null)} /> diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index f4574549..58df5d40 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -17,6 +17,7 @@ import type { McpCatalogItem, McpCustomInstallRequest, McpInstallRequest, + McpServerDiagnostic, PluginInstallRequest, } from '@shared/types/extensions'; import type { StateCreator } from 'zustand'; @@ -41,6 +42,10 @@ export interface ExtensionsSlice { mcpBrowseError: string | null; mcpInstalledServers: InstalledMcpEntry[]; mcpInstalledProjectPath: string | null; + mcpDiagnostics: Record; + mcpDiagnosticsLoading: boolean; + mcpDiagnosticsError: string | null; + mcpDiagnosticsLastCheckedAt: number | null; // ── Install progress ── pluginInstallProgress: Record; @@ -62,6 +67,7 @@ export interface ExtensionsSlice { fetchPluginReadme: (pluginId: string) => void; mcpBrowse: (cursor?: string) => Promise; mcpFetchInstalled: (projectPath?: string) => Promise; + runMcpDiagnostics: () => Promise; // ── Mutation actions ── installPlugin: (request: PluginInstallRequest) => Promise; @@ -93,6 +99,7 @@ export interface ExtensionsSlice { // ============================================================================= let pluginFetchInFlight: Promise | null = null; +let mcpDiagnosticsInFlight: Promise | null = null; /** Duration to show "success" state before returning to idle */ const SUCCESS_DISPLAY_MS = 2_000; @@ -115,6 +122,10 @@ export const createExtensionsSlice: StateCreator { + const mcpRegistry = api.mcpRegistry; + if (!mcpRegistry) return; + + if (mcpDiagnosticsInFlight) { + await mcpDiagnosticsInFlight; + return; + } + + set({ mcpDiagnosticsLoading: true, mcpDiagnosticsError: null }); + + const promise = (async () => { + try { + const diagnostics = await mcpRegistry.diagnose(); + set({ + mcpDiagnostics: Object.fromEntries( + diagnostics.map((entry) => [entry.name, entry] as const) + ), + mcpDiagnosticsLoading: false, + mcpDiagnosticsLastCheckedAt: Date.now(), + }); + } catch (err) { + set({ + mcpDiagnosticsLoading: false, + mcpDiagnosticsError: + err instanceof Error ? err.message : 'Failed to check MCP server health', + }); + } finally { + mcpDiagnosticsInFlight = null; + } + })(); + + mcpDiagnosticsInFlight = promise; + await promise; + }, + // ── Plugin install ── installPlugin: async (request: PluginInstallRequest) => { if (!api.plugins) return; @@ -347,13 +394,15 @@ export const createExtensionsSlice: StateCreator ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'success' }, })); - // Refresh installed list - void get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined); - setTimeout(() => { set((prev) => ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'idle' }, @@ -394,13 +443,15 @@ export const createExtensionsSlice: StateCreator ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'success' }, })); - // Refresh installed list - void get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined); - setTimeout(() => { set((prev) => ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'idle' }, @@ -447,12 +498,15 @@ export const createExtensionsSlice: StateCreator ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'success' }, })); - void get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined); - setTimeout(() => { set((prev) => ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'idle' }, diff --git a/src/shared/types/extensions/api.ts b/src/shared/types/extensions/api.ts index d4121bdf..76fde65f 100644 --- a/src/shared/types/extensions/api.ts +++ b/src/shared/types/extensions/api.ts @@ -16,6 +16,7 @@ import type { McpCatalogItem, McpCustomInstallRequest, McpInstallRequest, + McpServerDiagnostic, McpSearchResult, } from './mcp'; @@ -42,6 +43,7 @@ export interface McpCatalogAPI { ) => Promise<{ servers: McpCatalogItem[]; nextCursor?: string }>; getById: (registryId: string) => Promise; getInstalled: (projectPath?: string) => Promise; + diagnose: () => Promise; install: (request: McpInstallRequest) => Promise; installCustom: (request: McpCustomInstallRequest) => Promise; uninstall: (name: string, scope?: string, projectPath?: string) => Promise; diff --git a/src/shared/types/extensions/index.ts b/src/shared/types/extensions/index.ts index e91d3f21..689423ee 100644 --- a/src/shared/types/extensions/index.ts +++ b/src/shared/types/extensions/index.ts @@ -20,6 +20,8 @@ export type { McpAuthHeaderDef, McpCatalogItem, McpCustomInstallRequest, + McpServerDiagnostic, + McpServerHealthStatus, McpEnvVarDef, McpHeaderDef, McpHostingType, diff --git a/src/shared/types/extensions/mcp.ts b/src/shared/types/extensions/mcp.ts index ef05d644..577bd32a 100644 --- a/src/shared/types/extensions/mcp.ts +++ b/src/shared/types/extensions/mcp.ts @@ -87,6 +87,17 @@ export interface InstalledMcpEntry { transport?: string; } +export type McpServerHealthStatus = 'connected' | 'needs-authentication' | 'failed' | 'unknown'; + +export interface McpServerDiagnostic { + name: string; + target: string; + status: McpServerHealthStatus; + statusLabel: string; + rawLine: string; + checkedAt: number; +} + // ── Install request (renderer → main, minimal trusted data) ──────────────── export interface McpInstallRequest { diff --git a/test/main/services/extensions/McpHealthDiagnosticsService.test.ts b/test/main/services/extensions/McpHealthDiagnosticsService.test.ts new file mode 100644 index 00000000..bb239d68 --- /dev/null +++ b/test/main/services/extensions/McpHealthDiagnosticsService.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { parseMcpDiagnosticsOutput } from '@main/services/extensions/state/McpHealthDiagnosticsService'; + +describe('parseMcpDiagnosticsOutput', () => { + it('parses mixed MCP health lines from claude mcp list', () => { + const diagnostics = parseMcpDiagnosticsOutput(`Checking MCP server health... + +plugin:context7:context7: npx -y @upstash/context7-mcp - ✓ Connected +plugin:figma:figma: https://mcp.figma.com/mcp (HTTP) - ✓ Connected +browsermcp: npx @browsermcp/mcp@latest - ✓ Connected +tavily-remote-mcp: npx -y mcp-remote https://mcp.tavily.com/mcp/?tavilyApiKey=test - ✗ Failed to connect +alpic: https://mcp.alpic.ai (HTTP) - ! Needs authentication`); + + expect(diagnostics).toHaveLength(5); + expect(diagnostics[0]).toMatchObject({ + name: 'plugin:context7:context7', + target: 'npx -y @upstash/context7-mcp', + status: 'connected', + statusLabel: 'Connected', + }); + expect(diagnostics[3]).toMatchObject({ + name: 'tavily-remote-mcp', + target: 'npx -y mcp-remote https://mcp.tavily.com/mcp/?tavilyApiKey=test', + status: 'failed', + statusLabel: 'Failed to connect', + }); + expect(diagnostics[4]).toMatchObject({ + name: 'alpic', + target: 'https://mcp.alpic.ai (HTTP)', + status: 'needs-authentication', + statusLabel: 'Needs authentication', + }); + }); + + it('ignores lines that do not look like MCP status rows', () => { + const diagnostics = parseMcpDiagnosticsOutput(`Checking MCP server health... +random log line +another log line`); + + expect(diagnostics).toEqual([]); + }); +}); diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index a85cb0ca..80b175aa 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -20,6 +20,7 @@ vi.mock('../../../src/renderer/api', () => ({ browse: vi.fn(), getById: vi.fn(), getInstalled: vi.fn(), + diagnose: vi.fn(), install: vi.fn(), uninstall: vi.fn(), }, @@ -241,6 +242,7 @@ describe('extensionsSlice', () => { it('sets progress to pending then success', async () => { (api.mcpRegistry!.install as ReturnType).mockResolvedValue({ state: 'success' }); (api.mcpRegistry!.getInstalled as ReturnType).mockResolvedValue([]); + (api.mcpRegistry!.diagnose as ReturnType).mockResolvedValue([]); const promise = store.getState().installMcpServer({ registryId: 'test-id', @@ -261,13 +263,14 @@ describe('extensionsSlice', () => { it('sets progress to pending then success', async () => { (api.mcpRegistry!.uninstall as ReturnType).mockResolvedValue({ state: 'success' }); (api.mcpRegistry!.getInstalled as ReturnType).mockResolvedValue([]); + (api.mcpRegistry!.diagnose as ReturnType).mockResolvedValue([]); - const promise = store.getState().uninstallMcpServer('test-server', 'user'); + const promise = store.getState().uninstallMcpServer('test-id', 'test-server', 'user'); - expect(store.getState().mcpInstallProgress['test-server']).toBe('pending'); + expect(store.getState().mcpInstallProgress['test-id']).toBe('pending'); await promise; - expect(store.getState().mcpInstallProgress['test-server']).toBe('success'); + expect(store.getState().mcpInstallProgress['test-id']).toBe('success'); }); }); });