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.
Files changed (49) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +17 -6
  3. package/dist/core/bounded-http-client.d.ts +23 -0
  4. package/dist/core/bounded-http-client.js +240 -0
  5. package/dist/core/mcp-conformance.d.ts +2 -0
  6. package/dist/core/mcp-conformance.js +215 -0
  7. package/dist/core/output-contract.js +104 -5
  8. package/dist/core/release-check.d.ts +2 -0
  9. package/dist/core/release-check.js +2 -0
  10. package/dist/core/release-evidence.d.ts +2 -0
  11. package/dist/core/release-evidence.js +111 -7
  12. package/dist/core/remote-mcp-probe.d.ts +16 -0
  13. package/dist/core/remote-mcp-probe.js +226 -0
  14. package/dist/core/remote-network-policy.d.ts +24 -0
  15. package/dist/core/remote-network-policy.js +125 -0
  16. package/dist/core/remote-oauth-readiness.d.ts +12 -0
  17. package/dist/core/remote-oauth-readiness.js +295 -0
  18. package/dist/core/remote-url-policy.d.ts +7 -0
  19. package/dist/core/remote-url-policy.js +49 -0
  20. package/dist/core/runtime-plan.d.ts +3 -0
  21. package/dist/core/runtime-plan.js +36 -2
  22. package/dist/core/runtime-policy.js +5 -1
  23. package/dist/core/runtime-probe.d.ts +11 -2
  24. package/dist/core/runtime-probe.js +375 -70
  25. package/dist/core/runtime-transcript.js +13 -3
  26. package/dist/core/validate-plugin.js +32 -1
  27. package/dist/domain/types.d.ts +47 -0
  28. package/dist/index.d.ts +4 -0
  29. package/dist/index.js +4 -0
  30. package/dist/mcp/generic-mcp-doctor.d.ts +10 -2
  31. package/dist/mcp/generic-mcp-doctor.js +44 -13
  32. package/dist/reporting/render-markdown-report.js +44 -13
  33. package/dist/reporting/render-text-report.js +39 -22
  34. package/dist/rules/rule-catalog.js +253 -1
  35. package/dist/run-cli.js +137 -9
  36. package/dist/security/security-audit.js +34 -7
  37. package/examples/README.md +94 -94
  38. package/examples/codex-doctor-generic-mcp/.mcp.json +10 -10
  39. package/examples/codex-doctor-generic-mcp/server.js +1 -1
  40. package/examples/codex-doctor-risky/.codex-plugin/plugin.json +7 -7
  41. package/examples/codex-doctor-risky/.mcp.json +12 -12
  42. package/examples/codex-doctor-risky/mock-server.js +1 -1
  43. package/examples/codex-doctor-runtime/.codex-plugin/plugin.json +8 -8
  44. package/examples/codex-doctor-runtime/.mcp.json +9 -9
  45. package/examples/codex-doctor-runtime/mock-server.js +237 -237
  46. package/examples/codex-doctor-runtime/skills/context-check/SKILL.md +7 -7
  47. package/examples/codex-doctor-starter/.codex-plugin/plugin.json +7 -7
  48. package/examples/codex-doctor-starter/skills/repo-scan/SKILL.md +7 -7
  49. 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
- Output formats:
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.50.0
441
+ - uses: Esquetta/CodexPluginDoctor@v1.52.0
431
442
  with:
432
- version: "1.50.0"
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,2 @@
1
+ import type { McpConformanceObservation, McpConformanceResult } from "../domain/types.js";
2
+ export declare function evaluateMcpConformance(observation: McpConformanceObservation): McpConformanceResult;
@@ -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
+ }