codex-plugin-doctor 1.52.0 → 1.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -78,21 +78,25 @@ Runtime MCP validation with `--runtime`:
78
78
  - paginated list responses
79
79
  - runtime capability scorecard
80
80
  - redacted verbose transcript with `--verbose-runtime`
81
- - optional runtime approval gating with a precomputed `doctor runtime-plan` digest
82
- - optional Docker isolation for local Node.js stdio servers with `--sandbox docker`
83
-
84
- ### Remote MCP Readiness
85
-
86
- Remote MCP runtime probing is disabled until you explicitly pass `--allow-network`. Local endpoints also require `--allow-local-network`:
87
-
88
- ```bash
89
- codex-plugin-doctor check ./remote-mcp --runtime --allow-network
90
- codex-plugin-doctor check ./remote-mcp --runtime --allow-network --allow-local-network
91
- ```
81
+ - optional runtime approval gating with a precomputed `doctor runtime-plan` digest
82
+ - optional Docker isolation for local Node.js stdio servers with `--sandbox docker`
83
+
84
+ ### Remote MCP Readiness
85
+
86
+ Remote MCP runtime probing is disabled until you explicitly pass `--runtime --allow-network`. Loopback endpoints also require `--allow-local-network`:
87
+
88
+ ```bash
89
+ codex-plugin-doctor check ./remote-mcp --runtime --allow-network
90
+ codex-plugin-doctor check ./remote-mcp --runtime --allow-network --allow-local-network
91
+ ```
92
+
93
+ The probe makes only bounded protocol and OAuth metadata-discovery requests, redacts report output, and applies SSRF controls. It does not authenticate or follow redirects.
92
94
 
93
- The probe makes only bounded, read-only protocol and OAuth metadata-discovery requests, redacts report output, and applies SSRF controls. It does not authenticate or follow redirects. See [Remote MCP Readiness](./docs/architecture/remote-mcp-readiness.md).
95
+ Remote transport reliability adds one bounded SSE GET after initialization. HTTP `200` must be `text/event-stream`; HTTP `405` is compliant when the endpoint does not offer server-to-client SSE. At most one SSE resume and one session restart are attempted, and no remote response content is retained. This is a bounded readiness check, not a live interoperability, delivery-guarantee, or load-test claim.
94
96
 
95
- Output formats:
97
+ `--allow-session-lifecycle` is disabled by default and is state-changing: only after a valid `MCP-Session-Id` it permits one bounded session `DELETE`. `--require-remote-reliability` is a strict result gate: it fails unless every attempted remote reliability scorecard passes. Local-only runs are unaffected. It grants no network consent, so `--runtime --allow-network` (and loopback consent when applicable) remain required. See [Remote MCP Readiness](./docs/architecture/remote-mcp-readiness.md) and [Remote MCP Transport Reliability](./docs/architecture/remote-mcp-transport-reliability.md).
98
+
99
+ Output formats:
96
100
 
97
101
  - human text output
98
102
  - JSON reports
@@ -438,9 +442,9 @@ jobs:
438
442
  runs-on: ubuntu-latest
439
443
  steps:
440
444
  - uses: actions/checkout@v5
441
- - uses: Esquetta/CodexPluginDoctor@v1.52.0
445
+ - uses: Esquetta/CodexPluginDoctor@v1.53.0
442
446
  with:
443
- version: "1.52.0"
447
+ version: "1.53.0"
444
448
  path: .
445
449
  runtime: "true"
446
450
  policy: codex-publish
