codex-plugin-doctor 1.76.0 → 1.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +21 -2
  2. package/dist/core/init-ci.d.ts +1 -0
  3. package/dist/core/init-ci.js +16 -2
  4. package/dist/core/mcp-prompt-catalog.d.ts +36 -0
  5. package/dist/core/mcp-prompt-catalog.js +53 -0
  6. package/dist/core/mcp-prompt-definition.d.ts +24 -0
  7. package/dist/core/mcp-prompt-definition.js +107 -0
  8. package/dist/core/mcp-prompt-diff.d.ts +43 -0
  9. package/dist/core/mcp-prompt-diff.js +132 -0
  10. package/dist/core/mcp-prompt-file.d.ts +31 -0
  11. package/dist/core/mcp-prompt-file.js +118 -0
  12. package/dist/core/mcp-prompt-impact.d.ts +21 -0
  13. package/dist/core/mcp-prompt-impact.js +93 -0
  14. package/dist/core/mcp-stdio-tool-catalog.d.ts +9 -3
  15. package/dist/core/mcp-stdio-tool-catalog.js +74 -55
  16. package/dist/core/mcp-tool-catalog.d.ts +44 -5
  17. package/dist/core/mcp-tool-catalog.js +76 -31
  18. package/dist/core/mcp-tool-diff.d.ts +1 -0
  19. package/dist/core/mcp-tool-diff.js +1 -1
  20. package/dist/core/mcp-tool-file.d.ts +20 -0
  21. package/dist/core/mcp-tool-file.js +1 -1
  22. package/dist/core/mcp-tool-response-file.d.ts +6 -4
  23. package/dist/core/mcp-tool-response-file.js +8 -8
  24. package/dist/core/output-contract.js +177 -0
  25. package/dist/reporting/quote-server-text.d.ts +1 -0
  26. package/dist/reporting/quote-server-text.js +13 -0
  27. package/dist/reporting/render-mcp-prompt-catalog-report.d.ts +2 -0
  28. package/dist/reporting/render-mcp-prompt-catalog-report.js +31 -0
  29. package/dist/reporting/render-mcp-prompt-diff-markdown.d.ts +2 -0
  30. package/dist/reporting/render-mcp-prompt-diff-markdown.js +39 -0
  31. package/dist/reporting/render-mcp-prompt-diff-report.d.ts +2 -0
  32. package/dist/reporting/render-mcp-prompt-diff-report.js +48 -0
  33. package/dist/reporting/render-mcp-prompt-file-report.d.ts +5 -0
  34. package/dist/reporting/render-mcp-prompt-file-report.js +33 -0
  35. package/dist/reporting/render-mcp-tool-diff-markdown.js +3 -16
  36. package/dist/run-cli.d.ts +7 -0
  37. package/dist/run-cli.js +223 -150
  38. package/package.json +1 -1
@@ -29,18 +29,18 @@ export function hasMatchingEnvelope(message, requestId) {
29
29
  return message.jsonrpc === "2.0" && message.id === requestId
30
30
  && Object.hasOwn(message, "result") !== Object.hasOwn(message, "error");
31
31
  }
32
- export function parseToolsResult(message, requestId) {
32
+ export function parseListResult(message, requestId, resultKey) {
33
33
  if (!hasMatchingEnvelope(message, requestId)) {
34
34
  return null;
35
35
  }
36
- if (!Object.hasOwn(message, "result") || !isPlainObject(message.result) || !validCacheMetadata(message.result) || !Array.isArray(message.result.tools)) {
36
+ if (!Object.hasOwn(message, "result") || !isPlainObject(message.result) || !validCacheMetadata(message.result) || !Array.isArray(message.result[resultKey])) {
37
37
  return null;
38
38
  }
39
39
  if (Object.hasOwn(message.result, "nextCursor") && typeof message.result.nextCursor !== "string") {
40
40
  return null;
41
41
  }
42
42
  return {
43
- tools: message.result.tools,
43
+ items: message.result[resultKey],
44
44
  cursor: message.result.nextCursor,
45
45
  cache: { ttlMs: message.result.ttlMs, cacheScope: message.result.cacheScope }
46
46
  };
@@ -70,6 +70,18 @@ export function finalCatalogStatus(findings) {
70
70
  ? "warn"
71
71
  : "pass";
72
72
  }
73
+ export function listMethodUnsupported(list, server) {
74
+ return catalogFinding(`plugin.catalog.${list.capability}_list.method_unsupported`, "warn", `The ${server} advertised ${list.capability} but does not implement ${list.method}.`, `${list.noun === "tool" ? "Tool" : "Prompt"} definitions cannot be enumerated from the advertised capability.`, `Implement ${list.method} for the advertised ${server === "endpoint" ? "modern " : ""}MCP ${list.capability} capability.`);
75
+ }
76
+ export function modernInvalidFix(list) {
77
+ return `Return a matching JSON-RPC complete result with valid ${list.capability} and cache metadata.`;
78
+ }
79
+ export function listResponseInvalid(list, server, suggestedFix) {
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);
81
+ }
82
+ export function listCursorLoop(list, server) {
83
+ return catalogFinding("plugin.catalog.cursor.loop", "warn", `The ${server} repeated a ${list.method} cursor.`, "The bounded catalog enumeration cannot determine a complete result set.", "Return each continuation cursor at most once for a single enumeration.");
84
+ }
73
85
  function isAuthorizationFinding(discovery) {
74
86
  return discovery.findings.some((item) => item.id === "plugin.discovery.authorization.required" || item.id === "plugin.discovery.authorization.rejected");
75
87
  }
@@ -124,11 +136,36 @@ export function createMcpToolCatalogInspector() {
124
136
  }
125
137
  };
126
138
  }
