codex-plugin-doctor 1.78.0 → 1.79.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 (30) hide show
  1. package/README.md +15 -2
  2. package/dist/core/mcp-prompt-catalog.js +2 -0
  3. package/dist/core/mcp-resource-template-catalog.d.ts +36 -0
  4. package/dist/core/mcp-resource-template-catalog.js +55 -0
  5. package/dist/core/mcp-resource-template-definition.d.ts +28 -0
  6. package/dist/core/mcp-resource-template-definition.js +116 -0
  7. package/dist/core/mcp-resource-template-diff.d.ts +43 -0
  8. package/dist/core/mcp-resource-template-diff.js +136 -0
  9. package/dist/core/mcp-resource-template-file.d.ts +31 -0
  10. package/dist/core/mcp-resource-template-file.js +118 -0
  11. package/dist/core/mcp-resource-template-impact.d.ts +21 -0
  12. package/dist/core/mcp-resource-template-impact.js +74 -0
  13. package/dist/core/mcp-stdio-tool-catalog.d.ts +1 -1
  14. package/dist/core/mcp-stdio-tool-catalog.js +6 -6
  15. package/dist/core/mcp-tool-catalog.d.ts +8 -6
  16. package/dist/core/mcp-tool-catalog.js +7 -5
  17. package/dist/core/mcp-tool-response-file.d.ts +1 -1
  18. package/dist/core/mcp-tool-response-file.js +3 -2
  19. package/dist/core/output-contract.js +172 -1
  20. package/dist/reporting/render-mcp-resource-template-catalog-report.d.ts +2 -0
  21. package/dist/reporting/render-mcp-resource-template-catalog-report.js +33 -0
  22. package/dist/reporting/render-mcp-resource-template-diff-markdown.d.ts +2 -0
  23. package/dist/reporting/render-mcp-resource-template-diff-markdown.js +39 -0
  24. package/dist/reporting/render-mcp-resource-template-diff-report.d.ts +2 -0
  25. package/dist/reporting/render-mcp-resource-template-diff-report.js +48 -0
  26. package/dist/reporting/render-mcp-resource-template-file-report.d.ts +5 -0
  27. package/dist/reporting/render-mcp-resource-template-file-report.js +31 -0
  28. package/dist/run-cli.d.ts +7 -0
  29. package/dist/run-cli.js +60 -6
  30. package/package.json +1 -1
@@ -0,0 +1,74 @@
1
+ import { parseUriTemplate } from "./mcp-resource-template-definition.js";
2
+ export const RESOURCE_TEMPLATE_CHANGE_REASONS = [
3
+ "resource-template-removed",
4
+ "resource-template-uri-changed",
5
+ "resource-template-variable-removed",
6
+ "resource-template-variable-added",
7
+ "resource-template-mime-type-changed",
8
+ "other-fields-unclassified"
9
+ ];
10
+ export const MAX_RESOURCE_TEMPLATE_CHANGE_LOCATIONS = 100;
11
+ const UNCLASSIFIED_REASONS = new Set(["resource-template-mime-type-changed", "other-fields-unclassified"]);
12
+ function classifyUriTemplate(before, after, equal, add) {
13
+ const beforeTemplate = typeof before === "string" ? parseUriTemplate(before) : null;
14
+ const afterTemplate = typeof after === "string" ? parseUriTemplate(after) : null;
15
+ // Inspected definitions always have parseable templates; anything else cannot be compared by variable.
16
+ if (beforeTemplate === null || afterTemplate === null) {
17
+ add("resource-template-uri-changed", null);
18
+ return;
19
+ }
20
+ const beforeVariables = new Set(beforeTemplate.variables);
21
+ const afterVariables = new Set(afterTemplate.variables);
22
+ let variablesChanged = false;
23
+ for (const variable of beforeVariables) {
24
+ if (!afterVariables.has(variable)) {
25
+ variablesChanged = true;
26
+ add("resource-template-variable-removed", variable);
27
+ }
28
+ }
29
+ // Clients do not supply a new variable, so the server receives URIs without it.
30
+ for (const variable of afterVariables) {
31
+ if (!beforeVariables.has(variable)) {
32
+ variablesChanged = true;
33
+ add("resource-template-variable-added", variable);
34
+ }
35
+ }
36
+ // With the same variables, any other difference, such as order, modifiers, or literals, changes the URIs clients build.
37
+ if (beforeTemplate.skeleton !== afterTemplate.skeleton || (!variablesChanged && !equal(before, after))) {
38
+ add("resource-template-uri-changed", null);
39
+ }
40
+ }
41
+ function result(reasons, locations) {
42
+ const ordered = RESOURCE_TEMPLATE_CHANGE_REASONS.filter((reason) => reasons.has(reason));
43
+ const impact = ordered.some((reason) => !UNCLASSIFIED_REASONS.has(reason))
44
+ ? "breaking"
45
+ : ordered.length > 0 ? "unclassified" : "compatible";
46
+ return locations === null ? { impact, reasons: ordered } : { impact, reasons: ordered, locations };
47
+ }
48
+ export function classifyRemovedResourceTemplate() {
49
+ return { impact: "breaking", reasons: ["resource-template-removed"] };
50
+ }
51
+ export function classifyAddedResourceTemplate() {
52
+ return { impact: "compatible", reasons: [] };
53
+ }
54
+ export function classifyChangedResourceTemplate(before, after, fields, equal, options = {}) {
55
+ const reasons = new Set();
56
+ // In comparison order: removed variables in before order, then added variables in after order, then the template itself.
57
+ const locations = options.locate ? [] : null;
58
+ const seen = new Set();
59
+ const add = (reason, variable) => {
60
+ reasons.add(reason);
61
+ const key = JSON.stringify([reason, variable]);
62
+ if (locations !== null && locations.length < MAX_RESOURCE_TEMPLATE_CHANGE_LOCATIONS && !seen.has(key)) {
63
+ seen.add(key);
64
+ locations.push({ reason, variable });
65
+ }
66
+ };
67
+ if (fields.includes("uriTemplate"))
68
+ classifyUriTemplate(before.uriTemplate, after.uriTemplate, equal, add);
69
+ if (fields.includes("mimeType"))
70
+ add("resource-template-mime-type-changed", null);
71
+ if (fields.includes("other"))
72
+ add("other-fields-unclassified", null);
73
+ return result(reasons, locations);
74
+ }
@@ -28,7 +28,7 @@ export interface McpStdioToolCatalogOptions {
28
28
  shutdownMs?: number;
29
29
  };
