codex-plugin-doctor 1.49.0 → 1.51.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 (31) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +3 -3
  3. package/dist/core/mcp-conformance.d.ts +2 -0
  4. package/dist/core/mcp-conformance.js +215 -0
  5. package/dist/core/npm-package-doctor.d.ts +11 -0
  6. package/dist/core/npm-package-doctor.js +12 -2
  7. package/dist/core/output-contract.js +82 -5
  8. package/dist/core/runtime-plan.js +2 -0
  9. package/dist/core/runtime-probe.d.ts +4 -2
  10. package/dist/core/runtime-probe.js +326 -64
  11. package/dist/core/runtime-transcript.js +13 -3
  12. package/dist/domain/types.d.ts +34 -0
  13. package/dist/mcp/generic-mcp-doctor.d.ts +8 -2
  14. package/dist/mcp/generic-mcp-doctor.js +35 -11
  15. package/dist/reporting/render-markdown-report.js +31 -13
  16. package/dist/reporting/render-text-report.js +28 -22
  17. package/dist/rules/rule-catalog.js +72 -0
  18. package/dist/run-cli.js +43 -8
  19. package/examples/README.md +94 -94
  20. package/examples/codex-doctor-generic-mcp/.mcp.json +10 -10
  21. package/examples/codex-doctor-generic-mcp/server.js +1 -1
  22. package/examples/codex-doctor-risky/.codex-plugin/plugin.json +7 -7
  23. package/examples/codex-doctor-risky/.mcp.json +12 -12
  24. package/examples/codex-doctor-risky/mock-server.js +1 -1
  25. package/examples/codex-doctor-runtime/.codex-plugin/plugin.json +8 -8
  26. package/examples/codex-doctor-runtime/.mcp.json +9 -9
  27. package/examples/codex-doctor-runtime/mock-server.js +237 -237
  28. package/examples/codex-doctor-runtime/skills/context-check/SKILL.md +7 -7
  29. package/examples/codex-doctor-starter/.codex-plugin/plugin.json +7 -7
  30. package/examples/codex-doctor-starter/skills/repo-scan/SKILL.md +7 -7
  31. package/package.json +1 -1
