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,
|
ExtensionFacadeService,
|
||||||
GlamaMcpEnrichmentService,
|
GlamaMcpEnrichmentService,
|
||||||
McpCatalogAggregator,
|
McpCatalogAggregator,
|
||||||
|
McpHealthDiagnosticsService,
|
||||||
McpInstallationStateService,
|
McpInstallationStateService,
|
||||||
McpInstallService,
|
McpInstallService,
|
||||||
OfficialMcpRegistryService,
|
OfficialMcpRegistryService,
|
||||||
|
|
@ -711,6 +712,7 @@ function initializeServices(): void {
|
||||||
const glamaMcpService = new GlamaMcpEnrichmentService();
|
const glamaMcpService = new GlamaMcpEnrichmentService();
|
||||||
const mcpAggregator = new McpCatalogAggregator(officialMcpRegistry, glamaMcpService);
|
const mcpAggregator = new McpCatalogAggregator(officialMcpRegistry, glamaMcpService);
|
||||||
const mcpStateService = new McpInstallationStateService();
|
const mcpStateService = new McpInstallationStateService();
|
||||||
|
const mcpHealthDiagnosticsService = new McpHealthDiagnosticsService(null);
|
||||||
const extensionFacadeService = new ExtensionFacadeService(
|
const extensionFacadeService = new ExtensionFacadeService(
|
||||||
pluginCatalogService,
|
pluginCatalogService,
|
||||||
pluginStateService,
|
pluginStateService,
|
||||||
|
|
@ -783,6 +785,7 @@ function initializeServices(): void {
|
||||||
pluginInstallService,
|
pluginInstallService,
|
||||||
mcpInstallService,
|
mcpInstallService,
|
||||||
apiKeyService,
|
apiKeyService,
|
||||||
|
mcpHealthDiagnosticsService,
|
||||||
crossTeamService
|
crossTeamService
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import type {
|
||||||
McpCatalogItem,
|
McpCatalogItem,
|
||||||
McpCustomInstallRequest,
|
McpCustomInstallRequest,
|
||||||
McpInstallRequest,
|
McpInstallRequest,
|
||||||
|
McpServerDiagnostic,
|
||||||
McpSearchResult,
|
McpSearchResult,
|
||||||
OperationResult,
|
OperationResult,
|
||||||
PluginInstallRequest,
|
PluginInstallRequest,
|
||||||
|
|
@ -27,6 +28,7 @@ import type { ExtensionFacadeService } from '../services/extensions/ExtensionFac
|
||||||
import type { PluginInstallService } from '../services/extensions/install/PluginInstallService';
|
import type { PluginInstallService } from '../services/extensions/install/PluginInstallService';
|
||||||
import type { McpInstallService } from '../services/extensions/install/McpInstallService';
|
import type { McpInstallService } from '../services/extensions/install/McpInstallService';
|
||||||
import type { ApiKeyService } from '../services/extensions/apikeys/ApiKeyService';
|
import type { ApiKeyService } from '../services/extensions/apikeys/ApiKeyService';
|
||||||
|
import type { McpHealthDiagnosticsService } from '../services/extensions/state/McpHealthDiagnosticsService';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
API_KEYS_DELETE,
|
API_KEYS_DELETE,
|
||||||
|
|
@ -36,6 +38,7 @@ import {
|
||||||
API_KEYS_STORAGE_STATUS,
|
API_KEYS_STORAGE_STATUS,
|
||||||
MCP_GITHUB_STARS,
|
MCP_GITHUB_STARS,
|
||||||
MCP_REGISTRY_BROWSE,
|
MCP_REGISTRY_BROWSE,
|
||||||
|
MCP_REGISTRY_DIAGNOSE,
|
||||||
MCP_REGISTRY_GET_BY_ID,
|
MCP_REGISTRY_GET_BY_ID,
|
||||||
MCP_REGISTRY_GET_INSTALLED,
|
MCP_REGISTRY_GET_INSTALLED,
|
||||||
MCP_REGISTRY_INSTALL,
|
MCP_REGISTRY_INSTALL,
|
||||||
|
|
@ -63,6 +66,7 @@ let extensionFacade: ExtensionFacadeService | null = null;
|
||||||
let pluginInstaller: PluginInstallService | null = null;
|
let pluginInstaller: PluginInstallService | null = null;
|
||||||
let mcpInstaller: McpInstallService | null = null;
|
let mcpInstaller: McpInstallService | null = null;
|
||||||
let apiKeyService: ApiKeyService | null = null;
|
let apiKeyService: ApiKeyService | null = null;
|
||||||
|
let mcpHealthDiagnostics: McpHealthDiagnosticsService | null = null;
|
||||||
|
|
||||||
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -70,12 +74,14 @@ export function initializeExtensionHandlers(
|
||||||
facade: ExtensionFacadeService,
|
facade: ExtensionFacadeService,
|
||||||
pluginInstall?: PluginInstallService,
|
pluginInstall?: PluginInstallService,
|
||||||
mcpInstall?: McpInstallService,
|
mcpInstall?: McpInstallService,
|
||||||
apiKeys?: ApiKeyService
|
apiKeys?: ApiKeyService,
|
||||||
|
mcpDiagnostics?: McpHealthDiagnosticsService
|
||||||
): void {
|
): void {
|
||||||
extensionFacade = facade;
|
extensionFacade = facade;
|
||||||
pluginInstaller = pluginInstall ?? null;
|
pluginInstaller = pluginInstall ?? null;
|
||||||
mcpInstaller = mcpInstall ?? null;
|
mcpInstaller = mcpInstall ?? null;
|
||||||
apiKeyService = apiKeys ?? null;
|
apiKeyService = apiKeys ?? null;
|
||||||
|
mcpHealthDiagnostics = mcpDiagnostics ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerExtensionHandlers(ipcMain: IpcMain): void {
|
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_BROWSE, handleMcpBrowse);
|
||||||
ipcMain.handle(MCP_REGISTRY_GET_BY_ID, handleMcpGetById);
|
ipcMain.handle(MCP_REGISTRY_GET_BY_ID, handleMcpGetById);
|
||||||
ipcMain.handle(MCP_REGISTRY_GET_INSTALLED, handleMcpGetInstalled);
|
ipcMain.handle(MCP_REGISTRY_GET_INSTALLED, handleMcpGetInstalled);
|
||||||
|
ipcMain.handle(MCP_REGISTRY_DIAGNOSE, handleMcpDiagnose);
|
||||||
ipcMain.handle(MCP_REGISTRY_INSTALL, handleMcpInstall);
|
ipcMain.handle(MCP_REGISTRY_INSTALL, handleMcpInstall);
|
||||||
ipcMain.handle(MCP_REGISTRY_INSTALL_CUSTOM, handleMcpInstallCustom);
|
ipcMain.handle(MCP_REGISTRY_INSTALL_CUSTOM, handleMcpInstallCustom);
|
||||||
ipcMain.handle(MCP_REGISTRY_UNINSTALL, handleMcpUninstall);
|
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_BROWSE);
|
||||||
ipcMain.removeHandler(MCP_REGISTRY_GET_BY_ID);
|
ipcMain.removeHandler(MCP_REGISTRY_GET_BY_ID);
|
||||||
ipcMain.removeHandler(MCP_REGISTRY_GET_INSTALLED);
|
ipcMain.removeHandler(MCP_REGISTRY_GET_INSTALLED);
|
||||||
|
ipcMain.removeHandler(MCP_REGISTRY_DIAGNOSE);
|
||||||
ipcMain.removeHandler(MCP_REGISTRY_INSTALL);
|
ipcMain.removeHandler(MCP_REGISTRY_INSTALL);
|
||||||
ipcMain.removeHandler(MCP_REGISTRY_INSTALL_CUSTOM);
|
ipcMain.removeHandler(MCP_REGISTRY_INSTALL_CUSTOM);
|
||||||
ipcMain.removeHandler(MCP_REGISTRY_UNINSTALL);
|
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 ────────────────────────────────────────────
|
// ── Install/Uninstall Handlers ────────────────────────────────────────────
|
||||||
|
|
||||||
function getPluginInstaller(): PluginInstallService {
|
function getPluginInstaller(): PluginInstallService {
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,7 @@ import type { ExtensionFacadeService } from '../services/extensions/ExtensionFac
|
||||||
import type { McpInstallService } from '../services/extensions/install/McpInstallService';
|
import type { McpInstallService } from '../services/extensions/install/McpInstallService';
|
||||||
import type { PluginInstallService } from '../services/extensions/install/PluginInstallService';
|
import type { PluginInstallService } from '../services/extensions/install/PluginInstallService';
|
||||||
import type { ApiKeyService } from '../services/extensions/apikeys/ApiKeyService';
|
import type { ApiKeyService } from '../services/extensions/apikeys/ApiKeyService';
|
||||||
|
import type { McpHealthDiagnosticsService } from '../services/extensions/state/McpHealthDiagnosticsService';
|
||||||
import type { SchedulerService } from '../services/schedule/SchedulerService';
|
import type { SchedulerService } from '../services/schedule/SchedulerService';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -141,6 +142,7 @@ export function initializeIpcHandlers(
|
||||||
pluginInstaller?: PluginInstallService,
|
pluginInstaller?: PluginInstallService,
|
||||||
mcpInstaller?: McpInstallService,
|
mcpInstaller?: McpInstallService,
|
||||||
apiKeyService?: ApiKeyService,
|
apiKeyService?: ApiKeyService,
|
||||||
|
mcpHealthDiagnosticsService?: McpHealthDiagnosticsService,
|
||||||
crossTeamService?: CrossTeamService
|
crossTeamService?: CrossTeamService
|
||||||
): void {
|
): void {
|
||||||
// Initialize domain handlers with registry
|
// Initialize domain handlers with registry
|
||||||
|
|
@ -177,7 +179,13 @@ export function initializeIpcHandlers(
|
||||||
initializeScheduleHandlers(schedulerService);
|
initializeScheduleHandlers(schedulerService);
|
||||||
}
|
}
|
||||||
if (extensionFacade) {
|
if (extensionFacade) {
|
||||||
initializeExtensionHandlers(extensionFacade, pluginInstaller, mcpInstaller, apiKeyService);
|
initializeExtensionHandlers(
|
||||||
|
extensionFacade,
|
||||||
|
pluginInstaller,
|
||||||
|
mcpInstaller,
|
||||||
|
apiKeyService,
|
||||||
|
mcpHealthDiagnosticsService
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (crossTeamService) {
|
if (crossTeamService) {
|
||||||
initializeCrossTeamHandlers(crossTeamService);
|
initializeCrossTeamHandlers(crossTeamService);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ export { GlamaMcpEnrichmentService } from './catalog/GlamaMcpEnrichmentService';
|
||||||
export { McpCatalogAggregator } from './catalog/McpCatalogAggregator';
|
export { McpCatalogAggregator } from './catalog/McpCatalogAggregator';
|
||||||
export { PluginInstallationStateService } from './state/PluginInstallationStateService';
|
export { PluginInstallationStateService } from './state/PluginInstallationStateService';
|
||||||
export { McpInstallationStateService } from './state/McpInstallationStateService';
|
export { McpInstallationStateService } from './state/McpInstallationStateService';
|
||||||
|
export { McpHealthDiagnosticsService } from './state/McpHealthDiagnosticsService';
|
||||||
export { ExtensionFacadeService } from './ExtensionFacadeService';
|
export { ExtensionFacadeService } from './ExtensionFacadeService';
|
||||||
export { PluginInstallService } from './install/PluginInstallService';
|
export { PluginInstallService } from './install/PluginInstallService';
|
||||||
export { McpInstallService } from './install/McpInstallService';
|
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 */
|
/** Get installed MCP servers */
|
||||||
export const MCP_REGISTRY_GET_INSTALLED = 'mcpRegistry:getInstalled';
|
export const MCP_REGISTRY_GET_INSTALLED = 'mcpRegistry:getInstalled';
|
||||||
|
|
||||||
|
/** Run Claude CLI MCP health diagnostics */
|
||||||
|
export const MCP_REGISTRY_DIAGNOSE = 'mcpRegistry:diagnose';
|
||||||
|
|
||||||
/** Install a plugin */
|
/** Install a plugin */
|
||||||
export const PLUGIN_INSTALL = 'plugin:install';
|
export const PLUGIN_INSTALL = 'plugin:install';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,7 @@ import {
|
||||||
PLUGIN_UNINSTALL,
|
PLUGIN_UNINSTALL,
|
||||||
MCP_REGISTRY_SEARCH,
|
MCP_REGISTRY_SEARCH,
|
||||||
MCP_REGISTRY_BROWSE,
|
MCP_REGISTRY_BROWSE,
|
||||||
|
MCP_REGISTRY_DIAGNOSE,
|
||||||
MCP_REGISTRY_GET_BY_ID,
|
MCP_REGISTRY_GET_BY_ID,
|
||||||
MCP_REGISTRY_GET_INSTALLED,
|
MCP_REGISTRY_GET_INSTALLED,
|
||||||
MCP_REGISTRY_INSTALL,
|
MCP_REGISTRY_INSTALL,
|
||||||
|
|
@ -275,6 +276,7 @@ import type {
|
||||||
McpCatalogItem,
|
McpCatalogItem,
|
||||||
McpCustomInstallRequest,
|
McpCustomInstallRequest,
|
||||||
McpInstallRequest,
|
McpInstallRequest,
|
||||||
|
McpServerDiagnostic,
|
||||||
McpSearchResult,
|
McpSearchResult,
|
||||||
OperationResult,
|
OperationResult,
|
||||||
PluginInstallRequest,
|
PluginInstallRequest,
|
||||||
|
|
@ -1395,6 +1397,7 @@ const electronAPI: ElectronAPI = {
|
||||||
invokeIpcWithResult<McpCatalogItem | null>(MCP_REGISTRY_GET_BY_ID, registryId),
|
invokeIpcWithResult<McpCatalogItem | null>(MCP_REGISTRY_GET_BY_ID, registryId),
|
||||||
getInstalled: (projectPath?: string) =>
|
getInstalled: (projectPath?: string) =>
|
||||||
invokeIpcWithResult<InstalledMcpEntry[]>(MCP_REGISTRY_GET_INSTALLED, projectPath),
|
invokeIpcWithResult<InstalledMcpEntry[]>(MCP_REGISTRY_GET_INSTALLED, projectPath),
|
||||||
|
diagnose: () => invokeIpcWithResult<McpServerDiagnostic[]>(MCP_REGISTRY_DIAGNOSE),
|
||||||
install: (request: McpInstallRequest) =>
|
install: (request: McpInstallRequest) =>
|
||||||
invokeIpcWithResult<OperationResult>(MCP_REGISTRY_INSTALL, request),
|
invokeIpcWithResult<OperationResult>(MCP_REGISTRY_INSTALL, request),
|
||||||
installCustom: (request: McpCustomInstallRequest) =>
|
installCustom: (request: McpCustomInstallRequest) =>
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import { Github as GithubIcon } from 'lucide-react';
|
||||||
import { InstallButton } from '../common/InstallButton';
|
import { InstallButton } from '../common/InstallButton';
|
||||||
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
|
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 */
|
/** Ribbon colors by source */
|
||||||
const RIBBON_STYLES: Record<string, string> = {
|
const RIBBON_STYLES: Record<string, string> = {
|
||||||
|
|
@ -30,12 +30,16 @@ const RIBBON_STYLES: Record<string, string> = {
|
||||||
interface McpServerCardProps {
|
interface McpServerCardProps {
|
||||||
server: McpCatalogItem;
|
server: McpCatalogItem;
|
||||||
isInstalled: boolean;
|
isInstalled: boolean;
|
||||||
|
diagnostic?: McpServerDiagnostic | null;
|
||||||
|
diagnosticsLoading?: boolean;
|
||||||
onClick: (serverId: string) => void;
|
onClick: (serverId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const McpServerCard = ({
|
export const McpServerCard = ({
|
||||||
server,
|
server,
|
||||||
isInstalled,
|
isInstalled,
|
||||||
|
diagnostic,
|
||||||
|
diagnosticsLoading,
|
||||||
onClick,
|
onClick,
|
||||||
}: McpServerCardProps): React.JSX.Element => {
|
}: McpServerCardProps): React.JSX.Element => {
|
||||||
const installProgress = useStore((s) => s.mcpInstallProgress[server.id] ?? 'idle');
|
const installProgress = useStore((s) => s.mcpInstallProgress[server.id] ?? 'idle');
|
||||||
|
|
@ -53,6 +57,14 @@ export const McpServerCard = ({
|
||||||
(server.authHeaders?.length ?? 0) > 0;
|
(server.authHeaders?.length ?? 0) > 0;
|
||||||
const [imgError, setImgError] = useState(false);
|
const [imgError, setImgError] = useState(false);
|
||||||
const hasIcon = !!server.iconUrl && !imgError;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -103,6 +115,19 @@ export const McpServerCard = ({
|
||||||
Installed
|
Installed
|
||||||
</Badge>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -110,6 +135,11 @@ export const McpServerCard = ({
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<p className="line-clamp-2 text-xs text-text-secondary">{server.description}</p>
|
<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 */}
|
{/* Footer indicators + install button */}
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
|
|
||||||
|
|
@ -31,11 +31,13 @@ import { InstallButton } from '../common/InstallButton';
|
||||||
import { SourceBadge } from '../common/SourceBadge';
|
import { SourceBadge } from '../common/SourceBadge';
|
||||||
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
|
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 {
|
interface McpServerDetailDialogProps {
|
||||||
server: McpCatalogItem | null;
|
server: McpCatalogItem | null;
|
||||||
isInstalled: boolean;
|
isInstalled: boolean;
|
||||||
|
diagnostic?: McpServerDiagnostic | null;
|
||||||
|
diagnosticsLoading?: boolean;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
@ -50,6 +52,8 @@ const SCOPE_OPTIONS: { value: Scope; label: string }[] = [
|
||||||
export const McpServerDetailDialog = ({
|
export const McpServerDetailDialog = ({
|
||||||
server,
|
server,
|
||||||
isInstalled,
|
isInstalled,
|
||||||
|
diagnostic,
|
||||||
|
diagnosticsLoading,
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
}: McpServerDetailDialogProps): React.JSX.Element => {
|
}: McpServerDetailDialogProps): React.JSX.Element => {
|
||||||
|
|
@ -166,6 +170,14 @@ export const McpServerDetailDialog = ({
|
||||||
(header) => header.isRequired && !header.value.trim()
|
(header) => header.isRequired && !header.value.trim()
|
||||||
);
|
);
|
||||||
const installDisabled = !serverName.trim() || missingRequiredEnvVars || missingRequiredHeaders;
|
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 = () => {
|
const handleInstall = () => {
|
||||||
installMcpServer({
|
installMcpServer({
|
||||||
|
|
@ -315,6 +327,40 @@ export const McpServerDetailDialog = ({
|
||||||
does not describe them. If connection fails after install, check the provider docs.
|
does not describe them. If connection fails after install, check the provider docs.
|
||||||
</div>
|
</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 */}
|
{/* Install form */}
|
||||||
{canAutoInstall && (
|
{canAutoInstall && (
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||||
import { Label } from '@renderer/components/ui/label';
|
import { Label } from '@renderer/components/ui/label';
|
||||||
|
|
@ -15,14 +16,19 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@renderer/components/ui/select';
|
} from '@renderer/components/ui/select';
|
||||||
import { useStore } from '@renderer/store';
|
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 { SearchInput } from '../common/SearchInput';
|
||||||
|
|
||||||
import { McpServerCard } from './McpServerCard';
|
import { McpServerCard } from './McpServerCard';
|
||||||
import { McpServerDetailDialog } from './McpServerDetailDialog';
|
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';
|
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
|
||||||
|
|
||||||
type McpSortValue = 'name-asc' | 'name-desc' | 'tools-desc';
|
type McpSortValue = 'name-asc' | 'name-desc' | 'tools-desc';
|
||||||
|
|
@ -74,6 +80,11 @@ export const McpServersPanel = ({
|
||||||
const mcpBrowse = useStore((s) => s.mcpBrowse);
|
const mcpBrowse = useStore((s) => s.mcpBrowse);
|
||||||
const installedServers = useStore((s) => s.mcpInstalledServers);
|
const installedServers = useStore((s) => s.mcpInstalledServers);
|
||||||
const fetchMcpGitHubStars = useStore((s) => s.fetchMcpGitHubStars);
|
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 [mcpSort, setMcpSort] = useState<McpSortValue>('name-asc');
|
||||||
const [mcpInstalledOnly, setMcpInstalledOnly] = useState(false);
|
const [mcpInstalledOnly, setMcpInstalledOnly] = useState(false);
|
||||||
|
|
@ -85,6 +96,10 @@ export const McpServersPanel = ({
|
||||||
}
|
}
|
||||||
}, [browseCatalog.length, browseLoading, mcpBrowse]);
|
}, [browseCatalog.length, browseLoading, mcpBrowse]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void runMcpDiagnostics();
|
||||||
|
}, [runMcpDiagnostics]);
|
||||||
|
|
||||||
// Fetch GitHub stars after catalog loads (fire-and-forget)
|
// Fetch GitHub stars after catalog loads (fire-and-forget)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const urls = browseCatalog.map((s) => s.repositoryUrl).filter((u): u is string => !!u);
|
const urls = browseCatalog.map((s) => s.repositoryUrl).filter((u): u is string => !!u);
|
||||||
|
|
@ -105,10 +120,41 @@ export const McpServersPanel = ({
|
||||||
[installedServers]
|
[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 */
|
/** Check if a catalog server is installed by comparing sanitized names */
|
||||||
const isServerInstalled = (server: McpCatalogItem): boolean =>
|
const isServerInstalled = (server: McpCatalogItem): boolean =>
|
||||||
installedNames.has(sanitizeMcpServerName(server.name));
|
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
|
// Sort + filter
|
||||||
const displayServers = useMemo(() => {
|
const displayServers = useMemo(() => {
|
||||||
let result = rawServers;
|
let result = rawServers;
|
||||||
|
|
@ -131,6 +177,76 @@ export const McpServersPanel = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<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 */}
|
{/* Search + Sort + Installed only row */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|
@ -217,6 +333,12 @@ export const McpServersPanel = ({
|
||||||
</div>
|
</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 */}
|
{/* Empty state */}
|
||||||
{!isLoading && displayServers.length === 0 && (
|
{!isLoading && displayServers.length === 0 && (
|
||||||
<div className="flex flex-col items-center gap-3 rounded-sm border border-dashed border-border px-8 py-16">
|
<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}
|
key={server.id}
|
||||||
server={server}
|
server={server}
|
||||||
isInstalled={isServerInstalled(server)}
|
isInstalled={isServerInstalled(server)}
|
||||||
|
diagnostic={getDiagnostic(server)}
|
||||||
|
diagnosticsLoading={mcpDiagnosticsLoading}
|
||||||
onClick={setSelectedMcpServerId}
|
onClick={setSelectedMcpServerId}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
@ -275,6 +399,8 @@ export const McpServersPanel = ({
|
||||||
<McpServerDetailDialog
|
<McpServerDetailDialog
|
||||||
server={selectedServer}
|
server={selectedServer}
|
||||||
isInstalled={selectedServer ? isServerInstalled(selectedServer) : false}
|
isInstalled={selectedServer ? isServerInstalled(selectedServer) : false}
|
||||||
|
diagnostic={selectedServer ? getDiagnostic(selectedServer) : null}
|
||||||
|
diagnosticsLoading={mcpDiagnosticsLoading}
|
||||||
open={selectedMcpServerId !== null}
|
open={selectedMcpServerId !== null}
|
||||||
onClose={() => setSelectedMcpServerId(null)}
|
onClose={() => setSelectedMcpServerId(null)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import type {
|
||||||
McpCatalogItem,
|
McpCatalogItem,
|
||||||
McpCustomInstallRequest,
|
McpCustomInstallRequest,
|
||||||
McpInstallRequest,
|
McpInstallRequest,
|
||||||
|
McpServerDiagnostic,
|
||||||
PluginInstallRequest,
|
PluginInstallRequest,
|
||||||
} from '@shared/types/extensions';
|
} from '@shared/types/extensions';
|
||||||
import type { StateCreator } from 'zustand';
|
import type { StateCreator } from 'zustand';
|
||||||
|
|
@ -41,6 +42,10 @@ export interface ExtensionsSlice {
|
||||||
mcpBrowseError: string | null;
|
mcpBrowseError: string | null;
|
||||||
mcpInstalledServers: InstalledMcpEntry[];
|
mcpInstalledServers: InstalledMcpEntry[];
|
||||||
mcpInstalledProjectPath: string | null;
|
mcpInstalledProjectPath: string | null;
|
||||||
|
mcpDiagnostics: Record<string, McpServerDiagnostic>;
|
||||||
|
mcpDiagnosticsLoading: boolean;
|
||||||
|
mcpDiagnosticsError: string | null;
|
||||||
|
mcpDiagnosticsLastCheckedAt: number | null;
|
||||||
|
|
||||||
// ── Install progress ──
|
// ── Install progress ──
|
||||||
pluginInstallProgress: Record<string, ExtensionOperationState>;
|
pluginInstallProgress: Record<string, ExtensionOperationState>;
|
||||||
|
|
@ -62,6 +67,7 @@ export interface ExtensionsSlice {
|
||||||
fetchPluginReadme: (pluginId: string) => void;
|
fetchPluginReadme: (pluginId: string) => void;
|
||||||
mcpBrowse: (cursor?: string) => Promise<void>;
|
mcpBrowse: (cursor?: string) => Promise<void>;
|
||||||
mcpFetchInstalled: (projectPath?: string) => Promise<void>;
|
mcpFetchInstalled: (projectPath?: string) => Promise<void>;
|
||||||
|
runMcpDiagnostics: () => Promise<void>;
|
||||||
|
|
||||||
// ── Mutation actions ──
|
// ── Mutation actions ──
|
||||||
installPlugin: (request: PluginInstallRequest) => Promise<void>;
|
installPlugin: (request: PluginInstallRequest) => Promise<void>;
|
||||||
|
|
@ -93,6 +99,7 @@ export interface ExtensionsSlice {
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
let pluginFetchInFlight: Promise<void> | null = null;
|
let pluginFetchInFlight: Promise<void> | null = null;
|
||||||
|
let mcpDiagnosticsInFlight: Promise<void> | null = null;
|
||||||
|
|
||||||
/** Duration to show "success" state before returning to idle */
|
/** Duration to show "success" state before returning to idle */
|
||||||
const SUCCESS_DISPLAY_MS = 2_000;
|
const SUCCESS_DISPLAY_MS = 2_000;
|
||||||
|
|
@ -115,6 +122,10 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
mcpBrowseError: null,
|
mcpBrowseError: null,
|
||||||
mcpInstalledServers: [],
|
mcpInstalledServers: [],
|
||||||
mcpInstalledProjectPath: null,
|
mcpInstalledProjectPath: null,
|
||||||
|
mcpDiagnostics: {},
|
||||||
|
mcpDiagnosticsLoading: false,
|
||||||
|
mcpDiagnosticsError: null,
|
||||||
|
mcpDiagnosticsLastCheckedAt: null,
|
||||||
|
|
||||||
pluginInstallProgress: {},
|
pluginInstallProgress: {},
|
||||||
mcpInstallProgress: {},
|
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 ──
|
// ── Plugin install ──
|
||||||
installPlugin: async (request: PluginInstallRequest) => {
|
installPlugin: async (request: PluginInstallRequest) => {
|
||||||
if (!api.plugins) return;
|
if (!api.plugins) return;
|
||||||
|
|
@ -347,13 +394,15 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined),
|
||||||
|
get().runMcpDiagnostics(),
|
||||||
|
]);
|
||||||
|
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'success' },
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'success' },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Refresh installed list
|
|
||||||
void get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'idle' },
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'idle' },
|
||||||
|
|
@ -394,13 +443,15 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined),
|
||||||
|
get().runMcpDiagnostics(),
|
||||||
|
]);
|
||||||
|
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'success' },
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'success' },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Refresh installed list
|
|
||||||
void get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'idle' },
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'idle' },
|
||||||
|
|
@ -447,12 +498,15 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined),
|
||||||
|
get().runMcpDiagnostics(),
|
||||||
|
]);
|
||||||
|
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'success' },
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'success' },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
void get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'idle' },
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'idle' },
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import type {
|
||||||
McpCatalogItem,
|
McpCatalogItem,
|
||||||
McpCustomInstallRequest,
|
McpCustomInstallRequest,
|
||||||
McpInstallRequest,
|
McpInstallRequest,
|
||||||
|
McpServerDiagnostic,
|
||||||
McpSearchResult,
|
McpSearchResult,
|
||||||
} from './mcp';
|
} from './mcp';
|
||||||
|
|
||||||
|
|
@ -42,6 +43,7 @@ export interface McpCatalogAPI {
|
||||||
) => Promise<{ servers: McpCatalogItem[]; nextCursor?: string }>;
|
) => Promise<{ servers: McpCatalogItem[]; nextCursor?: string }>;
|
||||||
getById: (registryId: string) => Promise<McpCatalogItem | null>;
|
getById: (registryId: string) => Promise<McpCatalogItem | null>;
|
||||||
getInstalled: (projectPath?: string) => Promise<InstalledMcpEntry[]>;
|
getInstalled: (projectPath?: string) => Promise<InstalledMcpEntry[]>;
|
||||||
|
diagnose: () => Promise<McpServerDiagnostic[]>;
|
||||||
install: (request: McpInstallRequest) => Promise<OperationResult>;
|
install: (request: McpInstallRequest) => Promise<OperationResult>;
|
||||||
installCustom: (request: McpCustomInstallRequest) => Promise<OperationResult>;
|
installCustom: (request: McpCustomInstallRequest) => Promise<OperationResult>;
|
||||||
uninstall: (name: string, scope?: string, projectPath?: string) => Promise<OperationResult>;
|
uninstall: (name: string, scope?: string, projectPath?: string) => Promise<OperationResult>;
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ export type {
|
||||||
McpAuthHeaderDef,
|
McpAuthHeaderDef,
|
||||||
McpCatalogItem,
|
McpCatalogItem,
|
||||||
McpCustomInstallRequest,
|
McpCustomInstallRequest,
|
||||||
|
McpServerDiagnostic,
|
||||||
|
McpServerHealthStatus,
|
||||||
McpEnvVarDef,
|
McpEnvVarDef,
|
||||||
McpHeaderDef,
|
McpHeaderDef,
|
||||||
McpHostingType,
|
McpHostingType,
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,17 @@ export interface InstalledMcpEntry {
|
||||||
transport?: string;
|
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) ────────────────
|
// ── Install request (renderer → main, minimal trusted data) ────────────────
|
||||||
|
|
||||||
export interface McpInstallRequest {
|
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(),
|
browse: vi.fn(),
|
||||||
getById: vi.fn(),
|
getById: vi.fn(),
|
||||||
getInstalled: vi.fn(),
|
getInstalled: vi.fn(),
|
||||||
|
diagnose: vi.fn(),
|
||||||
install: vi.fn(),
|
install: vi.fn(),
|
||||||
uninstall: vi.fn(),
|
uninstall: vi.fn(),
|
||||||
},
|
},
|
||||||
|
|
@ -241,6 +242,7 @@ describe('extensionsSlice', () => {
|
||||||
it('sets progress to pending then success', async () => {
|
it('sets progress to pending then success', async () => {
|
||||||
(api.mcpRegistry!.install as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
|
(api.mcpRegistry!.install as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
|
||||||
(api.mcpRegistry!.getInstalled as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
(api.mcpRegistry!.getInstalled as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||||
|
(api.mcpRegistry!.diagnose as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||||
|
|
||||||
const promise = store.getState().installMcpServer({
|
const promise = store.getState().installMcpServer({
|
||||||
registryId: 'test-id',
|
registryId: 'test-id',
|
||||||
|
|
@ -261,13 +263,14 @@ describe('extensionsSlice', () => {
|
||||||
it('sets progress to pending then success', async () => {
|
it('sets progress to pending then success', async () => {
|
||||||
(api.mcpRegistry!.uninstall as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
|
(api.mcpRegistry!.uninstall as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
|
||||||
(api.mcpRegistry!.getInstalled as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
(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;
|
await promise;
|
||||||
expect(store.getState().mcpInstallProgress['test-server']).toBe('success');
|
expect(store.getState().mcpInstallProgress['test-id']).toBe('success');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue