codex-plugin-doctor 1.56.0 → 1.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +3 -2
  3. package/dist/compatibility/claude-desktop-install-preview.js +4 -4
  4. package/dist/compatibility/cline-install-preview.js +4 -4
  5. package/dist/compatibility/compatibility-matrix.js +13 -17
  6. package/dist/compatibility/cursor-install-preview.js +4 -4
  7. package/dist/compatibility/windsurf-install-preview.js +4 -4
  8. package/dist/core/doctor-size.d.ts +5 -1
  9. package/dist/core/doctor-size.js +77 -3
  10. package/dist/core/init-plugin.js +2 -2
  11. package/dist/core/inspector-bridge.js +8 -13
  12. package/dist/core/mcp-config-normalizer.d.ts +18 -0
  13. package/dist/core/mcp-config-normalizer.js +50 -0
  14. package/dist/core/package-path.d.ts +1 -0
  15. package/dist/core/package-path.js +25 -0
  16. package/dist/core/plugin-components.d.ts +7 -0
  17. package/dist/core/plugin-components.js +169 -0
  18. package/dist/core/plugin-hooks.d.ts +2 -0
  19. package/dist/core/plugin-hooks.js +190 -0
  20. package/dist/core/runtime-plan.js +97 -16
  21. package/dist/core/runtime-probe.js +3 -8
  22. package/dist/core/validate-plugin.js +39 -17
  23. package/dist/domain/types.d.ts +34 -0
  24. package/dist/domain/types.js +13 -1
  25. package/dist/mcp/generic-mcp-doctor.js +45 -29
  26. package/dist/rules/rule-catalog.js +130 -4
  27. package/dist/run-cli.js +32 -2
  28. package/dist/security/security-audit.d.ts +1 -0
  29. package/dist/security/security-audit.js +104 -7
  30. package/examples/README.md +94 -94
  31. package/examples/codex-doctor-generic-mcp/.mcp.json +10 -10
  32. package/examples/codex-doctor-generic-mcp/server.js +1 -1
  33. package/examples/codex-doctor-risky/.codex-plugin/plugin.json +7 -7
  34. package/examples/codex-doctor-risky/.mcp.json +12 -12
  35. package/examples/codex-doctor-risky/mock-server.js +1 -1
  36. package/examples/codex-doctor-runtime/.codex-plugin/plugin.json +8 -8
  37. package/examples/codex-doctor-runtime/.mcp.json +9 -9
  38. package/examples/codex-doctor-runtime/mock-server.js +237 -237
  39. package/examples/codex-doctor-runtime/skills/context-check/SKILL.md +7 -7
  40. package/examples/codex-doctor-starter/.codex-plugin/plugin.json +7 -7
  41. package/examples/codex-doctor-starter/skills/repo-scan/SKILL.md +7 -7
  42. package/package.json +1 -1
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Furkan Tasci
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Furkan Tasci
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -480,9 +480,9 @@ jobs:
480
480
  runs-on: ubuntu-latest
481
481
  steps:
482
482
  - uses: actions/checkout@v5
483
- - uses: Esquetta/CodexPluginDoctor@v1.56.0
483
+ - uses: Esquetta/CodexPluginDoctor@v1.58.0
484
484
  with:
485
- version: "1.56.0"
485
+ version: "1.58.0"
486
486
  path: .
487
487
  runtime: "true"
488
488
  policy: codex-publish
@@ -518,6 +518,7 @@ The validator is tuned against local fixtures and real marketplace-style plugin
518
518
  - [Real-World Validation Workflow](./docs/guides/real-world-validation.md)
519
519
  - [Examples](./examples/README.md)
520
520
  - [Rule Catalog](./docs/rules/catalog.md)
521
+ - [Official Plugin Components](./docs/architecture/official-plugin-components.md)
521
522
 
522
523
  Recent validation waves covered:
523
524
 
@@ -1,5 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { normalizeMcpConfig } from "../core/mcp-config-normalizer.js";
3
4
  import { getClaudeDesktopConfigPath, readMcpConfigPath } from "./compatibility-matrix.js";
