codex-plugin-doctor 1.50.0 → 1.52.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/LICENSE +21 -21
- package/README.md +17 -6
- package/dist/core/bounded-http-client.d.ts +23 -0
- package/dist/core/bounded-http-client.js +240 -0
- package/dist/core/mcp-conformance.d.ts +2 -0
- package/dist/core/mcp-conformance.js +215 -0
- package/dist/core/output-contract.js +104 -5
- package/dist/core/release-check.d.ts +2 -0
- package/dist/core/release-check.js +2 -0
- package/dist/core/release-evidence.d.ts +2 -0
- package/dist/core/release-evidence.js +111 -7
- package/dist/core/remote-mcp-probe.d.ts +16 -0
- package/dist/core/remote-mcp-probe.js +226 -0
- package/dist/core/remote-network-policy.d.ts +24 -0
- package/dist/core/remote-network-policy.js +125 -0
- package/dist/core/remote-oauth-readiness.d.ts +12 -0
- package/dist/core/remote-oauth-readiness.js +295 -0
- package/dist/core/remote-url-policy.d.ts +7 -0
- package/dist/core/remote-url-policy.js +49 -0
- package/dist/core/runtime-plan.d.ts +3 -0
- package/dist/core/runtime-plan.js +36 -2
- package/dist/core/runtime-policy.js +5 -1
- package/dist/core/runtime-probe.d.ts +11 -2
- package/dist/core/runtime-probe.js +375 -70
- package/dist/core/runtime-transcript.js +13 -3
- package/dist/core/validate-plugin.js +32 -1
- package/dist/domain/types.d.ts +47 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/mcp/generic-mcp-doctor.d.ts +10 -2
- package/dist/mcp/generic-mcp-doctor.js +44 -13
- package/dist/reporting/render-markdown-report.js +44 -13
- package/dist/reporting/render-text-report.js +39 -22
- package/dist/rules/rule-catalog.js +253 -1
- package/dist/run-cli.js +137 -9
- package/dist/security/security-audit.js +34 -7
- package/examples/README.md +94 -94
- package/examples/codex-doctor-generic-mcp/.mcp.json +10 -10
- package/examples/codex-doctor-generic-mcp/server.js +1 -1
- package/examples/codex-doctor-risky/.codex-plugin/plugin.json +7 -7
- package/examples/codex-doctor-risky/.mcp.json +12 -12
- package/examples/codex-doctor-risky/mock-server.js +1 -1
- package/examples/codex-doctor-runtime/.codex-plugin/plugin.json +8 -8
- package/examples/codex-doctor-runtime/.mcp.json +9 -9
- package/examples/codex-doctor-runtime/mock-server.js +237 -237
- package/examples/codex-doctor-runtime/skills/context-check/SKILL.md +7 -7
- package/examples/codex-doctor-starter/.codex-plugin/plugin.json +7 -7
- package/examples/codex-doctor-starter/skills/repo-scan/SKILL.md +7 -7
- package/package.json +1 -1
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Furkan Tasci
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Furkan Tasci
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -78,10 +78,21 @@ Runtime MCP validation with `--runtime`:
|
|
|
78
78
|
- paginated list responses
|
|
79
79
|
- runtime capability scorecard
|
|
80
80
|
- redacted verbose transcript with `--verbose-runtime`
|
|
81
|
-
- optional runtime approval gating with a precomputed `doctor runtime-plan` digest
|
|
82
|
-
- optional Docker isolation for local Node.js stdio servers with `--sandbox docker`
|
|
83
|
-
|
|
84
|
-
|
|
81
|
+
- optional runtime approval gating with a precomputed `doctor runtime-plan` digest
|
|
82
|
+
- optional Docker isolation for local Node.js stdio servers with `--sandbox docker`
|
|
83
|
+
|
|
84
|
+
### Remote MCP Readiness
|
|
85
|
+
|
|
86
|
+
Remote MCP runtime probing is disabled until you explicitly pass `--allow-network`. Local endpoints also require `--allow-local-network`:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
codex-plugin-doctor check ./remote-mcp --runtime --allow-network
|
|
90
|
+
codex-plugin-doctor check ./remote-mcp --runtime --allow-network --allow-local-network
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The probe makes only bounded, read-only protocol and OAuth metadata-discovery requests, redacts report output, and applies SSRF controls. It does not authenticate or follow redirects. See [Remote MCP Readiness](./docs/architecture/remote-mcp-readiness.md).
|
|
94
|
+
|
|
95
|
+
Output formats:
|
|
85
96
|
|
|
86
97
|
- human text output
|
|
87
98
|
- JSON reports
|
|
@@ -427,9 +438,9 @@ jobs:
|
|
|
427
438
|
runs-on: ubuntu-latest
|
|
428
439
|
steps:
|
|
429
440
|
- uses: actions/checkout@v5
|
|
430
|
-
- uses: Esquetta/CodexPluginDoctor@v1.
|
|
441
|
+
- uses: Esquetta/CodexPluginDoctor@v1.52.0
|
|
431
442
|
with:
|
|
432
|
-
version: "1.
|
|
443
|
+
version: "1.52.0"
|
|
433
444
|
path: .
|
|
434
445
|
runtime: "true"
|
|
435
446
|
policy: codex-publish
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type RemoteLookup } from "./remote-network-policy.js";
|
|
2
|
+
export interface BoundedHttpRequestOptions {
|
|
3
|
+
allowLocalNetwork?: boolean;
|
|
4
|
+
lookup?: RemoteLookup;
|
|
5
|
+
timeoutMs?: number;
|
|
6
|
+
maxResponseBytes?: number;
|
|
7
|
+
method?: string;
|
|
8
|
+
body?: string | Buffer;
|
|
9
|
+
headers?: Record<string, string | undefined>;
|
|
10
|
+
stopAfter?: (body: Buffer) => boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface BoundedHttpResponse {
|
|
13
|
+
statusCode: number;
|
|
14
|
+
headers: Record<string, string | string[]>;
|
|
15
|
+
body: Buffer;
|
|
16
|
+
}
|
|
17
|
+
export declare class BoundedHttpError extends Error {
|
|
18
|
+
readonly code: "REMOTE_HTTP_ENCODING_UNSUPPORTED" | "REMOTE_HTTP_HEADER_FORBIDDEN" | "REMOTE_HTTP_OPTIONS_INVALID" | "REMOTE_HTTP_PEER_MISMATCH" | "REMOTE_HTTP_REDIRECT" | "REMOTE_HTTP_REQUEST_FAILED" | "REMOTE_HTTP_RESPONSE_TOO_LARGE" | "REMOTE_HTTP_STOP_CONDITION_FAILED" | "REMOTE_HTTP_TIMEOUT" | "REMOTE_HTTP_URL_CREDENTIALS" | "REMOTE_HTTP_URL_UNSUPPORTED";
|
|
19
|
+
readonly statusCode?: number | undefined;
|
|
20
|
+
readonly headers?: Record<string, string | string[]> | undefined;
|
|
21
|
+
constructor(code: "REMOTE_HTTP_ENCODING_UNSUPPORTED" | "REMOTE_HTTP_HEADER_FORBIDDEN" | "REMOTE_HTTP_OPTIONS_INVALID" | "REMOTE_HTTP_PEER_MISMATCH" | "REMOTE_HTTP_REDIRECT" | "REMOTE_HTTP_REQUEST_FAILED" | "REMOTE_HTTP_RESPONSE_TOO_LARGE" | "REMOTE_HTTP_STOP_CONDITION_FAILED" | "REMOTE_HTTP_TIMEOUT" | "REMOTE_HTTP_URL_CREDENTIALS" | "REMOTE_HTTP_URL_UNSUPPORTED", message: string, statusCode?: number | undefined, headers?: Record<string, string | string[]> | undefined);
|
|
22
|
+
}
|
|
23
|
+
export declare function requestBoundedHttp(rawUrl: string, options?: BoundedHttpRequestOptions): Promise<BoundedHttpResponse>;
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import * as http from "node:http";
|
|
2
|
+
import * as https from "node:https";
|
|
3
|
+
import { BlockList, isIP } from "node:net";
|
|
4
|
+
import { resolveRemoteTarget } from "./remote-network-policy.js";
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 3_000;
|
|
6
|
+
const DEFAULT_MAX_RESPONSE_BYTES = 1_024 * 1_024;
|
|
7
|
+
const allowedRequestHeaders = new Set([
|
|
8
|
+
"accept",
|
|
9
|
+
"content-type",
|
|
10
|
+
"mcp-protocol-version",
|
|
11
|
+
"mcp-session-id",
|
|
12
|
+
"user-agent"
|
|
13
|
+
]);
|
|
14
|
+
const safeResponseHeaders = new Set([
|
|
15
|
+
"content-type",
|
|
16
|
+
"www-authenticate",
|
|
17
|
+
"mcp-session-id",
|
|
18
|
+
"mcp-protocol-version",
|
|
19
|
+
"location"
|
|
20
|
+
]);
|
|
21
|
+
export class BoundedHttpError extends Error {
|
|
22
|
+
code;
|
|
23
|
+
statusCode;
|
|
24
|
+
headers;
|
|
25
|
+
constructor(code, message, statusCode, headers) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.code = code;
|
|
28
|
+
this.statusCode = statusCode;
|
|
29
|
+
this.headers = headers;
|
|
30
|
+
this.name = "BoundedHttpError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function validateHeaders(headers) {
|
|
34
|
+
const validated = {};
|
|
35
|
+
for (const [name, value] of Object.entries(headers ?? {})) {
|
|
36
|
+
if (value === undefined) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const normalizedName = name.toLowerCase();
|
|
40
|
+
if (!allowedRequestHeaders.has(normalizedName)) {
|
|
41
|
+
throw new BoundedHttpError("REMOTE_HTTP_HEADER_FORBIDDEN", `Remote HTTP request header is not allowed: ${normalizedName}.`);
|
|
42
|
+
}
|
|
43
|
+
if (normalizedName in validated || typeof value !== "string") {
|
|
44
|
+
throw new BoundedHttpError("REMOTE_HTTP_HEADER_FORBIDDEN", `Remote HTTP request header must be a single valid string: ${normalizedName}.`);
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
http.validateHeaderValue(normalizedName, value);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
throw new BoundedHttpError("REMOTE_HTTP_HEADER_FORBIDDEN", `Remote HTTP request header must be a single valid string: ${normalizedName}.`);
|
|
51
|
+
}
|
|
52
|
+
validated[normalizedName] = value;
|
|
53
|
+
}
|
|
54
|
+
return validated;
|
|
55
|
+
}
|
|
56
|
+
function validateOptions(options) {
|
|
57
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
58
|
+
const maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
|
|
59
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > DEFAULT_TIMEOUT_MS
|
|
60
|
+
|| !Number.isInteger(maxResponseBytes) || maxResponseBytes < 1 || maxResponseBytes > DEFAULT_MAX_RESPONSE_BYTES) {
|
|
61
|
+
throw new BoundedHttpError("REMOTE_HTTP_OPTIONS_INVALID", "Remote HTTP request options are invalid.");
|
|
62
|
+
}
|
|
63
|
+
return { timeoutMs, maxResponseBytes };
|
|
64
|
+
}
|
|
65
|
+
async function resolveWithinDeadline(url, options, timeoutMs) {
|
|
66
|
+
let timer;
|
|
67
|
+
try {
|
|
68
|
+
return await Promise.race([
|
|
69
|
+
resolveRemoteTarget(url, {
|
|
70
|
+
allowLocalNetwork: options.allowLocalNetwork,
|
|
71
|
+
lookup: options.lookup
|
|
72
|
+
}),
|
|
73
|
+
new Promise((_resolve, reject) => {
|
|
74
|
+
timer = setTimeout(() => {
|
|
75
|
+
reject(new BoundedHttpError("REMOTE_HTTP_TIMEOUT", "Remote HTTP request timed out."));
|
|
76
|
+
}, timeoutMs);
|
|
77
|
+
})
|
|
78
|
+
]);
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
if (timer !== undefined) {
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function selectSafeHeaders(headers) {
|
|
87
|
+
const selected = {};
|
|
88
|
+
for (const name of safeResponseHeaders) {
|
|
89
|
+
const value = headers[name];
|
|
90
|
+
if (value !== undefined) {
|
|
91
|
+
selected[name] = value;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return selected;
|
|
95
|
+
}
|
|
96
|
+
function hasIdentityContentEncoding(headers) {
|
|
97
|
+
const value = headers["content-encoding"];
|
|
98
|
+
const values = Array.isArray(value) ? value : value === undefined ? [] : [value];
|
|
99
|
+
return values.every((entry) => entry.toLowerCase() === "identity");
|
|
100
|
+
}
|
|
101
|
+
function matchesTargetPeer(target, remoteAddress) {
|
|
102
|
+
if (remoteAddress === undefined || isIP(remoteAddress) === 0) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
const peers = new BlockList();
|
|
106
|
+
peers.addAddress(target.address, target.family === 4 ? "ipv4" : "ipv6");
|
|
107
|
+
if (target.family === 4) {
|
|
108
|
+
peers.addAddress(`::ffff:${target.address}`, "ipv6");
|
|
109
|
+
}
|
|
110
|
+
return peers.check(remoteAddress, isIP(remoteAddress) === 4 ? "ipv4" : "ipv6");
|
|
111
|
+
}
|
|
112
|
+
export async function requestBoundedHttp(rawUrl, options = {}) {
|
|
113
|
+
let url;
|
|
114
|
+
try {
|
|
115
|
+
url = new URL(rawUrl);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
throw new BoundedHttpError("REMOTE_HTTP_URL_UNSUPPORTED", "Remote HTTP URL must be absolute HTTP or HTTPS.");
|
|
119
|
+
}
|
|
120
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
121
|
+
throw new BoundedHttpError("REMOTE_HTTP_URL_UNSUPPORTED", "Remote HTTP URL must be absolute HTTP or HTTPS.");
|
|
122
|
+
}
|
|
123
|
+
if (url.username || url.password) {
|
|
124
|
+
throw new BoundedHttpError("REMOTE_HTTP_URL_CREDENTIALS", "Remote HTTP URL must not include credentials.");
|
|
125
|
+
}
|
|
126
|
+
const { timeoutMs, maxResponseBytes } = validateOptions(options);
|
|
127
|
+
const headers = validateHeaders(options.headers);
|
|
128
|
+
const deadline = Date.now() + timeoutMs;
|
|
129
|
+
const target = await resolveWithinDeadline(url, options, timeoutMs);
|
|
130
|
+
const remainingMs = deadline - Date.now();
|
|
131
|
+
if (remainingMs <= 0) {
|
|
132
|
+
throw new BoundedHttpError("REMOTE_HTTP_TIMEOUT", "Remote HTTP request timed out.");
|
|
133
|
+
}
|
|
134
|
+
const transport = url.protocol === "https:" ? https : http;
|
|
135
|
+
return new Promise((resolve, reject) => {
|
|
136
|
+
let request;
|
|
137
|
+
let response;
|
|
138
|
+
let settled = false;
|
|
139
|
+
const timeout = setTimeout(() => {
|
|
140
|
+
fail(new BoundedHttpError("REMOTE_HTTP_TIMEOUT", "Remote HTTP request timed out."));
|
|
141
|
+
}, remainingMs);
|
|
142
|
+
const fail = (error) => {
|
|
143
|
+
if (settled) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
settled = true;
|
|
147
|
+
clearTimeout(timeout);
|
|
148
|
+
response?.destroy();
|
|
149
|
+
request?.destroy();
|
|
150
|
+
reject(error);
|
|
151
|
+
};
|
|
152
|
+
const complete = (body) => {
|
|
153
|
+
if (settled) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
settled = true;
|
|
157
|
+
clearTimeout(timeout);
|
|
158
|
+
response?.destroy();
|
|
159
|
+
request?.destroy();
|
|
160
|
+
resolve({
|
|
161
|
+
statusCode: response?.statusCode ?? 0,
|
|
162
|
+
headers: response ? selectSafeHeaders(response.headers) : {},
|
|
163
|
+
body
|
|
164
|
+
});
|
|
165
|
+
};
|
|
166
|
+
try {
|
|
167
|
+
request = transport.request({
|
|
168
|
+
protocol: url.protocol,
|
|
169
|
+
hostname: url.hostname,
|
|
170
|
+
port: url.port || undefined,
|
|
171
|
+
path: `${url.pathname}${url.search}`,
|
|
172
|
+
method: options.method ?? "GET",
|
|
173
|
+
headers,
|
|
174
|
+
agent: false,
|
|
175
|
+
lookup: (_hostname, lookupOptions, callback) => {
|
|
176
|
+
if (lookupOptions.all) {
|
|
177
|
+
callback(null, [{ address: target.address, family: target.family }]);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
callback(null, target.address, target.family);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
fail(new BoundedHttpError("REMOTE_HTTP_REQUEST_FAILED", "Remote HTTP request failed."));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
request.once("error", () => {
|
|
189
|
+
fail(new BoundedHttpError("REMOTE_HTTP_REQUEST_FAILED", "Remote HTTP request failed."));
|
|
190
|
+
});
|
|
191
|
+
request.once("response", (incoming) => {
|
|
192
|
+
response = incoming;
|
|
193
|
+
const safeHeaders = selectSafeHeaders(incoming.headers);
|
|
194
|
+
if (!matchesTargetPeer(target, incoming.socket.remoteAddress)) {
|
|
195
|
+
fail(new BoundedHttpError("REMOTE_HTTP_PEER_MISMATCH", "Remote HTTP peer did not match the resolved target."));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if ((incoming.statusCode ?? 0) >= 300 && (incoming.statusCode ?? 0) < 400) {
|
|
199
|
+
fail(new BoundedHttpError("REMOTE_HTTP_REDIRECT", "Remote HTTP redirects are not allowed.", incoming.statusCode, safeHeaders));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (!hasIdentityContentEncoding(incoming.headers)) {
|
|
203
|
+
fail(new BoundedHttpError("REMOTE_HTTP_ENCODING_UNSUPPORTED", "Remote HTTP response content encoding must be identity."));
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const chunks = [];
|
|
207
|
+
let receivedBytes = 0;
|
|
208
|
+
incoming.on("data", (chunk) => {
|
|
209
|
+
receivedBytes += chunk.length;
|
|
210
|
+
if (receivedBytes > maxResponseBytes) {
|
|
211
|
+
fail(new BoundedHttpError("REMOTE_HTTP_RESPONSE_TOO_LARGE", "Remote HTTP response exceeded the configured size limit."));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
chunks.push(chunk);
|
|
215
|
+
const body = Buffer.concat(chunks);
|
|
216
|
+
let shouldStop;
|
|
217
|
+
try {
|
|
218
|
+
shouldStop = options.stopAfter?.(body) ?? false;
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
fail(new BoundedHttpError("REMOTE_HTTP_STOP_CONDITION_FAILED", "Remote HTTP stop condition failed."));
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (shouldStop) {
|
|
225
|
+
complete(body);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
incoming.once("error", () => {
|
|
230
|
+
fail(new BoundedHttpError("REMOTE_HTTP_REQUEST_FAILED", "Remote HTTP request failed."));
|
|
231
|
+
});
|
|
232
|
+
incoming.once("end", () => {
|
|
233
|
+
if (!settled) {
|
|
234
|
+
complete(Buffer.concat(chunks));
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
request.end(options.body);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
const LATEST_PROTOCOL_VERSION = "2025-11-25";
|
|
2
|
+
const LEGACY_PROTOCOL_VERSIONS = new Set([
|
|
3
|
+
"2024-11-05",
|
|
4
|
+
"2025-03-26",
|
|
5
|
+
"2025-06-18"
|
|
6
|
+
]);
|
|
7
|
+
const CANONICAL_SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema";
|
|
8
|
+
function isPlainObject(value) {
|
|
9
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
10
|
+
}
|
|
11
|
+
function buildFinding(id, severity, message, impact, suggestedFix, evidence) {
|
|
12
|
+
return {
|
|
13
|
+
id,
|
|
14
|
+
severity,
|
|
15
|
+
message,
|
|
16
|
+
impact,
|
|
17
|
+
suggestedFix,
|
|
18
|
+
...(evidence ? { evidence } : {})
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function classifyProtocolVersion(protocolVersion) {
|
|
22
|
+
if (LEGACY_PROTOCOL_VERSIONS.has(protocolVersion)) {
|
|
23
|
+
return "legacy";
|
|
24
|
+
}
|
|
25
|
+
if (protocolVersion === LATEST_PROTOCOL_VERSION) {
|
|
26
|
+
return "2025-11-25";
|
|
27
|
+
}
|
|
28
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(protocolVersion) && protocolVersion > LATEST_PROTOCOL_VERSION) {
|
|
29
|
+
return "future-compatible";
|
|
30
|
+
}
|
|
31
|
+
return "legacy";
|
|
32
|
+
}
|
|
33
|
+
function addTaskCapabilityFailure(findings, field) {
|
|
34
|
+
findings.push(buildFinding("mcp.conformance.tasks.capability_invalid", "fail", `The Tasks capability field \`${field}\` must be an object when present.`, "Codex cannot safely determine whether the server supports task-aware tool calls.", `Return an object for \`${field}\`, or omit the capability until it is implemented.`, { field }));
|
|
35
|
+
}
|
|
36
|
+
function getTaskToolsCallCapabilityState(capabilities, findings) {
|
|
37
|
+
if (!isPlainObject(capabilities)) {
|
|
38
|
+
addTaskCapabilityFailure(findings, "capabilities");
|
|
39
|
+
return "malformed";
|
|
40
|
+
}
|
|
41
|
+
const tasks = capabilities.tasks;
|
|
42
|
+
if (tasks === undefined) {
|
|
43
|
+
return "missing";
|
|
44
|
+
}
|
|
45
|
+
if (!isPlainObject(tasks)) {
|
|
46
|
+
addTaskCapabilityFailure(findings, "capabilities.tasks");
|
|
47
|
+
return "malformed";
|
|
48
|
+
}
|
|
49
|
+
for (const key of ["list", "cancel"]) {
|
|
50
|
+
if (tasks[key] !== undefined && !isPlainObject(tasks[key])) {
|
|
51
|
+
addTaskCapabilityFailure(findings, `capabilities.tasks.${key}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const requests = tasks.requests;
|
|
55
|
+
if (requests === undefined) {
|
|
56
|
+
return "missing";
|
|
57
|
+
}
|
|
58
|
+
if (!isPlainObject(requests)) {
|
|
59
|
+
addTaskCapabilityFailure(findings, "capabilities.tasks.requests");
|
|
60
|
+
return "malformed";
|
|
61
|
+
}
|
|
62
|
+
const tools = requests.tools;
|
|
63
|
+
if (tools === undefined) {
|
|
64
|
+
return "missing";
|
|
65
|
+
}
|
|
66
|
+
if (!isPlainObject(tools)) {
|
|
67
|
+
addTaskCapabilityFailure(findings, "capabilities.tasks.requests.tools");
|
|
68
|
+
return "malformed";
|
|
69
|
+
}
|
|
70
|
+
const call = tools.call;
|
|
71
|
+
if (call === undefined) {
|
|
72
|
+
return "missing";
|
|
73
|
+
}
|
|
74
|
+
if (!isPlainObject(call)) {
|
|
75
|
+
addTaskCapabilityFailure(findings, "capabilities.tasks.requests.tools.call");
|
|
76
|
+
return "malformed";
|
|
77
|
+
}
|
|
78
|
+
return "present-valid";
|
|
79
|
+
}
|
|
80
|
+
function collectTaskDeclarationFindings(observation, taskCallCapabilityState, findings) {
|
|
81
|
+
for (const tool of observation.tools) {
|
|
82
|
+
let taskSupport = "forbidden";
|
|
83
|
+
if (tool.execution !== undefined) {
|
|
84
|
+
if (!isPlainObject(tool.execution)) {
|
|
85
|
+
findings.push(buildFinding("mcp.conformance.tasks.task_support_invalid", "fail", `Tool \`${tool.name}\` has a non-object execution declaration.`, "Codex cannot safely interpret the tool's task support contract.", "Return an object for `execution` with taskSupport set to required, optional, or forbidden.", { toolName: tool.name }));
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
taskSupport = tool.execution.taskSupport ?? "forbidden";
|
|
89
|
+
}
|
|
90
|
+
if (taskSupport !== "required" &&
|
|
91
|
+
taskSupport !== "optional" &&
|
|
92
|
+
taskSupport !== "forbidden") {
|
|
93
|
+
findings.push(buildFinding("mcp.conformance.tasks.task_support_invalid", "fail", `Tool \`${tool.name}\` declares an unsupported taskSupport value.`, "Codex cannot determine whether task-aware tool calls are required or optional.", "Set execution.taskSupport to required, optional, or forbidden.", { toolName: tool.name }));
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (taskSupport !== "forbidden" && taskCallCapabilityState === "missing") {
|
|
97
|
+
findings.push(buildFinding("mcp.conformance.tasks.capability_mismatch", "fail", `Tool \`${tool.name}\` declares task support without tasks.requests.tools.call capability.`, "Codex may attempt task-aware tool calls that the server did not advertise as supported.", "Advertise capabilities.tasks.requests.tools.call as an object, or set execution.taskSupport to forbidden.", { toolName: tool.name, taskSupport }));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function isAbsoluteUri(value) {
|
|
102
|
+
try {
|
|
103
|
+
return new URL(value).protocol.length > 0;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function collectSchemaFindings(observation, findings) {
|
|
110
|
+
for (const tool of observation.tools) {
|
|
111
|
+
const schemas = [
|
|
112
|
+
["inputSchema", tool.inputSchema]
|
|
113
|
+
];
|
|
114
|
+
if (tool.outputSchema !== undefined) {
|
|
115
|
+
schemas.push(["outputSchema", tool.outputSchema]);
|
|
116
|
+
}
|
|
117
|
+
for (const [schemaName, schema] of schemas) {
|
|
118
|
+
if (!isPlainObject(schema)) {
|
|
119
|
+
findings.push(buildFinding("mcp.conformance.schema.dialect_invalid", "fail", `Tool \`${tool.name}\` has a non-object ${schemaName} container.`, "Codex cannot interpret a tool schema that is not represented as an object.", `Return an object for ${schemaName}.`, { toolName: tool.name, schema: schemaName }));
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const dialect = schema.$schema;
|
|
123
|
+
if (dialect === undefined) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (typeof dialect !== "string" || !isAbsoluteUri(dialect)) {
|
|
127
|
+
findings.push(buildFinding("mcp.conformance.schema.dialect_invalid", "fail", `Tool \`${tool.name}\` has an invalid ${schemaName} $schema URI.`, "Codex cannot reliably select a schema dialect for this tool.", `Omit $schema or provide an absolute URI in ${schemaName}.`, { toolName: tool.name, schema: schemaName }));
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (dialect !== CANONICAL_SCHEMA_DIALECT && dialect !== `${CANONICAL_SCHEMA_DIALECT}#`) {
|
|
131
|
+
findings.push(buildFinding("mcp.conformance.schema.dialect_unsupported", "warn", `Tool \`${tool.name}\` uses a non-canonical ${schemaName} $schema dialect.`, "The schema may be valid, but its dialect is outside the validator's latest compatibility baseline.", `Use ${CANONICAL_SCHEMA_DIALECT} or omit $schema.`, { toolName: tool.name, schema: schemaName }));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function collectTasksListFinding(observation, findings) {
|
|
137
|
+
if (!observation || observation.status === "skipped") {
|
|
138
|
+
return "skipped";
|
|
139
|
+
}
|
|
140
|
+
if (observation.status === "pass") {
|
|
141
|
+
return "pass";
|
|
142
|
+
}
|
|
143
|
+
const timeout = observation.failure === "timeout";
|
|
144
|
+
findings.push(buildFinding(timeout
|
|
145
|
+
? "mcp.conformance.tasks_list.timeout"
|
|
146
|
+
: "mcp.conformance.tasks_list.invalid", "fail", timeout
|
|
147
|
+
? "The server did not complete tasks/list within the observation window."
|
|
148
|
+
: "The server returned an invalid tasks/list observation.", "Codex cannot rely on task discovery when tasks/list cannot complete with a valid response.", timeout
|
|
149
|
+
? "Reduce tasks/list latency and verify pagination completes."
|
|
150
|
+
: "Return a valid tasks/list response with well-formed task items and pagination."));
|
|
151
|
+
return "fail";
|
|
152
|
+
}
|
|
153
|
+
function statusForFindings(findings) {
|
|
154
|
+
if (findings.some((finding) => finding.severity === "fail")) {
|
|
155
|
+
return "fail";
|
|
156
|
+
}
|
|
157
|
+
if (findings.some((finding) => finding.severity === "warn")) {
|
|
158
|
+
return "warn";
|
|
159
|
+
}
|
|
160
|
+
return "pass";
|
|
161
|
+
}
|
|
162
|
+
function overallStatus(scorecard, findings) {
|
|
163
|
+
const statuses = [
|
|
164
|
+
scorecard.capabilityConsistency,
|
|
165
|
+
scorecard.taskDeclarations,
|
|
166
|
+
scorecard.tasksList,
|
|
167
|
+
scorecard.schemaDialect
|
|
168
|
+
];
|
|
169
|
+
if (statuses.includes("fail") || findings.some((finding) => finding.severity === "fail")) {
|
|
170
|
+
return "fail";
|
|
171
|
+
}
|
|
172
|
+
if (statuses.includes("warn") || findings.some((finding) => finding.severity === "warn")) {
|
|
173
|
+
return "warn";
|
|
174
|
+
}
|
|
175
|
+
return "pass";
|
|
176
|
+
}
|
|
177
|
+
export function evaluateMcpConformance(observation) {
|
|
178
|
+
const profile = classifyProtocolVersion(observation.protocolVersion);
|
|
179
|
+
const findings = [];
|
|
180
|
+
const scorecard = {
|
|
181
|
+
protocolVersion: observation.protocolVersion,
|
|
182
|
+
profile,
|
|
183
|
+
capabilityConsistency: "skipped",
|
|
184
|
+
taskDeclarations: "skipped",
|
|
185
|
+
tasksList: "skipped",
|
|
186
|
+
schemaDialect: "skipped",
|
|
187
|
+
overall: "pass"
|
|
188
|
+
};
|
|
189
|
+
if (profile === "future-compatible") {
|
|
190
|
+
findings.push(buildFinding("mcp.conformance.protocol.unknown_newer", "warn", `The server advertises newer MCP protocol version \`${observation.protocolVersion}\`.`, "The validator applies the 2025-11-25 structural baseline, which may not cover newer protocol requirements.", "Confirm the server's newer protocol changes remain compatible with the 2025-11-25 MCP contract.", { protocolVersion: observation.protocolVersion }));
|
|
191
|
+
}
|
|
192
|
+
if (profile === "legacy") {
|
|
193
|
+
scorecard.overall = overallStatus(scorecard, findings);
|
|
194
|
+
return { findings, scorecard };
|
|
195
|
+
}
|
|
196
|
+
if (!observation.skipToolDerivedChecks) {
|
|
197
|
+
const capabilityFindings = [];
|
|
198
|
+
const taskCallCapabilityState = getTaskToolsCallCapabilityState(observation.capabilities, capabilityFindings);
|
|
199
|
+
findings.push(...capabilityFindings);
|
|
200
|
+
scorecard.capabilityConsistency = statusForFindings(capabilityFindings);
|
|
201
|
+
const declarationFindings = [];
|
|
202
|
+
collectTaskDeclarationFindings(observation, taskCallCapabilityState, declarationFindings);
|
|
203
|
+
findings.push(...declarationFindings);
|
|
204
|
+
scorecard.taskDeclarations = statusForFindings(declarationFindings);
|
|
205
|
+
}
|
|
206
|
+
scorecard.tasksList = collectTasksListFinding(observation.tasksList, findings);
|
|
207
|
+
if (!observation.skipToolDerivedChecks) {
|
|
208
|
+
const schemaFindings = [];
|
|
209
|
+
collectSchemaFindings(observation, schemaFindings);
|
|
210
|
+
findings.push(...schemaFindings);
|
|
211
|
+
scorecard.schemaDialect = statusForFindings(schemaFindings);
|
|
212
|
+
}
|
|
213
|
+
scorecard.overall = overallStatus(scorecard, findings);
|
|
214
|
+
return { findings, scorecard };
|
|
215
|
+
}
|