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.
- package/README.md +15 -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 +60 -6
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { markdownCodeSpan } from "./quote-server-text.js";
|
|
2
|
+
import { describeResourceTemplateLocation } from "./render-mcp-resource-template-file-report.js";
|
|
3
|
+
function count(value) {
|
|
4
|
+
return value === null || value === undefined ? "not compared" : String(value);
|
|
5
|
+
}
|
|
6
|
+
function templateIndex(value) {
|
|
7
|
+
return value === null ? "-" : String(value);
|
|
8
|
+
}
|
|
9
|
+
export function renderMcpResourceTemplateDiffMarkdown(report) {
|
|
10
|
+
const comparison = report.comparison;
|
|
11
|
+
const named = report.changes.some((change) => change.name !== undefined);
|
|
12
|
+
const lines = [
|
|
13
|
+
"## MCP Resource Template Definition Diff",
|
|
14
|
+
"",
|
|
15
|
+
`**Status:** \`${report.status}\` · **Comparison complete:** ${comparison.complete ? "yes" : "no"}${comparison.reason === null ? "" : ` · **Reason:** \`${comparison.reason}\``}`,
|
|
16
|
+
"",
|
|
17
|
+
"| Added | Removed | Changed | Unchanged | Breaking | Unclassified |",
|
|
18
|
+
"| --- | --- | --- | --- | --- | --- |",
|
|
19
|
+
`| ${count(comparison.added)} | ${count(comparison.removed)} | ${count(comparison.changed)} | ${count(comparison.unchanged)} | ${count(comparison.breaking)} | ${count(comparison.unclassified)} |`
|
|
20
|
+
];
|
|
21
|
+
if (report.changes.length > 0) {
|
|
22
|
+
lines.push("", "### Changes", "", named ? "| Change | Template | Before template | After template | Fields | Impact | Reasons |" : "| Change | Before template | After template | Fields | Impact | Reasons |", named ? "| --- | --- | --- | --- | --- | --- | --- |" : "| --- | --- | --- | --- | --- | --- |", ...report.changes.map((change) => `| ${change.kind}${named ? ` | ${change.name === undefined ? "-" : markdownCodeSpan(change.name)}` : ""} | ${templateIndex(change.beforeTemplateIndex)} | ${templateIndex(change.afterTemplateIndex)} | ${change.fields.map((field) => `\`${field}\``).join(", ") || "-"} | **${change.impact}** | ${change.reasons.map((reason) => `\`${reason}\``).join(", ") || "-"} |`));
|
|
23
|
+
}
|
|
24
|
+
const located = report.changes.flatMap((change) => (change.locations ?? []).map((location) => `| ${change.name === undefined ? "-" : markdownCodeSpan(change.name)} | \`${location.reason}\` | ${location.variable === null ? "-" : markdownCodeSpan(location.variable)} |`));
|
|
25
|
+
if (located.length > 0) {
|
|
26
|
+
lines.push("", "### Locations", "", "| Template | Reason | Variable |", "| --- | --- | --- |", ...located);
|
|
27
|
+
}
|
|
28
|
+
const findings = ["before", "after"].flatMap((side) => report[side].findings.map((finding) => {
|
|
29
|
+
const locator = finding.location ? `${side} ${describeResourceTemplateLocation(finding.location)}` : side;
|
|
30
|
+
return `| ${finding.severity} | \`${finding.id}\` | ${locator} | ${finding.message} |`;
|
|
31
|
+
}));
|
|
32
|
+
if (findings.length > 0) {
|
|
33
|
+
lines.push("", "### Input Findings", "", "| Severity | Finding | Location | Message |", "| --- | --- | --- | --- |", ...findings);
|
|
34
|
+
}
|
|
35
|
+
lines.push("", named
|
|
36
|
+
? `Template and variable names are shown because --show-names was used; URI templates, descriptions, and other values are not reported. Impact labels are ${report.coverage.impactClassification} URI template checks, not proof of compatibility.`
|
|
37
|
+
: `Template numbers are one-based positions in the before and after files; names, URI templates, and descriptions are not reported. Impact labels are ${report.coverage.impactClassification} URI template checks, not proof of compatibility.`);
|
|
38
|
+
return lines.join("\n");
|
|
39
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describeResourceTemplateLocation } from "./render-mcp-resource-template-file-report.js";
|
|
2
|
+
import { quoteServerText } from "./quote-server-text.js";
|
|
3
|
+
export function renderMcpResourceTemplateDiffReport(report) {
|
|
4
|
+
const comparison = report.comparison;
|
|
5
|
+
return [
|
|
6
|
+
"Offline MCP Resource Template Diff",
|
|
7
|
+
"==================================",
|
|
8
|
+
`Status: ${report.status}`,
|
|
9
|
+
`Scope: ${report.scope}`,
|
|
10
|
+
`Comparison complete: ${comparison.complete}`,
|
|
11
|
+
...(comparison.reason === null ? [] : [`Reason: ${comparison.reason}`]),
|
|
12
|
+
`Added: ${comparison.added ?? "not compared"}`,
|
|
13
|
+
`Removed: ${comparison.removed ?? "not compared"}`,
|
|
14
|
+
`Changed: ${comparison.changed ?? "not compared"}`,
|
|
15
|
+
`Unchanged: ${comparison.unchanged ?? "not compared"}`,
|
|
16
|
+
`Breaking: ${comparison.breaking ?? "not compared"}`,
|
|
17
|
+
`Unclassified: ${comparison.unclassified ?? "not compared"}`,
|
|
18
|
+
`Comparison coverage: ${report.coverage.comparison}`,
|
|
19
|
+
`Definition coverage: ${report.coverage.definitions}`,
|
|
20
|
+
`Compatibility: ${report.coverage.compatibility}`,
|
|
21
|
+
`Server catalog: ${report.coverage.serverCatalog}`,
|
|
22
|
+
`Resource read: ${report.coverage.resourceRead}`,
|
|
23
|
+
`Impact classification: ${report.coverage.impactClassification}`,
|
|
24
|
+
"Only the supplied responses are compared. Impact labels are URI template heuristics: a compatible label or an unchanged result does not establish compatibility.",
|
|
25
|
+
...["before", "after"].flatMap((side) => {
|
|
26
|
+
const source = report[side];
|
|
27
|
+
const label = side === "before" ? "Before" : "After";
|
|
28
|
+
return [
|
|
29
|
+
`${label} input: ${source.status}; templates checked: ${source.inspection.templatesChecked}; bytes read: ${source.source.bytesRead}`,
|
|
30
|
+
...(source.inspection.reason === null ? [] : [`${label} reason: ${source.inspection.reason}`]),
|
|
31
|
+
...source.findings.map((finding) => {
|
|
32
|
+
const locator = finding.location ? ` [${side} ${describeResourceTemplateLocation(finding.location)}]` : "";
|
|
33
|
+
return `${label} ${finding.severity.toUpperCase()} ${finding.id}${locator}: ${finding.message}\n ${finding.suggestedFix}`;
|
|
34
|
+
})
|
|
35
|
+
];
|
|
36
|
+
}),
|
|
37
|
+
...report.changes.map((change) => {
|
|
38
|
+
const locations = [
|
|
39
|
+
...(change.beforeTemplateIndex === null ? [] : [`before template ${change.beforeTemplateIndex}`]),
|
|
40
|
+
...(change.afterTemplateIndex === null ? [] : [`after template ${change.afterTemplateIndex}`])
|
|
41
|
+
];
|
|
42
|
+
const impact = `${change.impact}${change.reasons.length ? ` (${change.reasons.join(", ")})` : ""}`;
|
|
43
|
+
const name = change.name === undefined ? "" : ` ${quoteServerText(change.name)}`;
|
|
44
|
+
const at = (change.locations ?? []).map((location) => `\n At ${location.variable === null ? "(template)" : `variable ${quoteServerText(location.variable)}`}: ${location.reason}`).join("");
|
|
45
|
+
return `${change.kind.toUpperCase()}${name} [${locations.join(" -> ")}]${change.fields.length ? `: ${change.fields.join(", ")}` : ""}\n Impact: ${impact}${at}`;
|
|
46
|
+
})
|
|
47
|
+
].join("\n");
|
|
48
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ResourceTemplateCatalogLocation } from "../core/mcp-resource-template-definition.js";
|
|
2
|
+
import type { McpResourceTemplateFileReport } from "../core/mcp-resource-template-file.js";
|
|
3
|
+
/** Describes a finding location by one-based positions only, such as "template 2, related template 1". */
|
|
4
|
+
export declare function describeResourceTemplateLocation(location: ResourceTemplateCatalogLocation): string;
|
|
5
|
+
export declare function renderMcpResourceTemplateFileReport(report: McpResourceTemplateFileReport): string;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Describes a finding location by one-based positions only, such as "template 2, related template 1". */
|
|
2
|
+
export function describeResourceTemplateLocation(location) {
|
|
3
|
+
return [
|
|
4
|
+
`template ${location.templateIndex}`,
|
|
5
|
+
...(location.relatedTemplateIndex === undefined ? [] : [`related template ${location.relatedTemplateIndex}`])
|
|
6
|
+
].join(", ");
|
|
7
|
+
}
|
|
8
|
+
export function renderMcpResourceTemplateFileReport(report) {
|
|
9
|
+
return [
|
|
10
|
+
"Offline MCP Resource Template Definitions",
|
|
11
|
+
"=========================================",
|
|
12
|
+
`Status: ${report.status}`,
|
|
13
|
+
`Scope: ${report.scope}`,
|
|
14
|
+
`Source: ${report.source.kind} (${report.source.format})`,
|
|
15
|
+
`Bytes read: ${report.source.bytesRead}`,
|
|
16
|
+
`Inspection complete: ${report.inspection.complete}`,
|
|
17
|
+
`Templates checked: ${report.inspection.templatesChecked}`,
|
|
18
|
+
`More pages advertised: ${report.inspection.hasNextCursor ?? "unknown"}`,
|
|
19
|
+
...(report.inspection.reason === null ? [] : [`Reason: ${report.inspection.reason}`]),
|
|
20
|
+
`Definition coverage: ${report.coverage.definitions}`,
|
|
21
|
+
`Discovery: ${report.coverage.discovery}`,
|
|
22
|
+
`Server catalog: ${report.coverage.serverCatalog}`,
|
|
23
|
+
`Resource read: ${report.coverage.resourceRead}`,
|
|
24
|
+
`Full conformance: ${report.coverage.fullConformance}`,
|
|
25
|
+
"Only the supplied file was inspected. Its provenance and server completeness are not established.",
|
|
26
|
+
...report.findings.map((finding) => {
|
|
27
|
+
const locator = finding.location ? ` [${describeResourceTemplateLocation(finding.location)}]` : "";
|
|
28
|
+
return `${finding.severity.toUpperCase()} ${finding.id}${locator}: ${finding.message}\n ${finding.suggestedFix}`;
|
|
29
|
+
})
|
|
30
|
+
].join("\n");
|
|
31
|
+
}
|