4
5
  function isRecord(value) {
5
6
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -38,16 +39,15 @@ export async function buildClaudeDesktopInstallPreview(targetPath, environment =
38
39
  if (!mcpConfigPath) {
39
40
  throw new Error("No MCP config found for install preview.");
40
41
  }
41
- const parsed = JSON.parse(await readFile(mcpConfigPath, "utf8"));
42
- const servers = parsed.mcpServers;
43
- if (!isRecord(servers) || Object.keys(servers).length === 0) {
42
+ const normalizedConfig = normalizeMcpConfig(JSON.parse(await readFile(mcpConfigPath, "utf8")));
43
+ if (!normalizedConfig.ok) {
44
44
  throw new Error("MCP config does not contain a non-empty `mcpServers` object.");
45
45
  }
46
46
  return {
47
47
  targetPath: rootPath,
48
48
  configPath,
49
49
  snippet: {
50
- mcpServers: Object.fromEntries(Object.entries(servers).map(([serverName, serverConfig]) => [
50
+ mcpServers: Object.fromEntries(Object.entries(normalizedConfig.servers).map(([serverName, serverConfig]) => [
51
51
  serverName,
52
52
  normalizeServerConfig(serverConfig, rootPath)
53
53
  ]))
@@ -1,5 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { normalizeMcpConfig } from "../core/mcp-config-normalizer.js";
3
4
  import { getClineMcpConfigPath, readMcpConfigPath } from "./compatibility-matrix.js";
4
5
  function isRecord(value) {
5
6
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -34,16 +35,15 @@ export async function buildClineInstallPreview(targetPath, environment = {}) {
34
35
  if (!mcpConfigPath) {
35
36
  throw new Error("No MCP config found for install preview.");
36
37
  }
37
- const parsed = JSON.parse(await readFile(mcpConfigPath, "utf8"));
38
- const servers = parsed.mcpServers;
39
- if (!isRecord(servers) || Object.keys(servers).length === 0) {
38
+ const normalizedConfig = normalizeMcpConfig(JSON.parse(await readFile(mcpConfigPath, "utf8")));
39
+ if (!normalizedConfig.ok) {
40
40
  throw new Error("MCP config does not contain a non-empty `mcpServers` object.");
41
41
  }
42
42
  return {
43
43
  targetPath: rootPath,
44
44
  configPath: getClineMcpConfigPath(environment),
45
45
  snippet: {
46
- mcpServers: Object.fromEntries(Object.entries(servers).map(([serverName, serverConfig]) => [
46
+ mcpServers: Object.fromEntries(Object.entries(normalizedConfig.servers).map(([serverName, serverConfig]) => [
47
47
  serverName,
48
48
  normalizeServerConfig(serverConfig, rootPath)
49
49
  ]))
@@ -2,7 +2,9 @@ import { stat } from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { validatePlugin } from "../core/validate-plugin.js";
5
+ import { normalizeMcpConfig } from "../core/mcp-config-normalizer.js";
5
6
  import { readJsonFile } from "../core/read-json-file.js";
7
+ import { resolveContainedPackagePath } from "../core/package-path.js";
6
8
  async function fileExists(targetPath) {
7
9
  try {
8
10
  const details = await stat(targetPath);
@@ -30,16 +32,15 @@ function statusFromCheckResult(result) {
30
32
  }
31
33
  return "pass";
32
34
  }
33
- function isPathWithinRoot(rootPath, candidatePath) {
34
- const relativePath = path.relative(rootPath, candidatePath);
35
- return (relativePath === "" ||
36
- (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)));
37
- }
38
35
  export async function readMcpConfigPath(targetPath) {
39
36
  const rootPath = path.resolve(targetPath);
40
37
  const directMcpPath = path.join(rootPath, ".mcp.json");
41
38
  if (await fileExists(directMcpPath)) {
42
- return directMcpPath;
39
+ const containedDirectMcpPath = await resolveContainedPackagePath(rootPath, directMcpPath);
40
+ if (!containedDirectMcpPath) {
41
+ throw new Error("MCP config path resolves outside the package root.");
42
+ }
43
+ return containedDirectMcpPath;
43
44
  }
44
45
  const manifestPath = path.join(rootPath, ".codex-plugin", "plugin.json");
45
46
  if (!(await fileExists(manifestPath))) {
@@ -55,8 +56,8 @@ export async function readMcpConfigPath(targetPath) {
55
56
  if (typeof manifest.mcpServers !== "string") {
56
57
  return null;
57
58
  }
58
- const mcpConfigPath = path.resolve(rootPath, manifest.mcpServers);
59
- if (!isPathWithinRoot(rootPath, mcpConfigPath)) {
59
+ const mcpConfigPath = await resolveContainedPackagePath(rootPath, manifest.mcpServers);
60
+ if (!mcpConfigPath) {
60
61
  throw new Error("Manifest MCP config path resolves outside the package root.");
61
62
  }
62
63
  return mcpConfigPath;
@@ -87,11 +88,8 @@ async function checkGenericMcp(targetPath) {
87
88
  }
88
89
  try {
89
90
  const parsed = await readJsonFile(mcpConfigPath);
90
- const servers = parsed.mcpServers;
91
- if (typeof servers !== "object" ||
92
- servers === null ||
93
- Array.isArray(servers) ||
94
- Object.keys(servers).length === 0) {
91
+ const normalizedConfig = normalizeMcpConfig(parsed);
92
+ if (!normalizedConfig.ok) {
95
93
  return {
96
94
  client: "Generic MCP",
97
95
  status: "fail",
@@ -122,10 +120,8 @@ async function readMcpServerNames(targetPath) {
122
120
  }
123
121
  try {
124
122
  const parsed = await readJsonFile(mcpConfigPath);
125
- const servers = parsed.mcpServers;
126
- return typeof servers === "object" && servers !== null && !Array.isArray(servers)
127
- ? Object.keys(servers)
128
- : [];
123
+ const normalizedConfig = normalizeMcpConfig(parsed);
124
+ return normalizedConfig.ok ? Object.keys(normalizedConfig.servers) : [];
129
125
  }
130
126
  catch {
131
127
  return [];
@@ -1,5 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { normalizeMcpConfig } from "../core/mcp-config-normalizer.js";
3
4
  import { getCursorMcpConfigPath, readMcpConfigPath } from "./compatibility-matrix.js";
4
5
  function isRecord(value) {
5
6
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -34,16 +35,15 @@ export async function buildCursorInstallPreview(targetPath, environment = {}) {
34
35
  if (!mcpConfigPath) {
35
36
  throw new Error("No MCP config found for install preview.");
36
37
  }
37
- const parsed = JSON.parse(await readFile(mcpConfigPath, "utf8"));
38
- const servers = parsed.mcpServers;
39
- if (!isRecord(servers) || Object.keys(servers).length === 0) {
38
+ const normalizedConfig = normalizeMcpConfig(JSON.parse(await readFile(mcpConfigPath, "utf8")));
39
+ if (!normalizedConfig.ok) {
40
40
  throw new Error("MCP config does not contain a non-empty `mcpServers` object.");
41
41
  }
42
42
  return {
43
43
  targetPath: rootPath,
44
44
  configPath: await getCursorMcpConfigPath(rootPath, environment),
45
45
  snippet: {
46
- mcpServers: Object.fromEntries(Object.entries(servers).map(([serverName, serverConfig]) => [
46
+ mcpServers: Object.fromEntries(Object.entries(normalizedConfig.servers).map(([serverName, serverConfig]) => [
47
47
  serverName,
48
48
  normalizeServerConfig(serverConfig, rootPath)
49
49
  ]))
@@ -1,5 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { normalizeMcpConfig } from "../core/mcp-config-normalizer.js";
3
4
  import { getWindsurfMcpConfigPath, readMcpConfigPath } from "./compatibility-matrix.js";
4
5
  function isRecord(value) {
5
6
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -34,16 +35,15 @@ export async function buildWindsurfInstallPreview(targetPath, environment = {})
34
35
  if (!mcpConfigPath) {
35
36
  throw new Error("No MCP config found for install preview.");
36
37
  }
37
- const parsed = JSON.parse(await readFile(mcpConfigPath, "utf8"));
38
- const servers = parsed.mcpServers;
39
- if (!isRecord(servers) || Object.keys(servers).length === 0) {
38
+ const normalizedConfig = normalizeMcpConfig(JSON.parse(await readFile(mcpConfigPath, "utf8")));
39
+ if (!normalizedConfig.ok) {
40
40
  throw new Error("MCP config does not contain a non-empty `mcpServers` object.");
41
41
  }
42
42
  return {
43
43
  targetPath: rootPath,
44
44
  configPath: getWindsurfMcpConfigPath(environment),
45
45
  snippet: {
46
- mcpServers: Object.fromEntries(Object.entries(servers).map(([serverName, serverConfig]) => [
46
+ mcpServers: Object.fromEntries(Object.entries(normalizedConfig.servers).map(([serverName, serverConfig]) => [
47
47
  serverName,
48
48
  normalizeServerConfig(serverConfig, rootPath)
49
49
  ]))
@@ -10,7 +10,11 @@ export interface DoctorSizeReport {
10
10
  sizeHuman: string;
11
11
  }>;
12
12
  warnings: string[];
13
+ npmPackSize?: number;
14
+ npmPackSizeHuman?: string;
13
15
  }
14
- export declare function buildDoctorSize(targetPath: string): Promise<DoctorSizeReport>;
16
+ export declare function buildDoctorSize(targetPath: string, options?: {
17
+ npmPack?: boolean;
18
+ }): Promise<DoctorSizeReport>;
15
19
  export declare function renderDoctorSize(report: DoctorSizeReport): string;
16
20
  export declare function renderDoctorSizeJson(report: DoctorSizeReport): string;
@@ -1,4 +1,5 @@
1
1
  import { readdir, stat } from "node:fs/promises";
2
+ import { execFile } from "node:child_process";
2
3
  import path from "node:path";
3
4
  const skippedDirs = new Set([
4
5
  "node_modules",
@@ -14,6 +15,20 @@ const skippedDirs = new Set([
14
15
  const largeFileThreshold = 1024 * 1024;
15
16
  const packageSizeWarnThreshold = 10 * 1024 * 1024;
16
17
  const packageSizeFailThreshold = 50 * 1024 * 1024;
18
+ function npmPackCommand() {
19
+ if (process.platform === "win32") {
20
+ const npmExecPath = process.env.npm_execpath;
21
+ // Only use the inherited npm CLI path when it is an absolute npm entrypoint.
22
+ const npmCliPath = npmExecPath && path.isAbsolute(npmExecPath) && path.basename(npmExecPath).toLowerCase() === "npm-cli.js"
23
+ ? npmExecPath
24
+ : path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js");
25
+ return {
26
+ command: process.execPath,
27
+ args: [npmCliPath, "pack", "--dry-run"]
28
+ };
29
+ }
30
+ return { command: "npm", args: ["pack", "--dry-run"] };
31
+ }
17
32
  function formatSize(bytes) {
18
33
  if (bytes < 1024)
19
34
  return `${bytes} B`;
@@ -68,11 +83,63 @@ async function scanDirectory(rootPath, currentPath = rootPath) {
68
83
  }
69
84
  return { totalSize, fileCount, largeFiles };
70
85
  }
71
- export async function buildDoctorSize(targetPath) {
86
+ export async function buildDoctorSize(targetPath, options = {}) {
72
87
  const resolvedPath = path.resolve(targetPath);
73
88
  const { totalSize, fileCount, largeFiles } = await scanDirectory(resolvedPath);
74
89
  const warnings = [];
75
90
  let status = "pass";
91
+ let npmPackSize;
92
+ let npmPackSizeHuman;
93
+ if (options.npmPack) {
94
+ try {
95
+ const packOutput = await new Promise((resolve, reject) => {
96
+ const npmPack = npmPackCommand();
97
+ execFile(npmPack.command, npmPack.args, { cwd: resolvedPath, timeout: 30000 }, (error, stdout, stderr) => {
98
+ if (error) {
99
+ reject(new Error(stderr.trim() || error.message));
100
+ return;
101
+ }
102
+ resolve(stdout);
103
+ });
104
+ });
105
+ const tarballLine = packOutput.split("\n").find((line) => line.includes("total files") || line.includes("package size"));
106
+ if (tarballLine) {
107
+ const totalMatch = tarballLine.match(/(\d[\d.]*)\s*([KMG]?B)/i);
108
+ if (totalMatch) {
109
+ const value = Number.parseFloat(totalMatch[1]);
110
+ const unit = totalMatch[2].toUpperCase();
111
+ switch (unit) {
112
+ case "KB":
113
+ npmPackSize = Math.round(value * 1024);
114
+ break;
115
+ case "MB":
116
+ npmPackSize = Math.round(value * 1024 * 1024);
117
+ break;
118
+ case "GB":
119
+ npmPackSize = Math.round(value * 1024 * 1024 * 1024);
120
+ break;
121
+ default:
122
+ npmPackSize = Math.round(value);
123
+ }
124
+ npmPackSizeHuman = formatSize(npmPackSize);
125
+ }
126
+ }
127
+ if (npmPackSize === undefined) {
128
+ warnings.push("Could not determine npm pack size from output.");
129
+ }
130
+ else {
131
+ const diffPercent = totalSize > 0
132
+ ? Math.abs(Math.round(((npmPackSize - totalSize) / totalSize) * 100))
133
+ : 0;
134
+ if (diffPercent > 50 && npmPackSize > totalSize) {
135
+ warnings.push(`npm pack size (${npmPackSizeHuman}) differs from local size (${formatSize(totalSize)}) by ${diffPercent}%.`);
136
+ }
137
+ }
138
+ }
139
+ catch (error) {
140
+ warnings.push(`npm pack failed: ${error.message}`);
141
+ }
142
+ }
76
143
  if (totalSize >= packageSizeFailThreshold) {
77
144
  status = "fail";
78
145
  warnings.push(`Package size (${formatSize(totalSize)}) exceeds ${formatSize(packageSizeFailThreshold)}.`);
@@ -94,7 +161,9 @@ export async function buildDoctorSize(targetPath) {
94
161
  totalSizeHuman: formatSize(totalSize),
95
162
  fileCount,
96
163
  largeFiles: largeFiles.slice(0, 10),
97
- warnings
164
+ warnings,
165
+ npmPackSize,
166
+ npmPackSizeHuman
98
167
  };
99
168
  }
100
169
  export function renderDoctorSize(report) {
@@ -104,6 +173,7 @@ export function renderDoctorSize(report) {
104
173
  `Path: ${report.targetPath}`,
105
174
  `Status: ${report.status.toUpperCase()}`,
106
175
  `Total size: ${report.totalSizeHuman} (${report.fileCount} files)`,
176
+ report.npmPackSizeHuman ? `npm pack size: ${report.npmPackSizeHuman}` : "",
107
177
  ""
108
178
  ];
109
179
  if (report.warnings.length > 0) {
@@ -134,6 +204,10 @@ export function renderDoctorSizeJson(report) {
134
204
  totalSizeHuman: report.totalSizeHuman,
135
205
  fileCount: report.fileCount,
136
206
  largeFiles: report.largeFiles,
137
- warnings: report.warnings
207
+ warnings: report.warnings,
208
+ ...(report.npmPackSize !== undefined ? {
209
+ npmPackSize: report.npmPackSize,
210
+ npmPackSizeHuman: report.npmPackSizeHuman
211
+ } : {})
138
212
  }, null, 2);
139
213
  }
@@ -162,8 +162,8 @@ export async function initPluginPackage(targetPath, options = {}) {
162
162
  name: packageName,
163
163
  version: "0.1.0",
164
164
  description: "A Codex plugin package scaffolded by Codex Plugin Doctor.",
165
- skills: "skills",
166
- ...(template === "skill-only" ? {} : { mcpServers: ".mcp.json" })
165
+ skills: "./skills",
166
+ ...(template === "skill-only" ? {} : { mcpServers: "./.mcp.json" })
167
167
  }, null, 2)}\n`, "utf8");
168
168
  await writeFile(skillPath, buildSkillMarkdown(template), "utf8");
169
169
  if (template === "mcp-stdio" || template === "full-runtime") {
@@ -1,14 +1,8 @@
1
1
  import path from "node:path";
2
2
  import { readJsonFile } from "./read-json-file.js";
3
3
  import { discoverPackage } from "./discover-package.js";
4
- function isPlainObject(value) {
5
- return typeof value === "object" && value !== null && !Array.isArray(value);
6
- }
7
- function isPathWithinRoot(rootPath, candidatePath) {
8
- const relativePath = path.relative(rootPath, candidatePath);
9
- return (relativePath === "" ||
10
- (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)));
11
- }
4
+ import { normalizeMcpConfig } from "./mcp-config-normalizer.js";
5
+ import { resolveContainedPackagePath } from "./package-path.js";
12
6
  function buildFailure(targetPath, message, mcpConfigPath = null) {
13
7
  return {
14
8
  schemaVersion: "1.0.0",
@@ -28,9 +22,9 @@ export async function buildDoctorInspectorReport(targetPath, options = {}) {
28
22
  if (!discoveredPackage?.manifest.mcpServers) {
29
23
  return buildFailure(targetPath, "The target package does not declare an MCP server config in `.codex-plugin/plugin.json`.");
30
24
  }
31
- const mcpConfigPath = path.resolve(discoveredPackage.rootPath, discoveredPackage.manifest.mcpServers);
32
- if (!isPathWithinRoot(discoveredPackage.rootPath, mcpConfigPath)) {
33
- return buildFailure(discoveredPackage.rootPath, "The target package points the MCP server config outside the package root.", mcpConfigPath);
25
+ const mcpConfigPath = await resolveContainedPackagePath(discoveredPackage.rootPath, discoveredPackage.manifest.mcpServers);
26
+ if (!mcpConfigPath) {
27
+ return buildFailure(discoveredPackage.rootPath, "The target package points the MCP server config outside the package root.", null);
34
28
  }
35
29
  let parsedConfig;
36
30
  try {
@@ -39,10 +33,11 @@ export async function buildDoctorInspectorReport(targetPath, options = {}) {
39
33
  catch {
40
34
  return buildFailure(discoveredPackage.rootPath, "The MCP server config could not be parsed as JSON.", mcpConfigPath);
41
35
  }
42
- if (!isPlainObject(parsedConfig) || !isPlainObject(parsedConfig.mcpServers)) {
36
+ const normalizedConfig = normalizeMcpConfig(parsedConfig);
37
+ if (!normalizedConfig.ok) {
43
38
  return buildFailure(discoveredPackage.rootPath, "The MCP server config does not contain a valid `mcpServers` object.", mcpConfigPath);
44
39
  }
45
- const serverNames = Object.keys(parsedConfig.mcpServers).sort();
40
+ const serverNames = Object.keys(normalizedConfig.servers).sort();
46
41
  const selectedServerName = options.serverName ?? serverNames[0] ?? null;
47
42
  if (!selectedServerName || !serverNames.includes(selectedServerName)) {
48
43
  return buildFailure(discoveredPackage.rootPath, options.serverName
@@ -0,0 +1,18 @@
1
+ export type McpServerConfig = Record<string, unknown>;
2
+ export type McpServerMap = Record<string, McpServerConfig>;
3
+ export type McpConfigLayout = "direct" | "snake_case_wrapper" | "camel_case_wrapper";
4
+ export type McpConfigNormalizationResult = {
5
+ ok: true;
6
+ layout: McpConfigLayout;
7
+ servers: McpServerMap;
8
+ } | {
9
+ ok: false;
10
+ reason: "ambiguous_shape";
11
+ field: "root";
12
+ } | {
13
+ ok: false;
14
+ reason: "invalid_shape";
15
+ field: "root" | "mcp_servers" | "mcpServers" | "server";
16
+ invalidServerNames?: string[];
17
+ };
18
+ export declare function normalizeMcpConfig(value: unknown): McpConfigNormalizationResult;
@@ -0,0 +1,50 @@
1
+ function isPlainObject(value) {
2
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
3
+ return false;
4
+ }
5
+ const prototype = Object.getPrototypeOf(value);
6
+ return prototype === Object.prototype || prototype === null;
7
+ }
8
+ function normalizeServerMap(value, field, layout) {
9
+ if (!isPlainObject(value) || Object.keys(value).length === 0) {
10
+ return { ok: false, reason: "invalid_shape", field };
11
+ }
12
+ const invalidServerNames = [];
13
+ const servers = Object.create(null);
14
+ for (const [name, server] of Object.entries(value)) {
15
+ if (!isPlainObject(server)) {
16
+ invalidServerNames.push(name);
17
+ }
18
+ else {
19
+ servers[name] = server;
20
+ }
21
+ }
22
+ if (invalidServerNames.length > 0) {
23
+ return {
24
+ ok: false,
25
+ reason: "invalid_shape",
26
+ field: "server",
27
+ invalidServerNames: invalidServerNames.sort()
28
+ };
29
+ }
30
+ return { ok: true, layout, servers };
31
+ }
32
+ export function normalizeMcpConfig(value) {
33
+ if (!isPlainObject(value)) {
34
+ return { ok: false, reason: "invalid_shape", field: "root" };
35
+ }
36
+ const hasSnakeCaseWrapper = Object.hasOwn(value, "mcp_servers");
37
+ const hasCamelCaseWrapper = Object.hasOwn(value, "mcpServers");
38
+ if (hasSnakeCaseWrapper && hasCamelCaseWrapper) {
39
+ return { ok: false, reason: "ambiguous_shape", field: "root" };
40
+ }
41
+ if (hasSnakeCaseWrapper || hasCamelCaseWrapper) {
42
+ if (Object.keys(value).length !== 1) {
43
+ return { ok: false, reason: "ambiguous_shape", field: "root" };
44
+ }
45
+ return hasSnakeCaseWrapper
46
+ ? normalizeServerMap(value.mcp_servers, "mcp_servers", "snake_case_wrapper")
47
+ : normalizeServerMap(value.mcpServers, "mcpServers", "camel_case_wrapper");
48
+ }
49
+ return normalizeServerMap(value, "root", "direct");
50
+ }
@@ -0,0 +1 @@
1
+ export declare function resolveContainedPackagePath(rootPath: string, candidatePath: string): Promise<string | null>;
@@ -0,0 +1,25 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import path from "node:path";
3
+ function isPathWithinRoot(rootPath, candidatePath) {
4
+ const relativePath = path.relative(rootPath, candidatePath);
5
+ return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
6
+ }
7
+ export async function resolveContainedPackagePath(rootPath, candidatePath) {
8
+ const resolvedRootPath = path.resolve(rootPath);
9
+ const resolvedCandidatePath = path.resolve(resolvedRootPath, candidatePath);
10
+ if (!isPathWithinRoot(resolvedRootPath, resolvedCandidatePath)) {
11
+ return null;
12
+ }
13
+ try {
14
+ const [canonicalRootPath, canonicalCandidatePath] = await Promise.all([
15
+ realpath(resolvedRootPath),
16
+ realpath(resolvedCandidatePath)
17
+ ]);
18
+ return isPathWithinRoot(canonicalRootPath, canonicalCandidatePath)
19
+ ? resolvedCandidatePath
20
+ : null;
21
+ }
22
+ catch {
23
+ return resolvedCandidatePath;
24
+ }
25
+ }
@@ -0,0 +1,7 @@
1
+ import type { DiscoveredPackage, Finding } from "../domain/types.js";
2
+ export interface SafePackagePath {
3
+ path: string;
4
+ packagePath: string;
5
+ }
6
+ export declare function resolveSafePackagePath(rootPath: string, value: string): Promise<SafePackagePath | null>;
7
+ export declare function validatePluginComponents(discoveredPackage: DiscoveredPackage): Promise<Finding[]>;