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
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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>["
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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
|
|
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,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
|
+
}
|