codex-plugin-doctor 1.77.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 (32) hide show
  1. package/README.md +18 -2
  2. package/dist/core/init-ci.d.ts +1 -0
  3. package/dist/core/init-ci.js +16 -2
  4. package/dist/core/mcp-prompt-catalog.js +2 -0
  5. package/dist/core/mcp-resource-template-catalog.d.ts +36 -0
  6. package/dist/core/mcp-resource-template-catalog.js +55 -0
  7. package/dist/core/mcp-resource-template-definition.d.ts +28 -0
  8. package/dist/core/mcp-resource-template-definition.js +116 -0
  9. package/dist/core/mcp-resource-template-diff.d.ts +43 -0
  10. package/dist/core/mcp-resource-template-diff.js +136 -0
  11. package/dist/core/mcp-resource-template-file.d.ts +31 -0
  12. package/dist/core/mcp-resource-template-file.js +118 -0
  13. package/dist/core/mcp-resource-template-impact.d.ts +21 -0
  14. package/dist/core/mcp-resource-template-impact.js +74 -0
  15. package/dist/core/mcp-stdio-tool-catalog.d.ts +1 -1
  16. package/dist/core/mcp-stdio-tool-catalog.js +6 -6
  17. package/dist/core/mcp-tool-catalog.d.ts +8 -6
  18. package/dist/core/mcp-tool-catalog.js +7 -5
  19. package/dist/core/mcp-tool-response-file.d.ts +1 -1
  20. package/dist/core/mcp-tool-response-file.js +3 -2
  21. package/dist/core/output-contract.js +172 -1
  22. package/dist/reporting/render-mcp-resource-template-catalog-report.d.ts +2 -0
  23. package/dist/reporting/render-mcp-resource-template-catalog-report.js +33 -0
  24. package/dist/reporting/render-mcp-resource-template-diff-markdown.d.ts +2 -0
  25. package/dist/reporting/render-mcp-resource-template-diff-markdown.js +39 -0
  26. package/dist/reporting/render-mcp-resource-template-diff-report.d.ts +2 -0
  27. package/dist/reporting/render-mcp-resource-template-diff-report.js +48 -0
  28. package/dist/reporting/render-mcp-resource-template-file-report.d.ts +5 -0
  29. package/dist/reporting/render-mcp-resource-template-file-report.js +31 -0
  30. package/dist/run-cli.d.ts +7 -0
  31. package/dist/run-cli.js +80 -16
  32. package/package.json +1 -1
