From 3768dc8933d513137cd780ee0012d8cf12ceb3c4 Mon Sep 17 00:00:00 2001 From: Furkan Date: Wed, 22 Jul 2026 09:45:32 +0300 Subject: [PATCH 01/20] docs: design MCP 2025-11 conformance checks --- docs/architecture/mcp-2025-11-conformance.md | 259 +++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 docs/architecture/mcp-2025-11-conformance.md diff --git a/docs/architecture/mcp-2025-11-conformance.md b/docs/architecture/mcp-2025-11-conformance.md new file mode 100644 index 0000000..6765eee --- /dev/null +++ b/docs/architecture/mcp-2025-11-conformance.md @@ -0,0 +1,259 @@ +# MCP 2025-11 Conformance + +## Status + +Approved design for the release following `v1.50.0`. + +## Purpose + +Codex Plugin Doctor currently validates the established MCP initialization, tools, resources, prompts, pagination, and selected tool-call surfaces. MCP `2025-11-25` adds protocol semantics that can make a server appear healthy while its declared capabilities remain internally inconsistent. + +This feature adds version-aware, read-only conformance checks to the existing runtime validation flow. It detects declaration and schema defects without creating tasks, invoking model sampling, opening elicitation flows, or changing server state. + +## Goals + +- evaluate MCP rules against the protocol version negotiated during initialization +- preserve valid behavior for servers using older supported protocol versions +- validate MCP `2025-11-25` task declarations and tool-level task support +- validate the structural readiness of tool schemas for the negotiated schema dialect +- perform only explicitly safe, read-only runtime requests +- expose additive conformance results through existing text, Markdown, and JSON reports +- keep conformance rules independent from transport and process lifecycle code + +## Non-Goals + +- creating, polling, retrieving, or cancelling tasks +- invoking tools to test task execution +- accepting or rejecting elicitation requests +- handling URL-mode elicitation in a browser +- servicing `sampling/createMessage` requests +- validating remote HTTP authorization or OAuth discovery +- adding a new top-level CLI command +- requiring older servers to implement capabilities introduced after their negotiated version +- building a generic external rule-pack engine + +## User Experience + +The checks run automatically anywhere the existing runtime probe runs, including `check --runtime` and `mcp`. No new flag is required. + +Existing report fields remain unchanged. A new additive `conformance` section records: + +- negotiated protocol version +- selected conformance profile +- capability consistency status +- task declaration consistency status +- task list probe status +- schema dialect readiness status +- overall `pass`, `warn`, `fail`, or `skipped` status + +Text and Markdown output show the same statuses in the runtime scorecard. JSON output keeps machine-readable detail but never includes task objects or task identifiers. + +## Architecture + +### Runtime Probe + +`runtime-probe.ts` remains responsible for process lifecycle, JSON-RPC transport, timeouts, initialization, and safe request collection. It passes normalized protocol observations to the conformance evaluator: + +- negotiated protocol version +- server capabilities from `initialize` +- tool definitions from `tools/list` +- the shape-only outcome of an optional `tasks/list` probe + +The runtime probe does not contain version-specific policy beyond deciding whether a read-only method may be requested. + +### Conformance Evaluator + +A dedicated core module evaluates normalized observations and returns: + +- conformance profile identity +- check statuses +- findings with stable IDs +- an aggregate conformance status + +The evaluator is deterministic and has no process, network, filesystem, clock, or environment dependencies. Unit tests can therefore cover protocol-version and capability combinations without spawning an MCP server. + +### Reporting + +Existing runtime result and scorecard types gain additive conformance fields. Text, Markdown, JSON, runtime plan, and public output contract surfaces are updated together. + +No existing finding ID, severity, default runtime behavior, or command invocation changes. + +## Protocol Profiles + +### Known Older Versions + +For a known version before `2025-11-25`: + +- run existing common MCP validation +- mark task-specific checks as `skipped` +- do not require Tasks, URL elicitation, sampling tools, or JSON Schema 2020-12 declarations introduced by later specifications +- do not send `tasks/list` + +### MCP 2025-11-25 + +Apply common validation and the conformance rules defined below. + +### Unknown Newer Versions + +For a syntactically valid version newer than the latest profile known to the validator: + +- apply the latest known safe common and `2025-11-25` structural checks +- emit a warning that the validator does not fully understand the negotiated version +- do not fail solely because the protocol version is newer +- do not probe methods unknown to the validator + +Malformed or missing negotiated protocol versions remain protocol failures rather than unknown-version warnings. + +## Conformance Rules + +### Capability Shape + +- `capabilities.tasks`, when present for `2025-11-25`, must be an object +- `tasks.list` and `tasks.cancel`, when present, must use the protocol capability object shape +- `tasks.requests`, when present, must contain recognized nested capability objects +- malformed capability values fail conformance +- unrecognized additive capability keys are preserved as forward-compatible and do not fail validation + +### Tool Task Support + +For each tool returned by `tools/list`: + +- `execution`, when present, must be an object +- `execution.taskSupport`, when present, must be `required`, `optional`, or `forbidden` +- `required` or `optional` requires server capability `tasks.requests.tools.call` +- absent `taskSupport` is equivalent to `forbidden` +- a server may declare task request support without exposing a task-capable tool; this is valid + +Malformed values fail. Capability mismatches fail because clients cannot safely determine the required invocation form. + +### Safe Task List Probe + +Send `tasks/list` only when all conditions hold: + +- negotiated protocol version is `2025-11-25` or a newer unknown version using the latest safe profile +- the server explicitly declares `tasks.list` +- runtime probing is enabled + +The probe validates only the JSON-RPC result envelope, task-list container shape, pagination cursor shape, and aggregate item count. It must not retain, render, log, or return task records or task IDs. + +If the server does not declare `tasks.list`, the check is `skipped`. If it declares support but returns method-not-found, malformed output, or a timeout, the check fails with a method-specific finding. + +No `tasks/get`, `tasks/result`, `tasks/cancel`, task-augmented request, sampling, or elicitation method is sent. + +### Schema Dialect Readiness + +For `2025-11-25`, JSON Schema 2020-12 is the default dialect for MCP embedded schemas. The evaluator performs structural checks rather than implementing a complete JSON Schema validator: + +- tool `inputSchema` and `outputSchema`, when present, must be objects +- an explicit `$schema`, when present, must be a valid absolute URI string +- the known 2020-12 dialect URI is accepted +- malformed schema containers or invalid `$schema` values fail +- an omitted `$schema` is valid because the protocol defines the default dialect +- unsupported but syntactically valid explicit dialect URIs warn instead of failing + +The feature does not attempt semantic evaluation of every JSON Schema keyword. + +## Finding Semantics + +Stable finding IDs use the `mcp.conformance` namespace. The initial catalog covers: + +- unknown newer protocol version +- malformed Tasks capability +- invalid tool task support value +- task-support capability mismatch +- `tasks/list` timeout +- `tasks/list` invalid result +- invalid embedded schema dialect declaration + +Findings caused by malformed data or declared-but-broken behavior are failures. Forward-compatibility uncertainty and valid but unsupported schema dialect declarations are warnings. Non-applicable checks are represented as skipped scorecard entries and do not emit findings. + +## Data Flow + +1. Start the configured MCP server using the existing approved runtime path. +2. Send `initialize` and validate the response using existing rules. +3. Select a conformance profile from the negotiated protocol version. +4. Run existing capability-directed tools, resources, and prompts probes. +5. Collect tool declarations without changing tool-call behavior. +6. If safely declared, send one bounded `tasks/list` request. +7. Immediately reduce the task response to shape status, count, and pagination status; discard payload records. +8. Evaluate all normalized observations in the conformance module. +9. Merge findings and conformance scorecard fields into the existing runtime result. +10. Render additive text, Markdown, JSON, and contract output. + +## Security And Privacy + +- active task, sampling, elicitation, and state-changing requests are prohibited +- `tasks/list` uses the existing runtime timeout and payload-size limits +- task IDs, status messages, metadata, result references, and authorization context are never retained in reports or transcripts +- verbose runtime transcripts represent `tasks/list` responses only as a redacted shape summary +- capability and schema validation does not fetch external `$schema` URIs +- unknown future methods are never invoked automatically +- Docker sandbox and runtime approval behavior remain unchanged + +## Error Handling + +- a missing or malformed negotiated version follows existing initialize failure behavior +- an older known version skips non-applicable checks +- an unknown newer version warns and uses the latest safe structural profile +- a declared method that times out or returns malformed data fails that method's conformance check +- failure of one conformance check does not prevent safe report generation or process cleanup +- process termination and sandbox cleanup continue through the existing runtime finalization path + +## Compatibility + +This is an additive minor-release feature: + +- runtime probing remains opt-in +- command syntax remains unchanged +- existing JSON fields remain unchanged +- new JSON fields are additive and documented in `doctor contract` +- older compliant servers do not receive new requests +- servers without Tasks support do not receive task requests +- existing validation severities and finding IDs remain stable + +## Testing + +### Unit Tests + +- profile selection for older, `2025-11-25`, and unknown newer versions +- valid and malformed Tasks capability structures +- all `taskSupport` values and capability combinations +- schema dialect omission, valid 2020-12 URI, malformed URI, and unsupported valid URI +- deterministic aggregate status and finding IDs + +### Runtime Fixtures + +- valid `2025-11-25` server without Tasks +- valid task-capable server with redacted `tasks/list` +- task capability mismatch +- invalid task support declaration +- malformed and timed-out `tasks/list` +- older server proving no task request is sent +- unknown newer server proving only known safe requests are sent + +### Regression Tests + +- text, Markdown, and JSON report snapshots +- output contract additions +- runtime plan method list +- transcript redaction with synthetic sensitive task fields +- assertion that no state-changing task, sampling, or elicitation method is sent +- complete existing test suite and release checks + +## Acceptance Criteria + +- existing valid runtime fixture remains passing +- older protocol fixtures receive no `tasks/list` request +- valid `2025-11-25` task declarations pass +- invalid capability and task-support combinations produce stable failures +- declared `tasks.list` is probed once and bounded by existing limits +- no task record or identifier appears in text, Markdown, JSON, SARIF, transcript, or artifact output +- unknown newer protocol versions warn without failing solely for being newer +- all existing public commands remain backward-compatible +- full tests, build, security self-scan, and release dry-run pass + +## References + +- [MCP 2025-11-25 changelog](https://modelcontextprotocol.io/specification/2025-11-25/changelog) +- [MCP Tasks](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks) +- [MCP Elicitation](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) From 2aeb9ef4dfd6a77d11ab366c0c5782fed4e3c88e Mon Sep 17 00:00:00 2001 From: Furkan Date: Wed, 22 Jul 2026 10:13:45 +0300 Subject: [PATCH 02/20] feat: add MCP conformance evaluator --- src/core/mcp-conformance.ts | 368 ++++++++++++++++++++++++++++++++++ src/domain/types.ts | 42 ++++ tests/mcp-conformance.test.ts | 224 +++++++++++++++++++++ 3 files changed, 634 insertions(+) create mode 100644 src/core/mcp-conformance.ts create mode 100644 tests/mcp-conformance.test.ts diff --git a/src/core/mcp-conformance.ts b/src/core/mcp-conformance.ts new file mode 100644 index 0000000..570b7f1 --- /dev/null +++ b/src/core/mcp-conformance.ts @@ -0,0 +1,368 @@ +import type { + Finding, + FindingEvidence, + McpConformanceObservation, + McpConformanceProfile, + McpConformanceResult, + RuntimeCapabilityStatus, + RuntimeConformanceScorecard, + TasksListObservation +} from "../domain/types.js"; + +type JsonObject = Record; + +const LATEST_PROTOCOL_VERSION = "2025-11-25"; +const LEGACY_PROTOCOL_VERSIONS = new Set([ + "2024-11-05", + "2025-03-26", + "2025-06-18" +]); +const CANONICAL_SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema"; + +function isPlainObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function buildFinding( + id: string, + severity: Finding["severity"], + message: string, + impact: string, + suggestedFix: string, + evidence?: FindingEvidence +): Finding { + return { + id, + severity, + message, + impact, + suggestedFix, + ...(evidence ? { evidence } : {}) + }; +} + +function classifyProtocolVersion(protocolVersion: string): McpConformanceProfile { + if (LEGACY_PROTOCOL_VERSIONS.has(protocolVersion)) { + return "legacy"; + } + + if (protocolVersion === LATEST_PROTOCOL_VERSION) { + return "2025-11-25"; + } + + if (/^\d{4}-\d{2}-\d{2}$/.test(protocolVersion) && protocolVersion > LATEST_PROTOCOL_VERSION) { + return "future-compatible"; + } + + return "legacy"; +} + +function addTaskCapabilityFailure(findings: Finding[], field: string): void { + 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 } + ) + ); +} + +function hasTaskToolsCallCapability(capabilities: unknown, findings: Finding[]): boolean { + if (!isPlainObject(capabilities)) { + addTaskCapabilityFailure(findings, "capabilities"); + return false; + } + + const tasks = capabilities.tasks; + if (tasks === undefined) { + return false; + } + + if (!isPlainObject(tasks)) { + addTaskCapabilityFailure(findings, "capabilities.tasks"); + return false; + } + + for (const key of ["list", "cancel"] as const) { + if (tasks[key] !== undefined && !isPlainObject(tasks[key])) { + addTaskCapabilityFailure(findings, `capabilities.tasks.${key}`); + } + } + + const requests = tasks.requests; + if (requests === undefined) { + return false; + } + + if (!isPlainObject(requests)) { + addTaskCapabilityFailure(findings, "capabilities.tasks.requests"); + return false; + } + + const tools = requests.tools; + if (tools === undefined) { + return false; + } + + if (!isPlainObject(tools)) { + addTaskCapabilityFailure(findings, "capabilities.tasks.requests.tools"); + return false; + } + + const call = tools.call; + if (call === undefined) { + return false; + } + + if (!isPlainObject(call)) { + addTaskCapabilityFailure(findings, "capabilities.tasks.requests.tools.call"); + return false; + } + + return true; +} + +function collectTaskDeclarationFindings( + observation: McpConformanceObservation, + taskCallSupported: boolean, + findings: Finding[] +): void { + for (const tool of observation.tools) { + let taskSupport: unknown = "forbidden"; + + if (tool.execution !== undefined) { + if (!isPlainObject(tool.execution)) { + 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 } + ) + ); + continue; + } + + taskSupport = tool.execution.taskSupport ?? "forbidden"; + } + + if ( + taskSupport !== "required" && + taskSupport !== "optional" && + taskSupport !== "forbidden" + ) { + 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 } + ) + ); + continue; + } + + if (taskSupport !== "forbidden" && !taskCallSupported) { + 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 } + ) + ); + } + } +} + +function isAbsoluteUri(value: string): boolean { + try { + return new URL(value).protocol.length > 0; + } catch { + return false; + } +} + +function collectSchemaFindings(observation: McpConformanceObservation, findings: Finding[]): void { + for (const tool of observation.tools) { + const schemas: Array<["inputSchema" | "outputSchema", unknown]> = [ + ["inputSchema", tool.inputSchema] + ]; + + if (tool.outputSchema !== undefined) { + schemas.push(["outputSchema", tool.outputSchema]); + } + + for (const [schemaName, schema] of schemas) { + if (!isPlainObject(schema)) { + 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 } + ) + ); + continue; + } + + const dialect = schema.$schema; + if (dialect === undefined) { + continue; + } + + if (typeof dialect !== "string" || !isAbsoluteUri(dialect)) { + 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 } + ) + ); + continue; + } + + if (dialect !== CANONICAL_SCHEMA_DIALECT && dialect !== `${CANONICAL_SCHEMA_DIALECT}#`) { + 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 } + ) + ); + } + } + } +} + +function collectTasksListFinding(observation: TasksListObservation | undefined, findings: Finding[]): RuntimeCapabilityStatus { + if (!observation || observation.status === "skipped") { + return "skipped"; + } + + if (observation.status === "pass") { + return "pass"; + } + + const timeout = observation.failure === "timeout"; + findings.push( + buildFinding( + timeout + ? "mcp.conformance.tasks_list.timeout" + : "mcp.conformance.tasks_list.invalid", + "fail", + timeout + ? "The server did not complete tasks/list within the observation window." + : "The server returned an invalid tasks/list observation.", + "Codex cannot rely on task discovery when tasks/list cannot complete with a valid response.", + timeout + ? "Reduce tasks/list latency and verify pagination completes." + : "Return a valid tasks/list response with well-formed task items and pagination." + ) + ); + return "fail"; +} + +function statusForFindings(findings: Finding[]): RuntimeCapabilityStatus { + if (findings.some((finding) => finding.severity === "fail")) { + return "fail"; + } + + if (findings.some((finding) => finding.severity === "warn")) { + return "warn"; + } + + return "pass"; +} + +function overallStatus(scorecard: RuntimeConformanceScorecard): "pass" | "warn" | "fail" { + const statuses = [ + scorecard.protocolVersion, + scorecard.capabilityConsistency, + scorecard.taskDeclarations, + scorecard.tasksList, + scorecard.schemaDialect + ]; + + if (statuses.includes("fail")) { + return "fail"; + } + + if (statuses.includes("warn")) { + return "warn"; + } + + return "pass"; +} + +export function evaluateMcpConformance( + observation: McpConformanceObservation +): McpConformanceResult { + const profile = classifyProtocolVersion(observation.protocolVersion); + const findings: Finding[] = []; + const scorecard: RuntimeConformanceScorecard = { + protocolVersion: "pass", + profile, + capabilityConsistency: "skipped", + taskDeclarations: "skipped", + tasksList: "skipped", + schemaDialect: "skipped", + overall: "pass" + }; + + if (profile === "future-compatible") { + 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 } + ) + ); + scorecard.protocolVersion = "warn"; + } + + if (profile === "legacy") { + scorecard.overall = overallStatus(scorecard); + return { findings, scorecard }; + } + + const capabilityFindings: Finding[] = []; + const taskCallSupported = hasTaskToolsCallCapability(observation.capabilities, capabilityFindings); + findings.push(...capabilityFindings); + scorecard.capabilityConsistency = statusForFindings(capabilityFindings); + + const declarationFindings: Finding[] = []; + collectTaskDeclarationFindings(observation, taskCallSupported, declarationFindings); + findings.push(...declarationFindings); + scorecard.taskDeclarations = statusForFindings(declarationFindings); + + scorecard.tasksList = collectTasksListFinding(observation.tasksList, findings); + + const schemaFindings: Finding[] = []; + collectSchemaFindings(observation, schemaFindings); + findings.push(...schemaFindings); + scorecard.schemaDialect = statusForFindings(schemaFindings); + scorecard.overall = overallStatus(scorecard); + + return { findings, scorecard }; +} diff --git a/src/domain/types.ts b/src/domain/types.ts index 8fc927d..bd0280f 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -110,6 +110,48 @@ export interface RuntimeScorecard { resourceTemplatesList: RuntimeCapabilityStatus; promptsList: RuntimeCapabilityStatus; promptGet: RuntimeCapabilityStatus; + conformance?: RuntimeConformanceScorecard; +} + +export type McpConformanceProfile = + | "legacy" + | "2025-11-25" + | "future-compatible"; + +export interface RuntimeConformanceScorecard { + protocolVersion: RuntimeCapabilityStatus; + profile: McpConformanceProfile; + capabilityConsistency: RuntimeCapabilityStatus; + taskDeclarations: RuntimeCapabilityStatus; + tasksList: RuntimeCapabilityStatus; + schemaDialect: RuntimeCapabilityStatus; + overall: "pass" | "warn" | "fail"; +} + +export interface McpToolObservation { + name: string; + inputSchema: unknown; + outputSchema?: unknown; + execution?: unknown; +} + +export interface TasksListObservation { + status: "pass" | "fail" | "skipped"; + itemCount: number; + pageCount: number; + failure?: "timeout" | "invalid"; +} + +export interface McpConformanceObservation { + protocolVersion: string; + capabilities: unknown; + tools: McpToolObservation[]; + tasksList?: TasksListObservation; +} + +export interface McpConformanceResult { + findings: Finding[]; + scorecard: RuntimeConformanceScorecard; } export interface RuntimeProbeResult { diff --git a/tests/mcp-conformance.test.ts b/tests/mcp-conformance.test.ts new file mode 100644 index 0000000..0d96bfd --- /dev/null +++ b/tests/mcp-conformance.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from "vitest"; + +import { evaluateMcpConformance } from "../src/core/mcp-conformance.js"; + +const latestCapabilities = { + tasks: { + list: {}, + requests: { + tools: { + call: {} + } + } + } +}; + +function observe(overrides: Record = {}) { + return { + protocolVersion: "2025-11-25", + capabilities: {}, + tools: [], + ...overrides + }; +} + +describe("MCP 2025-11 conformance", () => { + it.each([ + ["2024-11-05", "legacy"], + ["2025-03-26", "legacy"], + ["2025-06-18", "legacy"], + ["2025-11-25", "2025-11-25"], + ["2026-01-01", "future-compatible"] + ] as const)("classifies %s as %s", (protocolVersion, profile) => { + const result = evaluateMcpConformance(observe({ protocolVersion })); + + expect(result.scorecard.profile).toBe(profile); + }); + + it("skips task and schema checks for legacy protocol versions", () => { + const result = evaluateMcpConformance( + observe({ + protocolVersion: "2025-06-18", + capabilities: { tasks: "invalid" }, + tools: [ + { + name: "legacy-tool", + inputSchema: "invalid", + execution: { taskSupport: "invalid" } + } + ], + tasksList: { status: "fail", itemCount: 0, pageCount: 0, failure: "timeout" } + }) + ); + + expect(result.findings).toEqual([]); + expect(result.scorecard).toMatchObject({ + capabilityConsistency: "skipped", + taskDeclarations: "skipped", + tasksList: "skipped", + schemaDialect: "skipped", + overall: "pass" + }); + }); + + it("warns for a syntactically newer protocol and applies the latest checks", () => { + const result = evaluateMcpConformance( + observe({ protocolVersion: "2026-01-01" }) + ); + + expect(result.findings.map((finding) => finding.id)).toEqual([ + "mcp.conformance.protocol.unknown_newer" + ]); + expect(result.scorecard).toMatchObject({ + protocolVersion: "warn", + overall: "warn" + }); + }); + + it("accepts additive task capability keys when every known task capability is an object", () => { + const result = evaluateMcpConformance( + observe({ + capabilities: { + ...latestCapabilities, + vendorExtension: true, + tasks: { + ...latestCapabilities.tasks, + extension: { future: true } + } + }, + tools: [ + { + name: "task-tool", + inputSchema: {}, + execution: { taskSupport: "optional", extension: true } + } + ] + }) + ); + + expect(result.findings).toEqual([]); + expect(result.scorecard).toMatchObject({ + capabilityConsistency: "pass", + taskDeclarations: "pass", + schemaDialect: "pass", + overall: "pass" + }); + }); + + it("rejects malformed known task capability objects", () => { + const result = evaluateMcpConformance( + observe({ capabilities: { tasks: { requests: { tools: { call: true } } } } }) + ); + + expect(result.findings).toEqual([ + expect.objectContaining({ + id: "mcp.conformance.tasks.capability_invalid", + severity: "fail", + evidence: { field: "capabilities.tasks.requests.tools.call" } + }) + ]); + expect(result.scorecard.capabilityConsistency).toBe("fail"); + }); + + it.each([ + [undefined, "pass", []], + ["forbidden", "pass", []], + ["optional", "fail", ["mcp.conformance.tasks.capability_mismatch"]], + ["required", "fail", ["mcp.conformance.tasks.capability_mismatch"]], + ["unexpected", "fail", ["mcp.conformance.tasks.task_support_invalid"]] + ] as const)( + "evaluates taskSupport %s", + (taskSupport, expectedStatus, expectedFindingIds) => { + const execution = taskSupport === undefined ? undefined : { taskSupport }; + const result = evaluateMcpConformance( + observe({ + tools: [{ name: "task-tool", inputSchema: {}, ...(execution ? { execution } : {}) }] + }) + ); + + expect(result.scorecard.taskDeclarations).toBe(expectedStatus); + expect(result.findings.map((finding) => finding.id)).toEqual(expectedFindingIds); + } + ); + + it("requires the tools/call task capability when task support is declared", () => { + const result = evaluateMcpConformance( + observe({ + capabilities: { tasks: { requests: { tools: {} } } }, + tools: [{ name: "task-tool", inputSchema: {}, execution: { taskSupport: "required" } }] + }) + ); + + expect(result.findings).toEqual([ + expect.objectContaining({ + id: "mcp.conformance.tasks.capability_mismatch", + evidence: { toolName: "task-tool", taskSupport: "required" } + }) + ]); + }); + + it("reports timeout and invalid task list observations", () => { + const timeout = evaluateMcpConformance( + observe({ tasksList: { status: "fail", itemCount: 0, pageCount: 1, failure: "timeout" } }) + ); + const invalid = evaluateMcpConformance( + observe({ tasksList: { status: "fail", itemCount: 0, pageCount: 1, failure: "invalid" } }) + ); + + expect(timeout.findings.map((finding) => finding.id)).toEqual([ + "mcp.conformance.tasks_list.timeout" + ]); + expect(invalid.findings.map((finding) => finding.id)).toEqual([ + "mcp.conformance.tasks_list.invalid" + ]); + expect(timeout.scorecard.tasksList).toBe("fail"); + expect(invalid.scorecard.tasksList).toBe("fail"); + }); + + it.each([ + ["omitted", {}, "pass", []], + ["canonical", { $schema: "https://json-schema.org/draft/2020-12/schema" }, "pass", []], + ["canonical trailing hash", { $schema: "https://json-schema.org/draft/2020-12/schema#" }, "pass", []], + ["malformed", { $schema: "not a URI" }, "fail", ["mcp.conformance.schema.dialect_invalid"]], + ["unsupported", { $schema: "https://json-schema.org/draft/2019-09/schema" }, "warn", ["mcp.conformance.schema.dialect_unsupported"]] + ] as const)("handles %s schema dialects", (_caseName, inputSchema, expectedStatus, ids) => { + const result = evaluateMcpConformance( + observe({ tools: [{ name: "schema-tool", inputSchema }] }) + ); + + expect(result.scorecard.schemaDialect).toBe(expectedStatus); + expect(result.findings.map((finding) => finding.id)).toEqual(ids); + }); + + it("rejects non-object schema containers and non-string schema dialects", () => { + const result = evaluateMcpConformance( + observe({ + tools: [ + { name: "input", inputSchema: [] }, + { name: "output", inputSchema: {}, outputSchema: { $schema: 202012 } } + ] + }) + ); + + expect(result.findings.map((finding) => finding.id)).toEqual([ + "mcp.conformance.schema.dialect_invalid", + "mcp.conformance.schema.dialect_invalid" + ]); + expect(result.scorecard).toMatchObject({ schemaDialect: "fail", overall: "fail" }); + }); + + it("aggregates deterministically with failure taking precedence over warnings", () => { + const result = evaluateMcpConformance( + observe({ + protocolVersion: "2026-01-01", + tools: [{ name: "schema-tool", inputSchema: { $schema: "not a URI" } }] + }) + ); + + expect(result.findings.map((finding) => finding.id)).toEqual([ + "mcp.conformance.protocol.unknown_newer", + "mcp.conformance.schema.dialect_invalid" + ]); + expect(result.scorecard.overall).toBe("fail"); + }); +}); From 138a338828c694da0107d7c4cfc5fe5939ee994a Mon Sep 17 00:00:00 2001 From: Furkan Date: Wed, 22 Jul 2026 10:20:39 +0300 Subject: [PATCH 03/20] feat: add MCP conformance evaluator --- src/core/mcp-conformance.ts | 17 +++++++++-------- src/domain/types.ts | 2 +- tests/mcp-conformance.test.ts | 2 +- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/core/mcp-conformance.ts b/src/core/mcp-conformance.ts index 570b7f1..100a3ea 100644 --- a/src/core/mcp-conformance.ts +++ b/src/core/mcp-conformance.ts @@ -292,20 +292,22 @@ function statusForFindings(findings: Finding[]): RuntimeCapabilityStatus { return "pass"; } -function overallStatus(scorecard: RuntimeConformanceScorecard): "pass" | "warn" | "fail" { +function overallStatus( + scorecard: RuntimeConformanceScorecard, + findings: Finding[] +): "pass" | "warn" | "fail" { const statuses = [ - scorecard.protocolVersion, scorecard.capabilityConsistency, scorecard.taskDeclarations, scorecard.tasksList, scorecard.schemaDialect ]; - if (statuses.includes("fail")) { + if (statuses.includes("fail") || findings.some((finding) => finding.severity === "fail")) { return "fail"; } - if (statuses.includes("warn")) { + if (statuses.includes("warn") || findings.some((finding) => finding.severity === "warn")) { return "warn"; } @@ -318,7 +320,7 @@ export function evaluateMcpConformance( const profile = classifyProtocolVersion(observation.protocolVersion); const findings: Finding[] = []; const scorecard: RuntimeConformanceScorecard = { - protocolVersion: "pass", + protocolVersion: observation.protocolVersion, profile, capabilityConsistency: "skipped", taskDeclarations: "skipped", @@ -338,11 +340,10 @@ export function evaluateMcpConformance( { protocolVersion: observation.protocolVersion } ) ); - scorecard.protocolVersion = "warn"; } if (profile === "legacy") { - scorecard.overall = overallStatus(scorecard); + scorecard.overall = overallStatus(scorecard, findings); return { findings, scorecard }; } @@ -362,7 +363,7 @@ export function evaluateMcpConformance( collectSchemaFindings(observation, schemaFindings); findings.push(...schemaFindings); scorecard.schemaDialect = statusForFindings(schemaFindings); - scorecard.overall = overallStatus(scorecard); + scorecard.overall = overallStatus(scorecard, findings); return { findings, scorecard }; } diff --git a/src/domain/types.ts b/src/domain/types.ts index bd0280f..a5665d8 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -119,7 +119,7 @@ export type McpConformanceProfile = | "future-compatible"; export interface RuntimeConformanceScorecard { - protocolVersion: RuntimeCapabilityStatus; + protocolVersion: string | null; profile: McpConformanceProfile; capabilityConsistency: RuntimeCapabilityStatus; taskDeclarations: RuntimeCapabilityStatus; diff --git a/tests/mcp-conformance.test.ts b/tests/mcp-conformance.test.ts index 0d96bfd..ec67afc 100644 --- a/tests/mcp-conformance.test.ts +++ b/tests/mcp-conformance.test.ts @@ -70,7 +70,7 @@ describe("MCP 2025-11 conformance", () => { "mcp.conformance.protocol.unknown_newer" ]); expect(result.scorecard).toMatchObject({ - protocolVersion: "warn", + protocolVersion: "2026-01-01", overall: "warn" }); }); From fa3d3e8ca94497fc979b930fc8dea746dee190e0 Mon Sep 17 00:00:00 2001 From: Furkan Date: Wed, 22 Jul 2026 10:30:04 +0300 Subject: [PATCH 04/20] test: cover MCP output schema dialects --- tests/mcp-conformance.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/mcp-conformance.test.ts b/tests/mcp-conformance.test.ts index ec67afc..3218070 100644 --- a/tests/mcp-conformance.test.ts +++ b/tests/mcp-conformance.test.ts @@ -190,6 +190,21 @@ describe("MCP 2025-11 conformance", () => { expect(result.findings.map((finding) => finding.id)).toEqual(ids); }); + it.each([ + ["omitted", {}, "pass", []], + ["canonical", { $schema: "https://json-schema.org/draft/2020-12/schema" }, "pass", []], + ["canonical trailing hash", { $schema: "https://json-schema.org/draft/2020-12/schema#" }, "pass", []], + ["malformed", { $schema: "not a URI" }, "fail", ["mcp.conformance.schema.dialect_invalid"]], + ["unsupported", { $schema: "https://json-schema.org/draft/2019-09/schema" }, "warn", ["mcp.conformance.schema.dialect_unsupported"]] + ] as const)("handles %s output schema dialects", (_caseName, outputSchema, expectedStatus, ids) => { + const result = evaluateMcpConformance( + observe({ tools: [{ name: "schema-tool", inputSchema: {}, outputSchema }] }) + ); + + expect(result.scorecard.schemaDialect).toBe(expectedStatus); + expect(result.findings.map((finding) => finding.id)).toEqual(ids); + }); + it("rejects non-object schema containers and non-string schema dialects", () => { const result = evaluateMcpConformance( observe({ From 4509a455f6047f7d12b6925768242dcabeeaf556 Mon Sep 17 00:00:00 2001 From: Furkan Date: Wed, 22 Jul 2026 10:37:44 +0300 Subject: [PATCH 05/20] fix: avoid duplicate MCP task findings --- src/core/mcp-conformance.ts | 38 +++++++++++++++++++++-------------- tests/mcp-conformance.test.ts | 19 ++++++++++++++++++ 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/src/core/mcp-conformance.ts b/src/core/mcp-conformance.ts index 100a3ea..d347821 100644 --- a/src/core/mcp-conformance.ts +++ b/src/core/mcp-conformance.ts @@ -10,6 +10,7 @@ import type { } from "../domain/types.js"; type JsonObject = Record; +type TaskToolsCallCapabilityState = "present-valid" | "missing" | "malformed"; const LATEST_PROTOCOL_VERSION = "2025-11-25"; const LEGACY_PROTOCOL_VERSIONS = new Set([ @@ -70,64 +71,68 @@ function addTaskCapabilityFailure(findings: Finding[], field: string): void { ); } -function hasTaskToolsCallCapability(capabilities: unknown, findings: Finding[]): boolean { +function getTaskToolsCallCapabilityState( + capabilities: unknown, + findings: Finding[] +): TaskToolsCallCapabilityState { if (!isPlainObject(capabilities)) { addTaskCapabilityFailure(findings, "capabilities"); - return false; + return "malformed"; } const tasks = capabilities.tasks; if (tasks === undefined) { - return false; + return "missing"; } if (!isPlainObject(tasks)) { addTaskCapabilityFailure(findings, "capabilities.tasks"); - return false; + return "malformed"; } for (const key of ["list", "cancel"] as const) { if (tasks[key] !== undefined && !isPlainObject(tasks[key])) { addTaskCapabilityFailure(findings, `capabilities.tasks.${key}`); + return "malformed"; } } const requests = tasks.requests; if (requests === undefined) { - return false; + return "missing"; } if (!isPlainObject(requests)) { addTaskCapabilityFailure(findings, "capabilities.tasks.requests"); - return false; + return "malformed"; } const tools = requests.tools; if (tools === undefined) { - return false; + return "missing"; } if (!isPlainObject(tools)) { addTaskCapabilityFailure(findings, "capabilities.tasks.requests.tools"); - return false; + return "malformed"; } const call = tools.call; if (call === undefined) { - return false; + return "missing"; } if (!isPlainObject(call)) { addTaskCapabilityFailure(findings, "capabilities.tasks.requests.tools.call"); - return false; + return "malformed"; } - return true; + return "present-valid"; } function collectTaskDeclarationFindings( observation: McpConformanceObservation, - taskCallSupported: boolean, + taskCallCapabilityState: TaskToolsCallCapabilityState, findings: Finding[] ): void { for (const tool of observation.tools) { @@ -169,7 +174,7 @@ function collectTaskDeclarationFindings( continue; } - if (taskSupport !== "forbidden" && !taskCallSupported) { + if (taskSupport !== "forbidden" && taskCallCapabilityState === "missing") { findings.push( buildFinding( "mcp.conformance.tasks.capability_mismatch", @@ -348,12 +353,15 @@ export function evaluateMcpConformance( } const capabilityFindings: Finding[] = []; - const taskCallSupported = hasTaskToolsCallCapability(observation.capabilities, capabilityFindings); + const taskCallCapabilityState = getTaskToolsCallCapabilityState( + observation.capabilities, + capabilityFindings + ); findings.push(...capabilityFindings); scorecard.capabilityConsistency = statusForFindings(capabilityFindings); const declarationFindings: Finding[] = []; - collectTaskDeclarationFindings(observation, taskCallSupported, declarationFindings); + collectTaskDeclarationFindings(observation, taskCallCapabilityState, declarationFindings); findings.push(...declarationFindings); scorecard.taskDeclarations = statusForFindings(declarationFindings); diff --git a/tests/mcp-conformance.test.ts b/tests/mcp-conformance.test.ts index 3218070..617bc52 100644 --- a/tests/mcp-conformance.test.ts +++ b/tests/mcp-conformance.test.ts @@ -120,6 +120,25 @@ describe("MCP 2025-11 conformance", () => { expect(result.scorecard.capabilityConsistency).toBe("fail"); }); + it("does not duplicate malformed task capability findings for task-enabled tools", () => { + const result = evaluateMcpConformance( + observe({ + capabilities: { tasks: { requests: { tools: { call: true } } } }, + tools: [ + { name: "required-tool", inputSchema: {}, execution: { taskSupport: "required" } }, + { name: "optional-tool", inputSchema: {}, execution: { taskSupport: "optional" } } + ] + }) + ); + + expect(result.findings).toEqual([ + expect.objectContaining({ + id: "mcp.conformance.tasks.capability_invalid", + evidence: { field: "capabilities.tasks.requests.tools.call" } + }) + ]); + }); + it.each([ [undefined, "pass", []], ["forbidden", "pass", []], From 0ff86eeccd009c910c70895762dc2f6ad6b2d077 Mon Sep 17 00:00:00 2001 From: Furkan Date: Wed, 22 Jul 2026 10:50:54 +0300 Subject: [PATCH 06/20] fix: isolate MCP task capability states --- src/core/mcp-conformance.ts | 1 - tests/mcp-conformance.test.ts | 18 ++++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/core/mcp-conformance.ts b/src/core/mcp-conformance.ts index d347821..71910ce 100644 --- a/src/core/mcp-conformance.ts +++ b/src/core/mcp-conformance.ts @@ -93,7 +93,6 @@ function getTaskToolsCallCapabilityState( for (const key of ["list", "cancel"] as const) { if (tasks[key] !== undefined && !isPlainObject(tasks[key])) { addTaskCapabilityFailure(findings, `capabilities.tasks.${key}`); - return "malformed"; } } diff --git a/tests/mcp-conformance.test.ts b/tests/mcp-conformance.test.ts index 617bc52..9ea3e5c 100644 --- a/tests/mcp-conformance.test.ts +++ b/tests/mcp-conformance.test.ts @@ -139,6 +139,24 @@ describe("MCP 2025-11 conformance", () => { ]); }); + it.each(["list", "cancel"] as const)( + "reports a missing task call capability alongside a malformed tasks.%s sibling capability", + (siblingCapability) => { + const result = evaluateMcpConformance( + observe({ + capabilities: { tasks: { [siblingCapability]: true, requests: { tools: {} } } }, + tools: [{ name: "task-tool", inputSchema: {}, execution: { taskSupport: "required" } }] + }) + ); + + expect(result.findings.map((finding) => finding.id)).toEqual([ + "mcp.conformance.tasks.capability_invalid", + "mcp.conformance.tasks.capability_mismatch" + ]); + expect(result.scorecard.taskDeclarations).toBe("fail"); + } + ); + it.each([ [undefined, "pass", []], ["forbidden", "pass", []], From 199181dacbb736c56e1efd76dd0bf62a6afbd68e Mon Sep 17 00:00:00 2001 From: Furkan Date: Wed, 22 Jul 2026 11:12:30 +0300 Subject: [PATCH 07/20] feat: integrate version-aware MCP conformance --- src/core/runtime-probe.ts | 203 ++++++++++++------ src/domain/types.ts | 2 +- .../.codex-plugin/plugin.json | 6 + .../runtime-conformance-future/.mcp.json | 8 + .../runtime-conformance-future/mock-server.js | 89 ++++++++ .../.codex-plugin/plugin.json | 6 + .../runtime-conformance-legacy/.mcp.json | 8 + .../runtime-conformance-legacy/mock-server.js | 40 ++++ tests/json-runtime-scorecard.test.ts | 11 +- tests/runtime-protocol.test.ts | 134 +++++++++++- 10 files changed, 441 insertions(+), 66 deletions(-) create mode 100644 tests/fixtures/runtime-conformance-future/.codex-plugin/plugin.json create mode 100644 tests/fixtures/runtime-conformance-future/.mcp.json create mode 100644 tests/fixtures/runtime-conformance-future/mock-server.js create mode 100644 tests/fixtures/runtime-conformance-legacy/.codex-plugin/plugin.json create mode 100644 tests/fixtures/runtime-conformance-legacy/.mcp.json create mode 100644 tests/fixtures/runtime-conformance-legacy/mock-server.js diff --git a/src/core/runtime-probe.ts b/src/core/runtime-probe.ts index 03b48e8..4a46afa 100644 --- a/src/core/runtime-probe.ts +++ b/src/core/runtime-probe.ts @@ -8,11 +8,13 @@ import type { DiscoveredPackage, Finding, FindingEvidence, + McpToolObservation, RuntimeExecutionEvidence, RuntimeProbeResult, RuntimeSandboxMode, RuntimeScorecard } from "../domain/types.js"; +import { evaluateMcpConformance } from "./mcp-conformance.js"; import { buildRuntimeLaunch, DOCKER_RUNTIME_STARTUP_TIMEOUT_MS, @@ -34,6 +36,13 @@ const DOCKER_CLEANUP_TIMEOUT_MS = 5_000; type JsonObject = Record; type ToolDefinition = { + name: string; + inputSchema: unknown; + outputSchema?: unknown; + execution?: unknown; +}; + +type CallableToolDefinition = { name: string; inputSchema: JsonObject; }; @@ -97,7 +106,16 @@ function createRuntimeScorecard(): RuntimeScorecard { resourceRead: "unsupported", resourceTemplatesList: "unsupported", promptsList: "unsupported", - promptGet: "unsupported" + promptGet: "unsupported", + conformance: { + protocolVersion: null, + profile: null, + capabilityConsistency: "skipped", + taskDeclarations: "skipped", + tasksList: "skipped", + schemaDialect: "skipped", + overall: "pass" + } }; } @@ -136,6 +154,19 @@ function buildWarning( } function methodForRuntimeFinding(id: string): string { + if ( + id.startsWith("mcp.conformance.protocol.") || + id.startsWith("mcp.conformance.tasks.capability_") + ) { + return "initialize"; + } + if ( + id.startsWith("mcp.conformance.tasks.task_support_") || + id.startsWith("mcp.conformance.schema.") + ) { + return "tools/list"; + } + if (id.startsWith("mcp.conformance.tasks_list.")) return "tasks/list"; if (id.includes(".initialize.")) return "initialize"; if (id.includes(".tools_list.")) return "tools/list"; if (id.includes(".tool_call.")) return "tools/call"; @@ -152,9 +183,9 @@ function withRuntimeEvidence(finding: Finding, serverName: string): Finding { return { ...finding, evidence: { + ...(finding.evidence ?? {}), serverName, - method: methodForRuntimeFinding(finding.id), - ...(finding.evidence ?? {}) + method: methodForRuntimeFinding(finding.id) } }; } @@ -457,15 +488,16 @@ function extractToolsPage( if ( !isPlainObject(tool) || typeof tool.name !== "string" || - !isPlainObject(tool.inputSchema) || - tool.inputSchema.type !== "object" + !("inputSchema" in tool) ) { return null; } parsedTools.push({ name: tool.name, - inputSchema: tool.inputSchema + inputSchema: tool.inputSchema, + ...(tool.outputSchema === undefined ? {} : { outputSchema: tool.outputSchema }), + ...(tool.execution === undefined ? {} : { execution: tool.execution }) }); } @@ -634,7 +666,7 @@ function extractPromptsPage( }; } -function isDestructiveTool(tool: ToolDefinition): boolean { +function isDestructiveTool(tool: Pick): boolean { return /(delete|remove|drop|destroy|erase|wipe|purge|send|deploy|refund|payment|charge|merge|push)/i.test( tool.name ); @@ -690,7 +722,7 @@ function buildSchemaValue( } function buildToolArguments( - tool: ToolDefinition + tool: CallableToolDefinition ): Record | undefined { const schemaValue = buildSchemaValue(tool.inputSchema); @@ -703,17 +735,26 @@ function buildToolArguments( function findCallableTool( tools: ToolDefinition[] -): { tool: ToolDefinition; args: Record } | null { +): { tool: CallableToolDefinition; args: Record } | null { for (const tool of tools) { if (isDestructiveTool(tool)) { continue; } - const args = buildToolArguments(tool); + if (!isPlainObject(tool.inputSchema) || tool.inputSchema.type !== "object") { + continue; + } + + const callableTool: CallableToolDefinition = { + name: tool.name, + inputSchema: tool.inputSchema + }; + + const args = buildToolArguments(callableTool); if (args !== undefined) { return { - tool, + tool: callableTool, args }; } @@ -722,6 +763,16 @@ function findCallableTool( return null; } +function hasValidToolInputSchemas(tools: ToolDefinition[]): boolean { + return tools.every( + (tool) => isPlainObject(tool.inputSchema) && tool.inputSchema.type === "object" + ); +} + +function isProtocolVersion(value: string): boolean { + return /^\d{4}-\d{2}-\d{2}$/.test(value); +} + function buildPromptArguments( prompt: PromptDefinition ): Record | undefined { @@ -1291,6 +1342,7 @@ async function probeCommandServer(input: { if ( typeof result.protocolVersion !== "string" || + !isProtocolVersion(result.protocolVersion) || !isPlainObject(result.capabilities) || !isPlainObject(result.serverInfo) || typeof result.serverInfo.name !== "string" || @@ -1311,10 +1363,13 @@ async function probeCommandServer(input: { scorecard.initialize = "pass"; sendNotification("notifications/initialized"); + let tools: ToolDefinition[] = []; + let toolTerminalFinding: Finding | null = null; + if (!hasToolsCapability(initializeResponse)) { scorecard.toolsList = "unsupported"; scorecard.toolsCall = "unsupported"; - settle( + warnings.push( buildWarning( "plugin.runtime.tools.unsupported", `The MCP server \`${serverName}\` does not advertise tools capability.`, @@ -1322,55 +1377,59 @@ async function probeCommandServer(input: { "Expose `capabilities.tools` during initialize if this server is expected to provide tools." ) ); - return; - } - - const tools = await fetchPaginated({ - method: "tools/list", - timeoutFinding: buildFailure( - "plugin.runtime.tools_list.timeout", - `The MCP server \`${serverName}\` did not answer the tools/list request in time.`, - "A server that cannot return its tool catalog in time will feel broken or invisible in Codex.", - "Inspect the tool discovery path and reduce latency before returning the tool list." - ), - extractPage: extractToolsPage - }); + } else { + const listedTools = await fetchPaginated({ + method: "tools/list", + timeoutFinding: buildFailure( + "plugin.runtime.tools_list.timeout", + `The MCP server \`${serverName}\` did not answer the tools/list request in time.`, + "A server that cannot return its tool catalog in time will feel broken or invisible in Codex.", + "Inspect the tool discovery path and reduce latency before returning the tool list." + ), + extractPage: extractToolsPage + }); - if (!tools) { - scorecard.toolsList = "fail"; - settle( - buildFailure( + if (!listedTools) { + scorecard.toolsList = "fail"; + toolTerminalFinding = buildFailure( "plugin.runtime.tools_list.invalid", `The MCP server \`${serverName}\` returned an invalid tools/list result.`, "Codex cannot safely consume malformed tool definitions from `tools/list`.", "Return a `tools` array where every tool has a string `name` and an object-shaped `inputSchema` with `type: \"object\"`." - ) - ); - return; - } - - scorecard.toolsList = "pass"; + ); + } else { + tools = listedTools; + + if (!hasValidToolInputSchemas(tools)) { + scorecard.toolsList = "fail"; + toolTerminalFinding = buildFailure( + "plugin.runtime.tools_list.invalid", + `The MCP server \`${serverName}\` returned an invalid tools/list result.`, + "Codex cannot safely consume malformed tool definitions from `tools/list`.", + "Return a `tools` array where every tool has a string `name` and an object-shaped `inputSchema` with `type: \"object\"`." + ); + } else { + scorecard.toolsList = "pass"; + } + } - const callableTool = findCallableTool(tools); + const callableTool = toolTerminalFinding ? null : findCallableTool(tools); - if (!callableTool) { - scorecard.toolsCall = "skipped"; - settle( - buildWarning( + if (!toolTerminalFinding && !callableTool) { + scorecard.toolsCall = "skipped"; + toolTerminalFinding = buildWarning( "plugin.runtime.tool_call.skipped", `The MCP server \`${serverName}\` does not expose a safely callable tool for probing.`, "The validator confirmed tool discovery but could not safely perform a non-destructive `tools/call` probe.", "Expose at least one non-destructive tool with a JSON schema the validator can generate arguments for." - ) - ); - return; - } else { - const toolCallResponse = await sendRequest( - "tools/call", - { - name: callableTool.tool.name, - arguments: callableTool.args - }, + ); + } else if (callableTool) { + const toolCallResponse = await sendRequest( + "tools/call", + { + name: callableTool.tool.name, + arguments: callableTool.args + }, buildFailure( "plugin.runtime.tool_call.timeout", `The MCP server \`${serverName}\` did not answer the tools/call request in time.`, @@ -1380,26 +1439,44 @@ async function probeCommandServer(input: { ) ); - if (isErrorResponse(toolCallResponse) || !isValidCallToolResult(toolCallResponse)) { - scorecard.toolsCall = "fail"; - settle( - buildFailure( + if (isErrorResponse(toolCallResponse) || !isValidCallToolResult(toolCallResponse)) { + scorecard.toolsCall = "fail"; + toolTerminalFinding = buildFailure( "plugin.runtime.tool_call.invalid", `The MCP server \`${serverName}\` returned an invalid tools/call result.`, "Codex cannot safely consume malformed tool call results from the server.", "Return a CallToolResult with a `content` array containing valid MCP content blocks.", { toolName: callableTool.tool.name } - ) - ); - return; + ); + } + + if (!toolTerminalFinding) { + scorecard.toolsCall = "pass"; + warnings.push( + ...collectOversizedToolCallWarnings(toolCallResponse, { + toolName: callableTool.tool.name + }) + ); + } } + } - scorecard.toolsCall = "pass"; - warnings.push( - ...collectOversizedToolCallWarnings(toolCallResponse, { - toolName: callableTool.tool.name - }) - ); + const conformance = evaluateMcpConformance({ + protocolVersion: result.protocolVersion, + capabilities: result.capabilities, + tools: tools satisfies McpToolObservation[], + tasksList: { + status: "skipped", + itemCount: 0, + pageCount: 0 + } + }); + scorecard.conformance = conformance.scorecard; + warnings.push(...conformance.findings); + + if (toolTerminalFinding) { + settle(toolTerminalFinding); + return; } if (!hasResourcesCapability(initializeResponse)) { diff --git a/src/domain/types.ts b/src/domain/types.ts index a5665d8..6cf79c5 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -120,7 +120,7 @@ export type McpConformanceProfile = export interface RuntimeConformanceScorecard { protocolVersion: string | null; - profile: McpConformanceProfile; + profile: McpConformanceProfile | null; capabilityConsistency: RuntimeCapabilityStatus; taskDeclarations: RuntimeCapabilityStatus; tasksList: RuntimeCapabilityStatus; diff --git a/tests/fixtures/runtime-conformance-future/.codex-plugin/plugin.json b/tests/fixtures/runtime-conformance-future/.codex-plugin/plugin.json new file mode 100644 index 0000000..ee34f38 --- /dev/null +++ b/tests/fixtures/runtime-conformance-future/.codex-plugin/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "runtime-conformance-future", + "version": "1.0.0", + "description": "Future runtime conformance fixture with a safe tool.", + "mcpServers": "./.mcp.json" +} diff --git a/tests/fixtures/runtime-conformance-future/.mcp.json b/tests/fixtures/runtime-conformance-future/.mcp.json new file mode 100644 index 0000000..4e15cc8 --- /dev/null +++ b/tests/fixtures/runtime-conformance-future/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "futureConformanceServer": { + "command": "node", + "args": ["./mock-server.js"] + } + } +} diff --git a/tests/fixtures/runtime-conformance-future/mock-server.js b/tests/fixtures/runtime-conformance-future/mock-server.js new file mode 100644 index 0000000..af06a0f --- /dev/null +++ b/tests/fixtures/runtime-conformance-future/mock-server.js @@ -0,0 +1,89 @@ +import readline from "node:readline"; + +const forbiddenMethods = new Set([ + "tasks/list", + "tasks/get", + "tasks/result", + "tasks/cancel", + "sampling/createMessage", + "elicitation/create" +]); + +const rl = readline.createInterface({ + input: process.stdin, + crlfDelay: Infinity +}); + +rl.on("line", (line) => { + const message = JSON.parse(line); + + if (forbiddenMethods.has(message.method)) { + throw new Error(`Unexpected method: ${message.method}`); + } + + if (message.method === "initialize") { + process.stdout.write( + `${JSON.stringify({ + jsonrpc: "2.0", + id: message.id, + result: { + protocolVersion: "2026-01-01", + capabilities: { + tools: {} + }, + serverInfo: { + name: "future-conformance-server", + version: "1.0.0" + } + } + })}\n` + ); + return; + } + + if (message.method === "tools/list") { + process.stdout.write( + `${JSON.stringify({ + jsonrpc: "2.0", + id: message.id, + result: { + tools: [ + { + name: "ping", + inputSchema: { + type: "object", + properties: {}, + required: [] + }, + outputSchema: { + type: "object", + properties: {} + }, + execution: { + taskSupport: "forbidden" + } + } + ] + } + })}\n` + ); + return; + } + + if (message.method === "tools/call") { + process.stdout.write( + `${JSON.stringify({ + jsonrpc: "2.0", + id: message.id, + result: { + content: [ + { + type: "text", + text: "pong" + } + ] + } + })}\n` + ); + } +}); diff --git a/tests/fixtures/runtime-conformance-legacy/.codex-plugin/plugin.json b/tests/fixtures/runtime-conformance-legacy/.codex-plugin/plugin.json new file mode 100644 index 0000000..c51f650 --- /dev/null +++ b/tests/fixtures/runtime-conformance-legacy/.codex-plugin/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "runtime-conformance-legacy", + "version": "1.0.0", + "description": "Legacy runtime conformance fixture without tools capability.", + "mcpServers": "./.mcp.json" +} diff --git a/tests/fixtures/runtime-conformance-legacy/.mcp.json b/tests/fixtures/runtime-conformance-legacy/.mcp.json new file mode 100644 index 0000000..cfd72f5 --- /dev/null +++ b/tests/fixtures/runtime-conformance-legacy/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "legacyConformanceServer": { + "command": "node", + "args": ["./mock-server.js"] + } + } +} diff --git a/tests/fixtures/runtime-conformance-legacy/mock-server.js b/tests/fixtures/runtime-conformance-legacy/mock-server.js new file mode 100644 index 0000000..49cb61a --- /dev/null +++ b/tests/fixtures/runtime-conformance-legacy/mock-server.js @@ -0,0 +1,40 @@ +import readline from "node:readline"; + +const forbiddenMethods = new Set([ + "tasks/list", + "tasks/get", + "tasks/result", + "tasks/cancel", + "sampling/createMessage", + "elicitation/create" +]); + +const rl = readline.createInterface({ + input: process.stdin, + crlfDelay: Infinity +}); + +rl.on("line", (line) => { + const message = JSON.parse(line); + + if (forbiddenMethods.has(message.method)) { + throw new Error(`Unexpected method: ${message.method}`); + } + + if (message.method === "initialize") { + process.stdout.write( + `${JSON.stringify({ + jsonrpc: "2.0", + id: message.id, + result: { + protocolVersion: "2025-06-18", + capabilities: {}, + serverInfo: { + name: "legacy-conformance-server", + version: "1.0.0" + } + } + })}\n` + ); + } +}); diff --git a/tests/json-runtime-scorecard.test.ts b/tests/json-runtime-scorecard.test.ts index 1c3c4b0..b28f05b 100644 --- a/tests/json-runtime-scorecard.test.ts +++ b/tests/json-runtime-scorecard.test.ts @@ -20,7 +20,16 @@ describe("runtime scorecard", () => { resourceRead: "pass", resourceTemplatesList: "pass", promptsList: "pass", - promptGet: "pass" + promptGet: "pass", + conformance: { + protocolVersion: "2025-11-25", + profile: "2025-11-25", + capabilityConsistency: "pass", + taskDeclarations: "pass", + tasksList: "skipped", + schemaDialect: "pass", + overall: "pass" + } }); expect(report.summary.runtimeExecution).toEqual({ backend: "native", diff --git a/tests/runtime-protocol.test.ts b/tests/runtime-protocol.test.ts index 630fb1f..b17cd24 100644 --- a/tests/runtime-protocol.test.ts +++ b/tests/runtime-protocol.test.ts @@ -1,4 +1,4 @@ -import { access, cp, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { access, cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; @@ -85,6 +85,138 @@ describe("runtime protocol probing", () => { }) ]) ); + expect(result.runtimeScorecard?.conformance).toMatchObject({ + protocolVersion: null, + profile: null, + capabilityConsistency: "skipped", + taskDeclarations: "skipped", + tasksList: "skipped", + schemaDialect: "skipped" + }); + }); + + it("rejects a negotiated protocol version that is not date-like", async () => { + const packageRoot = await mkdtemp( + path.join(os.tmpdir(), "codex-plugin-doctor-invalid-protocol-version-") + ); + + try { + await mkdir(path.join(packageRoot, ".codex-plugin")); + await writeFile( + path.join(packageRoot, ".codex-plugin", "plugin.json"), + JSON.stringify({ + name: "runtime-invalid-protocol-version", + version: "1.0.0", + description: "Fixture generated for malformed protocol-version coverage.", + mcpServers: "./.mcp.json" + }) + ); + await writeFile( + path.join(packageRoot, ".mcp.json"), + JSON.stringify({ + mcpServers: { + mockServer: { + command: "node", + args: ["./mock-server.js"] + } + } + }) + ); + await writeFile( + path.join(packageRoot, "mock-server.js"), + `import readline from "node:readline"; +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); +rl.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") { + process.stdout.write(JSON.stringify({ + jsonrpc: "2.0", + id: message.id, + result: { + protocolVersion: "latest", + capabilities: {}, + serverInfo: { name: "invalid-protocol-version", version: "1.0.0" } + } + }) + "\\n"); + } +}); +` + ); + + const result = await runCheck(packageRoot, { runtime: true }); + + expect(result.status).toBe("fail"); + expect(result.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "plugin.runtime.initialize.invalid" }) + ]) + ); + expect(result.runtimeScorecard?.conformance).toMatchObject({ + protocolVersion: null, + profile: null, + capabilityConsistency: "skipped", + taskDeclarations: "skipped", + tasksList: "skipped", + schemaDialect: "skipped" + }); + } finally { + await rm(packageRoot, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 100 + }); + } + }); + + it("records legacy conformance when tools are unsupported without sending task methods", async () => { + const result = await runCheck( + path.resolve("tests/fixtures/runtime-conformance-legacy"), + { runtime: true } + ); + + expect(result.status).toBe("warn"); + expect(result.findings.map((finding) => finding.id)).toEqual([ + "plugin.runtime.tools.unsupported" + ]); + expect(result.runtimeScorecard?.conformance).toEqual({ + protocolVersion: "2025-06-18", + profile: "legacy", + capabilityConsistency: "skipped", + taskDeclarations: "skipped", + tasksList: "skipped", + schemaDialect: "skipped", + overall: "pass" + }); + }); + + it("warns only for a newer protocol and keeps conformance evidence at the observed method", async () => { + const result = await runCheck( + path.resolve("tests/fixtures/runtime-conformance-future"), + { runtime: true } + ); + + expect(result.status).toBe("warn"); + expect(result.findings).toEqual([ + expect.objectContaining({ + id: "mcp.conformance.protocol.unknown_newer", + severity: "warn", + evidence: { + serverName: "futureConformanceServer", + method: "initialize", + protocolVersion: "2026-01-01" + } + }) + ]); + expect(result.runtimeScorecard?.conformance).toEqual({ + protocolVersion: "2026-01-01", + profile: "future-compatible", + capabilityConsistency: "pass", + taskDeclarations: "pass", + tasksList: "skipped", + schemaDialect: "pass", + overall: "warn" + }); }); it("fails when tools/list returns invalid tool definitions", async () => { From f445b2825c1130c172e69470487fa4597bf6f9e0 Mon Sep 17 00:00:00 2001 From: Furkan Date: Wed, 22 Jul 2026 11:21:57 +0300 Subject: [PATCH 08/20] fix: reject invalid MCP protocol dates --- src/core/runtime-probe.ts | 16 ++++- tests/runtime-protocol.test.ts | 113 +++++++++++++++++---------------- 2 files changed, 73 insertions(+), 56 deletions(-) diff --git a/src/core/runtime-probe.ts b/src/core/runtime-probe.ts index 4a46afa..0d30830 100644 --- a/src/core/runtime-probe.ts +++ b/src/core/runtime-probe.ts @@ -770,7 +770,21 @@ function hasValidToolInputSchemas(tools: ToolDefinition[]): boolean { } function isProtocolVersion(value: string): boolean { - return /^\d{4}-\d{2}-\d{2}$/.test(value); + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + + if (!match) { + return false; + } + + const [year, month, day] = match.slice(1).map(Number); + const date = new Date(0); + date.setUTCFullYear(year, month - 1, day); + + return ( + date.getUTCFullYear() === year && + date.getUTCMonth() === month - 1 && + date.getUTCDate() === day + ); } function buildPromptArguments( diff --git a/tests/runtime-protocol.test.ts b/tests/runtime-protocol.test.ts index b17cd24..c8e7942 100644 --- a/tests/runtime-protocol.test.ts +++ b/tests/runtime-protocol.test.ts @@ -95,36 +95,38 @@ describe("runtime protocol probing", () => { }); }); - it("rejects a negotiated protocol version that is not date-like", async () => { - const packageRoot = await mkdtemp( - path.join(os.tmpdir(), "codex-plugin-doctor-invalid-protocol-version-") - ); - - try { - await mkdir(path.join(packageRoot, ".codex-plugin")); - await writeFile( - path.join(packageRoot, ".codex-plugin", "plugin.json"), - JSON.stringify({ - name: "runtime-invalid-protocol-version", - version: "1.0.0", - description: "Fixture generated for malformed protocol-version coverage.", - mcpServers: "./.mcp.json" - }) + it.each(["latest", "2025-02-30", "2025-99-99"])( + "rejects an invalid negotiated protocol version: %s", + async (protocolVersion) => { + const packageRoot = await mkdtemp( + path.join(os.tmpdir(), "codex-plugin-doctor-invalid-protocol-version-") ); - await writeFile( - path.join(packageRoot, ".mcp.json"), - JSON.stringify({ - mcpServers: { - mockServer: { - command: "node", - args: ["./mock-server.js"] + + try { + await mkdir(path.join(packageRoot, ".codex-plugin")); + await writeFile( + path.join(packageRoot, ".codex-plugin", "plugin.json"), + JSON.stringify({ + name: "runtime-invalid-protocol-version", + version: "1.0.0", + description: "Fixture generated for malformed protocol-version coverage.", + mcpServers: "./.mcp.json" + }) + ); + await writeFile( + path.join(packageRoot, ".mcp.json"), + JSON.stringify({ + mcpServers: { + mockServer: { + command: "node", + args: ["./mock-server.js"] + } } - } - }) - ); - await writeFile( - path.join(packageRoot, "mock-server.js"), - `import readline from "node:readline"; + }) + ); + await writeFile( + path.join(packageRoot, "mock-server.js"), + `import readline from "node:readline"; const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); rl.on("line", (line) => { const message = JSON.parse(line); @@ -133,7 +135,7 @@ rl.on("line", (line) => { jsonrpc: "2.0", id: message.id, result: { - protocolVersion: "latest", + protocolVersion: ${JSON.stringify(protocolVersion)}, capabilities: {}, serverInfo: { name: "invalid-protocol-version", version: "1.0.0" } } @@ -141,33 +143,34 @@ rl.on("line", (line) => { } }); ` - ); - - const result = await runCheck(packageRoot, { runtime: true }); - - expect(result.status).toBe("fail"); - expect(result.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: "plugin.runtime.initialize.invalid" }) - ]) - ); - expect(result.runtimeScorecard?.conformance).toMatchObject({ - protocolVersion: null, - profile: null, - capabilityConsistency: "skipped", - taskDeclarations: "skipped", - tasksList: "skipped", - schemaDialect: "skipped" - }); - } finally { - await rm(packageRoot, { - recursive: true, - force: true, - maxRetries: 3, - retryDelay: 100 - }); + ); + + const result = await runCheck(packageRoot, { runtime: true }); + + expect(result.status).toBe("fail"); + expect(result.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "plugin.runtime.initialize.invalid" }) + ]) + ); + expect(result.runtimeScorecard?.conformance).toMatchObject({ + protocolVersion: null, + profile: null, + capabilityConsistency: "skipped", + taskDeclarations: "skipped", + tasksList: "skipped", + schemaDialect: "skipped" + }); + } finally { + await rm(packageRoot, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 100 + }); + } } - }); + ); it("records legacy conformance when tools are unsupported without sending task methods", async () => { const result = await runCheck( From 8c43ca2994fc5df8e0a2d6d74ece184521f4db5d Mon Sep 17 00:00:00 2001 From: Furkan Date: Wed, 22 Jul 2026 11:31:48 +0300 Subject: [PATCH 09/20] fix: skip unevaluable MCP conformance --- src/core/runtime-probe.ts | 30 +++++++++++++++++------------- src/domain/types.ts | 2 +- tests/runtime-protocol.test.ts | 9 +++++++++ 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/src/core/runtime-probe.ts b/src/core/runtime-probe.ts index 0d30830..27895b4 100644 --- a/src/core/runtime-probe.ts +++ b/src/core/runtime-probe.ts @@ -114,7 +114,7 @@ function createRuntimeScorecard(): RuntimeScorecard { taskDeclarations: "skipped", tasksList: "skipped", schemaDialect: "skipped", - overall: "pass" + overall: "skipped" } }; } @@ -1379,6 +1379,7 @@ async function probeCommandServer(input: { let tools: ToolDefinition[] = []; let toolTerminalFinding: Finding | null = null; + let canEvaluateConformance = true; if (!hasToolsCapability(initializeResponse)) { scorecard.toolsList = "unsupported"; @@ -1404,6 +1405,7 @@ async function probeCommandServer(input: { }); if (!listedTools) { + canEvaluateConformance = false; scorecard.toolsList = "fail"; toolTerminalFinding = buildFailure( "plugin.runtime.tools_list.invalid", @@ -1475,18 +1477,20 @@ async function probeCommandServer(input: { } } - const conformance = evaluateMcpConformance({ - protocolVersion: result.protocolVersion, - capabilities: result.capabilities, - tools: tools satisfies McpToolObservation[], - tasksList: { - status: "skipped", - itemCount: 0, - pageCount: 0 - } - }); - scorecard.conformance = conformance.scorecard; - warnings.push(...conformance.findings); + if (canEvaluateConformance) { + const conformance = evaluateMcpConformance({ + protocolVersion: result.protocolVersion, + capabilities: result.capabilities, + tools: tools satisfies McpToolObservation[], + tasksList: { + status: "skipped", + itemCount: 0, + pageCount: 0 + } + }); + scorecard.conformance = conformance.scorecard; + warnings.push(...conformance.findings); + } if (toolTerminalFinding) { settle(toolTerminalFinding); diff --git a/src/domain/types.ts b/src/domain/types.ts index 6cf79c5..e721b95 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -125,7 +125,7 @@ export interface RuntimeConformanceScorecard { taskDeclarations: RuntimeCapabilityStatus; tasksList: RuntimeCapabilityStatus; schemaDialect: RuntimeCapabilityStatus; - overall: "pass" | "warn" | "fail"; + overall: "pass" | "warn" | "fail" | "skipped"; } export interface McpToolObservation { diff --git a/tests/runtime-protocol.test.ts b/tests/runtime-protocol.test.ts index c8e7942..ddb4ae2 100644 --- a/tests/runtime-protocol.test.ts +++ b/tests/runtime-protocol.test.ts @@ -243,6 +243,15 @@ rl.on("line", (line) => { }) ]) ); + expect(result.runtimeScorecard?.conformance).toEqual({ + protocolVersion: null, + profile: null, + capabilityConsistency: "skipped", + taskDeclarations: "skipped", + tasksList: "skipped", + schemaDialect: "skipped", + overall: "skipped" + }); }); it("fails when tools/call returns an invalid result payload", async () => { From 2a8b13b1a77a76eb9b261aa24a53dcf7ac72846a Mon Sep 17 00:00:00 2001 From: Furkan Date: Wed, 22 Jul 2026 11:36:14 +0300 Subject: [PATCH 10/20] fix: skip conformance for invalid tool schemas --- src/core/runtime-probe.ts | 1 + .../.codex-plugin/plugin.json | 6 +++ .../runtime-invalid-tool-schema/.mcp.json | 8 +++ .../mock-server.js | 49 +++++++++++++++++++ tests/runtime-protocol.test.ts | 25 ++++++++++ 5 files changed, 89 insertions(+) create mode 100644 tests/fixtures/runtime-invalid-tool-schema/.codex-plugin/plugin.json create mode 100644 tests/fixtures/runtime-invalid-tool-schema/.mcp.json create mode 100644 tests/fixtures/runtime-invalid-tool-schema/mock-server.js diff --git a/src/core/runtime-probe.ts b/src/core/runtime-probe.ts index 27895b4..7dc1b06 100644 --- a/src/core/runtime-probe.ts +++ b/src/core/runtime-probe.ts @@ -1417,6 +1417,7 @@ async function probeCommandServer(input: { tools = listedTools; if (!hasValidToolInputSchemas(tools)) { + canEvaluateConformance = false; scorecard.toolsList = "fail"; toolTerminalFinding = buildFailure( "plugin.runtime.tools_list.invalid", diff --git a/tests/fixtures/runtime-invalid-tool-schema/.codex-plugin/plugin.json b/tests/fixtures/runtime-invalid-tool-schema/.codex-plugin/plugin.json new file mode 100644 index 0000000..50fec94 --- /dev/null +++ b/tests/fixtures/runtime-invalid-tool-schema/.codex-plugin/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "runtime-invalid-tool-schema", + "version": "1.0.0", + "description": "Fixture with a parseable tool that has an invalid input schema.", + "mcpServers": "./.mcp.json" +} diff --git a/tests/fixtures/runtime-invalid-tool-schema/.mcp.json b/tests/fixtures/runtime-invalid-tool-schema/.mcp.json new file mode 100644 index 0000000..5f05a4b --- /dev/null +++ b/tests/fixtures/runtime-invalid-tool-schema/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "mockServer": { + "command": "node", + "args": ["./mock-server.js"] + } + } +} diff --git a/tests/fixtures/runtime-invalid-tool-schema/mock-server.js b/tests/fixtures/runtime-invalid-tool-schema/mock-server.js new file mode 100644 index 0000000..acb4ecd --- /dev/null +++ b/tests/fixtures/runtime-invalid-tool-schema/mock-server.js @@ -0,0 +1,49 @@ +import readline from "node:readline"; + +const rl = readline.createInterface({ + input: process.stdin, + crlfDelay: Infinity +}); + +rl.on("line", (line) => { + const message = JSON.parse(line); + + if (message.method === "initialize") { + process.stdout.write( + `${JSON.stringify({ + jsonrpc: "2.0", + id: message.id, + result: { + protocolVersion: "2025-11-25", + capabilities: { + tools: {} + }, + serverInfo: { + name: "invalid-tool-schema-server", + version: "1.0.0" + } + } + })}\n` + ); + return; + } + + if (message.method === "tools/list") { + process.stdout.write( + `${JSON.stringify({ + jsonrpc: "2.0", + id: message.id, + result: { + tools: [ + { + name: "invalidSchema", + inputSchema: { + type: "string" + } + } + ] + } + })}\n` + ); + } +}); diff --git a/tests/runtime-protocol.test.ts b/tests/runtime-protocol.test.ts index ddb4ae2..c43249a 100644 --- a/tests/runtime-protocol.test.ts +++ b/tests/runtime-protocol.test.ts @@ -254,6 +254,31 @@ rl.on("line", (line) => { }); }); + it("skips conformance when a parseable tool has an invalid input schema", async () => { + const result = await runCheck( + path.resolve("tests/fixtures/runtime-invalid-tool-schema"), + { + runtime: true + } + ); + + expect(result.status).toBe("fail"); + expect(result.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "plugin.runtime.tools_list.invalid" }) + ]) + ); + expect(result.runtimeScorecard?.conformance).toEqual({ + protocolVersion: null, + profile: null, + capabilityConsistency: "skipped", + taskDeclarations: "skipped", + tasksList: "skipped", + schemaDialect: "skipped", + overall: "skipped" + }); + }); + it("fails when tools/call returns an invalid result payload", async () => { const result = await runCheck( path.resolve("tests/fixtures/runtime-invalid-call"), From 8910badadb5024f79c928d085b461c00cd71bdb3 Mon Sep 17 00:00:00 2001 From: Furkan Date: Wed, 22 Jul 2026 11:48:09 +0300 Subject: [PATCH 11/20] feat: add safe MCP task list probing --- src/core/runtime-probe.ts | 133 +++++++++++++++++- src/core/runtime-transcript.ts | 10 ++ .../.codex-plugin/plugin.json | 6 + .../.mcp.json | 8 ++ .../mock-server.js | 28 ++++ .../.codex-plugin/plugin.json | 6 + .../.mcp.json | 8 ++ .../mock-server.js | 28 ++++ .../.codex-plugin/plugin.json | 6 + .../.mcp.json | 8 ++ .../mock-server.js | 19 +++ .../.codex-plugin/plugin.json | 6 + .../runtime-conformance-tasks-valid/.mcp.json | 8 ++ .../mock-server.js | 67 +++++++++ tests/runtime-protocol.test.ts | 55 ++++++++ tests/runtime-transcript.test.ts | 20 +++ 16 files changed, 410 insertions(+), 6 deletions(-) create mode 100644 tests/fixtures/runtime-conformance-tasks-invalid/.codex-plugin/plugin.json create mode 100644 tests/fixtures/runtime-conformance-tasks-invalid/.mcp.json create mode 100644 tests/fixtures/runtime-conformance-tasks-invalid/mock-server.js create mode 100644 tests/fixtures/runtime-conformance-tasks-list-only/.codex-plugin/plugin.json create mode 100644 tests/fixtures/runtime-conformance-tasks-list-only/.mcp.json create mode 100644 tests/fixtures/runtime-conformance-tasks-list-only/mock-server.js create mode 100644 tests/fixtures/runtime-conformance-tasks-timeout/.codex-plugin/plugin.json create mode 100644 tests/fixtures/runtime-conformance-tasks-timeout/.mcp.json create mode 100644 tests/fixtures/runtime-conformance-tasks-timeout/mock-server.js create mode 100644 tests/fixtures/runtime-conformance-tasks-valid/.codex-plugin/plugin.json create mode 100644 tests/fixtures/runtime-conformance-tasks-valid/.mcp.json create mode 100644 tests/fixtures/runtime-conformance-tasks-valid/mock-server.js diff --git a/src/core/runtime-probe.ts b/src/core/runtime-probe.ts index 7dc1b06..93d4f1f 100644 --- a/src/core/runtime-probe.ts +++ b/src/core/runtime-probe.ts @@ -12,7 +12,8 @@ import type { RuntimeExecutionEvidence, RuntimeProbeResult, RuntimeSandboxMode, - RuntimeScorecard + RuntimeScorecard, + TasksListObservation } from "../domain/types.js"; import { evaluateMcpConformance } from "./mcp-conformance.js"; import { @@ -32,6 +33,7 @@ const MAX_TOOL_CALL_CONTENT_LENGTH = 4096; const MAX_RESOURCE_READ_CONTENT_LENGTH = 4096; const MAX_PROMPT_GET_CONTENT_LENGTH = 4096; const DOCKER_CLEANUP_TIMEOUT_MS = 5_000; +const MAX_RUNTIME_PAGINATION_PAGES = 100; type JsonObject = Record; @@ -461,6 +463,17 @@ function hasPromptsCapability(message: JsonObject): boolean { return capabilities !== null && isPlainObject(capabilities.prompts); } +function supportsTasksListProbe( + protocolVersion: string, + capabilities: JsonObject +): boolean { + if (protocolVersion < MCP_PROTOCOL_VERSION || !isPlainObject(capabilities.tasks)) { + return false; + } + + return isPlainObject(capabilities.tasks.list); +} + function getNextCursor(result: JsonObject): string | null { if (result.nextCursor === undefined) { return null; @@ -1190,8 +1203,14 @@ async function probeCommandServer(input: { }): Promise => { let cursor: string | null = null; const items: T[] = []; + const seenCursors = new Set(); + let pageCount = 0; do { + if (pageCount >= MAX_RUNTIME_PAGINATION_PAGES) { + return null; + } + const response = await sendRequest( input.method, cursor ? { cursor } : undefined, @@ -1216,12 +1235,108 @@ async function probeCommandServer(input: { } items.push(...page.items); + pageCount += 1; + + if (page.nextCursor && seenCursors.has(page.nextCursor)) { + return null; + } + + if (page.nextCursor) { + seenCursors.add(page.nextCursor); + } + cursor = page.nextCursor; } while (cursor); return items; }; + const observeTasksList = async (): Promise => { + let cursor: string | null = null; + const seenCursors = new Set(); + let itemCount = 0; + let pageCount = 0; + + try { + do { + if (pageCount >= MAX_RUNTIME_PAGINATION_PAGES) { + return { + status: "fail", + itemCount, + pageCount, + failure: "invalid" + }; + } + + const response = await sendRequest( + "tasks/list", + cursor ? { cursor } : undefined, + buildFailure( + "mcp.conformance.tasks_list.timeout", + `The MCP server \`${serverName}\` did not answer the tasks/list request in time.`, + "Codex cannot safely inspect task discovery when the server does not complete tasks/list.", + "Reduce tasks/list latency and verify pagination completes." + ) + ); + + if (isErrorResponse(response) || !isPlainObject(response.result)) { + return { + status: "fail", + itemCount, + pageCount, + failure: "invalid" + }; + } + + const tasks = response.result.tasks; + const nextCursor = getNextCursor(response.result); + + if ( + !Array.isArray(tasks) || + !tasks.every(isPlainObject) || + nextCursor === "__invalid__" + ) { + return { + status: "fail", + itemCount, + pageCount, + failure: "invalid" + }; + } + + itemCount += tasks.length; + pageCount += 1; + + if (nextCursor && seenCursors.has(nextCursor)) { + return { + status: "fail", + itemCount, + pageCount, + failure: "invalid" + }; + } + + if (nextCursor) { + seenCursors.add(nextCursor); + } + + cursor = nextCursor; + } while (cursor); + } catch (error) { + return { + status: "fail", + itemCount, + pageCount, + failure: + isFinding(error) && error.id === "mcp.conformance.tasks_list.timeout" + ? "timeout" + : "invalid" + }; + } + + return { status: "pass", itemCount, pageCount }; + }; + child.stderr?.on("data", (chunk: Buffer | string) => { if (stderrPreview.length >= 160) { return; @@ -1478,16 +1593,22 @@ async function probeCommandServer(input: { } } + const tasksList = + canEvaluateConformance && + supportsTasksListProbe(result.protocolVersion, result.capabilities) + ? await observeTasksList() + : { + status: "skipped" as const, + itemCount: 0, + pageCount: 0 + }; + if (canEvaluateConformance) { const conformance = evaluateMcpConformance({ protocolVersion: result.protocolVersion, capabilities: result.capabilities, tools: tools satisfies McpToolObservation[], - tasksList: { - status: "skipped", - itemCount: 0, - pageCount: 0 - } + tasksList }); scorecard.conformance = conformance.scorecard; warnings.push(...conformance.findings); diff --git a/src/core/runtime-transcript.ts b/src/core/runtime-transcript.ts index 43eb58a..fa95d42 100644 --- a/src/core/runtime-transcript.ts +++ b/src/core/runtime-transcript.ts @@ -110,6 +110,10 @@ export function formatRequestTranscript( return `-> ${method}`; } + if (method === "tasks/list" && params.cursor !== undefined) { + return '-> tasks/list {"cursor":"[CURSOR]"}'; + } + return `-> ${method} ${JSON.stringify(sanitizeTranscriptValue(params))}`; } @@ -180,6 +184,12 @@ export function formatResponseTranscript( nextCursor: typeof result.nextCursor === "string" ? "[CURSOR]" : undefined })}`; + case "tasks/list": + return `<- tasks/list ${JSON.stringify({ + tasks: Array.isArray(result.tasks) ? result.tasks.length : 0, + nextCursor: + typeof result.nextCursor === "string" ? "[CURSOR]" : undefined + })}`; case "prompts/get": return `<- prompts/get ${JSON.stringify({ messages: Array.isArray(result.messages) ? result.messages.length : 0 diff --git a/tests/fixtures/runtime-conformance-tasks-invalid/.codex-plugin/plugin.json b/tests/fixtures/runtime-conformance-tasks-invalid/.codex-plugin/plugin.json new file mode 100644 index 0000000..21e6ef7 --- /dev/null +++ b/tests/fixtures/runtime-conformance-tasks-invalid/.codex-plugin/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "runtime-conformance-tasks-invalid", + "version": "1.0.0", + "description": "Runtime fixture for invalid tasks/list observation.", + "mcpServers": "./.mcp.json" +} diff --git a/tests/fixtures/runtime-conformance-tasks-invalid/.mcp.json b/tests/fixtures/runtime-conformance-tasks-invalid/.mcp.json new file mode 100644 index 0000000..6c396fc --- /dev/null +++ b/tests/fixtures/runtime-conformance-tasks-invalid/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "tasksInvalidServer": { + "command": "node", + "args": ["./mock-server.js"] + } + } +} diff --git a/tests/fixtures/runtime-conformance-tasks-invalid/mock-server.js b/tests/fixtures/runtime-conformance-tasks-invalid/mock-server.js new file mode 100644 index 0000000..e3dcd36 --- /dev/null +++ b/tests/fixtures/runtime-conformance-tasks-invalid/mock-server.js @@ -0,0 +1,28 @@ +import readline from "node:readline"; + +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); + +rl.on("line", (line) => { + const message = JSON.parse(line); + + if (message.method === "initialize") { + process.stdout.write(`${JSON.stringify({ + jsonrpc: "2.0", + id: message.id, + result: { + protocolVersion: "2025-11-25", + capabilities: { tasks: { list: {} } }, + serverInfo: { name: "tasks-invalid-server", version: "1.0.0" } + } + })}\n`); + return; + } + + if (message.method === "tasks/list") { + process.stdout.write(`${JSON.stringify({ + jsonrpc: "2.0", + id: message.id, + result: { tasks: "not-an-array" } + })}\n`); + } +}); diff --git a/tests/fixtures/runtime-conformance-tasks-list-only/.codex-plugin/plugin.json b/tests/fixtures/runtime-conformance-tasks-list-only/.codex-plugin/plugin.json new file mode 100644 index 0000000..184d72a --- /dev/null +++ b/tests/fixtures/runtime-conformance-tasks-list-only/.codex-plugin/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "runtime-conformance-tasks-list-only", + "version": "1.0.0", + "description": "Runtime fixture for task listing without tools.", + "mcpServers": "./.mcp.json" +} diff --git a/tests/fixtures/runtime-conformance-tasks-list-only/.mcp.json b/tests/fixtures/runtime-conformance-tasks-list-only/.mcp.json new file mode 100644 index 0000000..b164601 --- /dev/null +++ b/tests/fixtures/runtime-conformance-tasks-list-only/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "tasksListOnlyServer": { + "command": "node", + "args": ["./mock-server.js"] + } + } +} diff --git a/tests/fixtures/runtime-conformance-tasks-list-only/mock-server.js b/tests/fixtures/runtime-conformance-tasks-list-only/mock-server.js new file mode 100644 index 0000000..d5281f3 --- /dev/null +++ b/tests/fixtures/runtime-conformance-tasks-list-only/mock-server.js @@ -0,0 +1,28 @@ +import readline from "node:readline"; + +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); + +rl.on("line", (line) => { + const message = JSON.parse(line); + + if (message.method === "initialize") { + process.stdout.write(`${JSON.stringify({ + jsonrpc: "2.0", + id: message.id, + result: { + protocolVersion: "2025-11-25", + capabilities: { tasks: { list: {} } }, + serverInfo: { name: "tasks-list-only-server", version: "1.0.0" } + } + })}\n`); + return; + } + + if (message.method === "tasks/list") { + process.stdout.write(`${JSON.stringify({ + jsonrpc: "2.0", + id: message.id, + result: { tasks: [] } + })}\n`); + } +}); diff --git a/tests/fixtures/runtime-conformance-tasks-timeout/.codex-plugin/plugin.json b/tests/fixtures/runtime-conformance-tasks-timeout/.codex-plugin/plugin.json new file mode 100644 index 0000000..6fa14d2 --- /dev/null +++ b/tests/fixtures/runtime-conformance-tasks-timeout/.codex-plugin/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "runtime-conformance-tasks-timeout", + "version": "1.0.0", + "description": "Runtime fixture for tasks/list timeout observation.", + "mcpServers": "./.mcp.json" +} diff --git a/tests/fixtures/runtime-conformance-tasks-timeout/.mcp.json b/tests/fixtures/runtime-conformance-tasks-timeout/.mcp.json new file mode 100644 index 0000000..21ff2f9 --- /dev/null +++ b/tests/fixtures/runtime-conformance-tasks-timeout/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "tasksTimeoutServer": { + "command": "node", + "args": ["./mock-server.js"] + } + } +} diff --git a/tests/fixtures/runtime-conformance-tasks-timeout/mock-server.js b/tests/fixtures/runtime-conformance-tasks-timeout/mock-server.js new file mode 100644 index 0000000..1f3433c --- /dev/null +++ b/tests/fixtures/runtime-conformance-tasks-timeout/mock-server.js @@ -0,0 +1,19 @@ +import readline from "node:readline"; + +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); + +rl.on("line", (line) => { + const message = JSON.parse(line); + + if (message.method === "initialize") { + process.stdout.write(`${JSON.stringify({ + jsonrpc: "2.0", + id: message.id, + result: { + protocolVersion: "2025-11-25", + capabilities: { tasks: { list: {} } }, + serverInfo: { name: "tasks-timeout-server", version: "1.0.0" } + } + })}\n`); + } +}); diff --git a/tests/fixtures/runtime-conformance-tasks-valid/.codex-plugin/plugin.json b/tests/fixtures/runtime-conformance-tasks-valid/.codex-plugin/plugin.json new file mode 100644 index 0000000..1a6a6ce --- /dev/null +++ b/tests/fixtures/runtime-conformance-tasks-valid/.codex-plugin/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "runtime-conformance-tasks-valid", + "version": "1.0.0", + "description": "Runtime fixture for safe tasks/list observation.", + "mcpServers": "./.mcp.json" +} diff --git a/tests/fixtures/runtime-conformance-tasks-valid/.mcp.json b/tests/fixtures/runtime-conformance-tasks-valid/.mcp.json new file mode 100644 index 0000000..75ff06e --- /dev/null +++ b/tests/fixtures/runtime-conformance-tasks-valid/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "tasksServer": { + "command": "node", + "args": ["./mock-server.js"] + } + } +} diff --git a/tests/fixtures/runtime-conformance-tasks-valid/mock-server.js b/tests/fixtures/runtime-conformance-tasks-valid/mock-server.js new file mode 100644 index 0000000..49624d6 --- /dev/null +++ b/tests/fixtures/runtime-conformance-tasks-valid/mock-server.js @@ -0,0 +1,67 @@ +import readline from "node:readline"; + +const forbiddenMethods = new Set([ + "tasks/get", + "tasks/result", + "tasks/cancel", + "sampling/createMessage", + "elicitation/create" +]); + +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); + +rl.on("line", (line) => { + const message = JSON.parse(line); + + if (forbiddenMethods.has(message.method) || message.params?.task) { + throw new Error(`Unexpected task probe: ${message.method}`); + } + + if (message.method === "initialize") { + process.stdout.write(`${JSON.stringify({ + jsonrpc: "2.0", + id: message.id, + result: { + protocolVersion: "2025-11-25", + capabilities: { + tools: {}, + tasks: { list: {}, requests: { tools: { call: {} } } } + }, + serverInfo: { name: "tasks-server", version: "1.0.0" } + } + })}\n`); + return; + } + + if (message.method === "tools/list") { + process.stdout.write(`${JSON.stringify({ + jsonrpc: "2.0", + id: message.id, + result: { tools: [{ name: "ping", inputSchema: { type: "object", properties: {} } }] } + })}\n`); + return; + } + + if (message.method === "tools/call") { + process.stdout.write(`${JSON.stringify({ + jsonrpc: "2.0", + id: message.id, + result: { content: [{ type: "text", text: "pong" }] } + })}\n`); + return; + } + + if (message.method === "tasks/list") { + const secondPage = message.params?.cursor === "private-task-cursor"; + process.stdout.write(`${JSON.stringify({ + jsonrpc: "2.0", + id: message.id, + result: secondPage + ? { tasks: [{ taskId: "private-task-id-2", status: "working", statusMessage: "private task text two" }] } + : { + tasks: [{ taskId: "private-task-id-1", status: "working", statusMessage: "private task text one" }], + nextCursor: "private-task-cursor" + } + })}\n`); + } +}); diff --git a/tests/runtime-protocol.test.ts b/tests/runtime-protocol.test.ts index c43249a..0eb669c 100644 --- a/tests/runtime-protocol.test.ts +++ b/tests/runtime-protocol.test.ts @@ -222,6 +222,61 @@ rl.on("line", (line) => { }); }); + it("observes paginated tasks/list metadata without retaining private task data", async () => { + const transcript: string[] = []; + const result = await runCheck( + path.resolve("tests/fixtures/runtime-conformance-tasks-valid"), + { runtime: true, runtimeTranscript: (line) => transcript.push(line) } + ); + + expect(result.status).toBe("pass"); + expect(result.findings).toEqual([]); + expect(result.runtimeScorecard?.conformance?.tasksList).toBe("pass"); + expect(JSON.stringify(result)).not.toContain("private-task"); + expect(transcript).toContain( + '<- tasks/list {"tasks":1,"nextCursor":"[CURSOR]"}' + ); + expect(transcript).toContain('<- tasks/list {"tasks":1}'); + expect(transcript.join("\n")).not.toContain("private-task"); + expect(transcript.join("\n")).not.toContain("working"); + expect(transcript.join("\n")).not.toContain("private-task-cursor"); + }); + + it("fails task-list conformance when tasks/list returns a non-array", async () => { + const result = await runCheck( + path.resolve("tests/fixtures/runtime-conformance-tasks-invalid"), + { runtime: true } + ); + + expect(result.status).toBe("fail"); + expect(result.findings.filter((finding) => finding.id === "mcp.conformance.tasks_list.invalid")).toHaveLength(1); + expect(result.runtimeScorecard?.conformance?.tasksList).toBe("fail"); + }); + + it("fails task-list conformance when tasks/list times out", async () => { + const result = await runCheck( + path.resolve("tests/fixtures/runtime-conformance-tasks-timeout"), + { runtime: true, runtimeStartupTimeoutMs: 500 } + ); + + expect(result.status).toBe("fail"); + expect(result.findings.filter((finding) => finding.id === "mcp.conformance.tasks_list.timeout")).toHaveLength(1); + expect(result.runtimeScorecard?.conformance?.tasksList).toBe("fail"); + }); + + it("probes tasks/list without tools and preserves the existing no-tools warning", async () => { + const result = await runCheck( + path.resolve("tests/fixtures/runtime-conformance-tasks-list-only"), + { runtime: true } + ); + + expect(result.status).toBe("warn"); + expect(result.findings.map((finding) => finding.id)).toEqual([ + "plugin.runtime.tools.unsupported" + ]); + expect(result.runtimeScorecard?.conformance?.tasksList).toBe("pass"); + }); + it("fails when tools/list returns invalid tool definitions", async () => { const result = await runCheck( path.resolve("tests/fixtures/runtime-invalid-tools"), diff --git a/tests/runtime-transcript.test.ts b/tests/runtime-transcript.test.ts index e643f8c..8306bd4 100644 --- a/tests/runtime-transcript.test.ts +++ b/tests/runtime-transcript.test.ts @@ -40,4 +40,24 @@ describe("runtime transcript sanitization", () => { expect(transcript).toContain("\"message\":\"[REDACTED]\""); }); + + it("summarizes tasks/list responses without task data or cursor values", () => { + const transcript = formatResponseTranscript("tasks/list", { + jsonrpc: "2.0", + result: { + tasks: [ + { + taskId: "private-task-id", + status: "working", + statusMessage: "private task text" + } + ], + nextCursor: "private-task-cursor" + } + }); + + expect(transcript).toBe('<- tasks/list {"tasks":1,"nextCursor":"[CURSOR]"}'); + expect(transcript).not.toContain("private-task"); + expect(transcript).not.toContain("working"); + }); }); From fb5046e72b622371e81116f96461d7c4d65dbcbd Mon Sep 17 00:00:00 2001 From: Furkan Date: Wed, 22 Jul 2026 12:06:09 +0300 Subject: [PATCH 12/20] test: cover MCP pagination bounds --- .../.codex-plugin/plugin.json | 6 ++++ .../runtime-pagination-bounds/.mcp.json | 12 +++++++ .../runtime-pagination-bounds/mock-server.js | 34 +++++++++++++++++++ tests/runtime-protocol.test.ts | 31 +++++++++++++++++ 4 files changed, 83 insertions(+) create mode 100644 tests/fixtures/runtime-pagination-bounds/.codex-plugin/plugin.json create mode 100644 tests/fixtures/runtime-pagination-bounds/.mcp.json create mode 100644 tests/fixtures/runtime-pagination-bounds/mock-server.js diff --git a/tests/fixtures/runtime-pagination-bounds/.codex-plugin/plugin.json b/tests/fixtures/runtime-pagination-bounds/.codex-plugin/plugin.json new file mode 100644 index 0000000..44bd703 --- /dev/null +++ b/tests/fixtures/runtime-pagination-bounds/.codex-plugin/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "runtime-pagination-bounds", + "version": "1.0.0", + "description": "Runtime fixture for MCP pagination bound coverage.", + "mcpServers": "./.mcp.json" +} diff --git a/tests/fixtures/runtime-pagination-bounds/.mcp.json b/tests/fixtures/runtime-pagination-bounds/.mcp.json new file mode 100644 index 0000000..b0dcaa0 --- /dev/null +++ b/tests/fixtures/runtime-pagination-bounds/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "sharedPaginationServer": { + "command": "node", + "args": ["./mock-server.js", "shared"] + }, + "tasksPaginationServer": { + "command": "node", + "args": ["./mock-server.js", "tasks"] + } + } +} diff --git a/tests/fixtures/runtime-pagination-bounds/mock-server.js b/tests/fixtures/runtime-pagination-bounds/mock-server.js new file mode 100644 index 0000000..c3b6723 --- /dev/null +++ b/tests/fixtures/runtime-pagination-bounds/mock-server.js @@ -0,0 +1,34 @@ +import readline from "node:readline"; + +const mode = process.argv[2]; +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); + +function respond(id, result) { + process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, result })}\n`); +} + +rl.on("line", (line) => { + const message = JSON.parse(line); + + if (message.method === "initialize") { + respond(message.id, { + protocolVersion: "2025-11-25", + capabilities: mode === "shared" ? { tools: {} } : { tasks: { list: {} } }, + serverInfo: { name: `${mode}-pagination-server`, version: "1.0.0" } + }); + return; + } + + if (mode === "shared" && message.method === "tools/list") { + respond(message.id, { + tools: [{ name: "ping", inputSchema: { type: "object", properties: {} } }], + nextCursor: "repeated-cursor" + }); + return; + } + + if (mode === "tasks" && message.method === "tasks/list") { + const page = Number(String(message.params?.cursor ?? "page-0").slice(5)) + 1; + respond(message.id, { tasks: [], nextCursor: `page-${page}` }); + } +}); diff --git a/tests/runtime-protocol.test.ts b/tests/runtime-protocol.test.ts index 0eb669c..ac2e62d 100644 --- a/tests/runtime-protocol.test.ts +++ b/tests/runtime-protocol.test.ts @@ -277,6 +277,37 @@ rl.on("line", (line) => { expect(result.runtimeScorecard?.conformance?.tasksList).toBe("pass"); }); + it("bounds repeated shared cursors and task pagination at 100 pages", async () => { + const transcript: string[] = []; + const result = await runCheck( + path.resolve("tests/fixtures/runtime-pagination-bounds"), + { + runtime: true, + runtimeStartupTimeoutMs: 2_000, + runtimeTranscript: (line) => transcript.push(line) + } + ); + + expect(result.status).toBe("fail"); + expect( + result.findings.filter( + (finding) => finding.id === "plugin.runtime.tools_list.invalid" + ) + ).toHaveLength(1); + expect( + result.findings.filter( + (finding) => finding.id === "mcp.conformance.tasks_list.invalid" + ) + ).toHaveLength(1); + expect(result.runtimeScorecard?.conformance?.tasksList).toBe("fail"); + expect( + transcript.filter((line) => line.startsWith("-> tools/list")) + ).toHaveLength(2); + expect( + transcript.filter((line) => line.startsWith("-> tasks/list")) + ).toHaveLength(100); + }); + it("fails when tools/list returns invalid tool definitions", async () => { const result = await runCheck( path.resolve("tests/fixtures/runtime-invalid-tools"), From be1e42f58fb26a28d470b05e20a0cdc0b86456e3 Mon Sep 17 00:00:00 2001 From: Furkan Date: Wed, 22 Jul 2026 12:22:38 +0300 Subject: [PATCH 13/20] fix: decouple MCP task list probing --- src/core/mcp-conformance.ts | 34 +++++++++-------- src/core/runtime-probe.ts | 6 +-- src/domain/types.ts | 1 + .../.codex-plugin/plugin.json | 6 +++ .../runtime-invalid-tools-and-tasks/.mcp.json | 8 ++++ .../mock-server.js | 37 +++++++++++++++++++ tests/runtime-protocol.test.ts | 24 ++++++++++++ 7 files changed, 98 insertions(+), 18 deletions(-) create mode 100644 tests/fixtures/runtime-invalid-tools-and-tasks/.codex-plugin/plugin.json create mode 100644 tests/fixtures/runtime-invalid-tools-and-tasks/.mcp.json create mode 100644 tests/fixtures/runtime-invalid-tools-and-tasks/mock-server.js diff --git a/src/core/mcp-conformance.ts b/src/core/mcp-conformance.ts index 71910ce..48c46c2 100644 --- a/src/core/mcp-conformance.ts +++ b/src/core/mcp-conformance.ts @@ -351,25 +351,29 @@ export function evaluateMcpConformance( return { findings, scorecard }; } - const capabilityFindings: Finding[] = []; - const taskCallCapabilityState = getTaskToolsCallCapabilityState( - observation.capabilities, - capabilityFindings - ); - findings.push(...capabilityFindings); - scorecard.capabilityConsistency = statusForFindings(capabilityFindings); + if (!observation.skipToolDerivedChecks) { + const capabilityFindings: Finding[] = []; + const taskCallCapabilityState = getTaskToolsCallCapabilityState( + observation.capabilities, + capabilityFindings + ); + findings.push(...capabilityFindings); + scorecard.capabilityConsistency = statusForFindings(capabilityFindings); - const declarationFindings: Finding[] = []; - collectTaskDeclarationFindings(observation, taskCallCapabilityState, declarationFindings); - findings.push(...declarationFindings); - scorecard.taskDeclarations = statusForFindings(declarationFindings); + const declarationFindings: Finding[] = []; + collectTaskDeclarationFindings(observation, taskCallCapabilityState, declarationFindings); + findings.push(...declarationFindings); + scorecard.taskDeclarations = statusForFindings(declarationFindings); + } scorecard.tasksList = collectTasksListFinding(observation.tasksList, findings); - const schemaFindings: Finding[] = []; - collectSchemaFindings(observation, schemaFindings); - findings.push(...schemaFindings); - scorecard.schemaDialect = statusForFindings(schemaFindings); + if (!observation.skipToolDerivedChecks) { + const schemaFindings: Finding[] = []; + collectSchemaFindings(observation, schemaFindings); + findings.push(...schemaFindings); + scorecard.schemaDialect = statusForFindings(schemaFindings); + } scorecard.overall = overallStatus(scorecard, findings); return { findings, scorecard }; diff --git a/src/core/runtime-probe.ts b/src/core/runtime-probe.ts index 93d4f1f..16d8dd8 100644 --- a/src/core/runtime-probe.ts +++ b/src/core/runtime-probe.ts @@ -1594,7 +1594,6 @@ async function probeCommandServer(input: { } const tasksList = - canEvaluateConformance && supportsTasksListProbe(result.protocolVersion, result.capabilities) ? await observeTasksList() : { @@ -1603,12 +1602,13 @@ async function probeCommandServer(input: { pageCount: 0 }; - if (canEvaluateConformance) { + if (canEvaluateConformance || tasksList.status !== "skipped") { const conformance = evaluateMcpConformance({ protocolVersion: result.protocolVersion, capabilities: result.capabilities, tools: tools satisfies McpToolObservation[], - tasksList + tasksList, + skipToolDerivedChecks: !canEvaluateConformance }); scorecard.conformance = conformance.scorecard; warnings.push(...conformance.findings); diff --git a/src/domain/types.ts b/src/domain/types.ts index e721b95..d7142e8 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -147,6 +147,7 @@ export interface McpConformanceObservation { capabilities: unknown; tools: McpToolObservation[]; tasksList?: TasksListObservation; + skipToolDerivedChecks?: boolean; } export interface McpConformanceResult { diff --git a/tests/fixtures/runtime-invalid-tools-and-tasks/.codex-plugin/plugin.json b/tests/fixtures/runtime-invalid-tools-and-tasks/.codex-plugin/plugin.json new file mode 100644 index 0000000..5643580 --- /dev/null +++ b/tests/fixtures/runtime-invalid-tools-and-tasks/.codex-plugin/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "runtime-invalid-tools-and-tasks", + "version": "1.0.0", + "description": "Runtime fixture for independent tasks/list probing.", + "mcpServers": "./.mcp.json" +} diff --git a/tests/fixtures/runtime-invalid-tools-and-tasks/.mcp.json b/tests/fixtures/runtime-invalid-tools-and-tasks/.mcp.json new file mode 100644 index 0000000..1a5e8b9 --- /dev/null +++ b/tests/fixtures/runtime-invalid-tools-and-tasks/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "invalidToolsAndTasksServer": { + "command": "node", + "args": ["./mock-server.js"] + } + } +} diff --git a/tests/fixtures/runtime-invalid-tools-and-tasks/mock-server.js b/tests/fixtures/runtime-invalid-tools-and-tasks/mock-server.js new file mode 100644 index 0000000..91ee54c --- /dev/null +++ b/tests/fixtures/runtime-invalid-tools-and-tasks/mock-server.js @@ -0,0 +1,37 @@ +import readline from "node:readline"; + +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); + +rl.on("line", (line) => { + const message = JSON.parse(line); + + if (message.method === "initialize") { + process.stdout.write(`${JSON.stringify({ + jsonrpc: "2.0", + id: message.id, + result: { + protocolVersion: "2025-11-25", + capabilities: { tools: {}, tasks: { list: {} } }, + serverInfo: { name: "invalid-tools-and-tasks-server", version: "1.0.0" } + } + })}\n`); + return; + } + + if (message.method === "tools/list") { + process.stdout.write(`${JSON.stringify({ + jsonrpc: "2.0", + id: message.id, + result: { tools: [{ inputSchema: { type: "string" } }] } + })}\n`); + return; + } + + if (message.method === "tasks/list") { + process.stdout.write(`${JSON.stringify({ + jsonrpc: "2.0", + id: message.id, + result: { tasks: "not-an-array" } + })}\n`); + } +}); diff --git a/tests/runtime-protocol.test.ts b/tests/runtime-protocol.test.ts index ac2e62d..2a50b8e 100644 --- a/tests/runtime-protocol.test.ts +++ b/tests/runtime-protocol.test.ts @@ -340,6 +340,30 @@ rl.on("line", (line) => { }); }); + it("probes declared tasks/list when tools/list is invalid", async () => { + const result = await runCheck( + path.resolve("tests/fixtures/runtime-invalid-tools-and-tasks"), + { runtime: true, runtimeStartupTimeoutMs: 2_000 } + ); + + expect(result.status).toBe("fail"); + expect(result.findings.map((finding) => finding.id)).toEqual( + expect.arrayContaining([ + "plugin.runtime.tools_list.invalid", + "mcp.conformance.tasks_list.invalid" + ]) + ); + expect(result.runtimeScorecard?.conformance).toEqual({ + protocolVersion: "2025-11-25", + profile: "2025-11-25", + capabilityConsistency: "skipped", + taskDeclarations: "skipped", + tasksList: "fail", + schemaDialect: "skipped", + overall: "fail" + }); + }); + it("skips conformance when a parseable tool has an invalid input schema", async () => { const result = await runCheck( path.resolve("tests/fixtures/runtime-invalid-tool-schema"), From 6425f4888a344127d45ee12ab4fdf65f36b1faaa Mon Sep 17 00:00:00 2001 From: Furkan Date: Fri, 24 Jul 2026 10:36:56 +0300 Subject: [PATCH 14/20] feat: add MCP runtime conformance opt-in --- src/core/runtime-probe.ts | 63 ++++++++++++++++++++++------------- src/mcp/generic-mcp-doctor.ts | 53 +++++++++++++++++++++++------ src/run-cli.ts | 12 +++++-- tests/cli-command.test.ts | 13 ++++++++ tests/mcp-command.test.ts | 36 ++++++++++++++++++++ 5 files changed, 140 insertions(+), 37 deletions(-) diff --git a/src/core/runtime-probe.ts b/src/core/runtime-probe.ts index 16d8dd8..a5def59 100644 --- a/src/core/runtime-probe.ts +++ b/src/core/runtime-probe.ts @@ -393,21 +393,7 @@ function isPathWithinRoot(rootPath: string, candidatePath: string): boolean { ); } -async function loadMcpServers( - discoveredPackage: DiscoveredPackage -): Promise | null> { - const { manifest, rootPath } = discoveredPackage; - - if (!manifest.mcpServers) { - return null; - } - - const mcpConfigPath = path.resolve(rootPath, manifest.mcpServers); - - if (!isPathWithinRoot(rootPath, mcpConfigPath)) { - return null; - } - +async function loadMcpServers(mcpConfigPath: string): Promise | null> { const exists = await fileExists(mcpConfigPath); if (!exists) { @@ -1841,14 +1827,43 @@ async function probeCommandServer(input: { export async function probeRuntime( discoveredPackage: DiscoveredPackage, - options: { - startupTimeoutMs?: number; - sandbox?: RuntimeSandboxMode; - transcript?: (line: string) => void; - } = {} + options: RuntimeProbeOptions = {} ): Promise { + const { manifest, rootPath } = discoveredPackage; + + if (!manifest.mcpServers) { + return { + findings: [], + scorecard: createRuntimeScorecard() + }; + } + + return probeRuntimeConfig(rootPath, manifest.mcpServers, options); +} + +export interface RuntimeProbeOptions { + startupTimeoutMs?: number; + sandbox?: RuntimeSandboxMode; + transcript?: (line: string) => void; +} + +export async function probeRuntimeConfig( + rootPath: string, + mcpConfigPath: string, + options: RuntimeProbeOptions = {} +): Promise { + const resolvedRootPath = path.resolve(rootPath); + const resolvedMcpConfigPath = path.resolve(resolvedRootPath, mcpConfigPath); const startupTimeoutMs = options.startupTimeoutMs ?? 400; - const servers = await loadMcpServers(discoveredPackage); + + if (!isPathWithinRoot(resolvedRootPath, resolvedMcpConfigPath)) { + return { + findings: [], + scorecard: createRuntimeScorecard() + }; + } + + const servers = await loadMcpServers(resolvedMcpConfigPath); if (!servers) { return { @@ -1877,12 +1892,12 @@ export async function probeRuntime( : []; const cwd = typeof config.cwd === "string" - ? path.resolve(discoveredPackage.rootPath, config.cwd) - : discoveredPackage.rootPath; + ? path.resolve(resolvedRootPath, config.cwd) + : resolvedRootPath; const result = await probeCommandServer({ serverName, - packageRoot: discoveredPackage.rootPath, + packageRoot: resolvedRootPath, command, args, cwd, diff --git a/src/mcp/generic-mcp-doctor.ts b/src/mcp/generic-mcp-doctor.ts index 2e8c68b..2204021 100644 --- a/src/mcp/generic-mcp-doctor.ts +++ b/src/mcp/generic-mcp-doctor.ts @@ -8,7 +8,13 @@ import { readMcpConfigPath } from "../compatibility/compatibility-matrix.js"; import { readJsonFile } from "../core/read-json-file.js"; -import type { Finding, FindingEvidence } from "../domain/types.js"; +import { probeRuntimeConfig } from "../core/runtime-probe.js"; +import type { + Finding, + FindingEvidence, + RuntimeExecutionEvidence, + RuntimeScorecard +} from "../domain/types.js"; import { formatFindingFingerprintLine, withFindingFingerprints @@ -28,6 +34,13 @@ export interface GenericMcpDoctorReport { findings: Finding[]; security: SecurityAudit; compatibility: CompatibilityMatrix; + runtimeScorecard?: RuntimeScorecard; + runtimeExecution?: RuntimeExecutionEvidence; +} + +export interface GenericMcpDoctorOptions { + runtime?: boolean; + runtimeStartupTimeoutMs?: number; } function buildFinding( @@ -162,14 +175,14 @@ function buildStaticMcpFindings( } function mergeReportStatus( - staticFindings: Finding[], + findings: Finding[], security: SecurityAudit ): "pass" | "warn" | "fail" { - if (staticFindings.some((finding) => finding.severity === "fail") || security.status === "fail") { + if (findings.some((finding) => finding.severity === "fail") || security.status === "fail") { return "fail"; } - if (staticFindings.some((finding) => finding.severity === "warn") || security.status === "warn") { + if (findings.some((finding) => finding.severity === "warn") || security.status === "warn") { return "warn"; } @@ -178,7 +191,8 @@ function mergeReportStatus( export async function buildGenericMcpDoctor( targetPath: string, - environment: CompatibilityEnvironment = {} + environment: CompatibilityEnvironment = {}, + options: GenericMcpDoctorOptions = {} ): Promise { const rootPath = path.resolve(targetPath); const compatibility = await buildCompatibilityMatrix(rootPath, environment); @@ -236,11 +250,22 @@ export async function buildGenericMcpDoctor( ? auditMcpServerConfig(rootPath, parsedConfig) : [] ); - const fingerprintedStaticFindings = withFindingFingerprints( - staticFindings, + const runtimeResult = + options.runtime && + mcpConfigPath !== null && + parsedConfig !== null && + isPathWithinRoot(rootPath, mcpConfigPath) && + !staticFindings.some((finding) => finding.severity === "fail") && + security.status !== "fail" + ? await probeRuntimeConfig(rootPath, mcpConfigPath, { + startupTimeoutMs: options.runtimeStartupTimeoutMs + }) + : null; + const fingerprintedFindings = withFindingFingerprints( + [...staticFindings, ...(runtimeResult?.findings ?? [])], rootPath ); - const status = mergeReportStatus(fingerprintedStaticFindings, security); + const status = mergeReportStatus(fingerprintedFindings, security); return { targetPath: rootPath, @@ -248,9 +273,11 @@ export async function buildGenericMcpDoctor( exitCode: status === "fail" ? 1 : 0, mcpConfigPath, serverCount, - findings: [...fingerprintedStaticFindings, ...security.findings], + findings: [...fingerprintedFindings, ...security.findings], security, - compatibility + compatibility, + ...(runtimeResult ? { runtimeScorecard: runtimeResult.scorecard } : {}), + ...(runtimeResult?.execution ? { runtimeExecution: runtimeResult.execution } : {}) }; } @@ -281,6 +308,12 @@ export function renderGenericMcpDoctor(report: GenericMcpDoctorReport): string { .join(", ")}` ]; + if (report.runtimeScorecard?.conformance) { + lines.push( + `Runtime conformance: ${report.runtimeScorecard.conformance.overall.toUpperCase()}` + ); + } + if (report.findings.length === 0) { lines.push("", "No findings."); return lines.join("\n"); diff --git a/src/run-cli.ts b/src/run-cli.ts index 8f7f566..9c09c89 100644 --- a/src/run-cli.ts +++ b/src/run-cli.ts @@ -323,7 +323,7 @@ function parseRuntimeSandbox( function printUsage(io: CliIo): void { io.writeStderr( - "Usage: codex-plugin-doctor check [filter] [--policy codex-publish|mcp-strict|security] [--compat] [--json|--markdown|--badge-json|--badge-markdown] [--output ] [--history ] [--runtime] [--sandbox docker] [--require-runtime-approval --runtime-approval-digest ] [--verbose-runtime] [--explain] [--no-animations] [--ascii] [--changed-since ]\n codex-plugin-doctor audit --installed [filter] [--policy codex-publish|mcp-strict|security] [--security] [--compat] [--json] [--output ] [--cache] [--changed]\n codex-plugin-doctor audit deps [--policy codex-publish|mcp-strict|security] [--recommend] [--json|--sarif] [--output ]\n codex-plugin-doctor mcp [--json] [--output ]\n codex-plugin-doctor security [--policy security] [--json|--scorecard]\n codex-plugin-doctor compat [--all|--client ] [--json] [--scorecard] [--output ] [--install-preview|--apply --backup]\n codex-plugin-doctor suppress add [--fingerprint --reason --expires-at YYYY-MM-DD] [--config ] [--json]\n codex-plugin-doctor suppress list [--config ] [--json]\n codex-plugin-doctor suppress remove [--fingerprint |--index ] [--config ] [--json]\n codex-plugin-doctor fix (--dry-run|--interactive --backup|--apply --backup)\n codex-plugin-doctor history [--json] [--fail-on-regression]\n codex-plugin-doctor watch [--runtime] [--json] [--output ] [--debounce-ms ] [--max-iterations ] [--fail-fast] [--accumulate-json ]\n codex-plugin-doctor doctor [npm |contract|corpus [--manifest ] [--json] [--output ]|corpus metrics --manifest [--json|--markdown] [--output ] [--min-precision <0..1>] [--min-recall <0..1>] [--max-false-positive-rate <0..1>]|runtime-plan [--runtime --sandbox docker] [--json|--markdown] [--output ]|runtime-policy [--runtime --sandbox docker] [--json] [--output ]|review-bundle --output --sign-key-env NAME [--json] [--allow-dirty] [--allow-untagged]|review-bundle verify --target --sign-key-env NAME [--json] [--output ] [--failures-only]|review-bundle diff --before --after [--json]|attest [--sign-key-env NAME]|attest verify --target --sign-key-env NAME|release-evidence --sign-key-env NAME [--runtime --sandbox docker] [--allow-dirty] [--allow-untagged] [--require-runtime-approval --runtime-approval-digest ]|release-evidence verify --target --sign-key-env NAME|release-evidence asset --tag --output --sign-key-env NAME [--upload]|mcp |inspector |diff --before --after |recommend |trust |perf [--max-total-ms ] [--max-stage-ms stage=ms]|export --bundle |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 [--json]\n codex-plugin-doctor release check [--json] [--runtime --sandbox docker]\n codex-plugin-doctor self-test\n codex-plugin-doctor list --installed\n codex-plugin-doctor explain \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" + "Usage: codex-plugin-doctor check [filter] [--policy codex-publish|mcp-strict|security] [--compat] [--json|--markdown|--badge-json|--badge-markdown] [--output ] [--history ] [--runtime] [--sandbox docker] [--require-runtime-approval --runtime-approval-digest ] [--verbose-runtime] [--explain] [--no-animations] [--ascii] [--changed-since ]\n codex-plugin-doctor audit --installed [filter] [--policy codex-publish|mcp-strict|security] [--security] [--compat] [--json] [--output ] [--cache] [--changed]\n codex-plugin-doctor audit deps [--policy codex-publish|mcp-strict|security] [--recommend] [--json|--sarif] [--output ]\n codex-plugin-doctor mcp [--runtime] [--json] [--output ]\n codex-plugin-doctor security [--policy security] [--json|--scorecard]\n codex-plugin-doctor compat [--all|--client ] [--json] [--scorecard] [--output ] [--install-preview|--apply --backup]\n codex-plugin-doctor suppress add [--fingerprint --reason --expires-at YYYY-MM-DD] [--config ] [--json]\n codex-plugin-doctor suppress list [--config ] [--json]\n codex-plugin-doctor suppress remove [--fingerprint |--index ] [--config ] [--json]\n codex-plugin-doctor fix (--dry-run|--interactive --backup|--apply --backup)\n codex-plugin-doctor history [--json] [--fail-on-regression]\n codex-plugin-doctor watch [--runtime] [--json] [--output ] [--debounce-ms ] [--max-iterations ] [--fail-fast] [--accumulate-json ]\n codex-plugin-doctor doctor [npm |contract|corpus [--manifest ] [--json] [--output ]|corpus metrics --manifest [--json|--markdown] [--output ] [--min-precision <0..1>] [--min-recall <0..1>] [--max-false-positive-rate <0..1>]|runtime-plan [--runtime --sandbox docker] [--json|--markdown] [--output ]|runtime-policy [--runtime --sandbox docker] [--json] [--output ]|review-bundle --output --sign-key-env NAME [--json] [--allow-dirty] [--allow-untagged]|review-bundle verify --target --sign-key-env NAME [--json] [--output ] [--failures-only]|review-bundle diff --before --after [--json]|attest [--sign-key-env NAME]|attest verify --target --sign-key-env NAME|release-evidence --sign-key-env NAME [--runtime --sandbox docker] [--allow-dirty] [--allow-untagged] [--require-runtime-approval --runtime-approval-digest ]|release-evidence verify --target --sign-key-env NAME|release-evidence asset --tag --output --sign-key-env NAME [--upload]|mcp |inspector |diff --before --after |recommend |trust |perf [--max-total-ms ] [--max-stage-ms stage=ms]|export --bundle |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 [--json]\n codex-plugin-doctor release check [--json] [--runtime --sandbox docker]\n codex-plugin-doctor self-test\n codex-plugin-doctor list --installed\n codex-plugin-doctor explain \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" ); io.writeStderr( "Corpus quality regression: codex-plugin-doctor doctor corpus metrics diff --before --after [--fail-on-regression] [--json|--markdown] [--output ]" @@ -1144,9 +1144,10 @@ function buildGenericMcpDoctorCommandArgs(commandTarget: string, flags: string[] targetPath: string; jsonOutput: boolean; outputPath: string | null; + runtime: boolean; } | string { if (!commandTarget || commandTarget.startsWith("--")) { - return "Missing target path. Usage: codex-plugin-doctor mcp [--json] [--output ]"; + return "Missing target path. Usage: codex-plugin-doctor mcp [--runtime] [--json] [--output ]"; } const outputIndex = flags.indexOf("--output"); @@ -1159,7 +1160,8 @@ function buildGenericMcpDoctorCommandArgs(commandTarget: string, flags: string[] return { targetPath: commandTarget, jsonOutput: flags.includes("--json"), - outputPath + outputPath, + runtime: flags.includes("--runtime") }; } @@ -1548,6 +1550,8 @@ export async function runCli( const report = await buildGenericMcpDoctor(parsedMcpArgs.targetPath, { env: terminalContext.env, platform: terminalContext.platform + }, { + runtime: parsedMcpArgs.runtime }); const renderedReport = parsedMcpArgs.jsonOutput ? renderGenericMcpDoctorJson(report) @@ -3131,6 +3135,8 @@ export async function runCli( const report = await buildGenericMcpDoctor(parsedMcpArgs.targetPath, { env: terminalContext.env, platform: terminalContext.platform + }, { + runtime: parsedMcpArgs.runtime }); const renderedReport = parsedMcpArgs.jsonOutput ? renderGenericMcpDoctorJson(report) diff --git a/tests/cli-command.test.ts b/tests/cli-command.test.ts index 6740e50..90eda46 100644 --- a/tests/cli-command.test.ts +++ b/tests/cli-command.test.ts @@ -200,6 +200,19 @@ async function createWindsurfHomeFixture(config?: unknown): Promise { const codexHomeFixture = path.resolve("tests/fixtures/codex-home"); describe("runCli", () => { + it("documents the runtime opt-in for both MCP command aliases", async () => { + for (const args of [["mcp"], ["doctor", "mcp"]]) { + const { io, stdout, stderr } = createIo(); + + const exitCode = await runCli(args, io); + + expect(exitCode).toBe(2); + expect(stdout).toEqual([]); + expect(stderr.join("")).toContain( + "codex-plugin-doctor mcp [--runtime] [--json] [--output ]" + ); + } + }); it("runs a bundled self-test against the doctor runtime sample", async () => { const { io, stdout, stderr } = createIo(); const directory = await mkdtemp(path.join(os.tmpdir(), "codex-plugin-doctor-self-test-")); diff --git a/tests/mcp-command.test.ts b/tests/mcp-command.test.ts index a603581..4058fd6 100644 --- a/tests/mcp-command.test.ts +++ b/tests/mcp-command.test.ts @@ -67,6 +67,7 @@ describe("mcp command", () => { expect(output.serverCount).toBe(1); expect(output.mcpConfigPath).toBe(path.join(targetPath, ".mcp.json")); expect(output.security.status).toBe("pass"); + expect(output.runtimeScorecard).toBeUndefined(); expect(output.compatibility.results).toEqual( expect.arrayContaining([ expect.objectContaining({ client: "Codex", status: "skipped" }), @@ -75,6 +76,41 @@ describe("mcp command", () => { ); }); + it("runs explicit runtime conformance for a valid task-capable MCP config", async () => { + const { io, stdout, stderr } = createIo(); + + const exitCode = await runCli( + ["mcp", "tests/fixtures/runtime-conformance-tasks-valid", "--runtime", "--json"], + io + ); + const output = JSON.parse(stdout.join("")); + + expect(exitCode).toBe(0); + expect(stderr).toEqual([]); + expect(output.runtimeScorecard.conformance).toMatchObject({ + profile: "2025-11-25", + tasksList: "pass", + overall: "pass" + }); + expect(output.runtimeExecution).toMatchObject({ backend: "native" }); + }); + + it("fingerprints runtime conformance failures and makes them fail the MCP report", async () => { + const { io, stdout, stderr } = createIo(); + + const exitCode = await runCli( + ["mcp", "tests/fixtures/runtime-conformance-tasks-invalid", "--runtime"], + io + ); + const output = stdout.join(""); + + expect(exitCode).toBe(1); + expect(stderr).toEqual([]); + expect(output).toContain("Status: FAIL"); + expect(output).toContain("Runtime conformance: FAIL"); + expect(output).toMatch(/mcp\.conformance\.tasks_list\.invalid[\s\S]*Fingerprint: [a-f0-9]{64}/); + }); + it("fails a standalone MCP package with unsafe server commands", async () => { const targetPath = await createStandaloneMcpPackage({ mcpServers: { From 9ab479af3f7aad1d7e346e02987a436769c95eb4 Mon Sep 17 00:00:00 2001 From: Furkan Date: Fri, 24 Jul 2026 10:54:02 +0300 Subject: [PATCH 15/20] fix: harden MCP runtime command --- src/core/runtime-probe.ts | 103 +++++++++++++++++++++++++++++++--- src/mcp/generic-mcp-doctor.ts | 20 +++++-- src/run-cli.ts | 51 +++++++++++++++-- tests/cli-command.test.ts | 27 +++++++++ tests/mcp-command.test.ts | 72 +++++++++++++++++++++++- 5 files changed, 254 insertions(+), 19 deletions(-) diff --git a/src/core/runtime-probe.ts b/src/core/runtime-probe.ts index a5def59..25d6746 100644 --- a/src/core/runtime-probe.ts +++ b/src/core/runtime-probe.ts @@ -1,6 +1,6 @@ import { execFile, spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { readFile, stat } from "node:fs/promises"; +import { readFile, realpath, stat } from "node:fs/promises"; import path from "node:path"; import readline from "node:readline"; @@ -9,6 +9,8 @@ import type { Finding, FindingEvidence, McpToolObservation, + RuntimeCapabilityStatus, + RuntimeConformanceScorecard, RuntimeExecutionEvidence, RuntimeProbeResult, RuntimeSandboxMode, @@ -449,6 +451,75 @@ function hasPromptsCapability(message: JsonObject): boolean { return capabilities !== null && isPlainObject(capabilities.prompts); } +function worstRuntimeStatus( + left: T, + right: T +): T { + const severity: Record = { + fail: 4, + warn: 3, + pass: 2, + skipped: 1, + unsupported: 0 + }; + + return severity[left] >= severity[right] ? left : right; +} + +function mergeRuntimeScorecards( + left: RuntimeScorecard, + right: RuntimeScorecard +): RuntimeScorecard { + const leftConformance = left.conformance; + const rightConformance = right.conformance; + const conformance = leftConformance && rightConformance + ? { + protocolVersion: + leftConformance.protocolVersion === null + ? rightConformance.protocolVersion + : rightConformance.protocolVersion === null || + leftConformance.protocolVersion === rightConformance.protocolVersion + ? leftConformance.protocolVersion + : null, + profile: + leftConformance.profile === null + ? rightConformance.profile + : rightConformance.profile === null || leftConformance.profile === rightConformance.profile + ? leftConformance.profile + : null, + capabilityConsistency: worstRuntimeStatus( + leftConformance.capabilityConsistency, + rightConformance.capabilityConsistency + ), + taskDeclarations: worstRuntimeStatus( + leftConformance.taskDeclarations, + rightConformance.taskDeclarations + ), + tasksList: worstRuntimeStatus(leftConformance.tasksList, rightConformance.tasksList), + schemaDialect: worstRuntimeStatus( + leftConformance.schemaDialect, + rightConformance.schemaDialect + ), + overall: worstRuntimeStatus(leftConformance.overall, rightConformance.overall) + } + : leftConformance ?? rightConformance; + + return { + initialize: worstRuntimeStatus(left.initialize, right.initialize), + toolsList: worstRuntimeStatus(left.toolsList, right.toolsList), + toolsCall: worstRuntimeStatus(left.toolsCall, right.toolsCall), + resourcesList: worstRuntimeStatus(left.resourcesList, right.resourcesList), + resourceRead: worstRuntimeStatus(left.resourceRead, right.resourceRead), + resourceTemplatesList: worstRuntimeStatus( + left.resourceTemplatesList, + right.resourceTemplatesList + ), + promptsList: worstRuntimeStatus(left.promptsList, right.promptsList), + promptGet: worstRuntimeStatus(left.promptGet, right.promptGet), + ...(conformance ? { conformance } : {}) + }; +} + function supportsTasksListProbe( protocolVersion: string, capabilities: JsonObject @@ -1855,15 +1926,29 @@ export async function probeRuntimeConfig( const resolvedRootPath = path.resolve(rootPath); const resolvedMcpConfigPath = path.resolve(resolvedRootPath, mcpConfigPath); const startupTimeoutMs = options.startupTimeoutMs ?? 400; + let canonicalRootPath: string; + let canonicalMcpConfigPath: string; + + try { + [canonicalRootPath, canonicalMcpConfigPath] = await Promise.all([ + realpath(resolvedRootPath), + realpath(resolvedMcpConfigPath) + ]); + } catch { + return { + findings: [], + scorecard: createRuntimeScorecard() + }; + } - if (!isPathWithinRoot(resolvedRootPath, resolvedMcpConfigPath)) { + if (!isPathWithinRoot(canonicalRootPath, canonicalMcpConfigPath)) { return { findings: [], scorecard: createRuntimeScorecard() }; } - const servers = await loadMcpServers(resolvedMcpConfigPath); + const servers = await loadMcpServers(canonicalMcpConfigPath); if (!servers) { return { @@ -1875,6 +1960,7 @@ export async function probeRuntimeConfig( const findings: Finding[] = []; let scorecard = createRuntimeScorecard(); let execution: RuntimeExecutionEvidence | undefined; + let hasProbedServer = false; for (const [serverName, config] of Object.entries(servers)) { if (!isPlainObject(config)) { @@ -1892,12 +1978,12 @@ export async function probeRuntimeConfig( : []; const cwd = typeof config.cwd === "string" - ? path.resolve(resolvedRootPath, config.cwd) - : resolvedRootPath; + ? path.resolve(canonicalRootPath, config.cwd) + : canonicalRootPath; const result = await probeCommandServer({ serverName, - packageRoot: resolvedRootPath, + packageRoot: canonicalRootPath, command, args, cwd, @@ -1906,7 +1992,10 @@ export async function probeRuntimeConfig( transcript: options.transcript }); - scorecard = result.scorecard; + scorecard = hasProbedServer + ? mergeRuntimeScorecards(scorecard, result.scorecard) + : result.scorecard; + hasProbedServer = true; execution = result.execution ?? execution; if (result.findings.length > 0) { diff --git a/src/mcp/generic-mcp-doctor.ts b/src/mcp/generic-mcp-doctor.ts index 2204021..007f7dc 100644 --- a/src/mcp/generic-mcp-doctor.ts +++ b/src/mcp/generic-mcp-doctor.ts @@ -1,4 +1,4 @@ -import { stat } from "node:fs/promises"; +import { realpath, stat } from "node:fs/promises"; import path from "node:path"; import { @@ -197,13 +197,21 @@ export async function buildGenericMcpDoctor( const rootPath = path.resolve(targetPath); const compatibility = await buildCompatibilityMatrix(rootPath, environment); const mcpConfigPath = await readMcpConfigPath(rootPath); + const canonicalRootPath = await realpath(rootPath).catch(() => null); + const canonicalMcpConfigPath = mcpConfigPath + ? await realpath(mcpConfigPath).catch(() => null) + : null; let parsedConfig: unknown = null; let staticFindings: Finding[] = []; let serverCount = 0; if (!mcpConfigPath || !(await fileExists(mcpConfigPath))) { staticFindings = buildStaticMcpFindings(null, null).findings; - } else if (!isPathWithinRoot(rootPath, mcpConfigPath)) { + } else if ( + !canonicalRootPath || + !canonicalMcpConfigPath || + !isPathWithinRoot(canonicalRootPath, canonicalMcpConfigPath) + ) { staticFindings = [ buildFinding( "fail", @@ -213,7 +221,7 @@ export async function buildGenericMcpDoctor( "Keep `.mcp.json` or the manifest `mcpServers` reference inside the package root.", { configPath: path.relative(rootPath, mcpConfigPath).replaceAll("\\", "/"), - resolvedPath: mcpConfigPath, + resolvedPath: canonicalMcpConfigPath ?? mcpConfigPath, field: "configPath" } ) @@ -254,10 +262,12 @@ export async function buildGenericMcpDoctor( options.runtime && mcpConfigPath !== null && parsedConfig !== null && - isPathWithinRoot(rootPath, mcpConfigPath) && + canonicalRootPath !== null && + canonicalMcpConfigPath !== null && + isPathWithinRoot(canonicalRootPath, canonicalMcpConfigPath) && !staticFindings.some((finding) => finding.severity === "fail") && security.status !== "fail" - ? await probeRuntimeConfig(rootPath, mcpConfigPath, { + ? await probeRuntimeConfig(canonicalRootPath, canonicalMcpConfigPath, { startupTimeoutMs: options.runtimeStartupTimeoutMs }) : null; diff --git a/src/run-cli.ts b/src/run-cli.ts index 9c09c89..19617ff 100644 --- a/src/run-cli.ts +++ b/src/run-cli.ts @@ -1150,18 +1150,57 @@ function buildGenericMcpDoctorCommandArgs(commandTarget: string, flags: string[] return "Missing target path. Usage: codex-plugin-doctor mcp [--runtime] [--json] [--output ]"; } - const outputIndex = flags.indexOf("--output"); - const outputPath = outputIndex === -1 ? null : flags[outputIndex + 1]; + let jsonOutput = false; + let outputPath: string | null = null; + let runtime = false; - if (outputIndex !== -1 && (!outputPath || outputPath.startsWith("--"))) { - return "Missing path after --output."; + for (let index = 0; index < flags.length; index += 1) { + const flag = flags[index]; + + if (flag === "--runtime") { + if (runtime) { + return "Duplicate MCP flag: --runtime."; + } + + runtime = true; + continue; + } + + if (flag === "--json") { + if (jsonOutput) { + return "Duplicate MCP flag: --json."; + } + + jsonOutput = true; + continue; + } + + if (flag === "--output") { + if (outputPath !== null) { + return "Duplicate MCP flag: --output."; + } + + const value = flags[index + 1]; + + if (!value || value.startsWith("--")) { + return "Missing path after --output."; + } + + outputPath = value; + index += 1; + continue; + } + + return flag.startsWith("--") + ? `Unknown MCP flag: ${flag}.` + : `Unexpected MCP argument: ${flag}.`; } return { targetPath: commandTarget, - jsonOutput: flags.includes("--json"), + jsonOutput, outputPath, - runtime: flags.includes("--runtime") + runtime }; } diff --git a/tests/cli-command.test.ts b/tests/cli-command.test.ts index 90eda46..039f5ce 100644 --- a/tests/cli-command.test.ts +++ b/tests/cli-command.test.ts @@ -213,6 +213,33 @@ describe("runCli", () => { ); } }); + + it("rejects malformed MCP flags for both command aliases", async () => { + const invalidFlags = [ + ["--runtme"], + ["unexpected"], + ["--runtime", "--runtime"], + ["--json", "--json"], + ["--output"], + ["--output", "--json"], + ["--output", "report.json", "--output", "second.json"] + ]; + + for (const prefix of [["mcp"], ["doctor", "mcp"]]) { + for (const flags of invalidFlags) { + const { io, stdout, stderr } = createIo(); + + const exitCode = await runCli( + [...prefix, "tests/fixtures/runtime-conformance-tasks-valid", ...flags], + io + ); + + expect(exitCode).toBe(2); + expect(stdout).toEqual([]); + expect(stderr).toHaveLength(1); + } + } + }); it("runs a bundled self-test against the doctor runtime sample", async () => { const { io, stdout, stderr } = createIo(); const directory = await mkdtemp(path.join(os.tmpdir(), "codex-plugin-doctor-self-test-")); diff --git a/tests/mcp-command.test.ts b/tests/mcp-command.test.ts index 4058fd6..2e50bef 100644 --- a/tests/mcp-command.test.ts +++ b/tests/mcp-command.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { access, mkdir, mkdtemp, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; @@ -111,6 +111,76 @@ describe("mcp command", () => { expect(output).toMatch(/mcp\.conformance\.tasks_list\.invalid[\s\S]*Fingerprint: [a-f0-9]{64}/); }); + it("preserves the worst runtime scorecard when a later server passes", async () => { + const targetPath = await createStandaloneMcpPackage({ + mcpServers: { + failing: { + command: process.execPath, + args: [path.resolve("tests/fixtures/runtime-conformance-tasks-invalid/mock-server.js")] + }, + passing: { + command: process.execPath, + args: [path.resolve("tests/fixtures/runtime-conformance-tasks-valid/mock-server.js")] + } + } + }); + const { io, stdout, stderr } = createIo(); + + const exitCode = await runCli(["mcp", targetPath, "--runtime", "--json"], io); + const output = JSON.parse(stdout.join("")); + + expect(exitCode).toBe(1); + expect(stderr).toEqual([]); + expect(output.status).toBe("fail"); + expect(output.runtimeScorecard.conformance).toMatchObject({ + profile: "2025-11-25", + tasksList: "fail", + overall: "fail" + }); + }); + + const symlinkIt = process.platform === "win32" ? it.skip : it; + + symlinkIt("does not execute an MCP config reached through a symlink escape", async () => { + const targetPath = await mkdtemp(path.join(os.tmpdir(), "codex-plugin-doctor-mcp-root-")); + const externalPath = await mkdtemp(path.join(os.tmpdir(), "codex-plugin-doctor-mcp-external-")); + const markerPath = path.join(externalPath, "runtime-started"); + const serverPath = path.join(externalPath, "server.js"); + const externalConfigPath = path.join(externalPath, ".mcp.json"); + + await writeFile( + serverPath, + `import { writeFileSync } from "node:fs";\nwriteFileSync(${JSON.stringify(markerPath)}, "started");\nprocess.stdin.resume();\n`, + "utf8" + ); + await writeFile( + externalConfigPath, + JSON.stringify({ + mcpServers: { + external: { + command: process.execPath, + args: [serverPath] + } + } + }), + "utf8" + ); + await symlink(externalConfigPath, path.join(targetPath, ".mcp.json"), "file"); + const { io, stdout, stderr } = createIo(); + + const exitCode = await runCli(["mcp", targetPath, "--runtime", "--json"], io); + const output = JSON.parse(stdout.join("")); + + expect(exitCode).toBe(1); + expect(stderr).toEqual([]); + expect(output.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "mcp.config.path_outside_root" }) + ]) + ); + await expect(access(markerPath)).rejects.toThrow(); + }); + it("fails a standalone MCP package with unsafe server commands", async () => { const targetPath = await createStandaloneMcpPackage({ mcpServers: { From 5acba7393f2c408ff5eaeb41022dfbfa69f67f79 Mon Sep 17 00:00:00 2001 From: Furkan Date: Fri, 24 Jul 2026 11:07:18 +0300 Subject: [PATCH 16/20] feat: report MCP conformance status --- src/core/output-contract.ts | 97 +++++++++++++++++++++++-- src/reporting/render-markdown-report.ts | 51 +++++++++---- src/reporting/render-text-report.ts | 55 ++++++++------ tests/contract-command.test.ts | 52 +++++++++++++ tests/json-report.test.ts | 44 +++++++++++ tests/markdown-report.test.ts | 80 +++++++++++++++++++- tests/mcp-command.test.ts | 6 +- tests/render-text-report.test.ts | 72 +++++++++++++++++- 8 files changed, 413 insertions(+), 44 deletions(-) diff --git a/src/core/output-contract.ts b/src/core/output-contract.ts index 8b28340..b546f89 100644 --- a/src/core/output-contract.ts +++ b/src/core/output-contract.ts @@ -45,16 +45,88 @@ export interface DoctorOutputContract { schemas: OutputContractSchema[]; } +const runtimeCapabilityStatusSchema = { + type: "string", + enum: ["pass", "fail", "warn", "skipped", "unsupported"] +}; + +const runtimeConformanceSchema = { + type: "object", + properties: { + protocolVersion: { + type: ["string", "null"] + }, + profile: { + type: ["string", "null"], + enum: ["legacy", "2025-11-25", "future-compatible", null] + }, + capabilityConsistency: runtimeCapabilityStatusSchema, + taskDeclarations: runtimeCapabilityStatusSchema, + tasksList: runtimeCapabilityStatusSchema, + schemaDialect: runtimeCapabilityStatusSchema, + overall: { + type: "string", + enum: ["pass", "warn", "fail", "skipped"] + } + }, + required: [ + "protocolVersion", + "profile", + "capabilityConsistency", + "taskDeclarations", + "tasksList", + "schemaDialect", + "overall" + ], + additionalProperties: false +}; + +const runtimeScorecardSchema = { + type: "object", + properties: { + initialize: runtimeCapabilityStatusSchema, + toolsList: runtimeCapabilityStatusSchema, + toolsCall: runtimeCapabilityStatusSchema, + resourcesList: runtimeCapabilityStatusSchema, + resourceRead: runtimeCapabilityStatusSchema, + resourceTemplatesList: runtimeCapabilityStatusSchema, + promptsList: runtimeCapabilityStatusSchema, + promptGet: runtimeCapabilityStatusSchema, + conformance: runtimeConformanceSchema + }, + required: [ + "initialize", + "toolsList", + "toolsCall", + "resourcesList", + "resourceRead", + "resourceTemplatesList", + "promptsList", + "promptGet" + ], + additionalProperties: false +}; + const publicSchemaDefinitions: Array<{ id: string; command: string; outputKind?: string; required?: string[]; + properties?: Record; }> = [ { id: "doctor.check.json", command: "codex-plugin-doctor check --json", - required: ["schemaVersion", "generatedAt", "summary", "findings"] + required: ["schemaVersion", "generatedAt", "summary", "findings"], + properties: { + summary: { + type: "object", + properties: { + runtimeScorecard: runtimeScorecardSchema + }, + additionalProperties: true + } + } }, { id: "doctor.installed.check.json", @@ -76,7 +148,13 @@ const publicSchemaDefinitions: Array<{ id: "doctor.mcp.json", command: "codex-plugin-doctor mcp --json", outputKind: "doctor.mcp.healthcheck", - required: ["schemaVersion", "kind", "generatedAt", "targetPath", "status", "serverCount", "findings", "security", "compatibility"] + required: ["schemaVersion", "kind", "generatedAt", "targetPath", "status", "serverCount", "findings", "security", "compatibility"], + properties: { + runtimeScorecard: { + ...runtimeScorecardSchema, + description: "Present only when codex-plugin-doctor mcp --runtime is used." + } + } }, { id: "doctor.audit.json", @@ -330,7 +408,8 @@ function contractRules(rules: RuleDefinition[]): OutputContractRule[] { function buildSchema( id: string, outputKind: string | null, - required: string[] + required: string[], + schemaProperties: Record = {} ): JsonSchema { const properties: Record = { schemaVersion: { @@ -350,7 +429,10 @@ function buildSchema( title: id, type: "object", required: [...new Set(["schemaVersion", ...required])], - properties, + properties: { + ...properties, + ...schemaProperties + }, additionalProperties: true }; } @@ -365,7 +447,12 @@ function buildSchemas(): OutputContractSchema[] { schemaVersion: "1.0.0", stability: "stable-through-1.0", outputKind, - schema: buildSchema(definition.id, outputKind, definition.required ?? []) + schema: buildSchema( + definition.id, + outputKind, + definition.required ?? [], + definition.properties + ) }; }); } diff --git a/src/reporting/render-markdown-report.ts b/src/reporting/render-markdown-report.ts index 35c1f90..bbe5757 100644 --- a/src/reporting/render-markdown-report.ts +++ b/src/reporting/render-markdown-report.ts @@ -3,6 +3,41 @@ import { formatFindingEvidenceLine } from "./format-finding-evidence.js"; import { formatFindingFingerprintLine } from "./finding-fingerprint.js"; import { buildRecommendedCommands } from "./recommended-commands.js"; +function appendRuntimeScorecard(lines: string[], result: CheckResult) { + if (!result.runtimeScorecard) { + return; + } + + lines.push("", "## Runtime Scorecard", ""); + lines.push("| Operation | Status |"); + lines.push("| --- | --- |"); + lines.push(`| initialize | ${result.runtimeScorecard.initialize.toUpperCase()} |`); + lines.push(`| tools/list | ${result.runtimeScorecard.toolsList.toUpperCase()} |`); + lines.push(`| tools/call | ${result.runtimeScorecard.toolsCall.toUpperCase()} |`); + lines.push(`| resources/list | ${result.runtimeScorecard.resourcesList.toUpperCase()} |`); + lines.push(`| resources/read | ${result.runtimeScorecard.resourceRead.toUpperCase()} |`); + lines.push(`| resources/templates/list | ${result.runtimeScorecard.resourceTemplatesList.toUpperCase()} |`); + lines.push(`| prompts/list | ${result.runtimeScorecard.promptsList.toUpperCase()} |`); + lines.push(`| prompts/get | ${result.runtimeScorecard.promptGet.toUpperCase()} |`); + + const conformance = result.runtimeScorecard.conformance; + + if (!conformance) { + return; + } + + lines.push("", "## MCP Conformance", ""); + lines.push("| Check | Status |"); + lines.push("| --- | --- |"); + lines.push(`| Protocol version | ${conformance.protocolVersion ?? "unavailable"} |`); + lines.push(`| Profile | ${conformance.profile ?? "unavailable"} |`); + lines.push(`| Capability consistency | ${conformance.capabilityConsistency.toUpperCase()} |`); + lines.push(`| Task declarations | ${conformance.taskDeclarations.toUpperCase()} |`); + lines.push(`| Tasks list | ${conformance.tasksList.toUpperCase()} |`); + lines.push(`| Schema dialect | ${conformance.schemaDialect.toUpperCase()} |`); + lines.push(`| Overall | ${conformance.overall.toUpperCase()} |`); +} + export function buildMarkdownReport( result: CheckResult, options: { runtimeProbeEnabled: boolean } @@ -52,25 +87,13 @@ export function buildMarkdownReport( ); } + appendRuntimeScorecard(lines, result); + if (result.findings.length === 0 && !result.suppressedFindings?.length) { lines.push("", result.baselineSummary ? "No new findings." : "No findings."); return lines.join("\n"); } - if (result.runtimeScorecard) { - lines.push("", "## Runtime Scorecard", ""); - lines.push("| Operation | Status |"); - lines.push("| --- | --- |"); - lines.push(`| initialize | ${result.runtimeScorecard.initialize.toUpperCase()} |`); - lines.push(`| tools/list | ${result.runtimeScorecard.toolsList.toUpperCase()} |`); - lines.push(`| tools/call | ${result.runtimeScorecard.toolsCall.toUpperCase()} |`); - lines.push(`| resources/list | ${result.runtimeScorecard.resourcesList.toUpperCase()} |`); - lines.push(`| resources/read | ${result.runtimeScorecard.resourceRead.toUpperCase()} |`); - lines.push(`| resources/templates/list | ${result.runtimeScorecard.resourceTemplatesList.toUpperCase()} |`); - lines.push(`| prompts/list | ${result.runtimeScorecard.promptsList.toUpperCase()} |`); - lines.push(`| prompts/get | ${result.runtimeScorecard.promptGet.toUpperCase()} |`); - } - if (result.findings.length > 0) { const nextActions = Array.from( new Set(result.findings.map((finding) => finding.suggestedFix)) diff --git a/src/reporting/render-text-report.ts b/src/reporting/render-text-report.ts index 8972f8f..6282225 100644 --- a/src/reporting/render-text-report.ts +++ b/src/reporting/render-text-report.ts @@ -33,6 +33,37 @@ function getGlyphs(ascii: boolean) { }; } +function appendRuntimeScorecard(lines: string[], result: CheckResult) { + if (!result.runtimeScorecard) { + return; + } + + lines.push("", "Runtime Scorecard", "----------------"); + lines.push(`initialize: ${result.runtimeScorecard.initialize}`); + lines.push(`tools/list: ${result.runtimeScorecard.toolsList}`); + lines.push(`tools/call: ${result.runtimeScorecard.toolsCall}`); + lines.push(`resources/list: ${result.runtimeScorecard.resourcesList}`); + lines.push(`resources/read: ${result.runtimeScorecard.resourceRead}`); + lines.push(`resources/templates/list: ${result.runtimeScorecard.resourceTemplatesList}`); + lines.push(`prompts/list: ${result.runtimeScorecard.promptsList}`); + lines.push(`prompts/get: ${result.runtimeScorecard.promptGet}`); + + const conformance = result.runtimeScorecard.conformance; + + if (!conformance) { + return; + } + + lines.push("", "MCP Conformance", "---------------"); + lines.push(`Protocol version: ${conformance.protocolVersion ?? "unavailable"}`); + lines.push(`Profile: ${conformance.profile ?? "unavailable"}`); + lines.push(`Capability consistency: ${conformance.capabilityConsistency}`); + lines.push(`Task declarations: ${conformance.taskDeclarations}`); + lines.push(`Tasks list: ${conformance.tasksList}`); + lines.push(`Schema dialect: ${conformance.schemaDialect}`); + lines.push(`Overall: ${conformance.overall}`); +} + export function renderTextReport( result: CheckResult, options: { ascii?: boolean; explain?: boolean } = {} @@ -69,17 +100,7 @@ export function renderTextReport( } if (result.findings.length === 0 && !result.suppressedFindings?.length) { - if (result.runtimeScorecard) { - lines.push("", "Runtime Scorecard", "----------------"); - lines.push(`initialize: ${result.runtimeScorecard.initialize}`); - lines.push(`tools/list: ${result.runtimeScorecard.toolsList}`); - lines.push(`tools/call: ${result.runtimeScorecard.toolsCall}`); - lines.push(`resources/list: ${result.runtimeScorecard.resourcesList}`); - lines.push(`resources/read: ${result.runtimeScorecard.resourceRead}`); - lines.push(`resources/templates/list: ${result.runtimeScorecard.resourceTemplatesList}`); - lines.push(`prompts/list: ${result.runtimeScorecard.promptsList}`); - lines.push(`prompts/get: ${result.runtimeScorecard.promptGet}`); - } + appendRuntimeScorecard(lines, result); lines.push("", result.baselineSummary ? "No new findings." : "No findings."); return lines.join("\n"); @@ -161,17 +182,7 @@ export function renderTextReport( } } - if (result.runtimeScorecard) { - lines.push("", "Runtime Scorecard", "----------------"); - lines.push(`initialize: ${result.runtimeScorecard.initialize}`); - lines.push(`tools/list: ${result.runtimeScorecard.toolsList}`); - lines.push(`tools/call: ${result.runtimeScorecard.toolsCall}`); - lines.push(`resources/list: ${result.runtimeScorecard.resourcesList}`); - lines.push(`resources/read: ${result.runtimeScorecard.resourceRead}`); - lines.push(`resources/templates/list: ${result.runtimeScorecard.resourceTemplatesList}`); - lines.push(`prompts/list: ${result.runtimeScorecard.promptsList}`); - lines.push(`prompts/get: ${result.runtimeScorecard.promptGet}`); - } + appendRuntimeScorecard(lines, result); const recommendedCommands = buildRecommendedCommands(result); diff --git a/tests/contract-command.test.ts b/tests/contract-command.test.ts index ca7b86f..e74ca37 100644 --- a/tests/contract-command.test.ts +++ b/tests/contract-command.test.ts @@ -203,6 +203,12 @@ describe("doctor contract command", () => { const runtimePolicySchema = output.schemas.find( (surface: { id: string }) => surface.id === "doctor.runtime.policy.json" ); + const checkSchema = output.schemas.find( + (surface: { id: string }) => surface.id === "doctor.check.json" + ); + const mcpSchema = output.schemas.find( + (surface: { id: string }) => surface.id === "doctor.mcp.json" + ); const releaseEvidenceSchema = output.schemas.find( (surface: { id: string }) => surface.id === "doctor.release.evidence.json" ); @@ -213,6 +219,52 @@ describe("doctor contract command", () => { expect(runtimePolicySchema.schema.required).toContain("execution"); expect(releaseEvidenceSchema.schemaVersion).toBe("1.0.0"); expect(releaseEvidenceSchema.schema.required).not.toContain("execution"); + expect(checkSchema.schema.required).toEqual([ + "schemaVersion", + "generatedAt", + "summary", + "findings" + ]); + expect(mcpSchema.schema.required).toEqual([ + "schemaVersion", + "kind", + "generatedAt", + "targetPath", + "status", + "serverCount", + "findings", + "security", + "compatibility" + ]); + expect(checkSchema.schema.properties.summary.properties.runtimeScorecard.properties.conformance) + .toMatchObject({ + type: "object", + properties: { + protocolVersion: { type: ["string", "null"] }, + profile: { type: ["string", "null"] }, + capabilityConsistency: { type: "string" }, + taskDeclarations: { type: "string" }, + tasksList: { type: "string" }, + schemaDialect: { type: "string" }, + overall: { type: "string" } + } + }); + expect(mcpSchema.schema.properties.runtimeScorecard.properties.conformance) + .toMatchObject({ + type: "object", + properties: { + protocolVersion: { type: ["string", "null"] }, + profile: { type: ["string", "null"] }, + capabilityConsistency: { type: "string" }, + taskDeclarations: { type: "string" }, + tasksList: { type: "string" }, + schemaDialect: { type: "string" }, + overall: { type: "string" } + } + }); + expect(mcpSchema.schema.properties.runtimeScorecard.description).toBe( + "Present only when codex-plugin-doctor mcp --runtime is used." + ); const suppressionSchemas = Object.fromEntries( output.schemas diff --git a/tests/json-report.test.ts b/tests/json-report.test.ts index c42c47a..f96fdf7 100644 --- a/tests/json-report.test.ts +++ b/tests/json-report.test.ts @@ -2,6 +2,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { runCheck } from "../src/index.js"; +import type { CheckResult } from "../src/domain/types.js"; import { buildJsonReport } from "../src/reporting/render-json-report.js"; describe("buildJsonReport", () => { @@ -23,5 +24,48 @@ describe("buildJsonReport", () => { }); expect(Array.isArray(report.findings)).toBe(true); }); + + it("serializes additive MCP conformance without task values", () => { + const result: CheckResult = { + targetPath: "example", + status: "pass", + exitCode: 0, + findings: [], + runtimeScorecard: { + initialize: "pass", + toolsList: "pass", + toolsCall: "pass", + resourcesList: "pass", + resourceRead: "pass", + resourceTemplatesList: "pass", + promptsList: "pass", + promptGet: "pass", + conformance: { + protocolVersion: "2025-11-25", + profile: "2025-11-25", + capabilityConsistency: "pass", + taskDeclarations: "pass", + tasksList: "pass", + schemaDialect: "pass", + overall: "pass" + } + } + }; + + const report = buildJsonReport(result, { runtimeProbeEnabled: true }); + const serialized = JSON.stringify(report); + + expect(report.summary.runtimeScorecard?.conformance).toEqual({ + protocolVersion: "2025-11-25", + profile: "2025-11-25", + capabilityConsistency: "pass", + taskDeclarations: "pass", + tasksList: "pass", + schemaDialect: "pass", + overall: "pass" + }); + expect(serialized).not.toContain("private-task-id"); + expect(Array.isArray(report.findings)).toBe(true); + }); }); diff --git a/tests/markdown-report.test.ts b/tests/markdown-report.test.ts index 8383ea2..14487b4 100644 --- a/tests/markdown-report.test.ts +++ b/tests/markdown-report.test.ts @@ -2,10 +2,88 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { runCheck } from "../src/index.js"; -import type { CheckResult } from "../src/domain/types.js"; +import type { CheckResult, RuntimeScorecard } from "../src/domain/types.js"; import { buildMarkdownReport } from "../src/reporting/render-markdown-report.js"; describe("buildMarkdownReport", () => { + const runtimeScorecard: RuntimeScorecard = { + initialize: "pass", + toolsList: "pass", + toolsCall: "pass", + resourcesList: "pass", + resourceRead: "pass", + resourceTemplatesList: "pass", + promptsList: "pass", + promptGet: "pass", + conformance: { + protocolVersion: "2025-11-25", + profile: "2025-11-25", + capabilityConsistency: "pass", + taskDeclarations: "pass", + tasksList: "pass", + schemaDialect: "pass", + overall: "pass" + } + }; + + function expectRuntimeScorecardWithConformance(report: string) { + expect(report).toContain("## Runtime Scorecard"); + expect(report).toContain("| initialize | PASS |"); + expect(report).toContain("| prompts/get | PASS |"); + expect(report).toContain("## MCP Conformance"); + expect(report).toContain("| Protocol version | 2025-11-25 |"); + expect(report).toContain("| Profile | 2025-11-25 |"); + expect(report).toContain("| Capability consistency | PASS |"); + expect(report).toContain("| Task declarations | PASS |"); + expect(report).toContain("| Tasks list | PASS |"); + expect(report).toContain("| Schema dialect | PASS |"); + expect(report).toContain("| Overall | PASS |"); + expect(report).not.toContain("private-task-id"); + } + + it("renders runtime scorecard and MCP Conformance before no-findings output", () => { + const report = buildMarkdownReport( + { + targetPath: "example", + status: "pass", + exitCode: 0, + findings: [], + runtimeScorecard + }, + { runtimeProbeEnabled: true } + ); + + expectRuntimeScorecardWithConformance(report); + expect(report.indexOf("## Runtime Scorecard")).toBeLessThan( + report.indexOf("No findings.") + ); + }); + + it("renders one MCP Conformance section alongside findings", () => { + const report = buildMarkdownReport( + { + targetPath: "example", + status: "fail", + exitCode: 1, + runtimeScorecard, + findings: [ + { + id: "plugin.manifest.missing", + severity: "fail", + message: "Missing manifest.", + impact: "Codex cannot load the package.", + suggestedFix: "Create `.codex-plugin/plugin.json`." + } + ] + }, + { runtimeProbeEnabled: true } + ); + + expectRuntimeScorecardWithConformance(report); + expect(report.match(/## MCP Conformance/g)).toHaveLength(1); + expect(report).toContain("## Findings"); + }); + it("renders a CI-friendly markdown summary", async () => { const targetPath = path.resolve("tests/fixtures/heuristic-long-plugin-description"); const result = await runCheck(targetPath); diff --git a/tests/mcp-command.test.ts b/tests/mcp-command.test.ts index 2e50bef..f0d65d8 100644 --- a/tests/mcp-command.test.ts +++ b/tests/mcp-command.test.ts @@ -83,7 +83,8 @@ describe("mcp command", () => { ["mcp", "tests/fixtures/runtime-conformance-tasks-valid", "--runtime", "--json"], io ); - const output = JSON.parse(stdout.join("")); + const serialized = stdout.join(""); + const output = JSON.parse(serialized); expect(exitCode).toBe(0); expect(stderr).toEqual([]); @@ -93,6 +94,9 @@ describe("mcp command", () => { overall: "pass" }); expect(output.runtimeExecution).toMatchObject({ backend: "native" }); + expect(serialized).not.toContain("private-task-id"); + expect(serialized).not.toContain("private task text"); + expect(serialized).not.toContain("private-task-cursor"); }); it("fingerprints runtime conformance failures and makes them fail the MCP report", async () => { diff --git a/tests/render-text-report.test.ts b/tests/render-text-report.test.ts index 1bb370f..70dc1e5 100644 --- a/tests/render-text-report.test.ts +++ b/tests/render-text-report.test.ts @@ -2,10 +2,80 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { runCheck } from "../src/index.js"; -import type { CheckResult } from "../src/domain/types.js"; +import type { CheckResult, RuntimeScorecard } from "../src/domain/types.js"; import { renderTextReport } from "../src/reporting/render-text-report.js"; describe("renderTextReport", () => { + const runtimeScorecard: RuntimeScorecard = { + initialize: "pass", + toolsList: "pass", + toolsCall: "pass", + resourcesList: "pass", + resourceRead: "pass", + resourceTemplatesList: "pass", + promptsList: "pass", + promptGet: "pass", + conformance: { + protocolVersion: "2025-11-25", + profile: "2025-11-25", + capabilityConsistency: "pass", + taskDeclarations: "pass", + tasksList: "pass", + schemaDialect: "pass", + overall: "pass" + } + }; + + function expectRuntimeScorecardWithConformance(output: string) { + expect(output).toContain("Runtime Scorecard\n----------------"); + expect(output).toContain("initialize: pass"); + expect(output).toContain("prompts/get: pass"); + expect(output).toContain("MCP Conformance\n---------------"); + expect(output).toContain("Protocol version: 2025-11-25"); + expect(output).toContain("Profile: 2025-11-25"); + expect(output).toContain("Capability consistency: pass"); + expect(output).toContain("Task declarations: pass"); + expect(output).toContain("Tasks list: pass"); + expect(output).toContain("Schema dialect: pass"); + expect(output).toContain("Overall: pass"); + expect(output).not.toContain("private-task-id"); + } + + it("renders runtime operations and MCP Conformance without findings", () => { + const output = renderTextReport({ + targetPath: "example", + status: "pass", + exitCode: 0, + findings: [], + runtimeScorecard + }); + + expectRuntimeScorecardWithConformance(output); + expect(output).toContain("No findings."); + }); + + it("renders one MCP Conformance section alongside findings", () => { + const output = renderTextReport({ + targetPath: "example", + status: "fail", + exitCode: 1, + runtimeScorecard, + findings: [ + { + id: "plugin.manifest.missing", + severity: "fail", + message: "Missing manifest.", + impact: "Codex cannot load the package.", + suggestedFix: "Create `.codex-plugin/plugin.json`." + } + ] + }); + + expectRuntimeScorecardWithConformance(output); + expect(output.match(/MCP Conformance/g)).toHaveLength(1); + expect(output).toContain("Failures"); + }); + it("renders a rich unicode summary for warn results", async () => { const result = await runCheck( path.resolve("tests/fixtures/heuristic-long-plugin-description") From 0bfef5c3500ec3eeb778bd2c9f4d2847ef413bb2 Mon Sep 17 00:00:00 2001 From: Furkan Date: Fri, 24 Jul 2026 11:28:52 +0300 Subject: [PATCH 17/20] docs: publish MCP conformance contract --- docs/README.md | 1 + docs/architecture/mcp-2025-11-conformance.md | 4 +- src/core/runtime-plan.ts | 2 + src/rules/rule-catalog.ts | 72 ++++++++++++++++ tests/rule-catalog.test.ts | 90 ++++++++++++++++++++ tests/runtime-plan-command.test.ts | 15 ++++ 6 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 tests/rule-catalog.test.ts diff --git a/docs/README.md b/docs/README.md index 4f0d009..116e482 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ This directory contains public documentation for users, contributors, and securi - [Validation Engine](architecture/validation-engine.md) - [Suppression Management](architecture/suppression-management.md) - [Runtime Sandbox and External Corpus](architecture/runtime-sandbox-and-external-corpus.md) +- [MCP 2025-11 Conformance](architecture/mcp-2025-11-conformance.md) - [Real-World Corpus Quality Metrics](architecture/real-world-corpus-quality-metrics.md) - [Corpus Metrics Regression Diff](architecture/corpus-metrics-regression-diff.md) diff --git a/docs/architecture/mcp-2025-11-conformance.md b/docs/architecture/mcp-2025-11-conformance.md index 6765eee..dc51900 100644 --- a/docs/architecture/mcp-2025-11-conformance.md +++ b/docs/architecture/mcp-2025-11-conformance.md @@ -2,7 +2,7 @@ ## Status -Approved design for the release following `v1.50.0`. +Implementation complete; targeted for `v1.51.0`. ## Purpose @@ -34,7 +34,7 @@ This feature adds version-aware, read-only conformance checks to the existing ru ## User Experience -The checks run automatically anywhere the existing runtime probe runs, including `check --runtime` and `mcp`. No new flag is required. +The checks run automatically whenever runtime probing is explicitly enabled, including `check --runtime` and `mcp --runtime`. Plain `mcp ` remains static and does not start an MCP server. Existing report fields remain unchanged. A new additive `conformance` section records: diff --git a/src/core/runtime-plan.ts b/src/core/runtime-plan.ts index 95cbd8e..a2740fe 100644 --- a/src/core/runtime-plan.ts +++ b/src/core/runtime-plan.ts @@ -217,6 +217,7 @@ export async function buildDoctorRuntimePlan( ? [ "initialize", "tools/list", + "tasks/list:declared-2025-11-only", "tools/call:safe-only", "resources/list", "resources/read:first-resource-only", @@ -346,6 +347,7 @@ export function renderDoctorRuntimePlanMarkdown(plan: DoctorRuntimePlan): string "## Execution Boundary", "", "- This plan is non-executing.", + "- Probe methods explicitly exclude task create, get, result, and cancel operations, plus sampling and elicitation requests.", "- Runtime probes require explicit operator approval before local MCP servers are started.", "- The approval digest changes when command, args, cwd, probe methods, risk reasons, or findings change.", "- Runtime approval is a review gate, not an OS, VM, or container sandbox.", diff --git a/src/rules/rule-catalog.ts b/src/rules/rule-catalog.ts index cc8d655..b97bb2a 100644 --- a/src/rules/rule-catalog.ts +++ b/src/rules/rule-catalog.ts @@ -297,6 +297,78 @@ export const ruleCatalog: RuleDefinition[] = [ fix: "Remove hidden override or exfiltration instructions and keep descriptions scoped to legitimate behavior.", example: "Keep SKILL.md, prompt, resource, and tool descriptions direct and user-facing." }, + { + id: "mcp.conformance.protocol.unknown_newer", + category: "mcp", + defaultSeverity: "warn", + summary: "The server advertises a newer MCP protocol version.", + why: "The validator applies the 2025-11-25 structural baseline, which may not cover newer protocol requirements.", + fix: "Confirm the server's newer protocol changes remain compatible with the 2025-11-25 MCP contract.", + example: '{ "protocolVersion": "2026-01-01" }' + }, + { + id: "mcp.conformance.tasks.capability_invalid", + category: "mcp", + defaultSeverity: "fail", + summary: "A Tasks capability declaration is malformed.", + why: "Codex cannot safely determine whether the server supports task-aware tool calls.", + fix: "Return an object for the affected Tasks capability field, or omit the capability until it is implemented.", + example: '{ "capabilities": { "tasks": { "requests": { "tools": { "call": {} } } } } }' + }, + { + id: "mcp.conformance.tasks.task_support_invalid", + category: "mcp", + defaultSeverity: "fail", + summary: "A tool declares invalid task support.", + why: "Codex cannot safely interpret the tool's task support contract.", + fix: "Set execution.taskSupport to required, optional, or forbidden.", + example: '{ "execution": { "taskSupport": "optional" } }' + }, + { + id: "mcp.conformance.tasks.capability_mismatch", + category: "mcp", + defaultSeverity: "fail", + summary: "A tool's task support does not match server capability.", + why: "Codex may attempt task-aware tool calls that the server did not advertise as supported.", + fix: "Advertise capabilities.tasks.requests.tools.call as an object, or set execution.taskSupport to forbidden.", + example: '{ "capabilities": { "tasks": { "requests": { "tools": { "call": {} } } } }, "execution": { "taskSupport": "required" } }' + }, + { + id: "mcp.conformance.tasks_list.timeout", + category: "mcp", + defaultSeverity: "fail", + summary: "The server did not complete tasks/list in time.", + why: "Codex cannot rely on task discovery when tasks/list cannot complete with a valid response.", + fix: "Reduce tasks/list latency and verify pagination completes.", + example: "Return a valid tasks/list response before the configured runtime timeout." + }, + { + id: "mcp.conformance.tasks_list.invalid", + category: "mcp", + defaultSeverity: "fail", + summary: "The server returned an invalid tasks/list response.", + why: "Codex cannot rely on task discovery when tasks/list cannot complete with a valid response.", + fix: "Return a valid tasks/list response with well-formed task items and pagination.", + example: '{ "tasks": [] }' + }, + { + id: "mcp.conformance.schema.dialect_invalid", + category: "mcp", + defaultSeverity: "fail", + summary: "A tool schema declares an invalid dialect.", + why: "Codex cannot reliably select a schema dialect for this tool.", + fix: "Omit $schema or provide an absolute URI in the affected tool schema.", + example: '{ "inputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema" } }' + }, + { + id: "mcp.conformance.schema.dialect_unsupported", + category: "mcp", + defaultSeverity: "warn", + summary: "A tool schema uses a non-canonical dialect.", + why: "The schema may be valid, but its dialect is outside the validator's latest compatibility baseline.", + fix: "Use https://json-schema.org/draft/2020-12/schema or omit $schema.", + example: '{ "inputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema" } }' + }, { id: "plugin.runtime.exited_early", category: "runtime", diff --git a/tests/rule-catalog.test.ts b/tests/rule-catalog.test.ts new file mode 100644 index 0000000..88a849f --- /dev/null +++ b/tests/rule-catalog.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; + +import { findRuleDefinition, ruleCatalog } from "../src/rules/rule-catalog.js"; + +const mcpConformanceRules = [ + { + id: "mcp.conformance.protocol.unknown_newer", + category: "mcp", + defaultSeverity: "warn", + summary: "The server advertises a newer MCP protocol version.", + why: "The validator applies the 2025-11-25 structural baseline, which may not cover newer protocol requirements.", + fix: "Confirm the server's newer protocol changes remain compatible with the 2025-11-25 MCP contract.", + example: '{ "protocolVersion": "2026-01-01" }' + }, + { + id: "mcp.conformance.tasks.capability_invalid", + category: "mcp", + defaultSeverity: "fail", + summary: "A Tasks capability declaration is malformed.", + why: "Codex cannot safely determine whether the server supports task-aware tool calls.", + fix: "Return an object for the affected Tasks capability field, or omit the capability until it is implemented.", + example: '{ "capabilities": { "tasks": { "requests": { "tools": { "call": {} } } } } }' + }, + { + id: "mcp.conformance.tasks.task_support_invalid", + category: "mcp", + defaultSeverity: "fail", + summary: "A tool declares invalid task support.", + why: "Codex cannot safely interpret the tool's task support contract.", + fix: "Set execution.taskSupport to required, optional, or forbidden.", + example: '{ "execution": { "taskSupport": "optional" } }' + }, + { + id: "mcp.conformance.tasks.capability_mismatch", + category: "mcp", + defaultSeverity: "fail", + summary: "A tool's task support does not match server capability.", + why: "Codex may attempt task-aware tool calls that the server did not advertise as supported.", + fix: "Advertise capabilities.tasks.requests.tools.call as an object, or set execution.taskSupport to forbidden.", + example: '{ "capabilities": { "tasks": { "requests": { "tools": { "call": {} } } } }, "execution": { "taskSupport": "required" } }' + }, + { + id: "mcp.conformance.tasks_list.timeout", + category: "mcp", + defaultSeverity: "fail", + summary: "The server did not complete tasks/list in time.", + why: "Codex cannot rely on task discovery when tasks/list cannot complete with a valid response.", + fix: "Reduce tasks/list latency and verify pagination completes.", + example: "Return a valid tasks/list response before the configured runtime timeout." + }, + { + id: "mcp.conformance.tasks_list.invalid", + category: "mcp", + defaultSeverity: "fail", + summary: "The server returned an invalid tasks/list response.", + why: "Codex cannot rely on task discovery when tasks/list cannot complete with a valid response.", + fix: "Return a valid tasks/list response with well-formed task items and pagination.", + example: '{ "tasks": [] }' + }, + { + id: "mcp.conformance.schema.dialect_invalid", + category: "mcp", + defaultSeverity: "fail", + summary: "A tool schema declares an invalid dialect.", + why: "Codex cannot reliably select a schema dialect for this tool.", + fix: "Omit $schema or provide an absolute URI in the affected tool schema.", + example: '{ "inputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema" } }' + }, + { + id: "mcp.conformance.schema.dialect_unsupported", + category: "mcp", + defaultSeverity: "warn", + summary: "A tool schema uses a non-canonical dialect.", + why: "The schema may be valid, but its dialect is outside the validator's latest compatibility baseline.", + fix: "Use https://json-schema.org/draft/2020-12/schema or omit $schema.", + example: '{ "inputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema" } }' + } +] as const; + +describe("MCP 2025-11 conformance rule catalog", () => { + it("resolves every evaluator finding with its public remediation contract", () => { + expect(ruleCatalog.filter((rule) => rule.id.startsWith("mcp.conformance."))).toEqual( + mcpConformanceRules + ); + + for (const expectedRule of mcpConformanceRules) { + expect(findRuleDefinition(expectedRule.id)).toEqual(expectedRule); + } + }); +}); diff --git a/tests/runtime-plan-command.test.ts b/tests/runtime-plan-command.test.ts index 05f54a6..c248125 100644 --- a/tests/runtime-plan-command.test.ts +++ b/tests/runtime-plan-command.test.ts @@ -49,11 +49,23 @@ describe("doctor runtime-plan command", () => { probeMethods: expect.arrayContaining([ "initialize", "tools/list", + "tasks/list:declared-2025-11-only", "tools/call:safe-only" ]) }) ]) ); + expect(output.servers[0].probeMethods).toEqual([ + "initialize", + "tools/list", + "tasks/list:declared-2025-11-only", + "tools/call:safe-only", + "resources/list", + "resources/read:first-resource-only", + "resources/templates/list", + "prompts/list", + "prompts/get:first-prompt-only" + ]); }); it("keeps the approval digest stable across repeated runs", async () => { @@ -173,8 +185,11 @@ describe("doctor runtime-plan command", () => { expect(stdout.join("")).toContain("# Doctor Runtime Review Plan"); expect(stdout.join("")).toContain("## Review Checklist"); expect(stdout.join("")).toContain("Approval digest: `sha256:"); + expect(stdout.join("")).toContain("- This plan is non-executing."); + expect(stdout.join("")).toContain("- Probe methods explicitly exclude task create, get, result, and cancel operations, plus sampling and elicitation requests."); expect(writtenPlan).toContain("| Risk | Name | Transport | Command or URL | Cwd |"); expect(writtenPlan).toContain("doctorRuntime"); + expect(writtenPlan).toContain("- tasks/list:declared-2025-11-only"); }); it("rejects conflicting runtime plan output formats", async () => { From 51638439117fc05a2fdcc78f8d6e96dee812cb23 Mon Sep 17 00:00:00 2001 From: Furkan Date: Fri, 24 Jul 2026 11:37:26 +0300 Subject: [PATCH 18/20] docs: align MCP runtime probe order --- docs/architecture/mcp-2025-11-conformance.md | 13 +++++++------ src/core/runtime-plan.ts | 2 +- tests/runtime-plan-command.test.ts | 2 +- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/architecture/mcp-2025-11-conformance.md b/docs/architecture/mcp-2025-11-conformance.md index dc51900..94dbf57 100644 --- a/docs/architecture/mcp-2025-11-conformance.md +++ b/docs/architecture/mcp-2025-11-conformance.md @@ -172,13 +172,14 @@ Findings caused by malformed data or declared-but-broken behavior are failures. 1. Start the configured MCP server using the existing approved runtime path. 2. Send `initialize` and validate the response using existing rules. 3. Select a conformance profile from the negotiated protocol version. -4. Run existing capability-directed tools, resources, and prompts probes. -5. Collect tool declarations without changing tool-call behavior. -6. If safely declared, send one bounded `tasks/list` request. +4. If tools are declared, send `tools/list` and validate the returned tool declarations. +5. If a safely callable tool is available, send one bounded, non-destructive `tools/call` request. +6. If safely declared for the negotiated profile, send one bounded `tasks/list` request. 7. Immediately reduce the task response to shape status, count, and pagination status; discard payload records. -8. Evaluate all normalized observations in the conformance module. -9. Merge findings and conformance scorecard fields into the existing runtime result. -10. Render additive text, Markdown, JSON, and contract output. +8. Evaluate the normalized conformance observations. +9. Run the remaining capability-directed resource and prompt probes. +10. Merge findings and conformance scorecard fields into the existing runtime result. +11. Render additive text, Markdown, JSON, and contract output. ## Security And Privacy diff --git a/src/core/runtime-plan.ts b/src/core/runtime-plan.ts index a2740fe..d964a28 100644 --- a/src/core/runtime-plan.ts +++ b/src/core/runtime-plan.ts @@ -217,8 +217,8 @@ export async function buildDoctorRuntimePlan( ? [ "initialize", "tools/list", - "tasks/list:declared-2025-11-only", "tools/call:safe-only", + "tasks/list:declared-2025-11-only", "resources/list", "resources/read:first-resource-only", "resources/templates/list", diff --git a/tests/runtime-plan-command.test.ts b/tests/runtime-plan-command.test.ts index c248125..81f29aa 100644 --- a/tests/runtime-plan-command.test.ts +++ b/tests/runtime-plan-command.test.ts @@ -58,8 +58,8 @@ describe("doctor runtime-plan command", () => { expect(output.servers[0].probeMethods).toEqual([ "initialize", "tools/list", - "tasks/list:declared-2025-11-only", "tools/call:safe-only", + "tasks/list:declared-2025-11-only", "resources/list", "resources/read:first-resource-only", "resources/templates/list", From 9230df5ea287aa71dc0af3c05aac186efe7d00fc Mon Sep 17 00:00:00 2001 From: Furkan Date: Fri, 24 Jul 2026 11:58:16 +0300 Subject: [PATCH 19/20] fix: harden MCP runtime privacy boundaries --- src/core/runtime-probe.ts | 69 ++++++++++--- src/core/runtime-transcript.ts | 4 +- tests/runtime-protocol.test.ts | 166 ++++++++++++++++++++++++++++++- tests/runtime-transcript.test.ts | 18 ++++ 4 files changed, 240 insertions(+), 17 deletions(-) diff --git a/src/core/runtime-probe.ts b/src/core/runtime-probe.ts index 25d6746..c76639a 100644 --- a/src/core/runtime-probe.ts +++ b/src/core/runtime-probe.ts @@ -839,6 +839,29 @@ function hasValidToolInputSchemas(tools: ToolDefinition[]): boolean { ); } +async function resolveRuntimeCwd( + rootPath: string, + configuredCwd: unknown +): Promise { + const resolvedCwd = + typeof configuredCwd === "string" + ? path.resolve(rootPath, configuredCwd) + : rootPath; + + try { + const [canonicalCwd, details] = await Promise.all([ + realpath(resolvedCwd), + stat(resolvedCwd) + ]); + + return details.isDirectory() && isPathWithinRoot(rootPath, canonicalCwd) + ? canonicalCwd + : null; + } catch { + return null; + } +} + function isProtocolVersion(value: string): boolean { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); @@ -1976,21 +1999,37 @@ export async function probeRuntimeConfig( const args = Array.isArray(config.args) ? config.args.filter((value): value is string => typeof value === "string") : []; - const cwd = - typeof config.cwd === "string" - ? path.resolve(canonicalRootPath, config.cwd) - : canonicalRootPath; - - const result = await probeCommandServer({ - serverName, - packageRoot: canonicalRootPath, - command, - args, - cwd, - startupTimeoutMs, - sandbox: options.sandbox, - transcript: options.transcript - }); + const cwd = await resolveRuntimeCwd(canonicalRootPath, config.cwd); + const result: RuntimeProbeResult = cwd === null + ? (() => { + const invalidCwdScorecard = createRuntimeScorecard(); + invalidCwdScorecard.initialize = "fail"; + + return { + findings: [ + withRuntimeEvidence( + buildFailure( + "plugin.runtime.startup.invalid_cwd", + `The MCP server \`${serverName}\` has an invalid runtime working directory.`, + "Runtime validation must not start a server from a missing, non-directory, or out-of-package working directory.", + "Set the MCP server cwd to an existing directory inside the plugin package root, or remove it." + ), + serverName + ) + ], + scorecard: invalidCwdScorecard + }; + })() + : await probeCommandServer({ + serverName, + packageRoot: canonicalRootPath, + command, + args, + cwd, + startupTimeoutMs, + sandbox: options.sandbox, + transcript: options.transcript + }); scorecard = hasProbedServer ? mergeRuntimeScorecards(scorecard, result.scorecard) diff --git a/src/core/runtime-transcript.ts b/src/core/runtime-transcript.ts index fa95d42..ed4c2d6 100644 --- a/src/core/runtime-transcript.ts +++ b/src/core/runtime-transcript.ts @@ -126,7 +126,9 @@ export function formatResponseTranscript( if (error) { const code = typeof error.code === "number" ? error.code : "?"; const messageText = - typeof error.message === "string" + method === "tasks/list" + ? "[REDACTED]" + : typeof error.message === "string" ? sanitizeTranscriptValue(error.message, ["error", "message"]) : "error"; diff --git a/tests/runtime-protocol.test.ts b/tests/runtime-protocol.test.ts index 2a50b8e..d29f8b1 100644 --- a/tests/runtime-protocol.test.ts +++ b/tests/runtime-protocol.test.ts @@ -1,11 +1,72 @@ -import { access, cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { access, cp, mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { probeRuntime } from "../src/core/runtime-probe.js"; +import type { DiscoveredPackage } from "../src/domain/types.js"; import { validatePlugin } from "../src/core/validate-plugin.js"; import { runCheck } from "../src/index.js"; +function markerServerSource(markerPath: string): string { + return `import fs from "node:fs"; +import readline from "node:readline"; +fs.writeFileSync(${JSON.stringify(markerPath)}, "executed"); +const input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); +input.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") { + process.stdout.write(JSON.stringify({ + jsonrpc: "2.0", + id: message.id, + result: { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "runtime-cwd", version: "1.0.0" } + } + }) + "\\n"); + } +}); +`; +} + +async function createRuntimeCwdPackage( + rootPath: string, + cwd: string +): Promise { + const manifestPath = path.join(rootPath, ".codex-plugin", "plugin.json"); + + await mkdir(path.dirname(manifestPath), { recursive: true }); + await writeFile( + manifestPath, + JSON.stringify({ + name: "runtime-cwd", + version: "1.0.0", + description: "Runtime cwd validation fixture.", + mcpServers: "./.mcp.json" + }) + ); + await writeFile( + path.join(rootPath, ".mcp.json"), + JSON.stringify({ + mcpServers: { + cwdServer: { command: "node", args: ["server.mjs"], cwd } + } + }) + ); + + return { + rootPath, + manifestPath, + manifest: { + name: "runtime-cwd", + version: "1.0.0", + description: "Runtime cwd validation fixture.", + mcpServers: "./.mcp.json" + } + }; +} + describe("runtime protocol probing", () => { it("does not start runtime probes when static validation fails", async () => { const packageRoot = await mkdtemp( @@ -50,6 +111,109 @@ describe("runtime protocol probing", () => { } }); + it.each(["missing-cwd", "not-a-directory"])( + "rejects a %s runtime cwd before spawn", + async (cwd) => { + const packageRoot = await mkdtemp( + path.join(os.tmpdir(), "codex-plugin-doctor-runtime-cwd-invalid-") + ); + const markerPath = path.join(packageRoot, "runtime-started"); + + try { + if (cwd === "not-a-directory") { + await writeFile(path.join(packageRoot, cwd), "not a directory"); + } + + const result = await probeRuntime( + await createRuntimeCwdPackage(packageRoot, cwd) + ); + + expect(result.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "plugin.runtime.startup.invalid_cwd", + severity: "fail", + evidence: { serverName: "cwdServer", method: "startup" } + }) + ]) + ); + expect(result.scorecard.initialize).toBe("fail"); + expect(result.execution).toBeUndefined(); + await expect(access(markerPath)).rejects.toThrow(); + } finally { + await rm(packageRoot, { recursive: true, force: true }); + } + } + ); + + it.skipIf(process.platform === "win32")( + "rejects an in-root cwd symlink that resolves outside the package before spawn", + async () => { + const packageRoot = await mkdtemp( + path.join(os.tmpdir(), "codex-plugin-doctor-runtime-cwd-root-") + ); + const externalPath = await mkdtemp( + path.join(os.tmpdir(), "codex-plugin-doctor-runtime-cwd-external-") + ); + const markerPath = path.join(externalPath, "runtime-started"); + + try { + await writeFile( + path.join(externalPath, "server.mjs"), + markerServerSource(markerPath) + ); + await symlink(externalPath, path.join(packageRoot, "linked-cwd"), "dir"); + + const result = await probeRuntime( + await createRuntimeCwdPackage(packageRoot, "linked-cwd") + ); + + expect(result.findings.map((finding) => finding.id)).toContain( + "plugin.runtime.startup.invalid_cwd" + ); + expect(result.execution).toBeUndefined(); + await expect(access(markerPath)).rejects.toThrow(); + } finally { + await rm(packageRoot, { recursive: true, force: true }); + await rm(externalPath, { recursive: true, force: true }); + } + } + ); + + it("starts an ordinary in-root cwd", async () => { + const packageRoot = await mkdtemp( + path.join(os.tmpdir(), "codex-plugin-doctor-runtime-cwd-in-root-") + ); + const serverPath = path.join(packageRoot, "server"); + const markerPath = path.join(serverPath, "runtime-started"); + + try { + await mkdir(serverPath); + await writeFile( + path.join(serverPath, "server.mjs"), + markerServerSource(markerPath) + ); + + const result = await probeRuntime( + await createRuntimeCwdPackage(packageRoot, "server"), + { startupTimeoutMs: 2_000 } + ); + + expect(result.scorecard.initialize).toBe("pass"); + expect(result.findings.map((finding) => finding.id)).not.toContain( + "plugin.runtime.startup.invalid_cwd" + ); + expect(await access(markerPath)).toBeUndefined(); + } finally { + await rm(packageRoot, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 100 + }); + } + }); + it("passes when the stdio server completes initialize and supports tools, resources, prompts, read, and get probing", async () => { const result = await runCheck(path.resolve("tests/fixtures/runtime-valid"), { runtime: true diff --git a/tests/runtime-transcript.test.ts b/tests/runtime-transcript.test.ts index 8306bd4..a484475 100644 --- a/tests/runtime-transcript.test.ts +++ b/tests/runtime-transcript.test.ts @@ -60,4 +60,22 @@ describe("runtime transcript sanitization", () => { expect(transcript).not.toContain("private-task"); expect(transcript).not.toContain("working"); }); + + it("redacts all tasks/list error message content", () => { + const transcript = formatResponseTranscript("tasks/list", { + jsonrpc: "2.0", + error: { + code: -32000, + message: + "task private-task-id failed for private task text at cursor private-task-cursor" + } + }); + + expect(transcript).toBe( + '<- tasks/list error {"code":-32000,"message":"[REDACTED]"}' + ); + expect(transcript).not.toContain("private-task-id"); + expect(transcript).not.toContain("private task text"); + expect(transcript).not.toContain("private-task-cursor"); + }); }); From 617c13717e13f038ab40f29f85b1cecdc6797aa1 Mon Sep 17 00:00:00 2001 From: Furkan Date: Fri, 24 Jul 2026 12:08:34 +0300 Subject: [PATCH 20/20] chore: prepare v1.51.0 release --- CHANGELOG.md | 19 +++++++++ README.md | 4 +- docs/architecture/mcp-2025-11-conformance.md | 2 +- docs/guides/github-action.md | 44 ++++++++++---------- package-lock.json | 4 +- package.json | 2 +- 6 files changed, 47 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c545e00..2442ff8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ All notable changes to `codex-plugin-doctor` are documented here. This changelog groups the shipped work into product-level release blocks instead of repeating every low-level git diff in isolation. +## [1.51.0] - 2026-07-24 + +### Added + +- added version-aware MCP conformance profiles for legacy, `2025-11-25`, and future-compatible protocol negotiation +- added deterministic capability, task support, and schema dialect checks with stable `mcp.conformance.*` rule identifiers +- added an explicit `mcp --runtime` opt-in with safe, bounded, read-only `tasks/list` probing + +### Changed + +- extended runtime scorecards, text, Markdown, JSON, rule catalog, and output contracts with additive conformance results +- aligned runtime approval plans with the actual version-gated probe order + +### Security + +- redact task records, identifiers, cursors, statuses, and runtime error text from retained findings and reports +- enforce canonical package-root containment for runtime working directories, including symlink escape protection +- reject malformed runtime flags and bound task pagination to 100 pages with cursor-cycle detection + ## [1.50.0] - 2026-07-21 ### Added diff --git a/README.md b/README.md index ae737eb..14e1d48 100644 --- a/README.md +++ b/README.md @@ -427,9 +427,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - - uses: Esquetta/CodexPluginDoctor@v1.50.0 + - uses: Esquetta/CodexPluginDoctor@v1.51.0 with: - version: "1.50.0" + version: "1.51.0" path: . runtime: "true" policy: codex-publish diff --git a/docs/architecture/mcp-2025-11-conformance.md b/docs/architecture/mcp-2025-11-conformance.md index 94dbf57..0f5c264 100644 --- a/docs/architecture/mcp-2025-11-conformance.md +++ b/docs/architecture/mcp-2025-11-conformance.md @@ -2,7 +2,7 @@ ## Status -Implementation complete; targeted for `v1.51.0`. +Implemented in `v1.51.0`. ## Purpose diff --git a/docs/guides/github-action.md b/docs/guides/github-action.md index dc2a9c1..db5104f 100644 --- a/docs/guides/github-action.md +++ b/docs/guides/github-action.md @@ -22,9 +22,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - - uses: Esquetta/CodexPluginDoctor@v1.50.0 + - uses: Esquetta/CodexPluginDoctor@v1.51.0 with: - version: "1.50.0" + version: "1.51.0" path: . runtime: "true" policy: codex-publish @@ -51,9 +51,9 @@ Every action run also writes `codex-plugin-doctor-action-manifest.json`. The man Use SARIF when repository security tooling should ingest validation findings. ```yaml -- uses: Esquetta/CodexPluginDoctor@v1.50.0 +- uses: Esquetta/CodexPluginDoctor@v1.51.0 with: - version: "1.50.0" + version: "1.51.0" path: . sarif: "true" ``` @@ -65,9 +65,9 @@ The action writes `codex-plugin-doctor.sarif` into `output-dir`. Uploading it to Use artifact and summary controls when the workflow needs custom retention or wants to disable generated report uploads. ```yaml -- uses: Esquetta/CodexPluginDoctor@v1.50.0 +- uses: Esquetta/CodexPluginDoctor@v1.51.0 with: - version: "1.50.0" + version: "1.51.0" path: . output-dir: doctor-ci-reports artifact-name: codex-plugin-doctor-reports @@ -102,11 +102,11 @@ The action also exposes these workflow outputs for follow-up steps: Use review bundle artifacts when a pull request or release workflow should preserve signed runtime approval, runtime policy, attestation, and release evidence handoff files. ```yaml -- uses: Esquetta/CodexPluginDoctor@v1.50.0 +- uses: Esquetta/CodexPluginDoctor@v1.51.0 env: CODEX_PLUGIN_DOCTOR_SIGNING_KEY: ${{ secrets.CODEX_PLUGIN_DOCTOR_SIGNING_KEY }} with: - version: "1.50.0" + version: "1.51.0" path: . review-bundle: "true" review-bundle-verify: "true" @@ -137,9 +137,9 @@ The CLI can produce badge output for release notes, README automation, or a stat Use a private corpus metrics manifest to measure reviewed precision, recall, and false-positive share in CI. The action writes only the public-safe metrics report into its artifact directory; snapshots, manifest contents, local paths, and review notes are not copied. ```yaml -- uses: Esquetta/CodexPluginDoctor@v1.50.0 +- uses: Esquetta/CodexPluginDoctor@v1.51.0 with: - version: "1.50.0" + version: "1.51.0" path: . corpus-metrics-manifest: ../private-corpus/metrics.json ``` @@ -147,9 +147,9 @@ Use a private corpus metrics manifest to measure reviewed precision, recall, and This writes `corpus-metrics.json`. To compare the result with a retained report and fail the job on regression: ```yaml -- uses: Esquetta/CodexPluginDoctor@v1.50.0 +- uses: Esquetta/CodexPluginDoctor@v1.51.0 with: - version: "1.50.0" + version: "1.51.0" path: . corpus-metrics-manifest: ../private-corpus/metrics.json corpus-metrics-baseline: .doctor-baselines/corpus-metrics.json @@ -178,9 +178,9 @@ The history file is newline-delimited JSON. Store it as an artifact, cache, or r The composite action can also append history directly: ```yaml -- uses: Esquetta/CodexPluginDoctor@v1.50.0 +- uses: Esquetta/CodexPluginDoctor@v1.51.0 with: - version: "1.50.0" + version: "1.51.0" path: . runtime: "true" history: validation-history.jsonl @@ -200,9 +200,9 @@ Use profiles when a consuming workflow needs a named validation policy instead o The composite action can pass profiles directly: ```yaml -- uses: Esquetta/CodexPluginDoctor@v1.50.0 +- uses: Esquetta/CodexPluginDoctor@v1.51.0 with: - version: "1.50.0" + version: "1.51.0" path: . profile: publish ``` @@ -212,9 +212,9 @@ The composite action can pass profiles directly: Use policy presets when a workflow should apply one of the opinionated release gates without adding a local `.codex-doctor.json`. ```yaml -- uses: Esquetta/CodexPluginDoctor@v1.50.0 +- uses: Esquetta/CodexPluginDoctor@v1.51.0 with: - version: "1.50.0" + version: "1.51.0" path: . policy: codex-publish ``` @@ -226,9 +226,9 @@ Supported policy values are `codex-publish`, `mcp-strict`, and `security`. The C Use installed-cache mode only in environments where Codex plugins are already available on the runner. ```yaml -- uses: Esquetta/CodexPluginDoctor@v1.50.0 +- uses: Esquetta/CodexPluginDoctor@v1.51.0 with: - version: "1.50.0" + version: "1.51.0" installed: "true" filter: github runtime: "false" @@ -239,9 +239,9 @@ Use installed-cache mode only in environments where Codex plugins are already av Pin both the action ref and npm package version for reproducible CI: ```yaml -- uses: Esquetta/CodexPluginDoctor@v1.50.0 +- uses: Esquetta/CodexPluginDoctor@v1.51.0 with: - version: "1.50.0" + version: "1.51.0" ``` Use `version: "latest"` only when the consuming repository intentionally wants automatic CLI upgrades. diff --git a/package-lock.json b/package-lock.json index 2ff4e04..bd849af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-plugin-doctor", - "version": "1.50.0", + "version": "1.51.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-plugin-doctor", - "version": "1.50.0", + "version": "1.51.0", "license": "MIT", "bin": { "codex-plugin-doctor": "dist/cli.js" diff --git a/package.json b/package.json index 1f1bd17..f9d0cd6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-plugin-doctor", - "version": "1.50.0", + "version": "1.51.0", "description": "CLI-first validator for Codex plugins, skills, and MCP package surfaces with runtime MCP protocol validation.", "type": "module", "main": "./dist/index.js",