codex-plugin-doctor 1.51.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.
Files changed (38) hide show
  1. package/README.md +17 -2
  2. package/dist/core/bounded-http-client.d.ts +23 -0
  3. package/dist/core/bounded-http-client.js +241 -0
  4. package/dist/core/doctor-export-bundle.js +3 -1
  5. package/dist/core/output-contract.js +50 -1
  6. package/dist/core/release-check.d.ts +4 -0
  7. package/dist/core/release-check.js +4 -0
  8. package/dist/core/release-evidence.d.ts +4 -0
  9. package/dist/core/release-evidence.js +113 -7
  10. package/dist/core/remote-mcp-probe.d.ts +17 -0
  11. package/dist/core/remote-mcp-probe.js +284 -0
  12. package/dist/core/remote-network-policy.d.ts +24 -0
  13. package/dist/core/remote-network-policy.js +125 -0
  14. package/dist/core/remote-oauth-readiness.d.ts +12 -0
  15. package/dist/core/remote-oauth-readiness.js +295 -0
  16. package/dist/core/remote-transport-reliability.d.ts +18 -0
  17. package/dist/core/remote-transport-reliability.js +243 -0
  18. package/dist/core/remote-url-policy.d.ts +7 -0
  19. package/dist/core/remote-url-policy.js +49 -0
  20. package/dist/core/runtime-plan.d.ts +3 -0
  21. package/dist/core/runtime-plan.js +34 -2
  22. package/dist/core/runtime-policy.js +5 -1
  23. package/dist/core/runtime-probe.d.ts +10 -1
  24. package/dist/core/runtime-probe.js +89 -29
  25. package/dist/core/sse-observation.d.ts +7 -0
  26. package/dist/core/sse-observation.js +67 -0
  27. package/dist/core/validate-plugin.js +35 -3
  28. package/dist/domain/types.d.ts +26 -0
  29. package/dist/index.d.ts +4 -0
  30. package/dist/index.js +4 -0
  31. package/dist/mcp/generic-mcp-doctor.d.ts +4 -0
  32. package/dist/mcp/generic-mcp-doctor.js +15 -5
  33. package/dist/reporting/render-markdown-report.js +38 -12
  34. package/dist/reporting/render-text-report.js +32 -10
  35. package/dist/rules/rule-catalog.js +289 -1
  36. package/dist/run-cli.js +172 -5
  37. package/dist/security/security-audit.js +34 -7
  38. package/package.json +1 -1
