codex-plugin-doctor 1.75.0 → 1.76.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 CHANGED
@@ -120,7 +120,8 @@ codex-plugin-doctor doctor tools-diff --before ./tools-old.json --after ./tools-
120
120
  This reports added, removed, and structurally changed definitions using numeric
121
121
  locations, and labels each change `breaking`, `compatible`, or `unclassified`
122
122
  with fixed reason codes. Add `--fail-on breaking` to fail CI only on breaking or
123
- unclassified changes, and `--markdown` for pull request tables. Incomplete inputs, invalid definitions, or duplicate names
123
+ unclassified changes, `--markdown` for pull request tables, and `--show-names`
124
+ locally to see tool names and the JSON Pointer of each reason. Incomplete inputs, invalid definitions, or duplicate names
124
125
  prevent a comparison. Impact labels follow nested object properties, array
125
126
  items, and common length and range bounds, but remain heuristics, not proof of
126
127
  compatibility.
@@ -589,9 +590,9 @@ jobs:
589
590
  runs-on: ubuntu-latest
590
591
  steps:
591
592
  - uses: actions/checkout@v5
592
- - uses: Esquetta/CodexPluginDoctor@v1.75.0
593
+ - uses: Esquetta/CodexPluginDoctor@v1.76.0
593
594
  with:
594
- version: "1.75.0"
595
+ version: "1.76.0"
595
596
  path: .
596
597
  runtime: "true"
597
598
  policy: codex-publish
@@ -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,12 @@ 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 compareMcpToolFiles(beforePath: string, afterPath: string, options?: McpToolDiffOptions): Promise<McpToolDiffReport>;
38
43
  export {};
