codex-plugin-doctor 1.63.0 → 1.64.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
@@ -109,6 +109,20 @@ tool, and finding limits. It does not establish server inventory completeness or
109
109
  execute tools. See [Offline MCP Tool Definitions](docs/guides/offline-tool-definitions.md)
110
110
  for the accepted input format, coverage, and exit codes.
111
111
 
112
+ ### Offline MCP Tool Diff
113
+
114
+ Compare two saved tool-list responses before updating a server or client:
115
+
116
+ ```bash
117
+ codex-plugin-doctor doctor tools-diff --before ./tools-old.json --after ./tools-new.json --json
118
+ ```
119
+
120
+ This reports added, removed, and structurally changed definitions using numeric
121
+ locations. Incomplete inputs, invalid definitions, or duplicate names prevent a
122
+ comparison. It does not determine whether a schema change breaks compatibility.
123
+ See [Offline MCP Tool Diff](docs/guides/offline-tool-diff.md) for matching rules,
124
+ privacy, limits, and exit codes.
125
+
112
126
  ### HTTP MCP Tool Catalog
113
127
 
114
128
  Inspect a modern HTTP MCP server's tool definitions without invoking them:
@@ -541,9 +555,9 @@ jobs:
541
555
  runs-on: ubuntu-latest
542
556
  steps:
543
557
  - uses: actions/checkout@v5
544
- - uses: Esquetta/CodexPluginDoctor@v1.63.0
558
+ - uses: Esquetta/CodexPluginDoctor@v1.64.0
545
559
  with:
546
- version: "1.63.0"
560
+ version: "1.64.0"
547
561
  path: .
548
562
  runtime: "true"
549
563
  policy: codex-publish
