codex-plugin-doctor 1.50.0 → 1.52.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 (49) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +17 -6
  3. package/dist/core/bounded-http-client.d.ts +23 -0
  4. package/dist/core/bounded-http-client.js +240 -0
  5. package/dist/core/mcp-conformance.d.ts +2 -0
  6. package/dist/core/mcp-conformance.js +215 -0
  7. package/dist/core/output-contract.js +104 -5
  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 +111 -7
  12. package/dist/core/remote-mcp-probe.d.ts +16 -0
  13. package/dist/core/remote-mcp-probe.js +226 -0
  14. package/dist/core/remote-network-policy.d.ts +24 -0
  15. package/dist/core/remote-network-policy.js +125 -0
  16. package/dist/core/remote-oauth-readiness.d.ts +12 -0
  17. package/dist/core/remote-oauth-readiness.js +295 -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 +36 -2
  22. package/dist/core/runtime-policy.js +5 -1
  23. package/dist/core/runtime-probe.d.ts +11 -2
  24. package/dist/core/runtime-probe.js +375 -70
  25. package/dist/core/runtime-transcript.js +13 -3
  26. package/dist/core/validate-plugin.js +32 -1
  27. package/dist/domain/types.d.ts +47 -0
  28. package/dist/index.d.ts +4 -0
  29. package/dist/index.js +4 -0
  30. package/dist/mcp/generic-mcp-doctor.d.ts +10 -2
  31. package/dist/mcp/generic-mcp-doctor.js +44 -13
  32. package/dist/reporting/render-markdown-report.js +44 -13
  33. package/dist/reporting/render-text-report.js +39 -22
  34. package/dist/rules/rule-catalog.js +253 -1
  35. package/dist/run-cli.js +137 -9
  36. package/dist/security/security-audit.js +34 -7
  37. package/examples/README.md +94 -94
  38. package/examples/codex-doctor-generic-mcp/.mcp.json +10 -10
  39. package/examples/codex-doctor-generic-mcp/server.js +1 -1
  40. package/examples/codex-doctor-risky/.codex-plugin/plugin.json +7 -7
  41. package/examples/codex-doctor-risky/.mcp.json +12 -12
  42. package/examples/codex-doctor-risky/mock-server.js +1 -1
  43. package/examples/codex-doctor-runtime/.codex-plugin/plugin.json +8 -8
  44. package/examples/codex-doctor-runtime/.mcp.json +9 -9
  45. package/examples/codex-doctor-runtime/mock-server.js +237 -237
  46. package/examples/codex-doctor-runtime/skills/context-check/SKILL.md +7 -7
  47. package/examples/codex-doctor-starter/.codex-plugin/plugin.json +7 -7
  48. package/examples/codex-doctor-starter/skills/repo-scan/SKILL.md +7 -7
  49. package/package.json +1 -1
@@ -1,11 +1,101 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { packageVersion } from "../version.js";
3
3
  import { ruleCatalog } from "../rules/rule-catalog.js";
