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.
- package/README.md +18 -2
- package/dist/core/init-ci.d.ts +1 -0
- package/dist/core/init-ci.js +16 -2
- package/dist/core/mcp-prompt-catalog.js +2 -0
- package/dist/core/mcp-resource-template-catalog.d.ts +36 -0
- package/dist/core/mcp-resource-template-catalog.js +55 -0
- package/dist/core/mcp-resource-template-definition.d.ts +28 -0
- package/dist/core/mcp-resource-template-definition.js +116 -0
- package/dist/core/mcp-resource-template-diff.d.ts +43 -0
- package/dist/core/mcp-resource-template-diff.js +136 -0
- package/dist/core/mcp-resource-template-file.d.ts +31 -0
- package/dist/core/mcp-resource-template-file.js +118 -0
- package/dist/core/mcp-resource-template-impact.d.ts +21 -0
- package/dist/core/mcp-resource-template-impact.js +74 -0
- package/dist/core/mcp-stdio-tool-catalog.d.ts +1 -1
- package/dist/core/mcp-stdio-tool-catalog.js +6 -6
- package/dist/core/mcp-tool-catalog.d.ts +8 -6
- package/dist/core/mcp-tool-catalog.js +7 -5
- package/dist/core/mcp-tool-response-file.d.ts +1 -1
- package/dist/core/mcp-tool-response-file.js +3 -2
- package/dist/core/output-contract.js +172 -1
- package/dist/reporting/render-mcp-resource-template-catalog-report.d.ts +2 -0
- package/dist/reporting/render-mcp-resource-template-catalog-report.js +33 -0
- package/dist/reporting/render-mcp-resource-template-diff-markdown.d.ts +2 -0
- package/dist/reporting/render-mcp-resource-template-diff-markdown.js +39 -0
- package/dist/reporting/render-mcp-resource-template-diff-report.d.ts +2 -0
- package/dist/reporting/render-mcp-resource-template-diff-report.js +48 -0
- package/dist/reporting/render-mcp-resource-template-file-report.d.ts +5 -0
- package/dist/reporting/render-mcp-resource-template-file-report.js +31 -0
- package/dist/run-cli.d.ts +7 -0
- package/dist/run-cli.js +80 -16
- 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.
|
|
625
|
+
- uses: Esquetta/CodexPluginDoctor@v1.79.0
|
|
610
626
|
with:
|
|
611
|
-
version: "1.
|
|
627
|
+
version: "1.79.0"
|
|
612
628
|
path: .
|
|
613
629
|
runtime: "true"
|
|
614
630
|
policy: codex-publish
|
package/dist/core/init-ci.d.ts
CHANGED
|
@@ -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>;
|
package/dist/core/init-ci.js
CHANGED
|
@@ -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
|
|
105
|
-
|
|
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>;
|