codex-plugin-doctor 1.52.0 → 1.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +33 -17
  3. package/dist/core/bounded-http-client.js +2 -1
  4. package/dist/core/doctor-export-bundle.js +3 -1
  5. package/dist/core/mcp-registry.d.ts +57 -0
  6. package/dist/core/mcp-registry.js +455 -0
  7. package/dist/core/output-contract.js +73 -0
  8. package/dist/core/release-check.d.ts +2 -0
  9. package/dist/core/release-check.js +2 -0
  10. package/dist/core/release-evidence.d.ts +2 -0
  11. package/dist/core/release-evidence.js +2 -0
  12. package/dist/core/remote-mcp-probe.d.ts +1 -0
  13. package/dist/core/remote-mcp-probe.js +59 -1
  14. package/dist/core/remote-transport-reliability.d.ts +18 -0
  15. package/dist/core/remote-transport-reliability.js +243 -0
  16. package/dist/core/runtime-probe.d.ts +3 -1
  17. package/dist/core/runtime-probe.js +18 -1
  18. package/dist/core/sse-observation.d.ts +7 -0
  19. package/dist/core/sse-observation.js +67 -0
  20. package/dist/core/validate-plugin.js +4 -3
  21. package/dist/domain/types.d.ts +13 -0
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.js +1 -0
  24. package/dist/mcp/generic-mcp-doctor.d.ts +2 -0
  25. package/dist/mcp/generic-mcp-doctor.js +6 -3
  26. package/dist/reporting/render-markdown-report.js +13 -0
  27. package/dist/reporting/render-text-report.js +11 -0
  28. package/dist/rules/rule-catalog.js +108 -0
  29. package/dist/run-cli.js +143 -7
  30. package/examples/README.md +94 -94
  31. package/examples/codex-doctor-generic-mcp/.mcp.json +10 -10
  32. package/examples/codex-doctor-generic-mcp/server.js +1 -1
  33. package/examples/codex-doctor-risky/.codex-plugin/plugin.json +7 -7
  34. package/examples/codex-doctor-risky/.mcp.json +12 -12
  35. package/examples/codex-doctor-risky/mock-server.js +1 -1
  36. package/examples/codex-doctor-runtime/.codex-plugin/plugin.json +8 -8
  37. package/examples/codex-doctor-runtime/.mcp.json +9 -9
  38. package/examples/codex-doctor-runtime/mock-server.js +237 -237
  39. package/examples/codex-doctor-runtime/skills/context-check/SKILL.md +7 -7
  40. package/examples/codex-doctor-starter/.codex-plugin/plugin.json +7 -7
  41. package/examples/codex-doctor-starter/skills/repo-scan/SKILL.md +7 -7
  42. package/package.json +1 -1
@@ -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"]
@@ -125,6 +152,52 @@ const publicSchemaDefinitions = [
125
152
  }
126
153
  }
127
154
  },
155
+ {
156
+ id: "doctor.registry.readiness.json",
157
+ command: "codex-plugin-doctor registry check <server.json|directory> --json",
158
+ outputKind: "mcp-registry-readiness",
159
+ required: [
160
+ "schemaVersion",
161
+ "kind",
162
+ "generatedAt",
163
+ "source",
164
+ "target",
165
+ "status",
166
+ "scorecard",
167
+ "installability",
168
+ "findings"
169
+ ],
170
+ properties: {
171
+ source: {
172
+ type: "string",
173
+ enum: ["file", "registry"]
174
+ },
175
+ status: {
176
+ type: "string",
177
+ enum: ["pass", "warn", "fail"]
178
+ },
179
+ scorecard: {
180
+ type: "object",
181
+ required: [
182
+ "metadata",
183
+ "ownership",
184
+ "packageIntegrity",
185
+ "transportReadiness",
186
+ "clientInstallability",
187
+ "overall"
188
+ ],
189
+ additionalProperties: false
190
+ },
191
+ installability: {
192
+ type: "object",
193
+ required: ["codex", "packageTypes", "remoteTransports"],
194
+ additionalProperties: true
195
+ },
196
+ findings: {
197
+ type: "array"
198
+ }
199
+ }
200
+ },
128
201
  {
129
202
  id: "doctor.audit.json",
130
203
  command: "codex-plugin-doctor audit --installed --json",
@@ -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 {