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.
- package/LICENSE +21 -21
- package/README.md +33 -17
- package/dist/core/bounded-http-client.js +2 -1
- package/dist/core/doctor-export-bundle.js +3 -1
- package/dist/core/mcp-registry.d.ts +57 -0
- package/dist/core/mcp-registry.js +455 -0
- package/dist/core/output-contract.js +73 -0
- package/dist/core/release-check.d.ts +2 -0
- package/dist/core/release-check.js +2 -0
- package/dist/core/release-evidence.d.ts +2 -0
- package/dist/core/release-evidence.js +2 -0
- package/dist/core/remote-mcp-probe.d.ts +1 -0
- package/dist/core/remote-mcp-probe.js +59 -1
- package/dist/core/remote-transport-reliability.d.ts +18 -0
- package/dist/core/remote-transport-reliability.js +243 -0
- package/dist/core/runtime-probe.d.ts +3 -1
- package/dist/core/runtime-probe.js +18 -1
- package/dist/core/sse-observation.d.ts +7 -0
- package/dist/core/sse-observation.js +67 -0
- package/dist/core/validate-plugin.js +4 -3
- package/dist/domain/types.d.ts +13 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp/generic-mcp-doctor.d.ts +2 -0
- package/dist/mcp/generic-mcp-doctor.js +6 -3
- package/dist/reporting/render-markdown-report.js +13 -0
- package/dist/reporting/render-text-report.js +11 -0
- package/dist/rules/rule-catalog.js +108 -0
- package/dist/run-cli.js +143 -7
- package/examples/README.md +94 -94
- package/examples/codex-doctor-generic-mcp/.mcp.json +10 -10
- package/examples/codex-doctor-generic-mcp/server.js +1 -1
- package/examples/codex-doctor-risky/.codex-plugin/plugin.json +7 -7
- package/examples/codex-doctor-risky/.mcp.json +12 -12
- package/examples/codex-doctor-risky/mock-server.js +1 -1
- package/examples/codex-doctor-runtime/.codex-plugin/plugin.json +8 -8
- package/examples/codex-doctor-runtime/.mcp.json +9 -9
- package/examples/codex-doctor-runtime/mock-server.js +237 -237
- package/examples/codex-doctor-runtime/skills/context-check/SKILL.md +7 -7
- package/examples/codex-doctor-starter/.codex-plugin/plugin.json +7 -7
- package/examples/codex-doctor-starter/skills/repo-scan/SKILL.md +7 -7
- package/package.json +1 -1
|
@@ -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
|
: {};
|
|
@@ -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
|
|
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,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 {
|