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
package/README.md CHANGED
@@ -191,6 +191,19 @@ inputs and comments on pull requests, and
191
191
  `init-ci --tools-diff-server <name> --prompts-baseline <path>` writes that workflow.
192
192
  See [MCP Prompt Catalog and Diff](docs/guides/mcp-prompts.md).
193
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
+
194
207
  ### Remote MCP Readiness
195
208
 
196
209
  Remote MCP runtime probing is disabled until you explicitly pass `--runtime --allow-network`. Loopback endpoints also require `--allow-local-network`:
@@ -609,9 +622,9 @@ jobs:
609
622
  runs-on: ubuntu-latest
610
623
  steps:
611
624
  - uses: actions/checkout@v5
612
- - uses: Esquetta/CodexPluginDoctor@v1.78.0
625
+ - uses: Esquetta/CodexPluginDoctor@v1.79.0
613
626
  with:
614
- version: "1.78.0"
627
+ version: "1.79.0"
615
628
  path: .
616
629
  runtime: "true"
617
630
  policy: codex-publish
@@ -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>;
@@ -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 {};