feat: add MCP doctor diagnostics service
Add the diagnostics core and report model for MCP health, scope, and config analysis. This creates the structured report used by both text and JSON doctor output. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
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