feat(mcp): add doctor command
Add the MCP doctor subcommand with text and JSON output, config-only mode, and scope filtering so users can diagnose MCP issues from the CLI. Co-Authored-By: Claude <noreply@anthropic.com>
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';
|
||||||
@@ -3887,6 +3888,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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user