codex-plugin-doctor 1.62.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 +58 -31
- package/dist/core/mcp-tool-diff.d.ts +32 -0
- package/dist/core/mcp-tool-diff.js +150 -0
- package/dist/core/mcp-tool-file.d.ts +32 -0
- package/dist/core/mcp-tool-file.js +247 -0
- package/dist/core/output-contract.js +101 -0
- package/dist/reporting/render-mcp-tool-diff-report.d.ts +2 -0
- package/dist/reporting/render-mcp-tool-diff-report.js +41 -0
- package/dist/reporting/render-mcp-tool-file-report.d.ts +2 -0
- package/dist/reporting/render-mcp-tool-file-report.js +26 -0
- package/dist/run-cli.d.ts +4 -0
- package/dist/run-cli.js +64 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -81,35 +81,62 @@ Runtime MCP validation with `--runtime`:
|
|
|
81
81
|
- optional runtime approval gating with a precomputed `doctor runtime-plan` digest
|
|
82
82
|
- optional Docker isolation for local Node.js stdio servers with `--sandbox docker`
|
|
83
83
|
|
|
84
|
-
### MCP Protocol Discovery
|
|
85
|
-
|
|
86
|
-
Inspect an HTTP MCP endpoint's advertised protocol versions using a bounded MCP
|
|
87
|
-
2026-07-28 `server/discover` request:
|
|
88
|
-
|
|
89
|
-
```bash
|
|
90
|
-
codex-plugin-doctor doctor discover https://mcp.example.com/mcp --allow-network
|
|
91
|
-
codex-plugin-doctor doctor discover https://mcp.example.com/mcp --allow-network --json
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
This is an opt-in discovery-only check. A successful result validates the discovery
|
|
95
|
-
response, not full protocol conformance, tool behavior, or compatibility with a
|
|
96
|
-
particular client. Existing `check --runtime` remains on its 2025-11-25 baseline.
|
|
97
|
-
See [MCP Discovery](docs/guides/mcp-discovery.md) for consent, scope, and exit codes.
|
|
98
|
-
|
|
99
|
-
###
|
|
100
|
-
|
|
101
|
-
Inspect a
|
|
102
|
-
|
|
103
|
-
```bash
|
|
104
|
-
codex-plugin-doctor doctor tools
|
|
105
|
-
```
|
|
106
|
-
|
|
107
|
-
This
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
84
|
+
### MCP Protocol Discovery
|
|
85
|
+
|
|
86
|
+
Inspect an HTTP MCP endpoint's advertised protocol versions using a bounded MCP
|
|
87
|
+
2026-07-28 `server/discover` request:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
codex-plugin-doctor doctor discover https://mcp.example.com/mcp --allow-network
|
|
91
|
+
codex-plugin-doctor doctor discover https://mcp.example.com/mcp --allow-network --json
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
This is an opt-in discovery-only check. A successful result validates the discovery
|
|
95
|
+
response, not full protocol conformance, tool behavior, or compatibility with a
|
|
96
|
+
particular client. Existing `check --runtime` remains on its 2025-11-25 baseline.
|
|
97
|
+
See [MCP Discovery](docs/guides/mcp-discovery.md) for consent, scope, and exit codes.
|
|
98
|
+
|
|
99
|
+
### Offline MCP Tool Definitions
|
|
100
|
+
|
|
101
|
+
Inspect a saved MCP 2026-07-28 `tools/list` response without contacting a server:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
codex-plugin-doctor doctor tools-file ./tools-list.json --json
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
This checks the supplied tool definitions and root-schema shapes within fixed file,
|
|
108
|
+
tool, and finding limits. It does not establish server inventory completeness or
|
|
109
|
+
execute tools. See [Offline MCP Tool Definitions](docs/guides/offline-tool-definitions.md)
|
|
110
|
+
for the accepted input format, coverage, and exit codes.
|
|
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
|
+
|
|
126
|
+
### HTTP MCP Tool Catalog
|
|
127
|
+
|
|
128
|
+
Inspect a modern HTTP MCP server's tool definitions without invoking them:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
codex-plugin-doctor doctor tools https://mcp.example.com/mcp --allow-network --json
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
This opt-in command performs discovery and bounded `tools/list` pagination. It
|
|
135
|
+
reports tool-definition and root-schema shape findings, explicit completion state,
|
|
136
|
+
and partial inspection limits. It does not claim full schema validation, tool
|
|
137
|
+
execution, or client compatibility. Existing `doctor discover` behavior is unchanged.
|
|
138
|
+
See [HTTP MCP Tool Catalog](docs/guides/mcp-tool-catalog.md).
|
|
139
|
+
|
|
113
140
|
### Remote MCP Readiness
|
|
114
141
|
|
|
115
142
|
Remote MCP runtime probing is disabled until you explicitly pass `--runtime --allow-network`. Loopback endpoints also require `--allow-local-network`:
|
|
@@ -528,9 +555,9 @@ jobs:
|
|
|
528
555
|
runs-on: ubuntu-latest
|
|
529
556
|
steps:
|
|
530
557
|
- uses: actions/checkout@v5
|
|
531
|
-
- uses: Esquetta/CodexPluginDoctor@v1.
|
|
558
|
+
- uses: Esquetta/CodexPluginDoctor@v1.64.0
|
|
532
559
|
with:
|
|
533
|
-
version: "1.
|
|
560
|
+
version: "1.64.0"
|
|
534
561
|
path: .
|
|
535
562
|
runtime: "true"
|
|
536
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
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { CatalogFinding } from "./mcp-tool-definition.js";
|
|
2
|
+
export interface McpToolFileReport {
|
|
3
|
+
schemaVersion: 1;
|
|
4
|
+
scope: "tool-definitions-file";
|
|
5
|
+
status: "pass" | "warn" | "fail" | "incomplete" | "blocked";
|
|
6
|
+
source: {
|
|
7
|
+
kind: "file";
|
|
8
|
+
format: "mcp-tools-list-response";
|
|
9
|
+
bytesRead: number;
|
|
10
|
+
};
|
|
11
|
+
inspection: {
|
|
12
|
+
complete: boolean;
|
|
13
|
+
toolsChecked: number;
|
|
14
|
+
hasNextCursor: boolean | null;
|
|
15
|
+
reason: string | null;
|
|
16
|
+
};
|
|
17
|
+
coverage: {
|
|
18
|
+
schema: "root-shape-only";
|
|
19
|
+
discovery: "not-tested";
|
|
20
|
+
serverCatalog: "not-tested";
|
|
21
|
+
toolExecution: "not-tested";
|
|
22
|
+
fullConformance: "not-tested";
|
|
23
|
+
customHeaderAnnotations: "not-tested";
|
|
24
|
+
};
|
|
25
|
+
findings: CatalogFinding[];
|
|
26
|
+
}
|
|
27
|
+
export interface McpToolFileSnapshot {
|
|
28
|
+
report: McpToolFileReport;
|
|
29
|
+
tools: unknown[] | null;
|
|
30
|
+
}
|
|
31
|
+
export declare function loadMcpToolFileSnapshot(filePath: string): Promise<McpToolFileSnapshot>;
|
|
32
|
+
export declare function inspectMcpToolFile(filePath: string): Promise<McpToolFileReport>;
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, open } from "node:fs/promises";
|
|
3
|
+
import { inspectMcpToolDefinition } from "./mcp-tool-definition.js";
|
|
4
|
+
const MAX_FILE_BYTES = 1024 * 1024;
|
|
5
|
+
const MAX_TOOLS = 500;
|
|
6
|
+
const MAX_FINDINGS = 100;
|
|
7
|
+
function snapshot(report, tools = null) {
|
|
8
|
+
return { report, tools };
|
|
9
|
+
}
|
|
10
|
+
function isJsonObject(value) {
|
|
11
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
12
|
+
}
|
|
13
|
+
function hasOwn(object, key) {
|
|
14
|
+
return Object.hasOwn(object, key);
|
|
15
|
+
}
|
|
16
|
+
function sameFile(left, right) {
|
|
17
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
18
|
+
}
|
|
19
|
+
function sameSnapshot(left, right) {
|
|
20
|
+
return sameFile(left, right)
|
|
21
|
+
&& left.size === right.size
|
|
22
|
+
&& left.mtimeMs === right.mtimeMs
|
|
23
|
+
&& left.ctimeMs === right.ctimeMs;
|
|
24
|
+
}
|
|
25
|
+
function isBlockedOfflinePath(filePath) {
|
|
26
|
+
return /^[A-Za-z][A-Za-z0-9+.-]+:/u.test(filePath)
|
|
27
|
+
|| /^[A-Za-z]:\/\//u.test(filePath)
|
|
28
|
+
|| /^[\\/]{2}/u.test(filePath);
|
|
29
|
+
}
|
|
30
|
+
function baseReport(bytesRead) {
|
|
31
|
+
return {
|
|
32
|
+
schemaVersion: 1,
|
|
33
|
+
scope: "tool-definitions-file",
|
|
34
|
+
source: { kind: "file", format: "mcp-tools-list-response", bytesRead },
|
|
35
|
+
coverage: {
|
|
36
|
+
schema: "root-shape-only",
|
|
37
|
+
discovery: "not-tested",
|
|
38
|
+
serverCatalog: "not-tested",
|
|
39
|
+
toolExecution: "not-tested",
|
|
40
|
+
fullConformance: "not-tested",
|
|
41
|
+
customHeaderAnnotations: "not-tested"
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function report(bytesRead, status, inspection, findings) {
|
|
46
|
+
return { ...baseReport(bytesRead), status, inspection, findings };
|
|
47
|
+
}
|
|
48
|
+
function fileFinding(id, severity, message, impact, suggestedFix) {
|
|
49
|
+
return { id, severity, message, impact, suggestedFix };
|
|
50
|
+
}
|
|
51
|
+
function incompleteFileReport(bytesRead, reason, finding) {
|
|
52
|
+
return report(bytesRead, "incomplete", {
|
|
53
|
+
complete: false,
|
|
54
|
+
toolsChecked: 0,
|
|
55
|
+
hasNextCursor: null,
|
|
56
|
+
reason
|
|
57
|
+
}, [finding]);
|
|
58
|
+
}
|
|
59
|
+
function blockedFileReport(reason, finding) {
|
|
60
|
+
return report(0, "blocked", {
|
|
61
|
+
complete: false,
|
|
62
|
+
toolsChecked: 0,
|
|
63
|
+
hasNextCursor: null,
|
|
64
|
+
reason
|
|
65
|
+
}, [finding]);
|
|
66
|
+
}
|
|
67
|
+
function finalStatus(findings) {
|
|
68
|
+
if (findings.some((finding) => finding.severity === "fail"))
|
|
69
|
+
return "fail";
|
|
70
|
+
return findings.some((finding) => finding.severity === "warn") ? "warn" : "pass";
|
|
71
|
+
}
|
|
72
|
+
function parseToolsResult(value) {
|
|
73
|
+
if (!isJsonObject(value)
|
|
74
|
+
|| value.jsonrpc !== "2.0"
|
|
75
|
+
|| !hasOwn(value, "id")
|
|
76
|
+
|| !(typeof value.id === "string" || (typeof value.id === "number" && Number.isFinite(value.id)))
|
|
77
|
+
|| !hasOwn(value, "result")
|
|
78
|
+
|| hasOwn(value, "error")
|
|
79
|
+
|| !isJsonObject(value.result)
|
|
80
|
+
|| value.result.resultType !== "complete"
|
|
81
|
+
|| !Array.isArray(value.result.tools)
|
|
82
|
+
|| typeof value.result.ttlMs !== "number"
|
|
83
|
+
|| !Number.isFinite(value.result.ttlMs)
|
|
84
|
+
|| value.result.ttlMs < 0
|
|
85
|
+
|| (value.result.cacheScope !== "public" && value.result.cacheScope !== "private")) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
const hasNextCursor = hasOwn(value.result, "nextCursor");
|
|
89
|
+
if (hasNextCursor && typeof value.result.nextCursor !== "string")
|
|
90
|
+
return null;
|
|
91
|
+
return { tools: value.result.tools, hasNextCursor };
|
|
92
|
+
}
|
|
93
|
+
async function readBoundedRegularFile(filePath) {
|
|
94
|
+
let initial;
|
|
95
|
+
try {
|
|
96
|
+
initial = await lstat(filePath);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return { kind: "unavailable", bytesRead: 0 };
|
|
100
|
+
}
|
|
101
|
+
if (initial.isSymbolicLink())
|
|
102
|
+
return { kind: "symlink", bytesRead: 0 };
|
|
103
|
+
if (!initial.isFile())
|
|
104
|
+
return { kind: "not-regular", bytesRead: 0 };
|
|
105
|
+
if (!Number.isSafeInteger(initial.size) || initial.size < 0 || initial.size > MAX_FILE_BYTES) {
|
|
106
|
+
return { kind: "too-large", bytesRead: 0 };
|
|
107
|
+
}
|
|
108
|
+
const flags = process.platform === "win32"
|
|
109
|
+
? constants.O_RDONLY
|
|
110
|
+
: constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK;
|
|
111
|
+
let fileHandle = null;
|
|
112
|
+
let bytesRead = 0;
|
|
113
|
+
try {
|
|
114
|
+
fileHandle = await open(filePath, flags);
|
|
115
|
+
const opened = await fileHandle.stat();
|
|
116
|
+
const current = await lstat(filePath);
|
|
117
|
+
if (!opened.isFile() || current.isSymbolicLink() || !current.isFile() || !sameSnapshot(initial, opened) || !sameSnapshot(opened, current)) {
|
|
118
|
+
return { kind: "changed", bytesRead };
|
|
119
|
+
}
|
|
120
|
+
if (!Number.isSafeInteger(opened.size) || opened.size < 0 || opened.size > MAX_FILE_BYTES) {
|
|
121
|
+
return { kind: "too-large", bytesRead };
|
|
122
|
+
}
|
|
123
|
+
const content = Buffer.alloc(opened.size);
|
|
124
|
+
while (bytesRead < content.length) {
|
|
125
|
+
const read = await fileHandle.read(content, bytesRead, content.length - bytesRead, bytesRead);
|
|
126
|
+
if (read.bytesRead === 0)
|
|
127
|
+
return { kind: "changed", bytesRead };
|
|
128
|
+
bytesRead += read.bytesRead;
|
|
129
|
+
}
|
|
130
|
+
const probe = Buffer.alloc(1);
|
|
131
|
+
const extra = await fileHandle.read(probe, 0, 1, bytesRead);
|
|
132
|
+
bytesRead += extra.bytesRead;
|
|
133
|
+
if (extra.bytesRead !== 0)
|
|
134
|
+
return { kind: "changed", bytesRead };
|
|
135
|
+
const completed = await fileHandle.stat();
|
|
136
|
+
const final = await lstat(filePath);
|
|
137
|
+
if (!final.isFile() || final.isSymbolicLink() || !sameSnapshot(opened, completed) || !sameSnapshot(completed, final)) {
|
|
138
|
+
return { kind: "changed", bytesRead };
|
|
139
|
+
}
|
|
140
|
+
return { kind: "content", content, bytesRead };
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return { kind: "unavailable", bytesRead };
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
await fileHandle?.close().catch(() => undefined);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
export async function loadMcpToolFileSnapshot(filePath) {
|
|
150
|
+
if (isBlockedOfflinePath(filePath)) {
|
|
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.")));
|
|
152
|
+
}
|
|
153
|
+
const file = await readBoundedRegularFile(filePath);
|
|
154
|
+
if (file.kind === "unavailable") {
|
|
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.")));
|
|
156
|
+
}
|
|
157
|
+
if (file.kind === "too-large") {
|
|
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.")));
|
|
159
|
+
}
|
|
160
|
+
if (file.kind === "changed") {
|
|
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.")));
|
|
162
|
+
}
|
|
163
|
+
if (file.kind === "symlink") {
|
|
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.")));
|
|
165
|
+
}
|
|
166
|
+
if (file.kind === "not-regular") {
|
|
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.")));
|
|
168
|
+
}
|
|
169
|
+
let decoded;
|
|
170
|
+
try {
|
|
171
|
+
decoded = new TextDecoder("utf-8", { fatal: true }).decode(file.content);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
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.")));
|
|
175
|
+
}
|
|
176
|
+
let value;
|
|
177
|
+
try {
|
|
178
|
+
value = JSON.parse(decoded);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
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.")));
|
|
182
|
+
}
|
|
183
|
+
const parsed = parseToolsResult(value);
|
|
184
|
+
if (parsed === null) {
|
|
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.")));
|
|
186
|
+
}
|
|
187
|
+
const findings = [];
|
|
188
|
+
const names = new Map();
|
|
189
|
+
let reason = null;
|
|
190
|
+
let toolsChecked = 0;
|
|
191
|
+
const addFindings = (items) => {
|
|
192
|
+
const remaining = MAX_FINDINGS - findings.length;
|
|
193
|
+
if (items.length > remaining) {
|
|
194
|
+
findings.push(...items.slice(0, remaining));
|
|
195
|
+
reason = "finding-limit";
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
findings.push(...items);
|
|
199
|
+
return true;
|
|
200
|
+
};
|
|
201
|
+
for (const tool of parsed.tools) {
|
|
202
|
+
if (toolsChecked >= MAX_TOOLS) {
|
|
203
|
+
reason = "tool-limit";
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
toolsChecked += 1;
|
|
207
|
+
const location = { page: 1, toolIndex: toolsChecked };
|
|
208
|
+
if (!addFindings(inspectMcpToolDefinition(tool, location)))
|
|
209
|
+
break;
|
|
210
|
+
if (isJsonObject(tool) && typeof tool.name === "string") {
|
|
211
|
+
const previous = names.get(tool.name);
|
|
212
|
+
if (previous !== undefined && !addFindings([fileFinding("plugin.catalog.tool.name.duplicate", "warn", "Two enumerated tools use the same name.", "Duplicate tool names can make client selection ambiguous.", "Use a unique MCP tool name for each definition.")])) {
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
if (previous !== undefined) {
|
|
216
|
+
findings[findings.length - 1] = {
|
|
217
|
+
...findings[findings.length - 1],
|
|
218
|
+
location: { ...location, relatedToolIndex: previous }
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
names.set(tool.name, toolsChecked);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (reason === null && parsed.tools.length > MAX_TOOLS)
|
|
227
|
+
reason = "tool-limit";
|
|
228
|
+
if (reason === null && parsed.hasNextCursor)
|
|
229
|
+
reason = "more-pages-present";
|
|
230
|
+
if (reason !== null) {
|
|
231
|
+
return snapshot(report(file.bytesRead, "incomplete", {
|
|
232
|
+
complete: false,
|
|
233
|
+
toolsChecked,
|
|
234
|
+
hasNextCursor: parsed.hasNextCursor,
|
|
235
|
+
reason
|
|
236
|
+
}, findings), parsed.tools);
|
|
237
|
+
}
|
|
238
|
+
return snapshot(report(file.bytesRead, finalStatus(findings), {
|
|
239
|
+
complete: true,
|
|
240
|
+
toolsChecked,
|
|
241
|
+
hasNextCursor: false,
|
|
242
|
+
reason: null
|
|
243
|
+
}, findings), parsed.tools);
|
|
244
|
+
}
|
|
245
|
+
export async function inspectMcpToolFile(filePath) {
|
|
246
|
+
return (await loadMcpToolFileSnapshot(filePath)).report;
|
|
247
|
+
}
|
|
@@ -714,8 +714,109 @@ const publicSchemaDefinitions = [
|
|
|
714
714
|
}
|
|
715
715
|
}
|
|
716
716
|
}
|
|
717
|
+
},
|
|
718
|
+
{
|
|
719
|
+
id: "doctor.tools.file.json",
|
|
720
|
+
command: "codex-plugin-doctor doctor tools-file <path> --json",
|
|
721
|
+
required: ["schemaVersion", "scope", "status", "source", "inspection", "coverage", "findings"],
|
|
722
|
+
properties: {
|
|
723
|
+
schemaVersion: { const: 1 },
|
|
724
|
+
scope: { const: "tool-definitions-file" },
|
|
725
|
+
status: { enum: ["pass", "warn", "fail", "incomplete", "blocked"] },
|
|
726
|
+
source: {
|
|
727
|
+
type: "object", required: ["kind", "format", "bytesRead"],
|
|
728
|
+
properties: {
|
|
729
|
+
kind: { const: "file" }, format: { const: "mcp-tools-list-response" },
|
|
730
|
+
bytesRead: { type: "integer", minimum: 0, maximum: 1048577 }
|
|
731
|
+
}, additionalProperties: false
|
|
732
|
+
},
|
|
733
|
+
inspection: {
|
|
734
|
+
type: "object", required: ["complete", "toolsChecked", "hasNextCursor", "reason"],
|
|
735
|
+
properties: {
|
|
736
|
+
complete: { type: "boolean" }, toolsChecked: { type: "integer", minimum: 0, maximum: 500 },
|
|
737
|
+
hasNextCursor: { type: ["boolean", "null"] }, reason: { type: ["string", "null"] }
|
|
738
|
+
}, additionalProperties: false
|
|
739
|
+
},
|
|
740
|
+
coverage: {
|
|
741
|
+
type: "object", required: ["schema", "discovery", "serverCatalog", "toolExecution", "fullConformance", "customHeaderAnnotations"],
|
|
742
|
+
properties: {
|
|
743
|
+
schema: { const: "root-shape-only" }, discovery: { const: "not-tested" },
|
|
744
|
+
serverCatalog: { const: "not-tested" }, toolExecution: { const: "not-tested" },
|
|
745
|
+
fullConformance: { const: "not-tested" }, customHeaderAnnotations: { const: "not-tested" }
|
|
746
|
+
}, additionalProperties: false
|
|
747
|
+
},
|
|
748
|
+
findings: {
|
|
749
|
+
type: "array", maxItems: 100,
|
|
750
|
+
items: {
|
|
751
|
+
type: "object", required: ["id", "severity", "message", "impact", "suggestedFix"],
|
|
752
|
+
properties: {
|
|
753
|
+
id: { type: "string", pattern: "^plugin\\.(tools_file|catalog)\\." },
|
|
754
|
+
severity: { enum: ["warn", "fail"] },
|
|
755
|
+
message: { type: "string" }, impact: { type: "string" }, suggestedFix: { type: "string" },
|
|
756
|
+
location: {
|
|
757
|
+
type: "object", required: ["page", "toolIndex"],
|
|
758
|
+
properties: {
|
|
759
|
+
page: { const: 1 }, toolIndex: { type: "integer", minimum: 1, maximum: 500 },
|
|
760
|
+
schema: { enum: ["inputSchema", "outputSchema"] },
|
|
761
|
+
relatedToolIndex: { type: "integer", minimum: 1, maximum: 500 }
|
|
762
|
+
}, additionalProperties: false
|
|
763
|
+
}
|
|
764
|
+
}, additionalProperties: false
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
717
768
|
}
|
|
718
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
|
+
});
|
|
719
820
|
function sha256(value) {
|
|
720
821
|
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
|
721
822
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function renderMcpToolFileReport(report) {
|
|
2
|
+
return [
|
|
3
|
+
"Offline MCP Tool Definitions",
|
|
4
|
+
"============================",
|
|
5
|
+
`Status: ${report.status}`,
|
|
6
|
+
`Scope: ${report.scope}`,
|
|
7
|
+
`Source: ${report.source.kind} (${report.source.format})`,
|
|
8
|
+
`Bytes read: ${report.source.bytesRead}`,
|
|
9
|
+
`Inspection complete: ${report.inspection.complete}`,
|
|
10
|
+
`Tools checked: ${report.inspection.toolsChecked}`,
|
|
11
|
+
`More pages advertised: ${report.inspection.hasNextCursor ?? "unknown"}`,
|
|
12
|
+
...(report.inspection.reason === null ? [] : [`Reason: ${report.inspection.reason}`]),
|
|
13
|
+
`Schema coverage: ${report.coverage.schema}`,
|
|
14
|
+
`Discovery: ${report.coverage.discovery}`,
|
|
15
|
+
`Server catalog: ${report.coverage.serverCatalog}`,
|
|
16
|
+
`Tool execution: ${report.coverage.toolExecution}`,
|
|
17
|
+
`Full conformance: ${report.coverage.fullConformance}`,
|
|
18
|
+
`Custom header annotations: ${report.coverage.customHeaderAnnotations}`,
|
|
19
|
+
"Only the supplied file was inspected. Its provenance and server completeness are not established.",
|
|
20
|
+
...report.findings.map((finding) => {
|
|
21
|
+
const location = finding.location;
|
|
22
|
+
const locator = location ? ` [tool ${location.toolIndex}${location.schema ? `, ${location.schema}` : ""}${location.relatedToolIndex ? `, related tool ${location.relatedToolIndex}` : ""}]` : "";
|
|
23
|
+
return `${finding.severity.toUpperCase()} ${finding.id}${locator}: ${finding.message}\n ${finding.suggestedFix}`;
|
|
24
|
+
})
|
|
25
|
+
].join("\n");
|
|
26
|
+
}
|
package/dist/run-cli.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { discoverMcpServer } from "./core/mcp-discovery.js";
|
|
2
2
|
import { inspectMcpToolCatalog } from "./core/mcp-tool-catalog.js";
|
|
3
|
+
import { inspectMcpToolFile } from "./core/mcp-tool-file.js";
|
|
4
|
+
import { compareMcpToolFiles } from "./core/mcp-tool-diff.js";
|
|
3
5
|
import { buildCorpusQualityMetricsDiffReport, buildCorpusQualityMetricsReport } from "./core/corpus-quality-metrics.js";
|
|
4
6
|
import { runCheck } from "./index.js";
|
|
5
7
|
import { writeRawDoctorConfig } from "./index.js";
|
|
@@ -15,6 +17,8 @@ export interface CliTerminalContext {
|
|
|
15
17
|
platform?: NodeJS.Platform;
|
|
16
18
|
}
|
|
17
19
|
export interface RunCliOptions {
|
|
20
|
+
compareMcpToolFilesImpl?: typeof compareMcpToolFiles;
|
|
21
|
+
inspectMcpToolFileImpl?: typeof inspectMcpToolFile;
|
|
18
22
|
inspectMcpToolCatalogImpl?: typeof inspectMcpToolCatalog;
|
|
19
23
|
discoverMcpServerImpl?: typeof discoverMcpServer;
|
|
20
24
|
terminalContext?: CliTerminalContext;
|
package/dist/run-cli.js
CHANGED
|
@@ -23,6 +23,10 @@ import { buildSubmissionArchivePreflight, submissionArchiveExitCode } from "./co
|
|
|
23
23
|
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
|
+
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";
|
|
29
|
+
import { renderMcpToolFileReport } from "./reporting/render-mcp-tool-file-report.js";
|
|
26
30
|
import { renderMcpToolCatalogReport } from "./reporting/render-mcp-tool-catalog-report.js";
|
|
27
31
|
import { renderMcpDiscoveryReport } from "./reporting/render-mcp-discovery-report.js";
|
|
28
32
|
import { buildDoctorValidationCorpusReport, renderDoctorValidationCorpusJson, renderDoctorValidationCorpusReport } from "./core/validation-corpus.js";
|
|
@@ -103,6 +107,8 @@ function writeExactStdout(io, message) {
|
|
|
103
107
|
}
|
|
104
108
|
class CliUsageError extends Error {
|
|
105
109
|
}
|
|
110
|
+
const toolsDiffUsage = "Usage: codex-plugin-doctor doctor tools-diff --before <path> --after <path> [--json]";
|
|
111
|
+
const toolsFileUsage = "Usage: codex-plugin-doctor doctor tools-file <path> [--json]";
|
|
106
112
|
const toolsUsage = "Usage: codex-plugin-doctor doctor tools <url> --allow-network [--allow-local-network] [--json]";
|
|
107
113
|
const discoveryUsage = "Usage: codex-plugin-doctor doctor discover <url> --allow-network [--allow-local-network] [--json]";
|
|
108
114
|
function parseRuntimeSandbox(flags, options = { requireRuntime: true }) {
|
|
@@ -189,6 +195,8 @@ function printUsage(io) {
|
|
|
189
195
|
io.writeStderr("Usage: codex-plugin-doctor check <path|--installed> [filter] [--policy codex-publish|mcp-strict|security] [--compat] [--json|--markdown|--badge-json|--badge-markdown] [--output <path>] [--history <path>] [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker] [--require-runtime-approval --runtime-approval-digest <digest>] [--verbose-runtime] [--explain] [--no-animations] [--ascii] [--changed-since <ref>] [--fail-on <rule-id>]\n codex-plugin-doctor audit --installed [filter] [--policy codex-publish|mcp-strict|security] [--security] [--compat] [--json] [--output <path>] [--cache] [--changed]\n codex-plugin-doctor audit deps <path> [--policy codex-publish|mcp-strict|security] [--recommend] [--json|--sarif] [--output <path>]\n codex-plugin-doctor mcp <path> [--runtime [--allow-network [--allow-local-network]]] [--json] [--output <path>]\n codex-plugin-doctor security <path> [--policy security] [--json|--scorecard]\n codex-plugin-doctor compat <path> [--all|--client <client>] [--json] [--scorecard] [--output <path>] [--install-preview|--apply --backup]\n codex-plugin-doctor suppress add <path> [--fingerprint <sha256> --reason <text> --expires-at YYYY-MM-DD] [--config <path>] [--json]\n codex-plugin-doctor suppress list <path> [--config <path>] [--json]\n codex-plugin-doctor suppress remove <path> [--fingerprint <sha256>|--index <n>] [--config <path>] [--json]\n codex-plugin-doctor fix <path> (--dry-run|--interactive --backup|--apply --backup)\n codex-plugin-doctor history <history.jsonl> [--json] [--fail-on-regression]\n codex-plugin-doctor watch <path> [--runtime] [--json] [--output <path>] [--debounce-ms <ms>] [--max-iterations <n>] [--fail-fast] [--accumulate-json <path>]\n codex-plugin-doctor doctor [npm <package>|contract|corpus [--manifest <corpus.json>] [--json] [--output <path>]|corpus metrics --manifest <corpus.json> [--json|--markdown] [--output <path>] [--min-precision <0..1>] [--min-recall <0..1>] [--max-false-positive-rate <0..1>]|runtime-plan <path> [--sandbox docker] [--json|--markdown] [--output <path>]|runtime-policy <path> [--sandbox docker] [--json] [--output <path>]|review-bundle <path> --output <dir> --sign-key-env NAME [--json] [--allow-dirty] [--allow-untagged]|review-bundle verify <bundle-dir> --target <path> --sign-key-env NAME [--json] [--output <path>] [--failures-only]|review-bundle diff --before <dir> --after <dir> [--json]|attest <path> [--sign-key-env NAME]|attest verify <attestation.json> --target <path> --sign-key-env NAME|release-evidence <path> --sign-key-env NAME [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker] [--allow-dirty] [--allow-untagged] [--require-runtime-approval --runtime-approval-digest <digest>]|release-evidence verify <evidence.json> --target <path> --sign-key-env NAME|release-evidence asset <path> --tag <tag> --output <evidence.json> --sign-key-env NAME [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker] [--allow-dirty] [--allow-untagged] [--require-runtime-approval --runtime-approval-digest <digest>] [--upload]|mcp <path> [--runtime [--allow-network [--allow-local-network]]]|inspector <path>|diff --before <path> --after <path>|recommend <path>|trust <path>|perf <path> [--max-total-ms <ms>] [--max-stage-ms stage=ms]|export --bundle <path>|snapshot|clients|--json|--update-check]\n codex-plugin-doctor init [path] [--template skill-only|mcp-stdio|mcp-http|full-runtime]\n codex-plugin-doctor init-ci [path]\n codex-plugin-doctor init-git-hooks [path] [--force] [--json]\n codex-plugin-doctor init-git-hooks [path] --remove [--json]\n codex-plugin-doctor completion bash|zsh|fish\n codex-plugin-doctor config validate <path> [--json]\n codex-plugin-doctor release check <path> [--json] [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker]\n codex-plugin-doctor self-test\n codex-plugin-doctor list --installed\n codex-plugin-doctor explain <finding-id>\n codex-plugin-doctor --version\n\nFirst run:\n codex-plugin-doctor doctor\n codex-plugin-doctor self-test\n codex-plugin-doctor init my-plugin\n codex-plugin-doctor check . --runtime --explain");
|
|
190
196
|
io.writeStderr(discoveryUsage);
|
|
191
197
|
io.writeStderr(toolsUsage);
|
|
198
|
+
io.writeStderr(toolsFileUsage);
|
|
199
|
+
io.writeStderr(toolsDiffUsage);
|
|
192
200
|
io.writeStderr("Registry readiness: codex-plugin-doctor registry check <server.json|directory> [--json] [--output <path>] [--require-registry-readiness]\n"
|
|
193
201
|
+ " codex-plugin-doctor registry inspect <server-name> --allow-network [--json] [--output <path>] [--require-registry-readiness]\n"
|
|
194
202
|
+ "Registry publication preflight: codex-plugin-doctor registry preflight <server.json|directory> [--allow-network] [--json] [--output <path>] [--require-publish-ready]");
|
|
@@ -1199,6 +1207,62 @@ export async function runCli(args, io = defaultIo, options = {}) {
|
|
|
1199
1207
|
return 0;
|
|
1200
1208
|
}
|
|
1201
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
|
+
}
|
|
1249
|
+
if (maybePath === "tools-file") {
|
|
1250
|
+
if (remainingArgs.length === 1 && remainingArgs[0] === "--help") {
|
|
1251
|
+
io.writeStdout(`${toolsFileUsage}\nInspects one saved UTF-8 MCP tools/list response. No server connection or tool execution.`);
|
|
1252
|
+
return 0;
|
|
1253
|
+
}
|
|
1254
|
+
const [filePath, ...flags] = remainingArgs;
|
|
1255
|
+
if (!filePath || filePath.startsWith("-") || flags.some((flag) => flag !== "--json") || flags.length > 1) {
|
|
1256
|
+
io.writeStderr(`Invalid file inspection arguments. ${toolsFileUsage}`);
|
|
1257
|
+
return 2;
|
|
1258
|
+
}
|
|
1259
|
+
const report = await (options.inspectMcpToolFileImpl ?? inspectMcpToolFile)(filePath);
|
|
1260
|
+
io.writeStdout(flags.includes("--json")
|
|
1261
|
+
? JSON.stringify(report, null, 2)
|
|
1262
|
+
: renderMcpToolFileReport(report));
|
|
1263
|
+
return report.status === "pass" ? 0
|
|
1264
|
+
: report.status === "warn" || report.status === "fail" ? 1 : 2;
|
|
1265
|
+
}
|
|
1202
1266
|
if (maybePath === "tools") {
|
|
1203
1267
|
if (remainingArgs.length === 1 && remainingArgs[0] === "--help") {
|
|
1204
1268
|
io.writeStdout(`${toolsUsage}\nHTTP tool catalog structure only; tools are not executed.`);
|
package/package.json
CHANGED