Merge pull request #137 from gnanam1990/feat/mcp-doctor
feat(mcp): add doctor diagnostics command
This commit is contained in:
@@ -12,6 +12,7 @@ import { render } from '../../ink.js';
|
|||||||
import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js';
|
import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js';
|
||||||
import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js';
|
import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js';
|
||||||
import { clearMcpClientConfig, clearServerTokensFromLocalStorage, getMcpClientConfig, readClientSecret, saveMcpClientSecret } from '../../services/mcp/auth.js';
|
import { clearMcpClientConfig, clearServerTokensFromLocalStorage, getMcpClientConfig, readClientSecret, saveMcpClientSecret } from '../../services/mcp/auth.js';
|
||||||
|
import { doctorAllServers, doctorServer, type McpDoctorReport, type McpDoctorScopeFilter } from '../../services/mcp/doctor.js';
|
||||||
import { connectToServer, getMcpServerConnectionBatchSize } from '../../services/mcp/client.js';
|
import { connectToServer, getMcpServerConnectionBatchSize } from '../../services/mcp/client.js';
|
||||||
import { addMcpConfig, getAllMcpConfigs, getMcpConfigByName, getMcpConfigsByScope, removeMcpConfig } from '../../services/mcp/config.js';
|
import { addMcpConfig, getAllMcpConfigs, getMcpConfigByName, getMcpConfigsByScope, removeMcpConfig } from '../../services/mcp/config.js';
|
||||||
import type { ConfigScope, ScopedMcpServerConfig } from '../../services/mcp/types.js';
|
import type { ConfigScope, ScopedMcpServerConfig } from '../../services/mcp/types.js';
|
||||||
@@ -23,6 +24,102 @@ import { gracefulShutdown } from '../../utils/gracefulShutdown.js';
|
|||||||
import { safeParseJSON } from '../../utils/json.js';
|
import { safeParseJSON } from '../../utils/json.js';
|
||||||
import { getPlatform } from '../../utils/platform.js';
|
import { getPlatform } from '../../utils/platform.js';
|
||||||
import { cliError, cliOk } from '../exit.js';
|
import { cliError, cliOk } from '../exit.js';
|
||||||
|
|
||||||
|
function formatDoctorReport(report: McpDoctorReport): string {
|
||||||
|
const lines: string[] = []
|
||||||
|
lines.push('MCP Doctor')
|
||||||
|
lines.push('')
|
||||||
|
lines.push('Summary')
|
||||||
|
lines.push(`- ${report.summary.totalReports} server reports generated`)
|
||||||
|
lines.push(`- ${report.summary.healthy} healthy`)
|
||||||
|
lines.push(`- ${report.summary.warnings} warnings`)
|
||||||
|
lines.push(`- ${report.summary.blocking} blocking issues`)
|
||||||
|
|
||||||
|
if (report.targetName) {
|
||||||
|
lines.push(`- target: ${report.targetName}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const server of report.servers) {
|
||||||
|
lines.push('')
|
||||||
|
lines.push(server.serverName)
|
||||||
|
|
||||||
|
const activeDefinition = server.definitions.find(definition => definition.runtimeActive)
|
||||||
|
if (activeDefinition) {
|
||||||
|
lines.push(`- Active source: ${activeDefinition.sourceType}`)
|
||||||
|
lines.push(`- Transport: ${activeDefinition.transport ?? 'unknown'}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.definitions.length > 1) {
|
||||||
|
const extraDefinitions = server.definitions
|
||||||
|
.filter(definition => !definition.runtimeActive)
|
||||||
|
.map(definition => definition.sourceType)
|
||||||
|
if (extraDefinitions.length > 0) {
|
||||||
|
lines.push(`- Additional definitions: ${extraDefinitions.join(', ')}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.liveCheck.result) {
|
||||||
|
const stateLikeResults = new Set(['disabled', 'pending', 'skipped'])
|
||||||
|
const label = stateLikeResults.has(server.liveCheck.result)
|
||||||
|
? 'State'
|
||||||
|
: 'Live check'
|
||||||
|
lines.push(`- ${label}: ${server.liveCheck.result}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.liveCheck.error) {
|
||||||
|
lines.push(`- Error: ${server.liveCheck.error}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const finding of server.findings) {
|
||||||
|
lines.push(`- ${finding.message}`)
|
||||||
|
if (finding.remediation) {
|
||||||
|
lines.push(`- Fix: ${finding.remediation}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (report.findings.length > 0) {
|
||||||
|
lines.push('')
|
||||||
|
lines.push('Global findings')
|
||||||
|
for (const finding of report.findings) {
|
||||||
|
lines.push(`- ${finding.message}`)
|
||||||
|
if (finding.remediation) {
|
||||||
|
lines.push(`- Fix: ${finding.remediation}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mcpDoctorHandler(name: string | undefined, options: {
|
||||||
|
scope?: string;
|
||||||
|
configOnly?: boolean;
|
||||||
|
json?: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
const scopeFilter = options.scope ? ensureConfigScope(options.scope) as McpDoctorScopeFilter : undefined
|
||||||
|
const configOnly = !!options.configOnly
|
||||||
|
const report = name
|
||||||
|
? await doctorServer(name, { configOnly, scopeFilter })
|
||||||
|
: await doctorAllServers({ configOnly, scopeFilter })
|
||||||
|
|
||||||
|
if (options.json) {
|
||||||
|
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
|
||||||
|
} else {
|
||||||
|
process.stdout.write(`${formatDoctorReport(report)}\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// On Windows, exiting immediately after a single failed HTTP MCP health check
|
||||||
|
// can trip a libuv assertion while async handle shutdown is still settling.
|
||||||
|
// Let the event loop drain briefly before exiting this one-shot command.
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50))
|
||||||
|
process.exit(report.summary.blocking > 0 ? 1 : 0)
|
||||||
|
return
|
||||||
|
} catch (error) {
|
||||||
|
cliError((error as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
async function checkMcpServerHealth(name: string, server: ScopedMcpServerConfig): Promise<string> {
|
async function checkMcpServerHealth(name: string, server: ScopedMcpServerConfig): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const result = await connectToServer(name, server);
|
const result = await connectToServer(name, server);
|
||||||
|
|||||||
19
src/commands/mcp/doctorCommand.test.ts
Normal file
19
src/commands/mcp/doctorCommand.test.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { Command } from '@commander-js/extra-typings'
|
||||||
|
|
||||||
|
import { registerMcpDoctorCommand } from './doctorCommand.js'
|
||||||
|
|
||||||
|
test('registerMcpDoctorCommand adds the doctor subcommand with expected options', () => {
|
||||||
|
const mcp = new Command('mcp')
|
||||||
|
|
||||||
|
registerMcpDoctorCommand(mcp)
|
||||||
|
|
||||||
|
const doctor = mcp.commands.find(command => command.name() === 'doctor')
|
||||||
|
assert.ok(doctor)
|
||||||
|
assert.equal(doctor?.usage(), '[options] [name]')
|
||||||
|
|
||||||
|
const optionFlags = doctor?.options.map(option => option.long)
|
||||||
|
assert.deepEqual(optionFlags, ['--scope', '--config-only', '--json'])
|
||||||
|
})
|
||||||
25
src/commands/mcp/doctorCommand.ts
Normal file
25
src/commands/mcp/doctorCommand.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* MCP doctor CLI subcommand.
|
||||||
|
*/
|
||||||
|
import { type Command } from '@commander-js/extra-typings'
|
||||||
|
|
||||||
|
export function registerMcpDoctorCommand(mcp: Command): void {
|
||||||
|
mcp
|
||||||
|
.command('doctor [name]')
|
||||||
|
.description(
|
||||||
|
'Diagnose MCP configuration, precedence, disabled/pending state, and connection health. ' +
|
||||||
|
'Note: unless --config-only is used, stdio servers may be spawned and remote servers may be contacted. ' +
|
||||||
|
'Only use this command in directories you trust.',
|
||||||
|
)
|
||||||
|
.option('-s, --scope <scope>', 'Restrict config analysis to a specific scope (local, project, user, or enterprise)')
|
||||||
|
.option('--config-only', 'Skip live connection checks and only analyze configuration state')
|
||||||
|
.option('--json', 'Output the diagnostics report as JSON')
|
||||||
|
.action(async (name: string | undefined, options: {
|
||||||
|
scope?: string
|
||||||
|
configOnly?: boolean
|
||||||
|
json?: boolean
|
||||||
|
}) => {
|
||||||
|
const { mcpDoctorHandler } = await import('../../cli/handlers/mcp.js')
|
||||||
|
await mcpDoctorHandler(name, options)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -139,6 +139,7 @@ import { validateUuid } from './utils/uuid.js';
|
|||||||
// Plugin startup checks are now handled non-blockingly in REPL.tsx
|
// Plugin startup checks are now handled non-blockingly in REPL.tsx
|
||||||
|
|
||||||
import { registerMcpAddCommand } from 'src/commands/mcp/addCommand.js';
|
import { registerMcpAddCommand } from 'src/commands/mcp/addCommand.js';
|
||||||
|
import { registerMcpDoctorCommand } from 'src/commands/mcp/doctorCommand.js';
|
||||||
import { registerMcpXaaIdpCommand } from 'src/commands/mcp/xaaIdpCommand.js';
|
import { registerMcpXaaIdpCommand } from 'src/commands/mcp/xaaIdpCommand.js';
|
||||||
import { logPermissionContextForAnts } from 'src/services/internalLogging.js';
|
import { logPermissionContextForAnts } from 'src/services/internalLogging.js';
|
||||||
import { fetchClaudeAIMcpConfigsIfEligible } from 'src/services/mcp/claudeai.js';
|
import { fetchClaudeAIMcpConfigsIfEligible } from 'src/services/mcp/claudeai.js';
|
||||||
@@ -3891,6 +3892,7 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
|
|
||||||
// Register the mcp add subcommand (extracted for testability)
|
// Register the mcp add subcommand (extracted for testability)
|
||||||
registerMcpAddCommand(mcp);
|
registerMcpAddCommand(mcp);
|
||||||
|
registerMcpDoctorCommand(mcp);
|
||||||
if (isXaaEnabled()) {
|
if (isXaaEnabled()) {
|
||||||
registerMcpXaaIdpCommand(mcp);
|
registerMcpXaaIdpCommand(mcp);
|
||||||
}
|
}
|
||||||
|
|||||||
48
src/services/mcp/client.test.ts
Normal file
48
src/services/mcp/client.test.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { cleanupFailedConnection } from './client.js'
|
||||||
|
|
||||||
|
test('cleanupFailedConnection awaits transport close before resolving', async () => {
|
||||||
|
let closed = false
|
||||||
|
let resolveClose: (() => void) | undefined
|
||||||
|
|
||||||
|
const transport = {
|
||||||
|
close: async () =>
|
||||||
|
await new Promise<void>(resolve => {
|
||||||
|
resolveClose = () => {
|
||||||
|
closed = true
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanupPromise = cleanupFailedConnection(transport)
|
||||||
|
|
||||||
|
assert.equal(closed, false)
|
||||||
|
resolveClose?.()
|
||||||
|
await cleanupPromise
|
||||||
|
assert.equal(closed, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cleanupFailedConnection closes in-process server and transport', async () => {
|
||||||
|
let inProcessClosed = false
|
||||||
|
let transportClosed = false
|
||||||
|
|
||||||
|
const inProcessServer = {
|
||||||
|
close: async () => {
|
||||||
|
inProcessClosed = true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const transport = {
|
||||||
|
close: async () => {
|
||||||
|
transportClosed = true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await cleanupFailedConnection(transport, inProcessServer)
|
||||||
|
|
||||||
|
assert.equal(inProcessClosed, true)
|
||||||
|
assert.equal(transportClosed, true)
|
||||||
|
})
|
||||||
@@ -560,6 +560,22 @@ function getRemoteMcpServerConnectionBatchSize(): number {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type InProcessMcpServer = {
|
||||||
|
connect(t: Transport): Promise<void>
|
||||||
|
close(): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupFailedConnection(
|
||||||
|
transport: Pick<Transport, 'close'>,
|
||||||
|
inProcessServer?: Pick<InProcessMcpServer, 'close'>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (inProcessServer) {
|
||||||
|
await inProcessServer.close().catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
await transport.close().catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
function isLocalMcpServer(config: ScopedMcpServerConfig): boolean {
|
function isLocalMcpServer(config: ScopedMcpServerConfig): boolean {
|
||||||
return !config.type || config.type === 'stdio' || config.type === 'sdk'
|
return !config.type || config.type === 'stdio' || config.type === 'sdk'
|
||||||
}
|
}
|
||||||
@@ -606,9 +622,7 @@ export const connectToServer = memoize(
|
|||||||
},
|
},
|
||||||
): Promise<MCPServerConnection> => {
|
): Promise<MCPServerConnection> => {
|
||||||
const connectStartTime = Date.now()
|
const connectStartTime = Date.now()
|
||||||
let inProcessServer:
|
let inProcessServer: InProcessMcpServer | undefined
|
||||||
| { connect(t: Transport): Promise<void>; close(): Promise<void> }
|
|
||||||
| undefined
|
|
||||||
try {
|
try {
|
||||||
let transport
|
let transport
|
||||||
|
|
||||||
@@ -1145,9 +1159,10 @@ export const connectToServer = memoize(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (inProcessServer) {
|
if (inProcessServer) {
|
||||||
inProcessServer.close().catch(() => { })
|
await cleanupFailedConnection(transport, inProcessServer)
|
||||||
|
} else {
|
||||||
|
await cleanupFailedConnection(transport)
|
||||||
}
|
}
|
||||||
transport.close().catch(() => { })
|
|
||||||
if (stderrOutput) {
|
if (stderrOutput) {
|
||||||
logMCPError(name, `Server stderr: ${stderrOutput}`)
|
logMCPError(name, `Server stderr: ${stderrOutput}`)
|
||||||
}
|
}
|
||||||
|
|||||||
540
src/services/mcp/doctor.test.ts
Normal file
540
src/services/mcp/doctor.test.ts
Normal file
@@ -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> = {}): 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)
|
||||||
|
})
|
||||||
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