30
30
  }
31
- export declare function resolveMcpStdioServer(targetPath: string, serverName: string, command?: "tools" | "prompts"): Promise<McpStdioServerResolution>;
31
+ export declare function resolveMcpStdioServer(targetPath: string, serverName: string, command?: "tools" | "prompts" | "resource-templates"): Promise<McpStdioServerResolution>;
32
32
  export declare function captureStdioMcpToolCatalog(server: McpStdioServer, options?: McpStdioToolCatalogOptions): Promise<McpToolCatalogSnapshot>;
33
33
  export interface McpStdioCatalogCapture<F extends Finding> {
34
34
  capture: McpCatalogCapture<F>;
@@ -330,7 +330,7 @@ function stopFinding(reason, docker, noun) {
330
330
  function modernEra(list) {
331
331
  return {
332
332
  request: (id, cursor) => serializeListRequest(list.method, id, cursor),
333
- parse: (message, id) => parseListResult(message, id, list.capability),
333
+ parse: (message, id) => parseListResult(message, id, list.resultKey),
334
334
  invalidFix: modernInvalidFix(list)
335
335
  };
336
336
  }
@@ -344,14 +344,14 @@ function legacyEra(list) {
344
344
  }),
345
345
  parse: (message, id) => {
346
346
  if (!hasMatchingEnvelope(message, id) || !Object.hasOwn(message, "result") || !isPlainObject(message.result)
347
- || !Array.isArray(message.result[list.capability])) {
347
+ || !Array.isArray(message.result[list.resultKey])) {
348
348
  return null;
349
349
  }
350
350
  if (Object.hasOwn(message.result, "nextCursor") && typeof message.result.nextCursor !== "string")
351
351
  return null;
352
- return { items: message.result[list.capability], cursor: message.result.nextCursor, cache: LEGACY_CACHE };
352
+ return { items: message.result[list.resultKey], cursor: message.result.nextCursor, cache: LEGACY_CACHE };
353
353
  },
354
- invalidFix: `Return a matching JSON-RPC result with a ${list.capability} array and an optional string nextCursor.`
354
+ invalidFix: `Return a matching JSON-RPC result with a ${list.resultKey} array and an optional string nextCursor.`
355
355
  };
356
356
  }