@@ -0,0 +1,295 @@
1
+ import { isIP } from "node:net";
2
+ import { requestBoundedHttp } from "./bounded-http-client.js";
3
+ const MAX_DISCOVERY_CANDIDATES = 4;
4
+ const MAX_DISCOVERY_TIMEOUT_MS = 3_000;
5
+ function failure(id) {
6
+ return {
7
+ status: "fail",
8
+ findings: [{
9
+ id,
10
+ severity: "fail",
11
+ message: "The remote MCP server authorization metadata could not be validated.",
12
+ impact: "Protected MCP endpoints cannot be safely assessed without valid OAuth discovery metadata.",
13
+ suggestedFix: "Publish valid HTTPS protected-resource and authorization-server metadata without credentials, queries, or fragments."
14
+ }]
15
+ };
16
+ }
17
+ function isPlainObject(value) {
18
+ return typeof value === "object" && value !== null && !Array.isArray(value);
19
+ }
20
+ function mediaType(headers) {
21
+ const value = headers["content-type"];
22
+ const source = Array.isArray(value) ? value[0] : value;
23
+ return typeof source === "string" ? source.split(";", 1)[0]?.trim().toLowerCase() ?? null : null;
24
+ }
25
+ function parseJsonObject(body) {
26
+ try {
27
+ const parsed = JSON.parse(body.toString("utf8"));
28
+ return isPlainObject(parsed) ? parsed : null;
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ function safeHttpsUrl(value) {
35
+ if (typeof value !== "string")
36
+ return null;
37
+ try {
38
+ const url = new URL(value);
39
+ if (url.protocol !== "https:" ||
40
+ !url.hostname ||
41
+ url.username ||
42
+ url.password ||
43
+ url.search ||
44
+ url.hash ||
45
+ url.hostname.startsWith("[") ||
46
+ isIP(url.hostname) !== 0) {
47
+ return null;
48
+ }
49
+ return value;
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ function insertWellKnown(resourceUrl, suffix) {
56
+ const url = new URL(resourceUrl);
57
+ const path = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "");
58
+ return `${url.origin}/.well-known/${suffix}${path}`;
59
+ }
60
+ function oidcDiscoveryUrl(issuer) {
61
+ const url = new URL(issuer);
62
+ return `${url.origin}${url.pathname.replace(/\/$/, "")}/.well-known/openid-configuration`;
63
+ }
64
+ function splitTopLevelCommas(value) {
65
+ const parts = [];
66
+ let start = 0;
67
+ let quoted = false;
68
+ for (let index = 0; index < value.length; index += 1) {
69
+ const character = value[index];
70
+ if (quoted && character === "\\") {
71
+ index += 1;
72
+ if (index >= value.length)
73
+ return null;
74
+ continue;
75
+ }
76
+ if (character === "\"") {
77
+ quoted = !quoted;
78
+ continue;
79
+ }
80
+ if (!quoted && character === ",") {
81
+ parts.push(value.slice(start, index));
82
+ start = index + 1;
83
+ }
84
+ }
85
+ return quoted ? null : [...parts, value.slice(start)];
86
+ }
87
+ function parseAuthParam(value) {
88
+ const match = /^([!#$%&'*+.^`|~\w-]+)[ \t]*=[ \t]*(.*)$/.exec(value);
89
+ if (match === null)
90
+ return null;
91
+ const name = match[1];
92
+ const rawValue = match[2].trim();
93
+ if (rawValue.length === 0)
94
+ return null;
95
+ if (!rawValue.startsWith("\"")) {
96
+ return /[\s"]/u.test(rawValue) ? null : { name, value: rawValue };
97
+ }
98
+ if (!rawValue.endsWith("\"") || rawValue.length === 1)
99
+ return null;
100
+ let decoded = "";
101
+ for (let index = 1; index < rawValue.length - 1; index += 1) {
102
+ const character = rawValue[index];
103
+ if (character === "\\") {
104
+ index += 1;
105
+ if (index >= rawValue.length - 1)
106
+ return null;
107
+ decoded += rawValue[index];
108
+ continue;
109
+ }
110
+ if (character === "\"")
111
+ return null;
112
+ decoded += character;
113
+ }
114
+ return { name, value: decoded };
115
+ }
116
+ function parseChallenge(value) {
117
+ const match = /^([!#$%&'*+.^`|~\w-]+)(?:[ \t]+(.*))?$/.exec(value);
118
+ if (match === null)
119
+ return null;
120
+ const scheme = match[1];
121
+ const credentials = match[2];
122
+ if (credentials === undefined || credentials.trim().length === 0)
123
+ return { scheme, parameter: null };
124
+ const parameter = parseAuthParam(credentials);
125
+ if (parameter !== null)
126
+ return { scheme, parameter };
127
+ return /^[A-Za-z0-9\-._~+/]+={0,}$/.test(credentials) ? { scheme, parameter: null } : null;
128
+ }
129
+ function parseBearerResourceMetadata(headers) {
130
+ const values = [];
131
+ let specified = false;
132
+ for (const header of headers.flatMap((value) => Array.isArray(value) ? value : [value])) {
133
+ const parts = splitTopLevelCommas(header);
134
+ if (parts === null)
135
+ return null;
136
+ let scheme = null;
137
+ for (const part of parts) {
138
+ const token = part.trim();
139
+ if (token.length === 0)
140
+ return null;
141
+ const parameter = parseAuthParam(token);
142
+ if (parameter !== null) {
143
+ if (scheme === null)
144
+ return null;
145
+ if (scheme.toLowerCase() === "bearer" && parameter.name.toLowerCase() === "resource_metadata") {
146
+ specified = true;
147
+ values.push(parameter.value);
148
+ }
149
+ continue;
150
+ }
151
+ const challenge = parseChallenge(token);
152
+ if (challenge === null)
153
+ return null;
154
+ scheme = challenge.scheme;
155
+ if (scheme.toLowerCase() === "bearer" && challenge.parameter?.name.toLowerCase() === "resource_metadata") {
156
+ specified = true;
157
+ values.push(challenge.parameter.value);
158
+ }
159
+ }
160
+ }
161
+ return { specified, values };
162
+ }
163
+ async function fetchMetadata(url, request, requestOptions, deadline) {
164
+ const remainingMs = deadline - Date.now();
165
+ if (remainingMs <= 0)
166
+ return { kind: "unavailable" };
167
+ let timeout;
168
+ try {
169
+ const response = await Promise.race([
170
+ request(url, {
171
+ ...requestOptions,
172
+ timeoutMs: Math.max(1, Math.floor(remainingMs)),
173
+ method: "GET",
174
+ headers: { Accept: "application/json" }
175
+ }),
176
+ new Promise((_resolve, reject) => {
177
+ timeout = setTimeout(() => reject(new Error("OAuth discovery timed out.")), remainingMs);
178
+ })
179
+ ]);
180
+ if (response.statusCode === 404)
181
+ return { kind: "not-found" };
182
+ if (response.statusCode !== 200 || mediaType(response.headers) !== "application/json") {
183
+ return { kind: "unavailable" };
184
+ }
185
+ const metadata = parseJsonObject(response.body);
186
+ return metadata === null ? { kind: "invalid" } : { kind: "ok", metadata };
187
+ }
188
+ catch {
189
+ return { kind: "unavailable" };
190
+ }
191
+ finally {
192
+ if (timeout !== undefined)
193
+ clearTimeout(timeout);
194
+ }
195
+ }
196
+ function validProtectedResourceMetadata(metadata, resourceUrl) {
197
+ if (metadata.resource !== resourceUrl || !Array.isArray(metadata.authorization_servers) || metadata.authorization_servers.length === 0) {
198
+ return null;
199
+ }
200
+ const issuers = metadata.authorization_servers.map(safeHttpsUrl);
201
+ if (!issuers.every((issuer) => issuer !== null))
202
+ return null;
203
+ const uniqueIssuers = [...new Set(issuers)];
204
+ return uniqueIssuers.length <= MAX_DISCOVERY_CANDIDATES ? uniqueIssuers : null;
205
+ }
206
+ function validAuthorizationServerMetadata(metadata, issuer) {
207
+ return (metadata.issuer === issuer &&
208
+ safeHttpsUrl(metadata.authorization_endpoint) !== null &&
209
+ safeHttpsUrl(metadata.token_endpoint) !== null);
210
+ }
211
+ async function authorizationServerIsReady(issuer, request, requestOptions, deadline) {
212
+ const rfc8414 = await fetchMetadata(insertWellKnown(issuer, "oauth-authorization-server"), request, requestOptions, deadline);
213
+ if (rfc8414.kind === "ok")
214
+ return validAuthorizationServerMetadata(rfc8414.metadata, issuer) ? "pass" : "invalid";
215
+ if (rfc8414.kind === "unavailable")
216
+ return "unavailable";
217
+ const oidcUrls = [...new Set([
218
+ insertWellKnown(issuer, "openid-configuration"),
219
+ oidcDiscoveryUrl(issuer)
220
+ ])];
221
+ for (const oidcUrl of oidcUrls) {
222
+ const oidc = await fetchMetadata(oidcUrl, request, requestOptions, deadline);
223
+ if (oidc.kind === "ok")
224
+ return validAuthorizationServerMetadata(oidc.metadata, issuer) ? "pass" : "invalid";
225
+ if (oidc.kind === "unavailable")
226
+ return "unavailable";
227
+ }
228
+ return "invalid";
229
+ }
230
+ export async function checkRemoteOAuthReadiness(resourceUrl, wwwAuthenticate, options = {}) {
231
+ const request = options.request ?? requestBoundedHttp;
232
+ const explicit = parseBearerResourceMetadata(wwwAuthenticate);
233
+ if (explicit === null)
234
+ return failure("plugin.runtime.remote.authorization.metadata.invalid");
235
+ const metadataCandidates = explicit.specified
236
+ ? explicit.values.map(safeHttpsUrl)
237
+ : [
238
+ insertWellKnown(resourceUrl, "oauth-protected-resource"),
239
+ `${new URL(resourceUrl).origin}/.well-known/oauth-protected-resource`
240
+ ].map(safeHttpsUrl);
241
+ if (!metadataCandidates.every((url) => url !== null)) {
242
+ return failure("plugin.runtime.remote.authorization.metadata.invalid");
243
+ }
244
+ const metadataUrls = [...new Set(metadataCandidates)];
245
+ if (metadataUrls.length > MAX_DISCOVERY_CANDIDATES)
246
+ return failure("plugin.runtime.remote.authorization.metadata.invalid");
247
+ const suppliedTimeoutMs = options.requestOptions?.timeoutMs;
248
+ const timeoutMs = typeof suppliedTimeoutMs === "number" && Number.isFinite(suppliedTimeoutMs)
249
+ ? Math.max(0, Math.min(suppliedTimeoutMs, MAX_DISCOVERY_TIMEOUT_MS))
250
+ : MAX_DISCOVERY_TIMEOUT_MS;
251
+ const deadline = Date.now() + timeoutMs;
252
+ let issuers = null;
253
+ let protectedMetadataFetchedInvalid = false;
254
+ let protectedMetadataUnavailable = false;
255
+ for (const metadataUrl of metadataUrls) {
256
+ const reply = await fetchMetadata(metadataUrl, request, options.requestOptions, deadline);
257
+ if (reply.kind === "ok") {
258
+ const candidateIssuers = validProtectedResourceMetadata(reply.metadata, resourceUrl);
259
+ if (candidateIssuers !== null) {
260
+ issuers = candidateIssuers;
261
+ break;
262
+ }
263
+ protectedMetadataFetchedInvalid = true;
264
+ if (!explicit.specified)
265
+ return failure("plugin.runtime.remote.authorization.metadata.invalid");
266
+ continue;
267
+ }
268
+ if (reply.kind === "invalid") {
269
+ protectedMetadataFetchedInvalid = true;
270
+ if (!explicit.specified)
271
+ return failure("plugin.runtime.remote.authorization.metadata.invalid");
272
+ continue;
273
+ }
274
+ protectedMetadataUnavailable = true;
275
+ if (!explicit.specified && reply.kind === "unavailable") {
276
+ return failure("plugin.runtime.remote.authorization.metadata.unavailable");
277
+ }
278
+ }
279
+ if (issuers === null)
280
+ return failure(protectedMetadataFetchedInvalid
281
+ ? "plugin.runtime.remote.authorization.metadata.invalid"
282
+ : protectedMetadataUnavailable
283
+ ? "plugin.runtime.remote.authorization.metadata.unavailable"
284
+ : "plugin.runtime.remote.authorization.metadata.invalid");
285
+ let unavailable = false;
286
+ for (const issuer of issuers) {
287
+ const readiness = await authorizationServerIsReady(issuer, request, options.requestOptions, deadline);
288
+ if (readiness === "pass")
289
+ return { status: "pass", findings: [] };
290
+ unavailable ||= readiness === "unavailable";
291
+ }
292
+ return failure(unavailable
293
+ ? "plugin.runtime.remote.authorization.metadata.unavailable"
294
+ : "plugin.runtime.remote.authorization.metadata.invalid");
295
+ }
@@ -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
+ }
@@ -0,0 +1,7 @@
1
+ export interface RemoteUrlInspection {
2
+ parsedUrl: URL | null;
3
+ sanitizedUrl: string | null;
4
+ isLoopbackHost: boolean;
5
+ issues: Array<"invalid" | "unsupported_scheme" | "credentials" | "query" | "fragment" | "ip_literal" | "insecure_non_loopback">;
6
+ }
7
+ export declare function inspectRemoteMcpUrl(rawUrl: string): RemoteUrlInspection;
@@ -0,0 +1,49 @@
1
+ function isIpLiteral(hostname) {
2
+ return (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(hostname) ||
3
+ /^\[[0-9a-f:.]+\]$/i.test(hostname));
4
+ }
5
+ export function inspectRemoteMcpUrl(rawUrl) {
6
+ let parsedUrl;
7
+ try {
8
+ parsedUrl = new URL(rawUrl);
9
+ }
10
+ catch {
11
+ return {
12
+ parsedUrl: null,
13
+ sanitizedUrl: null,
14
+ isLoopbackHost: false,
15
+ issues: ["invalid"]
16
+ };
17
+ }
18
+ const issues = [];
19
+ const isSupportedScheme = parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:";
20
+ const isAbsoluteHttpUrl = /^https?:\/\//i.test(rawUrl);
21
+ const isLoopbackHost = parsedUrl.hostname.toLowerCase() === "localhost";
22
+ if (!isSupportedScheme) {
23
+ issues.push("unsupported_scheme");
24
+ }
25
+ else if (!isAbsoluteHttpUrl || !parsedUrl.hostname) {
26
+ issues.push("invalid");
27
+ }
28
+ if (parsedUrl.username || parsedUrl.password) {
29
+ issues.push("credentials");
30
+ }
31
+ if (parsedUrl.search || rawUrl.includes("?")) {
32
+ issues.push("query");
33
+ }
34
+ if (parsedUrl.hash || rawUrl.includes("#")) {
35
+ issues.push("fragment");
36
+ }
37
+ if (isIpLiteral(parsedUrl.hostname)) {
38
+ issues.push("ip_literal");
39
+ }
40
+ if (parsedUrl.protocol === "http:" && !isLoopbackHost) {
41
+ issues.push("insecure_non_loopback");
42
+ }
43
+ return {
44
+ parsedUrl,
45
+ sanitizedUrl: `${parsedUrl.origin}${parsedUrl.pathname}`,
46
+ isLoopbackHost,
47
+ issues
48
+ };
49
+ }
@@ -4,6 +4,7 @@ import type { RuntimeExecutionEvidence, RuntimeSandboxMode } from "../domain/typ
4
4
  type RuntimePlanStatus = "pass" | "warn" | "fail";
5
5
  type RuntimePlanRiskLevel = "low" | "medium" | "high";
6
6
  type RuntimePlanTransport = "stdio" | "http";
7
+ type RemoteNetworkClass = "public_https" | "loopback_http" | "loopback_https" | "invalid";
7
8
  export interface RuntimePlanServer {
8
9
  name: string;
9
10
  transport: RuntimePlanTransport;
@@ -11,7 +12,9 @@ export interface RuntimePlanServer {
11
12
  args: string[];
12
13
  cwd: string | null;
13
14
  url: string | null;
15
+ networkClass?: RemoteNetworkClass;
14
16
  probeMethods: string[];
17
+ approvalRequirements?: string[];
15
18
  riskLevel: RuntimePlanRiskLevel;
16
19
  riskReasons: string[];
17
20
  }