@@ -0,0 +1,32 @@
1
+ import { type McpToolFileReport } from "./mcp-tool-file.js";
2
+ type DiffField = "inputSchema" | "outputSchema" | "description" | "title" | "annotations" | "other";
3
+ export interface McpToolDiffReport {
4
+ schemaVersion: 1;
5
+ scope: "tool-definitions-diff";
6
+ status: "pass" | "warn" | "incomplete" | "blocked";
7
+ before: McpToolFileReport;
8
+ after: McpToolFileReport;
9
+ comparison: {
10
+ complete: boolean;
11
+ reason: "input-blocked" | "input-incomplete" | "invalid-definitions" | "ambiguous-names" | null;
12
+ added: number | null;
13
+ removed: number | null;
14
+ changed: number | null;
15
+ unchanged: number | null;
16
+ };
17
+ coverage: {
18
+ comparison: "structural-only";
19
+ schema: "root-shape-only";
20
+ compatibility: "not-tested";
21
+ serverCatalog: "not-tested";
22
+ toolExecution: "not-tested";
23
+ };
24
+ changes: Array<{
25
+ kind: "added" | "removed" | "changed";
26
+ beforeToolIndex: number | null;
27
+ afterToolIndex: number | null;
28
+ fields: DiffField[];
29
+ }>;
30
+ }
31
+ export declare function compareMcpToolFiles(beforePath: string, afterPath: string): Promise<McpToolDiffReport>;
32
+ export {};
@@ -0,0 +1,150 @@
1
+ import { loadMcpToolFileSnapshot } from "./mcp-tool-file.js";
2
+ const FIXED_FIELDS = [
3
+ "inputSchema",
4
+ "outputSchema",
5
+ "description",
6
+ "title",
7
+ "annotations"
8
+ ];
9
+ const EXCLUDED_OTHER_FIELDS = new Set(["name", ...FIXED_FIELDS]);
10
+ function isJsonObject(value) {
11
+ return typeof value === "object" && value !== null && !Array.isArray(value);
12
+ }
13
+ function structurallyEqual(left, right) {
14
+ const pending = [[left, right]];
15
+ while (pending.length > 0) {
16
+ const [currentLeft, currentRight] = pending.pop();
17
+ if (Object.is(currentLeft, currentRight))
18
+ continue;
19
+ if (Array.isArray(currentLeft) || Array.isArray(currentRight)) {
20
+ if (!Array.isArray(currentLeft) || !Array.isArray(currentRight) || currentLeft.length !== currentRight.length)
21
+ return false;
22
+ for (let index = 0; index < currentLeft.length; index += 1)
23
+ pending.push([currentLeft[index], currentRight[index]]);
24
+ continue;
25
+ }
26
+ if (!isJsonObject(currentLeft) || !isJsonObject(currentRight))
27
+ return false;
28
+ const leftKeys = Object.keys(currentLeft);
29
+ const rightKeys = Object.keys(currentRight);
30
+ if (leftKeys.length !== rightKeys.length)
31
+ return false;
32
+ for (const key of leftKeys) {
33
+ if (!Object.hasOwn(currentRight, key))
34
+ return false;
35
+ pending.push([currentLeft[key], currentRight[key]]);
36
+ }
37
+ }
38
+ return true;
39
+ }
40
+ function filteredOther(definition) {
41
+ const other = Object.create(null);
42
+ for (const key of Object.keys(definition)) {
43
+ if (!EXCLUDED_OTHER_FIELDS.has(key))
44
+ other[key] = definition[key];
45
+ }
46
+ return other;
47
+ }
48
+ function changedFields(before, after) {
49
+ const fields = [];
50
+ for (const field of FIXED_FIELDS) {
51
+ if (!structurallyEqual(before[field], after[field]))
52
+ fields.push(field);
53
+ }
54
+ if (!structurallyEqual(filteredOther(before), filteredOther(after)))
55
+ fields.push("other");
56
+ return fields;
57
+ }
58
+ function toolDefinitions(tools) {
59
+ const definitions = new Map();
60
+ for (let offset = 0; offset < tools.length; offset += 1) {
61
+ const value = tools[offset];
62
+ if (!isJsonObject(value) || typeof value.name !== "string" || definitions.has(value.name))
63
+ return null;
64
+ definitions.set(value.name, { index: offset + 1, value });
65
+ }
66
+ return definitions;
67
+ }
68
+ function incompleteReport(before, after, status, reason) {
69
+ return {
70
+ schemaVersion: 1,
71
+ scope: "tool-definitions-diff",
72
+ status,
73
+ before,
74
+ after,
75
+ comparison: { complete: false, reason, added: null, removed: null, changed: null, unchanged: null },
76
+ coverage: {
77
+ comparison: "structural-only",
78
+ schema: "root-shape-only",
79
+ compatibility: "not-tested",
80
+ serverCatalog: "not-tested",
81
+ toolExecution: "not-tested"
82
+ },
83
+ changes: []
84
+ };
85
+ }
86
+ export async function compareMcpToolFiles(beforePath, afterPath) {
87
+ const [beforeSnapshot, afterSnapshot] = await Promise.all([
88
+ loadMcpToolFileSnapshot(beforePath),
89
+ loadMcpToolFileSnapshot(afterPath)
90
+ ]);
91
+ const { report: before } = beforeSnapshot;
92
+ const { report: after } = afterSnapshot;
93
+ if (before.status === "blocked" || after.status === "blocked") {
94
+ return incompleteReport(before, after, "blocked", "input-blocked");
95
+ }
96
+ if (!before.inspection.complete || !after.inspection.complete) {
97
+ return incompleteReport(before, after, "incomplete", "input-incomplete");
98
+ }
99
+ if (before.status === "fail" || after.status === "fail" || beforeSnapshot.tools === null || afterSnapshot.tools === null) {
100
+ return incompleteReport(before, after, "incomplete", "invalid-definitions");
101
+ }
102
+ const beforeDefinitions = toolDefinitions(beforeSnapshot.tools);
103
+ const afterDefinitions = toolDefinitions(afterSnapshot.tools);
104
+ if (beforeDefinitions === null || afterDefinitions === null) {
105
+ return incompleteReport(before, after, "incomplete", "ambiguous-names");
106
+ }
107
+ const changes = [];
108
+ let added = 0;
109
+ let removed = 0;
110
+ let changed = 0;
111
+ let unchanged = 0;
112
+ for (const [name, beforeDefinition] of beforeDefinitions) {
113
+ const afterDefinition = afterDefinitions.get(name);
114
+ if (afterDefinition === undefined) {
115
+ removed += 1;
116
+ changes.push({ kind: "removed", beforeToolIndex: beforeDefinition.index, afterToolIndex: null, fields: [] });
117
+ continue;
118
+ }
119
+ const fields = changedFields(beforeDefinition.value, afterDefinition.value);
120
+ if (fields.length === 0) {
121
+ unchanged += 1;
122
+ }
123
+ else {
124
+ changed += 1;
125
+ changes.push({ kind: "changed", beforeToolIndex: beforeDefinition.index, afterToolIndex: afterDefinition.index, fields });
126
+ }
127
+ }
128
+ for (const [name, afterDefinition] of afterDefinitions) {
129
+ if (!beforeDefinitions.has(name)) {
130
+ added += 1;
131
+ changes.push({ kind: "added", beforeToolIndex: null, afterToolIndex: afterDefinition.index, fields: [] });
132
+ }
133
+ }
134
+ return {
135
+ schemaVersion: 1,
136
+ scope: "tool-definitions-diff",
137
+ status: changes.length === 0 && before.status === "pass" && after.status === "pass" ? "pass" : "warn",
138
+ before,
139
+ after,
140
+ comparison: { complete: true, reason: null, added, removed, changed, unchanged },
141
+ coverage: {
142
+ comparison: "structural-only",
143
+ schema: "root-shape-only",
144
+ compatibility: "not-tested",
145
+ serverCatalog: "not-tested",
146
+ toolExecution: "not-tested"
147
+ },
148
+ changes
149
+ };
150
+ }
@@ -24,4 +24,9 @@ export interface McpToolFileReport {
24
24
  };