357
357
  function legacyInitializeRequest() {
@@ -463,7 +463,7 @@ export async function captureStdioMcpCatalog(server, list, options = {}) {
463
463
  reason = "page-limit";
464
464
  break;
465
465
  }
466
- const id = `${list.capability}-list-${pagesRead + 1}`;
466
+ const id = `${list.slug}-list-${pagesRead + 1}`;
467
467
  const outcome = await send(id, era.request(id, cursor), requestMs);
468
468
  if (outcome.kind !== "message") {
469
469
  if (outcome.kind === "stopped")
@@ -475,7 +475,7 @@ export async function captureStdioMcpCatalog(server, list, options = {}) {
475
475
  const message = outcome.message;
476
476
  if (hasMatchingEnvelope(message, id) && Object.hasOwn(message, "error") && validJsonRpcError(message.error) && message.error.code === -32601) {
477
477
  if (addFindings([listMethodUnsupported(list, "MCP server")]))
478
- reason = `${list.capability}-list-method-unsupported`;
478
+ reason = `${list.slug}-list-method-unsupported`;
479
479
  break;
480
480
  }
481
481
  const page = era.parse(message, id);
@@ -59,14 +59,14 @@ export declare function validJsonRpcError(value: unknown): value is McpJsonObjec
59
59
  message: string;
60
60
  };
61
61
  export declare function hasMatchingEnvelope(message: McpJsonObject, requestId: string): boolean;
62
- export declare function parseListResult(message: McpJsonObject, requestId: string, resultKey: McpCatalogList<Finding>["capability"]): {
62
+ export declare function parseListResult(message: McpJsonObject, requestId: string, resultKey: McpCatalogList<Finding>["resultKey"]): {
63
63
  items: unknown[];
64
64
  cursor: string | undefined;
65
65
  cache: McpToolCatalogCache;
66
66
  } | null;
67
67
  export declare function buildMcpToolCatalogReport(discovery: McpDiscoveryReport, status: CatalogStatus, catalog: McpToolCatalogReport["catalog"], catalogEnumeration: EnumerationCoverage, findings: CatalogFinding[]): McpToolCatalogReport;
68
68
  export declare function finalCatalogStatus(findings: Finding[]): "pass" | "warn" | "fail";
69
- type CatalogListName = Pick<McpCatalogList<Finding>, "method" | "capability" | "noun">;
69
+ type CatalogListName = Pick<McpCatalogList<Finding>, "method" | "capability" | "resultKey" | "slug" | "noun">;
70
70
  type CatalogServer = "endpoint" | "MCP server";
71
71
  export declare function listMethodUnsupported(list: CatalogListName, server: CatalogServer): CatalogFinding;
72
72
  export declare function modernInvalidFix(list: CatalogListName): string;
@@ -88,11 +88,13 @@ export interface McpCatalogInspector<F extends Finding> {
88
88
  addFindings(items: Finding[]): boolean;
89
89
  inspectPage(items: unknown[], page: number, timeExpired: () => boolean): string | null;
90
90
  }
91
- /** What differs between enumerating tool and prompt definitions; discovery, limits, paging, and findings are shared. */
91
+ /** What differs between enumerating tool, prompt, and resource template definitions; discovery, limits, paging, and findings are shared. */
92
92
  export interface McpCatalogList<F extends Finding> {
93
- method: "tools/list" | "prompts/list";
94
- capability: "tools" | "prompts";
95
- noun: "tool" | "prompt";
93
+ method: "tools/list" | "prompts/list" | "resources/templates/list";
94
+ capability: "tools" | "prompts" | "resources";
95
+ resultKey: "tools" | "prompts" | "resourceTemplates";
96
+ slug: "tools" | "prompts" | "resource-templates";
97
+ noun: "tool" | "prompt" | "resource template";
96
98
  createInspector(): McpCatalogInspector<F>;
97
99
  }
98
100
  export declare const MCP_TOOLS_LIST: McpCatalogList<CatalogFinding>;
@@ -71,10 +71,10 @@ export function finalCatalogStatus(findings) {
71
71
  : "pass";
72
72
  }
73
73
  export function listMethodUnsupported(list, server) {
74
- return catalogFinding(`plugin.catalog.${list.capability}_list.method_unsupported`, "warn", `The ${server} advertised ${list.capability} but does not implement ${list.method}.`, `${list.noun === "tool" ? "Tool" : "Prompt"} definitions cannot be enumerated from the advertised capability.`, `Implement ${list.method} for the advertised ${server === "endpoint" ? "modern " : ""}MCP ${list.capability} capability.`);
74
+ return catalogFinding(`plugin.catalog.${list.slug.replaceAll("-", "_")}_list.method_unsupported`, "warn", `The ${server} advertised ${list.capability} but does not implement ${list.method}.`, `${list.noun[0].toUpperCase()}${list.noun.slice(1)} definitions cannot be enumerated from the advertised capability.`, `Implement ${list.method} for the advertised ${server === "endpoint" ? "modern " : ""}MCP ${list.capability} capability.`);
75
75
  }
76
76
  export function modernInvalidFix(list) {
77
- return `Return a matching JSON-RPC complete result with valid ${list.capability} and cache metadata.`;
77
+ return `Return a matching JSON-RPC complete result with valid ${list.resultKey} and cache metadata.`;
78
78
  }
79
79
  export function listResponseInvalid(list, server, suggestedFix) {
80
80
  return catalogFinding("plugin.catalog.response.invalid", "fail", `The ${server} returned an invalid ${list.method} response.`, `The ${list.noun} catalog cannot be safely enumerated from this response.`, suggestedFix);
@@ -139,6 +139,8 @@ export function createMcpToolCatalogInspector() {
139
139
  export const MCP_TOOLS_LIST = {
140
140
  method: "tools/list",
141
141
  capability: "tools",
142
+ resultKey: "tools",
143
+ slug: "tools",
142
144
  noun: "tool",
143
145
  createInspector() {
144
146
  const inspector = createMcpToolCatalogInspector();
@@ -274,7 +276,7 @@ export async function captureMcpCatalog(rawUrl, list, options = {}) {
274
276
  incompleteReason = "total-time-limit";
275
277
  break;
276
278
  }
277
- const requestId = `${list.capability}-list-${pagesRead + 1}`;
279
+ const requestId = `${list.slug}-list-${pagesRead + 1}`;
278
280
  let response;
279
281
  try {
280
282
  response = await requestWithinBudget(rawUrl, {
@@ -315,7 +317,7 @@ export async function captureMcpCatalog(rawUrl, list, options = {}) {
315
317
  if (message !== null && hasMatchingEnvelope(message, requestId) && Object.hasOwn(message, "error")
316
318
  && validJsonRpcError(message.error) && message.error.code === -32601 && (response.statusCode === 200 || response.statusCode === 404)) {
317
319
  if (addFindings([listMethodUnsupported(list, "endpoint")]))
318
- incompleteReason = `${list.capability}-list-method-unsupported`;
320
+ incompleteReason = `${list.slug}-list-method-unsupported`;
319
321
  break;
320
322
  }
321
323
  if (response.statusCode !== 200) {
@@ -323,7 +325,7 @@ export async function captureMcpCatalog(rawUrl, list, options = {}) {
323
325
  incompleteReason = "http-status-invalid";
324
326
  break;
325
327
  }
326
- const page = message === null ? null : parseListResult(message, requestId, list.capability);
328
+ const page = message === null ? null : parseListResult(message, requestId, list.resultKey);
327
329
  if (page === null) {
328
330
  if (addFindings([listResponseInvalid(list, "endpoint", modernInvalidFix(list))]))
329
331
  incompleteReason = "response-invalid";
@@ -6,7 +6,7 @@ export type McpListResponseSaveResult = {
6
6
  } | {
7
7
  kind: "path-blocked" | "not-regular" | "too-large" | "write-failed";
8
8
  };
9
- type ListKey = "tools" | "prompts";
9
+ type ListKey = "tools" | "prompts" | "resourceTemplates";
10
10
  export declare function serializeMcpListResponse(key: ListKey, items: unknown[], cache: McpToolCatalogCache): string;
11
11
  export declare function saveMcpListResponse(filePath: string, key: ListKey, items: unknown[], cache: McpToolCatalogCache): Promise<McpListResponseSaveResult>;
12
12
  export {};
@@ -2,11 +2,12 @@ import { randomUUID } from "node:crypto";
2
2
  import { chmod, lstat, rename, rm, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { isBlockedOfflinePath, MAX_TOOL_FILE_BYTES } from "./mcp-tool-file.js";
5
- // Serializes an enumerated catalog as one complete tools/list or prompts/list response that the offline file and diff commands accept.
5
+ const RESPONSE_IDS = { tools: "tools-list", prompts: "prompts-list", resourceTemplates: "resource-templates-list" };
6
+ // Serializes an enumerated catalog as one complete list response, keyed by its result array, that the offline file and diff commands accept.
6
7
  export function serializeMcpListResponse(key, items, cache) {
7
8
  return `${JSON.stringify({
8
9
  jsonrpc: "2.0",
9
- id: `${key}-list`,
10
+ id: RESPONSE_IDS[key],
10
11
  result: { resultType: "complete", [key]: items, ttlMs: cache.ttlMs, cacheScope: cache.cacheScope }
11
12
  }, null, 2)}\n`;
12
13
  }
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { packageVersion } from "../version.js";
3
3
  import { MAX_PROMPT_CHANGE_LOCATIONS, PROMPT_CHANGE_REASONS } from "./mcp-prompt-impact.js";
4
+ import { MAX_RESOURCE_TEMPLATE_CHANGE_LOCATIONS, RESOURCE_TEMPLATE_CHANGE_REASONS } from "./mcp-resource-template-impact.js";
4
5
  import { MAX_TOOL_CHANGE_LOCATIONS, MAX_TOOL_CHANGE_POINTER_LENGTH, TOOL_CHANGE_REASONS } from "./mcp-tool-impact.js";
5
6
  import { ruleCatalog } from "../rules/rule-catalog.js";
6
7
  const runtimeCapabilityStatusSchema = {
@@ -853,7 +854,7 @@ publicSchemaDefinitions.push({
853
854
  }
854
855
  }
855
856
  });
856
- // The prompt catalog shares discovery, protocol, and execution evidence with the tool catalog.
857
+ // The prompt and resource template catalogs share discovery, protocol, and execution evidence with the tool catalog.
857
858
  const toolCatalogProperties = publicSchemaDefinitions.find((definition) => definition.id === "doctor.tools.json").properties;
858
859
  publicSchemaDefinitions.push({
859
860
  id: "doctor.prompts.json",
@@ -914,6 +915,63 @@ publicSchemaDefinitions.push({
914
915
  }
915
916
  }
916
917
  });
918
+ publicSchemaDefinitions.push({
919
+ id: "doctor.resource-templates.json",
920
+ command: "codex-plugin-doctor doctor resource-templates <url> --allow-network --json",
921
+ required: ["schemaVersion", "scope", "status", "discovery", "catalog", "coverage", "findings"],
922
+ properties: {
923
+ schemaVersion: { const: 1 },
924
+ scope: { const: "resource-template-catalog-structure" },
925
+ // transport, protocol, and execution appear only on reports from `doctor resource-templates <path> --server <name> --runtime`.
926
+ transport: { const: "stdio" },
927
+ status: toolCatalogProperties.status,
928
+ protocol: toolCatalogProperties.protocol,
929
+ execution: toolCatalogProperties.execution,
930
+ discovery: toolCatalogProperties.discovery,
931
+ catalog: {
932
+ type: "object", required: ["complete", "pagesRead", "templatesChecked", "reason"],
933
+ properties: {
934
+ complete: { type: "boolean" },
935
+ pagesRead: { type: "integer", minimum: 0, maximum: 5 },
936
+ templatesChecked: { type: "integer", minimum: 0, maximum: 500 },
937
+ reason: { type: ["string", "null"] }
938
+ },
939
+ additionalProperties: false
940
+ },
941
+ coverage: {
942
+ type: "object", required: ["discovery", "catalogEnumeration", "definitions", "resourceRead", "fullConformance"],
943
+ properties: {
944
+ discovery: { enum: ["pass", "fail", "skipped"] },
945
+ catalogEnumeration: { enum: ["complete", "incomplete", "not-tested"] },
946
+ definitions: { const: "shape-only" },
947
+ resourceRead: { const: "not-tested" },
948
+ fullConformance: { const: "not-tested" }
949
+ },
950
+ additionalProperties: false
951
+ },
952
+ findings: {
953
+ type: "array", maxItems: 100,
954
+ items: {
955
+ type: "object", required: ["id", "severity", "message", "impact", "suggestedFix"],
956
+ properties: {
957
+ id: { type: "string", pattern: "^plugin\\.catalog\\." },
958
+ severity: { enum: ["warn", "fail"] },
959
+ message: { type: "string" }, impact: { type: "string" }, suggestedFix: { type: "string" },
960
+ location: {
961
+ type: "object", required: ["page", "templateIndex"],
962
+ properties: {
963
+ page: { type: "integer", minimum: 1, maximum: 5 },
964
+ templateIndex: { type: "integer", minimum: 1, maximum: 500 },
965
+ relatedTemplateIndex: { type: "integer", minimum: 1, maximum: 500 }
966
+ },
967
+ additionalProperties: false
968
+ }
969
+ },
970
+ additionalProperties: false
971
+ }
972
+ }
973
+ }
974
+ });
917
975
  const promptFileReportDefinition = {
918
976
  id: "doctor.prompts.file.json",
919
977
  command: "codex-plugin-doctor doctor prompts-file <path> --json",
@@ -1029,6 +1087,119 @@ publicSchemaDefinitions.push(promptFileReportDefinition, {
1029
1087
  }
1030
1088
  }
1031
1089
  });
1090
+ const resourceTemplateFileReportDefinition = {
1091
+ id: "doctor.resource-templates.file.json",
1092
+ command: "codex-plugin-doctor doctor resource-templates-file <path> --json",
1093
+ required: ["schemaVersion", "scope", "status", "source", "inspection", "coverage", "findings"],
1094
+ properties: {
1095
+ schemaVersion: { const: 1 },
1096
+ scope: { const: "resource-template-definitions-file" },
1097
+ status: { enum: ["pass", "warn", "fail", "incomplete", "blocked"] },
1098
+ source: {
1099
+ type: "object", required: ["kind", "format", "bytesRead"],
1100
+ properties: {
1101
+ kind: { const: "file" }, format: { const: "mcp-resource-templates-list-response" },
1102
+ bytesRead: { type: "integer", minimum: 0, maximum: 1048577 }
1103
+ }, additionalProperties: false
1104
+ },
1105
+ inspection: {
1106
+ type: "object", required: ["complete", "templatesChecked", "hasNextCursor", "reason"],
1107
+ properties: {
1108
+ complete: { type: "boolean" }, templatesChecked: { type: "integer", minimum: 0, maximum: 500 },
1109
+ hasNextCursor: { type: ["boolean", "null"] }, reason: { type: ["string", "null"] }
1110
+ }, additionalProperties: false
1111
+ },
1112
+ coverage: {
1113
+ type: "object", required: ["definitions", "discovery", "serverCatalog", "resourceRead", "fullConformance"],
1114
+ properties: {
1115
+ definitions: { const: "shape-only" }, discovery: { const: "not-tested" },
1116
+ serverCatalog: { const: "not-tested" }, resourceRead: { const: "not-tested" },
1117
+ fullConformance: { const: "not-tested" }
1118
+ }, additionalProperties: false
1119
+ },
1120
+ findings: {
1121
+ type: "array", maxItems: 100,
1122
+ items: {
1123
+ type: "object", required: ["id", "severity", "message", "impact", "suggestedFix"],
1124
+ properties: {
1125
+ id: { type: "string", pattern: "^plugin\\.(catalog|resource_templates_file)\\." },
1126
+ severity: { enum: ["warn", "fail"] },
1127
+ message: { type: "string" }, impact: { type: "string" }, suggestedFix: { type: "string" },
1128
+ location: {
1129
+ type: "object", required: ["page", "templateIndex"],
1130
+ properties: {
1131
+ page: { const: 1 }, templateIndex: { type: "integer", minimum: 1, maximum: 500 },
1132
+ relatedTemplateIndex: { type: "integer", minimum: 1, maximum: 500 }
1133
+ }, additionalProperties: false
1134
+ }
1135
+ }, additionalProperties: false
1136
+ }
1137
+ }
1138
+ }
1139
+ };
1140
+ const embeddedResourceTemplateFileReportSchema = {
1141
+ type: "object",
1142
+ required: resourceTemplateFileReportDefinition.required,
1143
+ properties: resourceTemplateFileReportDefinition.properties,
1144
+ additionalProperties: true
1145
+ };
1146
+ publicSchemaDefinitions.push(resourceTemplateFileReportDefinition, {
1147
+ id: "doctor.resource-templates.diff.json",
1148
+ command: "codex-plugin-doctor doctor resource-templates-diff --before <path> --after <path> --json",
1149
+ required: ["schemaVersion", "scope", "status", "before", "after", "comparison", "coverage", "changes"],
1150
+ properties: {
1151
+ schemaVersion: { const: 1 },
1152
+ scope: { const: "resource-template-definitions-diff" },
1153
+ status: { enum: ["pass", "warn", "incomplete", "blocked"] },
1154
+ before: embeddedResourceTemplateFileReportSchema,
1155
+ after: embeddedResourceTemplateFileReportSchema,
1156
+ comparison: {
1157
+ type: "object", required: ["complete", "reason", "added", "removed", "changed", "unchanged", "breaking", "unclassified"],
1158
+ properties: {
1159
+ complete: { type: "boolean" },
1160
+ reason: { enum: [null, "input-blocked", "input-incomplete", "invalid-definitions", "ambiguous-names"] },
1161
+ added: { type: ["integer", "null"], minimum: 0, maximum: 500 },
1162
+ removed: { type: ["integer", "null"], minimum: 0, maximum: 500 },
1163
+ changed: { type: ["integer", "null"], minimum: 0, maximum: 500 },
1164
+ unchanged: { type: ["integer", "null"], minimum: 0, maximum: 500 },
1165
+ breaking: { type: ["integer", "null"], minimum: 0, maximum: 1000 },
1166
+ unclassified: { type: ["integer", "null"], minimum: 0, maximum: 1000 }
1167
+ }, additionalProperties: false
1168
+ },
1169
+ coverage: {
1170
+ type: "object", required: ["comparison", "definitions", "compatibility", "serverCatalog", "resourceRead", "impactClassification"],
1171
+ properties: {
1172
+ comparison: { const: "structural-only" }, definitions: { const: "shape-only" },
1173
+ compatibility: { const: "not-tested" }, serverCatalog: { const: "not-tested" },
1174
+ resourceRead: { const: "not-tested" }, impactClassification: { const: "heuristic" }
1175
+ }, additionalProperties: false
1176
+ },
1177
+ changes: {
1178
+ type: "array", maxItems: 1000,
1179
+ items: {
1180
+ type: "object", required: ["kind", "beforeTemplateIndex", "afterTemplateIndex", "fields", "impact", "reasons"],
1181
+ properties: {
1182
+ kind: { enum: ["added", "removed", "changed"] },
1183
+ beforeTemplateIndex: { type: ["integer", "null"], minimum: 1, maximum: 500 },
1184
+ afterTemplateIndex: { type: ["integer", "null"], minimum: 1, maximum: 500 },
1185
+ fields: { type: "array", maxItems: 5, uniqueItems: true, items: { enum: ["uriTemplate", "mimeType", "description", "title", "other"] } },
1186
+ impact: { enum: ["breaking", "compatible", "unclassified"] },
1187
+ reasons: { type: "array", maxItems: RESOURCE_TEMPLATE_CHANGE_REASONS.length, uniqueItems: true, items: { enum: [...RESOURCE_TEMPLATE_CHANGE_REASONS] } },
1188
+ // Only with --show-names; URI templates are never reported.
1189
+ name: { type: "string" },
1190
+ locations: {
1191
+ type: "array", maxItems: MAX_RESOURCE_TEMPLATE_CHANGE_LOCATIONS,
1192
+ items: {
1193
+ type: "object", required: ["reason", "variable"],
1194
+ properties: { reason: { enum: [...RESOURCE_TEMPLATE_CHANGE_REASONS] }, variable: { type: ["string", "null"] } },
1195
+ additionalProperties: false
1196
+ }
1197
+ }
1198
+ }, additionalProperties: false
1199
+ }
1200
+ }
1201
+ }
1202
+ });
1032
1203
  function sha256(value) {
1033
1204
  return `sha256:${createHash("sha256").update(value).digest("hex")}`;
1034
1205
  }
@@ -0,0 +1,2 @@
1
+ import type { McpResourceTemplateCatalogReport } from "../core/mcp-resource-template-catalog.js";
2
+ export declare function renderMcpResourceTemplateCatalogReport(report: McpResourceTemplateCatalogReport): string;
@@ -0,0 +1,33 @@
1
+ export function renderMcpResourceTemplateCatalogReport(report) {
2
+ return [
3
+ "MCP Resource Template Catalog",
4
+ "=============================",
5
+ `Status: ${report.status}`,
6
+ `Scope: ${report.scope}`,
7
+ ...(report.transport === "stdio"
8
+ ? [
9
+ "Transport: stdio",
10
+ `Protocol: ${report.protocol?.era ?? "not established"}${report.protocol?.version ? ` (${report.protocol.version})` : ""}`,
11
+ `Execution: ${report.execution?.backend ?? "not started"}`
12
+ ]
13
+ : []),
14
+ `Discovery: ${report.discovery.status}`,
15
+ `Catalog complete: ${report.catalog.complete}`,
16
+ `Pages read: ${report.catalog.pagesRead}`,
17
+ `Resource templates checked: ${report.catalog.templatesChecked}`,
18
+ ...(report.catalog.reason === null ? [] : [`Reason: ${report.catalog.reason}`]),
19
+ `Enumeration: ${report.coverage.catalogEnumeration}`,
20
+ `Definition coverage: ${report.coverage.definitions}`,
21
+ `Resource read: ${report.coverage.resourceRead}`,
22
+ `Full conformance: ${report.coverage.fullConformance}`,
23
+ "Counts describe inspected content, not a guaranteed server-wide inventory.",
24
+ ...report.discovery.findings.map((finding) => `${finding.severity.toUpperCase()} ${finding.id}: ${finding.message}\n ${finding.suggestedFix}`),
25
+ ...report.findings.map((finding) => {
26
+ const location = finding.location;
27
+ const locator = location
28
+ ? ` [page ${location.page}, template ${location.templateIndex}${location.relatedTemplateIndex === undefined ? "" : `, related template ${location.relatedTemplateIndex}`}]`
29
+ : "";
30
+ return `${finding.severity.toUpperCase()} ${finding.id}${locator}: ${finding.message}\n ${finding.suggestedFix}`;
31
+ })
32
+ ].join("\n");
33
+ }
@@ -0,0 +1,2 @@
1
+ import type { McpResourceTemplateDiffReport } from "../core/mcp-resource-template-diff.js";
2
+ export declare function renderMcpResourceTemplateDiffMarkdown(report: McpResourceTemplateDiffReport): string;
@@ -0,0 +1,39 @@
1
+ import { markdownCodeSpan } from "./quote-server-text.js";
2
+ import { describeResourceTemplateLocation } from "./render-mcp-resource-template-file-report.js";
3
+ function count(value) {
4
+ return value === null || value === undefined ? "not compared" : String(value);
5
+ }
6
+ function templateIndex(value) {
7
+ return value === null ? "-" : String(value);
8
+ }
9
+ export function renderMcpResourceTemplateDiffMarkdown(report) {
10
+ const comparison = report.comparison;
11
+ const named = report.changes.some((change) => change.name !== undefined);
12
+ const lines = [
13
+ "## MCP Resource Template Definition Diff",
14
+ "",
15
+ `**Status:** \`${report.status}\` · **Comparison complete:** ${comparison.complete ? "yes" : "no"}${comparison.reason === null ? "" : ` · **Reason:** \`${comparison.reason}\``}`,
16
+ "",
17
+ "| Added | Removed | Changed | Unchanged | Breaking | Unclassified |",
18
+ "| --- | --- | --- | --- | --- | --- |",
19
+ `| ${count(comparison.added)} | ${count(comparison.removed)} | ${count(comparison.changed)} | ${count(comparison.unchanged)} | ${count(comparison.breaking)} | ${count(comparison.unclassified)} |`
20
+ ];
21
+ if (report.changes.length > 0) {
22
+ lines.push("", "### Changes", "", named ? "| Change | Template | Before template | After template | Fields | Impact | Reasons |" : "| Change | Before template | After template | Fields | Impact | Reasons |", named ? "| --- | --- | --- | --- | --- | --- | --- |" : "| --- | --- | --- | --- | --- | --- |", ...report.changes.map((change) => `| ${change.kind}${named ? ` | ${change.name === undefined ? "-" : markdownCodeSpan(change.name)}` : ""} | ${templateIndex(change.beforeTemplateIndex)} | ${templateIndex(change.afterTemplateIndex)} | ${change.fields.map((field) => `\`${field}\``).join(", ") || "-"} | **${change.impact}** | ${change.reasons.map((reason) => `\`${reason}\``).join(", ") || "-"} |`));
23
+ }
24
+ const located = report.changes.flatMap((change) => (change.locations ?? []).map((location) => `| ${change.name === undefined ? "-" : markdownCodeSpan(change.name)} | \`${location.reason}\` | ${location.variable === null ? "-" : markdownCodeSpan(location.variable)} |`));
25
+ if (located.length > 0) {
26
+ lines.push("", "### Locations", "", "| Template | Reason | Variable |", "| --- | --- | --- |", ...located);
27
+ }
28
+ const findings = ["before", "after"].flatMap((side) => report[side].findings.map((finding) => {
29
+ const locator = finding.location ? `${side} ${describeResourceTemplateLocation(finding.location)}` : side;
30
+ return `| ${finding.severity} | \`${finding.id}\` | ${locator} | ${finding.message} |`;
31
+ }));
32
+ if (findings.length > 0) {
33
+ lines.push("", "### Input Findings", "", "| Severity | Finding | Location | Message |", "| --- | --- | --- | --- |", ...findings);
34
+ }
35
+ lines.push("", named
36
+ ? `Template and variable names are shown because --show-names was used; URI templates, descriptions, and other values are not reported. Impact labels are ${report.coverage.impactClassification} URI template checks, not proof of compatibility.`
37
+ : `Template numbers are one-based positions in the before and after files; names, URI templates, and descriptions are not reported. Impact labels are ${report.coverage.impactClassification} URI template checks, not proof of compatibility.`);
38
+ return lines.join("\n");
39
+ }
@@ -0,0 +1,2 @@
1
+ import type { McpResourceTemplateDiffReport } from "../core/mcp-resource-template-diff.js";
2
+ export declare function renderMcpResourceTemplateDiffReport(report: McpResourceTemplateDiffReport): string;
@@ -0,0 +1,48 @@
1
+ import { describeResourceTemplateLocation } from "./render-mcp-resource-template-file-report.js";
2
+ import { quoteServerText } from "./quote-server-text.js";
3
+ export function renderMcpResourceTemplateDiffReport(report) {
4
+ const comparison = report.comparison;
5
+ return [
6
+ "Offline MCP Resource Template Diff",
7
+ "==================================",
8
+ `Status: ${report.status}`,
9
+ `Scope: ${report.scope}`,
10
+ `Comparison complete: ${comparison.complete}`,
11
+ ...(comparison.reason === null ? [] : [`Reason: ${comparison.reason}`]),
12
+ `Added: ${comparison.added ?? "not compared"}`,
13
+ `Removed: ${comparison.removed ?? "not compared"}`,
14
+ `Changed: ${comparison.changed ?? "not compared"}`,
15
+ `Unchanged: ${comparison.unchanged ?? "not compared"}`,
16
+ `Breaking: ${comparison.breaking ?? "not compared"}`,
17
+ `Unclassified: ${comparison.unclassified ?? "not compared"}`,
18
+ `Comparison coverage: ${report.coverage.comparison}`,
19
+ `Definition coverage: ${report.coverage.definitions}`,
20
+ `Compatibility: ${report.coverage.compatibility}`,
21
+ `Server catalog: ${report.coverage.serverCatalog}`,
22
+ `Resource read: ${report.coverage.resourceRead}`,
23
+ `Impact classification: ${report.coverage.impactClassification}`,
24
+ "Only the supplied responses are compared. Impact labels are URI template heuristics: a compatible label or an unchanged result does not establish compatibility.",
25
+ ...["before", "after"].flatMap((side) => {
26
+ const source = report[side];
27
+ const label = side === "before" ? "Before" : "After";
28
+ return [
29
+ `${label} input: ${source.status}; templates checked: ${source.inspection.templatesChecked}; bytes read: ${source.source.bytesRead}`,
30
+ ...(source.inspection.reason === null ? [] : [`${label} reason: ${source.inspection.reason}`]),
31
+ ...source.findings.map((finding) => {
32
+ const locator = finding.location ? ` [${side} ${describeResourceTemplateLocation(finding.location)}]` : "";
33
+ return `${label} ${finding.severity.toUpperCase()} ${finding.id}${locator}: ${finding.message}\n ${finding.suggestedFix}`;
34
+ })
35
+ ];
36
+ }),
37
+ ...report.changes.map((change) => {
38
+ const locations = [
39
+ ...(change.beforeTemplateIndex === null ? [] : [`before template ${change.beforeTemplateIndex}`]),
40
+ ...(change.afterTemplateIndex === null ? [] : [`after template ${change.afterTemplateIndex}`])
41
+ ];
42
+ const impact = `${change.impact}${change.reasons.length ? ` (${change.reasons.join(", ")})` : ""}`;
43
+ const name = change.name === undefined ? "" : ` ${quoteServerText(change.name)}`;
44
+ const at = (change.locations ?? []).map((location) => `\n At ${location.variable === null ? "(template)" : `variable ${quoteServerText(location.variable)}`}: ${location.reason}`).join("");
45
+ return `${change.kind.toUpperCase()}${name} [${locations.join(" -> ")}]${change.fields.length ? `: ${change.fields.join(", ")}` : ""}\n Impact: ${impact}${at}`;
46
+ })
47
+ ].join("\n");
48
+ }
@@ -0,0 +1,5 @@
1
+ import type { ResourceTemplateCatalogLocation } from "../core/mcp-resource-template-definition.js";
2
+ import type { McpResourceTemplateFileReport } from "../core/mcp-resource-template-file.js";
3
+ /** Describes a finding location by one-based positions only, such as "template 2, related template 1". */
4
+ export declare function describeResourceTemplateLocation(location: ResourceTemplateCatalogLocation): string;
5
+ export declare function renderMcpResourceTemplateFileReport(report: McpResourceTemplateFileReport): string;
@@ -0,0 +1,31 @@
1
+ /** Describes a finding location by one-based positions only, such as "template 2, related template 1". */
2
+ export function describeResourceTemplateLocation(location) {
3
+ return [
4
+ `template ${location.templateIndex}`,
5
+ ...(location.relatedTemplateIndex === undefined ? [] : [`related template ${location.relatedTemplateIndex}`])
6
+ ].join(", ");
7
+ }
8
+ export function renderMcpResourceTemplateFileReport(report) {
9
+ return [
10
+ "Offline MCP Resource Template Definitions",
11
+ "=========================================",
12
+ `Status: ${report.status}`,
13
+ `Scope: ${report.scope}`,
14
+ `Source: ${report.source.kind} (${report.source.format})`,
15
+ `Bytes read: ${report.source.bytesRead}`,
16
+ `Inspection complete: ${report.inspection.complete}`,
17
+ `Templates checked: ${report.inspection.templatesChecked}`,
18
+ `More pages advertised: ${report.inspection.hasNextCursor ?? "unknown"}`,
19
+ ...(report.inspection.reason === null ? [] : [`Reason: ${report.inspection.reason}`]),
20
+ `Definition coverage: ${report.coverage.definitions}`,
21
+ `Discovery: ${report.coverage.discovery}`,
22
+ `Server catalog: ${report.coverage.serverCatalog}`,
23
+ `Resource read: ${report.coverage.resourceRead}`,
24
+ `Full conformance: ${report.coverage.fullConformance}`,
25
+ "Only the supplied file was inspected. Its provenance and server completeness are not established.",
26
+ ...report.findings.map((finding) => {
27
+ const locator = finding.location ? ` [${describeResourceTemplateLocation(finding.location)}]` : "";
28
+ return `${finding.severity.toUpperCase()} ${finding.id}${locator}: ${finding.message}\n ${finding.suggestedFix}`;
29
+ })
30
+ ].join("\n");
31
+ }