@@ -7,6 +7,7 @@ const DEFAULT_MAX_RESPONSE_BYTES = 1_024 * 1_024;
7
7
  const allowedRequestHeaders = new Set([
8
8
  "accept",
9
9
  "content-type",
10
+ "last-event-id",
10
11
  "mcp-protocol-version",
11
12
  "mcp-session-id",
12
13
  "user-agent"
@@ -137,7 +138,7 @@ export async function requestBoundedHttp(rawUrl, options = {}) {
137
138
  let response;
138
139
  let settled = false;
139
140
  const timeout = setTimeout(() => {
140
- fail(new BoundedHttpError("REMOTE_HTTP_TIMEOUT", "Remote HTTP request timed out."));
141
+ fail(new BoundedHttpError("REMOTE_HTTP_TIMEOUT", "Remote HTTP request timed out.", response?.statusCode, response ? selectSafeHeaders(response.headers) : undefined));
141
142
  }, remainingMs);
142
143
  const fail = (error) => {
143
144
  if (settled) {
@@ -16,7 +16,9 @@ export function redactValue(value) {
16
16
  if (typeof value === "object" && value !== null) {
17
17
  return Object.fromEntries(Object.entries(value).map(([key, nestedValue]) => [
18
18
  key,
19
- redactValue(nestedValue)
19
+ /^(?:mcp[-_]?session[-_]?id|session[-_]?id|last[-_]?event[-_]?id|event[-_]?id|retry|(?:sse[-_]?)?data)$/i.test(key)
20
+ ? "[REDACTED_SECRET]"
21
+ : redactValue(nestedValue)
20
22
  ]));
21
23
  }
22
24
  return value;
@@ -5,6 +5,10 @@ const runtimeCapabilityStatusSchema = {
5
5
  type: "string",
6
6
  enum: ["pass", "fail", "warn", "skipped", "unsupported"]
7
7
  };
8
+ const remoteTransportReliabilityStatusSchema = {
9
+ type: "string",
10
+ enum: ["pass", "warn", "fail", "skipped"]
11
+ };
8
12
  const runtimeConformanceSchema = {
9
13
  type: "object",
10
14
  properties: {
@@ -35,6 +39,28 @@ const runtimeConformanceSchema = {
35
39
  ],
36
40
  additionalProperties: false
37
41
  };
42
+ const remoteTransportReliabilitySchema = {
43
+ type: "object",
44
+ properties: {
45
+ getSse: remoteTransportReliabilityStatusSchema,
46
+ sessionPropagation: remoteTransportReliabilityStatusSchema,
47
+ resumability: remoteTransportReliabilityStatusSchema,
48
+ disconnectSafety: remoteTransportReliabilityStatusSchema,
49
+ sessionRestart: remoteTransportReliabilityStatusSchema,
50
+ termination: remoteTransportReliabilityStatusSchema,
51
+ overall: remoteTransportReliabilityStatusSchema
52
+ },
53
+ required: [
54
+ "getSse",
55
+ "sessionPropagation",
56
+ "resumability",
57
+ "disconnectSafety",
58
+ "sessionRestart",
59
+ "termination",
60
+ "overall"
61
+ ],
62
+ additionalProperties: false
63
+ };
38
64
  const remoteRuntimeScorecardSchema = {
39
65
  type: "object",
40
66
  properties: {
@@ -48,6 +74,7 @@ const remoteRuntimeScorecardSchema = {
48
74
  },
49
75
  protocolHeaders: runtimeCapabilityStatusSchema,
50
76
  authorization: runtimeCapabilityStatusSchema,
77
+ reliability: remoteTransportReliabilitySchema,
51
78
  overall: {
52
79
  type: "string",
53
80
  enum: ["pass", "warn", "fail", "skipped"]
@@ -43,6 +43,8 @@ export interface BuildReleaseCheckOptions {
43
43
  runtime?: boolean;
44
44
  allowNetwork?: boolean;
45
45
  allowLocalNetwork?: boolean;
46
+ allowSessionLifecycle?: boolean;
47
+ requireRemoteReliability?: boolean;
46
48
  runtimeSandbox?: RuntimeSandboxMode;
47
49
  runCheck?: (targetPath: string, options: CheckOptions) => Promise<CheckResult>;
48
50
  }
@@ -108,6 +108,8 @@ export async function buildReleaseCheck(targetPath, options = {}) {
108
108
  runtime: runtimeProbeEnabled,
109
109
  ...(options.allowNetwork ? { allowNetwork: true } : {}),
110
110
  ...(options.allowLocalNetwork ? { allowLocalNetwork: true } : {}),
111
+ ...(options.allowSessionLifecycle ? { allowSessionLifecycle: true } : {}),
112
+ ...(options.requireRemoteReliability ? { requireRemoteReliability: true } : {}),
111
113
  ...(options.runtimeSandbox ? { runtimeSandbox: options.runtimeSandbox } : {})
112
114
  });
113
115
  const securityResult = await buildSecurityAudit(resolvedPath);
@@ -113,6 +113,8 @@ export interface BuildDoctorReleaseEvidenceOptions {
113
113
  runtime?: boolean;
114
114
  allowNetwork?: boolean;
115
115
  allowLocalNetwork?: boolean;
116
+ allowSessionLifecycle?: boolean;
117
+ requireRemoteReliability?: boolean;
116
118
  sandbox?: RuntimeSandboxMode;
117
119
  environment?: CompatibilityEnvironment;
118
120
  runCheck?: (targetPath: string, options?: CheckOptions) => Promise<CheckResult>;
@@ -308,6 +308,8 @@ export async function buildDoctorReleaseEvidenceReport(targetPath, options) {
308
308
  runtime: true,
309
309
  ...(options.allowNetwork ? { allowNetwork: true } : {}),
310
310
  ...(options.allowLocalNetwork ? { allowLocalNetwork: true } : {}),
311
+ ...(options.allowSessionLifecycle ? { allowSessionLifecycle: true } : {}),
312
+ ...(options.requireRemoteReliability ? { requireRemoteReliability: true } : {}),
311
313
  ...(options.sandbox ? { runtimeSandbox: options.sandbox } : {})
312
314
  }
313
315
  : {};
@@ -8,6 +8,7 @@ export interface RemoteMcpProbeOptions {
8
8
  requestTimeoutMs?: number;
9
9
  lookup?: RemoteLookup;
10
10
  request?: RemoteMcpRequest;
11
+ allowSessionLifecycle?: boolean;
11
12
  }
12
13
  export interface RemoteMcpProbeResult {
13
14
  findings: Finding[];
@@ -1,6 +1,7 @@
1
1
  import { BoundedHttpError, requestBoundedHttp } from "./bounded-http-client.js";
2
2
  import { RemoteNetworkPolicyError } from "./remote-network-policy.js";
3
3
  import { checkRemoteOAuthReadiness } from "./remote-oauth-readiness.js";
4
+ import { probeRemoteTransportReliability } from "./remote-transport-reliability.js";
4
5
  import { inspectRemoteMcpUrl } from "./remote-url-policy.js";
5
6
  import { packageVersion } from "../version.js";
6
7
  const MCP_PROTOCOL_VERSION = "2025-11-25";
@@ -85,6 +86,9 @@ function isValidInitializeResponse(message) {
85
86
  typeof message.result.serverInfo.name === "string" &&
86
87
  typeof message.result.serverInfo.version === "string");
87
88
  }
89
+ function isInitializedNotificationAcknowledged(response) {
90
+ return response.statusCode === 202 && response.body.length === 0;
91
+ }
88
92
  function validSessionId(value) {
89
93
  return value !== null && /^[\x21-\x7e]+$/.test(value);
90
94
  }
@@ -210,7 +214,7 @@ export async function probeRemoteMcpServer(serverName, rawUrl, options = {}) {
210
214
  ...(sessionId === null ? {} : { "MCP-Session-Id": sessionId })
211
215
  }
212
216
  });
213
- if (initializedResponse.statusCode < 200 || initializedResponse.statusCode >= 300) {
217
+ if (!isInitializedNotificationAcknowledged(initializedResponse)) {
214
218
  scorecard.protocolHeaders = "fail";
215
219
  findings.push(failure("plugin.runtime.remote.initialized.failed", `The remote MCP server \`${serverName}\` did not acknowledge the initialized notification.`, "The MCP session may not be ready for subsequent protocol traffic.", "Accept a successful HTTP response to notifications/initialized at the same MCP endpoint."));
216
220
  return finalize(scorecard, findings);
@@ -222,5 +226,59 @@ export async function probeRemoteMcpServer(serverName, rawUrl, options = {}) {
222
226
  return finalize(scorecard, findings);
223
227
  }
224
228
  scorecard.protocolHeaders = "pass";
229
+ const reliability = await probeRemoteTransportReliability({
230
+ rawUrl,
231
+ protocolVersion: MCP_PROTOCOL_VERSION,
232
+ request,
233
+ requestTimeoutMs: options.requestTimeoutMs,
234
+ requestOptions: {
235
+ allowLocalNetwork: options.allowLocalNetwork,
236
+ lookup: options.lookup
237
+ },
238
+ sessionId,
239
+ allowSessionLifecycle: options.allowSessionLifecycle,
240
+ reinitialize: async (requestWithinBudget) => {
241
+ const restartInitialize = await requestWithinBudget({
242
+ method: "POST",
243
+ body: initializeBody,
244
+ headers: {
245
+ Accept: "application/json, text/event-stream",
246
+ "Content-Type": "application/json"
247
+ },
248
+ stopAfter: (body) => findSseInitializeResponse(body) !== null
249
+ });
250
+ if (restartInitialize.statusCode !== 200) {
251
+ throw new Error("restart initialize failed");
252
+ }
253
+ const restartContentType = mediaType(responseHeader(restartInitialize.headers, "content-type"));
254
+ if (restartContentType !== "application/json" && restartContentType !== "text/event-stream") {
255
+ throw new Error("restart initialize content type invalid");
256
+ }
257
+ const replacementSessionId = responseHeader(restartInitialize.headers, "mcp-session-id");
258
+ if (replacementSessionId !== null && !validSessionId(replacementSessionId)) {
259
+ throw new Error("restart session invalid");
260
+ }
261
+ const restartMessage = parseInitializeResponse(restartInitialize.body, restartContentType);
262
+ if (!restartMessage || !isValidInitializeResponse(restartMessage)) {
263
+ throw new Error("restart initialize result invalid");
264
+ }
265
+ const restartInitialized = await requestWithinBudget({
266
+ method: "POST",
267
+ body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
268
+ headers: {
269
+ Accept: "application/json, text/event-stream",
270
+ "Content-Type": "application/json",
271
+ "MCP-Protocol-Version": MCP_PROTOCOL_VERSION,
272
+ ...(replacementSessionId === null ? {} : { "MCP-Session-Id": replacementSessionId })
273
+ }
274
+ });
275
+ if (!isInitializedNotificationAcknowledged(restartInitialized)) {
276
+ throw new Error("restart initialized failed");
277
+ }
278
+ return replacementSessionId;
279
+ }
280
+ });
281
+ scorecard.reliability = reliability.scorecard;
282
+ findings.push(...reliability.findings);
225
283
  return finalize(scorecard, findings);
226
284
  }
@@ -0,0 +1,18 @@
1
+ import { type BoundedHttpRequestOptions, type BoundedHttpResponse } from "./bounded-http-client.js";
2
+ import type { Finding, RemoteTransportReliabilityScorecard } from "../domain/types.js";
3
+ export interface RemoteTransportReliabilityResult {
4
+ findings: Finding[];
5
+ scorecard: RemoteTransportReliabilityScorecard;
6
+ }
7
+ export type RemoteTransportReliabilityRequest = (options: BoundedHttpRequestOptions) => Promise<BoundedHttpResponse>;
8
+ export interface RemoteTransportReliabilityOptions {
9
+ rawUrl: string;
10
+ protocolVersion: string;
11
+ request: (rawUrl: string, options?: BoundedHttpRequestOptions) => Promise<BoundedHttpResponse>;
12
+ requestTimeoutMs?: number;
13
+ requestOptions?: Pick<BoundedHttpRequestOptions, "allowLocalNetwork" | "lookup">;
14
+ sessionId: string | null;
15
+ allowSessionLifecycle?: boolean;
16
+ reinitialize: (request: RemoteTransportReliabilityRequest) => Promise<string | null>;
17
+ }
18
+ export declare function probeRemoteTransportReliability(options: RemoteTransportReliabilityOptions): Promise<RemoteTransportReliabilityResult>;
@@ -0,0 +1,243 @@
1
+ import { BoundedHttpError } from "./bounded-http-client.js";
2
+ import { observeFirstSseEvent } from "./sse-observation.js";
3
+ const DEFAULT_RELIABILITY_TIMEOUT_MS = 3_000;
4
+ const MAX_RELIABILITY_REQUESTS = 6;
5
+ function createScorecard() {
6
+ return {
7
+ getSse: "skipped",
8
+ sessionPropagation: "skipped",
9
+ resumability: "skipped",
10
+ disconnectSafety: "skipped",
11
+ sessionRestart: "skipped",
12
+ termination: "skipped",
13
+ overall: "skipped"
14
+ };
15
+ }
16
+ function finding(id, severity, message, suggestedFix) {
17
+ return {
18
+ id,
19
+ severity,
20
+ message,
21
+ impact: "Remote MCP transport reliability could not be verified within the configured safety bounds.",
22
+ suggestedFix
23
+ };
24
+ }
25
+ function responseHeader(headers, name) {
26
+ const value = headers[name];
27
+ return typeof value === "string" ? value : null;
28
+ }
29
+ function mediaType(value) {
30
+ return value?.split(";", 1)[0]?.trim().toLowerCase() ?? null;
31
+ }
32
+ function validSessionId(value) {
33
+ return value !== null && /^[\x21-\x7e]+$/.test(value);
34
+ }
35
+ function wait(delayMs) {
36
+ return new Promise((resolve) => setTimeout(resolve, delayMs));
37
+ }
38
+ function finalize(scorecard, findings) {
39
+ const statuses = Object.entries(scorecard)
40
+ .filter(([name]) => name !== "overall")
41
+ .map(([, status]) => status);
42
+ scorecard.overall = statuses.includes("fail")
43
+ ? "fail"
44
+ : statuses.includes("warn")
45
+ ? "warn"
46
+ : statuses.includes("pass")
47
+ ? "pass"
48
+ : "skipped";
49
+ return { findings, scorecard };
50
+ }
51
+ function isSseTimeout(error) {
52
+ return error instanceof BoundedHttpError
53
+ && error.code === "REMOTE_HTTP_TIMEOUT"
54
+ && error.statusCode === 200
55
+ && mediaType(responseHeader(error.headers ?? {}, "content-type")) === "text/event-stream";
56
+ }
57
+ function classifySseResponse(response, failurePrefix, findings) {
58
+ if (response.statusCode !== 200) {
59
+ findings.push(finding(`plugin.runtime.remote.reliability.${failurePrefix}.status`, "fail", "The remote MCP endpoint returned an unsupported SSE transport status.", "Return HTTP 200 for an accepted SSE transport request."));
60
+ return null;
61
+ }
62
+ if (mediaType(responseHeader(response.headers, "content-type")) !== "text/event-stream") {
63
+ findings.push(finding(`plugin.runtime.remote.reliability.${failurePrefix}.content_type`, "fail", "The remote MCP endpoint returned a non-SSE media type for an SSE transport request.", "Return text/event-stream for accepted SSE transport requests."));
64
+ return null;
65
+ }
66
+ return observeFirstSseEvent(response.body);
67
+ }
68
+ export async function probeRemoteTransportReliability(options) {
69
+ const scorecard = createScorecard();
70
+ const findings = [];
71
+ const timeoutMs = options.requestTimeoutMs ?? DEFAULT_RELIABILITY_TIMEOUT_MS;
72
+ const deadline = Date.now() + Math.min(timeoutMs, DEFAULT_RELIABILITY_TIMEOUT_MS);
73
+ let currentSessionId = options.sessionId;
74
+ let requestCount = 0;
75
+ let restarted = false;
76
+ const requestWithinBudget = async (requestOptions) => {
77
+ if (requestCount >= MAX_RELIABILITY_REQUESTS) {
78
+ throw new Error("reliability request ceiling reached");
79
+ }
80
+ const remainingMs = deadline - Date.now();
81
+ if (remainingMs <= 0) {
82
+ throw new BoundedHttpError("REMOTE_HTTP_TIMEOUT", "Remote reliability deadline elapsed.");
83
+ }
84
+ requestCount += 1;
85
+ return options.request(options.rawUrl, {
86
+ ...requestOptions,
87
+ ...options.requestOptions,
88
+ timeoutMs: Math.max(1, Math.min(remainingMs, DEFAULT_RELIABILITY_TIMEOUT_MS))
89
+ });
90
+ };
91
+ const send = async (method, extraHeaders = {}, stopAfter) => {
92
+ let sessionRestarted = false;
93
+ for (let attempt = 0; attempt < 2; attempt += 1) {
94
+ const sessionId = currentSessionId;
95
+ const headers = {
96
+ Accept: "text/event-stream",
97
+ "MCP-Protocol-Version": options.protocolVersion,
98
+ ...(sessionId === null ? {} : { "MCP-Session-Id": sessionId }),
99
+ ...(sessionRestarted ? {} : extraHeaders)
100
+ };
101
+ if (sessionId !== null) {
102
+ scorecard.sessionPropagation = "pass";
103
+ }
104
+ let response;
105
+ try {
106
+ response = await requestWithinBudget({
107
+ method,
108
+ headers,
109
+ stopAfter
110
+ });
111
+ }
112
+ catch (error) {
113
+ return { error };
114
+ }
115
+ if (method === "GET" && sessionId !== null && response.statusCode === 404) {
116
+ if (restarted) {
117
+ scorecard.sessionRestart = "fail";
118
+ return { restartFailed: true };
119
+ }
120
+ restarted = true;
121
+ sessionRestarted = true;
122
+ currentSessionId = null;
123
+ try {
124
+ const replacementSessionId = await options.reinitialize(requestWithinBudget);
125
+ if (replacementSessionId !== null && !validSessionId(replacementSessionId)) {
126
+ throw new Error("invalid replacement session");
127
+ }
128
+ currentSessionId = replacementSessionId;
129
+ scorecard.sessionRestart = "pass";
130
+ }
131
+ catch {
132
+ scorecard.sessionRestart = "fail";
133
+ return { restartFailed: true };
134
+ }
135
+ continue;
136
+ }
137
+ return { response, sessionRestarted };
138
+ }
139
+ return { restartFailed: true };
140
+ };
141
+ const first = await send("GET", {}, (body) => observeFirstSseEvent(body).complete);
142
+ if (first.restartFailed) {
143
+ findings.push(finding("plugin.runtime.remote.reliability.session_restart.failed", "fail", "The remote MCP session expired and could not be restarted within the bounded probe.", "Accept a fresh initialize sequence after an expired MCP session."));
144
+ return finalize(scorecard, findings);
145
+ }
146
+ if (first.error) {
147
+ if (isSseTimeout(first.error)) {
148
+ scorecard.getSse = "warn";
149
+ scorecard.disconnectSafety = "warn";
150
+ findings.push(finding("plugin.runtime.remote.reliability.get.inconclusive", "warn", "The remote MCP SSE stream did not produce a complete event before the bounded observation deadline.", "Emit complete SSE event frames promptly when server-to-client streaming is supported."));
151
+ return finalize(scorecard, findings);
152
+ }
153
+ scorecard.getSse = "fail";
154
+ findings.push(finding("plugin.runtime.remote.reliability.get.failed", "fail", "The remote MCP SSE transport request could not be completed.", "Keep the Streamable HTTP transport reachable within the configured request bounds."));
155
+ return finalize(scorecard, findings);
156
+ }
157
+ const firstResponse = first.response;
158
+ if (!firstResponse) {
159
+ scorecard.getSse = "fail";
160
+ return finalize(scorecard, findings);
161
+ }
162
+ if (firstResponse.statusCode === 405) {
163
+ scorecard.getSse = "pass";
164
+ }
165
+ else {
166
+ const observation = classifySseResponse(firstResponse, "get", findings);
167
+ if (observation === null) {
168
+ scorecard.getSse = "fail";
169
+ return finalize(scorecard, findings);
170
+ }
171
+ if (!observation.complete) {
172
+ scorecard.getSse = "warn";
173
+ scorecard.disconnectSafety = "warn";
174
+ findings.push(finding("plugin.runtime.remote.reliability.get.inconclusive", "warn", "The remote MCP SSE response ended before a complete event could be observed.", "Emit complete SSE event frames when server-to-client streaming is supported."));
175
+ return finalize(scorecard, findings);
176
+ }
177
+ if (observation.malformed) {
178
+ scorecard.getSse = "fail";
179
+ scorecard.disconnectSafety = "fail";
180
+ findings.push(finding("plugin.runtime.remote.reliability.get.malformed", "fail", "The remote MCP endpoint emitted malformed SSE event framing.", "Return complete SSE events with valid id and retry fields."));
181
+ return finalize(scorecard, findings);
182
+ }
183
+ scorecard.getSse = "pass";
184
+ scorecard.disconnectSafety = "pass";
185
+ if (observation.eventId !== null) {
186
+ const remainingMs = deadline - Date.now();
187
+ if (observation.retryMs !== null) {
188
+ if (observation.retryMs >= remainingMs) {
189
+ scorecard.resumability = "warn";
190
+ findings.push(finding("plugin.runtime.remote.reliability.resume.inconclusive", "warn", "The remote MCP SSE retry delay exceeded the remaining bounded observation deadline.", "Advertise an SSE retry delay that fits within the configured probe deadline."));
191
+ return finalize(scorecard, findings);
192
+ }
193
+ await wait(observation.retryMs);
194
+ }
195
+ const resumed = await send("GET", { "Last-Event-ID": observation.eventId }, (body) => observeFirstSseEvent(body).complete);
196
+ if (resumed.restartFailed) {
197
+ findings.push(finding("plugin.runtime.remote.reliability.session_restart.failed", "fail", "The remote MCP session expired during SSE resumability validation and could not be restarted.", "Accept one fresh initialize sequence after an expired MCP session."));
198
+ return finalize(scorecard, findings);
199
+ }
200
+ if (resumed.error) {
201
+ scorecard.resumability = isSseTimeout(resumed.error) ? "warn" : "fail";
202
+ findings.push(finding(isSseTimeout(resumed.error)
203
+ ? "plugin.runtime.remote.reliability.resume.inconclusive"
204
+ : "plugin.runtime.remote.reliability.resume.failed", isSseTimeout(resumed.error) ? "warn" : "fail", "The remote MCP SSE resume request could not be completed within the bounded probe.", "Accept one bounded SSE reconnect using Last-Event-ID."));
205
+ return finalize(scorecard, findings);
206
+ }
207
+ if (!resumed.response) {
208
+ scorecard.resumability = "fail";
209
+ return finalize(scorecard, findings);
210
+ }
211
+ if (resumed.sessionRestarted) {
212
+ scorecard.resumability = "skipped";
213
+ }
214
+ else {
215
+ const resumeObservation = classifySseResponse(resumed.response, "resume", findings);
216
+ if (resumeObservation === null || resumeObservation.malformed) {
217
+ scorecard.resumability = "fail";
218
+ if (resumeObservation?.malformed) {
219
+ findings.push(finding("plugin.runtime.remote.reliability.resume.malformed", "fail", "The remote MCP endpoint emitted malformed SSE framing after a resume request.", "Return complete SSE events with valid id and retry fields after reconnecting."));
220
+ }
221
+ return finalize(scorecard, findings);
222
+ }
223
+ scorecard.resumability = resumeObservation.complete ? "pass" : "warn";
224
+ if (!resumeObservation.complete) {
225
+ findings.push(finding("plugin.runtime.remote.reliability.resume.inconclusive", "warn", "The remote MCP SSE resume response ended before a complete event could be observed.", "Emit complete SSE events after accepting a reconnect."));
226
+ return finalize(scorecard, findings);
227
+ }
228
+ }
229
+ }
230
+ }
231
+ if (!options.allowSessionLifecycle || currentSessionId === null) {
232
+ return finalize(scorecard, findings);
233
+ }
234
+ const terminated = await send("DELETE");
235
+ if (terminated.restartFailed || terminated.error || !terminated.response
236
+ || (terminated.response.statusCode !== 405 && (terminated.response.statusCode < 200 || terminated.response.statusCode >= 300))) {
237
+ scorecard.termination = "fail";
238
+ findings.push(finding("plugin.runtime.remote.reliability.termination.failed", "fail", "The remote MCP session could not be terminated with the approved lifecycle request.", "Return a successful response or HTTP 405 for a bounded MCP session DELETE request."));
239
+ return finalize(scorecard, findings);
240
+ }
241
+ scorecard.termination = "pass";
242
+ return finalize(scorecard, findings);
243
+ }
@@ -1,4 +1,4 @@
1
- import type { DiscoveredPackage, RuntimeProbeResult, RuntimeSandboxMode } from "../domain/types.js";
1
+ import type { DiscoveredPackage, RuntimeProbeResult, RuntimeSandboxMode, RuntimeScorecard } from "../domain/types.js";
2
2
  import { type RemoteMcpRequest } from "./remote-mcp-probe.js";
3
3
  import type { RemoteLookup } from "./remote-network-policy.js";
4
4
  export declare function probeRuntime(discoveredPackage: DiscoveredPackage, options?: RuntimeProbeOptions): Promise<RuntimeProbeResult>;
@@ -8,8 +8,10 @@ export interface RuntimeProbeOptions {
8
8
  transcript?: (line: string) => void;
9
9
  allowNetwork?: boolean;
10
10
  allowLocalNetwork?: boolean;
11
+ allowSessionLifecycle?: boolean;
11
12
  remoteRequestTimeoutMs?: number;
12
13
  remoteLookup?: RemoteLookup;
13
14
  remoteRequest?: RemoteMcpRequest;
14
15
  }
16
+ export declare function remoteReliabilityGatePassed(scorecard: RuntimeScorecard | undefined): boolean;
15
17
  export declare function probeRuntimeConfig(rootPath: string, mcpConfigPath: string, options?: RuntimeProbeOptions): Promise<RuntimeProbeResult>;
@@ -345,6 +345,17 @@ function mergeRemoteRuntimeScorecards(left, right) {
345
345
  "present-valid": 1,
346
346
  absent: 0
347
347
  };
348
+ const reliability = left.reliability && right.reliability
349
+ ? {
350
+ getSse: worstRuntimeStatus(left.reliability.getSse, right.reliability.getSse),
351
+ sessionPropagation: worstRuntimeStatus(left.reliability.sessionPropagation, right.reliability.sessionPropagation),
352
+ resumability: worstRuntimeStatus(left.reliability.resumability, right.reliability.resumability),
353
+ disconnectSafety: worstRuntimeStatus(left.reliability.disconnectSafety, right.reliability.disconnectSafety),
354
+ sessionRestart: worstRuntimeStatus(left.reliability.sessionRestart, right.reliability.sessionRestart),
355
+ termination: worstRuntimeStatus(left.reliability.termination, right.reliability.termination),
356
+ overall: worstRuntimeStatus(left.reliability.overall, right.reliability.overall)
357
+ }
358
+ : left.reliability ?? right.reliability;
348
359
  return {
349
360
  transport: worstRuntimeStatus(left.transport, right.transport),
350
361
  networkSafety: worstRuntimeStatus(left.networkSafety, right.networkSafety),
@@ -355,7 +366,8 @@ function mergeRemoteRuntimeScorecards(left, right) {
355
366
  : right.session,
356
367
  protocolHeaders: worstRuntimeStatus(left.protocolHeaders, right.protocolHeaders),
357
368
  authorization: worstRuntimeStatus(left.authorization, right.authorization),
358
- overall: worstRuntimeStatus(left.overall, right.overall)
369
+ overall: worstRuntimeStatus(left.overall, right.overall),
370
+ ...(reliability ? { reliability } : {})
359
371
  };
360
372
  }
361
373
  function supportsTasksListProbe(protocolVersion, capabilities) {
@@ -1251,6 +1263,10 @@ export async function probeRuntime(discoveredPackage, options = {}) {
1251
1263
  }
1252
1264
  return probeRuntimeConfig(rootPath, manifest.mcpServers, options);
1253
1265
  }
1266
+ export function remoteReliabilityGatePassed(scorecard) {
1267
+ return scorecard !== undefined &&
1268
+ (scorecard.remote === undefined || scorecard.remote.reliability?.overall === "pass");
1269
+ }
1254
1270
  export async function probeRuntimeConfig(rootPath, mcpConfigPath, options = {}) {
1255
1271
  const resolvedRootPath = path.resolve(rootPath);
1256
1272
  const resolvedMcpConfigPath = path.resolve(resolvedRootPath, mcpConfigPath);
@@ -1296,6 +1312,7 @@ export async function probeRuntimeConfig(rootPath, mcpConfigPath, options = {})
1296
1312
  const remote = await probeRemoteMcpServer(serverName, url, {
1297
1313
  allowNetwork: options.allowNetwork,
1298
1314
  allowLocalNetwork: options.allowLocalNetwork,
1315
+ allowSessionLifecycle: options.allowSessionLifecycle,
1299
1316
  requestTimeoutMs: options.remoteRequestTimeoutMs,
1300
1317
  lookup: options.remoteLookup,
1301
1318
  request: options.remoteRequest
@@ -0,0 +1,7 @@
1
+ export interface SseObservation {
2
+ complete: boolean;
3
+ eventId: string | null;
4
+ retryMs: number | null;
5
+ malformed: boolean;
6
+ }
7
+ export declare function observeFirstSseEvent(body: Buffer): SseObservation;
@@ -0,0 +1,67 @@
1
+ const MAX_SSE_FIELD_BYTES = 1_024;
2
+ function isSafeEventId(value) {
3
+ return value.length > 0
4
+ && value.length <= MAX_SSE_FIELD_BYTES
5
+ && value.every((byte) => byte >= 0x21 && byte <= 0x7e);
6
+ }
7
+ function parseRetry(value) {
8
+ if (value.length === 0 || value.length > MAX_SSE_FIELD_BYTES) {
9
+ return null;
10
+ }
11
+ for (const byte of value) {
12
+ if (byte < 0x30 || byte > 0x39) {
13
+ return null;
14
+ }
15
+ }
16
+ const retryMs = Number(value.toString("ascii"));
17
+ return Number.isSafeInteger(retryMs) ? retryMs : null;
18
+ }
19
+ export function observeFirstSseEvent(body) {
20
+ let eventId = null;
21
+ let retryMs = null;
22
+ let malformed = false;
23
+ let lineStart = 0;
24
+ for (let index = 0; index < body.length; index += 1) {
25
+ if (body[index] !== 0x0a && body[index] !== 0x0d) {
26
+ continue;
27
+ }
28
+ const line = body.subarray(lineStart, index);
29
+ if (body[index] === 0x0d && body[index + 1] === 0x0a) {
30
+ index += 1;
31
+ }
32
+ lineStart = index + 1;
33
+ if (line.length === 0) {
34
+ return {
35
+ complete: true,
36
+ eventId: malformed ? null : eventId,
37
+ retryMs: malformed ? null : retryMs,
38
+ malformed
39
+ };
40
+ }
41
+ if (line[0] === 0x3a) {
42
+ continue;
43
+ }
44
+ const separator = line.indexOf(0x3a);
45
+ const field = separator === -1 ? line : line.subarray(0, separator);
46
+ const valueStart = separator === -1 ? line.length : separator + 1;
47
+ const value = line.subarray(valueStart + (line[valueStart] === 0x20 ? 1 : 0));
48
+ if (field.equals(Buffer.from("id"))) {
49
+ if (value.length === 0) {
50
+ eventId = null;
51
+ }
52
+ else if (isSafeEventId(value)) {
53
+ eventId = value.toString("ascii");
54
+ }
55
+ else {
56
+ malformed = true;
57
+ }
58
+ }
59
+ else if (field.equals(Buffer.from("retry"))) {
60
+ const parsedRetryMs = parseRetry(value);
61
+ if (parsedRetryMs !== null) {
62
+ retryMs = parsedRetryMs;
63
+ }
64
+ }
65
+ }
66
+ return { complete: false, eventId: null, retryMs: null, malformed: false };
67
+ }
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import { withFindingFingerprints } from "../reporting/finding-fingerprint.js";
4
4
  import { discoverPackage } from "./discover-package.js";
5
5
  import { inspectRemoteMcpUrl } from "./remote-url-policy.js";
6
- import { probeRuntime } from "./runtime-probe.js";
6
+ import { probeRuntime, remoteReliabilityGatePassed } from "./runtime-probe.js";
7
7
  function buildFailure(id, message, impact, suggestedFix, evidence) {
8
8
  return {
9
9
  id,
@@ -446,7 +446,8 @@ export async function validatePlugin(targetPath, options = {}) {
446
446
  sandbox: options.runtimeSandbox,
447
447
  transcript: options.runtimeTranscript,
448
448
  allowNetwork: options.allowNetwork,
449
- allowLocalNetwork: options.allowLocalNetwork
449
+ allowLocalNetwork: options.allowLocalNetwork,
450
+ allowSessionLifecycle: options.allowSessionLifecycle
450
451
  })
451
452
  : null;
452
453
  const findings = [
@@ -454,7 +455,7 @@ export async function validatePlugin(targetPath, options = {}) {
454
455
  ...(runtimeResult?.findings ?? [])
455
456
  ];
456
457
  const fingerprintedFindings = withFindingFingerprints(findings, discoveredPackage.rootPath);
457
- const hasFailures = fingerprintedFindings.some((finding) => finding.severity === "fail");
458
+ const hasFailures = fingerprintedFindings.some((finding) => finding.severity === "fail") || (options.requireRemoteReliability === true && !remoteReliabilityGatePassed(runtimeResult?.scorecard));
458
459
  const hasWarnings = fingerprintedFindings.some((finding) => finding.severity === "warn");
459
460
  if (!hasFailures && !hasWarnings) {
460
461
  return {
@@ -43,6 +43,8 @@ export interface CheckOptions {
43
43
  runtime?: boolean;
44
44
  allowNetwork?: boolean;
45
45
  allowLocalNetwork?: boolean;
46
+ allowSessionLifecycle?: boolean;
47
+ requireRemoteReliability?: boolean;
46
48
  runtimeTranscript?: (line: string) => void;
47
49
  runtimeStartupTimeoutMs?: number;
48
50
  runtimeSandbox?: RuntimeSandboxMode;
@@ -103,8 +105,19 @@ export interface RemoteRuntimeScorecard {
103
105
  session: "absent" | "present-valid" | "present-invalid";
104
106
  protocolHeaders: RuntimeCapabilityStatus;
105
107
  authorization: RuntimeCapabilityStatus;
108
+ reliability?: RemoteTransportReliabilityScorecard;
106
109
  overall: "pass" | "warn" | "fail" | "skipped";
107
110
  }
111
+ export type RemoteTransportReliabilityStatus = "pass" | "warn" | "fail" | "skipped";
112
+ export interface RemoteTransportReliabilityScorecard {
113
+ getSse: RemoteTransportReliabilityStatus;
114
+ sessionPropagation: RemoteTransportReliabilityStatus;
115
+ resumability: RemoteTransportReliabilityStatus;
116
+ disconnectSafety: RemoteTransportReliabilityStatus;
117
+ sessionRestart: RemoteTransportReliabilityStatus;
118
+ termination: RemoteTransportReliabilityStatus;
119
+ overall: RemoteTransportReliabilityStatus;
120
+ }
108
121
  export type McpConformanceProfile = "legacy" | "2025-11-25" | "future-compatible";
109
122
  export interface RuntimeConformanceScorecard {
110
123
  protocolVersion: string | null;
@@ -17,6 +17,8 @@ export interface GenericMcpDoctorOptions {
17
17
  runtime?: boolean;
18
18
  allowNetwork?: boolean;
19
19
  allowLocalNetwork?: boolean;
20
+ allowSessionLifecycle?: boolean;
21
+ requireRemoteReliability?: boolean;
20
22
  runtimeStartupTimeoutMs?: number;
21
23
  }
22
24
  export declare function buildGenericMcpDoctor(targetPath: string, environment?: CompatibilityEnvironment, options?: GenericMcpDoctorOptions): Promise<GenericMcpDoctorReport>;
@@ -2,7 +2,7 @@ import { realpath, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { buildCompatibilityMatrix, readMcpConfigPath } from "../compatibility/compatibility-matrix.js";
4
4
  import { readJsonFile } from "../core/read-json-file.js";
5
- import { probeRuntimeConfig } from "../core/runtime-probe.js";
5
+ import { probeRuntimeConfig, remoteReliabilityGatePassed } from "../core/runtime-probe.js";
6
6
  import { formatFindingFingerprintLine, withFindingFingerprints } from "../reporting/finding-fingerprint.js";
7
7
  import { auditMcpServerConfig, buildSecurityAuditFromFindings } from "../security/security-audit.js";
8
8
  function buildFinding(severity, id, message, impact, suggestedFix, evidence) {
@@ -142,11 +142,14 @@ export async function buildGenericMcpDoctor(targetPath, environment = {}, option
142
142
  ? await probeRuntimeConfig(canonicalRootPath, canonicalMcpConfigPath, {
143
143
  startupTimeoutMs: options.runtimeStartupTimeoutMs,
144
144
  allowNetwork: options.allowNetwork,
145
- allowLocalNetwork: options.allowLocalNetwork
145
+ allowLocalNetwork: options.allowLocalNetwork,
146
+ allowSessionLifecycle: options.allowSessionLifecycle
146
147
  })
147
148
  : null;
148
149
  const fingerprintedFindings = withFindingFingerprints([...staticFindings, ...(runtimeResult?.findings ?? [])], rootPath);
149
- const status = mergeReportStatus(fingerprintedFindings, security);
150
+ const status = options.requireRemoteReliability === true && !remoteReliabilityGatePassed(runtimeResult?.scorecard)
151
+ ? "fail"
152
+ : mergeReportStatus(fingerprintedFindings, security);
150
153
  return {
151
154
  targetPath: rootPath,
152
155
  status,
@@ -42,6 +42,19 @@ function appendRuntimeScorecard(lines, result) {
42
42
  lines.push(`| Protocol headers | ${remote.protocolHeaders.toUpperCase()} |`);
43
43
  lines.push(`| Authorization | ${remote.authorization.toUpperCase()} |`);
44
44
  lines.push(`| Overall | ${remote.overall.toUpperCase()} |`);
45
+ if (remote.reliability) {
46
+ const reliability = remote.reliability;
47
+ lines.push("", "## Remote Transport Reliability", "");
48
+ lines.push("| Check | Status |");
49
+ lines.push("| --- | --- |");
50
+ lines.push(`| GET SSE | ${reliability.getSse.toUpperCase()} |`);
51
+ lines.push(`| Session propagation | ${reliability.sessionPropagation.toUpperCase()} |`);
52
+ lines.push(`| Resumability | ${reliability.resumability.toUpperCase()} |`);
53
+ lines.push(`| Disconnect safety | ${reliability.disconnectSafety.toUpperCase()} |`);
54
+ lines.push(`| Session restart | ${reliability.sessionRestart.toUpperCase()} |`);
55
+ lines.push(`| Termination | ${reliability.termination.toUpperCase()} |`);
56
+ lines.push(`| Overall | ${reliability.overall.toUpperCase()} |`);
57
+ }
45
58
  }
46
59
  }
47
60
  export function buildMarkdownReport(result, options) {
@@ -59,6 +59,17 @@ function appendRuntimeScorecard(lines, result) {
59
59
  lines.push(`protocol headers: ${remote.protocolHeaders}`);
60
60
  lines.push(`authorization: ${remote.authorization}`);
61
61
  lines.push(`overall: ${remote.overall}`);
62
+ if (remote.reliability) {
63
+ const reliability = remote.reliability;
64
+ lines.push("", "Remote Transport Reliability", "----------------------------");
65
+ lines.push(`GET SSE: ${reliability.getSse}`);
66
+ lines.push(`Session propagation: ${reliability.sessionPropagation}`);
67
+ lines.push(`Resumability: ${reliability.resumability}`);
68
+ lines.push(`Disconnect safety: ${reliability.disconnectSafety}`);
69
+ lines.push(`Session restart: ${reliability.sessionRestart}`);
70
+ lines.push(`Termination: ${reliability.termination}`);
71
+ lines.push(`Overall: ${reliability.overall}`);
72
+ }
62
73
  }
63
74
  }
64
75
  export function renderTextReport(result, options = {}) {
@@ -556,6 +556,114 @@ export const ruleCatalog = [
556
556
  why: "Authorization readiness cannot be confirmed without bounded metadata responses.",
557
557
  fix: "Make protected-resource and authorization-server metadata available over HTTPS.",
558
558
  example: "Return application/json discovery metadata."
559
+ },
560
+ {
561
+ id: "plugin.runtime.remote.reliability.get.status",
562
+ category: "runtime",
563
+ defaultSeverity: "fail",
564
+ summary: "The remote MCP endpoint rejected the SSE transport request.",
565
+ why: "Clients cannot rely on server-to-client streaming when an accepted SSE request does not return the expected status.",
566
+ fix: "Return HTTP 200 with a valid SSE response, or HTTP 405 when GET streaming is not supported.",
567
+ example: "HTTP/1.1 405 Method Not Allowed"
568
+ },
569
+ {
570
+ id: "plugin.runtime.remote.reliability.get.content_type",
571
+ category: "runtime",
572
+ defaultSeverity: "fail",
573
+ summary: "The remote MCP endpoint returned a non-SSE media type for GET streaming.",
574
+ why: "Clients cannot interpret a successful streaming response unless it is declared as Server-Sent Events.",
575
+ fix: "Return Content-Type: text/event-stream for accepted GET streaming requests.",
576
+ example: "Content-Type: text/event-stream"
577
+ },
578
+ {
579
+ id: "plugin.runtime.remote.reliability.get.inconclusive",
580
+ category: "runtime",
581
+ defaultSeverity: "warn",
582
+ summary: "The remote MCP GET streaming check was inconclusive.",
583
+ why: "A bounded probe could not observe a complete SSE event, so streaming reliability remains uncertain.",
584
+ fix: "Emit complete SSE event frames promptly when server-to-client streaming is supported.",
585
+ example: "id: event-1\n\ndata: {}\n\n"
586
+ },
587
+ {
588
+ id: "plugin.runtime.remote.reliability.get.failed",
589
+ category: "runtime",
590
+ defaultSeverity: "fail",
591
+ summary: "The remote MCP GET streaming request could not be completed.",
592
+ why: "Clients cannot rely on server-to-client streaming when a bounded transport request fails.",
593
+ fix: "Keep the Streamable HTTP transport reachable within the configured request bounds.",
594
+ example: "Complete the GET request before the runtime timeout."
595
+ },
596
+ {
597
+ id: "plugin.runtime.remote.reliability.get.malformed",
598
+ category: "runtime",
599
+ defaultSeverity: "fail",
600
+ summary: "The remote MCP endpoint emitted malformed SSE framing.",
601
+ why: "Malformed event framing prevents clients from safely processing stream progress or reconnecting.",
602
+ fix: "Return complete SSE events with valid id and retry fields.",
603
+ example: "id: event-1\n\nevent: message\n\ndata: {}\n\n"
604
+ },
605
+ {
606
+ id: "plugin.runtime.remote.reliability.resume.status",
607
+ category: "runtime",
608
+ defaultSeverity: "fail",
609
+ summary: "The remote MCP endpoint rejected the SSE resume request.",
610
+ why: "Clients cannot resume a dropped stream when the reconnect response has an unexpected status.",
611
+ fix: "Return HTTP 200 for an accepted Last-Event-ID reconnect request.",
612
+ example: "HTTP/1.1 200 OK"
613
+ },
614
+ {
615
+ id: "plugin.runtime.remote.reliability.resume.content_type",
616
+ category: "runtime",
617
+ defaultSeverity: "fail",
618
+ summary: "The remote MCP endpoint returned a non-SSE media type for a resume request.",
619
+ why: "Clients cannot process a resumed stream unless it is declared as Server-Sent Events.",
620
+ fix: "Return Content-Type: text/event-stream after accepting a Last-Event-ID reconnect request.",
621
+ example: "Content-Type: text/event-stream"
622
+ },
623
+ {
624
+ id: "plugin.runtime.remote.reliability.resume.inconclusive",
625
+ category: "runtime",
626
+ defaultSeverity: "warn",
627
+ summary: "The remote MCP SSE resume check was inconclusive.",
628
+ why: "A bounded probe could not confirm a complete resumed event, so reconnect reliability remains uncertain.",
629
+ fix: "Emit complete SSE event frames after accepting a reconnect.",
630
+ example: "Return one complete SSE event after a Last-Event-ID request."
631
+ },
632
+ {
633
+ id: "plugin.runtime.remote.reliability.resume.failed",
634
+ category: "runtime",
635
+ defaultSeverity: "fail",
636
+ summary: "The remote MCP SSE resume request could not be completed.",
637
+ why: "Clients cannot recover from a dropped stream when a bounded reconnect fails.",
638
+ fix: "Accept one bounded SSE reconnect using Last-Event-ID.",
639
+ example: "Honor the Last-Event-ID header on one reconnect."
640
+ },
641
+ {
642
+ id: "plugin.runtime.remote.reliability.resume.malformed",
643
+ category: "runtime",
644
+ defaultSeverity: "fail",
645
+ summary: "The remote MCP endpoint emitted malformed SSE framing after reconnecting.",
646
+ why: "Malformed resumed events prevent clients from safely continuing stream processing.",
647
+ fix: "Return complete SSE events with valid id and retry fields after reconnecting.",
648
+ example: "Return a complete event frame after reconnecting."
649
+ },
650
+ {
651
+ id: "plugin.runtime.remote.reliability.session_restart.failed",
652
+ category: "runtime",
653
+ defaultSeverity: "fail",
654
+ summary: "The remote MCP session could not be restarted.",
655
+ why: "Clients cannot recover from an expired MCP session when a fresh initialization sequence fails.",
656
+ fix: "Accept a fresh initialize sequence after an expired MCP session.",
657
+ example: "Return a valid initialize response after HTTP 404 for a session-bound request."
658
+ },
659
+ {
660
+ id: "plugin.runtime.remote.reliability.termination.failed",
661
+ category: "runtime",
662
+ defaultSeverity: "fail",
663
+ summary: "The remote MCP session could not be terminated.",
664
+ why: "A server that advertises session lifecycle support must safely handle the approved termination request.",
665
+ fix: "Return a successful response or HTTP 405 for a bounded MCP session DELETE request.",
666
+ example: "HTTP/1.1 204 No Content"
559
667
  }
560
668
  ];
561
669
  export function findRuleDefinition(id) {
package/dist/run-cli.js CHANGED
@@ -102,6 +102,8 @@ function parseRuntimeSandbox(flags, options = { requireRuntime: true }) {
102
102
  function parseRemoteNetworkFlags(flags, runtime) {
103
103
  let allowNetwork = false;
104
104
  let allowLocalNetwork = false;
105
+ let allowSessionLifecycle = false;
106
+ let requireRemoteReliability = false;
105
107
  for (let index = 0; index < flags.length; index += 1) {
106
108
  const flag = flags[index];
107
109
  if (flag === "--allow-network") {
@@ -120,23 +122,55 @@ function parseRemoteNetworkFlags(flags, runtime) {
120
122
  }
121
123
  allowLocalNetwork = true;
122
124
  }
123
- else if (flag?.startsWith("--allow-network=") || flag?.startsWith("--allow-local-network=")) {
125
+ else if (flag === "--allow-session-lifecycle") {
126
+ if (allowSessionLifecycle)
127
+ return new CliUsageError("Duplicate runtime remote flag: --allow-session-lifecycle.");
128
+ if (flags[index + 1] && !flags[index + 1].startsWith("--")) {
129
+ return new CliUsageError("--allow-session-lifecycle does not accept a value.");
130
+ }
131
+ allowSessionLifecycle = true;
132
+ }
133
+ else if (flag === "--require-remote-reliability") {
134
+ if (requireRemoteReliability)
135
+ return new CliUsageError("Duplicate runtime remote flag: --require-remote-reliability.");
136
+ if (flags[index + 1] && !flags[index + 1].startsWith("--")) {
137
+ return new CliUsageError("--require-remote-reliability does not accept a value.");
138
+ }
139
+ requireRemoteReliability = true;
140
+ }
141
+ else if (flag?.startsWith("--allow-network=") ||
142
+ flag?.startsWith("--allow-local-network=") ||
143
+ flag?.startsWith("--allow-session-lifecycle=") ||
144
+ flag?.startsWith("--require-remote-reliability=")) {
124
145
  return new CliUsageError(`${flag.split("=", 1)[0]} does not accept a value.`);
125
146
  }
126
147
  }
148
+ if (allowSessionLifecycle && !runtime) {
149
+ return new CliUsageError("--allow-session-lifecycle requires --runtime.");
150
+ }
151
+ if (requireRemoteReliability && !runtime) {
152
+ return new CliUsageError("--require-remote-reliability requires --runtime and --allow-network.");
153
+ }
127
154
  if ((allowNetwork || allowLocalNetwork) && !runtime) {
128
155
  return new CliUsageError("--allow-network requires --runtime.");
129
156
  }
130
157
  if (allowLocalNetwork && !allowNetwork) {
131
158
  return new CliUsageError("--allow-local-network requires --allow-network.");
132
159
  }
133
- return { allowNetwork, allowLocalNetwork };
160
+ if (allowSessionLifecycle && !allowNetwork) {
161
+ return new CliUsageError("--allow-session-lifecycle requires --allow-network.");
162
+ }
163
+ if (requireRemoteReliability && !allowNetwork) {
164
+ return new CliUsageError("--require-remote-reliability requires --runtime and --allow-network.");
165
+ }
166
+ return { allowNetwork, allowLocalNetwork, allowSessionLifecycle, requireRemoteReliability };
134
167
  }
135
168
  function printUsage(io) {
136
169
  io.writeStderr("Usage: codex-plugin-doctor check <path|--installed> [filter] [--policy codex-publish|mcp-strict|security] [--compat] [--json|--markdown|--badge-json|--badge-markdown] [--output <path>] [--history <path>] [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker] [--require-runtime-approval --runtime-approval-digest <digest>] [--verbose-runtime] [--explain] [--no-animations] [--ascii] [--changed-since <ref>]\n codex-plugin-doctor audit --installed [filter] [--policy codex-publish|mcp-strict|security] [--security] [--compat] [--json] [--output <path>] [--cache] [--changed]\n codex-plugin-doctor audit deps <path> [--policy codex-publish|mcp-strict|security] [--recommend] [--json|--sarif] [--output <path>]\n codex-plugin-doctor mcp <path> [--runtime [--allow-network [--allow-local-network]]] [--json] [--output <path>]\n codex-plugin-doctor security <path> [--policy security] [--json|--scorecard]\n codex-plugin-doctor compat <path> [--all|--client <client>] [--json] [--scorecard] [--output <path>] [--install-preview|--apply --backup]\n codex-plugin-doctor suppress add <path> [--fingerprint <sha256> --reason <text> --expires-at YYYY-MM-DD] [--config <path>] [--json]\n codex-plugin-doctor suppress list <path> [--config <path>] [--json]\n codex-plugin-doctor suppress remove <path> [--fingerprint <sha256>|--index <n>] [--config <path>] [--json]\n codex-plugin-doctor fix <path> (--dry-run|--interactive --backup|--apply --backup)\n codex-plugin-doctor history <history.jsonl> [--json] [--fail-on-regression]\n codex-plugin-doctor watch <path> [--runtime] [--json] [--output <path>] [--debounce-ms <ms>] [--max-iterations <n>] [--fail-fast] [--accumulate-json <path>]\n codex-plugin-doctor doctor [npm <package>|contract|corpus [--manifest <corpus.json>] [--json] [--output <path>]|corpus metrics --manifest <corpus.json> [--json|--markdown] [--output <path>] [--min-precision <0..1>] [--min-recall <0..1>] [--max-false-positive-rate <0..1>]|runtime-plan <path> [--sandbox docker] [--json|--markdown] [--output <path>]|runtime-policy <path> [--sandbox docker] [--json] [--output <path>]|review-bundle <path> --output <dir> --sign-key-env NAME [--json] [--allow-dirty] [--allow-untagged]|review-bundle verify <bundle-dir> --target <path> --sign-key-env NAME [--json] [--output <path>] [--failures-only]|review-bundle diff --before <dir> --after <dir> [--json]|attest <path> [--sign-key-env NAME]|attest verify <attestation.json> --target <path> --sign-key-env NAME|release-evidence <path> --sign-key-env NAME [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker] [--allow-dirty] [--allow-untagged] [--require-runtime-approval --runtime-approval-digest <digest>]|release-evidence verify <evidence.json> --target <path> --sign-key-env NAME|release-evidence asset <path> --tag <tag> --output <evidence.json> --sign-key-env NAME [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker] [--allow-dirty] [--allow-untagged] [--require-runtime-approval --runtime-approval-digest <digest>] [--upload]|mcp <path> [--runtime [--allow-network [--allow-local-network]]]|inspector <path>|diff --before <path> --after <path>|recommend <path>|trust <path>|perf <path> [--max-total-ms <ms>] [--max-stage-ms stage=ms]|export --bundle <path>|snapshot|clients|--json|--update-check]\n codex-plugin-doctor init [path] [--template skill-only|mcp-stdio|mcp-http|full-runtime]\n codex-plugin-doctor init-ci [path]\n codex-plugin-doctor init-git-hooks [path] [--force] [--json]\n codex-plugin-doctor init-git-hooks [path] --remove [--json]\n codex-plugin-doctor completion bash|zsh|fish\n codex-plugin-doctor config validate <path> [--json]\n codex-plugin-doctor release check <path> [--json] [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker]\n codex-plugin-doctor self-test\n codex-plugin-doctor list --installed\n codex-plugin-doctor explain <finding-id>\n codex-plugin-doctor --version\n\nFirst run:\n codex-plugin-doctor doctor\n codex-plugin-doctor self-test\n codex-plugin-doctor init my-plugin\n codex-plugin-doctor check . --runtime --explain");
137
170
  io.writeStderr("Corpus quality regression: codex-plugin-doctor doctor corpus metrics diff --before <metrics.json> --after <metrics.json> [--fail-on-regression] [--json|--markdown] [--output <path>]");
138
171
  io.writeStderr("Suppression governance: codex-plugin-doctor suppress list <path> [--fail-on-expired] [--fail-on-invalid] [--warn-expiring-within-days <days>]\n codex-plugin-doctor suppress prune <path> [--apply] [--json]");
139
172
  io.writeStderr("Baseline gating: codex-plugin-doctor baseline create <path> --output <path> [--runtime]\n codex-plugin-doctor check <path> --baseline <path>");
173
+ io.writeStderr("Remote MCP runtime flags (check, mcp, release check, and doctor release-evidence): --runtime --allow-network [--allow-local-network] [--allow-session-lifecycle] [--require-remote-reliability]\n --allow-session-lifecycle requires --runtime --allow-network and can terminate a remote session.\n --require-remote-reliability requires --runtime --allow-network, blocks non-pass reliability, and does not grant network consent.");
140
174
  }
141
175
  const suppressUsageText = [
142
176
  "Usage:",
@@ -687,6 +721,8 @@ function buildGenericMcpDoctorCommandArgs(commandTarget, flags) {
687
721
  let runtime = false;
688
722
  let allowNetwork = false;
689
723
  let allowLocalNetwork = false;
724
+ let allowSessionLifecycle = false;
725
+ let requireRemoteReliability = false;
690
726
  for (let index = 0; index < flags.length; index += 1) {
691
727
  const flag = flags[index];
692
728
  if (flag === "--runtime") {
@@ -715,6 +751,26 @@ function buildGenericMcpDoctorCommandArgs(commandTarget, flags) {
715
751
  allowLocalNetwork = true;
716
752
  continue;
717
753
  }
754
+ if (flag === "--allow-session-lifecycle") {
755
+ if (flags[index + 1] && !flags[index + 1].startsWith("--")) {
756
+ return "--allow-session-lifecycle does not accept a value.";
757
+ }
758
+ allowSessionLifecycle = true;
759
+ continue;
760
+ }
761
+ if (flag === "--require-remote-reliability") {
762
+ if (flags[index + 1] && !flags[index + 1].startsWith("--")) {
763
+ return "--require-remote-reliability does not accept a value.";
764
+ }
765
+ requireRemoteReliability = true;
766
+ continue;
767
+ }
768
+ if (flag.startsWith("--allow-network=") ||
769
+ flag.startsWith("--allow-local-network=") ||
770
+ flag.startsWith("--allow-session-lifecycle=") ||
771
+ flag.startsWith("--require-remote-reliability=")) {
772
+ return `${flag.split("=", 1)[0]} does not accept a value.`;
773
+ }
718
774
  if (flag === "--output") {
719
775
  if (outputPath !== null) {
720
776
  return "Duplicate MCP flag: --output.";
@@ -740,8 +796,10 @@ function buildGenericMcpDoctorCommandArgs(commandTarget, flags) {
740
796
  jsonOutput,
741
797
  outputPath,
742
798
  runtime,
743
- allowNetwork,
744
- allowLocalNetwork
799
+ allowNetwork: remoteNetwork.allowNetwork,
800
+ allowLocalNetwork: remoteNetwork.allowLocalNetwork,
801
+ allowSessionLifecycle,
802
+ requireRemoteReliability
745
803
  };
746
804
  }
747
805
  function parsePerformanceThresholds(flags) {
@@ -1031,7 +1089,9 @@ export async function runCli(args, io = defaultIo, options = {}) {
1031
1089
  }, {
1032
1090
  runtime: parsedMcpArgs.runtime,
1033
1091
  allowNetwork: parsedMcpArgs.allowNetwork,
1034
- allowLocalNetwork: parsedMcpArgs.allowLocalNetwork
1092
+ allowLocalNetwork: parsedMcpArgs.allowLocalNetwork,
1093
+ allowSessionLifecycle: parsedMcpArgs.allowSessionLifecycle,
1094
+ requireRemoteReliability: parsedMcpArgs.requireRemoteReliability
1035
1095
  });
1036
1096
  const renderedReport = parsedMcpArgs.jsonOutput
1037
1097
  ? renderGenericMcpDoctorJson(report)
@@ -1344,6 +1404,8 @@ export async function runCli(args, io = defaultIo, options = {}) {
1344
1404
  runtime,
1345
1405
  ...(remoteNetwork.allowNetwork ? { allowNetwork: true } : {}),
1346
1406
  ...(remoteNetwork.allowLocalNetwork ? { allowLocalNetwork: true } : {}),
1407
+ ...(remoteNetwork.allowSessionLifecycle ? { allowSessionLifecycle: true } : {}),
1408
+ ...(remoteNetwork.requireRemoteReliability ? { requireRemoteReliability: true } : {}),
1347
1409
  ...(runtimeSandbox ? { sandbox: runtimeSandbox } : {}),
1348
1410
  environment: {
1349
1411
  env: terminalContext.env,
@@ -1507,6 +1569,8 @@ export async function runCli(args, io = defaultIo, options = {}) {
1507
1569
  runtime,
1508
1570
  ...(remoteNetwork.allowNetwork ? { allowNetwork: true } : {}),
1509
1571
  ...(remoteNetwork.allowLocalNetwork ? { allowLocalNetwork: true } : {}),
1572
+ ...(remoteNetwork.allowSessionLifecycle ? { allowSessionLifecycle: true } : {}),
1573
+ ...(remoteNetwork.requireRemoteReliability ? { requireRemoteReliability: true } : {}),
1510
1574
  ...(runtimeSandbox ? { sandbox: runtimeSandbox } : {}),
1511
1575
  environment: {
1512
1576
  env: terminalContext.env,
@@ -2332,7 +2396,9 @@ export async function runCli(args, io = defaultIo, options = {}) {
2332
2396
  }, {
2333
2397
  runtime: parsedMcpArgs.runtime,
2334
2398
  allowNetwork: parsedMcpArgs.allowNetwork,
2335
- allowLocalNetwork: parsedMcpArgs.allowLocalNetwork
2399
+ allowLocalNetwork: parsedMcpArgs.allowLocalNetwork,
2400
+ allowSessionLifecycle: parsedMcpArgs.allowSessionLifecycle,
2401
+ requireRemoteReliability: parsedMcpArgs.requireRemoteReliability
2336
2402
  });
2337
2403
  const renderedReport = parsedMcpArgs.jsonOutput
2338
2404
  ? renderGenericMcpDoctorJson(report)
@@ -2653,6 +2719,8 @@ export async function runCli(args, io = defaultIo, options = {}) {
2653
2719
  runtime: runtimeProbeEnabled,
2654
2720
  ...(remoteNetwork.allowNetwork ? { allowNetwork: true } : {}),
2655
2721
  ...(remoteNetwork.allowLocalNetwork ? { allowLocalNetwork: true } : {}),
2722
+ ...(remoteNetwork.allowSessionLifecycle ? { allowSessionLifecycle: true } : {}),
2723
+ ...(remoteNetwork.requireRemoteReliability ? { requireRemoteReliability: true } : {}),
2656
2724
  ...(runtimeSandbox ? { runtimeSandbox } : {}),
2657
2725
  runCheck: options.runCheckImpl
2658
2726
  });
@@ -2867,6 +2935,8 @@ export async function runCli(args, io = defaultIo, options = {}) {
2867
2935
  runtime: effectiveRuntimeProbeEnabled,
2868
2936
  ...(remoteNetwork.allowNetwork ? { allowNetwork: true } : {}),
2869
2937
  ...(remoteNetwork.allowLocalNetwork ? { allowLocalNetwork: true } : {}),
2938
+ ...(remoteNetwork.allowSessionLifecycle ? { allowSessionLifecycle: true } : {}),
2939
+ ...(remoteNetwork.requireRemoteReliability ? { requireRemoteReliability: true } : {}),
2870
2940
  runtimeTranscript: effectiveRuntimeProbeEnabled && verboseRuntime
2871
2941
  ? (line) => io.writeStderr(line)
2872
2942
  : undefined
@@ -2938,7 +3008,9 @@ export async function runCli(args, io = defaultIo, options = {}) {
2938
3008
  const pluginResult = applyDoctorConfig(await runCheckImpl(pluginRoot, {
2939
3009
  runtime: effectiveRuntimeProbeEnabled,
2940
3010
  ...(remoteNetwork.allowNetwork ? { allowNetwork: true } : {}),
2941
- ...(remoteNetwork.allowLocalNetwork ? { allowLocalNetwork: true } : {})
3011
+ ...(remoteNetwork.allowLocalNetwork ? { allowLocalNetwork: true } : {}),
3012
+ ...(remoteNetwork.allowSessionLifecycle ? { allowSessionLifecycle: true } : {}),
3013
+ ...(remoteNetwork.requireRemoteReliability ? { requireRemoteReliability: true } : {})
2942
3014
  }), applyPolicyToDoctorConfig(applyCheckProfile(await loadDoctorConfig(pluginRoot, configPath), checkProfile), policy));
2943
3015
  io.writeStdout(`\n${pluginResult.targetPath}: ${pluginResult.status.toUpperCase()}`);
2944
3016
  validatedCount += 1;
@@ -2962,6 +3034,8 @@ export async function runCli(args, io = defaultIo, options = {}) {
2962
3034
  runtime: effectiveRuntimeProbeEnabled,
2963
3035
  ...(remoteNetwork.allowNetwork ? { allowNetwork: true } : {}),
2964
3036
  ...(remoteNetwork.allowLocalNetwork ? { allowLocalNetwork: true } : {}),
3037
+ ...(remoteNetwork.allowSessionLifecycle ? { allowSessionLifecycle: true } : {}),
3038
+ ...(remoteNetwork.requireRemoteReliability ? { requireRemoteReliability: true } : {}),
2965
3039
  ...(runtimeSandbox ? { runtimeSandbox } : {}),
2966
3040
  ...(effectiveRuntimeProbeEnabled && verboseRuntime
2967
3041
  ? { runtimeTranscript: (line) => io.writeStderr(line) }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-plugin-doctor",
3
- "version": "1.52.0",
3
+ "version": "1.53.0",
4
4
  "description": "CLI-first validator for Codex plugins, skills, and MCP package surfaces with runtime MCP protocol validation.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",