4
+ const runtimeCapabilityStatusSchema = {
5
+ type: "string",
6
+ enum: ["pass", "fail", "warn", "skipped", "unsupported"]
7
+ };
8
+ const runtimeConformanceSchema = {
9
+ type: "object",
10
+ properties: {
11
+ protocolVersion: {
12
+ type: ["string", "null"]
13
+ },
14
+ profile: {
15
+ type: ["string", "null"],
16
+ enum: ["legacy", "2025-11-25", "future-compatible", null]
17
+ },
18
+ capabilityConsistency: runtimeCapabilityStatusSchema,
19
+ taskDeclarations: runtimeCapabilityStatusSchema,
20
+ tasksList: runtimeCapabilityStatusSchema,
21
+ schemaDialect: runtimeCapabilityStatusSchema,
22
+ overall: {
23
+ type: "string",
24
+ enum: ["pass", "warn", "fail", "skipped"]
25
+ }
26
+ },
27
+ required: [
28
+ "protocolVersion",
29
+ "profile",
30
+ "capabilityConsistency",
31
+ "taskDeclarations",
32
+ "tasksList",
33
+ "schemaDialect",
34
+ "overall"
35
+ ],
36
+ additionalProperties: false
37
+ };
38
+ const remoteRuntimeScorecardSchema = {
39
+ type: "object",
40
+ properties: {
41
+ transport: runtimeCapabilityStatusSchema,
42
+ networkSafety: runtimeCapabilityStatusSchema,
43
+ initialize: runtimeCapabilityStatusSchema,
44
+ contentType: runtimeCapabilityStatusSchema,
45
+ session: {
46
+ type: "string",
47
+ enum: ["absent", "present-valid", "present-invalid"]
48
+ },
49
+ protocolHeaders: runtimeCapabilityStatusSchema,
50
+ authorization: runtimeCapabilityStatusSchema,
51
+ overall: {
52
+ type: "string",
53
+ enum: ["pass", "warn", "fail", "skipped"]
54
+ }
55
+ },
56
+ required: ["transport", "networkSafety", "initialize", "contentType", "session", "protocolHeaders", "authorization", "overall"],
57
+ additionalProperties: false
58
+ };
59
+ const runtimeScorecardSchema = {
60
+ type: "object",
61
+ properties: {
62
+ initialize: runtimeCapabilityStatusSchema,
63
+ toolsList: runtimeCapabilityStatusSchema,
64
+ toolsCall: runtimeCapabilityStatusSchema,
65
+ resourcesList: runtimeCapabilityStatusSchema,
66
+ resourceRead: runtimeCapabilityStatusSchema,
67
+ resourceTemplatesList: runtimeCapabilityStatusSchema,
68
+ promptsList: runtimeCapabilityStatusSchema,
69
+ promptGet: runtimeCapabilityStatusSchema,
70
+ conformance: runtimeConformanceSchema,
71
+ remote: remoteRuntimeScorecardSchema
72
+ },
73
+ required: [
74
+ "initialize",
75
+ "toolsList",
76
+ "toolsCall",
77
+ "resourcesList",
78
+ "resourceRead",
79
+ "resourceTemplatesList",
80
+ "promptsList",
81
+ "promptGet"
82
+ ],
83
+ additionalProperties: false
84
+ };
4
85
  const publicSchemaDefinitions = [
5
86
  {
6
87
  id: "doctor.check.json",
7
88
  command: "codex-plugin-doctor check <path> --json",
8
- required: ["schemaVersion", "generatedAt", "summary", "findings"]
89
+ required: ["schemaVersion", "generatedAt", "summary", "findings"],
90
+ properties: {
91
+ summary: {
92
+ type: "object",
93
+ properties: {
94
+ runtimeScorecard: runtimeScorecardSchema
95
+ },
96
+ additionalProperties: true
97
+ }
98
+ }
9
99
  },
10
100
  {
11
101
  id: "doctor.installed.check.json",
@@ -27,7 +117,13 @@ const publicSchemaDefinitions = [
27
117
  id: "doctor.mcp.json",
28
118
  command: "codex-plugin-doctor mcp <path> --json",
29
119
  outputKind: "doctor.mcp.healthcheck",
30
- required: ["schemaVersion", "kind", "generatedAt", "targetPath", "status", "serverCount", "findings", "security", "compatibility"]
120
+ required: ["schemaVersion", "kind", "generatedAt", "targetPath", "status", "serverCount", "findings", "security", "compatibility"],
121
+ properties: {
122
+ runtimeScorecard: {
123
+ ...runtimeScorecardSchema,
124
+ description: "Present only when codex-plugin-doctor mcp <path> --runtime is used."
125
+ }
126
+ }
31
127
  },
32
128
  {
33
129
  id: "doctor.audit.json",
@@ -271,7 +367,7 @@ function contractRules(rules) {
271
367
  }))
272
368
  .sort((left, right) => left.id.localeCompare(right.id));
273
369
  }
