mcp-medic 1.2.0 → 1.2.2

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/dist/cli.d.ts CHANGED
@@ -8,6 +8,7 @@ export interface ParsedArgs {
8
8
  policyPath?: string;
9
9
  exportJunit?: string;
10
10
  exportJson?: string;
11
+ exportSarif?: string;
11
12
  snapshotPath?: string;
12
13
  updateSnapshotPath?: string;
13
14
  json: boolean;
@@ -17,6 +18,8 @@ export interface ParsedArgs {
17
18
  failOn: 'error' | 'warning';
18
19
  checkFilter?: string;
19
20
  dryRun: boolean;
21
+ /** "auto" (default) or an explicit MCP protocolVersion string, e.g. "2025-06-18". */
22
+ protocolVersion: string;
20
23
  }
21
24
  /** Prompts the user with `question` and resolves true for an explicit "y"/"yes" answer. */
22
25
  export type ConfirmFn = (question: string) => Promise<boolean>;
package/dist/cli.js CHANGED
@@ -13,6 +13,8 @@ import { isConfigPatch, diffConfigPatch, applyConfigPatch } from './fix.js';
13
13
  import { loadPolicy, createPolicyChecks } from './policy.js';
14
14
  import { runFleetChecks, diffConfigs, filterDiagnosticsByBaseline } from './fleet.js';
15
15
  import { formatReportJUnit, formatFleetReportJUnit } from './junit.js';
16
+ import { formatReportSarif } from './sarif.js';
17
+ import { SUPPORTED_PROTOCOL_VERSIONS } from './protocol/versions.js';
16
18
  import pc from 'picocolors';
17
19
  export function parseArgs(argv) {
18
20
  const args = {
@@ -22,6 +24,7 @@ export function parseArgs(argv) {
22
24
  verbose: false,
23
25
  failOn: 'error',
24
26
  dryRun: false,
27
+ protocolVersion: 'auto',
25
28
  };
26
29
  const positional = [];
27
30
  for (let i = 0; i < argv.length; i++) {
@@ -67,6 +70,9 @@ export function parseArgs(argv) {
67
70
  else if (arg === '--export-json') {
68
71
  args.exportJson = argv[++i];
69
72
  }
73
+ else if (arg === '--export-sarif') {
74
+ args.exportSarif = argv[++i];
75
+ }
70
76
  else if (arg === '--snapshot') {
71
77
  args.snapshotPath = argv[++i];
72
78
  }
@@ -95,6 +101,13 @@ export function parseArgs(argv) {
95
101
  }
96
102
  args.timeoutMs = parsed;
97
103
  }
104
+ else if (arg === '--protocol-version') {
105
+ const val = argv[++i];
106
+ if (!val) {
107
+ throw new Error(`--protocol-version requires a value: "auto" or an explicit version (e.g. ${SUPPORTED_PROTOCOL_VERSIONS[0]})`);
108
+ }
109
+ args.protocolVersion = val;
110
+ }
98
111
  else {
99
112
  positional.push(arg);
100
113
  }
@@ -182,14 +195,23 @@ OPTIONS
182
195
  --update-snapshot <p> Save diagnostic report as new baseline snapshot
183
196
  --export-junit <file> Export report in JUnit XML format
184
197
  --export-json <file> Export report in JSON format
198
+ --export-sarif <file> Export report in SARIF 2.1.0 format (GitHub Code Scanning, etc.)
185
199
  --show-fixes Print actionable suggested fixes under diagnostics
186
200
  --fail-on <severity> Exit with code 1 on 'error' (default) or 'warning'
187
201
  --verbose, -v Print raw JSON-RPC traffic and debug messages
188
202
  --json Output report in JSON format
189
203
  --timeout <ms> Per-server handshake timeout in milliseconds (default: 5000)
204
+ --protocol-version <v> MCP protocolVersion to request: "auto" (default, latest supported)
205
+ or an explicit version, e.g. ${SUPPORTED_PROTOCOL_VERSIONS[SUPPORTED_PROTOCOL_VERSIONS.length - 1]}
190
206
  --help, -h Show help
191
207
  --version, -V Print the installed mcp-medic version
192
208
 
209
+ PROTOCOL VERSIONS
210
+ This client supports: ${SUPPORTED_PROTOCOL_VERSIONS.join(', ')}
211
+ "auto" requests ${SUPPORTED_PROTOCOL_VERSIONS[0]} (the newest). The server may negotiate an
212
+ older version instead; mcp-medic reports both and fails cleanly if the
213
+ negotiated version isn't one this client supports.
214
+
193
215
  FIX OPTIONS (mcp-medic fix)
194
216
  --check <id> Only offer fixes from this check id (e.g. security.untrusted-remote)
195
217
  --dry-run Show every available fix as a diff; apply nothing, prompt for nothing
@@ -222,6 +244,10 @@ function colorizeHumanReport(text) {
222
244
  return pc.yellow(line);
223
245
  if (/Suggested fix:/.test(line))
224
246
  return pc.cyan(line);
247
+ if (/^\s*Protocol:.*✗ incompatible/.test(line))
248
+ return pc.red(line);
249
+ if (/^\s*Protocol:/.test(line))
250
+ return pc.dim(line);
225
251
  return line;
226
252
  })
227
253
  .join('\n');
@@ -232,6 +258,7 @@ async function executeCheck(config, args) {
232
258
  timeoutMs: args.timeoutMs,
233
259
  checks,
234
260
  verbose: args.verbose,
261
+ protocolVersion: args.protocolVersion,
235
262
  });
236
263
  // Handle baseline snapshot comparison
237
264
  if (args.snapshotPath) {
@@ -275,6 +302,15 @@ async function executeCheck(config, args) {
275
302
  console.error(pc.red(`Failed to write JSON export: ${String(err)}`));
276
303
  }
277
304
  }
305
+ // Handle SARIF export
306
+ if (args.exportSarif) {
307
+ try {
308
+ writeFileSync(resolve(args.exportSarif), formatReportSarif(report));
309
+ }
310
+ catch (err) {
311
+ console.error(pc.red(`Failed to write SARIF export: ${String(err)}`));
312
+ }
313
+ }
278
314
  if (args.json) {
279
315
  console.log(formatReportJSON(report));
280
316
  }
@@ -338,7 +374,12 @@ async function defaultConfirm(question) {
338
374
  */
339
375
  async function executeFix(configPath, rawText, rawJson, config, args, confirm) {
340
376
  const [checks] = await Promise.all([loadChecks(args.policyPath), loadProtocol()]);
341
- const report = await runChecks(config, { timeoutMs: args.timeoutMs, checks, verbose: args.verbose });
377
+ const report = await runChecks(config, {
378
+ timeoutMs: args.timeoutMs,
379
+ checks,
380
+ verbose: args.verbose,
381
+ protocolVersion: args.protocolVersion,
382
+ });
342
383
  let fixable = report.diagnostics.filter((d) => isConfigPatch(d.suggestedFix?.patch));
343
384
  if (args.checkFilter) {
344
385
  fixable = fixable.filter((d) => d.checkId === args.checkFilter);
@@ -496,6 +537,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
496
537
  checks,
497
538
  timeoutMs: args.timeoutMs,
498
539
  verbose: args.verbose,
540
+ protocolVersion: args.protocolVersion,
499
541
  });
500
542
  if (args.exportJunit) {
501
543
  try {
package/dist/fleet.js CHANGED
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import { loadConfig } from './config-loader.js';
4
4
  import { runChecks } from './orchestrator.js';
5
5
  import { allChecks } from './checks/index.js';
6
+ import { redactRecord } from './redact.js';
6
7
  function findFilesMatching(dir, pattern, results = []) {
7
8
  if (!existsSync(dir))
8
9
  return results;
@@ -114,7 +115,10 @@ export function diffConfigs(configA, configB) {
114
115
  changes.push({ field: 'url', from: serverA.url, to: serverB.url });
115
116
  }
116
117
  if (JSON.stringify(serverA.env) !== JSON.stringify(serverB.env)) {
117
- changes.push({ field: 'env', from: serverA.env, to: serverB.env });
118
+ // Report that env changed and which keys, but never raw values —
119
+ // env vars routinely carry API keys/tokens and diff output gets
120
+ // pasted into PRs and CI logs.
121
+ changes.push({ field: 'env', from: redactRecord(serverA.env), to: redactRecord(serverB.env) });
118
122
  }
119
123
  if (changes.length > 0) {
120
124
  entries.push({ serverName: name, kind: 'modified', changes });
package/dist/index.d.ts CHANGED
@@ -16,5 +16,9 @@ export type { MCPDoctorPolicy } from './policy.js';
16
16
  export { runFleetChecks, diffConfigs, filterDiagnosticsByBaseline, findConfigFiles, } from './fleet.js';
17
17
  export type { FleetReport, ConfigDiffResult, ConfigDiffEntry, FileRunResult } from './fleet.js';
18
18
  export { formatReportJUnit, formatFleetReportJUnit } from './junit.js';
19
+ export { formatReportSarif } from './sarif.js';
19
20
  export { allChecks } from './checks/index.js';
21
+ export { SUPPORTED_PROTOCOL_VERSIONS, LATEST_SUPPORTED_PROTOCOL_VERSION, KNOWN_UNSUPPORTED_PROTOCOL_VERSIONS, isSupportedProtocolVersion, resolveRequestedProtocolVersion, } from './protocol/versions.js';
22
+ export type { SupportedProtocolVersion, ProtocolVersionNegotiation } from './protocol/versions.js';
23
+ export { isSecretKey, redactRecord, redactDeep, sanitizeServerConfig } from './redact.js';
20
24
  export * from './types.js';
package/dist/index.js CHANGED
@@ -9,5 +9,8 @@ export { isMCPConfigFile, validateMCPDocument, activateExtension } from './exten
9
9
  export { loadPolicy, createPolicyChecks } from './policy.js';
10
10
  export { runFleetChecks, diffConfigs, filterDiagnosticsByBaseline, findConfigFiles, } from './fleet.js';
11
11
  export { formatReportJUnit, formatFleetReportJUnit } from './junit.js';
12
+ export { formatReportSarif } from './sarif.js';
12
13
  export { allChecks } from './checks/index.js';
14
+ export { SUPPORTED_PROTOCOL_VERSIONS, LATEST_SUPPORTED_PROTOCOL_VERSION, KNOWN_UNSUPPORTED_PROTOCOL_VERSIONS, isSupportedProtocolVersion, resolveRequestedProtocolVersion, } from './protocol/versions.js';
15
+ export { isSecretKey, redactRecord, redactDeep, sanitizeServerConfig } from './redact.js';
13
16
  export * from './types.js';
@@ -1,3 +1,4 @@
1
+ import { sanitizeServerConfig } from './redact.js';
1
2
  async function connectStub(config) {
2
3
  return {
3
4
  server: config,
@@ -21,7 +22,12 @@ export async function runChecks(config, options = {}) {
21
22
  const connections = [];
22
23
  const diagnostics = [];
23
24
  for (const server of config.servers) {
24
- const connection = await connectImpl(server, timeoutMs, options);
25
+ const rawConnection = await connectImpl(server, timeoutMs, options);
26
+ // Redact secret-looking header/env/token-body values before this ever
27
+ // reaches a report, JSON export, or JUnit output — checks only read
28
+ // `server.name`/`transport`/`url`, never header or env *values*, so
29
+ // this can't change diagnostic behavior.
30
+ const connection = { ...rawConnection, server: sanitizeServerConfig(server) };
25
31
  connections.push(connection);
26
32
  if (connection.status !== 'connected') {
27
33
  continue; // checks require a live connection; connection failure is its own signal in the report
@@ -1,6 +1,18 @@
1
1
  import { spawn } from 'node:child_process';
2
- const PROTOCOL_VERSION = '2024-11-05';
3
- const CLIENT_INFO = { name: 'mcp-medic', version: '1.0.0' };
2
+ import { readFileSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { KNOWN_UNSUPPORTED_PROTOCOL_VERSIONS, SUPPORTED_PROTOCOL_VERSIONS, isSupportedProtocolVersion, resolveRequestedProtocolVersion, } from './versions.js';
5
+ function readOwnVersion() {
6
+ try {
7
+ const pkgPath = fileURLToPath(new URL('../../package.json', import.meta.url));
8
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
9
+ return pkg.version ?? '0.0.0';
10
+ }
11
+ catch {
12
+ return '0.0.0';
13
+ }
14
+ }
15
+ const CLIENT_INFO = { name: 'mcp-medic', version: readOwnVersion() };
4
16
  function messageOf(error) {
5
17
  return error instanceof Error ? error.message : String(error);
6
18
  }
@@ -12,8 +24,24 @@ function logVerbose(options, message) {
12
24
  console.error(`[debug] ${message}`);
13
25
  }
14
26
  }
15
- function failed(config, status, stage, message, raw) {
16
- return { server: config, status, error: { stage, message, ...(raw === undefined ? {} : { raw }) } };
27
+ function failed(config, status, stage, message, raw, extra) {
28
+ return {
29
+ server: config,
30
+ status,
31
+ error: { stage, message, ...(raw === undefined ? {} : { raw }) },
32
+ ...extra,
33
+ };
34
+ }
35
+ function normalizeServerInfo(value) {
36
+ if (!value || typeof value !== 'object' || Array.isArray(value))
37
+ return undefined;
38
+ const record = value;
39
+ const info = {};
40
+ if (typeof record.name === 'string')
41
+ info.name = record.name;
42
+ if (typeof record.version === 'string')
43
+ info.version = record.version;
44
+ return info.name || info.version ? info : undefined;
17
45
  }
18
46
  function withTimeout(promise, timeoutMs, label) {
19
47
  return new Promise((resolve, reject) => {
@@ -54,6 +82,37 @@ function normalizeTools(result) {
54
82
  };
55
83
  });
56
84
  }
85
+ function normalizeResources(result) {
86
+ if (!Array.isArray(result.resources))
87
+ throw new Error('resources/list response has no resources array');
88
+ return result.resources.map((resource, index) => {
89
+ if (!resource || typeof resource !== 'object' || typeof resource.uri !== 'string') {
90
+ throw new Error(`resources/list returned an invalid resource at index ${index}`);
91
+ }
92
+ const value = resource;
93
+ return {
94
+ uri: value.uri,
95
+ ...(typeof value.name === 'string' ? { name: value.name } : {}),
96
+ ...(typeof value.description === 'string' ? { description: value.description } : {}),
97
+ ...(typeof value.mimeType === 'string' ? { mimeType: value.mimeType } : {}),
98
+ };
99
+ });
100
+ }
101
+ function normalizePrompts(result) {
102
+ if (!Array.isArray(result.prompts))
103
+ throw new Error('prompts/list response has no prompts array');
104
+ return result.prompts.map((prompt, index) => {
105
+ if (!prompt || typeof prompt !== 'object' || typeof prompt.name !== 'string') {
106
+ throw new Error(`prompts/list returned an invalid prompt at index ${index}`);
107
+ }
108
+ const value = prompt;
109
+ return {
110
+ name: value.name,
111
+ ...(typeof value.description === 'string' ? { description: value.description } : {}),
112
+ ...(Array.isArray(value.arguments) ? { arguments: value.arguments } : {}),
113
+ };
114
+ });
115
+ }
57
116
  async function refreshTokenIfNeeded(config, options) {
58
117
  const headers = { ...(config.headers ?? {}) };
59
118
  if (!config.tokenRefreshUrl) {
@@ -347,6 +406,16 @@ class SseTransport {
347
406
  }
348
407
  export async function connect(config, timeoutMs, options) {
349
408
  const started = Date.now();
409
+ const requestedVersion = resolveRequestedProtocolVersion(options?.protocolVersion);
410
+ // Some real MCP protocol versions use a wire shape this client doesn't
411
+ // implement (e.g. 2026-07-28 drops the initialize handshake entirely).
412
+ // Fail fast on those, before spawning a process or opening a connection,
413
+ // rather than attempting a handshake that was never going to work.
414
+ const unsupportedReason = KNOWN_UNSUPPORTED_PROTOCOL_VERSIONS[requestedVersion];
415
+ if (unsupportedReason) {
416
+ return failed(config, 'failed', 'handshake', `cannot request protocol version ${requestedVersion}: ${unsupportedReason}. ` +
417
+ `This client supports: ${SUPPORTED_PROTOCOL_VERSIONS.join(', ')}.`);
418
+ }
350
419
  let transport;
351
420
  try {
352
421
  if (config.transport === 'stdio') {
@@ -371,42 +440,97 @@ export async function connect(config, timeoutMs, options) {
371
440
  else {
372
441
  return failed(config, 'failed', 'spawn', `unsupported transport: ${String(config.transport)}`);
373
442
  }
443
+ // Requests are numbered sequentially in the order they're actually sent
444
+ // (notifications don't consume an id) — tracked locally rather than
445
+ // hardcoded, since which optional capability calls happen below depends
446
+ // on what the server declares.
447
+ let nextExpectedId = 1;
374
448
  let initialize;
375
449
  try {
376
450
  const response = await withTimeout(transport.request('initialize', {
377
- protocolVersion: PROTOCOL_VERSION,
451
+ protocolVersion: requestedVersion,
378
452
  capabilities: {},
379
453
  clientInfo: CLIENT_INFO,
380
454
  }), timeoutMs, 'initialize handshake');
381
- initialize = validateResponse(response, 1);
455
+ initialize = validateResponse(response, nextExpectedId++);
382
456
  if (!initialize.capabilities || typeof initialize.capabilities !== 'object') {
383
457
  throw new Error('initialize response has no capabilities object');
384
458
  }
459
+ if (typeof initialize.protocolVersion !== 'string' || !initialize.protocolVersion) {
460
+ throw new Error('initialize response is missing a protocolVersion string (protocol violation — ' +
461
+ 'the server MUST report the version it negotiated)');
462
+ }
385
463
  }
386
464
  catch (error) {
387
465
  const timedOut = messageOf(error).includes('timed out');
388
466
  const message = messageOf(error);
389
467
  return failed(config, timedOut ? 'timeout' : 'failed', message.startsWith('failed to start') ? 'spawn' : 'handshake', message, error);
390
468
  }
469
+ const negotiatedVersion = initialize.protocolVersion;
470
+ const compatible = isSupportedProtocolVersion(negotiatedVersion);
471
+ const protocolVersion = { requested: requestedVersion, negotiated: negotiatedVersion, compatible };
472
+ const serverInfo = normalizeServerInfo(initialize.serverInfo);
473
+ const capabilities = initialize.capabilities;
474
+ if (!compatible) {
475
+ // Per spec: if the client doesn't support the version the server
476
+ // negotiated, it SHOULD disconnect rather than proceed — don't send
477
+ // notifications/initialized or tools/list against a protocol version
478
+ // this client can't actually speak.
479
+ return failed(config, 'failed', 'handshake', `protocol version mismatch: requested ${requestedVersion}, server negotiated ` +
480
+ `${negotiatedVersion}, which this client does not support. ` +
481
+ `This client supports: ${SUPPORTED_PROTOCOL_VERSIONS.join(', ')}.`, undefined, { protocolVersion, serverInfo, latencyMs: Date.now() - started });
482
+ }
391
483
  try {
392
484
  await withTimeout(transport.notify('notifications/initialized'), timeoutMs, 'initialized notification');
393
485
  }
394
486
  catch (error) {
395
- return failed(config, messageOf(error).includes('timed out') ? 'timeout' : 'failed', 'capability-negotiation', messageOf(error), error);
487
+ return failed(config, messageOf(error).includes('timed out') ? 'timeout' : 'failed', 'capability-negotiation', messageOf(error), error, { protocolVersion, serverInfo });
396
488
  }
489
+ let tools;
397
490
  try {
398
- const tools = normalizeTools(validateResponse(await withTimeout(transport.request('tools/list'), timeoutMs, 'tools/list'), 2));
399
- return {
400
- server: config,
401
- status: 'connected',
402
- capabilities: initialize.capabilities,
403
- tools,
404
- latencyMs: Date.now() - started,
405
- };
491
+ tools = normalizeTools(validateResponse(await withTimeout(transport.request('tools/list'), timeoutMs, 'tools/list'), nextExpectedId++));
406
492
  }
407
493
  catch (error) {
408
- return failed(config, messageOf(error).includes('timed out') ? 'timeout' : 'failed', 'list-tools', messageOf(error), error);
494
+ return failed(config, messageOf(error).includes('timed out') ? 'timeout' : 'failed', 'list-tools', messageOf(error), error, { protocolVersion, serverInfo });
495
+ }
496
+ // Resources and prompts are optional MCP capabilities: only inspect them
497
+ // if the server actually declared support in its initialize response.
498
+ // Passive enumeration only (resources/list, prompts/list) — never
499
+ // resources/read or prompts/get, which would be real invocation.
500
+ // A failure here is never fatal to the connection: tools is the one
501
+ // capability mcp-medic requires, so a broken resources/prompts listing
502
+ // is reported alongside a still-successful connection.
503
+ let resources;
504
+ let prompts;
505
+ const capabilityErrors = {};
506
+ if (capabilities.resources && typeof capabilities.resources === 'object') {
507
+ try {
508
+ resources = normalizeResources(validateResponse(await withTimeout(transport.request('resources/list'), timeoutMs, 'resources/list'), nextExpectedId++));
509
+ }
510
+ catch (error) {
511
+ capabilityErrors.resources = messageOf(error);
512
+ }
513
+ }
514
+ if (capabilities.prompts && typeof capabilities.prompts === 'object') {
515
+ try {
516
+ prompts = normalizePrompts(validateResponse(await withTimeout(transport.request('prompts/list'), timeoutMs, 'prompts/list'), nextExpectedId++));
517
+ }
518
+ catch (error) {
519
+ capabilityErrors.prompts = messageOf(error);
520
+ }
409
521
  }
522
+ return {
523
+ server: config,
524
+ status: 'connected',
525
+ capabilities,
526
+ tools,
527
+ ...(resources !== undefined ? { resources } : {}),
528
+ ...(prompts !== undefined ? { prompts } : {}),
529
+ ...(Object.keys(capabilityErrors).length > 0 ? { capabilityErrors } : {}),
530
+ protocolVersion,
531
+ serverInfo,
532
+ latencyMs: Date.now() - started,
533
+ };
410
534
  }
411
535
  catch (error) {
412
536
  return failed(config, 'failed', 'handshake', messageOf(error), error);
@@ -1,3 +1,4 @@
1
1
  import { connect } from './connect.js';
2
2
  export declare function registerProtocol(): void;
3
3
  export { connect };
4
+ export * from './versions.js';
@@ -4,3 +4,4 @@ export function registerProtocol() {
4
4
  registerConnectImpl(connect);
5
5
  }
6
6
  export { connect };
7
+ export * from './versions.js';
@@ -0,0 +1,43 @@
1
+ /**
2
+ * MCP protocol version support, centralized so it isn't scattered across
3
+ * the transport implementation.
4
+ *
5
+ * Source of truth: the official MCP specification's versioned schema
6
+ * directories, https://github.com/modelcontextprotocol/modelcontextprotocol/tree/main/schema
7
+ * (checked directly rather than assumed — do not add a version here
8
+ * without confirming it against that list).
9
+ *
10
+ * `SUPPORTED_PROTOCOL_VERSIONS` lists every version whose wire shape this
11
+ * client's transport (an `initialize` request, a `notifications/initialized`
12
+ * notification, then `tools/list`) can correctly speak. `2024-11-05`
13
+ * through `2025-11-25` are additive on top of that same handshake model —
14
+ * this client doesn't implement every feature each of them adds (OAuth
15
+ * flows, icons, elicitation, tasks, ...), but it doesn't need to: it only
16
+ * ever sends `initialize`/`notifications/initialized`/`tools/list`, and a
17
+ * spec-compliant server response to those three calls has the same shape
18
+ * across all four versions (any additive fields in between are safely
19
+ * ignored by a client that never reads them).
20
+ */
21
+ export declare const SUPPORTED_PROTOCOL_VERSIONS: readonly ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
22
+ export type SupportedProtocolVersion = (typeof SUPPORTED_PROTOCOL_VERSIONS)[number];
23
+ /** The version requested when the caller asks for "auto" (or specifies nothing). */
24
+ export declare const LATEST_SUPPORTED_PROTOCOL_VERSION: SupportedProtocolVersion;
25
+ /**
26
+ * Real MCP protocol versions that exist but use a wire shape this client
27
+ * does not implement, keyed by version string, valued by why. Requesting
28
+ * one of these fails fast with an explicit reason instead of attempting
29
+ * (and inevitably failing) an `initialize` call the server was never going
30
+ * to understand the way this client sends it.
31
+ */
32
+ export declare const KNOWN_UNSUPPORTED_PROTOCOL_VERSIONS: Readonly<Record<string, string>>;
33
+ export declare function isSupportedProtocolVersion(version: string): version is SupportedProtocolVersion;
34
+ /** Resolves a user-facing `--protocol-version` value ("auto" or a literal version) to the version to request. */
35
+ export declare function resolveRequestedProtocolVersion(preference: string | undefined): string;
36
+ export interface ProtocolVersionNegotiation {
37
+ /** The protocolVersion this client sent in `initialize`. */
38
+ requested: string;
39
+ /** The protocolVersion the server returned in its `initialize` response, if it sent a valid one. */
40
+ negotiated?: string;
41
+ /** Whether `negotiated` is a version this client's transport can actually speak. */
42
+ compatible: boolean;
43
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * MCP protocol version support, centralized so it isn't scattered across
3
+ * the transport implementation.
4
+ *
5
+ * Source of truth: the official MCP specification's versioned schema
6
+ * directories, https://github.com/modelcontextprotocol/modelcontextprotocol/tree/main/schema
7
+ * (checked directly rather than assumed — do not add a version here
8
+ * without confirming it against that list).
9
+ *
10
+ * `SUPPORTED_PROTOCOL_VERSIONS` lists every version whose wire shape this
11
+ * client's transport (an `initialize` request, a `notifications/initialized`
12
+ * notification, then `tools/list`) can correctly speak. `2024-11-05`
13
+ * through `2025-11-25` are additive on top of that same handshake model —
14
+ * this client doesn't implement every feature each of them adds (OAuth
15
+ * flows, icons, elicitation, tasks, ...), but it doesn't need to: it only
16
+ * ever sends `initialize`/`notifications/initialized`/`tools/list`, and a
17
+ * spec-compliant server response to those three calls has the same shape
18
+ * across all four versions (any additive fields in between are safely
19
+ * ignored by a client that never reads them).
20
+ */
21
+ export const SUPPORTED_PROTOCOL_VERSIONS = [
22
+ '2025-11-25',
23
+ '2025-06-18',
24
+ '2025-03-26',
25
+ '2024-11-05',
26
+ ];
27
+ /** The version requested when the caller asks for "auto" (or specifies nothing). */
28
+ export const LATEST_SUPPORTED_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS[0];
29
+ /**
30
+ * Real MCP protocol versions that exist but use a wire shape this client
31
+ * does not implement, keyed by version string, valued by why. Requesting
32
+ * one of these fails fast with an explicit reason instead of attempting
33
+ * (and inevitably failing) an `initialize` call the server was never going
34
+ * to understand the way this client sends it.
35
+ */
36
+ export const KNOWN_UNSUPPORTED_PROTOCOL_VERSIONS = {
37
+ '2026-07-28': "removes the initialize/notifications-initialized handshake entirely in favor of a stateless " +
38
+ "per-request model (a server/discover RPC, protocol version and capabilities carried per-request " +
39
+ "in _meta fields) — a different wire protocol this client doesn't implement yet",
40
+ };
41
+ export function isSupportedProtocolVersion(version) {
42
+ return SUPPORTED_PROTOCOL_VERSIONS.includes(version);
43
+ }
44
+ /** Resolves a user-facing `--protocol-version` value ("auto" or a literal version) to the version to request. */
45
+ export function resolveRequestedProtocolVersion(preference) {
46
+ const trimmed = preference?.trim();
47
+ if (!trimmed || trimmed === 'auto') {
48
+ return LATEST_SUPPORTED_PROTOCOL_VERSION;
49
+ }
50
+ return trimmed;
51
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Secret redaction for anything that ends up in a report, diff, or log line.
3
+ *
4
+ * mcp-medic's `MCPServerConfig` can carry live credentials — HTTP headers
5
+ * (`Authorization: Bearer ...`), stdio process env vars (`API_KEY=...`),
6
+ * and OAuth `tokenRefreshBody` fields (`client_secret`, ...). None of that
7
+ * should ever reach `--json`/`--export-json` output, `diff` output, or
8
+ * `--verbose` logs verbatim — those are routinely pasted into CI logs,
9
+ * issue trackers, and Slack.
10
+ */
11
+ export declare function isSecretKey(key: string): boolean;
12
+ /** Redacts values of secret-looking keys in a flat string-keyed record.
13
+ * Non-secret keys (e.g. `Content-Type`, `NODE_ENV`) are left untouched so
14
+ * the output stays useful for debugging. */
15
+ export declare function redactRecord(record: Record<string, string> | undefined): Record<string, string> | undefined;
16
+ /** Redacts secret-looking keys anywhere in an arbitrary JSON-like value
17
+ * (objects/arrays nested to any depth). Used for loosely-typed structures
18
+ * such as `tokenRefreshBody`. */
19
+ export declare function redactDeep(value: unknown): unknown;
20
+ /** Shape-preserving subset of `MCPServerConfig`'s secret-carrying fields —
21
+ * kept local (rather than importing the real type) so this module has no
22
+ * dependency on `types.ts` and can't accidentally widen what it touches. */
23
+ interface SanitizableServerConfig {
24
+ headers?: Record<string, string>;
25
+ env?: Record<string, string>;
26
+ tokenRefreshBody?: Record<string, unknown>;
27
+ }
28
+ /** Returns a shallow copy of a server config with `headers`, `env`, and
29
+ * `tokenRefreshBody` secret-looking values redacted. Safe to call before
30
+ * a connection result is stored in a report or diff — no built-in check
31
+ * reads these fields' values (only `name`/`transport`/`url`), so redacting
32
+ * them here doesn't change diagnostic behavior. */
33
+ export declare function sanitizeServerConfig<T extends SanitizableServerConfig>(server: T): T;
34
+ export {};
package/dist/redact.js ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Secret redaction for anything that ends up in a report, diff, or log line.
3
+ *
4
+ * mcp-medic's `MCPServerConfig` can carry live credentials — HTTP headers
5
+ * (`Authorization: Bearer ...`), stdio process env vars (`API_KEY=...`),
6
+ * and OAuth `tokenRefreshBody` fields (`client_secret`, ...). None of that
7
+ * should ever reach `--json`/`--export-json` output, `diff` output, or
8
+ * `--verbose` logs verbatim — those are routinely pasted into CI logs,
9
+ * issue trackers, and Slack.
10
+ */
11
+ const REDACTED = '[REDACTED]';
12
+ /** Matches key names that conventionally carry secret values. Intentionally
13
+ * broad — false positives (redacting a harmless key) are safe; false
14
+ * negatives (leaking a secret) are not. */
15
+ const SECRET_KEY_PATTERN = /(authorization|auth|token|secret|password|passwd|pwd|api[-_]?key|apikey|cookie|credential|bearer|session|private[-_]?key|client[-_]?secret)/i;
16
+ export function isSecretKey(key) {
17
+ return SECRET_KEY_PATTERN.test(key);
18
+ }
19
+ /** Redacts values of secret-looking keys in a flat string-keyed record.
20
+ * Non-secret keys (e.g. `Content-Type`, `NODE_ENV`) are left untouched so
21
+ * the output stays useful for debugging. */
22
+ export function redactRecord(record) {
23
+ if (!record)
24
+ return record;
25
+ const result = {};
26
+ for (const [key, value] of Object.entries(record)) {
27
+ result[key] = isSecretKey(key) ? REDACTED : value;
28
+ }
29
+ return result;
30
+ }
31
+ /** Redacts secret-looking keys anywhere in an arbitrary JSON-like value
32
+ * (objects/arrays nested to any depth). Used for loosely-typed structures
33
+ * such as `tokenRefreshBody`. */
34
+ export function redactDeep(value) {
35
+ if (Array.isArray(value)) {
36
+ return value.map(redactDeep);
37
+ }
38
+ if (value && typeof value === 'object') {
39
+ const result = {};
40
+ for (const [key, v] of Object.entries(value)) {
41
+ result[key] = isSecretKey(key) ? REDACTED : redactDeep(v);
42
+ }
43
+ return result;
44
+ }
45
+ return value;
46
+ }
47
+ /** Returns a shallow copy of a server config with `headers`, `env`, and
48
+ * `tokenRefreshBody` secret-looking values redacted. Safe to call before
49
+ * a connection result is stored in a report or diff — no built-in check
50
+ * reads these fields' values (only `name`/`transport`/`url`), so redacting
51
+ * them here doesn't change diagnostic behavior. */
52
+ export function sanitizeServerConfig(server) {
53
+ return {
54
+ ...server,
55
+ ...(server.headers !== undefined ? { headers: redactRecord(server.headers) } : {}),
56
+ ...(server.env !== undefined ? { env: redactRecord(server.env) } : {}),
57
+ ...(server.tokenRefreshBody !== undefined
58
+ ? { tokenRefreshBody: redactDeep(server.tokenRefreshBody) }
59
+ : {}),
60
+ };
61
+ }
package/dist/report.js CHANGED
@@ -8,6 +8,25 @@ export function formatReportHuman(report, options = {}) {
8
8
  for (const conn of report.connections) {
9
9
  const status = conn.status === 'connected' ? 'OK' : conn.status.toUpperCase();
10
10
  lines.push(`[${status}] ${conn.server.name} (${conn.server.transport})`);
11
+ if (conn.protocolVersion) {
12
+ const { requested, negotiated, compatible } = conn.protocolVersion;
13
+ const statusText = compatible ? '✓ compatible' : '✗ incompatible';
14
+ lines.push(` Protocol: requested ${requested}, server negotiated ${negotiated ?? '(none)'} — ${statusText}`);
15
+ }
16
+ if (conn.tools) {
17
+ const parts = [`${conn.tools.length} tool(s)`];
18
+ if (conn.resources)
19
+ parts.push(`${conn.resources.length} resource(s)`);
20
+ if (conn.prompts)
21
+ parts.push(`${conn.prompts.length} prompt(s)`);
22
+ lines.push(` Capabilities: ${parts.join(', ')}`);
23
+ }
24
+ if (conn.capabilityErrors?.resources) {
25
+ lines.push(` resources/list: ${conn.capabilityErrors.resources}`);
26
+ }
27
+ if (conn.capabilityErrors?.prompts) {
28
+ lines.push(` prompts/list: ${conn.capabilityErrors.prompts}`);
29
+ }
11
30
  if (conn.error) {
12
31
  lines.push(` ${conn.error.stage}: ${conn.error.message}`);
13
32
  }
@@ -0,0 +1,11 @@
1
+ import type { RunReport } from './types.js';
2
+ /**
3
+ * Formats a RunReport as SARIF 2.1.0 (https://sarifweb.azurewebsites.net/),
4
+ * consumable by GitHub Code Scanning (`upload-sarif`) and other SARIF
5
+ * viewers. mcp-medic's diagnostics have no source line/column — they're
6
+ * about a *running server's* declared tools/capabilities, not source code —
7
+ * so each result's physicalLocation points at the config file the server
8
+ * was declared in, with the server (and tool, if any) named as a logical
9
+ * location instead of a line range.
10
+ */
11
+ export declare function formatReportSarif(report: RunReport): string;
package/dist/sarif.js ADDED
@@ -0,0 +1,77 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ function readOwnVersion() {
4
+ try {
5
+ const pkgPath = fileURLToPath(new URL('../package.json', import.meta.url));
6
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
7
+ return pkg.version ?? '0.0.0';
8
+ }
9
+ catch {
10
+ return '0.0.0';
11
+ }
12
+ }
13
+ function sarifLevel(severity) {
14
+ if (severity === 'error')
15
+ return 'error';
16
+ if (severity === 'warning')
17
+ return 'warning';
18
+ return 'note';
19
+ }
20
+ /**
21
+ * Formats a RunReport as SARIF 2.1.0 (https://sarifweb.azurewebsites.net/),
22
+ * consumable by GitHub Code Scanning (`upload-sarif`) and other SARIF
23
+ * viewers. mcp-medic's diagnostics have no source line/column — they're
24
+ * about a *running server's* declared tools/capabilities, not source code —
25
+ * so each result's physicalLocation points at the config file the server
26
+ * was declared in, with the server (and tool, if any) named as a logical
27
+ * location instead of a line range.
28
+ */
29
+ export function formatReportSarif(report) {
30
+ const artifactUri = report.configSource || 'mcp-config.json';
31
+ const rules = new Map();
32
+ const results = [];
33
+ for (const d of report.diagnostics) {
34
+ if (!rules.has(d.checkId)) {
35
+ rules.set(d.checkId, {
36
+ id: d.checkId,
37
+ shortDescription: { text: d.checkId },
38
+ defaultConfiguration: { level: sarifLevel(d.severity) },
39
+ });
40
+ }
41
+ const logicalLocations = [{ name: d.serverName, kind: 'module' }];
42
+ if (d.toolName) {
43
+ logicalLocations.push({ name: d.toolName, kind: 'member' });
44
+ }
45
+ results.push({
46
+ ruleId: d.checkId,
47
+ level: sarifLevel(d.severity),
48
+ message: {
49
+ text: d.suggestedFix?.description ? `${d.message} Suggested fix: ${d.suggestedFix.description}` : d.message,
50
+ },
51
+ locations: [
52
+ {
53
+ physicalLocation: { artifactLocation: { uri: artifactUri } },
54
+ logicalLocations,
55
+ },
56
+ ],
57
+ });
58
+ }
59
+ const sarif = {
60
+ $schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
61
+ version: '2.1.0',
62
+ runs: [
63
+ {
64
+ tool: {
65
+ driver: {
66
+ name: 'mcp-medic',
67
+ informationUri: 'https://github.com/shivam039/mcp-doctor',
68
+ version: readOwnVersion(),
69
+ rules: [...rules.values()],
70
+ },
71
+ },
72
+ results,
73
+ },
74
+ ],
75
+ };
76
+ return JSON.stringify(sarif, null, 2);
77
+ }
package/dist/types.d.ts CHANGED
@@ -19,11 +19,47 @@ export interface MCPToolDefinition {
19
19
  description?: string;
20
20
  inputSchema: unknown;
21
21
  }
22
+ export interface MCPResourceDefinition {
23
+ uri: string;
24
+ name?: string;
25
+ description?: string;
26
+ mimeType?: string;
27
+ }
28
+ export interface MCPPromptDefinition {
29
+ name: string;
30
+ description?: string;
31
+ arguments?: unknown[];
32
+ }
33
+ export interface ProtocolVersionInfo {
34
+ /** The protocolVersion this client sent in `initialize`. */
35
+ requested: string;
36
+ /** The protocolVersion the server returned in its `initialize` response, if valid. */
37
+ negotiated?: string;
38
+ /** Whether `negotiated` is a version this client's transport can actually speak. */
39
+ compatible: boolean;
40
+ }
41
+ export interface MCPServerInfo {
42
+ name?: string;
43
+ version?: string;
44
+ }
22
45
  export interface MCPConnection {
23
46
  server: MCPServerConfig;
24
47
  status: 'connected' | 'failed' | 'timeout';
25
48
  capabilities?: Record<string, unknown>;
26
49
  tools?: MCPToolDefinition[];
50
+ /** Populated only if the server's `initialize` response declared a `resources` capability. */
51
+ resources?: MCPResourceDefinition[];
52
+ /** Populated only if the server's `initialize` response declared a `prompts` capability. */
53
+ prompts?: MCPPromptDefinition[];
54
+ /** Best-effort failures from optional capability inspection (resources/prompts) — these
55
+ * never fail the overall connection, since `tools` is the one capability mcp-medic requires. */
56
+ capabilityErrors?: {
57
+ resources?: string;
58
+ prompts?: string;
59
+ };
60
+ /** Set once an `initialize` response was received, even if negotiation was incompatible or a later stage failed. */
61
+ protocolVersion?: ProtocolVersionInfo;
62
+ serverInfo?: MCPServerInfo;
27
63
  error?: {
28
64
  stage: 'spawn' | 'handshake' | 'capability-negotiation' | 'list-tools';
29
65
  message: string;
@@ -55,6 +91,8 @@ export interface RunOptions {
55
91
  checks?: Check[];
56
92
  verbose?: boolean;
57
93
  onLog?: (message: string) => void;
94
+ /** "auto" (default) requests the newest protocol version this client supports; an explicit version string requests that version instead. */
95
+ protocolVersion?: string;
58
96
  }
59
97
  export interface RunReport {
60
98
  configSource?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-medic",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "Diagnose broken MCP (Model Context Protocol) server configs before they break your agent silently.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",