codex-plugin-doctor 1.75.0 → 1.77.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 (39) hide show
  1. package/README.md +20 -3
  2. package/dist/core/mcp-prompt-catalog.d.ts +36 -0
  3. package/dist/core/mcp-prompt-catalog.js +53 -0
  4. package/dist/core/mcp-prompt-definition.d.ts +24 -0
  5. package/dist/core/mcp-prompt-definition.js +107 -0
  6. package/dist/core/mcp-prompt-diff.d.ts +43 -0
  7. package/dist/core/mcp-prompt-diff.js +132 -0
  8. package/dist/core/mcp-prompt-file.d.ts +31 -0
  9. package/dist/core/mcp-prompt-file.js +118 -0
  10. package/dist/core/mcp-prompt-impact.d.ts +21 -0
  11. package/dist/core/mcp-prompt-impact.js +93 -0
  12. package/dist/core/mcp-stdio-tool-catalog.d.ts +9 -3
  13. package/dist/core/mcp-stdio-tool-catalog.js +74 -55
  14. package/dist/core/mcp-tool-catalog.d.ts +44 -5
  15. package/dist/core/mcp-tool-catalog.js +76 -31
  16. package/dist/core/mcp-tool-diff.d.ts +8 -2
  17. package/dist/core/mcp-tool-diff.js +7 -5
  18. package/dist/core/mcp-tool-file.d.ts +20 -0
  19. package/dist/core/mcp-tool-file.js +1 -1
  20. package/dist/core/mcp-tool-impact.d.ts +10 -1
  21. package/dist/core/mcp-tool-impact.js +74 -17
  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 +189 -2
  25. package/dist/reporting/quote-server-text.d.ts +3 -0
  26. package/dist/reporting/quote-server-text.js +20 -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 +10 -2
  36. package/dist/reporting/render-mcp-tool-diff-report.js +4 -1
  37. package/dist/run-cli.d.ts +7 -0
  38. package/dist/run-cli.js +206 -137
  39. 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
  }
@@ -1,4 +1,4 @@
1
- import { type ToolChangeImpact, type ToolChangeReason } from "./mcp-tool-impact.js";
1
+ import { type ToolChangeImpact, type ToolChangeLocation, type ToolChangeReason } from "./mcp-tool-impact.js";
2
2
  import { type McpToolFileReport } from "./mcp-tool-file.js";
3
3
  type DiffField = "inputSchema" | "outputSchema" | "description" | "title" | "annotations" | "other";
4
4
  export interface McpToolDiffReport {
@@ -32,7 +32,13 @@ export interface McpToolDiffReport {
32
32
  fields: DiffField[];
33
33
  impact: ToolChangeImpact;
34
34
  reasons: ToolChangeReason[];
35
+ name?: string;
36
+ locations?: ToolChangeLocation[];
35
37
  }>;
36
38
  }
37
- export declare function compareMcpToolFiles(beforePath: string, afterPath: string): Promise<McpToolDiffReport>;
39
+ export interface McpToolDiffOptions {
40
+ showNames?: boolean;
41
+ }
42
+ export declare function structurallyEqual(left: unknown, right: unknown): boolean;
43
+ export declare function compareMcpToolFiles(beforePath: string, afterPath: string, options?: McpToolDiffOptions): Promise<McpToolDiffReport>;
38
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();
@@ -85,7 +85,7 @@ function incompleteReport(before, after, status, reason) {
85
85
  changes: []
86
86
  };
87
87
  }