@@ -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 {
@@ -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,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { packageVersion } from "../version.js";
3
- import { TOOL_CHANGE_REASONS } from "./mcp-tool-impact.js";
3
+ import { MAX_TOOL_CHANGE_LOCATIONS, MAX_TOOL_CHANGE_POINTER_LENGTH, TOOL_CHANGE_REASONS } from "./mcp-tool-impact.js";
4
4
  import { ruleCatalog } from "../rules/rule-catalog.js";
5
5
  const runtimeCapabilityStatusSchema = {
6
6
  type: "string",
@@ -836,7 +836,17 @@ publicSchemaDefinitions.push({
836
836
  afterToolIndex: { type: ["integer", "null"], minimum: 1, maximum: 500 },
837
837
  fields: { type: "array", maxItems: 6, uniqueItems: true, items: { enum: ["inputSchema", "outputSchema", "description", "title", "annotations", "other"] } },
838
838
  impact: { enum: ["breaking", "compatible", "unclassified"] },
839
- reasons: { type: "array", maxItems: TOOL_CHANGE_REASONS.length, uniqueItems: true, items: { enum: [...TOOL_CHANGE_REASONS] } }
839
+ reasons: { type: "array", maxItems: TOOL_CHANGE_REASONS.length, uniqueItems: true, items: { enum: [...TOOL_CHANGE_REASONS] } },
840
+ // Only with --show-names.
841
+ name: { type: "string" },
842
+ locations: {
843
+ type: "array", maxItems: MAX_TOOL_CHANGE_LOCATIONS,
844
+ items: {
845
+ type: "object", required: ["reason", "pointer"],
846
+ properties: { reason: { enum: [...TOOL_CHANGE_REASONS] }, pointer: { type: "string", maxLength: MAX_TOOL_CHANGE_POINTER_LENGTH } },
847
+ additionalProperties: false
848
+ }
849
+ }
840
850
  }, additionalProperties: false
841
851
  }
842
852
  }
@@ -0,0 +1,2 @@
1
+ /** Quotes server-supplied text, such as a tool name or a JSON Pointer, as a JSON string that only holds printable characters. */
2
+ export declare function quoteServerText(text: string): string;
@@ -0,0 +1,7 @@
1
+ // Control, format, and line or paragraph separator characters, which can drive a terminal, end a line,
2
+ // reorder text, or hide in it; JSON escapes only the C0 controls among them.
3
+ const UNSAFE_TEXT = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu;
4
+ /** Quotes server-supplied text, such as a tool name or a JSON Pointer, as a JSON string that only holds printable characters. */
5
+ export function quoteServerText(text) {
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
+ }
@@ -1,11 +1,26 @@
1
+ import { quoteServerText } from "./quote-server-text.js";
1
2
  function count(value) {
2
3
  return value === null || value === undefined ? "not compared" : String(value);
3
4
  }
4
5
  function toolIndex(value) {
5
6
  return value === null ? "-" : String(value);
6
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
+ function code(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
+ }
7
21
  export function renderMcpToolDiffMarkdown(report) {
8
22
  const comparison = report.comparison;
23
+ const named = report.changes.some((change) => change.name !== undefined);
9
24
  const lines = [
10
25
  "## MCP Tool Definition Diff",
11
26
  "",
@@ -16,7 +31,11 @@ export function renderMcpToolDiffMarkdown(report) {
16
31
  `| ${count(comparison.added)} | ${count(comparison.removed)} | ${count(comparison.changed)} | ${count(comparison.unchanged)} | ${count(comparison.breaking)} | ${count(comparison.unclassified)} |`
17
32
  ];
18
33
  if (report.changes.length > 0) {
19
- lines.push("", "### Changes", "", "| Change | Before tool | After tool | Fields | Impact | Reasons |", "| --- | --- | --- | --- | --- | --- |", ...report.changes.map((change) => `| ${change.kind} | ${toolIndex(change.beforeToolIndex)} | ${toolIndex(change.afterToolIndex)} | ${change.fields.map((field) => `\`${field}\``).join(", ") || "-"} | **${change.impact}** | ${change.reasons.map((reason) => `\`${reason}\``).join(", ") || "-"} |`));
34
+ lines.push("", "### Changes", "", named ? "| Change | Tool | Before tool | After tool | Fields | Impact | Reasons |" : "| Change | Before tool | After tool | Fields | Impact | Reasons |", named ? "| --- | --- | --- | --- | --- | --- | --- |" : "| --- | --- | --- | --- | --- | --- |", ...report.changes.map((change) => `| ${change.kind}${named ? ` | ${change.name === undefined ? "-" : code(change.name)}` : ""} | ${toolIndex(change.beforeToolIndex)} | ${toolIndex(change.afterToolIndex)} | ${change.fields.map((field) => `\`${field}\``).join(", ") || "-"} | **${change.impact}** | ${change.reasons.map((reason) => `\`${reason}\``).join(", ") || "-"} |`));
35
+ }
36
+ const located = report.changes.flatMap((change) => (change.locations ?? []).map((location) => `| ${change.name === undefined ? "-" : code(change.name)} | \`${location.reason}\` | ${location.pointer === "" ? "(tool)" : code(location.pointer)} |`));
37
+ if (located.length > 0) {
38
+ lines.push("", "### Locations", "", "| Tool | Reason | Location |", "| --- | --- | --- |", ...located);
20
39
  }
21
40
  const findings = ["before", "after"].flatMap((side) => report[side].findings.map((finding) => {
22
41
  const location = finding.location;
@@ -28,6 +47,8 @@ export function renderMcpToolDiffMarkdown(report) {
28
47
  if (findings.length > 0) {
29
48
  lines.push("", "### Input Findings", "", "| Severity | Finding | Location | Message |", "| --- | --- | --- | --- |", ...findings);
30
49
  }
31
- lines.push("", `Tool numbers are one-based positions in the before and after files; names and schema values are not reported. Impact labels are ${report.coverage.impactClassification} schema checks, not proof of compatibility.`);
50
+ lines.push("", named
51
+ ? `Tool names and schema locations are shown because --show-names was used; schema values are not reported. Impact labels are ${report.coverage.impactClassification} schema checks, not proof of compatibility.`
52
+ : `Tool numbers are one-based positions in the before and after files; names and schema values are not reported. Impact labels are ${report.coverage.impactClassification} schema checks, not proof of compatibility.`);
32
53
  return lines.join("\n");
33
54
  }
@@ -1,3 +1,4 @@
1
+ import { quoteServerText } from "./quote-server-text.js";
1
2
  export function renderMcpToolDiffReport(report) {
2
3
  const comparison = report.comparison;
3
4
  return [
@@ -39,7 +40,9 @@ export function renderMcpToolDiffReport(report) {
39
40
  ...(change.afterToolIndex === null ? [] : [`after tool ${change.afterToolIndex}`])
40
41
  ];
41
42
  const impact = `${change.impact}${change.reasons.length ? ` (${change.reasons.join(", ")})` : ""}`;
42
- return `${change.kind.toUpperCase()} [${locations.join(" -> ")}]${change.fields.length ? `: ${change.fields.join(", ")}` : ""}\n Impact: ${impact}`;
43
+ const name = change.name === undefined ? "" : ` ${quoteServerText(change.name)}`;
44
+ const at = (change.locations ?? []).map((location) => `\n At ${location.pointer === "" ? "(tool)" : quoteServerText(location.pointer)}: ${location.reason}`).join("");
45
+ return `${change.kind.toUpperCase()}${name} [${locations.join(" -> ")}]${change.fields.length ? `: ${change.fields.join(", ")}` : ""}\n Impact: ${impact}${at}`;
43
46
  })
44
47
  ].join("\n");
45
48
  }
package/dist/run-cli.js CHANGED
@@ -111,7 +111,7 @@ function writeExactStdout(io, message) {
111
111
  }
112
112
  class CliUsageError extends Error {
113
113
  }
114
- const toolsDiffUsage = "Usage: codex-plugin-doctor doctor tools-diff --before <path> --after <path> [--json|--markdown] [--fail-on any|breaking]";
114
+ const toolsDiffUsage = "Usage: codex-plugin-doctor doctor tools-diff --before <path> --after <path> [--json|--markdown] [--fail-on any|breaking] [--show-names]";
115
115
  const toolsFileUsage = "Usage: codex-plugin-doctor doctor tools-file <path> [--json]";
116
116
  const toolsUsage = "Usage: codex-plugin-doctor doctor tools <url> --allow-network [--allow-local-network] [--json] [--save-response <path>] [--bearer-token-env <NAME>]\n codex-plugin-doctor doctor tools <path> --server <name> --runtime [--sandbox docker [--env <NAME>]...] [--require-runtime-approval [--runtime-approval-digest <digest>]] [--json] [--save-response <path>]";
117
117
  const discoveryUsage = "Usage: codex-plugin-doctor doctor discover <url> --allow-network [--allow-local-network] [--json] [--bearer-token-env <NAME>]";
@@ -1393,17 +1393,22 @@ export async function runCli(args, io = defaultIo, options = {}) {
1393
1393
  if (command === "doctor") {
1394
1394
  if (maybePath === "tools-diff") {
1395
1395
  if (remainingArgs.length === 1 && remainingArgs[0] === "--help") {
1396
- io.writeStdout(`${toolsDiffUsage}\nCompares two saved MCP tools/list responses offline. Changes are classified heuristically as breaking, compatible, or unclassified; compatibility is not tested.\n--fail-on breaking exits 1 only for breaking or unclassified changes (default: any change or input warning).`);
1396
+ io.writeStdout(`${toolsDiffUsage}\nCompares two saved MCP tools/list responses offline. Changes are classified heuristically as breaking, compatible, or unclassified; compatibility is not tested.\n--fail-on breaking exits 1 only for breaking or unclassified changes (default: any change or input warning).
1397
+ --show-names adds tool names and the JSON Pointer of each reason; reports omit both by default, so keep it out of public output such as pull request comments.`);
1397
1398
  return 0;
1398
1399
  }
1399
1400
  let beforePath;
1400
1401
  let afterPath;
1401
1402
  let format;
1402
1403
  let failOn;
1404
+ let showNames = false;
1403
1405
  let invalid = false;
1404
1406
  for (let index = 0; index < remainingArgs.length; index += 1) {
1405
1407
  const flag = remainingArgs[index];
1406
- if ((flag === "--json" || flag === "--markdown") && format === undefined) {
1408
+ if (flag === "--show-names" && !showNames) {
1409
+ showNames = true;
1410
+ }
1411
+ else if ((flag === "--json" || flag === "--markdown") && format === undefined) {
1407
1412
  format = flag === "--json" ? "json" : "markdown";
1408
1413
  }
1409
1414
  else if (flag === "--fail-on" && failOn === undefined) {
@@ -1436,7 +1441,8 @@ export async function runCli(args, io = defaultIo, options = {}) {
1436
1441
  io.writeStderr(`Invalid comparison arguments. ${toolsDiffUsage}`);
1437
1442
  return 2;
1438
1443
  }
1439
- const report = await (options.compareMcpToolFilesImpl ?? compareMcpToolFiles)(beforePath, afterPath);
1444
+ const compare = options.compareMcpToolFilesImpl ?? compareMcpToolFiles;
1445
+ const report = await (showNames ? compare(beforePath, afterPath, { showNames: true }) : compare(beforePath, afterPath));
1440
1446
  io.writeStdout(format === "json" ? JSON.stringify(report, null, 2)
1441
1447
  : format === "markdown" ? renderMcpToolDiffMarkdown(report)
1442
1448
  : renderMcpToolDiffReport(report));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-plugin-doctor",
3
- "version": "1.75.0",
3
+ "version": "1.76.0",
4
4
  "description": "CLI-first validator for Codex plugins, skills, and MCP package surfaces with runtime MCP protocol validation.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",