codex-plugin-doctor 1.51.0 → 1.53.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 (38) hide show
  1. package/README.md +17 -2
  2. package/dist/core/bounded-http-client.d.ts +23 -0
  3. package/dist/core/bounded-http-client.js +241 -0
  4. package/dist/core/doctor-export-bundle.js +3 -1
  5. package/dist/core/output-contract.js +50 -1
  6. package/dist/core/release-check.d.ts +4 -0
  7. package/dist/core/release-check.js +4 -0
  8. package/dist/core/release-evidence.d.ts +4 -0
  9. package/dist/core/release-evidence.js +113 -7
  10. package/dist/core/remote-mcp-probe.d.ts +17 -0
  11. package/dist/core/remote-mcp-probe.js +284 -0
  12. package/dist/core/remote-network-policy.d.ts +24 -0
  13. package/dist/core/remote-network-policy.js +125 -0
  14. package/dist/core/remote-oauth-readiness.d.ts +12 -0
  15. package/dist/core/remote-oauth-readiness.js +295 -0
  16. package/dist/core/remote-transport-reliability.d.ts +18 -0
  17. package/dist/core/remote-transport-reliability.js +243 -0
  18. package/dist/core/remote-url-policy.d.ts +7 -0
  19. package/dist/core/remote-url-policy.js +49 -0
  20. package/dist/core/runtime-plan.d.ts +3 -0
  21. package/dist/core/runtime-plan.js +34 -2
  22. package/dist/core/runtime-policy.js +5 -1
  23. package/dist/core/runtime-probe.d.ts +10 -1
  24. package/dist/core/runtime-probe.js +89 -29
  25. package/dist/core/sse-observation.d.ts +7 -0
  26. package/dist/core/sse-observation.js +67 -0
  27. package/dist/core/validate-plugin.js +35 -3
  28. package/dist/domain/types.d.ts +26 -0
  29. package/dist/index.d.ts +4 -0
  30. package/dist/index.js +4 -0
  31. package/dist/mcp/generic-mcp-doctor.d.ts +4 -0
  32. package/dist/mcp/generic-mcp-doctor.js +15 -5
  33. package/dist/reporting/render-markdown-report.js +38 -12
  34. package/dist/reporting/render-text-report.js +32 -10
  35. package/dist/rules/rule-catalog.js +289 -1
  36. package/dist/run-cli.js +172 -5
  37. package/dist/security/security-audit.js +34 -7
  38. package/package.json +1 -1
@@ -5,6 +5,7 @@ import { discoverPackage } from "./discover-package.js";
5
5
  import { readJsonFile } from "./read-json-file.js";
6
6
  import { buildSecurityAudit } from "../security/security-audit.js";
7
7
  import { DOCKER_RUNTIME_IMAGE } from "./runtime-sandbox.js";
8
+ import { inspectRemoteMcpUrl } from "./remote-url-policy.js";
8
9
  function isPlainObject(value) {
9
10
  return typeof value === "object" && value !== null && !Array.isArray(value);
10
11
  }
@@ -47,6 +48,26 @@ function buildRisk(serverName, serverConfig, findings) {
47
48
  riskReasons: [...new Set(riskReasons)]
48
49
  };
49
50
  }