88
- export async function compareMcpToolFiles(beforePath, afterPath) {
88
+ export async function compareMcpToolFiles(beforePath, afterPath, options = {}) {
89
89
  const [beforeSnapshot, afterSnapshot] = await Promise.all([
90
90
  loadMcpToolFileSnapshot(beforePath),
91
91
  loadMcpToolFileSnapshot(afterPath)
@@ -106,6 +106,7 @@ export async function compareMcpToolFiles(beforePath, afterPath) {
106
106
  if (beforeDefinitions === null || afterDefinitions === null) {
107
107
  return incompleteReport(before, after, "incomplete", "ambiguous-names");
108
108
  }
109
+ const named = (name) => (options.showNames === true ? { name } : {});
109
110
  const changes = [];
110
111
  let added = 0;
111
112
  let removed = 0;
@@ -115,7 +116,7 @@ export async function compareMcpToolFiles(beforePath, afterPath) {
115
116
  const afterDefinition = afterDefinitions.get(name);
116
117
  if (afterDefinition === undefined) {
117
118
  removed += 1;
118
- changes.push({ kind: "removed", beforeToolIndex: beforeDefinition.index, afterToolIndex: null, fields: [], ...classifyRemovedTool() });
119
+ changes.push({ kind: "removed", ...named(name), beforeToolIndex: beforeDefinition.index, afterToolIndex: null, fields: [], ...classifyRemovedTool() });
119
120
  continue;
120
121
  }
121
122
  const fields = changedFields(beforeDefinition.value, afterDefinition.value);
@@ -126,17 +127,18 @@ export async function compareMcpToolFiles(beforePath, afterPath) {
126
127
  changed += 1;
127
128
  changes.push({
128
129
  kind: "changed",
130
+ ...named(name),
129
131
  beforeToolIndex: beforeDefinition.index,
130
132
  afterToolIndex: afterDefinition.index,
131
133
  fields,
132
- ...classifyChangedTool(beforeDefinition.value, afterDefinition.value, fields, structurallyEqual)
134
+ ...classifyChangedTool(beforeDefinition.value, afterDefinition.value, fields, structurallyEqual, { locate: options.showNames === true })
133
135
  });
134
136
  }
135
137
  }
136
138
  for (const [name, afterDefinition] of afterDefinitions) {
137
139
  if (!beforeDefinitions.has(name)) {
138
140
  added += 1;
139
- changes.push({ kind: "added", beforeToolIndex: null, afterToolIndex: afterDefinition.index, fields: [], ...classifyAddedTool() });
141
+ changes.push({ kind: "added", ...named(name), beforeToolIndex: null, afterToolIndex: afterDefinition.index, fields: [], ...classifyAddedTool() });
140
142
  }
141
143
  }
142
144
  return {
@@ -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);
@@ -2,12 +2,21 @@ type JsonObject = Record<string, unknown>;
2
2
  export type ToolChangeImpact = "breaking" | "compatible" | "unclassified";
3
3
  export declare const TOOL_CHANGE_REASONS: readonly ["tool-removed", "input-type-narrowed", "input-required-added", "input-property-removed", "input-property-type-narrowed", "input-property-enum-narrowed", "input-constraint-tightened", "input-additional-properties-closed", "input-schema-unclassified", "output-schema-removed", "output-type-widened", "output-property-removed", "output-guarantee-removed", "output-property-type-widened", "output-property-enum-widened", "output-constraint-loosened", "output-schema-unclassified", "annotation-safety-reduced", "other-fields-unclassified"];
4
4
  export type ToolChangeReason = (typeof TOOL_CHANGE_REASONS)[number];
5
+ export interface ToolChangeLocation {
6
+ reason: ToolChangeReason;
7
+ pointer: string;
8
+ }
5
9
  export interface ToolChangeClassification {
6
10
  impact: ToolChangeImpact;
7
11
  reasons: ToolChangeReason[];
12
+ locations?: ToolChangeLocation[];
8
13
  }
14
+ export declare const MAX_TOOL_CHANGE_LOCATIONS = 100;
15
+ export declare const MAX_TOOL_CHANGE_POINTER_LENGTH = 1000;
9
16
  type Equal = (left: unknown, right: unknown) => boolean;
10
17
  export declare function classifyRemovedTool(): ToolChangeClassification;
11
18
  export declare function classifyAddedTool(): ToolChangeClassification;
12
- export declare function classifyChangedTool(before: JsonObject, after: JsonObject, fields: readonly string[], equal: Equal): ToolChangeClassification;
19
+ export declare function classifyChangedTool(before: JsonObject, after: JsonObject, fields: readonly string[], equal: Equal, options?: {
20
+ locate?: boolean;
21
+ }): ToolChangeClassification;
13
22
  export {};
@@ -19,6 +19,59 @@ export const TOOL_CHANGE_REASONS = [
19
19
  "annotation-safety-reduced",
20
20
  "other-fields-unclassified"
21
21
  ];
22
+ export const MAX_TOOL_CHANGE_LOCATIONS = 100;
23
+ // Longer pointers are not recorded, so a long property name cannot make locating costly or the report large.
24
+ export const MAX_TOOL_CHANGE_POINTER_LENGTH = 1_000;
25
+ /**
26
+ * A reason set that also records, for each reason, the JSON Pointer of the schema position being
27
+ * compared when it was added. A shared definition is compared once, so it is located at the first
28
+ * reference that reached it.
29
+ */
30
+ class LocatingReasons extends Set {
31
+ locations = [];
32
+ // Escaped pointer segments of the position being compared, and the pointer length at each one;
33
+ // a segment that would pass the length limit is left empty, with an infinite length.
34
+ segments = [];
35
+ lengths = [0];
36
+ seen = new Set();
37
+ enter(tokens) {
38
+ for (const token of tokens) {
39
+ const previous = this.lengths[this.lengths.length - 1];
40
+ const segment = previous + 1 + token.length > MAX_TOOL_CHANGE_POINTER_LENGTH ? "" : `/${token.replaceAll("~", "~0").replaceAll("/", "~1")}`;
41
+ this.segments.push(segment);
42
+ this.lengths.push(segment === "" || previous + segment.length > MAX_TOOL_CHANGE_POINTER_LENGTH ? Infinity : previous + segment.length);
43
+ }
44
+ }
45
+ leave(count) {
46
+ this.segments.length -= count;
47
+ this.lengths.length -= count;
48
+ }
49
+ add(reason) {
50
+ if (this.locations.length < MAX_TOOL_CHANGE_LOCATIONS && this.lengths[this.lengths.length - 1] !== Infinity) {
51
+ const pointer = this.segments.join("");
52
+ const key = `${reason} ${pointer}`;
53
+ if (!this.seen.has(key)) {
54
+ this.seen.add(key);
55
+ this.locations.push({ reason, pointer });
56
+ }
57
+ }
58
+ return super.add(reason);
59
+ }
60
+ }
61
+ /** Runs `compare` with `tokens` appended to the recorded location, when locations are recorded. */
62
+ function within(reasons, tokens, compare) {
63
+ if (!(reasons instanceof LocatingReasons)) {
64
+ compare();
65
+ return;
66
+ }
67
+ reasons.enter(tokens);
68
+ try {
69
+ compare();
70
+ }
71
+ finally {
72
+ reasons.leave(tokens.length);
73
+ }
74
+ }
22
75
  const UNCLASSIFIED_REASONS = new Set([
23
76
  "input-schema-unclassified",
24
77
  "output-schema-unclassified",
@@ -752,12 +805,14 @@ function compareSchema(before, after, direction, depth, equal, reasons, scope) {
752
805
  compareNumericAndArrayConstraints(inner, outer, codes, reasons);
753
806
  compareStringConstraints(inner, outer, codes, reasons);
754
807
  if (!sameMeaning(before.items, after.items, equal, scope)) {
755
- if (unevaluated)
756
- reasons.add(codes.unclassified);
757
- else if (outer.items !== undefined && inner.items === undefined)
758
- reasons.add(codes.constraint);
759
- else if (outer.items !== undefined)
760
- compareSchema(before.items, after.items, direction, depth + 1, equal, reasons, scope);
808
+ within(reasons, ["items"], () => {
809
+ if (unevaluated)
810
+ reasons.add(codes.unclassified);
811
+ else if (outer.items !== undefined && inner.items === undefined)
812
+ reasons.add(codes.constraint);
813
+ else if (outer.items !== undefined)
814
+ compareSchema(before.items, after.items, direction, depth + 1, equal, reasons, scope);
815
+ });
761
816
  }
762
817
  }
763
818
  const innerRequired = stringSet(inner.required);
@@ -770,13 +825,15 @@ function compareSchema(before, after, direction, depth, equal, reasons, scope) {
770
825
  }
771
826
  for (const key of outerRequired) {
772
827
  if (!innerRequired.has(key))
773
- reasons.add(codes.required);
828
+ within(reasons, ["properties", key], () => reasons.add(codes.required));
774
829
  }
775
830
  for (const key of Object.keys(beforeProperties)) {
776
- if (!Object.hasOwn(afterProperties, key))
777
- reasons.add(codes.propertyRemoved);
778
- else
779
- compareSchema(beforeProperties[key], afterProperties[key], direction, depth + 1, equal, reasons, scope);
831
+ within(reasons, ["properties", key], () => {
832
+ if (!Object.hasOwn(afterProperties, key))
833
+ reasons.add(codes.propertyRemoved);
834
+ else
835
+ compareSchema(beforeProperties[key], afterProperties[key], direction, depth + 1, equal, reasons, scope);
836
+ });
780
837
  }
781
838
  // In an object whose undeclared keys were governed by other keywords, or a nested input object
782
839
  // that left them open, a newly declared property can reject values callers send (input) or
@@ -834,7 +891,7 @@ function result(reasons) {
834
891
  const impact = ordered.some((reason) => !UNCLASSIFIED_REASONS.has(reason))
835
892
  ? "breaking"
836
893
  : ordered.length > 0 ? "unclassified" : "compatible";
837
- return { impact, reasons: ordered };
894
+ return reasons instanceof LocatingReasons ? { impact, reasons: ordered, locations: reasons.locations } : { impact, reasons: ordered };
838
895
  }
839
896
  export function classifyRemovedTool() {
840
897
  return { impact: "breaking", reasons: ["tool-removed"] };
@@ -842,15 +899,15 @@ export function classifyRemovedTool() {
842
899
  export function classifyAddedTool() {
843
900
  return { impact: "compatible", reasons: [] };
844
901
  }
845
- export function classifyChangedTool(before, after, fields, equal) {
846
- const reasons = new Set();
902
+ export function classifyChangedTool(before, after, fields, equal, options = {}) {
903
+ const reasons = options.locate ? new LocatingReasons() : new Set();
847
904
  if (fields.includes("inputSchema")) {
848
- compareSchema(before.inputSchema, after.inputSchema, "input", 0, equal, reasons, referenceScope(before.inputSchema, after.inputSchema));
905
+ within(reasons, ["inputSchema"], () => compareSchema(before.inputSchema, after.inputSchema, "input", 0, equal, reasons, referenceScope(before.inputSchema, after.inputSchema)));
849
906
  }
850
907
  if (fields.includes("outputSchema"))
851
- classifyOutputSchema(before.outputSchema, after.outputSchema, equal, reasons);
908
+ within(reasons, ["outputSchema"], () => classifyOutputSchema(before.outputSchema, after.outputSchema, equal, reasons));
852
909
  if (fields.includes("annotations"))
853
- classifyAnnotations(before.annotations, after.annotations, reasons);
910
+ within(reasons, ["annotations"], () => classifyAnnotations(before.annotations, after.annotations, reasons));
854
911
  if (fields.includes("other"))
855
912
  reasons.add("other-fields-unclassified");
856
913
  return result(reasons);
@@ -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
  }