274
- function buildSchema(id, outputKind, required) {
370
+ function buildSchema(id, outputKind, required, schemaProperties = {}) {
275
371
  const properties = {
276
372
  schemaVersion: {
277
373
  const: "1.0.0"
@@ -288,7 +384,10 @@ function buildSchema(id, outputKind, required) {
288
384
  title: id,
289
385
  type: "object",
290
386
  required: [...new Set(["schemaVersion", ...required])],
291
- properties,
387
+ properties: {
388
+ ...properties,
389
+ ...schemaProperties
390
+ },
292
391
  additionalProperties: true
293
392
  };
294
393
  }
@@ -301,7 +400,7 @@ function buildSchemas() {
301
400
  schemaVersion: "1.0.0",
302
401
  stability: "stable-through-1.0",
303
402
  outputKind,
304
- schema: buildSchema(definition.id, outputKind, definition.required ?? [])
403
+ schema: buildSchema(definition.id, outputKind, definition.required ?? [], definition.properties)
305
404
  };
306
405
  });
307
406
  }
@@ -41,6 +41,8 @@ export interface BuildReleaseCheckOptions {
41
41
  env?: Record<string, string | undefined>;
42
42
  platform?: NodeJS.Platform;
43
43
  runtime?: boolean;
44
+ allowNetwork?: boolean;
45
+ allowLocalNetwork?: boolean;
44
46
  runtimeSandbox?: RuntimeSandboxMode;
45
47
  runCheck?: (targetPath: string, options: CheckOptions) => Promise<CheckResult>;
46
48
  }
