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) })