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 { 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 { doctorAllServers, doctorServer, type McpDoctorReport, type McpDoctorScopeFilter } from '../../services/mcp/doctor.js';
|
||||
import { connectToServer, getMcpServerConnectionBatchSize } from '../../services/mcp/client.js';
|
||||
import { addMcpConfig, getAllMcpConfigs, getMcpConfigByName, getMcpConfigsByScope, removeMcpConfig } from '../../services/mcp/config.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 { getPlatform } from '../../utils/platform.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> {
|
||||
try {
|
||||
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
|
||||
|
||||
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 { logPermissionContextForAnts } from 'src/services/internalLogging.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)
|
||||
registerMcpAddCommand(mcp);
|
||||
registerMcpDoctorCommand(mcp);
|
||||
if (isXaaEnabled()) {
|
||||
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 {
|
||||
return !config.type || config.type === 'stdio' || config.type === 'sdk'
|
||||
}
|
||||
@@ -606,9 +622,7 @@ export const connectToServer = memoize(
|
||||
},
|
||||
): Promise<MCPServerConnection> => {
|
||||
const connectStartTime = Date.now()
|
||||
let inProcessServer:
|
||||
| { connect(t: Transport): Promise<void>; close(): Promise<void> }
|
||||
| undefined
|
||||
let inProcessServer: InProcessMcpServer | undefined
|
||||
try {
|
||||
let transport
|
||||
|
||||
@@ -1145,9 +1159,10 @@ export const connectToServer = memoize(
|
||||
})
|
||||
}
|
||||
if (inProcessServer) {
|
||||
inProcessServer.close().catch(() => { })
|
||||
await cleanupFailedConnection(transport, inProcessServer)
|
||||
} else {
|
||||
await cleanupFailedConnection(transport)
|
||||
}
|
||||
transport.close().catch(() => { })
|
||||
if (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