127
- export function serializeToolsListRequest(id, cursor) {
139
+ export const MCP_TOOLS_LIST = {
140
+ method: "tools/list",
141
+ capability: "tools",
142
+ noun: "tool",
143
+ createInspector() {
144
+ const inspector = createMcpToolCatalogInspector();
145
+ return {
146
+ findings: inspector.findings,
147
+ items: inspector.tools,
148
+ get checked() {
149
+ return inspector.toolsChecked;
150
+ },
151
+ addFindings: inspector.addFindings,
152
+ inspectPage: inspector.inspectPage
153
+ };
154
+ }
155
+ };
156
+ function toolCatalogSnapshot(capture) {
157
+ const { complete, pagesRead, checked, reason } = capture.catalog;
158
+ return {
159
+ report: buildMcpToolCatalogReport(capture.discovery, capture.status, { complete, pagesRead, toolsChecked: checked, reason }, capture.enumeration, capture.findings),
160
+ tools: capture.items,
161
+ cache: capture.cache
162
+ };
163
+ }
164
+ export function serializeListRequest(method, id, cursor) {
128
165
  return JSON.stringify({
129
166
  jsonrpc: "2.0",
130
167
  id,
131
- method: "tools/list",
168
+ method,
132
169
  params: {
133
170
  ...(cursor === undefined ? {} : { cursor }),
134
171
  _meta: {
@@ -144,6 +181,10 @@ export async function inspectMcpToolCatalog(rawUrl, options = {}) {
144
181
  }
145
182
  // Tools and cache metadata are returned only for a complete enumeration.
146
183
  export async function captureMcpToolCatalog(rawUrl, options = {}) {
184
+ return toolCatalogSnapshot(await captureMcpCatalog(rawUrl, MCP_TOOLS_LIST, options));
185
+ }
186
+ // Definitions and cache metadata are returned only for a complete enumeration.
187
+ export async function captureMcpCatalog(rawUrl, list, options = {}) {
147
188
  const request = options.request ?? requestBoundedHttp;
148
189
  const now = options.now ?? (() => performance.now());
149
190
  const deadline = now() + TOTAL_TIMEOUT_MS;
@@ -186,31 +227,31 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
186
227
  return response;
187
228
  }
188
229
  });
189
- const partial = (report) => ({ report, tools: null, cache: null });
190
- const emptyCatalog = { complete: false, pagesRead: 0, toolsChecked: 0, reason: null };
230
+ const partial = (status, catalog, enumeration, findings) => ({ discovery, status, catalog, enumeration, findings, items: null, cache: null });
231
+ const emptyCatalog = { complete: false, pagesRead: 0, checked: 0, reason: null };
191
232
  if (discovery.status === "blocked") {
192
233
  const reason = discovery.findings.some((item) => item.id === "plugin.discovery.credentials.insecure_transport")
193
234
  ? "credentials-insecure-transport"
194
235
  : "network-blocked";
195
- return partial(buildMcpToolCatalogReport(discovery, "blocked", { ...emptyCatalog, reason }, "not-tested", []));
236
+ return partial("blocked", { ...emptyCatalog, reason }, "not-tested", []);
196
237
  }
197
238
  if (isAuthorizationFinding(discovery)) {
198
- return partial(buildMcpToolCatalogReport(discovery, "incomplete", { ...emptyCatalog, reason: "authorization-required" }, "not-tested", []));
239
+ return partial("incomplete", { ...emptyCatalog, reason: "authorization-required" }, "not-tested", []);
199
240
  }
200
241
  if (discovery.status === "unsupported") {
201
- return partial(buildMcpToolCatalogReport(discovery, "unsupported", { ...emptyCatalog, reason: "discovery-unsupported" }, "not-tested", []));
242
+ return partial("unsupported", { ...emptyCatalog, reason: "discovery-unsupported" }, "not-tested", []);
202
243
  }
203
244
  if (discovery.status !== "discovered") {
204
- return partial(buildMcpToolCatalogReport(discovery, "incomplete", { ...emptyCatalog, reason: budgetReason ?? "discovery-failed" }, "not-tested", []));
245
+ return partial("incomplete", { ...emptyCatalog, reason: budgetReason ?? "discovery-failed" }, "not-tested", []);
205
246
  }
206
247
  const capabilities = discoveryResponse === null ? null : discoverMcpCapabilities(discoveryResponse);
207
248
  if (capabilities === null) {
208
- return partial(buildMcpToolCatalogReport(discovery, "incomplete", { ...emptyCatalog, reason: "discovery-response-unavailable" }, "not-tested", []));
249
+ return partial("incomplete", { ...emptyCatalog, reason: "discovery-response-unavailable" }, "not-tested", []);
209
250
  }
210
- if (!Object.hasOwn(capabilities, "tools")) {
211
- return partial(buildMcpToolCatalogReport(discovery, "not-applicable", emptyCatalog, "not-tested", []));
251
+ if (!Object.hasOwn(capabilities, list.capability)) {
252
+ return partial("not-applicable", emptyCatalog, "not-tested", []);
212
253
  }
213
- const inspector = createMcpToolCatalogInspector();
254
+ const inspector = list.createInspector();
214
255
  const findings = inspector.findings;
215
256
  const seenCursors = new Set();
216
257
  let pagesRead = 0;
@@ -233,7 +274,7 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
233
274
  incompleteReason = "total-time-limit";
234
275
  break;
235
276
  }
236
- const requestId = `tools-list-${pagesRead + 1}`;
277
+ const requestId = `${list.capability}-list-${pagesRead + 1}`;
237
278
  let response;
238
279
  try {
239
280
  response = await requestWithinBudget(rawUrl, {
@@ -241,12 +282,12 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
241
282
  lookup: options.lookup,
242
283
  bearerToken: options.bearerToken,
243
284
  method: "POST",
244
- body: serializeToolsListRequest(requestId, cursor),
285
+ body: serializeListRequest(list.method, requestId, cursor),
245
286
  headers: {
246
287
  Accept: "application/json, text/event-stream",
247
288
  "Content-Type": "application/json",
248
289
  "MCP-Protocol-Version": REQUESTED_VERSION,
249
- "Mcp-Method": "tools/list"
290
+ "Mcp-Method": list.method
250
291
  },
251
292
  stopAfter: (received) => findMcpSseResponse(received, requestId) !== null
252
293
  });
@@ -273,18 +314,18 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
273
314
  const message = parseMcpResponse(response, requestId);
274
315
  if (message !== null && hasMatchingEnvelope(message, requestId) && Object.hasOwn(message, "error")
275
316
  && validJsonRpcError(message.error) && message.error.code === -32601 && (response.statusCode === 200 || response.statusCode === 404)) {
276
- if (addFindings([catalogFinding("plugin.catalog.tools_list.method_unsupported", "warn", "The endpoint advertised tools but does not implement tools/list.", "Tool definitions cannot be enumerated from the advertised capability.", "Implement tools/list for the advertised modern MCP tools capability.")]))
277
- incompleteReason = "tools-list-method-unsupported";
317
+ if (addFindings([listMethodUnsupported(list, "endpoint")]))
318
+ incompleteReason = `${list.capability}-list-method-unsupported`;
278
319
  break;
279
320
  }
280
321
  if (response.statusCode !== 200) {
281
- if (addFindings([catalogFinding("plugin.catalog.http_status.invalid", "fail", "The endpoint returned an unexpected tools/list HTTP status.", "The tool catalog cannot be safely enumerated from this response.", "Return HTTP 200 for a tools/list response after authorization succeeds.")]))
322
+ if (addFindings([catalogFinding("plugin.catalog.http_status.invalid", "fail", `The endpoint returned an unexpected ${list.method} HTTP status.`, `The ${list.noun} catalog cannot be safely enumerated from this response.`, `Return HTTP 200 for a ${list.method} response after authorization succeeds.`)]))
282
323
  incompleteReason = "http-status-invalid";
283
324
  break;
284
325
  }
285
- const page = message === null ? null : parseToolsResult(message, requestId);
326
+ const page = message === null ? null : parseListResult(message, requestId, list.capability);
286
327
  if (page === null) {
287
- if (addFindings([catalogFinding("plugin.catalog.response.invalid", "fail", "The endpoint returned an invalid tools/list response.", "The tool catalog cannot be safely enumerated from this response.", "Return a matching JSON-RPC complete result with valid tools and cache metadata.")]))
328
+ if (addFindings([listResponseInvalid(list, "endpoint", modernInvalidFix(list))]))
288
329
  incompleteReason = "response-invalid";
289
330
  break;
290
331
  }
@@ -293,32 +334,36 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
293
334
  ttlMs: Math.min(cache.ttlMs, page.cache.ttlMs),
294
335
  cacheScope: cache.cacheScope === "private" ? "private" : page.cache.cacheScope
295
336
  };
296
- incompleteReason = inspector.inspectPage(page.tools, pagesRead, () => deadline - now() <= 0);
337
+ incompleteReason = inspector.inspectPage(page.items, pagesRead, () => deadline - now() <= 0);
297
338
  if (incompleteReason !== null)
298
339
  break;
299
340
  if (page.cursor === undefined)
300
341
  break;
301
342
  if (seenCursors.has(page.cursor)) {
302
- if (addFindings([catalogFinding("plugin.catalog.cursor.loop", "warn", "The endpoint repeated a tools/list cursor.", "The bounded catalog enumeration cannot determine a complete result set.", "Return each continuation cursor at most once for a single enumeration.")]))
343
+ if (addFindings([listCursorLoop(list, "endpoint")]))
303
344
  incompleteReason = "cursor-loop";
304
345
  break;
305
346
  }
306
347
  seenCursors.add(page.cursor);
307
348
  cursor = page.cursor;
308
349
  }
309
- const toolsChecked = inspector.toolsChecked;
350
+ const checked = inspector.checked;
310
351
  if (blocked) {
311
- return partial(buildMcpToolCatalogReport(discovery, "blocked", { complete: false, pagesRead, toolsChecked, reason: "network-blocked" }, "incomplete", findings));
352
+ return partial("blocked", { complete: false, pagesRead, checked, reason: "network-blocked" }, "incomplete", findings);
312
353
  }
313
354
  if (incompleteReason !== null) {
314
- return partial(buildMcpToolCatalogReport(discovery, "incomplete", { complete: false, pagesRead, toolsChecked, reason: incompleteReason }, "incomplete", findings));
355
+ return partial("incomplete", { complete: false, pagesRead, checked, reason: incompleteReason }, "incomplete", findings);
315
356
  }
316
357
  if (deadline - now() <= 0) {
317
- return partial(buildMcpToolCatalogReport(discovery, "incomplete", { complete: false, pagesRead, toolsChecked, reason: "total-time-limit" }, "incomplete", findings));
358
+ return partial("incomplete", { complete: false, pagesRead, checked, reason: "total-time-limit" }, "incomplete", findings);
318
359
  }
319
360
  return {
320
- report: buildMcpToolCatalogReport(discovery, finalCatalogStatus(findings), { complete: true, pagesRead, toolsChecked, reason: null }, "complete", findings),
321
- tools: inspector.tools,
361
+ discovery,
362
+ status: finalCatalogStatus(findings),
363
+ catalog: { complete: true, pagesRead, checked, reason: null },
364
+ enumeration: "complete",
365
+ findings,
366
+ items: inspector.items,
322
367
  cache
323
368
  };
324
369
  }
@@ -39,5 +39,6 @@ export interface McpToolDiffReport {
39
39
  export interface McpToolDiffOptions {
40
40
  showNames?: boolean;
41
41
  }
42
+ export declare function structurallyEqual(left: unknown, right: unknown): boolean;
42
43
  export declare function compareMcpToolFiles(beforePath: string, afterPath: string, options?: McpToolDiffOptions): Promise<McpToolDiffReport>;
43
44
  export {};
@@ -11,7 +11,7 @@ const EXCLUDED_OTHER_FIELDS = new Set(["name", ...FIXED_FIELDS]);
11
11
  function isJsonObject(value) {
12
12
  return typeof value === "object" && value !== null && !Array.isArray(value);
13
13
  }
14
- function structurallyEqual(left, right) {
14
+ export function structurallyEqual(left, right) {
15
15
  const pending = [[left, right]];
16
16
  while (pending.length > 0) {
17
17
  const [currentLeft, currentRight] = pending.pop();
@@ -30,5 +30,25 @@ export interface McpToolFileSnapshot {
30
30
  tools: unknown[] | null;
31
31
  }
32
32
  export declare function isBlockedOfflinePath(filePath: string): boolean;
33
+ export declare function readBoundedRegularFile(filePath: string): Promise<{
34
+ kind: "content";
35
+ content: Buffer;
36
+ bytesRead: number;
37
+ } | {
38
+ kind: "unavailable";
39
+ bytesRead: number;
40
+ } | {
41
+ kind: "too-large";
42
+ bytesRead: number;
43
+ } | {
44
+ kind: "changed";
45
+ bytesRead: number;
46
+ } | {
47
+ kind: "symlink";
48
+ bytesRead: number;
49
+ } | {
50
+ kind: "not-regular";
51
+ bytesRead: number;
52
+ }>;
33
53
  export declare function loadMcpToolFileSnapshot(filePath: string): Promise<McpToolFileSnapshot>;
34
54
  export declare function inspectMcpToolFile(filePath: string): Promise<McpToolFileReport>;
@@ -90,7 +90,7 @@ function parseToolsResult(value) {
90
90
  return null;
91
91
  return { tools: value.result.tools, hasNextCursor };
92
92
  }
93
- async function readBoundedRegularFile(filePath) {
93
+ export async function readBoundedRegularFile(filePath) {
94
94
  let initial;
95
95
  try {
96
96
  initial = await lstat(filePath);
@@ -1,10 +1,12 @@
1
1
  import type { McpToolCatalogCache } from "./mcp-tool-catalog.js";
2
- export type McpToolResponseSaveResult = {
2
+ export type McpListResponseSaveResult = {
3
3
  kind: "saved";
4
4
  bytes: number;
5
- tools: number;
5
+ count: number;
6
6
  } | {
7
7
  kind: "path-blocked" | "not-regular" | "too-large" | "write-failed";
8
8
  };
9
- export declare function serializeMcpToolsListResponse(tools: unknown[], cache: McpToolCatalogCache): string;
10
- export declare function saveMcpToolsListResponse(filePath: string, tools: unknown[], cache: McpToolCatalogCache): Promise<McpToolResponseSaveResult>;
9
+ type ListKey = "tools" | "prompts";
10
+ export declare function serializeMcpListResponse(key: ListKey, items: unknown[], cache: McpToolCatalogCache): string;
11
+ export declare function saveMcpListResponse(filePath: string, key: ListKey, items: unknown[], cache: McpToolCatalogCache): Promise<McpListResponseSaveResult>;
12
+ export {};
@@ -2,21 +2,21 @@ 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
- // Serializes an enumerated catalog as one complete tools/list response that doctor tools-file and tools-diff accept.
6
- export function serializeMcpToolsListResponse(tools, cache) {
5
+ // Serializes an enumerated catalog as one complete tools/list or prompts/list response that the offline file and diff commands accept.
6
+ export function serializeMcpListResponse(key, items, cache) {
7
7
  return `${JSON.stringify({
8
8
  jsonrpc: "2.0",
9
- id: "tools-list",
10
- result: { resultType: "complete", tools, ttlMs: cache.ttlMs, cacheScope: cache.cacheScope }
9
+ id: `${key}-list`,
10
+ result: { resultType: "complete", [key]: items, ttlMs: cache.ttlMs, cacheScope: cache.cacheScope }
11
11
  }, null, 2)}\n`;
12
12
  }
13
- export async function saveMcpToolsListResponse(filePath, tools, cache) {
13
+ export async function saveMcpListResponse(filePath, key, items, cache) {
14
14
  if (isBlockedOfflinePath(filePath))
15
15
  return { kind: "path-blocked" };
16
16
  let content;
17
17
  let bytes;
18
18
  try {
19
- content = serializeMcpToolsListResponse(tools, cache);
19
+ content = serializeMcpListResponse(key, items, cache);
20
20
  bytes = Buffer.byteLength(content);
21
21
  }
22
22
  catch {
@@ -38,7 +38,7 @@ export async function saveMcpToolsListResponse(filePath, tools, cache) {
38
38
  // Write beside the target and rename so a failed write never leaves a partial response file.
39
39
  const tempPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`);
40
40
  try {
41
- // Create with the replaced baseline's permission bits, which may protect private tool definitions.
41
+ // Create with the replaced baseline's permission bits, which may protect private definitions.
42
42
  await writeFile(tempPath, content, { encoding: "utf8", flag: "wx", mode: existingMode ?? 0o666 });
43
43
  if (existingMode !== null)
44
44
  await chmod(tempPath, existingMode);
@@ -48,5 +48,5 @@ export async function saveMcpToolsListResponse(filePath, tools, cache) {
48
48
  await rm(tempPath, { force: true }).catch(() => undefined);
49
49
  return { kind: "write-failed" };
50
50
  }
51
- return { kind: "saved", bytes, tools: tools.length };
51
+ return { kind: "saved", bytes, count: items.length };
52
52
  }
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { packageVersion } from "../version.js";
3
+ import { MAX_PROMPT_CHANGE_LOCATIONS, PROMPT_CHANGE_REASONS } from "./mcp-prompt-impact.js";
3
4
  import { MAX_TOOL_CHANGE_LOCATIONS, MAX_TOOL_CHANGE_POINTER_LENGTH, TOOL_CHANGE_REASONS } from "./mcp-tool-impact.js";
4
5
  import { ruleCatalog } from "../rules/rule-catalog.js";
5
6
  const runtimeCapabilityStatusSchema = {
@@ -852,6 +853,182 @@ publicSchemaDefinitions.push({
852
853
  }
853
854
  }
854
855
  });
856
+ // The prompt catalog shares discovery, protocol, and execution evidence with the tool catalog.
857
+ const toolCatalogProperties = publicSchemaDefinitions.find((definition) => definition.id === "doctor.tools.json").properties;
858
+ publicSchemaDefinitions.push({
859
+ id: "doctor.prompts.json",
860
+ command: "codex-plugin-doctor doctor prompts <url> --allow-network --json",
861
+ required: ["schemaVersion", "scope", "status", "discovery", "catalog", "coverage", "findings"],
862
+ properties: {
863
+ schemaVersion: { const: 1 },
864
+ scope: { const: "prompt-catalog-structure" },
865
+ // transport, protocol, and execution appear only on reports from `doctor prompts <path> --server <name> --runtime`.
866
+ transport: { const: "stdio" },
867
+ status: toolCatalogProperties.status,
868
+ protocol: toolCatalogProperties.protocol,
869
+ execution: toolCatalogProperties.execution,
870
+ discovery: toolCatalogProperties.discovery,
871
+ catalog: {
872
+ type: "object", required: ["complete", "pagesRead", "promptsChecked", "reason"],
873
+ properties: {
874
+ complete: { type: "boolean" },
875
+ pagesRead: { type: "integer", minimum: 0, maximum: 5 },
876
+ promptsChecked: { type: "integer", minimum: 0, maximum: 500 },
877
+ reason: { type: ["string", "null"] }
878
+ },
879
+ additionalProperties: false
880
+ },
881
+ coverage: {
882
+ type: "object", required: ["discovery", "catalogEnumeration", "definitions", "promptRetrieval", "fullConformance"],
883
+ properties: {
884
+ discovery: { enum: ["pass", "fail", "skipped"] },
885
+ catalogEnumeration: { enum: ["complete", "incomplete", "not-tested"] },
886
+ definitions: { const: "shape-only" },
887
+ promptRetrieval: { const: "not-tested" },
888
+ fullConformance: { const: "not-tested" }
889
+ },
890
+ additionalProperties: false
891
+ },
892
+ findings: {
893
+ type: "array", maxItems: 100,
894
+ items: {
895
+ type: "object", required: ["id", "severity", "message", "impact", "suggestedFix"],
896
+ properties: {
897
+ id: { type: "string", pattern: "^plugin\\.catalog\\." },
898
+ severity: { enum: ["warn", "fail"] },
899
+ message: { type: "string" }, impact: { type: "string" }, suggestedFix: { type: "string" },
900
+ location: {
901
+ type: "object", required: ["page", "promptIndex"],
902
+ properties: {
903
+ page: { type: "integer", minimum: 1, maximum: 5 },
904
+ promptIndex: { type: "integer", minimum: 1, maximum: 500 },
905
+ argumentIndex: { type: "integer", minimum: 1 },
906
+ relatedPromptIndex: { type: "integer", minimum: 1, maximum: 500 },
907
+ relatedArgumentIndex: { type: "integer", minimum: 1 }
908
+ },
909
+ additionalProperties: false
910
+ }
911
+ },
912
+ additionalProperties: false
913
+ }
914
+ }
915
+ }
916
+ });
917
+ const promptFileReportDefinition = {
918
+ id: "doctor.prompts.file.json",
919
+ command: "codex-plugin-doctor doctor prompts-file <path> --json",
920
+ required: ["schemaVersion", "scope", "status", "source", "inspection", "coverage", "findings"],
921
+ properties: {
922
+ schemaVersion: { const: 1 },
923
+ scope: { const: "prompt-definitions-file" },
924
+ status: { enum: ["pass", "warn", "fail", "incomplete", "blocked"] },
925
+ source: {
926
+ type: "object", required: ["kind", "format", "bytesRead"],
927
+ properties: {
928
+ kind: { const: "file" }, format: { const: "mcp-prompts-list-response" },
929
+ bytesRead: { type: "integer", minimum: 0, maximum: 1048577 }
930
+ }, additionalProperties: false
931
+ },
932
+ inspection: {
933
+ type: "object", required: ["complete", "promptsChecked", "hasNextCursor", "reason"],
934
+ properties: {
935
+ complete: { type: "boolean" }, promptsChecked: { type: "integer", minimum: 0, maximum: 500 },
936
+ hasNextCursor: { type: ["boolean", "null"] }, reason: { type: ["string", "null"] }
937
+ }, additionalProperties: false
938
+ },
939
+ coverage: {
940
+ type: "object", required: ["definitions", "discovery", "serverCatalog", "promptRetrieval", "fullConformance"],
941
+ properties: {
942
+ definitions: { const: "shape-only" }, discovery: { const: "not-tested" },
943
+ serverCatalog: { const: "not-tested" }, promptRetrieval: { const: "not-tested" },
944
+ fullConformance: { const: "not-tested" }
945
+ }, additionalProperties: false
946
+ },
947
+ findings: {
948
+ type: "array", maxItems: 100,
949
+ items: {
950
+ type: "object", required: ["id", "severity", "message", "impact", "suggestedFix"],
951
+ properties: {
952
+ id: { type: "string", pattern: "^plugin\\.(catalog|prompts_file)\\." },
953
+ severity: { enum: ["warn", "fail"] },
954
+ message: { type: "string" }, impact: { type: "string" }, suggestedFix: { type: "string" },
955
+ location: {
956
+ type: "object", required: ["page", "promptIndex"],
957
+ properties: {
958
+ page: { const: 1 }, promptIndex: { type: "integer", minimum: 1, maximum: 500 },
959
+ argumentIndex: { type: "integer", minimum: 1 },
960
+ relatedPromptIndex: { type: "integer", minimum: 1, maximum: 500 },
961
+ relatedArgumentIndex: { type: "integer", minimum: 1 }
962
+ }, additionalProperties: false
963
+ }
964
+ }, additionalProperties: false
965
+ }
966
+ }
967
+ }
968
+ };
969
+ const embeddedPromptFileReportSchema = {
970
+ type: "object",
971
+ required: promptFileReportDefinition.required,
972
+ properties: promptFileReportDefinition.properties,
973
+ additionalProperties: true
974
+ };
975
+ publicSchemaDefinitions.push(promptFileReportDefinition, {
976
+ id: "doctor.prompts.diff.json",
977
+ command: "codex-plugin-doctor doctor prompts-diff --before <path> --after <path> --json",
978
+ required: ["schemaVersion", "scope", "status", "before", "after", "comparison", "coverage", "changes"],
979
+ properties: {
980
+ schemaVersion: { const: 1 },
981
+ scope: { const: "prompt-definitions-diff" },
982
+ status: { enum: ["pass", "warn", "incomplete", "blocked"] },
983
+ before: embeddedPromptFileReportSchema,
984
+ after: embeddedPromptFileReportSchema,
985
+ comparison: {
986
+ type: "object", required: ["complete", "reason", "added", "removed", "changed", "unchanged", "breaking", "unclassified"],
987
+ properties: {
988
+ complete: { type: "boolean" },
989
+ reason: { enum: [null, "input-blocked", "input-incomplete", "invalid-definitions", "ambiguous-names"] },
990
+ added: { type: ["integer", "null"], minimum: 0, maximum: 500 },
991
+ removed: { type: ["integer", "null"], minimum: 0, maximum: 500 },
992
+ changed: { type: ["integer", "null"], minimum: 0, maximum: 500 },
993
+ unchanged: { type: ["integer", "null"], minimum: 0, maximum: 500 },
994
+ breaking: { type: ["integer", "null"], minimum: 0, maximum: 1000 },
995
+ unclassified: { type: ["integer", "null"], minimum: 0, maximum: 1000 }
996
+ }, additionalProperties: false
997
+ },
998
+ coverage: {
999
+ type: "object", required: ["comparison", "definitions", "compatibility", "serverCatalog", "promptRetrieval", "impactClassification"],
1000
+ properties: {
1001
+ comparison: { const: "structural-only" }, definitions: { const: "shape-only" },
1002
+ compatibility: { const: "not-tested" }, serverCatalog: { const: "not-tested" },
1003
+ promptRetrieval: { const: "not-tested" }, impactClassification: { const: "heuristic" }
1004
+ }, additionalProperties: false
1005
+ },
1006
+ changes: {
1007
+ type: "array", maxItems: 1000,
1008
+ items: {
1009
+ type: "object", required: ["kind", "beforePromptIndex", "afterPromptIndex", "fields", "impact", "reasons"],
1010
+ properties: {
1011
+ kind: { enum: ["added", "removed", "changed"] },
1012
+ beforePromptIndex: { type: ["integer", "null"], minimum: 1, maximum: 500 },
1013
+ afterPromptIndex: { type: ["integer", "null"], minimum: 1, maximum: 500 },
1014
+ fields: { type: "array", maxItems: 4, uniqueItems: true, items: { enum: ["arguments", "description", "title", "other"] } },
1015
+ impact: { enum: ["breaking", "compatible", "unclassified"] },
1016
+ reasons: { type: "array", maxItems: PROMPT_CHANGE_REASONS.length, uniqueItems: true, items: { enum: [...PROMPT_CHANGE_REASONS] } },
1017
+ // Only with --show-names.
1018
+ name: { type: "string" },
1019
+ locations: {
1020
+ type: "array", maxItems: MAX_PROMPT_CHANGE_LOCATIONS,
1021
+ items: {
1022
+ type: "object", required: ["reason", "argument"],
1023
+ properties: { reason: { enum: [...PROMPT_CHANGE_REASONS] }, argument: { type: ["string", "null"] } },
1024
+ additionalProperties: false
1025
+ }
1026
+ }
1027
+ }, additionalProperties: false
1028
+ }
1029
+ }
1030
+ }
1031
+ });
855
1032
  function sha256(value) {
856
1033
  return `sha256:${createHash("sha256").update(value).digest("hex")}`;
857
1034
  }
@@ -1,2 +1,3 @@
1
1
  /** Quotes server-supplied text, such as a tool name or a JSON Pointer, as a JSON string that only holds printable characters. */
2
2
  export declare function quoteServerText(text: string): string;
3
+ export declare function markdownCodeSpan(text: string): string;
@@ -5,3 +5,16 @@ const UNSAFE_TEXT = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu;
5
5
  export function quoteServerText(text) {
6
6
  return JSON.stringify(text).replace(UNSAFE_TEXT, (character) => Array.from({ length: character.length }, (_, index) => `\\u${character.charCodeAt(index).toString(16).padStart(4, "0")}`).join(""));
7
7
  }
8
+ // Names and pointers come from the server, so they are quoted and put in a code span, where Markdown,
9
+ // HTML, mentions, and links stay inert; the fence outgrows any backtick run, and pipes are escaped for the table.
10
+ export function markdownCodeSpan(text) {
11
+ const quoted = quoteServerText(text);
12
+ let run = 0;
13
+ let longest = 0;
14
+ for (const character of quoted) {
15
+ run = character === "`" ? run + 1 : 0;
16
+ longest = Math.max(longest, run);
17
+ }
18
+ const fence = "`".repeat(longest + 1);
19
+ return `${fence}${quoted.replace(/\|/gu, "\\|")}${fence}`;
20
+ }
@@ -0,0 +1,2 @@
1
+ import type { McpPromptCatalogReport } from "../core/mcp-prompt-catalog.js";
2
+ export declare function renderMcpPromptCatalogReport(report: McpPromptCatalogReport): string;
@@ -0,0 +1,31 @@
1
+ import { describePromptLocation } from "./render-mcp-prompt-file-report.js";
2
+ export function renderMcpPromptCatalogReport(report) {
3
+ return [
4
+ "MCP Prompt Catalog",
5
+ "==================",
6
+ `Status: ${report.status}`,
7
+ `Scope: ${report.scope}`,
8
+ ...(report.transport === "stdio"
9
+ ? [
10
+ "Transport: stdio",
11
+ `Protocol: ${report.protocol?.era ?? "not established"}${report.protocol?.version ? ` (${report.protocol.version})` : ""}`,
12
+ `Execution: ${report.execution?.backend ?? "not started"}`
13
+ ]
14
+ : []),
15
+ `Discovery: ${report.discovery.status}`,
16
+ `Catalog complete: ${report.catalog.complete}`,
17
+ `Pages read: ${report.catalog.pagesRead}`,
18
+ `Prompts checked: ${report.catalog.promptsChecked}`,
19
+ ...(report.catalog.reason === null ? [] : [`Reason: ${report.catalog.reason}`]),
20
+ `Enumeration: ${report.coverage.catalogEnumeration}`,
21
+ `Definition coverage: ${report.coverage.definitions}`,
22
+ `Prompt retrieval: ${report.coverage.promptRetrieval}`,
23
+ `Full conformance: ${report.coverage.fullConformance}`,
24
+ "Counts describe inspected content, not a guaranteed server-wide inventory.",
25
+ ...report.discovery.findings.map((finding) => `${finding.severity.toUpperCase()} ${finding.id}: ${finding.message}\n ${finding.suggestedFix}`),
26
+ ...report.findings.map((finding) => {
27
+ const locator = finding.location ? ` [page ${finding.location.page}, ${describePromptLocation(finding.location)}]` : "";
28
+ return `${finding.severity.toUpperCase()} ${finding.id}${locator}: ${finding.message}\n ${finding.suggestedFix}`;
29
+ })
30
+ ].join("\n");
31
+ }
@@ -0,0 +1,2 @@
1
+ import type { McpPromptDiffReport } from "../core/mcp-prompt-diff.js";
2
+ export declare function renderMcpPromptDiffMarkdown(report: McpPromptDiffReport): string;
@@ -0,0 +1,39 @@
1
+ import { markdownCodeSpan } from "./quote-server-text.js";
2
+ import { describePromptLocation } from "./render-mcp-prompt-file-report.js";
3
+ function count(value) {
4
+ return value === null || value === undefined ? "not compared" : String(value);
5
+ }
6
+ function promptIndex(value) {
7
+ return value === null ? "-" : String(value);
8
+ }
9
+ export function renderMcpPromptDiffMarkdown(report) {
10
+ const comparison = report.comparison;
11
+ const named = report.changes.some((change) => change.name !== undefined);
12
+ const lines = [
13
+ "## MCP Prompt 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 | Prompt | Before prompt | After prompt | Fields | Impact | Reasons |" : "| Change | Before prompt | After prompt | Fields | Impact | Reasons |", named ? "| --- | --- | --- | --- | --- | --- | --- |" : "| --- | --- | --- | --- | --- | --- |", ...report.changes.map((change) => `| ${change.kind}${named ? ` | ${change.name === undefined ? "-" : markdownCodeSpan(change.name)}` : ""} | ${promptIndex(change.beforePromptIndex)} | ${promptIndex(change.afterPromptIndex)} | ${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.argument === null ? "-" : markdownCodeSpan(location.argument)} |`));
25
+ if (located.length > 0) {
26
+ lines.push("", "### Locations", "", "| Prompt | Reason | Argument |", "| --- | --- | --- |", ...located);
27
+ }
28
+ const findings = ["before", "after"].flatMap((side) => report[side].findings.map((finding) => {
29
+ const locator = finding.location ? `${side} ${describePromptLocation(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
+ ? `Prompt and argument names are shown because --show-names was used; descriptions and other values are not reported. Impact labels are ${report.coverage.impactClassification} argument checks, not proof of compatibility.`
37
+ : `Prompt numbers are one-based positions in the before and after files; names and descriptions are not reported. Impact labels are ${report.coverage.impactClassification} argument checks, not proof of compatibility.`);
38
+ return lines.join("\n");
39
+ }