51
+ function remoteProbeMethods() {
52
+ return [
53
+ "POST initialize",
54
+ "POST notifications/initialized",
55
+ "GET OAuth protected-resource metadata (401 only)",
56
+ "GET OAuth authorization-server metadata (401 only)"
57
+ ];
58
+ }
59
+ function remoteNetworkClass(rawUrl) {
60
+ const inspection = inspectRemoteMcpUrl(rawUrl);
61
+ if (inspection.parsedUrl === null ||
62
+ (inspection.parsedUrl.protocol !== "http:" && inspection.parsedUrl.protocol !== "https:") ||
63
+ !inspection.parsedUrl.hostname) {
64
+ return "invalid";
65
+ }
66
+ if (inspection.isLoopbackHost) {
67
+ return inspection.parsedUrl.protocol === "http:" ? "loopback_http" : "loopback_https";
68
+ }
69
+ return "public_https";
70
+ }
50
71
  function planDigestPayload(plan) {
51
72
  return {
52
73
  schemaVersion: plan.schemaVersion,
@@ -119,6 +140,8 @@ export async function buildDoctorRuntimePlan(targetPath, generatedAt = new Date(
119
140
  const command = typeof serverConfig.command === "string" ? serverConfig.command : null;
120
141
  const url = typeof serverConfig.url === "string" ? serverConfig.url : null;
121
142
  const { riskLevel, riskReasons } = buildRisk(serverName, serverConfig, security.findings);
143
+ const networkClass = url ? remoteNetworkClass(url) : undefined;
144
+ const sanitizedUrl = url ? inspectRemoteMcpUrl(url).sanitizedUrl : null;
122
145
  return {
123
146
  name: serverName,
124
147
  transport: command ? "stdio" : "http",
@@ -127,7 +150,8 @@ export async function buildDoctorRuntimePlan(targetPath, generatedAt = new Date(
127
150
  ? serverConfig.args.filter((arg) => typeof arg === "string")
128
151
  : [],
129
152
  cwd: command ? normalizeCwd(discoveredPackage.rootPath, serverConfig.cwd) : null,
130
- url,
153
+ url: sanitizedUrl,
154
+ ...(networkClass ? { networkClass } : {}),
131
155
  probeMethods: command
132
156
  ? [
133
157
  "initialize",
@@ -140,7 +164,14 @@ export async function buildDoctorRuntimePlan(targetPath, generatedAt = new Date(
140
164
  "prompts/list",
141
165
  "prompts/get:first-prompt-only"
142
166
  ]
143
- : [],
167
+ : url ? remoteProbeMethods() : [],
168
+ ...(url
169
+ ? {
170
+ approvalRequirements: networkClass === "loopback_http" || networkClass === "loopback_https"
171
+ ? ["--runtime", "--allow-network", "--allow-local-network"]
172
+ : ["--runtime", "--allow-network"]
173
+ }
174
+ : {}),
144
175
  riskLevel,
145
176
  riskReasons
146
177
  };
@@ -247,6 +278,7 @@ export function renderDoctorRuntimePlanMarkdown(plan) {
247
278
  "- This plan is non-executing.",
248
279
  "- Probe methods explicitly exclude task create, get, result, and cancel operations, plus sampling and elicitation requests.",
249
280
  "- Runtime probes require explicit operator approval before local MCP servers are started.",
281
+ "- Any remote target whose DNS resolves to loopback requires `--allow-local-network`, even if its URL looks public.",
250
282
  "- The approval digest changes when command, args, cwd, probe methods, risk reasons, or findings change.",
251
283
  "- Runtime approval is a review gate, not an OS, VM, or container sandbox.",
252
284
  "",
@@ -38,7 +38,8 @@ function strongestDecision(decisions) {
38
38
  return "allow";
39
39
  }
40
40
  function buildRecommendation(plan, decision, reasons) {
41
- if (plan.summary.executableServerCount === 0) {
41
+ const hasRemoteServer = plan.servers.some((server) => server.transport === "http");
42
+ if (plan.summary.executableServerCount === 0 && !hasRemoteServer) {
42
43
  return {
43
44
  decision: "allow",
44
45
  reason: "No executable local MCP runtime servers were found.",
@@ -78,6 +79,9 @@ function buildRecommendation(plan, decision, reasons) {
78
79
  actions: [
79
80
  "Review command, args, cwd, URL, probe methods, and risk reasons before execution.",
80
81
  "Approve the exact plan digest with `check --runtime --require-runtime-approval --runtime-approval-digest <digest>`.",
82
+ ...(hasRemoteServer
83
+ ? ["Remote MCP probing also requires `--runtime --allow-network`; any target that DNS resolves to loopback additionally requires `--allow-local-network`."]
84
+ : []),
81
85
  "Use `doctor runtime-plan --markdown` when the approval needs to be preserved with release evidence."
82
86
  ]
83
87
  };
@@ -1,8 +1,17 @@
1
- import type { DiscoveredPackage, RuntimeProbeResult, RuntimeSandboxMode } from "../domain/types.js";
1
+ import type { DiscoveredPackage, RuntimeProbeResult, RuntimeSandboxMode, RuntimeScorecard } from "../domain/types.js";
2
+ import { type RemoteMcpRequest } from "./remote-mcp-probe.js";
3
+ import type { RemoteLookup } from "./remote-network-policy.js";
2
4
  export declare function probeRuntime(discoveredPackage: DiscoveredPackage, options?: RuntimeProbeOptions): Promise<RuntimeProbeResult>;
3
5
  export interface RuntimeProbeOptions {
4
6
  startupTimeoutMs?: number;
5
7
  sandbox?: RuntimeSandboxMode;
6
8
  transcript?: (line: string) => void;
9
+ allowNetwork?: boolean;
10
+ allowLocalNetwork?: boolean;
11
+ allowSessionLifecycle?: boolean;
12
+ remoteRequestTimeoutMs?: number;
13
+ remoteLookup?: RemoteLookup;
14
+ remoteRequest?: RemoteMcpRequest;
7
15
  }
16
+ export declare function remoteReliabilityGatePassed(scorecard: RuntimeScorecard | undefined): boolean;
8
17
  export declare function probeRuntimeConfig(rootPath: string, mcpConfigPath: string, options?: RuntimeProbeOptions): Promise<RuntimeProbeResult>;
@@ -4,6 +4,7 @@ import { readFile, realpath, stat } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import readline from "node:readline";
6
6
  import { evaluateMcpConformance } from "./mcp-conformance.js";
7
+ import { probeRemoteMcpServer } from "./remote-mcp-probe.js";
7
8
  import { buildRuntimeLaunch, DOCKER_RUNTIME_STARTUP_TIMEOUT_MS, RuntimeSandboxError } from "./runtime-sandbox.js";
8
9
  import { formatRequestTranscript as formatRequestTranscriptForLog, formatResponseTranscript as formatResponseTranscriptForLog } from "./runtime-transcript.js";
9
10
  const MCP_PROTOCOL_VERSION = "2025-11-25";
@@ -322,6 +323,9 @@ function mergeRuntimeScorecards(left, right) {
322
323
  overall: worstRuntimeStatus(leftConformance.overall, rightConformance.overall)
323
324
  }
324
325
  : leftConformance ?? rightConformance;
326
+ const remote = left.remote && right.remote
327
+ ? mergeRemoteRuntimeScorecards(left.remote, right.remote)
328
+ : left.remote ?? right.remote;
325
329
  return {
326
330
  initialize: worstRuntimeStatus(left.initialize, right.initialize),
327
331
  toolsList: worstRuntimeStatus(left.toolsList, right.toolsList),
@@ -331,7 +335,39 @@ function mergeRuntimeScorecards(left, right) {
331
335
  resourceTemplatesList: worstRuntimeStatus(left.resourceTemplatesList, right.resourceTemplatesList),
332
336
  promptsList: worstRuntimeStatus(left.promptsList, right.promptsList),
333
337
  promptGet: worstRuntimeStatus(left.promptGet, right.promptGet),
334
- ...(conformance ? { conformance } : {})
338
+ ...(conformance ? { conformance } : {}),
339
+ ...(remote ? { remote } : {})
340
+ };
341
+ }
342
+ function mergeRemoteRuntimeScorecards(left, right) {
343
+ const sessionSeverity = {
344
+ "present-invalid": 2,
345
+ "present-valid": 1,
346
+ absent: 0
347
+ };
348
+ const reliability = left.reliability && right.reliability
349
+ ? {
350
+ getSse: worstRuntimeStatus(left.reliability.getSse, right.reliability.getSse),
351
+ sessionPropagation: worstRuntimeStatus(left.reliability.sessionPropagation, right.reliability.sessionPropagation),
352
+ resumability: worstRuntimeStatus(left.reliability.resumability, right.reliability.resumability),
353
+ disconnectSafety: worstRuntimeStatus(left.reliability.disconnectSafety, right.reliability.disconnectSafety),
354
+ sessionRestart: worstRuntimeStatus(left.reliability.sessionRestart, right.reliability.sessionRestart),
355
+ termination: worstRuntimeStatus(left.reliability.termination, right.reliability.termination),
356
+ overall: worstRuntimeStatus(left.reliability.overall, right.reliability.overall)
357
+ }
358
+ : left.reliability ?? right.reliability;
359
+ return {
360
+ transport: worstRuntimeStatus(left.transport, right.transport),
361
+ networkSafety: worstRuntimeStatus(left.networkSafety, right.networkSafety),
362
+ initialize: worstRuntimeStatus(left.initialize, right.initialize),
363
+ contentType: worstRuntimeStatus(left.contentType, right.contentType),
364
+ session: sessionSeverity[left.session] >= sessionSeverity[right.session]
365
+ ? left.session
366
+ : right.session,
367
+ protocolHeaders: worstRuntimeStatus(left.protocolHeaders, right.protocolHeaders),
368
+ authorization: worstRuntimeStatus(left.authorization, right.authorization),
369
+ overall: worstRuntimeStatus(left.overall, right.overall),
370
+ ...(reliability ? { reliability } : {})
335
371
  };
336
372
  }
337
373
  function supportsTasksListProbe(protocolVersion, capabilities) {
@@ -1227,6 +1263,10 @@ export async function probeRuntime(discoveredPackage, options = {}) {
1227
1263
  }
1228
1264
  return probeRuntimeConfig(rootPath, manifest.mcpServers, options);
1229
1265
  }
1266
+ export function remoteReliabilityGatePassed(scorecard) {
1267
+ return scorecard !== undefined &&
1268
+ (scorecard.remote === undefined || scorecard.remote.reliability?.overall === "pass");
1269
+ }
1230
1270
  export async function probeRuntimeConfig(rootPath, mcpConfigPath, options = {}) {
1231
1271
  const resolvedRootPath = path.resolve(rootPath);
1232
1272
  const resolvedMcpConfigPath = path.resolve(resolvedRootPath, mcpConfigPath);
@@ -1266,35 +1306,55 @@ export async function probeRuntimeConfig(rootPath, mcpConfigPath, options = {})
1266
1306
  if (!isPlainObject(config)) {
1267
1307
  continue;
1268
1308
  }
1269
- const command = config.command;
1270
- if (typeof command !== "string") {
1271
- continue;
1272
- }
1273
- const args = Array.isArray(config.args)
1274
- ? config.args.filter((value) => typeof value === "string")
1275
- : [];
1276
- const cwd = await resolveRuntimeCwd(canonicalRootPath, config.cwd);
1277
- const result = cwd === null
1278
- ? (() => {
1279
- const invalidCwdScorecard = createRuntimeScorecard();
1280
- invalidCwdScorecard.initialize = "fail";
1281
- return {
1282
- findings: [
1283
- withRuntimeEvidence(buildFailure("plugin.runtime.startup.invalid_cwd", `The MCP server \`${serverName}\` has an invalid runtime working directory.`, "Runtime validation must not start a server from a missing, non-directory, or out-of-package working directory.", "Set the MCP server cwd to an existing directory inside the plugin package root, or remove it."), serverName)
1284
- ],
1285
- scorecard: invalidCwdScorecard
1286
- };
1287
- })()
1288
- : await probeCommandServer({
1289
- serverName,
1290
- packageRoot: canonicalRootPath,
1291
- command,
1292
- args,
1293
- cwd,
1294
- startupTimeoutMs,
1295
- sandbox: options.sandbox,
1296
- transcript: options.transcript
1309
+ const url = config.url;
1310
+ let result;
1311
+ if (typeof url === "string") {
1312
+ const remote = await probeRemoteMcpServer(serverName, url, {
1313
+ allowNetwork: options.allowNetwork,
1314
+ allowLocalNetwork: options.allowLocalNetwork,
1315
+ allowSessionLifecycle: options.allowSessionLifecycle,
1316
+ requestTimeoutMs: options.remoteRequestTimeoutMs,
1317
+ lookup: options.remoteLookup,
1318
+ request: options.remoteRequest
1297
1319
  });
1320
+ const remoteScorecard = createRuntimeScorecard();
1321
+ remoteScorecard.remote = remote.scorecard;
1322
+ result = {
1323
+ findings: remote.findings.map((finding) => withRuntimeEvidence(finding, serverName)),
1324
+ scorecard: remoteScorecard
1325
+ };
1326
+ }
1327
+ else {
1328
+ const command = config.command;
1329
+ if (typeof command !== "string") {
1330
+ continue;
1331
+ }
1332
+ const args = Array.isArray(config.args)
1333
+ ? config.args.filter((value) => typeof value === "string")
1334
+ : [];
1335
+ const cwd = await resolveRuntimeCwd(canonicalRootPath, config.cwd);
1336
+ result = cwd === null
1337
+ ? (() => {
1338
+ const invalidCwdScorecard = createRuntimeScorecard();
1339
+ invalidCwdScorecard.initialize = "fail";
1340
+ return {
1341
+ findings: [
1342
+ withRuntimeEvidence(buildFailure("plugin.runtime.startup.invalid_cwd", `The MCP server \`${serverName}\` has an invalid runtime working directory.`, "Runtime validation must not start a server from a missing, non-directory, or out-of-package working directory.", "Set the MCP server cwd to an existing directory inside the plugin package root, or remove it."), serverName)
1343
+ ],
1344
+ scorecard: invalidCwdScorecard
1345
+ };
1346
+ })()
1347
+ : await probeCommandServer({
1348
+ serverName,
1349
+ packageRoot: canonicalRootPath,
1350
+ command,
1351
+ args,
1352
+ cwd,
1353
+ startupTimeoutMs,
1354
+ sandbox: options.sandbox,
1355
+ transcript: options.transcript
1356
+ });
1357
+ }
1298
1358
  scorecard = hasProbedServer
1299
1359
  ? mergeRuntimeScorecards(scorecard, result.scorecard)
1300
1360
  : result.scorecard;
@@ -0,0 +1,7 @@
1
+ export interface SseObservation {
2
+ complete: boolean;
3
+ eventId: string | null;
4
+ retryMs: number | null;
5
+ malformed: boolean;
6
+ }
7
+ export declare function observeFirstSseEvent(body: Buffer): SseObservation;
@@ -0,0 +1,67 @@
1
+ const MAX_SSE_FIELD_BYTES = 1_024;
2
+ function isSafeEventId(value) {
3
+ return value.length > 0
4
+ && value.length <= MAX_SSE_FIELD_BYTES
5
+ && value.every((byte) => byte >= 0x21 && byte <= 0x7e);
6
+ }
7
+ function parseRetry(value) {
8
+ if (value.length === 0 || value.length > MAX_SSE_FIELD_BYTES) {
9
+ return null;
10
+ }
11
+ for (const byte of value) {
12
+ if (byte < 0x30 || byte > 0x39) {
13
+ return null;
14
+ }
15
+ }
16
+ const retryMs = Number(value.toString("ascii"));
17
+ return Number.isSafeInteger(retryMs) ? retryMs : null;
18
+ }
19
+ export function observeFirstSseEvent(body) {
20
+ let eventId = null;
21
+ let retryMs = null;
22
+ let malformed = false;
23
+ let lineStart = 0;
24
+ for (let index = 0; index < body.length; index += 1) {
25
+ if (body[index] !== 0x0a && body[index] !== 0x0d) {
26
+ continue;
27
+ }
28
+ const line = body.subarray(lineStart, index);
29
+ if (body[index] === 0x0d && body[index + 1] === 0x0a) {
30
+ index += 1;
31
+ }
32
+ lineStart = index + 1;
33
+ if (line.length === 0) {
34
+ return {
35
+ complete: true,
36
+ eventId: malformed ? null : eventId,
37
+ retryMs: malformed ? null : retryMs,
38
+ malformed
39
+ };
40
+ }
41
+ if (line[0] === 0x3a) {
42
+ continue;
43
+ }
44
+ const separator = line.indexOf(0x3a);
45
+ const field = separator === -1 ? line : line.subarray(0, separator);
46
+ const valueStart = separator === -1 ? line.length : separator + 1;
47
+ const value = line.subarray(valueStart + (line[valueStart] === 0x20 ? 1 : 0));
48
+ if (field.equals(Buffer.from("id"))) {
49
+ if (value.length === 0) {
50
+ eventId = null;
51
+ }
52
+ else if (isSafeEventId(value)) {
53
+ eventId = value.toString("ascii");
54
+ }
55
+ else {
56
+ malformed = true;
57
+ }
58
+ }
59
+ else if (field.equals(Buffer.from("retry"))) {
60
+ const parsedRetryMs = parseRetry(value);
61
+ if (parsedRetryMs !== null) {
62
+ retryMs = parsedRetryMs;
63
+ }
64
+ }
65
+ }
66
+ return { complete: false, eventId: null, retryMs: null, malformed: false };
67
+ }
@@ -2,7 +2,8 @@ import { readFile, readdir, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { withFindingFingerprints } from "../reporting/finding-fingerprint.js";
4
4
  import { discoverPackage } from "./discover-package.js";
5
- import { probeRuntime } from "./runtime-probe.js";
5
+ import { inspectRemoteMcpUrl } from "./remote-url-policy.js";
6
+ import { probeRuntime, remoteReliabilityGatePassed } from "./runtime-probe.js";
6
7
  function buildFailure(id, message, impact, suggestedFix, evidence) {
7
8
  return {
8
9
  id,
@@ -23,6 +24,16 @@ function buildWarning(id, message, impact, suggestedFix, evidence) {
23
24
  ...(evidence ? { evidence } : {})
24
25
  };
25
26
  }
27
+ function remoteUrlIssueFindingId(issue) {
28
+ return issue === "insecure_non_loopback"
29
+ ? "plugin.security.insecure_http_url"
30
+ : `plugin.security.remote_mcp_url.${issue}`;
31
+ }
32
+ function remoteUrlIssueMessage(issue) {
33
+ return issue === "insecure_non_loopback"
34
+ ? "uses an insecure public HTTP URL"
35
+ : `uses a remote MCP URL with ${issue.replaceAll("_", " ")}`;
36
+ }
26
37
  async function directoryExists(targetPath) {
27
38
  try {
28
39
  const details = await stat(targetPath);
@@ -376,6 +387,24 @@ async function validateMcpConfig(discoveredPackage) {
376
387
  field: "transport"
377
388
  }));
378
389
  }
390
+ if (typeof command === "string" && typeof url === "string") {
391
+ findings.push(buildFailure("plugin.mcp.server.transport.conflict", `The MCP server \`${serverName}\` must not define both \`command\` and \`url\`.`, "A server with two transports cannot be selected deterministically by MCP clients.", `Keep either \`command\` or \`url\` for the \`${serverName}\` entry in \`${mcpConfigPath}\`.`, {
392
+ configPath: relativePackagePath(rootPath, mcpConfigPath),
393
+ serverName,
394
+ field: "transport"
395
+ }));
396
+ }
397
+ if (typeof url === "string") {
398
+ const inspection = inspectRemoteMcpUrl(url);
399
+ for (const issue of inspection.issues) {
400
+ findings.push(buildFailure(remoteUrlIssueFindingId(issue), `The MCP server \`${serverName}\` ${remoteUrlIssueMessage(issue)}.`, "Unsafe or ambiguous remote transport configuration can expose credentials or prevent reliable MCP connectivity.", "Use an absolute HTTPS URL without credentials, query parameters, fragments, or numeric IP literals; HTTP is only supported for localhost development.", {
401
+ configPath: relativePackagePath(rootPath, mcpConfigPath),
402
+ serverName,
403
+ field: "url",
404
+ url: inspection.sanitizedUrl
405
+ }));
406
+ }
407
+ }
379
408
  if (isPlainObject(env)) {
380
409
  for (const [envKey, envValue] of Object.entries(env)) {
381
410
  if (isSensitiveKey(envKey) &&
@@ -415,7 +444,10 @@ export async function validatePlugin(targetPath, options = {}) {
415
444
  ? await probeRuntime(discoveredPackage, {
416
445
  startupTimeoutMs: options.runtimeStartupTimeoutMs,
417
446
  sandbox: options.runtimeSandbox,
418
- transcript: options.runtimeTranscript
447
+ transcript: options.runtimeTranscript,
448
+ allowNetwork: options.allowNetwork,
449
+ allowLocalNetwork: options.allowLocalNetwork,
450
+ allowSessionLifecycle: options.allowSessionLifecycle
419
451
  })
420
452
  : null;
421
453
  const findings = [
@@ -423,7 +455,7 @@ export async function validatePlugin(targetPath, options = {}) {
423
455
  ...(runtimeResult?.findings ?? [])
424
456
  ];
425
457
  const fingerprintedFindings = withFindingFingerprints(findings, discoveredPackage.rootPath);
426
- const hasFailures = fingerprintedFindings.some((finding) => finding.severity === "fail");
458
+ const hasFailures = fingerprintedFindings.some((finding) => finding.severity === "fail") || (options.requireRemoteReliability === true && !remoteReliabilityGatePassed(runtimeResult?.scorecard));
427
459
  const hasWarnings = fingerprintedFindings.some((finding) => finding.severity === "warn");
428
460
  if (!hasFailures && !hasWarnings) {
429
461
  return {
@@ -41,6 +41,10 @@ export interface BaselineSummary {
41
41
  }
42
42
  export interface CheckOptions {
43
43
  runtime?: boolean;
44
+ allowNetwork?: boolean;
45
+ allowLocalNetwork?: boolean;
46
+ allowSessionLifecycle?: boolean;
47
+ requireRemoteReliability?: boolean;
44
48
  runtimeTranscript?: (line: string) => void;
45
49
  runtimeStartupTimeoutMs?: number;
46
50
  runtimeSandbox?: RuntimeSandboxMode;
@@ -91,6 +95,28 @@ export interface RuntimeScorecard {
91
95
  promptsList: RuntimeCapabilityStatus;
92
96
  promptGet: RuntimeCapabilityStatus;
93
97
  conformance?: RuntimeConformanceScorecard;
98
+ remote?: RemoteRuntimeScorecard;
99
+ }
100
+ export interface RemoteRuntimeScorecard {
101
+ transport: RuntimeCapabilityStatus;
102
+ networkSafety: RuntimeCapabilityStatus;
103
+ initialize: RuntimeCapabilityStatus;
104
+ contentType: RuntimeCapabilityStatus;
105
+ session: "absent" | "present-valid" | "present-invalid";
106
+ protocolHeaders: RuntimeCapabilityStatus;
107
+ authorization: RuntimeCapabilityStatus;
108
+ reliability?: RemoteTransportReliabilityScorecard;
109
+ overall: "pass" | "warn" | "fail" | "skipped";
110
+ }
111
+ export type RemoteTransportReliabilityStatus = "pass" | "warn" | "fail" | "skipped";
112
+ export interface RemoteTransportReliabilityScorecard {
113
+ getSse: RemoteTransportReliabilityStatus;
114
+ sessionPropagation: RemoteTransportReliabilityStatus;
115
+ resumability: RemoteTransportReliabilityStatus;
116
+ disconnectSafety: RemoteTransportReliabilityStatus;
117
+ sessionRestart: RemoteTransportReliabilityStatus;
118
+ termination: RemoteTransportReliabilityStatus;
119
+ overall: RemoteTransportReliabilityStatus;
94
120
  }
95
121
  export type McpConformanceProfile = "legacy" | "2025-11-25" | "future-compatible";
96
122
  export interface RuntimeConformanceScorecard {
package/dist/index.d.ts CHANGED
@@ -18,6 +18,10 @@ export { buildDoctorExportBundleFromAnalysis, buildDoctorRecommendationsFromAnal
18
18
  export { buildDoctorPerformanceReport, renderDoctorPerformanceReport, renderDoctorPerformanceReportJson, type BuildDoctorPerformanceReportOptions, type DoctorPerformanceReport, type DoctorPerformanceStage, type DoctorPerformanceStageName } from "./core/performance-report.js";
19
19
  export { buildDoctorRuntimePlan, evaluateRuntimeApproval, renderDoctorRuntimePlan, renderDoctorRuntimePlanMarkdown, renderDoctorRuntimePlanJson, runtimeApprovalPassed, type DoctorRuntimePlan, type RuntimeApprovalReport, type RuntimePlanServer } from "./core/runtime-plan.js";
20
20
  export { buildDoctorRuntimePolicyReport, renderDoctorRuntimePolicy, renderDoctorRuntimePolicyJson, type DoctorRuntimePolicyReport, type RuntimePolicyDecision, type RuntimePolicyRecommendation } from "./core/runtime-policy.js";
21
+ export { inspectRemoteMcpUrl, type RemoteUrlInspection } from "./core/remote-url-policy.js";
22
+ export { resolveRemoteTarget, RemoteNetworkPolicyError, type ResolvedRemoteTarget, type ResolveRemoteTargetOptions } from "./core/remote-network-policy.js";
23
+ export { probeRemoteMcpServer, type RemoteMcpProbeOptions, type RemoteMcpProbeResult } from "./core/remote-mcp-probe.js";
24
+ export { checkRemoteOAuthReadiness, type RemoteOAuthReadinessOptions, type RemoteOAuthReadinessResult } from "./core/remote-oauth-readiness.js";
21
25
  export { buildDoctorReviewBundle, diffDoctorReviewBundles, renderDoctorReviewBundle, renderDoctorReviewBundleDiff, renderDoctorReviewBundleDiffJson, renderDoctorReviewBundleJson, renderDoctorReviewBundleVerification, renderDoctorReviewBundleVerificationJson, verifyDoctorReviewBundle, type BuildDoctorReviewBundleOptions, type DoctorReviewBundle, type DoctorReviewBundleDiffChange, type DoctorReviewBundleDiffReport, type DoctorReviewBundleDiffSnapshot, type DoctorReviewBundleManifest, type DoctorReviewBundleVerificationReport } from "./core/review-bundle.js";
22
26
  export { buildDoctorReleaseEvidenceAssetReport, buildDoctorReleaseEvidenceReport, renderDoctorReleaseEvidenceAsset, renderDoctorReleaseEvidenceAssetJson, renderDoctorReleaseEvidence, renderDoctorReleaseEvidenceJson, renderDoctorReleaseEvidenceVerification, renderDoctorReleaseEvidenceVerificationJson, verifyDoctorReleaseEvidence, type BuildDoctorReleaseEvidenceOptions, type DoctorReleaseEvidenceAssetReport, type DoctorReleaseEvidenceGitMetadata, type DoctorReleaseEvidencePackageMetadata, type DoctorReleaseEvidenceReport, type DoctorReleaseEvidenceVerificationReport } from "./core/release-evidence.js";
23
27
  export { buildDoctorNpmPackageReport, renderDoctorNpmPackageReport, renderDoctorNpmPackageReportJson, type BuildDoctorNpmPackageReportOptions, type DoctorNpmPackageReport } from "./core/npm-package-doctor.js";
package/dist/index.js CHANGED
@@ -18,6 +18,10 @@ export { buildDoctorExportBundleFromAnalysis, buildDoctorRecommendationsFromAnal
18
18
  export { buildDoctorPerformanceReport, renderDoctorPerformanceReport, renderDoctorPerformanceReportJson } from "./core/performance-report.js";
19
19
  export { buildDoctorRuntimePlan, evaluateRuntimeApproval, renderDoctorRuntimePlan, renderDoctorRuntimePlanMarkdown, renderDoctorRuntimePlanJson, runtimeApprovalPassed } from "./core/runtime-plan.js";
20
20
  export { buildDoctorRuntimePolicyReport, renderDoctorRuntimePolicy, renderDoctorRuntimePolicyJson } from "./core/runtime-policy.js";
21
+ export { inspectRemoteMcpUrl } from "./core/remote-url-policy.js";
22
+ export { resolveRemoteTarget, RemoteNetworkPolicyError } from "./core/remote-network-policy.js";
23
+ export { probeRemoteMcpServer } from "./core/remote-mcp-probe.js";
24
+ export { checkRemoteOAuthReadiness } from "./core/remote-oauth-readiness.js";
21
25
  export { buildDoctorReviewBundle, diffDoctorReviewBundles, renderDoctorReviewBundle, renderDoctorReviewBundleDiff, renderDoctorReviewBundleDiffJson, renderDoctorReviewBundleJson, renderDoctorReviewBundleVerification, renderDoctorReviewBundleVerificationJson, verifyDoctorReviewBundle } from "./core/review-bundle.js";
22
26
  export { buildDoctorReleaseEvidenceAssetReport, buildDoctorReleaseEvidenceReport, renderDoctorReleaseEvidenceAsset, renderDoctorReleaseEvidenceAssetJson, renderDoctorReleaseEvidence, renderDoctorReleaseEvidenceJson, renderDoctorReleaseEvidenceVerification, renderDoctorReleaseEvidenceVerificationJson, verifyDoctorReleaseEvidence } from "./core/release-evidence.js";
23
27
  export { buildDoctorNpmPackageReport, renderDoctorNpmPackageReport, renderDoctorNpmPackageReportJson } from "./core/npm-package-doctor.js";
@@ -15,6 +15,10 @@ export interface GenericMcpDoctorReport {
15
15
  }
16
16
  export interface GenericMcpDoctorOptions {
17
17
  runtime?: boolean;
18
+ allowNetwork?: boolean;
19
+ allowLocalNetwork?: boolean;
20
+ allowSessionLifecycle?: boolean;
21
+ requireRemoteReliability?: boolean;
18
22
  runtimeStartupTimeoutMs?: number;
19
23
  }
20
24
  export declare function buildGenericMcpDoctor(targetPath: string, environment?: CompatibilityEnvironment, options?: GenericMcpDoctorOptions): Promise<GenericMcpDoctorReport>;
@@ -2,7 +2,7 @@ import { realpath, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { buildCompatibilityMatrix, readMcpConfigPath } from "../compatibility/compatibility-matrix.js";
4
4
  import { readJsonFile } from "../core/read-json-file.js";
5
- import { probeRuntimeConfig } from "../core/runtime-probe.js";
5
+ import { probeRuntimeConfig, remoteReliabilityGatePassed } from "../core/runtime-probe.js";
6
6
  import { formatFindingFingerprintLine, withFindingFingerprints } from "../reporting/finding-fingerprint.js";
7
7
  import { auditMcpServerConfig, buildSecurityAuditFromFindings } from "../security/security-audit.js";
8
8
  function buildFinding(severity, id, message, impact, suggestedFix, evidence) {
@@ -64,9 +64,14 @@ function buildStaticMcpFindings(configPath, parsedConfig) {
64
64
  findings.push(buildFinding("fail", "mcp.server.invalid", `The MCP server \`${serverName}\` must be configured as an object.`, "MCP clients cannot interpret a server entry unless it is represented as an object with server options.", `Change the \`${serverName}\` entry in \`${configPath}\` to an object.`, { configPath, serverName, field: "server" }));
65
65
  continue;
66
66
  }
67
- if (typeof serverConfig.command !== "string" && typeof serverConfig.url !== "string") {
67
+ const command = serverConfig.command;
68
+ const url = serverConfig.url;
69
+ if (typeof command !== "string" && typeof url !== "string") {
68
70
  findings.push(buildFinding("fail", "mcp.server.transport.missing", `The MCP server \`${serverName}\` must define either \`command\` or \`url\`.`, "MCP clients need a process command for stdio servers or a URL for remote servers.", `Add either \`command\` or \`url\` to the \`${serverName}\` entry in \`${configPath}\`.`, { configPath, serverName, field: "transport" }));
69
71
  }
72
+ if (typeof command === "string" && typeof url === "string") {
73
+ findings.push(buildFinding("fail", "mcp.server.transport.conflict", `The MCP server \`${serverName}\` must not define both \`command\` and \`url\`.`, "A server with two transports cannot be selected deterministically by MCP clients.", `Keep either \`command\` or \`url\` for the \`${serverName}\` entry in \`${configPath}\`.`, { configPath, serverName, field: "transport" }));
74
+ }
70
75
  }
71
76
  return {
72
77
  serverCount: Object.keys(servers).length,
@@ -124,7 +129,7 @@ export async function buildGenericMcpDoctor(targetPath, environment = {}, option
124
129
  }
125
130
  }
126
131
  const security = buildSecurityAuditFromFindings(rootPath, mcpConfigPath && parsedConfig !== null
127
- ? auditMcpServerConfig(rootPath, parsedConfig)
132
+ ? auditMcpServerConfig(rootPath, parsedConfig, { configPath: mcpConfigPath })
128
133
  : []);
129
134
  const runtimeResult = options.runtime &&
130
135
  mcpConfigPath !== null &&
@@ -135,11 +140,16 @@ export async function buildGenericMcpDoctor(targetPath, environment = {}, option
135
140
  !staticFindings.some((finding) => finding.severity === "fail") &&
136
141
  security.status !== "fail"
137
142
  ? await probeRuntimeConfig(canonicalRootPath, canonicalMcpConfigPath, {
138
- startupTimeoutMs: options.runtimeStartupTimeoutMs
143
+ startupTimeoutMs: options.runtimeStartupTimeoutMs,
144
+ allowNetwork: options.allowNetwork,
145
+ allowLocalNetwork: options.allowLocalNetwork,
146
+ allowSessionLifecycle: options.allowSessionLifecycle
139
147
  })
140
148
  : null;
141
149
  const fingerprintedFindings = withFindingFingerprints([...staticFindings, ...(runtimeResult?.findings ?? [])], rootPath);
142
- const status = mergeReportStatus(fingerprintedFindings, security);
150
+ const status = options.requireRemoteReliability === true && !remoteReliabilityGatePassed(runtimeResult?.scorecard)
151
+ ? "fail"
152
+ : mergeReportStatus(fingerprintedFindings, security);
143
153
  return {
144
154
  targetPath: rootPath,
145
155
  status,
@@ -17,19 +17,45 @@ function appendRuntimeScorecard(lines, result) {
17
17
  lines.push(`| prompts/list | ${result.runtimeScorecard.promptsList.toUpperCase()} |`);
18
18
  lines.push(`| prompts/get | ${result.runtimeScorecard.promptGet.toUpperCase()} |`);
19
19
  const conformance = result.runtimeScorecard.conformance;
20
- if (!conformance) {
21
- return;
20
+ if (conformance) {
21
+ lines.push("", "## MCP Conformance", "");
22
+ lines.push("| Check | Status |");
23
+ lines.push("| --- | --- |");
24
+ lines.push(`| Protocol version | ${conformance.protocolVersion ?? "unavailable"} |`);
25
+ lines.push(`| Profile | ${conformance.profile ?? "unavailable"} |`);
26
+ lines.push(`| Capability consistency | ${conformance.capabilityConsistency.toUpperCase()} |`);
27
+ lines.push(`| Task declarations | ${conformance.taskDeclarations.toUpperCase()} |`);
28
+ lines.push(`| Tasks list | ${conformance.tasksList.toUpperCase()} |`);
29
+ lines.push(`| Schema dialect | ${conformance.schemaDialect.toUpperCase()} |`);
30
+ lines.push(`| Overall | ${conformance.overall.toUpperCase()} |`);
31
+ }
32
+ const remote = result.runtimeScorecard.remote;
33
+ if (remote) {
34
+ lines.push("", "## Remote MCP Scorecard", "");
35
+ lines.push("| Check | Status |");
36
+ lines.push("| --- | --- |");
37
+ lines.push(`| Transport | ${remote.transport.toUpperCase()} |`);
38
+ lines.push(`| Network safety | ${remote.networkSafety.toUpperCase()} |`);
39
+ lines.push(`| Initialize | ${remote.initialize.toUpperCase()} |`);
40
+ lines.push(`| Content type | ${remote.contentType.toUpperCase()} |`);
41
+ lines.push(`| Session | ${remote.session.toUpperCase()} |`);
42
+ lines.push(`| Protocol headers | ${remote.protocolHeaders.toUpperCase()} |`);
43
+ lines.push(`| Authorization | ${remote.authorization.toUpperCase()} |`);
44
+ lines.push(`| Overall | ${remote.overall.toUpperCase()} |`);
45
+ if (remote.reliability) {
46
+ const reliability = remote.reliability;
47
+ lines.push("", "## Remote Transport Reliability", "");
48
+ lines.push("| Check | Status |");
49
+ lines.push("| --- | --- |");
50
+ lines.push(`| GET SSE | ${reliability.getSse.toUpperCase()} |`);
51
+ lines.push(`| Session propagation | ${reliability.sessionPropagation.toUpperCase()} |`);
52
+ lines.push(`| Resumability | ${reliability.resumability.toUpperCase()} |`);
53
+ lines.push(`| Disconnect safety | ${reliability.disconnectSafety.toUpperCase()} |`);
54
+ lines.push(`| Session restart | ${reliability.sessionRestart.toUpperCase()} |`);
55
+ lines.push(`| Termination | ${reliability.termination.toUpperCase()} |`);
56
+ lines.push(`| Overall | ${reliability.overall.toUpperCase()} |`);
57
+ }
22
58
  }
23
- lines.push("", "## MCP Conformance", "");
24
- lines.push("| Check | Status |");
25
- lines.push("| --- | --- |");
26
- lines.push(`| Protocol version | ${conformance.protocolVersion ?? "unavailable"} |`);
27
- lines.push(`| Profile | ${conformance.profile ?? "unavailable"} |`);
28
- lines.push(`| Capability consistency | ${conformance.capabilityConsistency.toUpperCase()} |`);
29
- lines.push(`| Task declarations | ${conformance.taskDeclarations.toUpperCase()} |`);
30
- lines.push(`| Tasks list | ${conformance.tasksList.toUpperCase()} |`);
31
- lines.push(`| Schema dialect | ${conformance.schemaDialect.toUpperCase()} |`);
32
- lines.push(`| Overall | ${conformance.overall.toUpperCase()} |`);
33
59
  }
34
60
  export function buildMarkdownReport(result, options) {
35
61
  const failCount = result.findings.filter((finding) => finding.severity === "fail").length;