@@ -106,6 +106,8 @@ export async function buildReleaseCheck(targetPath, options = {}) {
106
106
  const runtimeProbeEnabled = options.runtime ?? false;
107
107
  const validationResult = await (options.runCheck ?? validatePlugin)(resolvedPath, {
108
108
  runtime: runtimeProbeEnabled,
109
+ ...(options.allowNetwork ? { allowNetwork: true } : {}),
110
+ ...(options.allowLocalNetwork ? { allowLocalNetwork: true } : {}),
109
111
  ...(options.runtimeSandbox ? { runtimeSandbox: options.runtimeSandbox } : {})
110
112
  });
111
113
  const securityResult = await buildSecurityAudit(resolvedPath);
@@ -111,6 +111,8 @@ export interface BuildDoctorReleaseEvidenceOptions {
111
111
  requireRuntimeApproval?: boolean;
112
112
  runtimeApprovalDigest?: string | null;
113
113
  runtime?: boolean;
114
+ allowNetwork?: boolean;
115
+ allowLocalNetwork?: boolean;
114
116
  sandbox?: RuntimeSandboxMode;
115
117
  environment?: CompatibilityEnvironment;
116
118
  runCheck?: (targetPath: string, options?: CheckOptions) => Promise<CheckResult>;
@@ -192,17 +192,126 @@ function signReleaseEvidence(report, signingKey, keyHint) {
192
192
  keyHint
193
193
  };
194
194
  }
195
+ function buildRuntimeApprovalFailureReport(rootPath, runtimePlan, runtimeApproval, options) {
196
+ const generatedAt = new Date().toISOString();
197
+ const emptyDigest = sha256("");
198
+ const failureMessage = "Release evidence checks were not run because runtime approval did not pass.";
199
+ const report = {
200
+ schemaVersion: "1.0.0",
201
+ kind: "doctor.release.evidence",
202
+ generatedAt,
203
+ version: packageVersion,
204
+ targetPath: rootPath,
205
+ status: "fail",
206
+ exitCode: 1,
207
+ releaseReady: false,
208
+ summary: {
209
+ attestation: "fail",
210
+ attestationVerification: "fail",
211
+ corpus: "fail",
212
+ performance: "fail",
213
+ releaseGates: "fail",
214
+ runtimeApproval: "fail",
215
+ security: "fail",
216
+ trust: "fail"
217
+ },
218
+ releaseGates: {
219
+ status: "fail",
220
+ checks: [{ id: "runtime.approval", status: "fail", message: runtimeApproval.message }]
221
+ },
222
+ runtimeApproval,
223
+ ...(options.runtime ? { execution: runtimePlan.execution } : {}),
224
+ package: { name: null, version: null, private: null },
225
+ git: { commit: null, tag: null, dirty: null },
226
+ attestation: {
227
+ schemaVersion: "1.0.0",
228
+ kind: "doctor.attestation",
229
+ generatedAt,
230
+ version: packageVersion,
231
+ targetPath: rootPath,
232
+ subject: { name: "unavailable", version: null, description: null },
233
+ packageFingerprint: { algorithm: "sha256", digest: emptyDigest, files: { total: 0, bytes: 0 } },
234
+ reportDigest: { algorithm: "sha256", digest: emptyDigest },
235
+ summary: {
236
+ status: "fail",
237
+ validation: { status: "fail", findingCount: 0 },
238
+ security: { status: "fail", score: 0, findingCount: 0 },
239
+ compatibility: { failedClients: [] },
240
+ trust: { status: "fail", score: 0, findingCount: 0 },
241
+ recommendations: { actionCount: 0 }
242
+ },
243
+ verification: { recomputeCommand: "", notes: [failureMessage] },
244
+ signature: { status: "unsigned", reason: failureMessage }
245
+ },
246
+ attestationVerification: {
247
+ schemaVersion: "1.0.0",
248
+ kind: "doctor.attestation.verification",
249
+ generatedAt,
250
+ artifactPath: "inline:doctor.release-evidence.attestation",
251
+ targetPath: rootPath,
252
+ status: "fail",
253
+ exitCode: 1,
254
+ summary: { packageFingerprint: "fail", reportDigest: "fail", signature: "fail" },
255
+ unsignedFields: [],
256
+ checks: [{ id: "runtime.approval", status: "fail", message: runtimeApproval.message }]
257
+ },
258
+ corpus: {
259
+ schemaVersion: "1.0.0",
260
+ kind: "doctor.validation.corpus",
261
+ generatedAt,
262
+ version: packageVersion,
263
+ summary: { status: "fail", caseCount: 0, passedExpectations: 0, failedExpectations: 0, runtimeCases: 0 },
264
+ cases: []
265
+ },
266
+ performance: {
267
+ schemaVersion: "1.0.0",
268
+ kind: "doctor.perf",
269
+ generatedAt,
270
+ targetPath: rootPath,
271
+ status: "fail",
272
+ exitCode: 1,
273
+ summary: {
274
+ stageCount: 0,
275
+ slowestStage: "total",
276
+ totalDurationMs: 0,
277
+ validationStatus: "fail",
278
+ securityStatus: "fail",
279
+ trustScore: 0,
280
+ compatibilityFailures: 0,
281
+ thresholdFailures: 0
282
+ },
283
+ stages: [],
284
+ thresholds: []
285
+ },
286
+ security: { status: "fail", score: 0, findingCounts: { fail: 0, warn: 0, total: 0 } },
287
+ trust: { status: "fail", score: 0, findingCounts: { fail: 0, warn: 0, total: 0 } }
288
+ };
289
+ return {
290
+ ...report,
291
+ evidenceSignature: signReleaseEvidence(report, options.signingKey, `env:${options.signingKeyEnv}`)
292
+ };
293
+ }
195
294
  export async function buildDoctorReleaseEvidenceReport(targetPath, options) {
196
295
  const rootPath = path.resolve(targetPath);
296
+ const runtimePlan = await buildDoctorRuntimePlan(rootPath, new Date().toISOString(), options.sandbox ? { sandbox: options.sandbox } : {});
297
+ const runtimeApproval = evaluateRuntimeApproval(runtimePlan, {
298
+ required: options.requireRuntimeApproval ?? false,
299
+ approvedDigest: options.runtimeApprovalDigest
300
+ });
301
+ if (!runtimeApprovalPassed(runtimeApproval)) {
302
+ return buildRuntimeApprovalFailureReport(rootPath, runtimePlan, runtimeApproval, options);
303
+ }
197
304
  const security = await buildSecurityAudit(rootPath);
198
305
  const runCheck = options.runCheck ?? validatePlugin;
199
306
  const checkOptions = options.runtime
200
307
  ? {
201
308
  runtime: true,
309
+ ...(options.allowNetwork ? { allowNetwork: true } : {}),
310
+ ...(options.allowLocalNetwork ? { allowLocalNetwork: true } : {}),
202
311
  ...(options.sandbox ? { runtimeSandbox: options.sandbox } : {})
203
312
  }
204
313
  : {};
205
- const [attestation, corpus, performance, trust, packageMetadata, git, runtimePlan] = await Promise.all([
314
+ const [attestation, corpus, performance, trust, packageMetadata, git] = await Promise.all([
206
315
  buildDoctorAttestation(rootPath, {
207
316
  signingKey: options.signingKey,
208
317
  signingKeyHint: `env:${options.signingKeyEnv}`,
@@ -216,8 +325,7 @@ export async function buildDoctorReleaseEvidenceReport(targetPath, options) {
216
325
  }),
217
326
  buildTrustScore(rootPath, { securityAudit: security }),
218
327
  readPackageMetadata(rootPath),
219
- readGitMetadata(rootPath),
220
- buildDoctorRuntimePlan(rootPath, new Date().toISOString(), options.sandbox ? { sandbox: options.sandbox } : {})
328
+ readGitMetadata(rootPath)
221
329
  ]);
222
330
  const normalizedPackageMetadata = {
223
331
  name: packageMetadata.name ?? attestation.subject.name,
@@ -229,10 +337,6 @@ export async function buildDoctorReleaseEvidenceReport(targetPath, options) {
229
337
  artifactPath: "inline:doctor.release-evidence.attestation"
230
338
  });
231
339
  const releaseGates = buildReleaseGateReport(git, options);
232
- const runtimeApproval = evaluateRuntimeApproval(runtimePlan, {
233
- required: options.requireRuntimeApproval ?? false,
234
- approvedDigest: options.runtimeApprovalDigest
235
- });
236
340
  const partialReport = {
237
341
  schemaVersion: "1.0.0",
238
342
  kind: "doctor.release.evidence",
@@ -0,0 +1,16 @@
1
+ import { type BoundedHttpRequestOptions, type BoundedHttpResponse } from "./bounded-http-client.js";
2
+ import { type RemoteLookup } from "./remote-network-policy.js";
3
+ import type { Finding, RemoteRuntimeScorecard } from "../domain/types.js";
4
+ export type RemoteMcpRequest = (rawUrl: string, options?: BoundedHttpRequestOptions) => Promise<BoundedHttpResponse>;
5
+ export interface RemoteMcpProbeOptions {
6
+ allowNetwork?: boolean;
7
+ allowLocalNetwork?: boolean;
8
+ requestTimeoutMs?: number;
9
+ lookup?: RemoteLookup;
10
+ request?: RemoteMcpRequest;
11
+ }
12
+ export interface RemoteMcpProbeResult {
13
+ findings: Finding[];
14
+ scorecard: RemoteRuntimeScorecard;
15
+ }
16
+ export declare function probeRemoteMcpServer(serverName: string, rawUrl: string, options?: RemoteMcpProbeOptions): Promise<RemoteMcpProbeResult>;
@@ -0,0 +1,226 @@
1
+ import { BoundedHttpError, requestBoundedHttp } from "./bounded-http-client.js";
2
+ import { RemoteNetworkPolicyError } from "./remote-network-policy.js";
3
+ import { checkRemoteOAuthReadiness } from "./remote-oauth-readiness.js";
4
+ import { inspectRemoteMcpUrl } from "./remote-url-policy.js";
5
+ import { packageVersion } from "../version.js";
6
+ const MCP_PROTOCOL_VERSION = "2025-11-25";
7
+ function createScorecard() {
8
+ return {
9
+ transport: "skipped",
10
+ networkSafety: "skipped",
11
+ initialize: "skipped",
12
+ contentType: "skipped",
13
+ session: "absent",
14
+ protocolHeaders: "skipped",
15
+ authorization: "skipped",
16
+ overall: "skipped"
17
+ };
18
+ }
19
+ function failure(id, message, impact, suggestedFix) {
20
+ return { id, severity: "fail", message, impact, suggestedFix };
21
+ }
22
+ function finalize(scorecard, findings) {
23
+ scorecard.overall = findings.some((finding) => finding.severity === "fail")
24
+ ? "fail"
25
+ : findings.some((finding) => finding.severity === "warn")
26
+ ? "warn"
27
+ : scorecard.initialize === "pass" || scorecard.authorization === "pass"
28
+ ? "pass"
29
+ : "skipped";
30
+ return { findings, scorecard };
31
+ }
32
+ function isPlainObject(value) {
33
+ return typeof value === "object" && value !== null && !Array.isArray(value);
34
+ }
35
+ function responseHeader(headers, name) {
36
+ const value = headers[name];
37
+ return typeof value === "string" ? value : null;
38
+ }
39
+ function mediaType(value) {
40
+ return value?.split(";", 1)[0]?.trim().toLowerCase() ?? null;
41
+ }
42
+ function parseJsonObject(source) {
43
+ try {
44
+ const parsed = JSON.parse(source);
45
+ return isPlainObject(parsed) ? parsed : null;
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ function isInitializeResponseCandidate(message) {
52
+ return message.id === 1;
53
+ }
54
+ function findSseInitializeResponse(body) {
55
+ const text = body.toString("utf8").replace(/\r\n/g, "\n");
56
+ let offset = 0;
57
+ while (offset < text.length) {
58
+ const boundary = text.indexOf("\n\n", offset);
59
+ if (boundary === -1) {
60
+ return null;
61
+ }
62
+ const data = text.slice(offset, boundary).split("\n")
63
+ .filter((line) => line.startsWith("data:"))
64
+ .map((line) => line.slice(5).replace(/^ /, ""));
65
+ const message = data.length > 0 ? parseJsonObject(data.join("\n")) : null;
66
+ if (message && isInitializeResponseCandidate(message)) {
67
+ return message;
68
+ }
69
+ offset = boundary + 2;
70
+ }
71
+ return null;
72
+ }
73
+ function parseInitializeResponse(body, contentType) {
74
+ return contentType === "text/event-stream"
75
+ ? findSseInitializeResponse(body)
76
+ : parseJsonObject(body.toString("utf8"));
77
+ }
78
+ function isValidInitializeResponse(message) {
79
+ return (message.jsonrpc === "2.0" &&
80
+ message.id === 1 &&
81
+ isPlainObject(message.result) &&
82
+ message.result.protocolVersion === MCP_PROTOCOL_VERSION &&
83
+ isPlainObject(message.result.capabilities) &&
84
+ isPlainObject(message.result.serverInfo) &&
85
+ typeof message.result.serverInfo.name === "string" &&
86
+ typeof message.result.serverInfo.version === "string");
87
+ }
88
+ function validSessionId(value) {
89
+ return value !== null && /^[\x21-\x7e]+$/.test(value);
90
+ }
91
+ function transportFailureId(error) {
92
+ if (error instanceof BoundedHttpError) {
93
+ if (error.code === "REMOTE_HTTP_TIMEOUT")
94
+ return "plugin.runtime.remote.transport.timeout";
95
+ if (error.code === "REMOTE_HTTP_RESPONSE_TOO_LARGE")
96
+ return "plugin.runtime.remote.transport.response_too_large";
97
+ }
98
+ return "plugin.runtime.remote.transport.failed";
99
+ }
100
+ function transportStatus(error) {
101
+ return error instanceof RemoteNetworkPolicyError
102
+ ? "skipped"
103
+ : "fail";
104
+ }
105
+ export async function probeRemoteMcpServer(serverName, rawUrl, options = {}) {
106
+ const scorecard = createScorecard();
107
+ const findings = [];
108
+ const request = options.request ?? requestBoundedHttp;
109
+ if (!options.allowNetwork) {
110
+ scorecard.networkSafety = "fail";
111
+ findings.push(failure("plugin.runtime.remote.network_not_approved", `The remote MCP server \`${serverName}\` was not contacted because network probing is not approved.`, "Remote MCP initialization can create outbound network traffic and must be explicitly approved.", "Enable remote network probing only after reviewing the server endpoint."));
112
+ return finalize(scorecard, findings);
113
+ }
114
+ const inspection = inspectRemoteMcpUrl(rawUrl);
115
+ if (inspection.issues.length > 0) {
116
+ scorecard.networkSafety = "fail";
117
+ findings.push(failure("plugin.runtime.remote.url.invalid", `The remote MCP server \`${serverName}\` has an unsafe or unsupported endpoint URL.`, "Unsafe remote endpoint URLs can bypass network controls or expose credentials.", "Use an absolute HTTP or HTTPS endpoint without credentials, query parameters, fragments, or IP literals."));
118
+ return finalize(scorecard, findings);
119
+ }
120
+ scorecard.networkSafety = "pass";
121
+ const initializeBody = JSON.stringify({
122
+ jsonrpc: "2.0",
123
+ id: 1,
124
+ method: "initialize",
125
+ params: {
126
+ protocolVersion: MCP_PROTOCOL_VERSION,
127
+ capabilities: {},
128
+ clientInfo: { name: "Codex Plugin Doctor", version: packageVersion }
129
+ }
130
+ });
131
+ let initializeResponse;
132
+ try {
133
+ initializeResponse = await request(rawUrl, {
134
+ allowLocalNetwork: options.allowLocalNetwork,
135
+ lookup: options.lookup,
136
+ timeoutMs: options.requestTimeoutMs,
137
+ method: "POST",
138
+ body: initializeBody,
139
+ headers: {
140
+ Accept: "application/json, text/event-stream",
141
+ "Content-Type": "application/json"
142
+ },
143
+ stopAfter: (body) => findSseInitializeResponse(body) !== null
144
+ });
145
+ }
146
+ catch (error) {
147
+ scorecard.transport = transportStatus(error);
148
+ if (error instanceof RemoteNetworkPolicyError) {
149
+ scorecard.networkSafety = "fail";
150
+ }
151
+ findings.push(failure(transportFailureId(error), `The remote MCP server \`${serverName}\` could not complete a bounded initialize request.`, "A failed transport prevents safe MCP protocol negotiation.", "Verify the endpoint is reachable and complies with the configured remote network policy."));
152
+ return finalize(scorecard, findings);
153
+ }
154
+ scorecard.transport = "pass";
155
+ if (initializeResponse.statusCode === 401) {
156
+ const challenge = initializeResponse.headers["www-authenticate"];
157
+ const readiness = await checkRemoteOAuthReadiness(inspection.sanitizedUrl ?? rawUrl, challenge === undefined ? [] : [challenge], {
158
+ request,
159
+ requestOptions: {
160
+ allowLocalNetwork: options.allowLocalNetwork,
161
+ lookup: options.lookup,
162
+ timeoutMs: options.requestTimeoutMs
163
+ }
164
+ });
165
+ scorecard.authorization = readiness.status;
166
+ findings.push(...readiness.findings);
167
+ return finalize(scorecard, findings);
168
+ }
169
+ if (initializeResponse.statusCode !== 200) {
170
+ scorecard.initialize = "fail";
171
+ findings.push(failure("plugin.runtime.remote.http_status.invalid", `The remote MCP server \`${serverName}\` returned an unexpected initialize HTTP status.`, "Streamable HTTP MCP initialization requires a successful response before protocol negotiation can continue.", "Return HTTP 200 for initialize, or configure authorization before probing a protected endpoint."));
172
+ return finalize(scorecard, findings);
173
+ }
174
+ const contentType = mediaType(responseHeader(initializeResponse.headers, "content-type"));
175
+ if (contentType !== "application/json" && contentType !== "text/event-stream") {
176
+ scorecard.contentType = "fail";
177
+ scorecard.initialize = "fail";
178
+ findings.push(failure("plugin.runtime.remote.content_type.invalid", `The remote MCP server \`${serverName}\` returned an unsupported initialize content type.`, "MCP initialization responses must be JSON or Server-Sent Events so the JSON-RPC result can be validated.", "Return application/json or text/event-stream with a JSON-RPC initialize response."));
179
+ return finalize(scorecard, findings);
180
+ }
181
+ scorecard.contentType = "pass";
182
+ const sessionId = responseHeader(initializeResponse.headers, "mcp-session-id");
183
+ if (sessionId !== null && !validSessionId(sessionId)) {
184
+ scorecard.session = "present-invalid";
185
+ scorecard.initialize = "fail";
186
+ findings.push(failure("plugin.runtime.remote.session.invalid", `The remote MCP server \`${serverName}\` returned an invalid MCP session header.`, "Invalid session identifiers cannot be safely replayed on the initialized notification.", "Return MCP-Session-Id only as visible ASCII characters."));
187
+ return finalize(scorecard, findings);
188
+ }
189
+ if (sessionId !== null) {
190
+ scorecard.session = "present-valid";
191
+ }
192
+ const initializeMessage = parseInitializeResponse(initializeResponse.body, contentType);
193
+ if (!initializeMessage || !isValidInitializeResponse(initializeMessage)) {
194
+ scorecard.initialize = "fail";
195
+ findings.push(failure("plugin.runtime.remote.initialize.invalid", `The remote MCP server \`${serverName}\` returned an invalid initialize JSON-RPC result.`, "A malformed initialize result prevents protocol version negotiation.", "Return a JSON-RPC 2.0 result with id 1 and protocol version 2025-11-25."));
196
+ return finalize(scorecard, findings);
197
+ }
198
+ scorecard.initialize = "pass";
199
+ try {
200
+ const initializedResponse = await request(rawUrl, {
201
+ allowLocalNetwork: options.allowLocalNetwork,
202
+ lookup: options.lookup,
203
+ timeoutMs: options.requestTimeoutMs,
204
+ method: "POST",
205
+ body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
206
+ headers: {
207
+ Accept: "application/json, text/event-stream",
208
+ "Content-Type": "application/json",
209
+ "MCP-Protocol-Version": MCP_PROTOCOL_VERSION,
210
+ ...(sessionId === null ? {} : { "MCP-Session-Id": sessionId })
211
+ }
212
+ });
213
+ if (initializedResponse.statusCode < 200 || initializedResponse.statusCode >= 300) {
214
+ scorecard.protocolHeaders = "fail";
215
+ 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
+ return finalize(scorecard, findings);
217
+ }
218
+ }
219
+ catch {
220
+ scorecard.protocolHeaders = "fail";
221
+ findings.push(failure("plugin.runtime.remote.initialized.failed", `The remote MCP server \`${serverName}\` could not receive 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."));
222
+ return finalize(scorecard, findings);
223
+ }
224
+ scorecard.protocolHeaders = "pass";
225
+ return finalize(scorecard, findings);
226
+ }
@@ -0,0 +1,24 @@
1
+ export interface ResolvedRemoteTarget {
2
+ hostname: string;
3
+ address: string;
4
+ family: 4 | 6;
5
+ local: boolean;
6
+ }
7
+ export interface RemoteLookup {
8
+ (hostname: string, options: {
9
+ all: true;
10
+ verbatim: true;
11
+ }): Promise<Array<{
12
+ address: string;
13
+ family: 4 | 6;
14
+ }>>;
15
+ }
16
+ export interface ResolveRemoteTargetOptions {
17
+ allowLocalNetwork?: boolean;
18
+ lookup?: RemoteLookup;
19
+ }
20
+ export declare class RemoteNetworkPolicyError extends Error {
21
+ readonly code: "REMOTE_TARGET_EMPTY" | "REMOTE_TARGET_FORBIDDEN" | "REMOTE_TARGET_INVALID_ADDRESS" | "REMOTE_TARGET_LOOKUP_FAILED" | "REMOTE_TARGET_URL_INVALID";
22
+ constructor(code: "REMOTE_TARGET_EMPTY" | "REMOTE_TARGET_FORBIDDEN" | "REMOTE_TARGET_INVALID_ADDRESS" | "REMOTE_TARGET_LOOKUP_FAILED" | "REMOTE_TARGET_URL_INVALID", message: string);
23
+ }
24
+ export declare function resolveRemoteTarget(url: URL, options?: ResolveRemoteTargetOptions): Promise<ResolvedRemoteTarget>;