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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -0
  3. package/action.yml +57 -0
  4. package/dist/checks/index.d.ts +6 -0
  5. package/dist/checks/index.js +17 -0
  6. package/dist/checks/malformed-schema.d.ts +2 -0
  7. package/dist/checks/malformed-schema.js +63 -0
  8. package/dist/checks/missing-description.d.ts +2 -0
  9. package/dist/checks/missing-description.js +71 -0
  10. package/dist/checks/missing-required-fields.d.ts +2 -0
  11. package/dist/checks/missing-required-fields.js +55 -0
  12. package/dist/checks/sample-call-simulation.d.ts +2 -0
  13. package/dist/checks/sample-call-simulation.js +239 -0
  14. package/dist/checks/type-mismatch.d.ts +2 -0
  15. package/dist/checks/type-mismatch.js +154 -0
  16. package/dist/cli.d.ts +20 -0
  17. package/dist/cli.js +457 -0
  18. package/dist/config-loader.d.ts +6 -0
  19. package/dist/config-loader.js +77 -0
  20. package/dist/conformance.d.ts +10 -0
  21. package/dist/conformance.js +112 -0
  22. package/dist/discovery.d.ts +9 -0
  23. package/dist/discovery.js +76 -0
  24. package/dist/extension/index.d.ts +79 -0
  25. package/dist/extension/index.js +125 -0
  26. package/dist/fleet.d.ts +48 -0
  27. package/dist/fleet.js +153 -0
  28. package/dist/index.d.ts +20 -0
  29. package/dist/index.js +13 -0
  30. package/dist/junit.d.ts +10 -0
  31. package/dist/junit.js +87 -0
  32. package/dist/orchestrator.d.ts +6 -0
  33. package/dist/orchestrator.js +60 -0
  34. package/dist/policy.d.ts +16 -0
  35. package/dist/policy.js +143 -0
  36. package/dist/protocol/connect.d.ts +2 -0
  37. package/dist/protocol/connect.js +417 -0
  38. package/dist/protocol/index.d.ts +3 -0
  39. package/dist/protocol/index.js +6 -0
  40. package/dist/registry.d.ts +16 -0
  41. package/dist/registry.js +87 -0
  42. package/dist/report.d.ts +7 -0
  43. package/dist/report.js +30 -0
  44. package/dist/types.d.ts +70 -0
  45. package/dist/types.js +4 -0
  46. package/dist/watch.d.ts +12 -0
  47. package/dist/watch.js +85 -0
  48. package/package.json +56 -0