@@ -0,0 +1,118 @@
1
+ import { createMcpResourceTemplateCatalogInspector } from "./mcp-resource-template-definition.js";
2
+ import { isBlockedOfflinePath, readBoundedRegularFile } from "./mcp-tool-file.js";
3
+ function snapshot(report, templates = null) {
4
+ return { report, templates };
5
+ }
6
+ function isJsonObject(value) {
7
+ return typeof value === "object" && value !== null && !Array.isArray(value);
8
+ }
9
+ function report(bytesRead, status, inspection, findings) {
10
+ return {
11
+ schemaVersion: 1,
12
+ scope: "resource-template-definitions-file",
13
+ status,
14
+ source: { kind: "file", format: "mcp-resource-templates-list-response", bytesRead },
15
+ inspection,
16
+ coverage: {
17
+ definitions: "shape-only",
18
+ discovery: "not-tested",
19
+ serverCatalog: "not-tested",
20
+ resourceRead: "not-tested",
21
+ fullConformance: "not-tested"
22
+ },
23
+ findings
24
+ };
25
+ }
26
+ function fileFinding(id, message, impact, suggestedFix) {
27
+ return { id, severity: "fail", message, impact, suggestedFix };
28
+ }
29
+ function incompleteFileReport(bytesRead, reason, finding) {
30
+ return report(bytesRead, "incomplete", { complete: false, templatesChecked: 0, hasNextCursor: null, reason }, [finding]);
31
+ }
32
+ function blockedFileReport(reason, finding) {
33
+ return report(0, "blocked", { complete: false, templatesChecked: 0, hasNextCursor: null, reason }, [finding]);
34
+ }
35
+ function finalStatus(findings) {
36
+ if (findings.some((finding) => finding.severity === "fail"))
37
+ return "fail";
38
+ return findings.some((finding) => finding.severity === "warn") ? "warn" : "pass";
39
+ }
40
+ function parseResourceTemplatesResult(value) {
41
+ if (!isJsonObject(value)
42
+ || value.jsonrpc !== "2.0"
43
+ || !Object.hasOwn(value, "id")
44
+ || !(typeof value.id === "string" || (typeof value.id === "number" && Number.isFinite(value.id)))
45
+ || !Object.hasOwn(value, "result")
46
+ || Object.hasOwn(value, "error")
47
+ || !isJsonObject(value.result)
48
+ || value.result.resultType !== "complete"
49
+ || !Array.isArray(value.result.resourceTemplates)
50
+ || typeof value.result.ttlMs !== "number"
51
+ || !Number.isFinite(value.result.ttlMs)
52
+ || value.result.ttlMs < 0
53
+ || (value.result.cacheScope !== "public" && value.result.cacheScope !== "private")) {
54
+ return null;
55
+ }
56
+ const hasNextCursor = Object.hasOwn(value.result, "nextCursor");
57
+ if (hasNextCursor && typeof value.result.nextCursor !== "string")
58
+ return null;
59
+ return { templates: value.result.resourceTemplates, hasNextCursor };
60
+ }
61
+ export async function loadMcpResourceTemplateFileSnapshot(filePath) {
62
+ if (isBlockedOfflinePath(filePath)) {
63
+ return snapshot(blockedFileReport("offline-path-blocked", fileFinding("plugin.resource_templates_file.path.blocked", "The local resource template definition path is not permitted for offline inspection.", "The offline resource template definition inspection does not access remote or device paths.", "Provide a local regular file path.")));
64
+ }
65
+ const file = await readBoundedRegularFile(filePath);
66
+ if (file.kind === "unavailable") {
67
+ return snapshot(incompleteFileReport(file.bytesRead, "file-unavailable", fileFinding("plugin.resource_templates_file.file.unavailable", "The local resource template definition file could not be read.", "The offline resource template definition inspection could not start.", "Provide a readable local regular file.")));
68
+ }
69
+ if (file.kind === "too-large") {
70
+ return snapshot(incompleteFileReport(file.bytesRead, "file-size-limit", fileFinding("plugin.resource_templates_file.file.too_large", "The local resource template definition file exceeds the one MiB inspection limit.", "The offline resource template definition inspection cannot safely read this file within its byte limit.", "Provide a resource template definition file no larger than one MiB.")));
71
+ }
72
+ if (file.kind === "changed") {
73
+ return snapshot(incompleteFileReport(file.bytesRead, "file-changed", fileFinding("plugin.resource_templates_file.file.changed", "The local resource template definition file changed during inspection.", "The offline resource template definition inspection cannot rely on an inconsistent file snapshot.", "Retry after the file is stable.")));
74
+ }
75
+ if (file.kind === "symlink") {
76
+ return snapshot(blockedFileReport("final-symlink", fileFinding("plugin.resource_templates_file.file.symlink", "The local resource template definition target is a symbolic link.", "The offline resource template definition inspection does not follow final symbolic links.", "Provide a regular file directly.")));
77
+ }
78
+ if (file.kind === "not-regular") {
79
+ return snapshot(blockedFileReport("non-regular-file", fileFinding("plugin.resource_templates_file.file.not_regular", "The local resource template definition target is not a regular file.", "The offline resource template definition inspection reads only bounded regular files.", "Provide a regular file directly.")));
80
+ }
81
+ let decoded;
82
+ try {
83
+ decoded = new TextDecoder("utf-8", { fatal: true }).decode(file.content);
84
+ }
85
+ catch {
86
+ return snapshot(incompleteFileReport(file.bytesRead, "invalid-utf8", fileFinding("plugin.resource_templates_file.encoding.invalid", "The local resource template definition file is not valid UTF-8.", "The offline resource template definition inspection cannot parse the file safely.", "Save the resources/templates/list response as UTF-8 JSON.")));
87
+ }
88
+ let value;
89
+ try {
90
+ value = JSON.parse(decoded);
91
+ }
92
+ catch {
93
+ return snapshot(incompleteFileReport(file.bytesRead, "invalid-json", fileFinding("plugin.resource_templates_file.json.invalid", "The local resource template definition file is not valid JSON.", "The offline resource template definition inspection cannot parse the file safely.", "Provide one UTF-8 JSON-RPC resources/templates/list response.")));
94
+ }
95
+ const parsed = parseResourceTemplatesResult(value);
96
+ if (parsed === null) {
97
+ return snapshot(incompleteFileReport(file.bytesRead, "response-invalid", fileFinding("plugin.resource_templates_file.response.invalid", "The local JSON value is not a supported complete resources/templates/list response.", "The offline resource template definition inspection cannot identify a valid resource template list.", "Provide one JSON-RPC 2.0 complete resources/templates/list result with valid cache metadata.")));
98
+ }
99
+ const inspector = createMcpResourceTemplateCatalogInspector();
100
+ const reason = inspector.inspectPage(parsed.templates, 1, () => false) ?? (parsed.hasNextCursor ? "more-pages-present" : null);
101
+ if (reason !== null) {
102
+ return snapshot(report(file.bytesRead, "incomplete", {
103
+ complete: false,
104
+ templatesChecked: inspector.templatesChecked,
105
+ hasNextCursor: parsed.hasNextCursor,
106
+ reason
107
+ }, inspector.findings), parsed.templates);
108
+ }
109
+ return snapshot(report(file.bytesRead, finalStatus(inspector.findings), {
110
+ complete: true,
111
+ templatesChecked: inspector.templatesChecked,
112
+ hasNextCursor: false,
113
+ reason: null
114
+ }, inspector.findings), parsed.templates);
115
+ }
116
+ export async function inspectMcpResourceTemplateFile(filePath) {
117
+ return (await loadMcpResourceTemplateFileSnapshot(filePath)).report;
118
+ }
@@ -0,0 +1,21 @@
1
+ type JsonObject = Record<string, unknown>;
2
+ type Equal = (left: unknown, right: unknown) => boolean;
3
+ export type ResourceTemplateChangeImpact = "breaking" | "compatible" | "unclassified";
4
+ export declare const RESOURCE_TEMPLATE_CHANGE_REASONS: readonly ["resource-template-removed", "resource-template-uri-changed", "resource-template-variable-removed", "resource-template-variable-added", "resource-template-mime-type-changed", "other-fields-unclassified"];
5
+ export type ResourceTemplateChangeReason = (typeof RESOURCE_TEMPLATE_CHANGE_REASONS)[number];
6
+ export interface ResourceTemplateChangeLocation {
7
+ reason: ResourceTemplateChangeReason;
8
+ variable: string | null;
9
+ }
10
+ export interface ResourceTemplateChangeClassification {
11
+ impact: ResourceTemplateChangeImpact;
12
+ reasons: ResourceTemplateChangeReason[];
13
+ locations?: ResourceTemplateChangeLocation[];
14
+ }
15
+ export declare const MAX_RESOURCE_TEMPLATE_CHANGE_LOCATIONS = 100;
16
+ export declare function classifyRemovedResourceTemplate(): ResourceTemplateChangeClassification;
17
+ export declare function classifyAddedResourceTemplate(): ResourceTemplateChangeClassification;
18
+ export declare function classifyChangedResourceTemplate(before: JsonObject, after: JsonObject, fields: readonly string[], equal: Equal, options?: {
19
+ locate?: boolean;
20
+ }): ResourceTemplateChangeClassification;
21
+ export {};
@@ -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;