feat: add MCP doctor diagnostics service

Add the diagnostics core and report model for MCP health, scope, and config analysis. This creates the structured report used by both text and JSON doctor output.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
gnanam1990
2026-04-02 14:55:04 +05:30
parent 1059915c84
commit 001f89f62c
2 changed files with 1235 additions and 0 deletions

695
src/services/mcp/doctor.ts Normal file
View File

@@ -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<string, McpDoctorFinding[]>
} {
const globalFindings: McpDoctorFinding[] = []
const serverFindingsByName = new Map<string, McpDoctorFinding[]>()
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<string, ScopedMcpServerConfig>,
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<McpDoctorDependencies['getMcpConfigsByScope']>
local: ReturnType<McpDoctorDependencies['getMcpConfigsByScope']>
project: ReturnType<McpDoctorDependencies['getMcpConfigsByScope']>
user: ReturnType<McpDoctorDependencies['getMcpConfigsByScope']>
},
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<McpDoctorLiveCheck> {
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<string, McpDoctorFinding[]>,
deps: McpDoctorDependencies,
): Promise<McpDoctorServerReport> {
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<Record<string, ScopedMcpServerConfig>>,
activeServers: Record<string, ScopedMcpServerConfig>,
includeActiveServers: boolean,
): string[] {
const names = new Set<string>(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<McpDoctorReport> {
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<McpDoctorReport> {
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)
}