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:
parent
5da9e2372d
commit
8216d25eac
16 changed files with 461 additions and 17 deletions
|
|
@ -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
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ export type {
|
|||
McpAuthHeaderDef,
|
||||
McpCatalogItem,
|
||||
McpCustomInstallRequest,
|
||||
McpServerDiagnostic,
|
||||
McpServerHealthStatus,
|
||||
McpEnvVarDef,
|
||||
McpHeaderDef,
|
||||
McpHostingType,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue