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
package/README.md CHANGED
@@ -186,8 +186,24 @@ server with `--server <name> --runtime`, without retrieving any prompt.
186
186
  `doctor prompts-file` inspects a saved list offline, and `doctor prompts-diff`
187
187
  labels a removed prompt, a removed argument, or a newly required argument as
188
188
  breaking. Like the tool diff, reports omit names unless `--show-names` is given.
189
+ The GitHub Action runs the same capture and comparison through `prompts-*`
190
+ inputs and comments on pull requests, and
191
+ `init-ci --tools-diff-server <name> --prompts-baseline <path>` writes that workflow.
189
192
  See [MCP Prompt Catalog and Diff](docs/guides/mcp-prompts.md).
190
193
 
194
+ ### MCP Resource Template Catalog and Diff
195
+
196
+ ```bash
197
+ codex-plugin-doctor doctor resource-templates https://mcp.example.com/mcp --allow-network --save-response ./mcp/resource-templates-baseline.json
198
+ codex-plugin-doctor doctor resource-templates-diff --before ./mcp/resource-templates-baseline.json --after ./resource-templates-current.json --fail-on breaking
199
+ ```
200
+
201
+ `doctor resource-templates` reads `resources/templates/list` the same way, without
202
+ reading any resource; `resource-templates-file` inspects a saved list, and
203
+ `resource-templates-diff` labels a removed template, a changed URI template, or an
204
+ added or removed template variable as breaking. Reports never include URI templates,
205
+ and names appear only with `--show-names`. See [MCP Resource Template Catalog and Diff](docs/guides/mcp-resource-templates.md).
206
+
191
207
  ### Remote MCP Readiness
192
208
 
193
209
  Remote MCP runtime probing is disabled until you explicitly pass `--runtime --allow-network`. Loopback endpoints also require `--allow-local-network`:
@@ -606,9 +622,9 @@ jobs:
606
622
  runs-on: ubuntu-latest
607
623
  steps:
608
624
  - uses: actions/checkout@v5
609
- - uses: Esquetta/CodexPluginDoctor@v1.77.0
625
+ - uses: Esquetta/CodexPluginDoctor@v1.79.0
610
626
  with:
611
- version: "1.77.0"
627
+ version: "1.79.0"
612
628
  path: .
613
629
  runtime: "true"
614
630
  policy: codex-publish
@@ -6,6 +6,7 @@ export interface ToolsDiffWorkflowOptions {
6
6
  server: string;
7
7
  baseline: string;
8
8
  sandbox: "docker" | null;
9
+ promptsBaseline: string | null;
9
10
  }
10
11
  export declare function initToolsDiffWorkflow(targetPath: string, options: ToolsDiffWorkflowOptions): Promise<InitCiResult>;
11
12
  export declare function initCiWorkflow(targetPath: string): Promise<InitCiResult>;
@@ -66,6 +66,11 @@ function buildToolsDiffWorkflow(options) {
66
66
  ...(options.sandbox === "docker" ? [" tools-capture-sandbox: docker"] : []),
67
67
  " # Catalog findings do not fail this job; the compare job re-checks the saved list.",
68
68
  " tools-capture-defer-findings: \"true\"",
69
+ ...(options.promptsBaseline === null ? [] : [
70
+ ` prompts-capture-server: ${yamlString(options.server)}`,
71
+ ...(options.sandbox === "docker" ? [" prompts-capture-sandbox: docker"] : []),
72
+ " prompts-capture-defer-findings: \"true\""
73
+ ]),
69
74
  " artifact-name: mcp-tools-capture",
70
75
  "",
71
76
  " compare:",
@@ -91,6 +96,12 @@ function buildToolsDiffWorkflow(options) {
91
96
  " tools-diff-after: ${{ runner.temp }}/mcp-tools-capture/mcp-tools-current.json",
92
97
  " tools-diff-fail-on: breaking",
93
98
  " tools-diff-comment: \"true\"",
99
+ ...(options.promptsBaseline === null ? [] : [
100
+ ` prompts-diff-before: ${yamlString(options.promptsBaseline)}`,
101
+ " prompts-diff-after: ${{ runner.temp }}/mcp-tools-capture/mcp-prompts-current.json",
102
+ " prompts-diff-fail-on: breaking",
103
+ " prompts-diff-comment: \"true\""
104
+ ]),
94
105
  " output-dir: ${{ runner.temp }}/codex-plugin-doctor-reports",
95
106
  " artifact-name: mcp-tools-diff",
96
107
  ""
@@ -101,8 +112,11 @@ export async function initToolsDiffWorkflow(targetPath, options) {
101
112
  const workflowPath = path.join(rootPath, ".github", "workflows", "codex-plugin-doctor-tools.yml");
102
113
  await mkdir(path.dirname(workflowPath), { recursive: true });
103
114
  await writeFile(workflowPath, buildToolsDiffWorkflow(options), "utf8");
104
- // doctor tools --save-response writes into an existing directory, so the baseline can be captured next.
105
- await mkdir(path.dirname(path.resolve(rootPath, options.baseline)), { recursive: true });
115
+ // doctor tools --save-response writes into an existing directory, so the baselines can be captured next.
116
+ for (const baseline of [options.baseline, options.promptsBaseline]) {
117
+ if (baseline !== null)
118
+ await mkdir(path.dirname(path.resolve(rootPath, baseline)), { recursive: true });
119
+ }
106
120
  return { rootPath, workflowPath };
107
121
  }
108
122
  export async function initCiWorkflow(targetPath) {
@@ -4,6 +4,8 @@ import { captureMcpCatalog } from "./mcp-tool-catalog.js";
4
4
  export const MCP_PROMPTS_LIST = {
5
5
  method: "prompts/list",
6
6
  capability: "prompts",
7
+ resultKey: "prompts",
8
+ slug: "prompts",
7
9
  noun: "prompt",
8
10
  createInspector() {
9
11
  const inspector = createMcpPromptCatalogInspector();
@@ -0,0 +1,36 @@
1
+ import type { RuntimeExecutionEvidence } from "../domain/types.js";
2
+ import type { McpDiscoveryReport } from "./mcp-discovery.js";
3
+ import { type ResourceTemplateCatalogFinding } from "./mcp-resource-template-definition.js";
4
+ import { type McpStdioServer, type McpStdioToolCatalogOptions } from "./mcp-stdio-tool-catalog.js";
5
+ import { type CatalogStatus, type EnumerationCoverage, type McpCatalogList, type McpToolCatalogCache, type McpToolCatalogOptions, type McpToolCatalogProtocol } from "./mcp-tool-catalog.js";
6
+ export declare const MCP_RESOURCE_TEMPLATES_LIST: McpCatalogList<ResourceTemplateCatalogFinding>;
7
+ export interface McpResourceTemplateCatalogReport {
8
+ schemaVersion: 1;
9
+ scope: "resource-template-catalog-structure";
10
+ transport?: "stdio";
11
+ status: CatalogStatus;
12
+ protocol?: McpToolCatalogProtocol;
13
+ execution?: RuntimeExecutionEvidence | null;
14
+ discovery: McpDiscoveryReport;
15
+ catalog: {
16
+ complete: boolean;
17
+ pagesRead: number;
18
+ templatesChecked: number;
19
+ reason: string | null;
20
+ };
21
+ coverage: {
22
+ discovery: "pass" | "fail" | "skipped";
23
+ catalogEnumeration: EnumerationCoverage;
24
+ definitions: "shape-only";
25
+ resourceRead: "not-tested";
26
+ fullConformance: "not-tested";
27
+ };
28
+ findings: ResourceTemplateCatalogFinding[];
29
+ }
30
+ export interface McpResourceTemplateCatalogSnapshot {
31
+ report: McpResourceTemplateCatalogReport;
32
+ templates: unknown[] | null;
33
+ cache: McpToolCatalogCache | null;
34
+ }
35
+ export declare function captureMcpResourceTemplateCatalog(rawUrl: string, options?: McpToolCatalogOptions): Promise<McpResourceTemplateCatalogSnapshot>;
36
+ export declare function captureStdioMcpResourceTemplateCatalog(server: McpStdioServer, options?: McpStdioToolCatalogOptions): Promise<McpResourceTemplateCatalogSnapshot>;
@@ -0,0 +1,55 @@
1
+ import { createMcpResourceTemplateCatalogInspector } from "./mcp-resource-template-definition.js";
2
+ import { captureStdioMcpCatalog } from "./mcp-stdio-tool-catalog.js";
3
+ import { captureMcpCatalog } from "./mcp-tool-catalog.js";
4
+ export const MCP_RESOURCE_TEMPLATES_LIST = {
5
+ method: "resources/templates/list",
6
+ capability: "resources",
7
+ resultKey: "resourceTemplates",
8
+ slug: "resource-templates",
9
+ noun: "resource template",
10
+ createInspector() {
11
+ const inspector = createMcpResourceTemplateCatalogInspector();
12
+ return {
13
+ findings: inspector.findings,
14
+ items: inspector.templates,
15
+ get checked() {
16
+ return inspector.templatesChecked;
17
+ },
18
+ addFindings: inspector.addFindings,
19
+ inspectPage: inspector.inspectPage
20
+ };
21
+ }
22
+ };
23
+ function templateSnapshot(capture, stdio) {
24
+ const { complete, pagesRead, checked, reason } = capture.catalog;
25
+ return {
26
+ report: {
27
+ schemaVersion: 1,
28
+ scope: "resource-template-catalog-structure",
29
+ ...(stdio === undefined ? {} : { transport: "stdio" }),
30
+ status: capture.status,
31
+ ...(stdio === undefined ? {} : { protocol: stdio.protocol, execution: stdio.execution }),
32
+ discovery: capture.discovery,
33
+ catalog: { complete, pagesRead, templatesChecked: checked, reason },
34
+ coverage: {
35
+ discovery: capture.discovery.coverage.discovery,
36
+ catalogEnumeration: capture.enumeration,
37
+ definitions: "shape-only",
38
+ resourceRead: "not-tested",
39
+ fullConformance: "not-tested"
40
+ },
41
+ findings: capture.findings
42
+ },
43
+ templates: capture.items,
44
+ cache: capture.cache
45
+ };
46
+ }
47
+ // Templates and cache metadata are returned only for a complete enumeration; no resource is read.
48
+ export async function captureMcpResourceTemplateCatalog(rawUrl, options = {}) {
49
+ return templateSnapshot(await captureMcpCatalog(rawUrl, MCP_RESOURCE_TEMPLATES_LIST, options));
50
+ }
51
+ // Starts one configured stdio server, reads its resource template list without reading resources, and stops it.
52
+ export async function captureStdioMcpResourceTemplateCatalog(server, options = {}) {
53
+ const { capture, protocol, execution } = await captureStdioMcpCatalog(server, MCP_RESOURCE_TEMPLATES_LIST, options);
54
+ return templateSnapshot(capture, { protocol, execution });
55
+ }
@@ -0,0 +1,28 @@
1
+ import type { Finding } from "../domain/types.js";
2
+ export interface ResourceTemplateCatalogLocation {
3
+ page: number;
4
+ templateIndex: number;
5
+ relatedTemplateIndex?: number;
6
+ }
7
+ export interface ResourceTemplateCatalogFinding extends Finding {
8
+ location?: ResourceTemplateCatalogLocation;
9
+ }
10
+ export declare const MAX_RESOURCE_TEMPLATES = 500;
11
+ export interface ParsedUriTemplate {
12
+ skeleton: string;
13
+ variables: string[];
14
+ }
15
+ /** Parses an RFC 6570 URI template; null when it is not one. */
16
+ export declare function parseUriTemplate(template: string): ParsedUriTemplate | null;
17
+ export declare function inspectMcpResourceTemplateDefinition(value: unknown, location: {
18
+ page: number;
19
+ templateIndex: number;
20
+ }): ResourceTemplateCatalogFinding[];
21
+ export interface McpResourceTemplateCatalogInspector {
22
+ readonly findings: ResourceTemplateCatalogFinding[];
23
+ readonly templates: unknown[];
24
+ readonly templatesChecked: number;
25
+ addFindings(items: ResourceTemplateCatalogFinding[]): boolean;
26
+ inspectPage(pageTemplates: unknown[], page: number, timeExpired: () => boolean): "template-limit" | "finding-limit" | "total-time-limit" | null;
27
+ }
28
+ export declare function createMcpResourceTemplateCatalogInspector(): McpResourceTemplateCatalogInspector;
@@ -0,0 +1,116 @@
1
+ export const MAX_RESOURCE_TEMPLATES = 500;
2
+ const MAX_FINDINGS = 100;
3
+ const DUPLICATE_TEMPLATE_NAME = "plugin.catalog.resource_template.name.duplicate";
4
+ function isJsonObject(value) {
5
+ return typeof value === "object" && value !== null && !Array.isArray(value);
6
+ }
7
+ // RFC 6570 expressions: an optional operator, then comma-separated variable names with an optional
8
+ // prefix (":1" to ":9999") or explode ("*") modifier. Names are letters, digits, "_", percent-encoded
9
+ // octets, and single dots between them.
10
+ const OPERATORS = new Set(["+", "#", ".", "/", ";", "?", "&"]);
11
+ const VARSPEC = /^((?:[A-Za-z0-9_]|%[0-9A-Fa-f]{2})(?:\.?(?:[A-Za-z0-9_]|%[0-9A-Fa-f]{2}))*)(?::[1-9][0-9]{0,3}|\*)?$/u;
12
+ // What RFC 6570 excludes from literals: controls, the space, a "%" that does not start a percent-encoded
13
+ // octet, lone surrogates, and the listed ASCII characters. Other Unicode, including non-ASCII spaces, is allowed.
14
+ const INVALID_LITERAL = /[ \u0000-\u001F\u007F-\u009F"'<>\\^`|\uD800-\uDFFF]|%(?![0-9A-Fa-f]{2})/u;
15
+ /** Parses an RFC 6570 URI template; null when it is not one. */
16
+ export function parseUriTemplate(template) {
17
+ let skeleton = "";
18
+ const variables = [];
19
+ let index = 0;
20
+ while (index < template.length) {
21
+ const open = template.indexOf("{", index);
22
+ const literal = template.slice(index, open === -1 ? template.length : open);
23
+ if (literal.includes("}") || INVALID_LITERAL.test(literal))
24
+ return null;
25
+ skeleton += literal;
26
+ if (open === -1)
27
+ break;
28
+ const close = template.indexOf("}", open + 1);
29
+ if (close === -1)
30
+ return null;
31
+ let body = template.slice(open + 1, close);
32
+ if (body.includes("{"))
33
+ return null;
34
+ const operator = OPERATORS.has(body[0] ?? "") ? body[0] : "";
35
+ body = body.slice(operator.length);
36
+ if (body === "")
37
+ return null;
38
+ for (const varspec of body.split(",")) {
39
+ const match = VARSPEC.exec(varspec);
40
+ if (match === null)
41
+ return null;
42
+ variables.push(match[1]);
43
+ }
44
+ skeleton += `{${operator}}`;
45
+ index = close + 1;
46
+ }
47
+ return { skeleton, variables };
48
+ }
49
+ function finding(id, message, impact, suggestedFix, location) {
50
+ return { id, severity: id === DUPLICATE_TEMPLATE_NAME ? "warn" : "fail", message, impact, suggestedFix, location };
51
+ }
52
+ export function inspectMcpResourceTemplateDefinition(value, location) {
53
+ const findings = [];
54
+ const baseLocation = { page: location.page, templateIndex: location.templateIndex };
55
+ if (!isJsonObject(value)) {
56
+ findings.push(finding("plugin.catalog.resource_template.invalid_object", "A catalog resource template entry must be an object.", "The validator cannot inspect a resource template that is not an object.", "Return each resources/templates/list entry as an object.", baseLocation));
57
+ return findings;
58
+ }
59
+ if (typeof value.name !== "string" || value.name === "") {
60
+ findings.push(finding("plugin.catalog.resource_template.name.invalid", "A catalog resource template name must be a non-empty string.", "Clients cannot identify a resource template without a name.", "Provide a non-empty string name for the resource template.", baseLocation));
61
+ }
62
+ if (typeof value.uriTemplate !== "string" || parseUriTemplate(value.uriTemplate) === null) {
63
+ findings.push(finding("plugin.catalog.resource_template.uri_template.invalid", "A catalog resource template uriTemplate must be an RFC 6570 URI template string.", "Clients cannot build resource URIs from a missing or malformed template.", "Provide uriTemplate as an RFC 6570 URI template, such as file:///{path}.", baseLocation));
64
+ }
65
+ for (const [field, id] of [["title", "title"], ["description", "description"], ["mimeType", "mime_type"]]) {
66
+ if (Object.hasOwn(value, field) && typeof value[field] !== "string") {
67
+ findings.push(finding(`plugin.catalog.resource_template.${id}.invalid`, `A catalog resource template ${field} must be a string when provided.`, `Clients cannot reliably use a non-string resource template ${field}.`, `Provide a string ${field} or omit the field.`, baseLocation));
68
+ }
69
+ }
70
+ return findings;
71
+ }
72
+ // Shared by saved-file inspection and the HTTP and stdio catalogs so all apply the same template, finding, and duplicate-name rules.
73
+ export function createMcpResourceTemplateCatalogInspector() {
74
+ const findings = [];
75
+ const templates = [];
76
+ const names = new Map();
77
+ let templatesChecked = 0;
78
+ const addFindings = (items) => {
79
+ if (findings.length + items.length > MAX_FINDINGS) {
80
+ findings.push(...items.slice(0, MAX_FINDINGS - findings.length));
81
+ return false;
82
+ }
83
+ findings.push(...items);
84
+ return true;
85
+ };
86
+ return {
87
+ findings,
88
+ templates,
89
+ get templatesChecked() {
90
+ return templatesChecked;
91
+ },
92
+ addFindings,
93
+ inspectPage(pageTemplates, page, timeExpired) {
94
+ for (const template of pageTemplates) {
95
+ if (templatesChecked >= MAX_RESOURCE_TEMPLATES)
96
+ return "template-limit";
97
+ templatesChecked += 1;
98
+ templates.push(template);
99
+ const location = { page, templateIndex: templatesChecked };
100
+ if (!addFindings(inspectMcpResourceTemplateDefinition(template, location)))
101
+ return "finding-limit";
102
+ if (isJsonObject(template) && typeof template.name === "string" && template.name !== "") {
103
+ const previous = names.get(template.name);
104
+ if (previous !== undefined && !addFindings([finding(DUPLICATE_TEMPLATE_NAME, "Two enumerated resource templates use the same name.", "Duplicate resource template names can make client selection ambiguous.", "Use a unique name for each resource template.", { ...location, relatedTemplateIndex: previous })])) {
105
+ return "finding-limit";
106
+ }
107
+ if (previous === undefined)
108
+ names.set(template.name, templatesChecked);
109
+ }
110
+ if (timeExpired())
111
+ return "total-time-limit";
112
+ }
113
+ return null;
114
+ }
115
+ };
116
+ }
@@ -0,0 +1,43 @@
1
+ import { type McpResourceTemplateFileReport } from "./mcp-resource-template-file.js";
2
+ import { type ResourceTemplateChangeImpact, type ResourceTemplateChangeLocation, type ResourceTemplateChangeReason } from "./mcp-resource-template-impact.js";
3
+ type DiffField = "uriTemplate" | "mimeType" | "description" | "title" | "other";
4
+ export interface McpResourceTemplateDiffReport {
5
+ schemaVersion: 1;
6
+ scope: "resource-template-definitions-diff";
7
+ status: "pass" | "warn" | "incomplete" | "blocked";
8
+ before: McpResourceTemplateFileReport;
9
+ after: McpResourceTemplateFileReport;
10
+ comparison: {
11
+ complete: boolean;
12
+ reason: "input-blocked" | "input-incomplete" | "invalid-definitions" | "ambiguous-names" | null;
13
+ added: number | null;
14
+ removed: number | null;
15
+ changed: number | null;
16
+ unchanged: number | null;
17
+ breaking: number | null;
18
+ unclassified: number | null;
19
+ };
20
+ coverage: {
21
+ comparison: "structural-only";
22
+ definitions: "shape-only";
23
+ compatibility: "not-tested";
24
+ serverCatalog: "not-tested";
25
+ resourceRead: "not-tested";
26
+ impactClassification: "heuristic";
27
+ };
28
+ changes: Array<{
29
+ kind: "added" | "removed" | "changed";
30
+ beforeTemplateIndex: number | null;
31
+ afterTemplateIndex: number | null;
32
+ fields: DiffField[];
33
+ impact: ResourceTemplateChangeImpact;
34
+ reasons: ResourceTemplateChangeReason[];
35
+ name?: string;
36
+ locations?: ResourceTemplateChangeLocation[];
37
+ }>;
38
+ }
39
+ export interface McpResourceTemplateDiffOptions {
40
+ showNames?: boolean;
41
+ }
42
+ export declare function compareMcpResourceTemplateFiles(beforePath: string, afterPath: string, options?: McpResourceTemplateDiffOptions): Promise<McpResourceTemplateDiffReport>;
43
+ export {};
@@ -0,0 +1,136 @@
1
+ import { loadMcpResourceTemplateFileSnapshot } from "./mcp-resource-template-file.js";
2
+ import { classifyAddedResourceTemplate, classifyChangedResourceTemplate, classifyRemovedResourceTemplate } from "./mcp-resource-template-impact.js";
3
+ import { structurallyEqual } from "./mcp-tool-diff.js";
4
+ const FIXED_FIELDS = ["uriTemplate", "mimeType", "description", "title"];
5
+ const EXCLUDED_OTHER_FIELDS = new Set(["name", ...FIXED_FIELDS]);
6
+ const COVERAGE = {
7
+ comparison: "structural-only",
8
+ definitions: "shape-only",
9
+ compatibility: "not-tested",
10
+ serverCatalog: "not-tested",
11
+ resourceRead: "not-tested",
12
+ impactClassification: "heuristic"
13
+ };
14
+ function isJsonObject(value) {
15
+ return typeof value === "object" && value !== null && !Array.isArray(value);
16
+ }
17
+ function filteredOther(definition) {
18
+ const other = Object.create(null);
19
+ for (const key of Object.keys(definition)) {
20
+ if (!EXCLUDED_OTHER_FIELDS.has(key))
21
+ other[key] = definition[key];
22
+ }
23
+ return other;
24
+ }
25
+ function changedFields(before, after) {
26
+ const fields = [];
27
+ for (const field of FIXED_FIELDS) {
28
+ if (!structurallyEqual(before[field], after[field]))
29
+ fields.push(field);
30
+ }
31
+ if (!structurallyEqual(filteredOther(before), filteredOther(after)))
32
+ fields.push("other");
33
+ return fields;
34
+ }
35
+ function templateDefinitions(templates) {
36
+ const definitions = new Map();
37
+ for (let offset = 0; offset < templates.length; offset += 1) {
38
+ const value = templates[offset];
39
+ if (!isJsonObject(value) || typeof value.name !== "string" || definitions.has(value.name))
40
+ return null;
41
+ definitions.set(value.name, { index: offset + 1, value });
42
+ }
43
+ return definitions;
44
+ }
45
+ function incompleteReport(before, after, status, reason) {
46
+ return {
47
+ schemaVersion: 1,
48
+ scope: "resource-template-definitions-diff",
49
+ status,
50
+ before,
51
+ after,
52
+ comparison: { complete: false, reason, added: null, removed: null, changed: null, unchanged: null, breaking: null, unclassified: null },
53
+ coverage: { ...COVERAGE },
54
+ changes: []
55
+ };
56
+ }
57
+ export async function compareMcpResourceTemplateFiles(beforePath, afterPath, options = {}) {
58
+ const [beforeSnapshot, afterSnapshot] = await Promise.all([
59
+ loadMcpResourceTemplateFileSnapshot(beforePath),
60
+ loadMcpResourceTemplateFileSnapshot(afterPath)
61
+ ]);
62
+ const { report: before } = beforeSnapshot;
63
+ const { report: after } = afterSnapshot;
64
+ if (before.status === "blocked" || after.status === "blocked") {
65
+ return incompleteReport(before, after, "blocked", "input-blocked");
66
+ }
67
+ if (!before.inspection.complete || !after.inspection.complete) {
68
+ return incompleteReport(before, after, "incomplete", "input-incomplete");
69
+ }
70
+ if (before.status === "fail" || after.status === "fail" || beforeSnapshot.templates === null || afterSnapshot.templates === null) {
71
+ return incompleteReport(before, after, "incomplete", "invalid-definitions");
72
+ }
73
+ const beforeDefinitions = templateDefinitions(beforeSnapshot.templates);
74
+ const afterDefinitions = templateDefinitions(afterSnapshot.templates);
75
+ if (beforeDefinitions === null || afterDefinitions === null) {
76
+ return incompleteReport(before, after, "incomplete", "ambiguous-names");
77
+ }
78
+ const named = (name) => (options.showNames === true ? { name } : {});
79
+ const changes = [];
80
+ let added = 0;
81
+ let removed = 0;
82
+ let changed = 0;
83
+ let unchanged = 0;
84
+ for (const [name, beforeDefinition] of beforeDefinitions) {
85
+ const afterDefinition = afterDefinitions.get(name);
86
+ if (afterDefinition === undefined) {
87
+ removed += 1;
88
+ changes.push({
89
+ kind: "removed", ...named(name), beforeTemplateIndex: beforeDefinition.index, afterTemplateIndex: null, fields: [], ...classifyRemovedResourceTemplate()
90
+ });
91
+ continue;
92
+ }
93
+ const fields = changedFields(beforeDefinition.value, afterDefinition.value);
94
+ if (fields.length === 0) {
95
+ unchanged += 1;
96
+ }
97
+ else {
98
+ changed += 1;
99
+ changes.push({
100
+ kind: "changed",
101
+ ...named(name),
102
+ beforeTemplateIndex: beforeDefinition.index,
103
+ afterTemplateIndex: afterDefinition.index,
104
+ fields,
105
+ ...classifyChangedResourceTemplate(beforeDefinition.value, afterDefinition.value, fields, structurallyEqual, { locate: options.showNames === true })
106
+ });
107
+ }
108
+ }
109
+ for (const [name, afterDefinition] of afterDefinitions) {
110
+ if (!beforeDefinitions.has(name)) {
111
+ added += 1;
112
+ changes.push({
113
+ kind: "added", ...named(name), beforeTemplateIndex: null, afterTemplateIndex: afterDefinition.index, fields: [], ...classifyAddedResourceTemplate()
114
+ });
115
+ }
116
+ }
117
+ return {
118
+ schemaVersion: 1,
119
+ scope: "resource-template-definitions-diff",
120
+ status: changes.length === 0 && before.status === "pass" && after.status === "pass" ? "pass" : "warn",
121
+ before,
122
+ after,
123
+ comparison: {
124
+ complete: true,
125
+ reason: null,
126
+ added,
127
+ removed,
128
+ changed,
129
+ unchanged,
130
+ breaking: changes.filter((change) => change.impact === "breaking").length,
131
+ unclassified: changes.filter((change) => change.impact === "unclassified").length
132
+ },
133
+ coverage: { ...COVERAGE },
134
+ changes
135
+ };
136
+ }
@@ -0,0 +1,31 @@
1
+ import { type ResourceTemplateCatalogFinding } from "./mcp-resource-template-definition.js";
2
+ export interface McpResourceTemplateFileReport {
3
+ schemaVersion: 1;
4
+ scope: "resource-template-definitions-file";
5
+ status: "pass" | "warn" | "fail" | "incomplete" | "blocked";
6
+ source: {
7
+ kind: "file";
8
+ format: "mcp-resource-templates-list-response";
9
+ bytesRead: number;
10
+ };
11
+ inspection: {
12
+ complete: boolean;
13
+ templatesChecked: number;
14
+ hasNextCursor: boolean | null;
15
+ reason: string | null;
16
+ };
17
+ coverage: {
18
+ definitions: "shape-only";
19
+ discovery: "not-tested";
20
+ serverCatalog: "not-tested";
21
+ resourceRead: "not-tested";
22
+ fullConformance: "not-tested";
23
+ };
24
+ findings: ResourceTemplateCatalogFinding[];
25
+ }
26
+ export interface McpResourceTemplateFileSnapshot {
27
+ report: McpResourceTemplateFileReport;
28
+ templates: unknown[] | null;
29
+ }
30
+ export declare function loadMcpResourceTemplateFileSnapshot(filePath: string): Promise<McpResourceTemplateFileSnapshot>;
31
+ export declare function inspectMcpResourceTemplateFile(filePath: string): Promise<McpResourceTemplateFileReport>;