@@ -1,8 +1,9 @@
1
1
  import { execFile, spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
- import { readFile, stat } from "node:fs/promises";
3
+ import { readFile, realpath, stat } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import readline from "node:readline";
6
+ import { evaluateMcpConformance } from "./mcp-conformance.js";
6
7
  import { buildRuntimeLaunch, DOCKER_RUNTIME_STARTUP_TIMEOUT_MS, RuntimeSandboxError } from "./runtime-sandbox.js";
7
8
  import { formatRequestTranscript as formatRequestTranscriptForLog, formatResponseTranscript as formatResponseTranscriptForLog } from "./runtime-transcript.js";
8
9
  const MCP_PROTOCOL_VERSION = "2025-11-25";
@@ -12,6 +13,7 @@ const MAX_TOOL_CALL_CONTENT_LENGTH = 4096;
12
13
  const MAX_RESOURCE_READ_CONTENT_LENGTH = 4096;
13
14
  const MAX_PROMPT_GET_CONTENT_LENGTH = 4096;
14
15
  const DOCKER_CLEANUP_TIMEOUT_MS = 5_000;
16
+ const MAX_RUNTIME_PAGINATION_PAGES = 100;
15
17
  function buildNativeRuntimeLaunch(input) {
16
18
  return {
17
19
  ...input,
@@ -33,7 +35,16 @@ function createRuntimeScorecard() {
33
35
  resourceRead: "unsupported",
34
36
  resourceTemplatesList: "unsupported",
35
37
  promptsList: "unsupported",
36
- promptGet: "unsupported"
38
+ promptGet: "unsupported",
39
+ conformance: {
40
+ protocolVersion: null,
41
+ profile: null,
42
+ capabilityConsistency: "skipped",
43
+ taskDeclarations: "skipped",
44
+ tasksList: "skipped",
45
+ schemaDialect: "skipped",
46
+ overall: "skipped"
47
+ }
37
48
  };
38
49
  }
39
50
  function buildFailure(id, message, impact, suggestedFix, evidence) {
@@ -57,6 +68,16 @@ function buildWarning(id, message, impact, suggestedFix, evidence) {
57
68
  };
58
69
  }
59
70
  function methodForRuntimeFinding(id) {
71
+ if (id.startsWith("mcp.conformance.protocol.") ||
72
+ id.startsWith("mcp.conformance.tasks.capability_")) {
73
+ return "initialize";
74
+ }
75
+ if (id.startsWith("mcp.conformance.tasks.task_support_") ||
76
+ id.startsWith("mcp.conformance.schema.")) {
77
+ return "tools/list";
78
+ }
79
+ if (id.startsWith("mcp.conformance.tasks_list."))
80
+ return "tasks/list";
60
81
  if (id.includes(".initialize."))
61
82
  return "initialize";
62
83
  if (id.includes(".tools_list."))
@@ -81,9 +102,9 @@ function withRuntimeEvidence(finding, serverName) {
81
102
  return {
82
103
  ...finding,
83
104
  evidence: {
105
+ ...(finding.evidence ?? {}),
84
106
  serverName,
85
- method: methodForRuntimeFinding(finding.id),
86
- ...(finding.evidence ?? {})
107
+ method: methodForRuntimeFinding(finding.id)
87
108
  }
88
109
  };
89
110
  }
@@ -228,15 +249,7 @@ function isPathWithinRoot(rootPath, candidatePath) {
228
249
  return (relativePath === "" ||
229
250
  (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)));
230
251
  }
231
- async function loadMcpServers(discoveredPackage) {
232
- const { manifest, rootPath } = discoveredPackage;
233
- if (!manifest.mcpServers) {
234
- return null;
235
- }
236
- const mcpConfigPath = path.resolve(rootPath, manifest.mcpServers);
237
- if (!isPathWithinRoot(rootPath, mcpConfigPath)) {
238
- return null;
239
- }
252
+ async function loadMcpServers(mcpConfigPath) {
240
253
  const exists = await fileExists(mcpConfigPath);
241
254
  if (!exists) {
242
255
  return null;
@@ -276,6 +289,57 @@ function hasPromptsCapability(message) {
276
289
  const capabilities = getCapabilities(message);
277
290
  return capabilities !== null && isPlainObject(capabilities.prompts);
278
291
  }
292
+ function worstRuntimeStatus(left, right) {
293
+ const severity = {
294
+ fail: 4,
295
+ warn: 3,
296
+ pass: 2,
297
+ skipped: 1,
298
+ unsupported: 0
299
+ };
300
+ return severity[left] >= severity[right] ? left : right;
301
+ }
302
+ function mergeRuntimeScorecards(left, right) {
303
+ const leftConformance = left.conformance;
304
+ const rightConformance = right.conformance;
305
+ const conformance = leftConformance && rightConformance
306
+ ? {
307
+ protocolVersion: leftConformance.protocolVersion === null
308
+ ? rightConformance.protocolVersion
309
+ : rightConformance.protocolVersion === null ||
310
+ leftConformance.protocolVersion === rightConformance.protocolVersion
311
+ ? leftConformance.protocolVersion
312
+ : null,
313
+ profile: leftConformance.profile === null
314
+ ? rightConformance.profile
315
+ : rightConformance.profile === null || leftConformance.profile === rightConformance.profile
316
+ ? leftConformance.profile
317
+ : null,
318
+ capabilityConsistency: worstRuntimeStatus(leftConformance.capabilityConsistency, rightConformance.capabilityConsistency),
319
+ taskDeclarations: worstRuntimeStatus(leftConformance.taskDeclarations, rightConformance.taskDeclarations),
320
+ tasksList: worstRuntimeStatus(leftConformance.tasksList, rightConformance.tasksList),
321
+ schemaDialect: worstRuntimeStatus(leftConformance.schemaDialect, rightConformance.schemaDialect),
322
+ overall: worstRuntimeStatus(leftConformance.overall, rightConformance.overall)
323
+ }
324
+ : leftConformance ?? rightConformance;
325
+ return {
326
+ initialize: worstRuntimeStatus(left.initialize, right.initialize),
327
+ toolsList: worstRuntimeStatus(left.toolsList, right.toolsList),
328
+ toolsCall: worstRuntimeStatus(left.toolsCall, right.toolsCall),
329
+ resourcesList: worstRuntimeStatus(left.resourcesList, right.resourcesList),
330
+ resourceRead: worstRuntimeStatus(left.resourceRead, right.resourceRead),
331
+ resourceTemplatesList: worstRuntimeStatus(left.resourceTemplatesList, right.resourceTemplatesList),
332
+ promptsList: worstRuntimeStatus(left.promptsList, right.promptsList),
333
+ promptGet: worstRuntimeStatus(left.promptGet, right.promptGet),
334
+ ...(conformance ? { conformance } : {})
335
+ };
336
+ }
337
+ function supportsTasksListProbe(protocolVersion, capabilities) {
338
+ if (protocolVersion < MCP_PROTOCOL_VERSION || !isPlainObject(capabilities.tasks)) {
339
+ return false;
340
+ }
341
+ return isPlainObject(capabilities.tasks.list);
342
+ }
279
343
  function getNextCursor(result) {
280
344
  if (result.nextCursor === undefined) {
281
345
  return null;
@@ -294,13 +358,14 @@ function extractToolsPage(message) {
294
358
  for (const tool of tools) {
295
359
  if (!isPlainObject(tool) ||
296
360
  typeof tool.name !== "string" ||
297
- !isPlainObject(tool.inputSchema) ||
298
- tool.inputSchema.type !== "object") {
361
+ !("inputSchema" in tool)) {
299
362
  return null;
300
363
  }
301
364
  parsedTools.push({
302
365
  name: tool.name,
303
- inputSchema: tool.inputSchema
366
+ inputSchema: tool.inputSchema,
367
+ ...(tool.outputSchema === undefined ? {} : { outputSchema: tool.outputSchema }),
368
+ ...(tool.execution === undefined ? {} : { execution: tool.execution })
304
369
  });
305
370
  }
306
371
  const nextCursor = getNextCursor(message.result);
@@ -472,16 +537,55 @@ function findCallableTool(tools) {
472
537
  if (isDestructiveTool(tool)) {
473
538
  continue;
474
539
  }
475
- const args = buildToolArguments(tool);
540
+ if (!isPlainObject(tool.inputSchema) || tool.inputSchema.type !== "object") {
541
+ continue;
542
+ }
543
+ const callableTool = {
544
+ name: tool.name,
545
+ inputSchema: tool.inputSchema
546
+ };
547
+ const args = buildToolArguments(callableTool);
476
548
  if (args !== undefined) {
477
549
  return {
478
- tool,
550
+ tool: callableTool,
479
551
  args
480
552
  };
481
553
  }
482
554
  }
483
555
  return null;
484
556
  }
557
+ function hasValidToolInputSchemas(tools) {
558
+ return tools.every((tool) => isPlainObject(tool.inputSchema) && tool.inputSchema.type === "object");
559
+ }
560
+ async function resolveRuntimeCwd(rootPath, configuredCwd) {
561
+ const resolvedCwd = typeof configuredCwd === "string"
562
+ ? path.resolve(rootPath, configuredCwd)
563
+ : rootPath;
564
+ try {
565
+ const [canonicalCwd, details] = await Promise.all([
566
+ realpath(resolvedCwd),
567
+ stat(resolvedCwd)
568
+ ]);
569
+ return details.isDirectory() && isPathWithinRoot(rootPath, canonicalCwd)
570
+ ? canonicalCwd
571
+ : null;
572
+ }
573
+ catch {
574
+ return null;
575
+ }
576
+ }
577
+ function isProtocolVersion(value) {
578
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
579
+ if (!match) {
580
+ return false;
581
+ }
582
+ const [year, month, day] = match.slice(1).map(Number);
583
+ const date = new Date(0);
584
+ date.setUTCFullYear(year, month - 1, day);
585
+ return (date.getUTCFullYear() === year &&
586
+ date.getUTCMonth() === month - 1 &&
587
+ date.getUTCDate() === day);
588
+ }
485
589
  function buildPromptArguments(prompt) {
486
590
  if (!prompt.arguments || prompt.arguments.length === 0) {
487
591
  return undefined;
@@ -744,7 +848,12 @@ async function probeCommandServer(input) {
744
848
  const fetchPaginated = async (input) => {
745
849
  let cursor = null;
746
850
  const items = [];
851
+ const seenCursors = new Set();
852
+ let pageCount = 0;
747
853
  do {
854
+ if (pageCount >= MAX_RUNTIME_PAGINATION_PAGES) {
855
+ return null;
856
+ }
748
857
  const response = await sendRequest(input.method, cursor ? { cursor } : undefined, input.timeoutFinding);
749
858
  transcript?.(formatResponseTranscriptForLog(input.method, response));
750
859
  if (isErrorResponse(response)) {
@@ -759,10 +868,81 @@ async function probeCommandServer(input) {
759
868
  return null;
760
869
  }
761
870
  items.push(...page.items);
871
+ pageCount += 1;
872
+ if (page.nextCursor && seenCursors.has(page.nextCursor)) {
873
+ return null;
874
+ }
875
+ if (page.nextCursor) {
876
+ seenCursors.add(page.nextCursor);
877
+ }
762
878
  cursor = page.nextCursor;
763
879
  } while (cursor);
764
880
  return items;
765
881
  };
882
+ const observeTasksList = async () => {
883
+ let cursor = null;
884
+ const seenCursors = new Set();
885
+ let itemCount = 0;
886
+ let pageCount = 0;
887
+ try {
888
+ do {
889
+ if (pageCount >= MAX_RUNTIME_PAGINATION_PAGES) {
890
+ return {
891
+ status: "fail",
892
+ itemCount,
893
+ pageCount,
894
+ failure: "invalid"
895
+ };
896
+ }
897
+ const response = await sendRequest("tasks/list", cursor ? { cursor } : undefined, buildFailure("mcp.conformance.tasks_list.timeout", `The MCP server \`${serverName}\` did not answer the tasks/list request in time.`, "Codex cannot safely inspect task discovery when the server does not complete tasks/list.", "Reduce tasks/list latency and verify pagination completes."));
898
+ if (isErrorResponse(response) || !isPlainObject(response.result)) {
899
+ return {
900
+ status: "fail",
901
+ itemCount,
902
+ pageCount,
903
+ failure: "invalid"
904
+ };
905
+ }
906
+ const tasks = response.result.tasks;
907
+ const nextCursor = getNextCursor(response.result);
908
+ if (!Array.isArray(tasks) ||
909
+ !tasks.every(isPlainObject) ||
910
+ nextCursor === "__invalid__") {
911
+ return {
912
+ status: "fail",
913
+ itemCount,
914
+ pageCount,
915
+ failure: "invalid"
916
+ };
917
+ }
918
+ itemCount += tasks.length;
919
+ pageCount += 1;
920
+ if (nextCursor && seenCursors.has(nextCursor)) {
921
+ return {
922
+ status: "fail",
923
+ itemCount,
924
+ pageCount,
925
+ failure: "invalid"
926
+ };
927
+ }
928
+ if (nextCursor) {
929
+ seenCursors.add(nextCursor);
930
+ }
931
+ cursor = nextCursor;
932
+ } while (cursor);
933
+ }
934
+ catch (error) {
935
+ return {
936
+ status: "fail",
937
+ itemCount,
938
+ pageCount,
939
+ failure: isFinding(error) && error.id === "mcp.conformance.tasks_list.timeout"
940
+ ? "timeout"
941
+ : "invalid"
942
+ };
943
+ }
944
+ return { status: "pass", itemCount, pageCount };
945
+ };
766
946
  child.stderr?.on("data", (chunk) => {
767
947
  if (stderrPreview.length >= 160) {
768
948
  return;
@@ -835,6 +1015,7 @@ async function probeCommandServer(input) {
835
1015
  }
836
1016
  const result = initializeResponse.result;
837
1017
  if (typeof result.protocolVersion !== "string" ||
1018
+ !isProtocolVersion(result.protocolVersion) ||
838
1019
  !isPlainObject(result.capabilities) ||
839
1020
  !isPlainObject(result.serverInfo) ||
840
1021
  typeof result.serverInfo.name !== "string" ||
@@ -845,43 +1026,79 @@ async function probeCommandServer(input) {
845
1026
  }
846
1027
  scorecard.initialize = "pass";
847
1028
  sendNotification("notifications/initialized");
1029
+ let tools = [];
1030
+ let toolTerminalFinding = null;
1031
+ let canEvaluateConformance = true;
848
1032
  if (!hasToolsCapability(initializeResponse)) {
849
1033
  scorecard.toolsList = "unsupported";
850
1034
  scorecard.toolsCall = "unsupported";
851
- settle(buildWarning("plugin.runtime.tools.unsupported", `The MCP server \`${serverName}\` does not advertise tools capability.`, "Codex cannot use `tools/list` for deeper validation if the server does not declare tools support.", "Expose `capabilities.tools` during initialize if this server is expected to provide tools."));
852
- return;
853
- }
854
- const tools = await fetchPaginated({
855
- method: "tools/list",
856
- timeoutFinding: buildFailure("plugin.runtime.tools_list.timeout", `The MCP server \`${serverName}\` did not answer the tools/list request in time.`, "A server that cannot return its tool catalog in time will feel broken or invisible in Codex.", "Inspect the tool discovery path and reduce latency before returning the tool list."),
857
- extractPage: extractToolsPage
858
- });
859
- if (!tools) {
860
- scorecard.toolsList = "fail";
861
- settle(buildFailure("plugin.runtime.tools_list.invalid", `The MCP server \`${serverName}\` returned an invalid tools/list result.`, "Codex cannot safely consume malformed tool definitions from `tools/list`.", "Return a `tools` array where every tool has a string `name` and an object-shaped `inputSchema` with `type: \"object\"`."));
862
- return;
863
- }
864
- scorecard.toolsList = "pass";
865
- const callableTool = findCallableTool(tools);
866
- if (!callableTool) {
867
- scorecard.toolsCall = "skipped";
868
- settle(buildWarning("plugin.runtime.tool_call.skipped", `The MCP server \`${serverName}\` does not expose a safely callable tool for probing.`, "The validator confirmed tool discovery but could not safely perform a non-destructive `tools/call` probe.", "Expose at least one non-destructive tool with a JSON schema the validator can generate arguments for."));
869
- return;
1035
+ warnings.push(buildWarning("plugin.runtime.tools.unsupported", `The MCP server \`${serverName}\` does not advertise tools capability.`, "Codex cannot use `tools/list` for deeper validation if the server does not declare tools support.", "Expose `capabilities.tools` during initialize if this server is expected to provide tools."));
870
1036
  }
871
1037
  else {
872
- const toolCallResponse = await sendRequest("tools/call", {
873
- name: callableTool.tool.name,
874
- arguments: callableTool.args
875
- }, buildFailure("plugin.runtime.tool_call.timeout", `The MCP server \`${serverName}\` did not answer the tools/call request in time.`, "A server that cannot complete a basic tool invocation in time will feel broken in real Codex usage.", "Inspect the selected tool implementation and reduce its response latency.", { toolName: callableTool.tool.name }));
876
- if (isErrorResponse(toolCallResponse) || !isValidCallToolResult(toolCallResponse)) {
877
- scorecard.toolsCall = "fail";
878
- settle(buildFailure("plugin.runtime.tool_call.invalid", `The MCP server \`${serverName}\` returned an invalid tools/call result.`, "Codex cannot safely consume malformed tool call results from the server.", "Return a CallToolResult with a `content` array containing valid MCP content blocks.", { toolName: callableTool.tool.name }));
879
- return;
1038
+ const listedTools = await fetchPaginated({
1039
+ method: "tools/list",
1040
+ timeoutFinding: buildFailure("plugin.runtime.tools_list.timeout", `The MCP server \`${serverName}\` did not answer the tools/list request in time.`, "A server that cannot return its tool catalog in time will feel broken or invisible in Codex.", "Inspect the tool discovery path and reduce latency before returning the tool list."),
1041
+ extractPage: extractToolsPage
1042
+ });
1043
+ if (!listedTools) {
1044
+ canEvaluateConformance = false;
1045
+ scorecard.toolsList = "fail";
1046
+ toolTerminalFinding = buildFailure("plugin.runtime.tools_list.invalid", `The MCP server \`${serverName}\` returned an invalid tools/list result.`, "Codex cannot safely consume malformed tool definitions from `tools/list`.", "Return a `tools` array where every tool has a string `name` and an object-shaped `inputSchema` with `type: \"object\"`.");
1047
+ }
1048
+ else {
1049
+ tools = listedTools;
1050
+ if (!hasValidToolInputSchemas(tools)) {
1051
+ canEvaluateConformance = false;
1052
+ scorecard.toolsList = "fail";
1053
+ toolTerminalFinding = buildFailure("plugin.runtime.tools_list.invalid", `The MCP server \`${serverName}\` returned an invalid tools/list result.`, "Codex cannot safely consume malformed tool definitions from `tools/list`.", "Return a `tools` array where every tool has a string `name` and an object-shaped `inputSchema` with `type: \"object\"`.");
1054
+ }
1055
+ else {
1056
+ scorecard.toolsList = "pass";
1057
+ }
880
1058
  }
881
- scorecard.toolsCall = "pass";
882
- warnings.push(...collectOversizedToolCallWarnings(toolCallResponse, {
883
- toolName: callableTool.tool.name
884
- }));
1059
+ const callableTool = toolTerminalFinding ? null : findCallableTool(tools);
1060
+ if (!toolTerminalFinding && !callableTool) {
1061
+ scorecard.toolsCall = "skipped";
1062
+ toolTerminalFinding = buildWarning("plugin.runtime.tool_call.skipped", `The MCP server \`${serverName}\` does not expose a safely callable tool for probing.`, "The validator confirmed tool discovery but could not safely perform a non-destructive `tools/call` probe.", "Expose at least one non-destructive tool with a JSON schema the validator can generate arguments for.");
1063
+ }
1064
+ else if (callableTool) {
1065
+ const toolCallResponse = await sendRequest("tools/call", {
1066
+ name: callableTool.tool.name,
1067
+ arguments: callableTool.args
1068
+ }, buildFailure("plugin.runtime.tool_call.timeout", `The MCP server \`${serverName}\` did not answer the tools/call request in time.`, "A server that cannot complete a basic tool invocation in time will feel broken in real Codex usage.", "Inspect the selected tool implementation and reduce its response latency.", { toolName: callableTool.tool.name }));
1069
+ if (isErrorResponse(toolCallResponse) || !isValidCallToolResult(toolCallResponse)) {
1070
+ scorecard.toolsCall = "fail";
1071
+ toolTerminalFinding = buildFailure("plugin.runtime.tool_call.invalid", `The MCP server \`${serverName}\` returned an invalid tools/call result.`, "Codex cannot safely consume malformed tool call results from the server.", "Return a CallToolResult with a `content` array containing valid MCP content blocks.", { toolName: callableTool.tool.name });
1072
+ }
1073
+ if (!toolTerminalFinding) {
1074
+ scorecard.toolsCall = "pass";
1075
+ warnings.push(...collectOversizedToolCallWarnings(toolCallResponse, {
1076
+ toolName: callableTool.tool.name
1077
+ }));
1078
+ }
1079
+ }
1080
+ }
1081
+ const tasksList = supportsTasksListProbe(result.protocolVersion, result.capabilities)
1082
+ ? await observeTasksList()
1083
+ : {
1084
+ status: "skipped",
1085
+ itemCount: 0,
1086
+ pageCount: 0
1087
+ };
1088
+ if (canEvaluateConformance || tasksList.status !== "skipped") {
1089
+ const conformance = evaluateMcpConformance({
1090
+ protocolVersion: result.protocolVersion,
1091
+ capabilities: result.capabilities,
1092
+ tools: tools,
1093
+ tasksList,
1094
+ skipToolDerivedChecks: !canEvaluateConformance
1095
+ });
1096
+ scorecard.conformance = conformance.scorecard;
1097
+ warnings.push(...conformance.findings);
1098
+ }
1099
+ if (toolTerminalFinding) {
1100
+ settle(toolTerminalFinding);
1101
+ return;
885
1102
  }
886
1103
  if (!hasResourcesCapability(initializeResponse)) {
887
1104
  scorecard.resourcesList = "unsupported";
@@ -1001,8 +1218,40 @@ async function probeCommandServer(input) {
1001
1218
  });
1002
1219
  }
1003
1220
  export async function probeRuntime(discoveredPackage, options = {}) {
1221
+ const { manifest, rootPath } = discoveredPackage;
1222
+ if (!manifest.mcpServers) {
1223
+ return {
1224
+ findings: [],
1225
+ scorecard: createRuntimeScorecard()
1226
+ };
1227
+ }
1228
+ return probeRuntimeConfig(rootPath, manifest.mcpServers, options);
1229
+ }
1230
+ export async function probeRuntimeConfig(rootPath, mcpConfigPath, options = {}) {
1231
+ const resolvedRootPath = path.resolve(rootPath);
1232
+ const resolvedMcpConfigPath = path.resolve(resolvedRootPath, mcpConfigPath);
1004
1233
  const startupTimeoutMs = options.startupTimeoutMs ?? 400;
1005
- const servers = await loadMcpServers(discoveredPackage);
1234
+ let canonicalRootPath;
1235
+ let canonicalMcpConfigPath;
1236
+ try {
1237
+ [canonicalRootPath, canonicalMcpConfigPath] = await Promise.all([
1238
+ realpath(resolvedRootPath),
1239
+ realpath(resolvedMcpConfigPath)
1240
+ ]);
1241
+ }
1242
+ catch {
1243
+ return {
1244
+ findings: [],
1245
+ scorecard: createRuntimeScorecard()
1246
+ };
1247
+ }
1248
+ if (!isPathWithinRoot(canonicalRootPath, canonicalMcpConfigPath)) {
1249
+ return {
1250
+ findings: [],
1251
+ scorecard: createRuntimeScorecard()
1252
+ };
1253
+ }
1254
+ const servers = await loadMcpServers(canonicalMcpConfigPath);
1006
1255
  if (!servers) {
1007
1256
  return {
1008
1257
  findings: [],
@@ -1012,6 +1261,7 @@ export async function probeRuntime(discoveredPackage, options = {}) {
1012
1261
  const findings = [];
1013
1262
  let scorecard = createRuntimeScorecard();
1014
1263
  let execution;
1264
+ let hasProbedServer = false;
1015
1265
  for (const [serverName, config] of Object.entries(servers)) {
1016
1266
  if (!isPlainObject(config)) {
1017
1267
  continue;
@@ -1023,20 +1273,32 @@ export async function probeRuntime(discoveredPackage, options = {}) {
1023
1273
  const args = Array.isArray(config.args)
1024
1274
  ? config.args.filter((value) => typeof value === "string")
1025
1275
  : [];
1026
- const cwd = typeof config.cwd === "string"
1027
- ? path.resolve(discoveredPackage.rootPath, config.cwd)
1028
- : discoveredPackage.rootPath;
1029
- const result = await probeCommandServer({
1030
- serverName,
1031
- packageRoot: discoveredPackage.rootPath,
1032
- command,
1033
- args,
1034
- cwd,
1035
- startupTimeoutMs,
1036
- sandbox: options.sandbox,
1037
- transcript: options.transcript
1038
- });
1039
- scorecard = result.scorecard;
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
1297
+ });
1298
+ scorecard = hasProbedServer
1299
+ ? mergeRuntimeScorecards(scorecard, result.scorecard)
1300
+ : result.scorecard;
1301
+ hasProbedServer = true;
1040
1302
  execution = result.execution ?? execution;
1041
1303
  if (result.findings.length > 0) {
1042
1304
  findings.push(...result.findings);
@@ -73,15 +73,20 @@ export function formatRequestTranscript(method, params) {
73
73
  if (!params) {
74
74
  return `-> ${method}`;
75
75
  }
76
+ if (method === "tasks/list" && params.cursor !== undefined) {
77
+ return '-> tasks/list {"cursor":"[CURSOR]"}';
78
+ }
76
79
  return `-> ${method} ${JSON.stringify(sanitizeTranscriptValue(params))}`;
77
80
  }
78
81
  export function formatResponseTranscript(method, message) {
79
82
  const error = getErrorObject(message);
80
83
  if (error) {
81
84
  const code = typeof error.code === "number" ? error.code : "?";
82
- const messageText = typeof error.message === "string"
83
- ? sanitizeTranscriptValue(error.message, ["error", "message"])
84
- : "error";
85
+ const messageText = method === "tasks/list"
86
+ ? "[REDACTED]"
87
+ : typeof error.message === "string"
88
+ ? sanitizeTranscriptValue(error.message, ["error", "message"])
89
+ : "error";
85
90
  return `<- ${method} error ${JSON.stringify({
86
91
  code,
87
92
  message: messageText
@@ -129,6 +134,11 @@ export function formatResponseTranscript(method, message) {
129
134
  prompts: Array.isArray(result.prompts) ? result.prompts.length : 0,
130
135
  nextCursor: typeof result.nextCursor === "string" ? "[CURSOR]" : undefined
131
136
  })}`;
137
+ case "tasks/list":
138
+ return `<- tasks/list ${JSON.stringify({
139
+ tasks: Array.isArray(result.tasks) ? result.tasks.length : 0,
140
+ nextCursor: typeof result.nextCursor === "string" ? "[CURSOR]" : undefined
141
+ })}`;
132
142
  case "prompts/get":
133
143
  return `<- prompts/get ${JSON.stringify({
134
144
  messages: Array.isArray(result.messages) ? result.messages.length : 0
@@ -90,6 +90,40 @@ export interface RuntimeScorecard {
90
90
  resourceTemplatesList: RuntimeCapabilityStatus;
91
91
  promptsList: RuntimeCapabilityStatus;
92
92
  promptGet: RuntimeCapabilityStatus;
93
+ conformance?: RuntimeConformanceScorecard;
94
+ }
95
+ export type McpConformanceProfile = "legacy" | "2025-11-25" | "future-compatible";
96
+ export interface RuntimeConformanceScorecard {
97
+ protocolVersion: string | null;
98
+ profile: McpConformanceProfile | null;
99
+ capabilityConsistency: RuntimeCapabilityStatus;
100
+ taskDeclarations: RuntimeCapabilityStatus;
101
+ tasksList: RuntimeCapabilityStatus;
102
+ schemaDialect: RuntimeCapabilityStatus;
103
+ overall: "pass" | "warn" | "fail" | "skipped";
104
+ }
105
+ export interface McpToolObservation {
106
+ name: string;
107
+ inputSchema: unknown;
108
+ outputSchema?: unknown;
109
+ execution?: unknown;
110
+ }
111
+ export interface TasksListObservation {
112
+ status: "pass" | "fail" | "skipped";
113
+ itemCount: number;
114
+ pageCount: number;
115
+ failure?: "timeout" | "invalid";
116
+ }
117
+ export interface McpConformanceObservation {
118
+ protocolVersion: string;
119
+ capabilities: unknown;
120
+ tools: McpToolObservation[];
121
+ tasksList?: TasksListObservation;
122
+ skipToolDerivedChecks?: boolean;
123
+ }
124
+ export interface McpConformanceResult {
125
+ findings: Finding[];
126
+ scorecard: RuntimeConformanceScorecard;
93
127
  }
94
128
  export interface RuntimeProbeResult {
95
129
  findings: Finding[];
@@ -1,5 +1,5 @@
1
1
  import { type CompatibilityEnvironment, type CompatibilityMatrix } from "../compatibility/compatibility-matrix.js";
2
- import type { Finding } from "../domain/types.js";
2
+ import type { Finding, RuntimeExecutionEvidence, RuntimeScorecard } from "../domain/types.js";
3
3
  import { type SecurityAudit } from "../security/security-audit.js";
4
4
  export interface GenericMcpDoctorReport {
5
5
  targetPath: string;
@@ -10,7 +10,13 @@ export interface GenericMcpDoctorReport {
10
10
  findings: Finding[];
11
11
  security: SecurityAudit;
12
12
  compatibility: CompatibilityMatrix;
13
+ runtimeScorecard?: RuntimeScorecard;
14
+ runtimeExecution?: RuntimeExecutionEvidence;
13
15
  }
14
- export declare function buildGenericMcpDoctor(targetPath: string, environment?: CompatibilityEnvironment): Promise<GenericMcpDoctorReport>;
16
+ export interface GenericMcpDoctorOptions {
17
+ runtime?: boolean;
18
+ runtimeStartupTimeoutMs?: number;
19
+ }
20
+ export declare function buildGenericMcpDoctor(targetPath: string, environment?: CompatibilityEnvironment, options?: GenericMcpDoctorOptions): Promise<GenericMcpDoctorReport>;
15
21
  export declare function renderGenericMcpDoctorJson(report: GenericMcpDoctorReport): string;
16
22
  export declare function renderGenericMcpDoctor(report: GenericMcpDoctorReport): string;