25
25
  findings: CatalogFinding[];
26
26
  }
27
+ export interface McpToolFileSnapshot {
28
+ report: McpToolFileReport;
29
+ tools: unknown[] | null;
30
+ }
31
+ export declare function loadMcpToolFileSnapshot(filePath: string): Promise<McpToolFileSnapshot>;
27
32
  export declare function inspectMcpToolFile(filePath: string): Promise<McpToolFileReport>;
@@ -4,6 +4,9 @@ import { inspectMcpToolDefinition } from "./mcp-tool-definition.js";
4
4
  const MAX_FILE_BYTES = 1024 * 1024;
5
5
  const MAX_TOOLS = 500;
6
6
  const MAX_FINDINGS = 100;
7
+ function snapshot(report, tools = null) {
8
+ return { report, tools };
9
+ }
7
10
  function isJsonObject(value) {
8
11
  return typeof value === "object" && value !== null && !Array.isArray(value);
9
12
  }
@@ -143,43 +146,43 @@ async function readBoundedRegularFile(filePath) {
143
146
  await fileHandle?.close().catch(() => undefined);
144
147
  }
145
148
  }
146
- export async function inspectMcpToolFile(filePath) {
149
+ export async function loadMcpToolFileSnapshot(filePath) {
147
150
  if (isBlockedOfflinePath(filePath)) {
148
- return blockedFileReport("offline-path-blocked", fileFinding("plugin.tools_file.path.blocked", "fail", "The local tool definition path is not permitted for offline inspection.", "The offline tool definition inspection does not access remote or device paths.", "Provide a local regular file path."));
151
+ return snapshot(blockedFileReport("offline-path-blocked", fileFinding("plugin.tools_file.path.blocked", "fail", "The local tool definition path is not permitted for offline inspection.", "The offline tool definition inspection does not access remote or device paths.", "Provide a local regular file path.")));
149
152
  }
150
153
  const file = await readBoundedRegularFile(filePath);
151
154
  if (file.kind === "unavailable") {
152
- return incompleteFileReport(file.bytesRead, "file-unavailable", fileFinding("plugin.tools_file.file.unavailable", "fail", "The local tool definition file could not be read.", "The offline tool definition inspection could not start.", "Provide a readable local regular file."));
155
+ return snapshot(incompleteFileReport(file.bytesRead, "file-unavailable", fileFinding("plugin.tools_file.file.unavailable", "fail", "The local tool definition file could not be read.", "The offline tool definition inspection could not start.", "Provide a readable local regular file.")));
153
156
  }
154
157
  if (file.kind === "too-large") {
155
- return incompleteFileReport(file.bytesRead, "file-size-limit", fileFinding("plugin.tools_file.file.too_large", "fail", "The local tool definition file exceeds the one MiB inspection limit.", "The offline tool definition inspection cannot safely read this file within its byte limit.", "Provide a tool definition file no larger than one MiB."));
158
+ return snapshot(incompleteFileReport(file.bytesRead, "file-size-limit", fileFinding("plugin.tools_file.file.too_large", "fail", "The local tool definition file exceeds the one MiB inspection limit.", "The offline tool definition inspection cannot safely read this file within its byte limit.", "Provide a tool definition file no larger than one MiB.")));
156
159
  }
157
160
  if (file.kind === "changed") {
158
- return incompleteFileReport(file.bytesRead, "file-changed", fileFinding("plugin.tools_file.file.changed", "fail", "The local tool definition file changed during inspection.", "The offline tool definition inspection cannot rely on an inconsistent file snapshot.", "Retry after the file is stable."));
161
+ return snapshot(incompleteFileReport(file.bytesRead, "file-changed", fileFinding("plugin.tools_file.file.changed", "fail", "The local tool definition file changed during inspection.", "The offline tool definition inspection cannot rely on an inconsistent file snapshot.", "Retry after the file is stable.")));
159
162
  }
160
163
  if (file.kind === "symlink") {
161
- return blockedFileReport("final-symlink", fileFinding("plugin.tools_file.file.symlink", "fail", "The local tool definition target is a symbolic link.", "The offline tool definition inspection does not follow final symbolic links.", "Provide a regular file directly."));
164
+ return snapshot(blockedFileReport("final-symlink", fileFinding("plugin.tools_file.file.symlink", "fail", "The local tool definition target is a symbolic link.", "The offline tool definition inspection does not follow final symbolic links.", "Provide a regular file directly.")));
162
165
  }
163
166
  if (file.kind === "not-regular") {
164
- return blockedFileReport("non-regular-file", fileFinding("plugin.tools_file.file.not_regular", "fail", "The local tool definition target is not a regular file.", "The offline tool definition inspection reads only bounded regular files.", "Provide a regular file directly."));
167
+ return snapshot(blockedFileReport("non-regular-file", fileFinding("plugin.tools_file.file.not_regular", "fail", "The local tool definition target is not a regular file.", "The offline tool definition inspection reads only bounded regular files.", "Provide a regular file directly.")));
165
168
  }
166
169
  let decoded;
167
170
  try {
168
171
  decoded = new TextDecoder("utf-8", { fatal: true }).decode(file.content);
169
172
  }
170
173
  catch {
171
- return incompleteFileReport(file.bytesRead, "invalid-utf8", fileFinding("plugin.tools_file.encoding.invalid", "fail", "The local tool definition file is not valid UTF-8.", "The offline tool definition inspection cannot parse the file safely.", "Save the tools/list response as UTF-8 JSON."));
174
+ return snapshot(incompleteFileReport(file.bytesRead, "invalid-utf8", fileFinding("plugin.tools_file.encoding.invalid", "fail", "The local tool definition file is not valid UTF-8.", "The offline tool definition inspection cannot parse the file safely.", "Save the tools/list response as UTF-8 JSON.")));
172
175
  }
173
176
  let value;
174
177
  try {
175
178
  value = JSON.parse(decoded);
176
179
  }
177
180
  catch {
178
- return incompleteFileReport(file.bytesRead, "invalid-json", fileFinding("plugin.tools_file.json.invalid", "fail", "The local tool definition file is not valid JSON.", "The offline tool definition inspection cannot parse the file safely.", "Provide one UTF-8 JSON-RPC tools/list response."));
181
+ return snapshot(incompleteFileReport(file.bytesRead, "invalid-json", fileFinding("plugin.tools_file.json.invalid", "fail", "The local tool definition file is not valid JSON.", "The offline tool definition inspection cannot parse the file safely.", "Provide one UTF-8 JSON-RPC tools/list response.")));
179
182
  }
180
183
  const parsed = parseToolsResult(value);
181
184
  if (parsed === null) {
182
- return incompleteFileReport(file.bytesRead, "response-invalid", fileFinding("plugin.tools_file.response.invalid", "fail", "The local JSON value is not a supported complete tools/list response.", "The offline tool definition inspection cannot identify a valid tool list.", "Provide one JSON-RPC 2.0 complete tools/list result with valid cache metadata."));
185
+ return snapshot(incompleteFileReport(file.bytesRead, "response-invalid", fileFinding("plugin.tools_file.response.invalid", "fail", "The local JSON value is not a supported complete tools/list response.", "The offline tool definition inspection cannot identify a valid tool list.", "Provide one JSON-RPC 2.0 complete tools/list result with valid cache metadata.")));
183
186
  }
184
187
  const findings = [];
185
188
  const names = new Map();
@@ -225,17 +228,20 @@ export async function inspectMcpToolFile(filePath) {
225
228
  if (reason === null && parsed.hasNextCursor)
226
229
  reason = "more-pages-present";
227
230
  if (reason !== null) {
228
- return report(file.bytesRead, "incomplete", {
231
+ return snapshot(report(file.bytesRead, "incomplete", {
229
232
  complete: false,
230
233
  toolsChecked,
231
234
  hasNextCursor: parsed.hasNextCursor,
232
235
  reason
233
- }, findings);
236
+ }, findings), parsed.tools);
234
237
  }
235
- return report(file.bytesRead, finalStatus(findings), {
238
+ return snapshot(report(file.bytesRead, finalStatus(findings), {
236
239
  complete: true,
237
240
  toolsChecked,
238
241
  hasNextCursor: false,
239
242
  reason: null
240
- }, findings);
243
+ }, findings), parsed.tools);
244
+ }
245
+ export async function inspectMcpToolFile(filePath) {
246
+ return (await loadMcpToolFileSnapshot(filePath)).report;
241
247
  }
@@ -767,6 +767,56 @@ const publicSchemaDefinitions = [
767
767
  }
768
768
  }
769
769
  ];
