diff --git a/src/services/mcp/doctor.test.ts b/src/services/mcp/doctor.test.ts new file mode 100644 index 00000000..83b74d3f --- /dev/null +++ b/src/services/mcp/doctor.test.ts @@ -0,0 +1,540 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import type { ValidationError } from '../../utils/settings/validation.js' + +import { + buildEmptyDoctorReport, + doctorAllServers, + doctorServer, + findingsFromValidationErrors, + type McpDoctorDependencies, +} from './doctor.js' + +function stdioConfig(scope: 'local' | 'project' | 'user' | 'enterprise', command: string) { + return { + type: 'stdio' as const, + command, + args: [], + scope, + } +} + +function makeDependencies(overrides: Partial = {}): McpDoctorDependencies { + return { + getAllMcpConfigs: async () => ({ servers: {}, errors: [] }), + getMcpConfigsByScope: () => ({ servers: {}, errors: [] }), + getProjectMcpServerStatus: () => 'approved', + isMcpServerDisabled: () => false, + describeMcpConfigFilePath: scope => `scope://${scope}`, + clearServerCache: async () => {}, + connectToServer: async (name, config) => ({ + name, + type: 'connected', + capabilities: {}, + config, + cleanup: async () => {}, + }), + ...overrides, + } +} + +test('buildEmptyDoctorReport returns zeroed summary', () => { + const report = buildEmptyDoctorReport({ + configOnly: true, + scopeFilter: 'project', + targetName: 'filesystem', + }) + + assert.equal(report.targetName, 'filesystem') + assert.equal(report.scopeFilter, 'project') + assert.equal(report.configOnly, true) + assert.deepEqual(report.summary, { + totalReports: 0, + healthy: 0, + warnings: 0, + blocking: 0, + }) + assert.deepEqual(report.findings, []) + assert.deepEqual(report.servers, []) +}) + +test('findingsFromValidationErrors maps missing env warnings into doctor findings', () => { + const validationErrors: ValidationError[] = [ + { + file: '.mcp.json', + path: 'mcpServers.filesystem', + message: 'Missing environment variables: API_KEY, API_URL', + suggestion: 'Set the following environment variables: API_KEY, API_URL', + mcpErrorMetadata: { + scope: 'project', + serverName: 'filesystem', + severity: 'warning', + }, + }, + ] + + const findings = findingsFromValidationErrors(validationErrors) + + assert.equal(findings.length, 1) + assert.deepEqual(findings[0], { + blocking: false, + code: 'config.missing_env_vars', + message: 'Missing environment variables: API_KEY, API_URL', + remediation: 'Set the following environment variables: API_KEY, API_URL', + scope: 'project', + serverName: 'filesystem', + severity: 'warn', + sourcePath: '.mcp.json', + }) +}) + +test('findingsFromValidationErrors maps Windows npx warnings into doctor findings', () => { + const validationErrors: ValidationError[] = [ + { + file: '.mcp.json', + path: 'mcpServers.node-tools', + message: "Windows requires 'cmd /c' wrapper to execute npx", + suggestion: + 'Change command to "cmd" with args ["/c", "npx", ...]. See: https://code.claude.com/docs/en/mcp#configure-mcp-servers', + mcpErrorMetadata: { + scope: 'project', + serverName: 'node-tools', + severity: 'warning', + }, + }, + ] + + const findings = findingsFromValidationErrors(validationErrors) + + assert.equal(findings.length, 1) + assert.equal(findings[0]?.code, 'config.windows_npx_wrapper_required') + assert.equal(findings[0]?.serverName, 'node-tools') + assert.equal(findings[0]?.severity, 'warn') + assert.equal(findings[0]?.blocking, false) +}) + +test('findingsFromValidationErrors maps fatal parse errors into blocking findings', () => { + const validationErrors: ValidationError[] = [ + { + file: 'C:/repo/.mcp.json', + path: '', + message: 'MCP config is not a valid JSON', + suggestion: 'Fix the JSON syntax errors in the file', + mcpErrorMetadata: { + scope: 'project', + severity: 'fatal', + }, + }, + ] + + const findings = findingsFromValidationErrors(validationErrors) + + assert.equal(findings.length, 1) + assert.equal(findings[0]?.code, 'config.invalid_json') + assert.equal(findings[0]?.severity, 'error') + assert.equal(findings[0]?.blocking, true) +}) + +test('doctorAllServers reports global validation findings once without duplicating them into every server', async () => { + const localConfig = stdioConfig('local', 'node-local') + const deps = makeDependencies({ + getAllMcpConfigs: async () => ({ + servers: { filesystem: localConfig }, + errors: [], + }), + getMcpConfigsByScope: scope => + scope === 'project' + ? { + servers: {}, + errors: [ + { + file: '.mcp.json', + path: '', + message: 'MCP config is not a valid JSON', + suggestion: 'Fix the JSON syntax errors in the file', + mcpErrorMetadata: { + scope: 'project', + severity: 'fatal', + }, + }, + ], + } + : scope === 'local' + ? { servers: { filesystem: localConfig }, errors: [] } + : { servers: {}, errors: [] }, + }) + + const report = await doctorAllServers({ configOnly: true }, deps) + + assert.equal(report.summary.totalReports, 1) + assert.equal(report.summary.blocking, 1) + assert.equal(report.findings.length, 1) + assert.equal(report.findings[0]?.code, 'config.invalid_json') + assert.deepEqual(report.servers[0]?.findings, []) +}) + +test('doctorServer explains same-name shadowing across scopes', async () => { + const localConfig = stdioConfig('local', 'node-local') + const userConfig = stdioConfig('user', 'node-user') + const deps = makeDependencies({ + getAllMcpConfigs: async () => ({ + servers: { + filesystem: localConfig, + }, + errors: [], + }), + getMcpConfigsByScope: scope => { + switch (scope) { + case 'local': + return { servers: { filesystem: localConfig }, errors: [] } + case 'user': + return { servers: { filesystem: userConfig }, errors: [] } + default: + return { servers: {}, errors: [] } + } + }, + }) + + const report = await doctorServer('filesystem', { configOnly: true }, deps) + assert.equal(report.servers.length, 1) + assert.equal(report.servers[0]?.definitions.length, 2) + assert.equal(report.servers[0]?.definitions.find(def => def.sourceType === 'local')?.runtimeActive, true) + assert.equal(report.servers[0]?.definitions.find(def => def.sourceType === 'user')?.runtimeActive, false) + assert.deepEqual( + report.servers[0]?.findings.map(finding => finding.code).sort(), + ['duplicate.same_name_multiple_scopes', 'scope.shadowed'], + ) +}) + +test('doctorServer reports project servers pending approval', async () => { + const projectConfig = stdioConfig('project', 'node-project') + const deps = makeDependencies({ + getMcpConfigsByScope: scope => + scope === 'project' + ? { servers: { sentry: projectConfig }, errors: [] } + : { servers: {}, errors: [] }, + getProjectMcpServerStatus: name => (name === 'sentry' ? 'pending' : 'approved'), + }) + + const report = await doctorServer('sentry', { configOnly: true }, deps) + assert.equal(report.servers.length, 1) + assert.equal(report.servers[0]?.definitions[0]?.pendingApproval, true) + assert.equal(report.servers[0]?.definitions[0]?.runtimeActive, false) + assert.equal(report.servers[0]?.definitions[0]?.runtimeVisible, false) + assert.equal( + report.servers[0]?.findings.some(finding => finding.code === 'state.pending_project_approval'), + true, + ) +}) + +test('doctorServer does not treat disabled servers as runtime-active or live-check targets', async () => { + let connectCalls = 0 + const localConfig = stdioConfig('local', 'node-local') + const deps = makeDependencies({ + getAllMcpConfigs: async () => ({ + servers: { github: localConfig }, + errors: [], + }), + getMcpConfigsByScope: scope => + scope === 'local' + ? { servers: { github: localConfig }, errors: [] } + : { servers: {}, errors: [] }, + isMcpServerDisabled: name => name === 'github', + connectToServer: async (name, config) => { + connectCalls += 1 + return { + name, + type: 'failed', + config, + error: 'should not connect', + } + }, + }) + + const report = await doctorServer('github', { configOnly: false }, deps) + + assert.equal(connectCalls, 0) + assert.equal(report.summary.blocking, 0) + assert.equal(report.summary.warnings, 1) + assert.equal(report.servers[0]?.definitions[0]?.disabled, true) + assert.equal(report.servers[0]?.definitions[0]?.runtimeActive, false) + assert.equal(report.servers[0]?.definitions[0]?.runtimeVisible, false) + assert.equal(report.servers[0]?.liveCheck.result, 'disabled') + assert.equal( + report.servers[0]?.findings.some(finding => finding.code === 'state.disabled' && finding.severity === 'warn'), + true, + ) +}) + +test('doctorAllServers skips live checks in config-only mode', async () => { + let connectCalls = 0 + const localConfig = stdioConfig('local', 'node-local') + const deps = makeDependencies({ + getAllMcpConfigs: async () => ({ + servers: { linear: localConfig }, + errors: [], + }), + getMcpConfigsByScope: scope => + scope === 'local' + ? { servers: { linear: localConfig }, errors: [] } + : { servers: {}, errors: [] }, + connectToServer: async (name, config) => { + connectCalls += 1 + return { + name, + type: 'connected', + capabilities: {}, + config, + cleanup: async () => {}, + } + }, + }) + + const report = await doctorAllServers({ configOnly: true }, deps) + assert.equal(connectCalls, 0) + assert.equal(report.servers[0]?.liveCheck.attempted, false) + assert.equal(report.servers[0]?.liveCheck.result, 'skipped') +}) + +test('doctorAllServers honors scopeFilter when collecting names', async () => { + const pluginConfig = { + type: 'http' as const, + url: 'https://example.test/mcp', + scope: 'dynamic' as const, + pluginSource: 'plugin:github@official', + } + const deps = makeDependencies({ + getAllMcpConfigs: async () => ({ + servers: { 'plugin:github:github': pluginConfig }, + errors: [], + }), + }) + + const report = await doctorAllServers({ configOnly: true, scopeFilter: 'user' }, deps) + + assert.equal(report.summary.totalReports, 0) + assert.deepEqual(report.servers, []) +}) + +test('doctorAllServers honors scopeFilter when collecting validation errors', async () => { + const userConfig = stdioConfig('user', 'node-user') + const deps = makeDependencies({ + getAllMcpConfigs: async () => ({ + servers: { filesystem: userConfig }, + errors: [], + }), + getMcpConfigsByScope: scope => { + switch (scope) { + case 'project': + return { + servers: {}, + errors: [ + { + file: '.mcp.json', + path: '', + message: 'MCP config is not a valid JSON', + suggestion: 'Fix the JSON syntax errors in the file', + mcpErrorMetadata: { + scope: 'project', + severity: 'fatal', + }, + }, + ], + } + case 'user': + return { servers: { filesystem: userConfig }, errors: [] } + default: + return { servers: {}, errors: [] } + } + }, + }) + + const report = await doctorAllServers({ configOnly: true, scopeFilter: 'user' }, deps) + + assert.equal(report.summary.totalReports, 1) + assert.equal(report.summary.blocking, 0) + assert.deepEqual(report.findings, []) + assert.deepEqual(report.servers[0]?.findings, []) +}) + +test('doctorAllServers includes observed runtime definitions for plugin-only servers', async () => { + const pluginConfig = { + type: 'http' as const, + url: 'https://example.test/mcp', + scope: 'dynamic' as const, + pluginSource: 'plugin:github@official', + } + const deps = makeDependencies({ + getAllMcpConfigs: async () => ({ + servers: { 'plugin:github:github': pluginConfig }, + errors: [], + }), + }) + + const report = await doctorAllServers({ configOnly: true }, deps) + + assert.equal(report.summary.totalReports, 1) + assert.equal(report.servers[0]?.definitions.length, 1) + assert.equal(report.servers[0]?.definitions[0]?.sourceType, 'plugin') + assert.equal(report.servers[0]?.definitions[0]?.runtimeActive, true) +}) + +test('doctorAllServers reports disabled plugin servers as disabled, not not-found', async () => { + const pluginConfig = { + type: 'http' as const, + url: 'https://example.test/mcp', + scope: 'dynamic' as const, + pluginSource: 'plugin:github@official', + } + const deps = makeDependencies({ + getAllMcpConfigs: async () => ({ + servers: { 'plugin:github:github': pluginConfig }, + errors: [], + }), + isMcpServerDisabled: name => name === 'plugin:github:github', + }) + + const report = await doctorAllServers({ configOnly: true }, deps) + + assert.equal(report.summary.totalReports, 1) + assert.equal(report.summary.warnings, 1) + assert.equal(report.summary.blocking, 0) + assert.equal(report.servers[0]?.definitions.length, 1) + assert.equal(report.servers[0]?.definitions[0]?.sourceType, 'plugin') + assert.equal(report.servers[0]?.definitions[0]?.disabled, true) + assert.equal(report.servers[0]?.definitions[0]?.runtimeActive, false) + assert.equal( + report.servers[0]?.findings.some(finding => finding.code === 'state.disabled' && !finding.blocking), + true, + ) + assert.equal( + report.servers[0]?.findings.some(finding => finding.code === 'state.not_found'), + false, + ) +}) + +test('doctorServer converts failed live checks into blocking findings', async () => { + const localConfig = stdioConfig('local', 'node-local') + const deps = makeDependencies({ + getAllMcpConfigs: async () => ({ + servers: { github: localConfig }, + errors: [], + }), + getMcpConfigsByScope: scope => + scope === 'local' + ? { servers: { github: localConfig }, errors: [] } + : { servers: {}, errors: [] }, + connectToServer: async (name, config) => ({ + name, + type: 'failed', + config, + error: 'command not found: node-local', + }), + }) + + const report = await doctorServer('github', { configOnly: false }, deps) + + assert.equal(report.summary.blocking, 1) + assert.equal(report.servers[0]?.liveCheck.result, 'failed') + assert.equal( + report.servers[0]?.findings.some( + finding => finding.code === 'stdio.command_not_found' && finding.blocking, + ), + true, + ) +}) + +test('doctorServer converts needs-auth live checks into warning findings', async () => { + const localConfig = stdioConfig('local', 'node-local') + const deps = makeDependencies({ + getAllMcpConfigs: async () => ({ + servers: { sentry: localConfig }, + errors: [], + }), + getMcpConfigsByScope: scope => + scope === 'local' + ? { servers: { sentry: localConfig }, errors: [] } + : { servers: {}, errors: [] }, + connectToServer: async (name, config) => ({ + name, + type: 'needs-auth', + config, + }), + }) + + const report = await doctorServer('sentry', { configOnly: false }, deps) + + assert.equal(report.summary.warnings, 1) + assert.equal(report.summary.blocking, 0) + assert.equal( + report.servers[0]?.findings.some(finding => finding.code === 'auth.needs_auth' && finding.severity === 'warn'), + true, + ) +}) + +test('doctorServer includes observed runtime definition for plugin-only targets', async () => { + const pluginConfig = { + type: 'http' as const, + url: 'https://example.test/mcp', + scope: 'dynamic' as const, + pluginSource: 'plugin:github@official', + } + const deps = makeDependencies({ + getAllMcpConfigs: async () => ({ + servers: { 'plugin:github:github': pluginConfig }, + errors: [], + }), + }) + + const report = await doctorServer('plugin:github:github', { configOnly: true }, deps) + + assert.equal(report.summary.totalReports, 1) + assert.equal(report.servers[0]?.definitions.length, 1) + assert.equal(report.servers[0]?.definitions[0]?.sourceType, 'plugin') + assert.equal(report.servers[0]?.definitions[0]?.runtimeActive, true) +}) + +test('doctorServer with scopeFilter does not leak runtime definition from another scope when target is absent', async () => { + let connectCalls = 0 + const localConfig = stdioConfig('local', 'node-local') + const deps = makeDependencies({ + getAllMcpConfigs: async () => ({ + servers: { github: localConfig }, + errors: [], + }), + getMcpConfigsByScope: scope => + scope === 'local' + ? { servers: { github: localConfig }, errors: [] } + : { servers: {}, errors: [] }, + connectToServer: async (name, config) => { + connectCalls += 1 + return { + name, + type: 'connected', + capabilities: {}, + config, + cleanup: async () => {}, + } + }, + }) + + const report = await doctorServer('github', { configOnly: false, scopeFilter: 'user' }, deps) + + assert.equal(connectCalls, 0) + assert.equal(report.summary.totalReports, 1) + assert.equal(report.summary.blocking, 1) + assert.deepEqual(report.servers[0]?.definitions, []) + assert.equal(report.servers[0]?.liveCheck.result, 'skipped') + assert.equal( + report.servers[0]?.findings.some(finding => finding.code === 'state.not_found' && finding.blocking), + true, + ) +}) + +test('doctorServer reports blocking not-found state when no definition exists', async () => { + const report = await doctorServer('missing-server', { configOnly: true }, makeDependencies()) + + assert.equal(report.summary.blocking, 1) + assert.equal(report.servers[0]?.findings.some(finding => finding.code === 'state.not_found' && finding.blocking), true) +}) diff --git a/src/services/mcp/doctor.ts b/src/services/mcp/doctor.ts new file mode 100644 index 00000000..6cdd15e5 --- /dev/null +++ b/src/services/mcp/doctor.ts @@ -0,0 +1,695 @@ +import type { ValidationError } from '../../utils/settings/validation.js' +import { clearServerCache, connectToServer } from './client.js' +import { + getAllMcpConfigs, + getMcpConfigsByScope, + isMcpServerDisabled, +} from './config.js' +import type { + ConfigScope, + ScopedMcpServerConfig, +} from './types.js' +import { describeMcpConfigFilePath, getProjectMcpServerStatus } from './utils.js' + +export type McpDoctorSeverity = 'info' | 'warn' | 'error' +export type McpDoctorScopeFilter = 'local' | 'project' | 'user' | 'enterprise' + +export type McpDoctorFinding = { + blocking: boolean + code: string + message: string + remediation?: string + scope?: string + serverName?: string + severity: McpDoctorSeverity + sourcePath?: string +} + +export type McpDoctorLiveCheck = { + attempted: boolean + durationMs?: number + error?: string + result?: 'connected' | 'needs-auth' | 'failed' | 'pending' | 'disabled' | 'skipped' +} + +export type McpDoctorDefinition = { + name: string + sourceType: + | 'local' + | 'project' + | 'user' + | 'enterprise' + | 'managed' + | 'plugin' + | 'claudeai' + | 'dynamic' + | 'internal' + sourcePath?: string + transport?: string + runtimeVisible: boolean + runtimeActive: boolean + pendingApproval?: boolean + disabled?: boolean +} + +export type McpDoctorServerReport = { + serverName: string + requestedByUser: boolean + definitions: McpDoctorDefinition[] + liveCheck: McpDoctorLiveCheck + findings: McpDoctorFinding[] +} + +export type McpDoctorDependencies = { + getAllMcpConfigs: typeof getAllMcpConfigs + getMcpConfigsByScope: typeof getMcpConfigsByScope + getProjectMcpServerStatus: typeof getProjectMcpServerStatus + isMcpServerDisabled: typeof isMcpServerDisabled + describeMcpConfigFilePath: typeof describeMcpConfigFilePath + connectToServer: typeof connectToServer + clearServerCache: typeof clearServerCache +} + +export type McpDoctorReport = { + generatedAt: string + targetName?: string + scopeFilter?: McpDoctorScopeFilter + configOnly: boolean + summary: { + totalReports: number + healthy: number + warnings: number + blocking: number + } + findings: McpDoctorFinding[] + servers: McpDoctorServerReport[] +} + +const DEFAULT_DEPENDENCIES: McpDoctorDependencies = { + getAllMcpConfigs, + getMcpConfigsByScope, + getProjectMcpServerStatus, + isMcpServerDisabled, + describeMcpConfigFilePath, + connectToServer, + clearServerCache, +} + +export function buildEmptyDoctorReport(options: { + configOnly: boolean + scopeFilter?: McpDoctorScopeFilter + targetName?: string +}): McpDoctorReport { + return { + generatedAt: new Date().toISOString(), + targetName: options.targetName, + scopeFilter: options.scopeFilter, + configOnly: options.configOnly, + summary: { + totalReports: 0, + healthy: 0, + warnings: 0, + blocking: 0, + }, + findings: [], + servers: [], + } +} + +function getFindingCode(error: ValidationError): string { + if (error.message === 'MCP config is not a valid JSON') { + return 'config.invalid_json' + } + if (error.message.startsWith('Missing environment variables:')) { + return 'config.missing_env_vars' + } + if (error.message.includes("Windows requires 'cmd /c' wrapper to execute npx")) { + return 'config.windows_npx_wrapper_required' + } + if (error.message === 'Does not adhere to MCP server configuration schema') { + return 'config.invalid_schema' + } + return 'config.validation_error' +} + +function getSeverity(error: ValidationError): McpDoctorSeverity { + const severity = error.mcpErrorMetadata?.severity + if (severity === 'fatal') { + return 'error' + } + if (severity === 'warning') { + return 'warn' + } + return 'warn' +} + +export function findingsFromValidationErrors( + validationErrors: ValidationError[], +): McpDoctorFinding[] { + return validationErrors.map(error => { + const severity = getSeverity(error) + return { + blocking: severity === 'error', + code: getFindingCode(error), + message: error.message, + remediation: error.suggestion, + scope: error.mcpErrorMetadata?.scope, + serverName: error.mcpErrorMetadata?.serverName, + severity, + sourcePath: error.file, + } + }) +} + +function splitValidationFindings(validationFindings: McpDoctorFinding[]): { + globalFindings: McpDoctorFinding[] + serverFindingsByName: Map +} { + const globalFindings: McpDoctorFinding[] = [] + const serverFindingsByName = new Map() + + for (const finding of validationFindings) { + if (!finding.serverName) { + globalFindings.push(finding) + continue + } + + const findings = serverFindingsByName.get(finding.serverName) ?? [] + findings.push(finding) + serverFindingsByName.set(finding.serverName, findings) + } + + return { + globalFindings, + serverFindingsByName, + } +} + +function getSourceType(config: ScopedMcpServerConfig): McpDoctorDefinition['sourceType'] { + if (config.scope === 'claudeai') { + return 'claudeai' + } + if (config.scope === 'dynamic') { + return config.pluginSource ? 'plugin' : 'dynamic' + } + if (config.scope === 'managed') { + return 'managed' + } + return config.scope +} + +function getTransport(config: ScopedMcpServerConfig): string { + return config.type ?? 'stdio' +} + +function getConfigSignature(config: ScopedMcpServerConfig): string { + switch (config.type) { + case 'sse': + case 'http': + case 'ws': + case 'claudeai-proxy': + return `${config.scope}:${config.type}:${config.url}` + case 'sdk': + return `${config.scope}:${config.type}:${config.name}` + default: + return `${config.scope}:${config.type ?? 'stdio'}:${config.command}:${JSON.stringify(config.args ?? [])}` + } +} + +function isSameDefinition( + config: ScopedMcpServerConfig, + activeConfig: ScopedMcpServerConfig | undefined, +): boolean { + if (!activeConfig) { + return false + } + return getSourceType(config) === getSourceType(activeConfig) && getConfigSignature(config) === getConfigSignature(activeConfig) +} + +function buildScopeDefinitions( + name: string, + scope: ConfigScope, + servers: Record, + activeConfig: ScopedMcpServerConfig | undefined, + deps: McpDoctorDependencies, +): McpDoctorDefinition[] { + const config = servers[name] + if (!config) { + return [] + } + + const pendingApproval = + scope === 'project' ? deps.getProjectMcpServerStatus(name) === 'pending' : false + const disabled = deps.isMcpServerDisabled(name) + const runtimeActive = !disabled && isSameDefinition(config, activeConfig) + + return [ + { + name, + sourceType: getSourceType(config), + sourcePath: deps.describeMcpConfigFilePath(scope), + transport: getTransport(config), + runtimeVisible: runtimeActive, + runtimeActive, + pendingApproval, + disabled, + }, + ] +} + +function shouldIncludeScope( + scope: ConfigScope, + scopeFilter: McpDoctorScopeFilter | undefined, +): boolean { + if (!scopeFilter) { + return scope === 'enterprise' || scope === 'local' || scope === 'project' || scope === 'user' + } + return scope === scopeFilter +} + +function getValidationErrorsForSelectedScopes( + scopeResults: { + enterprise: ReturnType + local: ReturnType + project: ReturnType + user: ReturnType + }, + scopeFilter: McpDoctorScopeFilter | undefined, +): ValidationError[] { + return [ + ...(shouldIncludeScope('enterprise', scopeFilter) ? scopeResults.enterprise.errors : []), + ...(shouldIncludeScope('local', scopeFilter) ? scopeResults.local.errors : []), + ...(shouldIncludeScope('project', scopeFilter) ? scopeResults.project.errors : []), + ...(shouldIncludeScope('user', scopeFilter) ? scopeResults.user.errors : []), + ] +} + +function buildObservedDefinition( + name: string, + activeConfig: ScopedMcpServerConfig, + options?: { + disabled?: boolean + runtimeActive?: boolean + runtimeVisible?: boolean + }, +): McpDoctorDefinition { + return { + name, + sourceType: getSourceType(activeConfig), + sourcePath: + getSourceType(activeConfig) === 'plugin' + ? `plugin:${activeConfig.pluginSource ?? 'unknown'}` + : getSourceType(activeConfig) === 'claudeai' + ? 'claude.ai' + : activeConfig.scope, + transport: getTransport(activeConfig), + runtimeVisible: options?.runtimeVisible ?? true, + runtimeActive: options?.runtimeActive ?? true, + disabled: options?.disabled ?? false, + } +} + +function hasDefinitionForRuntimeSource( + definitions: McpDoctorDefinition[], + runtimeConfig: ScopedMcpServerConfig, + deps: McpDoctorDependencies, +): boolean { + const runtimeSourceType = getSourceType(runtimeConfig) + const runtimeSourcePath = + runtimeSourceType === 'plugin' + ? `plugin:${runtimeConfig.pluginSource ?? 'unknown'}` + : runtimeSourceType === 'claudeai' + ? 'claude.ai' + : deps.describeMcpConfigFilePath(runtimeConfig.scope) + + return definitions.some( + definition => + definition.sourceType === runtimeSourceType && + definition.sourcePath === runtimeSourcePath && + definition.transport === getTransport(runtimeConfig), + ) +} + +function buildShadowingFindings(definitions: McpDoctorDefinition[]): McpDoctorFinding[] { + const userEditable = definitions.filter(definition => + definition.sourceType === 'local' || + definition.sourceType === 'project' || + definition.sourceType === 'user' || + definition.sourceType === 'enterprise', + ) + + if (userEditable.length <= 1) { + return [] + } + + const active = userEditable.find(definition => definition.runtimeActive) ?? userEditable[0] + return [ + { + blocking: false, + code: 'duplicate.same_name_multiple_scopes', + message: `Server is defined in multiple config scopes; active source is ${active.sourceType}`, + remediation: 'Remove or rename one of the duplicate definitions to avoid confusion.', + serverName: active.name, + severity: 'warn', + }, + { + blocking: false, + code: 'scope.shadowed', + message: `${active.name} has shadowed definitions in lower-precedence config scopes.`, + remediation: 'Inspect the other definitions and remove the ones you no longer want to keep.', + serverName: active.name, + severity: 'warn', + }, + ] +} + +function buildStateFindings(definitions: McpDoctorDefinition[]): McpDoctorFinding[] { + const findings: McpDoctorFinding[] = [] + + for (const definition of definitions) { + if (definition.pendingApproval) { + findings.push({ + blocking: false, + code: 'state.pending_project_approval', + message: `${definition.name} is declared in project config but pending project approval.`, + remediation: 'Approve the server in the project MCP approval flow before expecting it to become active.', + scope: 'project', + serverName: definition.name, + severity: 'warn', + sourcePath: definition.sourcePath, + }) + } + + if (definition.disabled) { + findings.push({ + blocking: false, + code: 'state.disabled', + message: `${definition.name} is currently disabled.`, + remediation: 'Re-enable the server before expecting it to be available at runtime.', + serverName: definition.name, + severity: 'warn', + sourcePath: definition.sourcePath, + }) + } + } + + return findings +} + +function summarizeReport(report: McpDoctorReport): McpDoctorReport { + const allFindings = [...report.findings, ...report.servers.flatMap(server => server.findings)] + const blocking = allFindings.filter(finding => finding.blocking).length + const warnings = allFindings.filter(finding => finding.severity === 'warn').length + const healthy = report.servers.filter( + server => + server.liveCheck.result === 'connected' && + server.findings.every(finding => !finding.blocking && finding.severity !== 'warn'), + ).length + + return { + ...report, + summary: { + totalReports: report.servers.length, + healthy, + warnings, + blocking, + }, + } +} + +async function getLiveCheck( + name: string, + activeConfig: ScopedMcpServerConfig | undefined, + configOnly: boolean, + definitions: McpDoctorDefinition[], + deps: McpDoctorDependencies, +): Promise { + if (configOnly) { + return { attempted: false, result: 'skipped' } + } + + if (!activeConfig) { + if (definitions.some(definition => definition.pendingApproval)) { + return { attempted: false, result: 'pending' } + } + if (definitions.some(definition => definition.disabled)) { + return { attempted: false, result: 'disabled' } + } + return { attempted: false, result: 'skipped' } + } + + const startedAt = Date.now() + const connection = await deps.connectToServer(name, activeConfig) + const durationMs = Date.now() - startedAt + + try { + switch (connection.type) { + case 'connected': + return { attempted: true, result: 'connected', durationMs } + case 'needs-auth': + return { attempted: true, result: 'needs-auth', durationMs } + case 'disabled': + return { attempted: true, result: 'disabled', durationMs } + case 'pending': + return { attempted: true, result: 'pending', durationMs } + case 'failed': + return { + attempted: true, + result: 'failed', + durationMs, + error: connection.error, + } + } + } finally { + await deps.clearServerCache(name, activeConfig).catch(() => { + // Best-effort cleanup for diagnostic connections. + }) + } +} + +function buildLiveFindings( + name: string, + definitions: McpDoctorDefinition[], + liveCheck: McpDoctorLiveCheck, +): McpDoctorFinding[] { + const activeDefinition = definitions.find(definition => definition.runtimeActive) + + if (liveCheck.result === 'needs-auth') { + return [ + { + blocking: false, + code: 'auth.needs_auth', + message: `${name} requires authentication before it can be used.`, + remediation: 'Authenticate the server and then rerun the doctor command.', + serverName: name, + severity: 'warn', + sourcePath: activeDefinition?.sourcePath, + }, + ] + } + + if (liveCheck.result === 'failed') { + const commandNotFound = + activeDefinition?.transport === 'stdio' && + typeof liveCheck.error === 'string' && + liveCheck.error.toLowerCase().includes('not found') + + return [ + { + blocking: true, + code: commandNotFound ? 'stdio.command_not_found' : 'health.failed', + message: liveCheck.error + ? `${name} failed its live health check: ${liveCheck.error}` + : `${name} failed its live health check.`, + remediation: commandNotFound + ? 'Verify the configured executable exists on PATH or use a full executable path.' + : 'Inspect the server configuration and retry the connection once the underlying problem is fixed.', + serverName: name, + severity: 'error', + sourcePath: activeDefinition?.sourcePath, + }, + ] + } + + return [] +} + +async function buildServerReport( + name: string, + options: { + configOnly: boolean + requestedByUser: boolean + scopeFilter?: McpDoctorScopeFilter + }, + validationFindingsByName: Map, + deps: McpDoctorDependencies, +): Promise { + const scopeResults = { + enterprise: deps.getMcpConfigsByScope('enterprise'), + local: deps.getMcpConfigsByScope('local'), + project: deps.getMcpConfigsByScope('project'), + user: deps.getMcpConfigsByScope('user'), + } + const { servers: activeServers } = await deps.getAllMcpConfigs() + const serverDisabled = deps.isMcpServerDisabled(name) + const runtimeConfig = activeServers[name] ?? undefined + const activeConfig = serverDisabled ? undefined : runtimeConfig + + const definitions = [ + ...(shouldIncludeScope('enterprise', options.scopeFilter) + ? buildScopeDefinitions(name, 'enterprise', scopeResults.enterprise.servers, activeConfig, deps) + : []), + ...(shouldIncludeScope('local', options.scopeFilter) + ? buildScopeDefinitions(name, 'local', scopeResults.local.servers, activeConfig, deps) + : []), + ...(shouldIncludeScope('project', options.scopeFilter) + ? buildScopeDefinitions(name, 'project', scopeResults.project.servers, activeConfig, deps) + : []), + ...(shouldIncludeScope('user', options.scopeFilter) + ? buildScopeDefinitions(name, 'user', scopeResults.user.servers, activeConfig, deps) + : []), + ] + + const shouldAddObservedDefinition = + !!runtimeConfig && + !hasDefinitionForRuntimeSource(definitions, runtimeConfig, deps) && + ((definitions.length === 0 && !options.scopeFilter) || + (definitions.length > 0 && definitions.every(definition => !definition.runtimeActive))) + + if (runtimeConfig && shouldAddObservedDefinition) { + definitions.push( + buildObservedDefinition(name, runtimeConfig, { + disabled: serverDisabled, + runtimeActive: !serverDisabled, + runtimeVisible: !serverDisabled, + }), + ) + } + + const visibleRuntimeConfig = + definitions.some(definition => definition.runtimeActive) || shouldAddObservedDefinition + ? activeConfig + : undefined + + const findings: McpDoctorFinding[] = [ + ...(validationFindingsByName.get(name) ?? []), + ...buildShadowingFindings(definitions), + ...buildStateFindings(definitions), + ] + + if (definitions.length === 0 && !shouldAddObservedDefinition) { + findings.push({ + blocking: true, + code: 'state.not_found', + message: `${name} was not found in the selected MCP configuration sources.`, + remediation: 'Check the server name and scope, or add the MCP server before retrying.', + serverName: name, + severity: 'error', + }) + } + + const liveCheck = await getLiveCheck(name, visibleRuntimeConfig, options.configOnly, definitions, deps) + findings.push(...buildLiveFindings(name, definitions, liveCheck)) + + return { + serverName: name, + requestedByUser: options.requestedByUser, + definitions, + liveCheck, + findings, + } +} + +function getServerNames( + scopeServers: Array>, + activeServers: Record, + includeActiveServers: boolean, +): string[] { + const names = new Set(includeActiveServers ? Object.keys(activeServers) : []) + for (const servers of scopeServers) { + for (const name of Object.keys(servers)) { + names.add(name) + } + } + return [...names].sort() +} + +export async function doctorAllServers( + options: { configOnly: boolean; scopeFilter?: McpDoctorScopeFilter } = { + configOnly: false, + }, + deps: McpDoctorDependencies = DEFAULT_DEPENDENCIES, +): Promise { + const report = buildEmptyDoctorReport(options) + const scopeResults = { + enterprise: deps.getMcpConfigsByScope('enterprise'), + local: deps.getMcpConfigsByScope('local'), + project: deps.getMcpConfigsByScope('project'), + user: deps.getMcpConfigsByScope('user'), + } + const validationFindings = findingsFromValidationErrors( + getValidationErrorsForSelectedScopes(scopeResults, options.scopeFilter), + ) + const { globalFindings, serverFindingsByName } = splitValidationFindings(validationFindings) + const { servers: activeServers } = await deps.getAllMcpConfigs() + const names = getServerNames( + [ + ...(shouldIncludeScope('enterprise', options.scopeFilter) ? [scopeResults.enterprise.servers] : []), + ...(shouldIncludeScope('local', options.scopeFilter) ? [scopeResults.local.servers] : []), + ...(shouldIncludeScope('project', options.scopeFilter) ? [scopeResults.project.servers] : []), + ...(shouldIncludeScope('user', options.scopeFilter) ? [scopeResults.user.servers] : []), + ], + activeServers, + !options.scopeFilter, + ) + + const servers = await Promise.all( + names.map(name => + buildServerReport( + name, + { + configOnly: options.configOnly, + requestedByUser: false, + scopeFilter: options.scopeFilter, + }, + serverFindingsByName, + deps, + ), + ), + ) + + report.servers = servers + report.findings = globalFindings + return summarizeReport(report) +} + +export async function doctorServer( + name: string, + options: { configOnly: boolean; scopeFilter?: McpDoctorScopeFilter }, + deps: McpDoctorDependencies = DEFAULT_DEPENDENCIES, +): Promise { + const report = buildEmptyDoctorReport({ ...options, targetName: name }) + const scopeResults = { + enterprise: deps.getMcpConfigsByScope('enterprise'), + local: deps.getMcpConfigsByScope('local'), + project: deps.getMcpConfigsByScope('project'), + user: deps.getMcpConfigsByScope('user'), + } + const validationFindings = findingsFromValidationErrors( + getValidationErrorsForSelectedScopes(scopeResults, options.scopeFilter), + ) + const { globalFindings, serverFindingsByName } = splitValidationFindings(validationFindings) + const server = await buildServerReport( + name, + { + configOnly: options.configOnly, + requestedByUser: true, + scopeFilter: options.scopeFilter, + }, + serverFindingsByName, + deps, + ) + report.servers = [server] + report.findings = globalFindings + return summarizeReport(report) +}