fix(extensions): harden mcp diagnostics output

This commit is contained in:
777genius 2026-04-17 14:21:17 +03:00
parent fd4fd135a1
commit 81c59440bf
8 changed files with 128 additions and 12 deletions

View file

@ -12,7 +12,7 @@ import type { CliFlavor } from '@shared/types';
import type { InstalledMcpEntry, McpServerDiagnostic } from '@shared/types/extensions';
const MCP_LIST_TIMEOUT_MS = 15_000;
const MCP_DIAGNOSE_TIMEOUT_MS = 30_000;
const MCP_DIAGNOSE_TIMEOUT_MS = 60_000;
export interface ExtensionsRuntimeAdapter {
readonly flavor: CliFlavor;

View file

@ -3,6 +3,8 @@ import type { McpServerDiagnostic, McpServerHealthStatus } from '@shared/types/e
interface McpDiagnoseJsonEntry {
name?: string;
target?: string;
scope?: 'local' | 'user' | 'project' | 'global' | 'dynamic' | 'managed';
transport?: string;
status?: 'connected' | 'needs-authentication' | 'failed' | 'timeout';
statusLabel?: string;
}
@ -12,6 +14,10 @@ interface McpDiagnoseJsonPayload {
diagnostics?: McpDiagnoseJsonEntry[];
}
const EMBEDDED_HTTP_URL_PATTERN = /https?:\/\/[^\s"'`]+/gi;
const SENSITIVE_FLAG_VALUE_PATTERN =
/(--(?:api[-_]?key|access[-_]?token|auth[-_]?token|token|secret|password|client[-_]?secret))(?:=([^\s]+)|\s+([^\s]+))/gi;
function extractJsonObject<T>(raw: string): T {
const trimmed = raw.trim();
try {
@ -45,6 +51,42 @@ function parseStatusChunk(statusChunk: string): {
}
}
function redactHttpUrl(urlString: string): string {
try {
const parsed = new URL(urlString);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return urlString;
}
if (!parsed.username && !parsed.password && !parsed.search && !parsed.hash) {
return urlString;
}
if (parsed.username) parsed.username = '***';
if (parsed.password) parsed.password = '***';
for (const key of new Set(parsed.searchParams.keys())) {
parsed.searchParams.set(key, 'REDACTED');
}
if (parsed.hash) {
parsed.hash = 'REDACTED';
}
return parsed.toString();
} catch {
return urlString;
}
}
function redactDiagnosticTarget(target: string): string {
return target
.replace(EMBEDDED_HTTP_URL_PATTERN, (match) => redactHttpUrl(match))
.replace(SENSITIVE_FLAG_VALUE_PATTERN, (_match, flag: string, inlineValue?: string) =>
inlineValue ? `${flag}=REDACTED` : `${flag} REDACTED`
);
}
function parseDiagnosticLine(line: string, checkedAt: number): McpServerDiagnostic | null {
const statusSeparatorIdx = line.lastIndexOf(' - ');
if (statusSeparatorIdx === -1) {
@ -60,7 +102,7 @@ function parseDiagnosticLine(line: string, checkedAt: number): McpServerDiagnost
}
const name = descriptor.slice(0, nameSeparatorIdx).trim();
const target = descriptor.slice(nameSeparatorIdx + 2).trim();
const target = redactDiagnosticTarget(descriptor.slice(nameSeparatorIdx + 2).trim());
if (!name || !target) {
return null;
}
@ -102,6 +144,7 @@ export function parseMcpDiagnosticsJsonOutput(output: string): McpServerDiagnost
return [];
}
const redactedTarget = redactDiagnosticTarget(entry.target);
const normalizedStatus: McpServerHealthStatus =
entry.status === 'connected'
? 'connected'
@ -111,11 +154,13 @@ export function parseMcpDiagnosticsJsonOutput(output: string): McpServerDiagnost
? 'failed'
: 'unknown';
const rawLine = `${entry.name}: ${entry.target} - ${entry.statusLabel}`;
const rawLine = `${entry.name}: ${redactedTarget} - ${entry.statusLabel}`;
return [
{
name: entry.name,
target: entry.target,
target: redactedTarget,
scope: entry.scope,
transport: entry.transport,
status: normalizedStatus,
statusLabel: entry.statusLabel,
rawLine,

View file

@ -17,6 +17,7 @@ import { useStore } from '@renderer/store';
import { formatRelativeTime } from '@renderer/utils/formatters';
import { CLI_NOT_FOUND_MARKER } from '@shared/constants/cli';
import {
getMcpDiagnosticKey,
getPreferredMcpInstallationEntry,
sanitizeMcpServerName,
} from '@shared/utils/extensionNormalizers';
@ -164,7 +165,12 @@ export const McpServersPanel = ({
const getDiagnostic = (server: McpCatalogItem): McpServerDiagnostic | null => {
const installedEntry = getInstalledEntry(server);
return installedEntry ? (mcpDiagnostics[installedEntry.name] ?? null) : null;
return installedEntry
? (mcpDiagnostics[getMcpDiagnosticKey(installedEntry.name, installedEntry.scope)] ??
mcpDiagnostics[getMcpDiagnosticKey(installedEntry.name)] ??
mcpDiagnostics[installedEntry.name] ??
null)
: null;
};
const allDiagnostics = useMemo(
@ -250,11 +256,18 @@ export const McpServersPanel = ({
<div className="mcp-diagnostics-list max-h-[18.5rem] space-y-2 overflow-y-auto pr-1">
{allDiagnostics.map((diagnostic) => (
<div
key={diagnostic.name}
key={getMcpDiagnosticKey(diagnostic.name, diagnostic.scope)}
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>
<div className="flex items-center gap-2">
<p className="text-sm text-text">{diagnostic.name}</p>
{diagnostic.scope && (
<span className="rounded-full border border-border bg-surface-raised px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-text-muted">
{diagnostic.scope}
</span>
)}
</div>
<p
className="truncate font-mono text-[11px] text-text-muted"
title={diagnostic.target}
@ -269,7 +282,9 @@ export const McpServersPanel = ({
))}
</div>
) : (
<p className="text-xs text-text-muted">Waiting for `claude mcp list` results...</p>
<p className="text-xs text-text-muted">
Waiting for <code>{diagnosticsCommand}</code> results...
</p>
)}
</div>
)}

View file

@ -6,7 +6,11 @@
import { api } from '@renderer/api';
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
import { isProjectScopedMcpScope } from '@shared/utils/mcpScopes';
import { getMcpOperationKey, getPluginOperationKey } from '@shared/utils/extensionNormalizers';
import {
getMcpDiagnosticKey,
getMcpOperationKey,
getPluginOperationKey,
} from '@shared/utils/extensionNormalizers';
import { findPaneByTabId, updatePane } from '../utils/paneHelpers';
@ -555,7 +559,9 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
const diagnostics = await mcpRegistry.diagnose(projectPath);
set({
mcpDiagnostics: Object.fromEntries(
diagnostics.map((entry) => [entry.name, entry] as const)
diagnostics.map(
(entry) => [getMcpDiagnosticKey(entry.name, entry.scope), entry] as const
)
),
mcpDiagnosticsLoading: false,
mcpDiagnosticsLastCheckedAt: Date.now(),

View file

@ -92,6 +92,8 @@ export type McpServerHealthStatus = 'connected' | 'needs-authentication' | 'fail
export interface McpServerDiagnostic {
name: string;
target: string;
scope?: 'local' | 'user' | 'project' | 'global' | 'dynamic' | 'managed';
transport?: string;
status: McpServerHealthStatus;
statusLabel: string;
rawLine: string;

View file

@ -120,6 +120,14 @@ export function getMcpOperationKey(registryId: string, scope: InstallScope): str
return `mcp:${registryId}:${scope}`;
}
/**
* Namespaced lookup key for MCP diagnostics. Scope is included when available
* so the same server name can coexist across global/project/local installs.
*/
export function getMcpDiagnosticKey(name: string, scope?: string | null): string {
return scope ? `mcp-diagnostic:${scope}:${name}` : `mcp-diagnostic:${name}`;
}
/**
* Check whether a plugin has an installation for the selected scope.
*/

View file

@ -25,7 +25,7 @@ alpic: https://mcp.alpic.ai (HTTP) - ! Needs authentication`);
});
expect(diagnostics[3]).toMatchObject({
name: 'tavily-remote-mcp',
target: 'npx -y mcp-remote https://mcp.tavily.com/mcp/?tavilyApiKey=test',
target: 'npx -y mcp-remote https://mcp.tavily.com/mcp/?tavilyApiKey=REDACTED',
status: 'failed',
statusLabel: 'Failed to connect',
});
@ -58,7 +58,9 @@ another log line`);
},
{
name: 'tavily',
target: 'https://mcp.tavily.com/mcp',
target: 'https://mcp.tavily.com/mcp?token=secret',
scope: 'global',
transport: 'http',
status: 'timeout',
statusLabel: 'Timed out',
},
@ -74,6 +76,9 @@ another log line`);
}),
expect.objectContaining({
name: 'tavily',
target: 'https://mcp.tavily.com/mcp?token=REDACTED',
scope: 'global',
transport: 'http',
status: 'failed',
statusLabel: 'Timed out',
}),

View file

@ -55,6 +55,7 @@ vi.mock('../../../src/renderer/api', () => ({
import { api } from '../../../src/renderer/api';
import {
getMcpDiagnosticKey,
getMcpOperationKey,
getPluginOperationKey,
} from '../../../src/shared/utils/extensionNormalizers';
@ -833,6 +834,40 @@ describe('extensionsSlice', () => {
expect(api.cliInstaller!.getStatus).toHaveBeenCalled();
expect(store.getState().apiKeys).toEqual([]);
});
it('keys MCP diagnostics by scope when the same server exists in multiple scopes', async () => {
(api.mcpRegistry!.diagnose as ReturnType<typeof vi.fn>).mockResolvedValue([
{
name: 'context7',
scope: 'global',
target: 'npx -y @upstash/context7-mcp',
status: 'connected',
statusLabel: 'Connected',
rawLine: 'context7: npx -y @upstash/context7-mcp - Connected',
checkedAt: 1,
},
{
name: 'context7',
scope: 'project',
target: 'uvx context7-project',
status: 'failed',
statusLabel: 'Failed to connect',
rawLine: 'context7: uvx context7-project - Failed to connect',
checkedAt: 1,
},
]);
await store.getState().runMcpDiagnostics('/tmp/project-a');
expect(store.getState().mcpDiagnostics).toMatchObject({
[getMcpDiagnosticKey('context7', 'global')]: expect.objectContaining({
target: 'npx -y @upstash/context7-mcp',
}),
[getMcpDiagnosticKey('context7', 'project')]: expect.objectContaining({
target: 'uvx context7-project',
}),
});
});
});
describe('skills state hardening', () => {