770
+ const toolFileReportDefinition = publicSchemaDefinitions.find((definition) => definition.id === "doctor.tools.file.json");
771
+ const embeddedToolFileReportSchema = {
772
+ type: "object",
773
+ required: toolFileReportDefinition.required,
774
+ properties: toolFileReportDefinition.properties,
775
+ additionalProperties: true
776
+ };
777
+ publicSchemaDefinitions.push({
778
+ id: "doctor.tools.diff.json",
779
+ command: "codex-plugin-doctor doctor tools-diff --before <path> --after <path> --json",
780
+ required: ["schemaVersion", "scope", "status", "before", "after", "comparison", "coverage", "changes"],
781
+ properties: {
782
+ schemaVersion: { const: 1 },
783
+ scope: { const: "tool-definitions-diff" },
784
+ status: { enum: ["pass", "warn", "incomplete", "blocked"] },
785
+ before: embeddedToolFileReportSchema,
786
+ after: embeddedToolFileReportSchema,
787
+ comparison: {
788
+ type: "object", required: ["complete", "reason", "added", "removed", "changed", "unchanged"],
789
+ properties: {
790
+ complete: { type: "boolean" },
791
+ reason: { enum: [null, "input-blocked", "input-incomplete", "invalid-definitions", "ambiguous-names"] },
792
+ added: { type: ["integer", "null"], minimum: 0, maximum: 500 },
793
+ removed: { type: ["integer", "null"], minimum: 0, maximum: 500 },
794
+ changed: { type: ["integer", "null"], minimum: 0, maximum: 500 },
795
+ unchanged: { type: ["integer", "null"], minimum: 0, maximum: 500 }
796
+ }, additionalProperties: false
797
+ },
798
+ coverage: {
799
+ type: "object", required: ["comparison", "schema", "compatibility", "serverCatalog", "toolExecution"],
800
+ properties: {
801
+ comparison: { const: "structural-only" }, schema: { const: "root-shape-only" },
802
+ compatibility: { const: "not-tested" }, serverCatalog: { const: "not-tested" },
803
+ toolExecution: { const: "not-tested" }
804
+ }, additionalProperties: false
805
+ },
806
+ changes: {
807
+ type: "array", maxItems: 1000,
808
+ items: {
809
+ type: "object", required: ["kind", "beforeToolIndex", "afterToolIndex", "fields"],
810
+ properties: {
811
+ kind: { enum: ["added", "removed", "changed"] },
812
+ beforeToolIndex: { type: ["integer", "null"], minimum: 1, maximum: 500 },
813
+ afterToolIndex: { type: ["integer", "null"], minimum: 1, maximum: 500 },
814
+ fields: { type: "array", maxItems: 6, uniqueItems: true, items: { enum: ["inputSchema", "outputSchema", "description", "title", "annotations", "other"] } }
815
+ }, additionalProperties: false
816
+ }
817
+ }
818
+ }
819
+ });
770
820
  function sha256(value) {
771
821
  return `sha256:${createHash("sha256").update(value).digest("hex")}`;
772
822
  }
