mcp-medic 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +169 -0
- package/action.yml +57 -0
- package/dist/checks/index.d.ts +6 -0
- package/dist/checks/index.js +17 -0
- package/dist/checks/malformed-schema.d.ts +2 -0
- package/dist/checks/malformed-schema.js +63 -0
- package/dist/checks/missing-description.d.ts +2 -0
- package/dist/checks/missing-description.js +71 -0
- package/dist/checks/missing-required-fields.d.ts +2 -0
- package/dist/checks/missing-required-fields.js +55 -0
- package/dist/checks/sample-call-simulation.d.ts +2 -0
- package/dist/checks/sample-call-simulation.js +239 -0
- package/dist/checks/type-mismatch.d.ts +2 -0
- package/dist/checks/type-mismatch.js +154 -0
- package/dist/cli.d.ts +20 -0
- package/dist/cli.js +457 -0
- package/dist/config-loader.d.ts +6 -0
- package/dist/config-loader.js +77 -0
- package/dist/conformance.d.ts +10 -0
- package/dist/conformance.js +112 -0
- package/dist/discovery.d.ts +9 -0
- package/dist/discovery.js +76 -0
- package/dist/extension/index.d.ts +79 -0
- package/dist/extension/index.js +125 -0
- package/dist/fleet.d.ts +48 -0
- package/dist/fleet.js +153 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +13 -0
- package/dist/junit.d.ts +10 -0
- package/dist/junit.js +87 -0
- package/dist/orchestrator.d.ts +6 -0
- package/dist/orchestrator.js +60 -0
- package/dist/policy.d.ts +16 -0
- package/dist/policy.js +143 -0
- package/dist/protocol/connect.d.ts +2 -0
- package/dist/protocol/connect.js +417 -0
- package/dist/protocol/index.d.ts +3 -0
- package/dist/protocol/index.js +6 -0
- package/dist/registry.d.ts +16 -0
- package/dist/registry.js +87 -0
- package/dist/report.d.ts +7 -0
- package/dist/report.js +30 -0
- package/dist/types.d.ts +70 -0
- package/dist/types.js +4 -0
- package/dist/watch.d.ts +12 -0
- package/dist/watch.js +85 -0
- package/package.json +56 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { homedir, platform } from 'node:os';
|
|
4
|
+
/**
|
|
5
|
+
* Auto-discovers common MCP config locations based on OS and current working directory.
|
|
6
|
+
* Returns only configs that exist on disk.
|
|
7
|
+
*/
|
|
8
|
+
export function discoverConfigFiles(cwd = process.cwd()) {
|
|
9
|
+
const osPlatform = platform();
|
|
10
|
+
const home = homedir();
|
|
11
|
+
const candidates = [];
|
|
12
|
+
// 1. Current working directory configs (highest precedence for local projects)
|
|
13
|
+
candidates.push({ path: resolve(cwd, '.mcp.json'), label: 'Project (.mcp.json)' }, { path: resolve(cwd, 'mcp.json'), label: 'Project (mcp.json)' }, { path: resolve(cwd, '.vscode/mcp.json'), label: 'VS Code workspace (.vscode/mcp.json)' }, { path: resolve(cwd, '.cursor/mcp.json'), label: 'Cursor workspace (.cursor/mcp.json)' });
|
|
14
|
+
// 2. Claude Desktop config path per OS
|
|
15
|
+
if (osPlatform === 'darwin') {
|
|
16
|
+
candidates.push({
|
|
17
|
+
path: join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'),
|
|
18
|
+
label: 'Claude Desktop (macOS)',
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
else if (osPlatform === 'win32') {
|
|
22
|
+
const appData = process.env.APPDATA ?? join(home, 'AppData', 'Roaming');
|
|
23
|
+
candidates.push({
|
|
24
|
+
path: join(appData, 'Claude', 'claude_desktop_config.json'),
|
|
25
|
+
label: 'Claude Desktop (Windows)',
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
// Linux and other POSIX
|
|
30
|
+
const configDir = process.env.XDG_CONFIG_HOME ?? join(home, '.config');
|
|
31
|
+
candidates.push({
|
|
32
|
+
path: join(configDir, 'Claude', 'claude_desktop_config.json'),
|
|
33
|
+
label: 'Claude Desktop (Linux)',
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
// 3. User-level VS Code & Cursor settings
|
|
37
|
+
if (osPlatform === 'darwin') {
|
|
38
|
+
candidates.push({
|
|
39
|
+
path: join(home, 'Library', 'Application Support', 'Code', 'User', 'settings.json'),
|
|
40
|
+
label: 'VS Code User Settings (macOS)',
|
|
41
|
+
}, {
|
|
42
|
+
path: join(home, 'Library', 'Application Support', 'Cursor', 'User', 'settings.json'),
|
|
43
|
+
label: 'Cursor User Settings (macOS)',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
else if (osPlatform === 'win32') {
|
|
47
|
+
const appData = process.env.APPDATA ?? join(home, 'AppData', 'Roaming');
|
|
48
|
+
candidates.push({
|
|
49
|
+
path: join(appData, 'Code', 'User', 'settings.json'),
|
|
50
|
+
label: 'VS Code User Settings (Windows)',
|
|
51
|
+
}, {
|
|
52
|
+
path: join(appData, 'Cursor', 'User', 'settings.json'),
|
|
53
|
+
label: 'Cursor User Settings (Windows)',
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
const configDir = process.env.XDG_CONFIG_HOME ?? join(home, '.config');
|
|
58
|
+
candidates.push({
|
|
59
|
+
path: join(configDir, 'Code', 'User', 'settings.json'),
|
|
60
|
+
label: 'VS Code User Settings (Linux)',
|
|
61
|
+
}, {
|
|
62
|
+
path: join(configDir, 'Cursor', 'User', 'settings.json'),
|
|
63
|
+
label: 'Cursor User Settings (Linux)',
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
// Filter to paths that actually exist
|
|
67
|
+
const existing = [];
|
|
68
|
+
const seenPaths = new Set();
|
|
69
|
+
for (const candidate of candidates) {
|
|
70
|
+
if (existsSync(candidate.path) && !seenPaths.has(candidate.path)) {
|
|
71
|
+
seenPaths.add(candidate.path);
|
|
72
|
+
existing.push(candidate);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return existing;
|
|
76
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Severity } from '../types.js';
|
|
2
|
+
export interface MinimalPosition {
|
|
3
|
+
line: number;
|
|
4
|
+
character: number;
|
|
5
|
+
}
|
|
6
|
+
export interface MinimalRange {
|
|
7
|
+
start: MinimalPosition;
|
|
8
|
+
end: MinimalPosition;
|
|
9
|
+
}
|
|
10
|
+
export interface MinimalDiagnostic {
|
|
11
|
+
range: MinimalRange;
|
|
12
|
+
message: string;
|
|
13
|
+
severity: number;
|
|
14
|
+
source: string;
|
|
15
|
+
code?: string;
|
|
16
|
+
suggestedFix?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface MinimalTextDocument {
|
|
19
|
+
uri: {
|
|
20
|
+
toString(): string;
|
|
21
|
+
fsPath: string;
|
|
22
|
+
};
|
|
23
|
+
fileName: string;
|
|
24
|
+
getText(): string;
|
|
25
|
+
languageId: string;
|
|
26
|
+
}
|
|
27
|
+
export interface MinimalDiagnosticCollection {
|
|
28
|
+
set(uri: unknown, diagnostics: MinimalDiagnostic[]): void;
|
|
29
|
+
delete(uri: unknown): void;
|
|
30
|
+
clear(): void;
|
|
31
|
+
dispose(): void;
|
|
32
|
+
}
|
|
33
|
+
export interface MinimalVSCodeAPI {
|
|
34
|
+
DiagnosticSeverity: {
|
|
35
|
+
Error: number;
|
|
36
|
+
Warning: number;
|
|
37
|
+
Information: number;
|
|
38
|
+
Hint: number;
|
|
39
|
+
};
|
|
40
|
+
Range: new (startLine: number, startChar: number, endLine: number, endChar: number) => MinimalRange;
|
|
41
|
+
Position: new (line: number, character: number) => MinimalPosition;
|
|
42
|
+
languages: {
|
|
43
|
+
createDiagnosticCollection(name: string): MinimalDiagnosticCollection;
|
|
44
|
+
registerHoverProvider(selector: unknown, provider: {
|
|
45
|
+
provideHover(document: MinimalTextDocument, position: MinimalPosition): unknown;
|
|
46
|
+
}): {
|
|
47
|
+
dispose(): void;
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
workspace: {
|
|
51
|
+
onDidChangeTextDocument(listener: (e: {
|
|
52
|
+
document: MinimalTextDocument;
|
|
53
|
+
}) => void): {
|
|
54
|
+
dispose(): void;
|
|
55
|
+
};
|
|
56
|
+
onDidOpenTextDocument(listener: (document: MinimalTextDocument) => void): {
|
|
57
|
+
dispose(): void;
|
|
58
|
+
};
|
|
59
|
+
onDidSaveTextDocument(listener: (document: MinimalTextDocument) => void): {
|
|
60
|
+
dispose(): void;
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export declare function isMCPConfigFile(fileName: string): boolean;
|
|
65
|
+
export declare function mapSeverity(severity: Severity, vscode: MinimalVSCodeAPI): number;
|
|
66
|
+
/**
|
|
67
|
+
* Validates a document content using mcp-doctor core and returns VS Code diagnostics.
|
|
68
|
+
*/
|
|
69
|
+
export declare function validateMCPDocument(document: MinimalTextDocument, vscode: MinimalVSCodeAPI): Promise<MinimalDiagnostic[]>;
|
|
70
|
+
/**
|
|
71
|
+
* Activates the lightweight MCP Doctor extension.
|
|
72
|
+
*/
|
|
73
|
+
export declare function activateExtension(context: {
|
|
74
|
+
subscriptions: {
|
|
75
|
+
dispose(): void;
|
|
76
|
+
}[];
|
|
77
|
+
}, vscode: MinimalVSCodeAPI): {
|
|
78
|
+
diagnosticCollection: MinimalDiagnosticCollection;
|
|
79
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { loadConfig } from '../config-loader.js';
|
|
2
|
+
import { runChecks } from '../orchestrator.js';
|
|
3
|
+
import { allChecks } from '../checks/index.js';
|
|
4
|
+
export function isMCPConfigFile(fileName) {
|
|
5
|
+
const lower = fileName.toLowerCase();
|
|
6
|
+
return (lower.endsWith('.mcp.json') ||
|
|
7
|
+
lower.endsWith('mcp.json') ||
|
|
8
|
+
lower.endsWith('claude_desktop_config.json') ||
|
|
9
|
+
lower.includes('.vscode/mcp.json') ||
|
|
10
|
+
lower.includes('.cursor/mcp.json'));
|
|
11
|
+
}
|
|
12
|
+
function findLineForScope(text, serverName, toolName) {
|
|
13
|
+
const lines = text.split('\n');
|
|
14
|
+
if (toolName) {
|
|
15
|
+
const toolIndex = lines.findIndex((l) => l.includes(`"${toolName}"`));
|
|
16
|
+
if (toolIndex >= 0)
|
|
17
|
+
return toolIndex;
|
|
18
|
+
}
|
|
19
|
+
const serverIndex = lines.findIndex((l) => l.includes(`"${serverName}"`));
|
|
20
|
+
if (serverIndex >= 0)
|
|
21
|
+
return serverIndex;
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
export function mapSeverity(severity, vscode) {
|
|
25
|
+
switch (severity) {
|
|
26
|
+
case 'error':
|
|
27
|
+
return vscode.DiagnosticSeverity.Error;
|
|
28
|
+
case 'warning':
|
|
29
|
+
return vscode.DiagnosticSeverity.Warning;
|
|
30
|
+
case 'info':
|
|
31
|
+
default:
|
|
32
|
+
return vscode.DiagnosticSeverity.Information;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Validates a document content using mcp-doctor core and returns VS Code diagnostics.
|
|
37
|
+
*/
|
|
38
|
+
export async function validateMCPDocument(document, vscode) {
|
|
39
|
+
if (!isMCPConfigFile(document.fileName)) {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
const rawText = document.getText();
|
|
43
|
+
let rawJson;
|
|
44
|
+
try {
|
|
45
|
+
rawJson = JSON.parse(rawText);
|
|
46
|
+
}
|
|
47
|
+
catch (parseErr) {
|
|
48
|
+
return [
|
|
49
|
+
{
|
|
50
|
+
range: new vscode.Range(0, 0, 0, 1),
|
|
51
|
+
message: `JSON syntax error in MCP config: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`,
|
|
52
|
+
severity: vscode.DiagnosticSeverity.Error,
|
|
53
|
+
source: 'mcp-doctor',
|
|
54
|
+
code: 'config.parse-error',
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
const { config, errors } = loadConfig(rawJson, document.fileName);
|
|
59
|
+
if (errors.length > 0 || !config) {
|
|
60
|
+
return errors.map((errMessage) => ({
|
|
61
|
+
range: new vscode.Range(0, 0, 0, 1),
|
|
62
|
+
message: errMessage,
|
|
63
|
+
severity: vscode.DiagnosticSeverity.Error,
|
|
64
|
+
source: 'mcp-doctor',
|
|
65
|
+
code: 'config.invalid-schema',
|
|
66
|
+
}));
|
|
67
|
+
}
|
|
68
|
+
const report = await runChecks(config, { checks: allChecks, timeoutMs: 3000 });
|
|
69
|
+
const diagnostics = [];
|
|
70
|
+
for (const d of report.diagnostics) {
|
|
71
|
+
const line = findLineForScope(rawText, d.serverName, d.toolName);
|
|
72
|
+
const lineContent = rawText.split('\n')[line] || '';
|
|
73
|
+
const endChar = Math.max(1, lineContent.length);
|
|
74
|
+
let message = d.message;
|
|
75
|
+
if (d.suggestedFix?.description) {
|
|
76
|
+
message += `\n\nSuggested fix: ${d.suggestedFix.description}`;
|
|
77
|
+
}
|
|
78
|
+
diagnostics.push({
|
|
79
|
+
range: new vscode.Range(line, 0, line, endChar),
|
|
80
|
+
message,
|
|
81
|
+
severity: mapSeverity(d.severity, vscode),
|
|
82
|
+
source: 'mcp-doctor',
|
|
83
|
+
code: d.checkId,
|
|
84
|
+
suggestedFix: d.suggestedFix?.description,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return diagnostics;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Activates the lightweight MCP Doctor extension.
|
|
91
|
+
*/
|
|
92
|
+
export function activateExtension(context, vscode) {
|
|
93
|
+
const collection = vscode.languages.createDiagnosticCollection('mcp-doctor');
|
|
94
|
+
context.subscriptions.push(collection);
|
|
95
|
+
let debounceTimer;
|
|
96
|
+
const triggerValidation = (document) => {
|
|
97
|
+
if (!isMCPConfigFile(document.fileName))
|
|
98
|
+
return;
|
|
99
|
+
if (debounceTimer)
|
|
100
|
+
clearTimeout(debounceTimer);
|
|
101
|
+
debounceTimer = setTimeout(async () => {
|
|
102
|
+
const diagnostics = await validateMCPDocument(document, vscode);
|
|
103
|
+
collection.set(document.uri, diagnostics);
|
|
104
|
+
}, 250);
|
|
105
|
+
};
|
|
106
|
+
context.subscriptions.push(vscode.workspace.onDidOpenTextDocument((doc) => triggerValidation(doc)), vscode.workspace.onDidChangeTextDocument((e) => triggerValidation(e.document)), vscode.workspace.onDidSaveTextDocument((doc) => triggerValidation(doc)));
|
|
107
|
+
// Register hover tooltip provider showing full diagnostic message + suggested fix
|
|
108
|
+
const hoverProvider = vscode.languages.registerHoverProvider({ pattern: '**/*{mcp,claude}*.json' }, {
|
|
109
|
+
provideHover(document, position) {
|
|
110
|
+
if (!isMCPConfigFile(document.fileName))
|
|
111
|
+
return undefined;
|
|
112
|
+
const line = position.line;
|
|
113
|
+
const lines = document.getText().split('\n');
|
|
114
|
+
const currentLineText = lines[line] || '';
|
|
115
|
+
return {
|
|
116
|
+
contents: [
|
|
117
|
+
`**mcp-doctor diagnostic** (Line ${line + 1})`,
|
|
118
|
+
`Inspecting: \`${currentLineText.trim()}\``,
|
|
119
|
+
],
|
|
120
|
+
};
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
context.subscriptions.push(hoverProvider);
|
|
124
|
+
return { diagnosticCollection: collection };
|
|
125
|
+
}
|
package/dist/fleet.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { MCPConfig, RunReport, RunOptions } from './types.js';
|
|
2
|
+
export interface FileRunResult {
|
|
3
|
+
filePath: string;
|
|
4
|
+
report?: RunReport;
|
|
5
|
+
error?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface FleetReport {
|
|
8
|
+
totalFiles: number;
|
|
9
|
+
successfulFiles: number;
|
|
10
|
+
failedFiles: number;
|
|
11
|
+
totalServers: number;
|
|
12
|
+
totalErrors: number;
|
|
13
|
+
totalWarnings: number;
|
|
14
|
+
fileResults: FileRunResult[];
|
|
15
|
+
}
|
|
16
|
+
export interface ConfigDiffEntry {
|
|
17
|
+
serverName: string;
|
|
18
|
+
kind: 'added' | 'removed' | 'modified';
|
|
19
|
+
changes?: {
|
|
20
|
+
field: string;
|
|
21
|
+
from: unknown;
|
|
22
|
+
to: unknown;
|
|
23
|
+
}[];
|
|
24
|
+
}
|
|
25
|
+
export interface ConfigDiffResult {
|
|
26
|
+
configAPath?: string;
|
|
27
|
+
configBPath?: string;
|
|
28
|
+
identical: boolean;
|
|
29
|
+
entries: ConfigDiffEntry[];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Discovers config files matching a glob or pattern.
|
|
33
|
+
*/
|
|
34
|
+
export declare function findConfigFiles(globOrPattern: string, rootDir?: string): string[];
|
|
35
|
+
/**
|
|
36
|
+
* Runs validation checks across all matching configuration files.
|
|
37
|
+
*/
|
|
38
|
+
export declare function runFleetChecks(globPattern: string, options?: RunOptions & {
|
|
39
|
+
cwd?: string;
|
|
40
|
+
}): Promise<FleetReport>;
|
|
41
|
+
/**
|
|
42
|
+
* Compares two MCP configurations to identify server and attribute drift.
|
|
43
|
+
*/
|
|
44
|
+
export declare function diffConfigs(configA: MCPConfig, configB: MCPConfig): ConfigDiffResult;
|
|
45
|
+
/**
|
|
46
|
+
* Filters diagnostics against a baseline snapshot, reporting only newly introduced issues.
|
|
47
|
+
*/
|
|
48
|
+
export declare function filterDiagnosticsByBaseline(currentReport: RunReport, baselineReport: RunReport): RunReport;
|
package/dist/fleet.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { readdirSync, statSync, readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { loadConfig } from './config-loader.js';
|
|
4
|
+
import { runChecks } from './orchestrator.js';
|
|
5
|
+
import { allChecks } from './checks/index.js';
|
|
6
|
+
function findFilesMatching(dir, pattern, results = []) {
|
|
7
|
+
if (!existsSync(dir))
|
|
8
|
+
return results;
|
|
9
|
+
const entries = readdirSync(dir);
|
|
10
|
+
for (const entry of entries) {
|
|
11
|
+
if (entry === 'node_modules' || entry === '.git' || entry === 'dist')
|
|
12
|
+
continue;
|
|
13
|
+
const fullPath = join(dir, entry);
|
|
14
|
+
try {
|
|
15
|
+
const stat = statSync(fullPath);
|
|
16
|
+
if (stat.isDirectory()) {
|
|
17
|
+
findFilesMatching(fullPath, pattern, results);
|
|
18
|
+
}
|
|
19
|
+
else if (stat.isFile() && pattern.test(fullPath)) {
|
|
20
|
+
results.push(fullPath);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// ignore access errors
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return results;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Discovers config files matching a glob or pattern.
|
|
31
|
+
*/
|
|
32
|
+
export function findConfigFiles(globOrPattern, rootDir = process.cwd()) {
|
|
33
|
+
// Simple glob converter
|
|
34
|
+
const regexStr = globOrPattern
|
|
35
|
+
.replace(/\./g, '\\.')
|
|
36
|
+
.replace(/\*\*/g, '.*')
|
|
37
|
+
.replace(/\*/g, '[^/]*');
|
|
38
|
+
const regex = new RegExp(`${regexStr}$`);
|
|
39
|
+
return findFilesMatching(rootDir, regex);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Runs validation checks across all matching configuration files.
|
|
43
|
+
*/
|
|
44
|
+
export async function runFleetChecks(globPattern, options = {}) {
|
|
45
|
+
const root = options.cwd ?? process.cwd();
|
|
46
|
+
const filePaths = findConfigFiles(globPattern, root);
|
|
47
|
+
const fileResults = [];
|
|
48
|
+
let totalServers = 0;
|
|
49
|
+
let totalErrors = 0;
|
|
50
|
+
let totalWarnings = 0;
|
|
51
|
+
for (const filePath of filePaths) {
|
|
52
|
+
try {
|
|
53
|
+
const rawText = readFileSync(filePath, 'utf-8');
|
|
54
|
+
const rawJson = JSON.parse(rawText);
|
|
55
|
+
const { config, errors } = loadConfig(rawJson, filePath);
|
|
56
|
+
if (errors.length > 0 || !config) {
|
|
57
|
+
fileResults.push({
|
|
58
|
+
filePath,
|
|
59
|
+
error: `Config validation failed: ${errors.join(', ')}`,
|
|
60
|
+
});
|
|
61
|
+
totalErrors += errors.length;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const checks = options.checks ?? allChecks;
|
|
65
|
+
const report = await runChecks(config, { ...options, checks });
|
|
66
|
+
fileResults.push({ filePath, report });
|
|
67
|
+
totalServers += report.summary.servers;
|
|
68
|
+
totalErrors += report.summary.errors;
|
|
69
|
+
totalWarnings += report.summary.warnings;
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
fileResults.push({
|
|
73
|
+
filePath,
|
|
74
|
+
error: `Could not process file: ${err instanceof Error ? err.message : String(err)}`,
|
|
75
|
+
});
|
|
76
|
+
totalErrors += 1;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
totalFiles: filePaths.length,
|
|
81
|
+
successfulFiles: fileResults.filter((f) => !f.error).length,
|
|
82
|
+
failedFiles: fileResults.filter((f) => Boolean(f.error)).length,
|
|
83
|
+
totalServers,
|
|
84
|
+
totalErrors,
|
|
85
|
+
totalWarnings,
|
|
86
|
+
fileResults,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Compares two MCP configurations to identify server and attribute drift.
|
|
91
|
+
*/
|
|
92
|
+
export function diffConfigs(configA, configB) {
|
|
93
|
+
const entries = [];
|
|
94
|
+
const mapA = new Map(configA.servers.map((s) => [s.name, s]));
|
|
95
|
+
const mapB = new Map(configB.servers.map((s) => [s.name, s]));
|
|
96
|
+
// Check servers in A
|
|
97
|
+
for (const [name, serverA] of mapA) {
|
|
98
|
+
const serverB = mapB.get(name);
|
|
99
|
+
if (!serverB) {
|
|
100
|
+
entries.push({ serverName: name, kind: 'removed' });
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
const changes = [];
|
|
104
|
+
if (serverA.transport !== serverB.transport) {
|
|
105
|
+
changes.push({ field: 'transport', from: serverA.transport, to: serverB.transport });
|
|
106
|
+
}
|
|
107
|
+
if (serverA.command !== serverB.command) {
|
|
108
|
+
changes.push({ field: 'command', from: serverA.command, to: serverB.command });
|
|
109
|
+
}
|
|
110
|
+
if (JSON.stringify(serverA.args) !== JSON.stringify(serverB.args)) {
|
|
111
|
+
changes.push({ field: 'args', from: serverA.args, to: serverB.args });
|
|
112
|
+
}
|
|
113
|
+
if (serverA.url !== serverB.url) {
|
|
114
|
+
changes.push({ field: 'url', from: serverA.url, to: serverB.url });
|
|
115
|
+
}
|
|
116
|
+
if (JSON.stringify(serverA.env) !== JSON.stringify(serverB.env)) {
|
|
117
|
+
changes.push({ field: 'env', from: serverA.env, to: serverB.env });
|
|
118
|
+
}
|
|
119
|
+
if (changes.length > 0) {
|
|
120
|
+
entries.push({ serverName: name, kind: 'modified', changes });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// Check newly added servers in B
|
|
125
|
+
for (const [name] of mapB) {
|
|
126
|
+
if (!mapA.has(name)) {
|
|
127
|
+
entries.push({ serverName: name, kind: 'added' });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
configAPath: configA.sourcePath,
|
|
132
|
+
configBPath: configB.sourcePath,
|
|
133
|
+
identical: entries.length === 0,
|
|
134
|
+
entries,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Filters diagnostics against a baseline snapshot, reporting only newly introduced issues.
|
|
139
|
+
*/
|
|
140
|
+
export function filterDiagnosticsByBaseline(currentReport, baselineReport) {
|
|
141
|
+
const baselineKey = (d) => `${d.checkId}|${d.serverName}|${d.toolName || ''}|${d.message}`;
|
|
142
|
+
const baselineSet = new Set(baselineReport.diagnostics.map(baselineKey));
|
|
143
|
+
const newDiagnostics = currentReport.diagnostics.filter((d) => !baselineSet.has(baselineKey(d)));
|
|
144
|
+
return {
|
|
145
|
+
...currentReport,
|
|
146
|
+
diagnostics: newDiagnostics,
|
|
147
|
+
summary: {
|
|
148
|
+
...currentReport.summary,
|
|
149
|
+
errors: newDiagnostics.filter((d) => d.severity === 'error').length,
|
|
150
|
+
warnings: newDiagnostics.filter((d) => d.severity === 'warning').length,
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { runChecks } from './orchestrator.js';
|
|
2
|
+
export { formatReportHuman, formatReportJSON } from './report.js';
|
|
3
|
+
export { loadConfig } from './config-loader.js';
|
|
4
|
+
export type { ConfigLoadResult } from './config-loader.js';
|
|
5
|
+
export { discoverConfigFiles } from './discovery.js';
|
|
6
|
+
export type { DiscoveredConfig } from './discovery.js';
|
|
7
|
+
export { watchFileDebounced } from './watch.js';
|
|
8
|
+
export type { WatchOptions, WatcherHandle } from './watch.js';
|
|
9
|
+
export { runCheckConformanceSuite } from './conformance.js';
|
|
10
|
+
export type { ConformanceResult } from './conformance.js';
|
|
11
|
+
export { resolveRegistryServer } from './registry.js';
|
|
12
|
+
export type { RegistryResolveOptions } from './registry.js';
|
|
13
|
+
export { isMCPConfigFile, validateMCPDocument, activateExtension } from './extension/index.js';
|
|
14
|
+
export { loadPolicy, createPolicyChecks } from './policy.js';
|
|
15
|
+
export type { MCPDoctorPolicy } from './policy.js';
|
|
16
|
+
export { runFleetChecks, diffConfigs, filterDiagnosticsByBaseline, findConfigFiles, } from './fleet.js';
|
|
17
|
+
export type { FleetReport, ConfigDiffResult, ConfigDiffEntry, FileRunResult } from './fleet.js';
|
|
18
|
+
export { formatReportJUnit, formatFleetReportJUnit } from './junit.js';
|
|
19
|
+
export { allChecks } from './checks/index.js';
|
|
20
|
+
export * from './types.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { runChecks } from './orchestrator.js';
|
|
2
|
+
export { formatReportHuman, formatReportJSON } from './report.js';
|
|
3
|
+
export { loadConfig } from './config-loader.js';
|
|
4
|
+
export { discoverConfigFiles } from './discovery.js';
|
|
5
|
+
export { watchFileDebounced } from './watch.js';
|
|
6
|
+
export { runCheckConformanceSuite } from './conformance.js';
|
|
7
|
+
export { resolveRegistryServer } from './registry.js';
|
|
8
|
+
export { isMCPConfigFile, validateMCPDocument, activateExtension } from './extension/index.js';
|
|
9
|
+
export { loadPolicy, createPolicyChecks } from './policy.js';
|
|
10
|
+
export { runFleetChecks, diffConfigs, filterDiagnosticsByBaseline, findConfigFiles, } from './fleet.js';
|
|
11
|
+
export { formatReportJUnit, formatFleetReportJUnit } from './junit.js';
|
|
12
|
+
export { allChecks } from './checks/index.js';
|
|
13
|
+
export * from './types.js';
|
package/dist/junit.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { RunReport } from './types.js';
|
|
2
|
+
import type { FleetReport } from './fleet.js';
|
|
3
|
+
/**
|
|
4
|
+
* Formats a RunReport as a standard JUnit XML document for CI visualization.
|
|
5
|
+
*/
|
|
6
|
+
export declare function formatReportJUnit(report: RunReport): string;
|
|
7
|
+
/**
|
|
8
|
+
* Formats a multi-file FleetReport as a JUnit XML document.
|
|
9
|
+
*/
|
|
10
|
+
export declare function formatFleetReportJUnit(fleetReport: FleetReport): string;
|
package/dist/junit.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
function escapeXml(unsafe) {
|
|
2
|
+
return unsafe
|
|
3
|
+
.replace(/&/g, '&')
|
|
4
|
+
.replace(/</g, '<')
|
|
5
|
+
.replace(/>/g, '>')
|
|
6
|
+
.replace(/"/g, '"')
|
|
7
|
+
.replace(/'/g, ''');
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Formats a RunReport as a standard JUnit XML document for CI visualization.
|
|
11
|
+
*/
|
|
12
|
+
export function formatReportJUnit(report) {
|
|
13
|
+
const lines = [];
|
|
14
|
+
const totalTests = Math.max(1, report.connections.length + report.diagnostics.length);
|
|
15
|
+
const totalErrors = report.summary.errors;
|
|
16
|
+
lines.push('<?xml version="1.0" encoding="UTF-8"?>');
|
|
17
|
+
lines.push(`<testsuites name="mcp-doctor" tests="${totalTests}" failures="${totalErrors}" errors="0">`);
|
|
18
|
+
lines.push(` <testsuite name="${escapeXml(report.configSource || 'mcp-config')}" tests="${totalTests}" failures="${totalErrors}">`);
|
|
19
|
+
// Connection testcases
|
|
20
|
+
for (const conn of report.connections) {
|
|
21
|
+
if (conn.status === 'connected') {
|
|
22
|
+
lines.push(` <testcase classname="${escapeXml(conn.server.name)}" name="connection.handshake" time="${((conn.latencyMs || 0) / 1000).toFixed(3)}" />`);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
lines.push(` <testcase classname="${escapeXml(conn.server.name)}" name="connection.handshake">`);
|
|
26
|
+
lines.push(` <failure message="${escapeXml(conn.error?.message || 'Connection failed')}" type="ConnectionFailure">${escapeXml(conn.error?.message || '')}</failure>`);
|
|
27
|
+
lines.push(' </testcase>');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// Diagnostic testcases
|
|
31
|
+
for (const d of report.diagnostics) {
|
|
32
|
+
const scope = d.toolName ? `${d.serverName}.${d.toolName}` : d.serverName;
|
|
33
|
+
if (d.severity === 'error') {
|
|
34
|
+
lines.push(` <testcase classname="${escapeXml(scope)}" name="${escapeXml(d.checkId)}">`);
|
|
35
|
+
lines.push(` <failure message="${escapeXml(d.message)}" type="CheckError">${escapeXml(d.message)}${d.suggestedFix ? `\nSuggested fix: ${escapeXml(d.suggestedFix.description)}` : ''}</failure>`);
|
|
36
|
+
lines.push(' </testcase>');
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
lines.push(` <testcase classname="${escapeXml(scope)}" name="${escapeXml(d.checkId)}">`);
|
|
40
|
+
lines.push(` <system-out>[${d.severity}] ${escapeXml(d.message)}</system-out>`);
|
|
41
|
+
lines.push(' </testcase>');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
lines.push(' </testsuite>');
|
|
45
|
+
lines.push('</testsuites>');
|
|
46
|
+
return lines.join('\n');
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Formats a multi-file FleetReport as a JUnit XML document.
|
|
50
|
+
*/
|
|
51
|
+
export function formatFleetReportJUnit(fleetReport) {
|
|
52
|
+
const lines = [];
|
|
53
|
+
lines.push('<?xml version="1.0" encoding="UTF-8"?>');
|
|
54
|
+
lines.push(`<testsuites name="mcp-doctor-fleet" tests="${fleetReport.totalServers}" failures="${fleetReport.totalErrors}">`);
|
|
55
|
+
for (const fileResult of fleetReport.fileResults) {
|
|
56
|
+
if (fileResult.report) {
|
|
57
|
+
const rep = fileResult.report;
|
|
58
|
+
lines.push(` <testsuite name="${escapeXml(fileResult.filePath)}" tests="${rep.summary.servers + rep.diagnostics.length}" failures="${rep.summary.errors}">`);
|
|
59
|
+
for (const conn of rep.connections) {
|
|
60
|
+
if (conn.status === 'connected') {
|
|
61
|
+
lines.push(` <testcase classname="${escapeXml(conn.server.name)}" name="connection.handshake" />`);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
lines.push(` <testcase classname="${escapeXml(conn.server.name)}" name="connection.handshake">`);
|
|
65
|
+
lines.push(` <failure message="${escapeXml(conn.error?.message || 'Connection failed')}" type="ConnectionFailure" />`);
|
|
66
|
+
lines.push(' </testcase>');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
for (const d of rep.diagnostics) {
|
|
70
|
+
const scope = d.toolName ? `${d.serverName}.${d.toolName}` : d.serverName;
|
|
71
|
+
if (d.severity === 'error') {
|
|
72
|
+
lines.push(` <testcase classname="${escapeXml(scope)}" name="${escapeXml(d.checkId)}">`);
|
|
73
|
+
lines.push(` <failure message="${escapeXml(d.message)}" type="CheckError">${escapeXml(d.message)}</failure>`);
|
|
74
|
+
lines.push(' </testcase>');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
lines.push(' </testsuite>');
|
|
78
|
+
}
|
|
79
|
+
else if (fileResult.error) {
|
|
80
|
+
lines.push(` <testsuite name="${escapeXml(fileResult.filePath)}" tests="1" failures="1">`);
|
|
81
|
+
lines.push(` <testcase classname="config.file" name="parse"><failure message="${escapeXml(fileResult.error)}" type="ConfigError" /></testcase>`);
|
|
82
|
+
lines.push(' </testsuite>');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
lines.push('</testsuites>');
|
|
86
|
+
return lines.join('\n');
|
|
87
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { MCPConfig, MCPConnection, RunOptions, RunReport } from './types.js';
|
|
2
|
+
/** Allows the protocol layer to register its real implementation without
|
|
3
|
+
* this file needing to import it directly (keeps orchestrator decoupled
|
|
4
|
+
* from protocol internals per CONTRACT.md module boundaries). */
|
|
5
|
+
export declare function registerConnectImpl(impl: (config: MCPConfig['servers'][number], timeoutMs: number, options?: RunOptions) => Promise<MCPConnection>): void;
|
|
6
|
+
export declare function runChecks(config: MCPConfig, options?: RunOptions): Promise<RunReport>;
|