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.
This commit is contained in:
iliya 2026-03-11 00:55:13 +02:00
parent 5da9e2372d
commit 8216d25eac
16 changed files with 461 additions and 17 deletions

View file

@ -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
);

View file

@ -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<IpcResult<McpServerDiagnostic[]>> {
return wrapHandler('mcpDiagnose', () => getMcpHealthDiagnostics().diagnose());
}
// ── Install/Uninstall Handlers ────────────────────────────────────────────
function getPluginInstaller(): PluginInstallService {

View file

@ -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);

View file

@ -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';

View file

@ -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<McpServerDiagnostic[]> {
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 };
}
}

View file

@ -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';

View file

@ -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<McpCatalogItem | null>(MCP_REGISTRY_GET_BY_ID, registryId),
getInstalled: (projectPath?: string) =>
invokeIpcWithResult<InstalledMcpEntry[]>(MCP_REGISTRY_GET_INSTALLED, projectPath),
diagnose: () => invokeIpcWithResult<McpServerDiagnostic[]>(MCP_REGISTRY_DIAGNOSE),
install: (request: McpInstallRequest) =>
invokeIpcWithResult<OperationResult>(MCP_REGISTRY_INSTALL, request),
installCustom: (request: McpCustomInstallRequest) =>

View file

@ -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<string, string> = {
@ -30,12 +30,16 @@ const RIBBON_STYLES: Record<string, string> = {
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 (
<div
@ -103,6 +115,19 @@ export const McpServerCard = ({
Installed
</Badge>
)}
{isInstalled && diagnosticsLoading && !diagnostic && (
<Badge
className="border-border bg-surface-raised text-text-muted"
variant="outline"
>
Checking...
</Badge>
)}
{diagnostic && (
<Badge className={diagnosticBadgeClass} variant="outline">
{diagnostic.statusLabel}
</Badge>
)}
</div>
</div>
</div>
@ -110,6 +135,11 @@ export const McpServerCard = ({
{/* Description */}
<p className="line-clamp-2 text-xs text-text-secondary">{server.description}</p>
{diagnostic?.target && (
<p className="truncate font-mono text-[10px] text-text-muted" title={diagnostic.target}>
{diagnostic.target}
</p>
)}
{/* Footer indicators + install button */}
<div className="flex items-center justify-between gap-2">

View file

@ -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.
</div>
)}
{(isInstalled || diagnosticsLoading) && (
<div className="space-y-2 rounded-md border border-border bg-surface-raised px-4 py-3">
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium text-text">Claude Status</span>
{diagnosticsLoading && !diagnostic ? (
<Badge
className="border-border bg-surface-raised text-text-muted"
variant="outline"
>
Checking...
</Badge>
) : diagnostic ? (
<Badge className={diagnosticBadgeClass} variant="outline">
{diagnostic.statusLabel}
</Badge>
) : (
<Badge
className="border-border bg-surface-raised text-text-muted"
variant="outline"
>
Not checked
</Badge>
)}
</div>
{diagnostic?.target && (
<div>
<p className="mb-1 text-xs text-text-muted">Launch Target</p>
<code className="block overflow-x-auto rounded bg-surface px-2 py-1 text-xs text-text">
{diagnostic.target}
</code>
</div>
)}
</div>
)}
{/* Install form */}
{canAutoInstall && (

View file

@ -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<McpSortValue>('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 (
<div className="flex flex-col gap-4">
<div className="rounded-md border border-black/10 bg-surface-raised px-4 py-3 dark:border-white/10">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-text">MCP Health Status</p>
<p className="text-xs text-text-muted">
{mcpDiagnosticsLoading ? (
<>
Checking installed MCP servers via Claude CLI (<code>claude mcp list</code>) ...
</>
) : mcpDiagnosticsLastCheckedAt ? (
`Last checked ${formatRelativeTime(new Date(mcpDiagnosticsLastCheckedAt).toISOString())}`
) : (
<>
Run diagnostics (<code>claude mcp list</code>) to verify installed MCP
connectivity.
</>
)}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => void runMcpDiagnostics()}
disabled={mcpDiagnosticsLoading}
className="whitespace-nowrap"
>
<RefreshCw
className={`mr-1.5 size-3.5 ${mcpDiagnosticsLoading ? 'animate-spin' : ''}`}
/>
{mcpDiagnosticsLoading ? 'Checking...' : 'Check Status'}
</Button>
</div>
{(mcpDiagnosticsLoading || allDiagnostics.length > 0) && (
<div className="mt-4 border-t border-black/10 pt-4 dark:border-white/10">
<div className="mb-3 flex items-center justify-between gap-3">
<p className="text-sm font-medium text-text">Claude MCP List Results</p>
{allDiagnostics.length > 0 && (
<span className="text-xs text-text-muted">{allDiagnostics.length} servers</span>
)}
</div>
{allDiagnostics.length > 0 ? (
<div className="max-h-[18.5rem] space-y-2 overflow-y-auto pr-1">
{allDiagnostics.map((diagnostic) => (
<div
key={diagnostic.name}
className="flex items-start justify-between gap-3 rounded-md border border-black/10 px-3 py-2 dark:border-white/10"
>
<div className="min-w-0 flex-1">
<p className="text-sm text-text">{diagnostic.name}</p>
<p
className="truncate font-mono text-[11px] text-text-muted"
title={diagnostic.target}
>
{diagnostic.target}
</p>
</div>
<Badge className={getDiagnosticBadgeClass(diagnostic.status)} variant="outline">
{diagnostic.statusLabel}
</Badge>
</div>
))}
</div>
) : (
<p className="text-xs text-text-muted">Waiting for `claude mcp list` results...</p>
)}
</div>
)}
</div>
{/* Search + Sort + Installed only row */}
<div className="flex items-center gap-3">
<div className="flex-1">
@ -217,6 +333,12 @@ export const McpServersPanel = ({
</div>
)}
{mcpDiagnosticsError && (
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-4 text-sm text-red-400">
{mcpDiagnosticsError}
</div>
)}
{/* Empty state */}
{!isLoading && displayServers.length === 0 && (
<div className="flex flex-col items-center gap-3 rounded-sm border border-dashed border-border px-8 py-16">
@ -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 = ({
<McpServerDetailDialog
server={selectedServer}
isInstalled={selectedServer ? isServerInstalled(selectedServer) : false}
diagnostic={selectedServer ? getDiagnostic(selectedServer) : null}
diagnosticsLoading={mcpDiagnosticsLoading}
open={selectedMcpServerId !== null}
onClose={() => setSelectedMcpServerId(null)}
/>

View file

@ -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<string, McpServerDiagnostic>;
mcpDiagnosticsLoading: boolean;
mcpDiagnosticsError: string | null;
mcpDiagnosticsLastCheckedAt: number | null;
// ── Install progress ──
pluginInstallProgress: Record<string, ExtensionOperationState>;
@ -62,6 +67,7 @@ export interface ExtensionsSlice {
fetchPluginReadme: (pluginId: string) => void;
mcpBrowse: (cursor?: string) => Promise<void>;
mcpFetchInstalled: (projectPath?: string) => Promise<void>;
runMcpDiagnostics: () => Promise<void>;
// ── Mutation actions ──
installPlugin: (request: PluginInstallRequest) => Promise<void>;
@ -93,6 +99,7 @@ export interface ExtensionsSlice {
// =============================================================================
let pluginFetchInFlight: Promise<void> | null = null;
let mcpDiagnosticsInFlight: Promise<void> | 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<AppState, [], [], ExtensionsSli
mcpBrowseError: null,
mcpInstalledServers: [],
mcpInstalledProjectPath: null,
mcpDiagnostics: {},
mcpDiagnosticsLoading: false,
mcpDiagnosticsError: null,
mcpDiagnosticsLastCheckedAt: null,
pluginInstallProgress: {},
mcpInstallProgress: {},
@ -235,6 +246,42 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
}
},
runMcpDiagnostics: async () => {
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<AppState, [], [], ExtensionsSli
return;
}
await Promise.all([
get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined),
get().runMcpDiagnostics(),
]);
set((prev) => ({
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<AppState, [], [], ExtensionsSli
return;
}
await Promise.all([
get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined),
get().runMcpDiagnostics(),
]);
set((prev) => ({
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<AppState, [], [], ExtensionsSli
return;
}
await Promise.all([
get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined),
get().runMcpDiagnostics(),
]);
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'success' },
}));
void get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined);
setTimeout(() => {
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'idle' },

View file

@ -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<McpCatalogItem | null>;
getInstalled: (projectPath?: string) => Promise<InstalledMcpEntry[]>;
diagnose: () => Promise<McpServerDiagnostic[]>;
install: (request: McpInstallRequest) => Promise<OperationResult>;
installCustom: (request: McpCustomInstallRequest) => Promise<OperationResult>;
uninstall: (name: string, scope?: string, projectPath?: string) => Promise<OperationResult>;

View file

@ -20,6 +20,8 @@ export type {
McpAuthHeaderDef,
McpCatalogItem,
McpCustomInstallRequest,
McpServerDiagnostic,
McpServerHealthStatus,
McpEnvVarDef,
McpHeaderDef,
McpHostingType,

View file

@ -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 {

View file

@ -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([]);
});
});

View file

@ -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<typeof vi.fn>).mockResolvedValue({ state: 'success' });
(api.mcpRegistry!.getInstalled as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(api.mcpRegistry!.diagnose as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue({ state: 'success' });
(api.mcpRegistry!.getInstalled as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(api.mcpRegistry!.diagnose as ReturnType<typeof vi.fn>).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');
});
});
});