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:
695
src/services/mcp/doctor.ts
Normal file
695
src/services/mcp/doctor.ts
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user