@@ -0,0 +1,2 @@
1
+ import type { McpToolDiffReport } from "../core/mcp-tool-diff.js";
2
+ export declare function renderMcpToolDiffReport(report: McpToolDiffReport): string;
@@ -0,0 +1,41 @@
1
+ export function renderMcpToolDiffReport(report) {
2
+ const comparison = report.comparison;
3
+ return [
4
+ "Offline MCP Tool Diff",
5
+ "=====================",
6
+ `Status: ${report.status}`,
7
+ `Scope: ${report.scope}`,
8
+ `Comparison complete: ${comparison.complete}`,
9
+ ...(comparison.reason === null ? [] : [`Reason: ${comparison.reason}`]),
10
+ `Added: ${comparison.added ?? "not compared"}`,
11
+ `Removed: ${comparison.removed ?? "not compared"}`,
12
+ `Changed: ${comparison.changed ?? "not compared"}`,
13
+ `Unchanged: ${comparison.unchanged ?? "not compared"}`,
14
+ `Comparison coverage: ${report.coverage.comparison}`,
15
+ `Schema coverage: ${report.coverage.schema}`,
16
+ `Compatibility: ${report.coverage.compatibility}`,
17
+ `Server catalog: ${report.coverage.serverCatalog}`,
18
+ `Tool execution: ${report.coverage.toolExecution}`,
19
+ "Only the supplied responses are compared. A change does not establish incompatibility; an unchanged result does not establish compatibility.",
20
+ ...["before", "after"].flatMap((side) => {
21
+ const source = report[side];
22
+ const label = side === "before" ? "Before" : "After";
23
+ return [
24
+ `${label} input: ${source.status}; tools checked: ${source.inspection.toolsChecked}; bytes read: ${source.source.bytesRead}`,
25
+ ...(source.inspection.reason === null ? [] : [`${label} reason: ${source.inspection.reason}`]),
26
+ ...source.findings.map((finding) => {
27
+ const location = finding.location;
28
+ const locator = location ? ` [${side} tool ${location.toolIndex}${location.schema ? `, ${location.schema}` : ""}${location.relatedToolIndex ? `, related tool ${location.relatedToolIndex}` : ""}]` : "";
29
+ return `${label} ${finding.severity.toUpperCase()} ${finding.id}${locator}: ${finding.message}\n ${finding.suggestedFix}`;
30
+ })
31
+ ];
32
+ }),
33
+ ...report.changes.map((change) => {
34
+ const locations = [
35
+ ...(change.beforeToolIndex === null ? [] : [`before tool ${change.beforeToolIndex}`]),
36
+ ...(change.afterToolIndex === null ? [] : [`after tool ${change.afterToolIndex}`])
37
+ ];
38
+ return `${change.kind.toUpperCase()} [${locations.join(" -> ")}]${change.fields.length ? `: ${change.fields.join(", ")}` : ""}`;
39
+ })
40
+ ].join("\n");
41
+ }
package/dist/run-cli.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { discoverMcpServer } from "./core/mcp-discovery.js";
2
2
  import { inspectMcpToolCatalog } from "./core/mcp-tool-catalog.js";