package/dist/cli.js ADDED
@@ -0,0 +1,457 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import { runChecks, registerConnectImpl } from './orchestrator.js';
5
+ import { formatReportHuman, formatReportJSON } from './report.js';
6
+ import { loadConfig } from './config-loader.js';
7
+ import { discoverConfigFiles } from './discovery.js';
8
+ import { watchFileDebounced } from './watch.js';
9
+ import { resolveRegistryServer } from './registry.js';
10
+ import { loadPolicy, createPolicyChecks } from './policy.js';
11
+ import { runFleetChecks, diffConfigs, filterDiagnosticsByBaseline } from './fleet.js';
12
+ import { formatReportJUnit, formatFleetReportJUnit } from './junit.js';
13
+ import pc from 'picocolors';
14
+ export function parseArgs(argv) {
15
+ const args = {
16
+ command: 'check',
17
+ json: false,
18
+ showFixes: false,
19
+ verbose: false,
20
+ failOn: 'error',
21
+ };
22
+ const positional = [];
23
+ for (let i = 0; i < argv.length; i++) {
24
+ const arg = argv[i];
25
+ if (arg === '--json') {
26
+ args.json = true;
27
+ }
28
+ else if (arg === '--show-fixes') {
29
+ args.showFixes = true;
30
+ }
31
+ else if (arg === '--verbose' || arg === '-v') {
32
+ args.verbose = true;
33
+ }
34
+ else if (arg === '--help' || arg === '-h') {
35
+ args.command = 'help';
36
+ }
37
+ else if (arg === '--fail-on') {
38
+ const val = argv[++i];
39
+ if (val !== 'error' && val !== 'warning') {
40
+ throw new Error(`--fail-on requires "error" or "warning", got: ${val ?? '(none)'}`);
41
+ }
42
+ args.failOn = val;
43
+ }
44
+ else if (arg === '--policy') {
45
+ args.policyPath = argv[++i];
46
+ }
47
+ else if (arg === '--export-junit' || arg === '--junit') {
48
+ args.exportJunit = argv[++i];
49
+ }
50
+ else if (arg === '--export-json') {
51
+ args.exportJson = argv[++i];
52
+ }
53
+ else if (arg === '--snapshot') {
54
+ args.snapshotPath = argv[++i];
55
+ }
56
+ else if (arg === '--update-snapshot') {
57
+ args.updateSnapshotPath = argv[++i];
58
+ }
59
+ else if (arg === '--registry') {
60
+ const val = argv[++i];
61
+ if (!val) {
62
+ throw new Error('--registry requires a server identifier or registry URL');
63
+ }
64
+ args.registryServer = val;
65
+ }
66
+ else if (arg === '--config') {
67
+ const val = argv[++i];
68
+ if (!val) {
69
+ throw new Error('--config requires a path argument');
70
+ }
71
+ args.configPath = val;
72
+ }
73
+ else if (arg === '--timeout') {
74
+ const value = argv[++i];
75
+ const parsed = value ? Number(value) : NaN;
76
+ if (Number.isNaN(parsed) || parsed <= 0) {
77
+ throw new Error(`--timeout requires a positive numeric value in ms, got: ${value ?? '(none)'}`);
78
+ }
79
+ args.timeoutMs = parsed;
80
+ }
81
+ else {
82
+ positional.push(arg);
83
+ }
84
+ }
85
+ if (args.command !== 'help') {
86
+ const first = positional[0];
87
+ if (first === 'check' || first === 'watch' || first === 'check-all' || first === 'diff') {
88
+ args.command = first;
89
+ if (first === 'diff') {
90
+ args.configPath = positional[1];
91
+ args.configPathB = positional[2];
92
+ }
93
+ else if (first === 'check-all') {
94
+ args.globPattern = positional[1] || '**/*mcp*.json';
95
+ }
96
+ else {
97
+ if (!args.configPath && positional[1]) {
98
+ args.configPath = positional[1];
99
+ }
100
+ }
101
+ }
102
+ else if (first && !args.configPath) {
103
+ args.configPath = first;
104
+ }
105
+ }
106
+ return args;
107
+ }
108
+ async function importOptional(specifier) {
109
+ try {
110
+ return await import(specifier);
111
+ }
112
+ catch {
113
+ return undefined;
114
+ }
115
+ }
116
+ async function loadChecks(policyPath) {
117
+ const mod = await importOptional('./checks/index.js');
118
+ const baseChecks = Array.isArray(mod?.allChecks) ? mod?.allChecks : [];
119
+ const policy = loadPolicy(policyPath);
120
+ if (policy) {
121
+ const policyChecks = createPolicyChecks(policy);
122
+ return [...baseChecks, ...policyChecks];
123
+ }
124
+ return baseChecks;
125
+ }
126
+ async function loadProtocol() {
127
+ const mod = await importOptional('./protocol/index.js');
128
+ if (!mod)
129
+ return;
130
+ if (typeof mod.registerProtocol === 'function') {
131
+ mod.registerProtocol();
132
+ }
133
+ else if (typeof mod.connect === 'function') {
134
+ registerConnectImpl(mod.connect);
135
+ }
136
+ }
137
+ function printHelp() {
138
+ console.log(`
139
+ ${pc.bold('mcp-doctor')} — Diagnose broken MCP server configs before they break your agent.
140
+
141
+ ${pc.bold('USAGE')}
142
+ $ mcp-doctor [check] [path/to/config.json] [options]
143
+ $ mcp-doctor check --registry <server-id> [options]
144
+ $ mcp-doctor check-all "<glob-pattern>" [options]
145
+ $ mcp-doctor diff <configA.json> <configB.json>
146
+ $ mcp-doctor watch <path/to/config.json> [options]
147
+
148
+ ${pc.bold('OPTIONS')}
149
+ --config <path> Specify path to MCP configuration file
150
+ --registry <id/url> Validate published registry entry directly
151
+ --policy <path> Apply organizational policy rules (.mcp-doctor-policy.json)
152
+ --snapshot <path> Filter report against baseline snapshot, reporting regressions only
153
+ --update-snapshot <p> Save diagnostic report as new baseline snapshot
154
+ --export-junit <file> Export report in JUnit XML format
155
+ --export-json <file> Export report in JSON format
156
+ --show-fixes Print actionable suggested fixes under diagnostics
157
+ --fail-on <severity> Exit with code 1 on 'error' (default) or 'warning'
158
+ --verbose, -v Print raw JSON-RPC traffic and debug messages
159
+ --json Output report in JSON format
160
+ --timeout <ms> Per-server handshake timeout in milliseconds (default: 5000)
161
+ --help, -h Show help
162
+
163
+ ${pc.bold('EXIT CODES')}
164
+ 0 All checks passed cleanly
165
+ 1 Diagnostics failed (errors found, or warnings when --fail-on warning)
166
+ 2 Usage or configuration error (invalid flags, missing/malformed config)
167
+ `);
168
+ }
169
+ function colorizeHumanReport(text) {
170
+ return text
171
+ .split('\n')
172
+ .map((line) => {
173
+ if (/^\[OK\]/.test(line))
174
+ return pc.green(line);
175
+ if (/^\[FAILED\]|^\[TIMEOUT\]/.test(line))
176
+ return pc.red(line);
177
+ if (/\[error\]/.test(line))
178
+ return pc.red(line);
179
+ if (/\[warning\]/.test(line))
180
+ return pc.yellow(line);
181
+ if (/Suggested fix:/.test(line))
182
+ return pc.cyan(line);
183
+ return line;
184
+ })
185
+ .join('\n');
186
+ }
187
+ async function executeCheck(config, args) {
188
+ const [checks] = await Promise.all([loadChecks(args.policyPath), loadProtocol()]);
189
+ let report = await runChecks(config, {
190
+ timeoutMs: args.timeoutMs,
191
+ checks,
192
+ verbose: args.verbose,
193
+ });
194
+ // Handle baseline snapshot comparison
195
+ if (args.snapshotPath) {
196
+ if (existsSync(args.snapshotPath)) {
197
+ try {
198
+ const baseline = JSON.parse(readFileSync(args.snapshotPath, 'utf-8'));
199
+ report = filterDiagnosticsByBaseline(report, baseline);
200
+ }
201
+ catch (err) {
202
+ console.error(pc.yellow(`Warning: Could not read snapshot baseline: ${String(err)}`));
203
+ }
204
+ }
205
+ }
206
+ // Handle update snapshot
207
+ if (args.updateSnapshotPath) {
208
+ try {
209
+ writeFileSync(resolve(args.updateSnapshotPath), JSON.stringify(report, null, 2));
210
+ if (!args.json) {
211
+ console.log(pc.green(`Updated baseline snapshot at ${args.updateSnapshotPath}`));
212
+ }
213
+ }
214
+ catch (err) {
215
+ console.error(pc.red(`Failed to save snapshot: ${String(err)}`));
216
+ }
217
+ }
218
+ // Handle JUnit XML export
219
+ if (args.exportJunit) {
220
+ try {
221
+ writeFileSync(resolve(args.exportJunit), formatReportJUnit(report));
222
+ }
223
+ catch (err) {
224
+ console.error(pc.red(`Failed to write JUnit export: ${String(err)}`));
225
+ }
226
+ }
227
+ // Handle JSON export
228
+ if (args.exportJson) {
229
+ try {
230
+ writeFileSync(resolve(args.exportJson), JSON.stringify(report, null, 2));
231
+ }
232
+ catch (err) {
233
+ console.error(pc.red(`Failed to write JSON export: ${String(err)}`));
234
+ }
235
+ }
236
+ if (args.json) {
237
+ console.log(formatReportJSON(report));
238
+ }
239
+ else {
240
+ console.log(colorizeHumanReport(formatReportHuman(report, { showFixes: args.showFixes })));
241
+ }
242
+ const hasErrors = report.summary.errors > 0;
243
+ const hasWarnings = report.summary.warnings > 0;
244
+ if (args.failOn === 'warning') {
245
+ return hasErrors || hasWarnings ? 1 : 0;
246
+ }
247
+ return hasErrors ? 1 : 0;
248
+ }
249
+ async function loadConfigFromPath(configPath) {
250
+ if (!existsSync(configPath)) {
251
+ console.error(pc.red(`Config file not found: ${configPath}`));
252
+ return { exitCode: 2 };
253
+ }
254
+ let rawText;
255
+ try {
256
+ rawText = readFileSync(configPath, 'utf-8');
257
+ }
258
+ catch (err) {
259
+ console.error(pc.red(`Could not read config file: ${err instanceof Error ? err.message : String(err)}`));
260
+ return { exitCode: 2 };
261
+ }
262
+ let rawJson;
263
+ try {
264
+ rawJson = JSON.parse(rawText);
265
+ }
266
+ catch (err) {
267
+ console.error(pc.red(`Could not parse config as JSON: ${err instanceof Error ? err.message : String(err)}`));
268
+ return { exitCode: 2 };
269
+ }
270
+ const { config, errors } = loadConfig(rawJson, configPath);
271
+ if (errors.length > 0 || !config) {
272
+ console.error(pc.red('Config validation failed:'));
273
+ for (const error of errors) {
274
+ console.error(pc.red(` - ${error}`));
275
+ }
276
+ return { exitCode: 2 };
277
+ }
278
+ return { config };
279
+ }
280
+ export async function main(argv = process.argv.slice(2)) {
281
+ let args;
282
+ try {
283
+ args = parseArgs(argv);
284
+ }
285
+ catch (err) {
286
+ console.error(pc.red(`mcp-doctor usage error: ${err instanceof Error ? err.message : String(err)}`));
287
+ return 2;
288
+ }
289
+ if (args.command === 'help') {
290
+ printHelp();
291
+ return 0;
292
+ }
293
+ // Handle diff command
294
+ if (args.command === 'diff') {
295
+ if (!args.configPath || !args.configPathB) {
296
+ console.error(pc.red('mcp-doctor diff requires two config paths: mcp-doctor diff <configA> <configB>'));
297
+ return 2;
298
+ }
299
+ const [resA, resB] = await Promise.all([
300
+ loadConfigFromPath(args.configPath),
301
+ loadConfigFromPath(args.configPathB),
302
+ ]);
303
+ if (!resA.config || !resB.config)
304
+ return 2;
305
+ const diff = diffConfigs(resA.config, resB.config);
306
+ if (args.json) {
307
+ console.log(JSON.stringify(diff, null, 2));
308
+ }
309
+ else {
310
+ console.log(pc.bold(`\nMCP Config Drift Report`));
311
+ console.log(`Config A: ${args.configPath}`);
312
+ console.log(`Config B: ${args.configPathB}\n`);
313
+ if (diff.identical) {
314
+ console.log(pc.green('✔ Configurations are identical. No drift detected.'));
315
+ }
316
+ else {
317
+ for (const entry of diff.entries) {
318
+ if (entry.kind === 'added') {
319
+ console.log(pc.green(`+ Added in B: ${entry.serverName}`));
320
+ }
321
+ else if (entry.kind === 'removed') {
322
+ console.log(pc.red(`- Removed in B: ${entry.serverName}`));
323
+ }
324
+ else if (entry.kind === 'modified') {
325
+ console.log(pc.yellow(`~ Modified server: ${entry.serverName}`));
326
+ for (const ch of entry.changes || []) {
327
+ console.log(pc.dim(` ${ch.field}: ${JSON.stringify(ch.from)} -> ${JSON.stringify(ch.to)}`));
328
+ }
329
+ }
330
+ }
331
+ }
332
+ }
333
+ return diff.identical ? 0 : 1;
334
+ }
335
+ // Handle fleet check-all command
336
+ if (args.command === 'check-all') {
337
+ const glob = args.globPattern || '**/*mcp*.json';
338
+ const [checks] = await Promise.all([loadChecks(args.policyPath), loadProtocol()]);
339
+ const fleetReport = await runFleetChecks(glob, {
340
+ checks,
341
+ timeoutMs: args.timeoutMs,
342
+ verbose: args.verbose,
343
+ });
344
+ if (args.exportJunit) {
345
+ try {
346
+ writeFileSync(resolve(args.exportJunit), formatFleetReportJUnit(fleetReport));
347
+ }
348
+ catch (err) {
349
+ console.error(pc.red(`Failed to write JUnit export: ${String(err)}`));
350
+ }
351
+ }
352
+ if (args.json) {
353
+ console.log(JSON.stringify(fleetReport, null, 2));
354
+ }
355
+ else {
356
+ console.log(pc.bold(`\nMCP Doctor Fleet Report`));
357
+ console.log(`Files scanned: ${fleetReport.totalFiles} (${fleetReport.successfulFiles} valid, ${fleetReport.failedFiles} invalid)`);
358
+ console.log(`Servers checked: ${fleetReport.totalServers}`);
359
+ console.log(`Results: ${fleetReport.totalErrors} error(s), ${fleetReport.totalWarnings} warning(s)\n`);
360
+ for (const res of fleetReport.fileResults) {
361
+ if (res.error) {
362
+ console.log(pc.red(`[FAIL] ${res.filePath} — ${res.error}`));
363
+ }
364
+ else if (res.report) {
365
+ const status = res.report.summary.errors === 0 ? pc.green('[PASS]') : pc.red('[FAIL]');
366
+ console.log(`${status} ${res.filePath} (${res.report.summary.servers} servers, ${res.report.summary.errors} errors, ${res.report.summary.warnings} warnings)`);
367
+ }
368
+ }
369
+ }
370
+ const hasErrors = fleetReport.totalErrors > 0;
371
+ const hasWarnings = fleetReport.totalWarnings > 0;
372
+ if (args.failOn === 'warning') {
373
+ return hasErrors || hasWarnings ? 1 : 0;
374
+ }
375
+ return hasErrors ? 1 : 0;
376
+ }
377
+ // Handle direct registry validation
378
+ if (args.registryServer) {
379
+ try {
380
+ const serverConfig = await resolveRegistryServer(args.registryServer, {
381
+ timeoutMs: args.timeoutMs,
382
+ });
383
+ const config = {
384
+ servers: [serverConfig],
385
+ sourcePath: `registry:${args.registryServer}`,
386
+ };
387
+ return await executeCheck(config, args);
388
+ }
389
+ catch (err) {
390
+ console.error(pc.red(`Failed to resolve registry server: ${err instanceof Error ? err.message : String(err)}`));
391
+ return 2;
392
+ }
393
+ }
394
+ // Resolve config path (explicit argument or auto-discovery)
395
+ let targetPath = args.configPath;
396
+ if (!targetPath) {
397
+ const discovered = discoverConfigFiles();
398
+ if (discovered.length === 0) {
399
+ console.error(pc.red('No MCP configuration files discovered. Specify a file path or create a .mcp.json in your project.'));
400
+ return 2;
401
+ }
402
+ if (discovered.length === 1) {
403
+ targetPath = discovered[0].path;
404
+ if (!args.json) {
405
+ console.log(pc.dim(`Auto-discovered config: ${targetPath} (${discovered[0].label})`));
406
+ }
407
+ }
408
+ else {
409
+ targetPath = discovered[0].path;
410
+ if (!args.json) {
411
+ console.log(pc.yellow(`Found ${discovered.length} MCP configuration files:`));
412
+ for (let i = 0; i < discovered.length; i++) {
413
+ console.log(pc.dim(` ${i + 1}. ${discovered[i].label}: ${discovered[i].path}`));
414
+ }
415
+ console.log(pc.dim(`Using: ${targetPath} (use --config <path> to specify another)\n`));
416
+ }
417
+ }
418
+ }
419
+ if (args.command === 'watch') {
420
+ if (!args.json) {
421
+ console.log(pc.bold(`\nWatching ${targetPath} for changes... (Press Ctrl+C to exit)\n`));
422
+ }
423
+ const { config, exitCode } = await loadConfigFromPath(targetPath);
424
+ if (exitCode !== undefined || !config)
425
+ return exitCode ?? 2;
426
+ await executeCheck(config, args);
427
+ return new Promise(() => {
428
+ watchFileDebounced(targetPath, {
429
+ onTrigger: async () => {
430
+ if (!args.json) {
431
+ console.log(pc.dim(`\n--- Config changed: re-running checks ---`));
432
+ }
433
+ const loaded = await loadConfigFromPath(targetPath);
434
+ if (loaded.config) {
435
+ await executeCheck(loaded.config, args);
436
+ }
437
+ },
438
+ onError: (err) => {
439
+ console.error(pc.red(`Watch error: ${err.message}`));
440
+ },
441
+ });
442
+ });
443
+ }
444
+ const { config, exitCode } = await loadConfigFromPath(targetPath);
445
+ if (exitCode !== undefined || !config)
446
+ return exitCode ?? 2;
447
+ return executeCheck(config, args);
448
+ }
449
+ // Only invoke automatically when run as CLI entry point
450
+ if (process.argv[1] && (process.argv[1].endsWith('/cli.js') || process.argv[1].endsWith('/cli.ts') || process.argv[1].endsWith('/mcp-doctor'))) {
451
+ main()
452
+ .then((code) => process.exit(code))
453
+ .catch((err) => {
454
+ console.error(pc.red(`mcp-doctor: unexpected error: ${err instanceof Error ? err.message : String(err)}`));
455
+ process.exit(2);
456
+ });
457
+ }
@@ -0,0 +1,6 @@
1
+ import type { MCPConfig } from './types.js';
2
+ export interface ConfigLoadResult {
3
+ config?: MCPConfig;
4
+ errors: string[];
5
+ }
6
+ export declare function loadConfig(rawJson: unknown, sourcePath?: string): ConfigLoadResult;
@@ -0,0 +1,77 @@
1
+ const VALID_TRANSPORTS = ['stdio', 'sse', 'http'];
2
+ function label(raw, index) {
3
+ const name = typeof raw === 'object' && raw !== null && 'name' in raw && typeof raw.name === 'string'
4
+ ? raw.name
5
+ : undefined;
6
+ return name ? `server[${index}] ("${name}")` : `server[${index}]`;
7
+ }
8
+ function validateServer(raw, index, errors) {
9
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
10
+ errors.push(`${label(raw, index)}: expected an object`);
11
+ return false;
12
+ }
13
+ const server = raw;
14
+ let valid = true;
15
+ if (typeof server.name !== 'string' || server.name.length === 0) {
16
+ errors.push(`${label(raw, index)}: missing or invalid 'name'`);
17
+ valid = false;
18
+ }
19
+ if (typeof server.transport !== 'string' || !VALID_TRANSPORTS.includes(server.transport)) {
20
+ errors.push(`${label(raw, index)}: invalid transport ${JSON.stringify(server.transport)} (expected 'stdio', 'sse', or 'http')`);
21
+ valid = false;
22
+ return valid;
23
+ }
24
+ if (server.transport === 'stdio') {
25
+ if (typeof server.command !== 'string' || server.command.length === 0) {
26
+ errors.push(`${label(raw, index)}: missing 'command' for stdio transport`);
27
+ valid = false;
28
+ }
29
+ }
30
+ else {
31
+ if (typeof server.url !== 'string' || server.url.length === 0) {
32
+ errors.push(`${label(raw, index)}: missing 'url' for ${server.transport} transport`);
33
+ valid = false;
34
+ }
35
+ }
36
+ return valid;
37
+ }
38
+ export function loadConfig(rawJson, sourcePath) {
39
+ const errors = [];
40
+ if (typeof rawJson !== 'object' || rawJson === null || Array.isArray(rawJson)) {
41
+ return { errors: ['config: expected a top-level object with a "servers" array or "mcpServers" map'] };
42
+ }
43
+ const raw = rawJson;
44
+ // Normalize { mcpServers: { serverName: { ... } } } format (Claude Desktop / Cursor)
45
+ if (!Array.isArray(raw.servers) && raw.mcpServers && typeof raw.mcpServers === 'object' && !Array.isArray(raw.mcpServers)) {
46
+ const serversList = [];
47
+ for (const [name, def] of Object.entries(raw.mcpServers)) {
48
+ if (typeof def === 'object' && def !== null && !Array.isArray(def)) {
49
+ const obj = def;
50
+ const transport = obj.transport || (obj.url ? (String(obj.url).includes('sse') ? 'sse' : 'http') : 'stdio');
51
+ serversList.push({
52
+ name,
53
+ transport,
54
+ command: typeof obj.command === 'string' ? obj.command : undefined,
55
+ args: Array.isArray(obj.args) ? obj.args : undefined,
56
+ env: obj.env || undefined,
57
+ url: typeof obj.url === 'string' ? obj.url : undefined,
58
+ headers: obj.headers || undefined,
59
+ });
60
+ }
61
+ }
62
+ return { config: { servers: serversList, sourcePath }, errors: [] };
63
+ }
64
+ if (!Array.isArray(raw.servers)) {
65
+ return { errors: ['config: "servers" must be an array'] };
66
+ }
67
+ const servers = [];
68
+ raw.servers.forEach((rawServer, index) => {
69
+ if (validateServer(rawServer, index, errors)) {
70
+ servers.push(rawServer);
71
+ }
72
+ });
73
+ if (errors.length > 0) {
74
+ return { errors };
75
+ }
76
+ return { config: { servers, sourcePath }, errors };
77
+ }
@@ -0,0 +1,10 @@
1
+ import type { Check } from './types.js';
2
+ export interface ConformanceResult {
3
+ pass: boolean;
4
+ errors: string[];
5
+ }
6
+ /**
7
+ * Conformance test helper for community check packages (mcp-doctor-check-*).
8
+ * Validates that a Check implementation conforms strictly to CONTRACT.md.
9
+ */
10
+ export declare function runCheckConformanceSuite(check: Check): Promise<ConformanceResult>;
@@ -0,0 +1,112 @@
1
+ const VALID_SEVERITIES = new Set(['error', 'warning', 'info']);
2
+ /**
3
+ * Conformance test helper for community check packages (mcp-doctor-check-*).
4
+ * Validates that a Check implementation conforms strictly to CONTRACT.md.
5
+ */
6
+ export async function runCheckConformanceSuite(check) {
7
+ const errors = [];
8
+ // 1. Metadata validation
9
+ if (!check.id || typeof check.id !== 'string' || check.id.trim() === '') {
10
+ errors.push('Check must have a non-empty string `id`.');
11
+ }
12
+ if (!check.description || typeof check.description !== 'string' || check.description.trim() === '') {
13
+ errors.push('Check must have a non-empty string `description`.');
14
+ }
15
+ if (typeof check.run !== 'function') {
16
+ errors.push('Check must have a `run()` method.');
17
+ return { pass: false, errors };
18
+ }
19
+ // 2. Test execution against clean connection
20
+ const mockCleanConnection = {
21
+ server: { name: 'conformance-clean-server', transport: 'stdio' },
22
+ status: 'connected',
23
+ tools: [
24
+ {
25
+ name: 'sample_tool',
26
+ description: 'A sample tool for testing.',
27
+ inputSchema: {
28
+ type: 'object',
29
+ properties: {
30
+ input: { type: 'string', description: 'Sample input' },
31
+ },
32
+ required: ['input'],
33
+ },
34
+ },
35
+ ],
36
+ };
37
+ try {
38
+ const results = await check.run(mockCleanConnection);
39
+ validateDiagnosticsShape(results, errors, 'clean connection');
40
+ }
41
+ catch (err) {
42
+ errors.push(`Check threw uncaught exception on clean connection: ${String(err)}`);
43
+ }
44
+ // 3. Test execution against empty tools connection
45
+ const mockEmptyToolsConnection = {
46
+ server: { name: 'conformance-empty-server', transport: 'stdio' },
47
+ status: 'connected',
48
+ tools: [],
49
+ };
50
+ try {
51
+ const results = await check.run(mockEmptyToolsConnection);
52
+ validateDiagnosticsShape(results, errors, 'empty tools connection');
53
+ }
54
+ catch (err) {
55
+ errors.push(`Check threw uncaught exception on empty tools connection: ${String(err)}`);
56
+ }
57
+ // 4. Test execution against undefined tools connection
58
+ const mockUndefinedToolsConnection = {
59
+ server: { name: 'conformance-undefined-server', transport: 'stdio' },
60
+ status: 'connected',
61
+ };
62
+ try {
63
+ const results = await check.run(mockUndefinedToolsConnection);
64
+ validateDiagnosticsShape(results, errors, 'undefined tools connection');
65
+ }
66
+ catch (err) {
67
+ errors.push(`Check threw uncaught exception on undefined tools connection: ${String(err)}`);
68
+ }
69
+ // 5. Test execution against throwing getter connection (error boundary test)
70
+ const mockExplodingConnection = {
71
+ server: { name: 'conformance-exploding-server', transport: 'stdio' },
72
+ status: 'connected',
73
+ get tools() {
74
+ throw new Error('conformance simulated unexpected getter failure');
75
+ },
76
+ };
77
+ try {
78
+ const results = await check.run(mockExplodingConnection);
79
+ validateDiagnosticsShape(results, errors, 'exploding getter connection');
80
+ }
81
+ catch (err) {
82
+ errors.push(`Check threw uncaught exception when connection threw internally (must catch all errors in run()): ${String(err)}`);
83
+ }
84
+ return {
85
+ pass: errors.length === 0,
86
+ errors,
87
+ };
88
+ }
89
+ function validateDiagnosticsShape(results, errors, context) {
90
+ if (!Array.isArray(results)) {
91
+ errors.push(`Check.run() must return an array of DiagnosticResult on ${context}, got ${typeof results}.`);
92
+ return;
93
+ }
94
+ for (const d of results) {
95
+ if (!d || typeof d !== 'object') {
96
+ errors.push(`Diagnostic entry is not an object on ${context}.`);
97
+ continue;
98
+ }
99
+ if (!d.checkId || typeof d.checkId !== 'string') {
100
+ errors.push(`Diagnostic missing valid \`checkId\` on ${context}.`);
101
+ }
102
+ if (!d.severity || !VALID_SEVERITIES.has(d.severity)) {
103
+ errors.push(`Diagnostic has invalid \`severity\` "${String(d.severity)}" on ${context}.`);
104
+ }
105
+ if (!d.message || typeof d.message !== 'string') {
106
+ errors.push(`Diagnostic missing valid \`message\` string on ${context}.`);
107
+ }
108
+ if (!d.serverName || typeof d.serverName !== 'string') {
109
+ errors.push(`Diagnostic missing valid \`serverName\` string on ${context}.`);
110
+ }
111
+ }
112
+ }
@@ -0,0 +1,9 @@
1
+ export interface DiscoveredConfig {
2
+ path: string;
3
+ label: string;
4
+ }
5
+ /**
6
+ * Auto-discovers common MCP config locations based on OS and current working directory.
7
+ * Returns only configs that exist on disk.
8
+ */
9
+ export declare function discoverConfigFiles(cwd?: string): DiscoveredConfig[];