3
3
  import { inspectMcpToolFile } from "./core/mcp-tool-file.js";
4
+ import { compareMcpToolFiles } from "./core/mcp-tool-diff.js";
4
5
  import { buildCorpusQualityMetricsDiffReport, buildCorpusQualityMetricsReport } from "./core/corpus-quality-metrics.js";
5
6
  import { runCheck } from "./index.js";
6
7
  import { writeRawDoctorConfig } from "./index.js";
@@ -16,6 +17,7 @@ export interface CliTerminalContext {
16
17
  platform?: NodeJS.Platform;
17
18
  }
18
19
  export interface RunCliOptions {
20
+ compareMcpToolFilesImpl?: typeof compareMcpToolFiles;
19
21
  inspectMcpToolFileImpl?: typeof inspectMcpToolFile;
20
22
  inspectMcpToolCatalogImpl?: typeof inspectMcpToolCatalog;
21
23
  discoverMcpServerImpl?: typeof discoverMcpServer;
package/dist/run-cli.js CHANGED
@@ -24,6 +24,8 @@ import { buildSubmissionPreflight } from "./core/submission-preflight.js";
24
24
  import { discoverMcpServer } from "./core/mcp-discovery.js";
25
25
  import { inspectMcpToolCatalog } from "./core/mcp-tool-catalog.js";
26
26
  import { inspectMcpToolFile } from "./core/mcp-tool-file.js";
27
+ import { compareMcpToolFiles } from "./core/mcp-tool-diff.js";
28
+ import { renderMcpToolDiffReport } from "./reporting/render-mcp-tool-diff-report.js";
27
29
  import { renderMcpToolFileReport } from "./reporting/render-mcp-tool-file-report.js";
28
30
  import { renderMcpToolCatalogReport } from "./reporting/render-mcp-tool-catalog-report.js";
29
31
  import { renderMcpDiscoveryReport } from "./reporting/render-mcp-discovery-report.js";
@@ -105,6 +107,7 @@ function writeExactStdout(io, message) {
105
107
  }
106
108
  class CliUsageError extends Error {
107
109
  }
110
+ const toolsDiffUsage = "Usage: codex-plugin-doctor doctor tools-diff --before <path> --after <path> [--json]";
108
111
  const toolsFileUsage = "Usage: codex-plugin-doctor doctor tools-file <path> [--json]";
109
112
  const toolsUsage = "Usage: codex-plugin-doctor doctor tools <url> --allow-network [--allow-local-network] [--json]";
110
113
  const discoveryUsage = "Usage: codex-plugin-doctor doctor discover <url> --allow-network [--allow-local-network] [--json]";
@@ -193,6 +196,7 @@ function printUsage(io) {
193
196
  io.writeStderr(discoveryUsage);
194
197
  io.writeStderr(toolsUsage);
195
198
  io.writeStderr(toolsFileUsage);
199
+ io.writeStderr(toolsDiffUsage);
196
200
  io.writeStderr("Registry readiness: codex-plugin-doctor registry check <server.json|directory> [--json] [--output <path>] [--require-registry-readiness]\n"
197
201
  + " codex-plugin-doctor registry inspect <server-name> --allow-network [--json] [--output <path>] [--require-registry-readiness]\n"
198
202
  + "Registry publication preflight: codex-plugin-doctor registry preflight <server.json|directory> [--allow-network] [--json] [--output <path>] [--require-publish-ready]");
@@ -1203,6 +1207,45 @@ export async function runCli(args, io = defaultIo, options = {}) {
1203
1207
  return 0;
1204
1208
  }
1205
1209
  if (command === "doctor") {
1210
+ if (maybePath === "tools-diff") {
1211
+ if (remainingArgs.length === 1 && remainingArgs[0] === "--help") {
1212
+ io.writeStdout(`${toolsDiffUsage}\nCompares two saved MCP tools/list responses offline. Structural changes only; compatibility is not tested.`);
1213
+ return 0;
1214
+ }
1215
+ let beforePath;
1216
+ let afterPath;
1217
+ let json = false;
1218
+ let invalid = false;
1219
+ for (let index = 0; index < remainingArgs.length; index += 1) {
1220
+ const flag = remainingArgs[index];
1221
+ if (flag === "--json" && !json) {
1222
+ json = true;
1223
+ }
1224
+ else if ((flag === "--before" && beforePath === undefined) || (flag === "--after" && afterPath === undefined)) {
1225
+ const value = remainingArgs[index + 1];
1226
+ if (!value || value.startsWith("-")) {
1227
+ invalid = true;
1228
+ break;
1229
+ }
1230
+ if (flag === "--before")
1231
+ beforePath = value;
1232
+ else
1233
+ afterPath = value;
1234
+ index += 1;
1235
+ }
1236
+ else {
1237
+ invalid = true;
1238
+ break;
1239
+ }
1240
+ }
1241
+ if (invalid || !beforePath || !afterPath) {
1242
+ io.writeStderr(`Invalid comparison arguments. ${toolsDiffUsage}`);
1243
+ return 2;
1244
+ }
1245
+ const report = await (options.compareMcpToolFilesImpl ?? compareMcpToolFiles)(beforePath, afterPath);
1246
+ io.writeStdout(json ? JSON.stringify(report, null, 2) : renderMcpToolDiffReport(report));
1247
+ return report.status === "pass" ? 0 : report.status === "warn" ? 1 : 2;
1248
+ }
1206
1249
  if (maybePath === "tools-file") {
1207
1250
  if (remainingArgs.length === 1 && remainingArgs[0] === "--help") {
1208
1251
  io.writeStdout(`${toolsFileUsage}\nInspects one saved UTF-8 MCP tools/list response. No server connection or tool execution.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-plugin-doctor",
3
- "version": "1.63.0",
3
+ "version": "1.64.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",