@triflux/core 10.1.0 → 10.25.1

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 (143) hide show
  1. package/hooks/agent-route-guard.mjs +14 -10
  2. package/hooks/cross-review-tracker.mjs +21 -8
  3. package/hooks/error-context.mjs +20 -8
  4. package/hooks/hook-adaptive-collector.mjs +18 -16
  5. package/hooks/hook-manager.mjs +93 -32
  6. package/hooks/hook-orchestrator.mjs +267 -35
  7. package/hooks/hook-registry.json +343 -256
  8. package/hooks/hooks.json +49 -1
  9. package/hooks/keyword-rules.json +183 -23
  10. package/hooks/lib/resolve-root.mjs +1 -1
  11. package/hooks/mcp-config-watcher.mjs +6 -2
  12. package/hooks/permission-safe-allow.mjs +233 -0
  13. package/hooks/pipeline-stop.mjs +369 -22
  14. package/hooks/post-tool-tips.mjs +306 -0
  15. package/hooks/pre-compact-snapshot.mjs +181 -0
  16. package/hooks/safety-guard.mjs +254 -33
  17. package/hooks/session-end-cleanup.mjs +345 -0
  18. package/hooks/session-start-fast.mjs +214 -0
  19. package/hooks/subagent-tracker.mjs +267 -0
  20. package/hooks/subagent-verifier.mjs +9 -4
  21. package/hub/account-broker.mjs +1036 -66
  22. package/hub/adaptive-diagnostic.mjs +75 -48
  23. package/hub/adaptive-inject.mjs +96 -58
  24. package/hub/adaptive-memory.mjs +156 -42
  25. package/hub/adaptive.mjs +60 -31
  26. package/hub/assign-callbacks.mjs +163 -126
  27. package/hub/bridge.mjs +831 -290
  28. package/hub/cli-adapter-base.mjs +321 -97
  29. package/hub/codex-adapter.mjs +179 -93
  30. package/hub/codex-compat.mjs +4 -4
  31. package/hub/codex-preflight.mjs +113 -37
  32. package/hub/delegator/contracts.mjs +19 -23
  33. package/hub/delegator/index.mjs +14 -14
  34. package/hub/delegator/service.mjs +88 -64
  35. package/hub/delegator/tool-definitions.mjs +35 -35
  36. package/hub/dynamic-routing-engine.mjs +511 -0
  37. package/hub/fullcycle.mjs +34 -18
  38. package/hub/gemini-adapter.mjs +77 -95
  39. package/hub/hitl.mjs +202 -143
  40. package/hub/intent.mjs +167 -38
  41. package/hub/lib/bash-path.mjs +73 -0
  42. package/hub/lib/cache-guard.mjs +43 -17
  43. package/hub/lib/env-detect.mjs +145 -0
  44. package/hub/lib/hosts-compat.mjs +319 -0
  45. package/hub/lib/mcp-response-cache.mjs +239 -0
  46. package/hub/lib/memory-store.mjs +285 -111
  47. package/hub/lib/path-utils.mjs +165 -0
  48. package/hub/lib/phase-manager.mjs +493 -0
  49. package/hub/lib/process-utils.mjs +797 -104
  50. package/hub/lib/prompt-tmp.mjs +33 -0
  51. package/hub/lib/risk-tier.mjs +53 -0
  52. package/hub/lib/spawn-trace.mjs +561 -0
  53. package/hub/lib/ssh-command.mjs +44 -24
  54. package/hub/lib/ssh-retry.mjs +5 -1
  55. package/hub/lib/staged-file-detect.mjs +43 -0
  56. package/hub/lib/state-snapshot.mjs +293 -0
  57. package/hub/lib/tfx-route-args.mjs +309 -0
  58. package/hub/lib/timeline-adapter.mjs +190 -0
  59. package/hub/lib/trace-recorder.mjs +153 -0
  60. package/hub/lib/uuidv7.mjs +4 -3
  61. package/hub/memory-doctor.mjs +266 -106
  62. package/hub/middleware/quota-middleware.mjs +70 -0
  63. package/hub/middleware/request-logger.mjs +61 -34
  64. package/hub/paths.mjs +9 -9
  65. package/hub/pipeline/gates/confidence.mjs +34 -15
  66. package/hub/pipeline/gates/consensus.mjs +27 -15
  67. package/hub/pipeline/gates/index.mjs +7 -3
  68. package/hub/pipeline/gates/selfcheck.mjs +57 -19
  69. package/hub/pipeline/index.mjs +77 -42
  70. package/hub/pipeline/state.mjs +10 -10
  71. package/hub/pipeline/transitions.mjs +40 -23
  72. package/hub/platform.mjs +65 -48
  73. package/hub/quality/deslop.mjs +70 -49
  74. package/hub/reflexion.mjs +87 -13
  75. package/hub/research.mjs +33 -25
  76. package/hub/router.mjs +941 -791
  77. package/hub/routing/complexity.mjs +132 -29
  78. package/hub/routing/index.mjs +17 -12
  79. package/hub/routing/q-learning.mjs +76 -28
  80. package/hub/routing-snapshot.mjs +261 -0
  81. package/hub/session-fingerprint.mjs +127 -61
  82. package/hub/state.mjs +86 -44
  83. package/hub/team/retry-state-machine.mjs +267 -0
  84. package/hub/token-mode.mjs +72 -71
  85. package/hub/workers/worker-utils.mjs +22 -16
  86. package/hud/colors.mjs +27 -9
  87. package/hud/constants.mjs +184 -27
  88. package/hud/context-monitor.mjs +133 -44
  89. package/hud/hud-qos-status.mjs +153 -50
  90. package/hud/mission-board.mjs +56 -0
  91. package/hud/providers/claude.mjs +398 -114
  92. package/hud/providers/codex.mjs +96 -21
  93. package/hud/providers/gemini.mjs +184 -51
  94. package/hud/renderers.mjs +413 -123
  95. package/hud/terminal.mjs +101 -31
  96. package/hud/utils.mjs +81 -39
  97. package/mesh/index.mjs +69 -0
  98. package/mesh/mesh-budget.mjs +137 -0
  99. package/mesh/mesh-heartbeat.mjs +100 -0
  100. package/mesh/mesh-protocol.mjs +96 -0
  101. package/mesh/mesh-queue.mjs +163 -0
  102. package/mesh/mesh-registry.mjs +78 -0
  103. package/mesh/mesh-router.mjs +77 -0
  104. package/package.json +5 -1
  105. package/scripts/lib/agent-json.mjs +27 -0
  106. package/scripts/lib/async.mjs +174 -0
  107. package/scripts/lib/claudemd-scanner.mjs +6 -1
  108. package/scripts/lib/cli-agy.mjs +62 -0
  109. package/scripts/lib/cli-claude.mjs +78 -0
  110. package/scripts/lib/cli-codex.mjs +199 -0
  111. package/scripts/lib/cli-gemini.mjs +67 -0
  112. package/scripts/lib/codex-recovery.sh +50 -0
  113. package/scripts/lib/context.mjs +3 -3
  114. package/scripts/lib/cross-review-utils.mjs +13 -5
  115. package/scripts/lib/doctor-env-checks.mjs +121 -0
  116. package/scripts/lib/dynamic-route-cli.mjs +107 -0
  117. package/scripts/lib/env-probe.mjs +58 -30
  118. package/scripts/lib/env.mjs +14 -0
  119. package/scripts/lib/gemini-profiles.mjs +44 -10
  120. package/scripts/lib/handoff.mjs +33 -17
  121. package/scripts/lib/hook-utils.mjs +8 -6
  122. package/scripts/lib/hub.mjs +48 -0
  123. package/scripts/lib/keyword-rules.mjs +48 -19
  124. package/scripts/lib/logger.mjs +24 -24
  125. package/scripts/lib/mcp-filter.mjs +391 -247
  126. package/scripts/lib/mcp-gateway-health-check.mjs +159 -0
  127. package/scripts/lib/mcp-gateway-servers.mjs +70 -0
  128. package/scripts/lib/mcp-gateway-wrapper-check.mjs +35 -0
  129. package/scripts/lib/mcp-guard-engine.mjs +1281 -115
  130. package/scripts/lib/mcp-health.mjs +773 -0
  131. package/scripts/lib/mcp-manifest.mjs +24 -14
  132. package/scripts/lib/mcp-server-catalog.mjs +355 -118
  133. package/scripts/lib/pid.mjs +63 -0
  134. package/scripts/lib/process-utils.mjs +26 -0
  135. package/scripts/lib/psmux-info.mjs +70 -10
  136. package/scripts/lib/quota.mjs +47 -0
  137. package/scripts/lib/remote-spawn-transfer.mjs +44 -14
  138. package/scripts/lib/skill-state.mjs +220 -0
  139. package/scripts/lib/skill-template.mjs +32 -7
  140. package/scripts/lib/team.mjs +75 -0
  141. package/scripts/lib/timeout.mjs +66 -0
  142. package/scripts/lib/tmp.mjs +26 -0
  143. package/scripts/lib/toml.mjs +113 -0
@@ -1,12 +1,38 @@
1
- import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import {
2
+ chmodSync,
3
+ copyFileSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readdirSync,
7
+ readFileSync,
8
+ writeFileSync,
9
+ } from "node:fs";
2
10
  import { homedir } from "node:os";
3
- import { basename, dirname, isAbsolute, join, resolve } from "node:path";
11
+ import {
12
+ basename,
13
+ dirname,
14
+ isAbsolute,
15
+ join,
16
+ relative,
17
+ resolve,
18
+ } from "node:path";
4
19
  import { fileURLToPath } from "node:url";
5
20
 
6
21
  const PROJECT_ROOT = fileURLToPath(new URL("../../", import.meta.url));
7
- const REGISTRY_PATH = join(PROJECT_ROOT, "config", "mcp-registry.json");
22
+ const DEFAULT_REGISTRY_PATH = join(PROJECT_ROOT, "config", "mcp-registry.json");
8
23
  const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
9
24
  const DEFAULT_HUB_PATH = "/mcp";
25
+ const SERVER_POLICIES = new Set(["hosted", "gateway-sse", "stdio"]);
26
+ const GATEWAY_PORTS = Object.freeze({
27
+ context7: 8100,
28
+ "brave-search": 8101,
29
+ exa: 8102,
30
+ tavily: 8103,
31
+ jira: 8104,
32
+ serena: 8105,
33
+ notion: 8106,
34
+ "notion-guest": 8107,
35
+ });
10
36
  const DEFAULT_REGISTRY = Object.freeze({
11
37
  $schema: "mcp-registry-schema",
12
38
  version: 1,
@@ -15,24 +41,37 @@ const DEFAULT_REGISTRY = Object.freeze({
15
41
  transport: "hub-url",
16
42
  hub_base: "http://127.0.0.1:27888",
17
43
  },
44
+ policy_notes: {
45
+ transport:
46
+ 'Server transport accepts "hub-url" for triflux Hub URL flow, "http" for direct Streamable HTTP MCP endpoints, or "stdio" for upstream-stdio-only MCP servers (rare; only for servers that have no hosted HTTP endpoint, e.g. brave-search). stdio servers require command, args, and may include env.',
47
+ headers:
48
+ 'Optional headers are allowed only for HTTP-compatible transports. Each header value must be a descriptor: {"value":"literal"} for non-secret static values, {"env":"ENV_VAR_NAME"} for secrets resolved at sync/runtime, or {"env":"ENV_VAR_NAME","prefix":"Bearer "} for common authorization formats.',
49
+ secret_safety:
50
+ "Resolved secret values must not be written back to this registry file. Missing env vars warn during sync and do not emit empty secret headers.",
51
+ },
18
52
  servers: {
19
53
  "tfx-hub": {
54
+ policy: "hosted",
20
55
  transport: "hub-url",
21
56
  url: "http://127.0.0.1:27888/mcp",
22
57
  safe: true,
23
- targets: ["claude", "gemini", "codex"],
58
+ targets: ["claude", "gemini", "codex", "antigravity"],
24
59
  description: "triflux Hub MCP 서버",
25
60
  },
26
61
  },
27
62
  policies: {
28
63
  stdio_action: "replace-with-hub",
29
64
  unknown_server_action: "warn",
65
+ sync_denylist: [],
30
66
  watched_paths: [
31
67
  "~/.gemini/settings.json",
32
68
  "~/.codex/config.toml",
69
+ "~/.claude.json",
33
70
  "~/.claude/settings.json",
34
71
  "~/.claude/settings.local.json",
72
+ ".claude/mcp.json",
35
73
  ".mcp.json",
74
+ "~/.gemini/config/mcp_config.json",
36
75
  ],
37
76
  },
38
77
  });
@@ -43,13 +82,39 @@ function cloneDefaultRegistry() {
43
82
 
44
83
  function expandHome(filePath) {
45
84
  if (typeof filePath !== "string") return "";
46
- if (!filePath.startsWith("~/") && !filePath.startsWith("~\\")) return filePath;
85
+ if (!filePath.startsWith("~/") && !filePath.startsWith("~\\"))
86
+ return filePath;
47
87
  return join(homedir(), filePath.slice(2));
48
88
  }
49
89
 
50
90
  function resolveFilePath(filePath) {
51
91
  const expanded = expandHome(filePath);
52
- return isAbsolute(expanded) ? resolve(expanded) : resolve(process.cwd(), expanded);
92
+ return isAbsolute(expanded)
93
+ ? resolve(expanded)
94
+ : resolve(process.cwd(), expanded);
95
+ }
96
+
97
+ function defaultProjectsRoot() {
98
+ return process.env.TFX_MCP_PROJECTS_ROOT
99
+ ? resolve(expandHome(process.env.TFX_MCP_PROJECTS_ROOT))
100
+ : join(homedir(), "Projects");
101
+ }
102
+
103
+ function globToRegExp(glob) {
104
+ const source = String(glob || "")
105
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
106
+ .replace(/\*/g, ".*")
107
+ .replace(/\?/g, ".");
108
+ return new RegExp(`^${source}$`);
109
+ }
110
+
111
+ function isExcludedProjectPath(relativePath, excludePatterns = []) {
112
+ const normalized = relativePath.replace(/\\/g, "/").replace(/^\/+/, "");
113
+ const basenameValue = basename(normalized);
114
+ return excludePatterns.some((pattern) => {
115
+ const re = globToRegExp(pattern);
116
+ return re.test(normalized) || re.test(basenameValue);
117
+ });
53
118
  }
54
119
 
55
120
  function normalizeForMatch(filePath) {
@@ -60,6 +125,14 @@ function pathBasename(filePath) {
60
125
  return basename(filePath.replace(/\\/g, "/")).toLowerCase();
61
126
  }
62
127
 
128
+ function isClaudeProjectMcpConfig(filePath) {
129
+ return normalizeForMatch(filePath).endsWith("/.claude/mcp.json");
130
+ }
131
+
132
+ function isClaudeUserConfig(filePath) {
133
+ return normalizeForMatch(filePath).endsWith("/.claude.json");
134
+ }
135
+
63
136
  function readJsonFile(filePath) {
64
137
  return JSON.parse(readFileSync(filePath, "utf8"));
65
138
  }
@@ -69,6 +142,12 @@ function writeJsonFile(filePath, data) {
69
142
  writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf8");
70
143
  }
71
144
 
145
+ function registryPath() {
146
+ return process.env.TFX_MCP_REGISTRY_PATH
147
+ ? resolve(process.env.TFX_MCP_REGISTRY_PATH)
148
+ : DEFAULT_REGISTRY_PATH;
149
+ }
150
+
72
151
  function ensureBackup(filePath) {
73
152
  const backupPath = `${filePath}.bak`;
74
153
  copyFileSync(filePath, backupPath);
@@ -77,7 +156,15 @@ function ensureBackup(filePath) {
77
156
 
78
157
  function isJsonMcpConfig(filePath) {
79
158
  const name = pathBasename(filePath);
80
- return name === "settings.json" || name === "settings.local.json" || name === ".mcp.json";
159
+ return (
160
+ isAntigravityConfig(filePath) ||
161
+ isClaudeUserConfig(filePath) ||
162
+ name === "settings.json" ||
163
+ name === "settings.local.json" ||
164
+ name === "mcp_config.json" ||
165
+ name === ".mcp.json" ||
166
+ isClaudeProjectMcpConfig(filePath)
167
+ );
81
168
  }
82
169
 
83
170
  function isCodexConfig(filePath) {
@@ -85,14 +172,59 @@ function isCodexConfig(filePath) {
85
172
  return normalized.endsWith("/.codex/config.toml");
86
173
  }
87
174
 
175
+ function isAntigravityConfig(filePath) {
176
+ const normalized = normalizeForMatch(filePath);
177
+ return normalized.endsWith("/.gemini/config/mcp_config.json");
178
+ }
179
+
180
+ function plaintextSecretHeaderClient(filePath) {
181
+ const client = detectClient(filePath);
182
+ return client === "gemini" || client === "antigravity" ? client : null;
183
+ }
184
+
185
+ function plaintextSecretPermissionHint(filePath) {
186
+ const normalized = normalizeForMatch(filePath);
187
+ if (normalized.endsWith("/.gemini/settings.json")) {
188
+ return "~/.gemini/settings.json";
189
+ }
190
+ if (normalized.endsWith("/.gemini/config/mcp_config.json")) {
191
+ return "~/.gemini/config/mcp_config.json";
192
+ }
193
+ return filePath;
194
+ }
195
+
196
+ function shouldLockJsonConfigPermissions(filePath) {
197
+ return Boolean(plaintextSecretHeaderClient(filePath));
198
+ }
199
+
200
+ function lockJsonConfigPermissions(filePath) {
201
+ if (!shouldLockJsonConfigPermissions(filePath)) return;
202
+ chmodSync(filePath, 0o600);
203
+ }
204
+
205
+ function isProtectedCodexConfigMutationEnv(env = process.env) {
206
+ return env.NODE_ENV === "test" || env.CI === "true" || env.TFX_TEST === "1";
207
+ }
208
+
209
+ function shouldSkipCodexConfigMutation() {
210
+ return (
211
+ process.env.TFX_CODEX_CONFIG_SYNC !== "1" &&
212
+ isProtectedCodexConfigMutationEnv()
213
+ );
214
+ }
215
+
88
216
  function detectClient(filePath) {
89
217
  const normalized = normalizeForMatch(filePath);
218
+ if (normalized.endsWith("/.gemini/config/mcp_config.json"))
219
+ return "antigravity";
90
220
  if (normalized.endsWith("/.gemini/settings.json")) return "gemini";
91
221
  if (normalized.endsWith("/.codex/config.toml")) return "codex";
92
222
  if (
93
- normalized.endsWith("/.claude/settings.json")
94
- || normalized.endsWith("/.claude/settings.local.json")
95
- || normalized.endsWith("/.mcp.json")
223
+ normalized.endsWith("/.claude.json") ||
224
+ normalized.endsWith("/.claude/settings.json") ||
225
+ normalized.endsWith("/.claude/settings.local.json") ||
226
+ normalized.endsWith("/.claude/mcp.json") ||
227
+ normalized.endsWith("/.mcp.json")
96
228
  ) {
97
229
  return "claude";
98
230
  }
@@ -101,19 +233,31 @@ function detectClient(filePath) {
101
233
 
102
234
  function detectLabel(filePath) {
103
235
  const normalized = normalizeForMatch(filePath);
236
+ if (normalized.endsWith("/.gemini/config/mcp_config.json"))
237
+ return "Antigravity";
104
238
  if (normalized.endsWith("/.gemini/settings.json")) return "Gemini";
239
+ if (normalized.endsWith("/.gemini/config/mcp_config.json"))
240
+ return "Antigravity";
105
241
  if (normalized.endsWith("/.codex/config.toml")) return "Codex";
242
+ if (normalized.endsWith("/.claude.json")) return "Claude User MCP";
106
243
  if (normalized.endsWith("/.claude/settings.json")) return "Claude User";
107
- if (normalized.endsWith("/.claude/settings.local.json")) return "Claude Local";
244
+ if (normalized.endsWith("/.claude/settings.local.json"))
245
+ return "Claude Local";
246
+ if (normalized.endsWith("/.claude/mcp.json")) return "Claude Project MCP";
108
247
  if (normalized.endsWith("/.mcp.json")) return "Project MCP";
109
248
  return basename(filePath);
110
249
  }
111
250
 
112
251
  function isPrimaryConfigTarget(filePath) {
113
252
  const normalized = normalizeForMatch(filePath);
114
- return normalized.endsWith("/.gemini/settings.json")
115
- || normalized.endsWith("/.codex/config.toml")
116
- || normalized.endsWith("/.mcp.json");
253
+ return (
254
+ normalized.endsWith("/.gemini/settings.json") ||
255
+ normalized.endsWith("/.gemini/config/mcp_config.json") ||
256
+ normalized.endsWith("/.codex/config.toml") ||
257
+ normalized.endsWith("/.claude.json") ||
258
+ normalized.endsWith("/.claude/mcp.json") ||
259
+ normalized.endsWith("/.mcp.json")
260
+ );
117
261
  }
118
262
 
119
263
  function normalizeUrl(value) {
@@ -139,12 +283,95 @@ function parseTomlScalar(rawValue) {
139
283
  if (value === "true") return true;
140
284
  if (value === "false") return false;
141
285
  if (/^-?\d[\d_]*$/.test(value)) return Number(value.replace(/_/g, ""));
286
+ if (value.startsWith("[") && value.endsWith("]")) {
287
+ return parseTomlArray(value);
288
+ }
289
+ if (value.startsWith("{") && value.endsWith("}")) {
290
+ return parseTomlInlineTable(value);
291
+ }
142
292
  if (value.startsWith('"') && value.endsWith('"')) {
143
293
  return value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
144
294
  }
145
295
  return value;
146
296
  }
147
297
 
298
+ function parseTomlArray(rawValue) {
299
+ const source = String(rawValue || "").trim();
300
+ const body = source.slice(1, -1).trim();
301
+ if (!body) return [];
302
+
303
+ const parts = [];
304
+ let current = "";
305
+ let inString = false;
306
+ let escaped = false;
307
+ for (const char of body) {
308
+ if (escaped) {
309
+ current += char;
310
+ escaped = false;
311
+ continue;
312
+ }
313
+ if (char === "\\" && inString) {
314
+ current += char;
315
+ escaped = true;
316
+ continue;
317
+ }
318
+ if (char === '"') inString = !inString;
319
+ if (char === "," && !inString) {
320
+ parts.push(current.trim());
321
+ current = "";
322
+ continue;
323
+ }
324
+ current += char;
325
+ }
326
+ if (current.trim()) parts.push(current.trim());
327
+
328
+ return parts.map((part) => parseTomlScalar(part));
329
+ }
330
+
331
+ function parseTomlInlineTable(rawValue) {
332
+ const source = String(rawValue || "").trim();
333
+ const body = source.slice(1, -1).trim();
334
+ if (!body) return {};
335
+
336
+ const parts = [];
337
+ let current = "";
338
+ let inString = false;
339
+ let escaped = false;
340
+ for (const char of body) {
341
+ if (escaped) {
342
+ current += char;
343
+ escaped = false;
344
+ continue;
345
+ }
346
+ if (char === "\\" && inString) {
347
+ current += char;
348
+ escaped = true;
349
+ continue;
350
+ }
351
+ if (char === '"') inString = !inString;
352
+ if (char === "," && !inString) {
353
+ parts.push(current.trim());
354
+ current = "";
355
+ continue;
356
+ }
357
+ current += char;
358
+ }
359
+ if (current.trim()) parts.push(current.trim());
360
+
361
+ const table = {};
362
+ for (const part of parts) {
363
+ const match = part.match(
364
+ /^\s*(?:"((?:\\"|[^"])*)"|([A-Za-z0-9_-]+))\s*=\s*(.+?)\s*$/,
365
+ );
366
+ if (!match) continue;
367
+ const key = (match[1] || match[2] || "")
368
+ .replace(/\\"/g, '"')
369
+ .replace(/\\\\/g, "\\");
370
+ table[key] = parseTomlScalar(match[3]);
371
+ }
372
+ return table;
373
+ }
374
+
148
375
  function parseCodexMcpServers(raw) {
149
376
  const lines = String(raw || "").split(/\r?\n/);
150
377
  const servers = {};
@@ -173,6 +400,231 @@ function parseCodexMcpServers(raw) {
173
400
  return servers;
174
401
  }
175
402
 
403
+ function isValidHeaderName(name) {
404
+ return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(String(name || ""));
405
+ }
406
+
407
+ function isValidEnvName(name) {
408
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(String(name || ""));
409
+ }
410
+
411
+ function normalizeHeaderDescriptors(headers = {}) {
412
+ const normalized = {};
413
+ if (!headers || typeof headers !== "object" || Array.isArray(headers)) {
414
+ return normalized;
415
+ }
416
+ for (const [name, descriptor] of Object.entries(headers)) {
417
+ if (!isValidHeaderName(name)) continue;
418
+ if (
419
+ descriptor &&
420
+ typeof descriptor === "object" &&
421
+ !Array.isArray(descriptor)
422
+ ) {
423
+ if (
424
+ Object.hasOwn(descriptor, "value") &&
425
+ typeof descriptor.value === "string"
426
+ ) {
427
+ normalized[name] = { value: descriptor.value };
428
+ } else if (
429
+ Object.hasOwn(descriptor, "env") &&
430
+ typeof descriptor.env === "string" &&
431
+ descriptor.env.trim()
432
+ ) {
433
+ normalized[name] = { env: descriptor.env.trim() };
434
+ if (typeof descriptor.prefix === "string") {
435
+ normalized[name].prefix = descriptor.prefix;
436
+ }
437
+ }
438
+ }
439
+ }
440
+ return normalized;
441
+ }
442
+
443
+ function normalizeEnvDescriptors(env = {}) {
444
+ const normalized = {};
445
+ if (!env || typeof env !== "object" || Array.isArray(env)) {
446
+ return normalized;
447
+ }
448
+ for (const [name, descriptor] of Object.entries(env)) {
449
+ if (!isValidEnvName(name)) continue;
450
+ if (
451
+ descriptor &&
452
+ typeof descriptor === "object" &&
453
+ !Array.isArray(descriptor) &&
454
+ Object.hasOwn(descriptor, "env") &&
455
+ typeof descriptor.env === "string" &&
456
+ descriptor.env.trim()
457
+ ) {
458
+ normalized[name] = { env: descriptor.env.trim() };
459
+ }
460
+ }
461
+ return normalized;
462
+ }
463
+
464
+ function resolveEnvDescriptors(name, serverConfig) {
465
+ const descriptors = normalizeEnvDescriptors(serverConfig?.env || {});
466
+ const env = {};
467
+ const warnings = [];
468
+
469
+ for (const [envKey, descriptor] of Object.entries(descriptors)) {
470
+ const envName = descriptor.env;
471
+ const envValue = process.env[envName];
472
+ if (typeof envValue !== "string" || envValue.length === 0) {
473
+ warnings.push(
474
+ `[mcp-guard] ${name}.${envKey} env skipped: env ${envName} is not set`,
475
+ );
476
+ continue;
477
+ }
478
+ env[envKey] = envValue;
479
+ }
480
+
481
+ return { descriptors, env, warnings };
482
+ }
483
+
484
+ function resolveHeaderDescriptors(name, serverConfig, filePath) {
485
+ const descriptors = normalizeHeaderDescriptors(serverConfig?.headers || {});
486
+ const headers = {};
487
+ const warnings = [];
488
+ const codex = {
489
+ bearer_token_env_var: null,
490
+ env_http_headers: {},
491
+ http_headers: {},
492
+ };
493
+ const codexTarget = isCodexConfig(filePath);
494
+
495
+ for (const [headerName, descriptor] of Object.entries(descriptors)) {
496
+ if (Object.hasOwn(descriptor, "value")) {
497
+ headers[headerName] = descriptor.value;
498
+ if (codexTarget) codex.http_headers[headerName] = descriptor.value;
499
+ continue;
500
+ }
501
+
502
+ const envName = descriptor.env;
503
+ const envValue = process.env[envName];
504
+ const prefix = descriptor.prefix || "";
505
+ if (typeof envValue !== "string" || envValue.length === 0) {
506
+ warnings.push(
507
+ `[mcp-guard] ${name}.${headerName} header skipped: env ${envName} is not set`,
508
+ );
509
+ continue;
510
+ }
511
+
512
+ headers[headerName] = `${prefix}${envValue}`;
513
+
514
+ const plaintextClient = plaintextSecretHeaderClient(filePath);
515
+ if (plaintextClient) {
516
+ warnings.push(
517
+ `[tfx mcp sync] warn: ${plaintextClient} server '${name}' headers contain plaintext secret resolved from $${envName}. Ensure ${plaintextSecretPermissionHint(filePath)} permissions stay 600.`,
518
+ );
519
+ }
520
+
521
+ if (!codexTarget) continue;
522
+
523
+ if (
524
+ headerName.toLowerCase() === "authorization" &&
525
+ /^Bearer\s+$/i.test(prefix)
526
+ ) {
527
+ codex.bearer_token_env_var = envName;
528
+ } else if (!prefix) {
529
+ codex.env_http_headers[headerName] = envName;
530
+ } else {
531
+ codex.http_headers[headerName] = `${prefix}${envValue}`;
532
+ warnings.push(
533
+ `[mcp-guard] ${name}.${headerName} header uses resolved literal in Codex TOML because prefixed env headers are unsupported`,
534
+ );
535
+ }
536
+ }
537
+
538
+ return {
539
+ descriptors,
540
+ headers,
541
+ warnings,
542
+ codex: {
543
+ ...(codex.bearer_token_env_var
544
+ ? { bearer_token_env_var: codex.bearer_token_env_var }
545
+ : {}),
546
+ env_http_headers: codex.env_http_headers,
547
+ http_headers: codex.http_headers,
548
+ },
549
+ };
550
+ }
551
+
552
+ function hasOwnEntries(value) {
553
+ return value && typeof value === "object" && Object.keys(value).length > 0;
554
+ }
555
+
556
+ function redactHeaders(headers = {}) {
557
+ const redacted = {};
558
+ for (const key of Object.keys(headers || {})) {
559
+ redacted[key] = "***REDACTED***";
560
+ }
561
+ return redacted;
562
+ }
563
+
564
+ function redactServerConfig(config = {}) {
565
+ if (!config || typeof config !== "object") return config;
566
+ return {
567
+ ...config,
568
+ ...(config.headers ? { headers: redactHeaders(config.headers) } : {}),
569
+ };
570
+ }
571
+
572
+ function descriptorsFromCodexConfig(config = {}) {
573
+ const descriptors = {};
574
+ if (typeof config.bearer_token_env_var === "string") {
575
+ descriptors.Authorization = {
576
+ env: config.bearer_token_env_var,
577
+ prefix: "Bearer ",
578
+ };
579
+ }
580
+ if (config.env_http_headers && typeof config.env_http_headers === "object") {
581
+ for (const [name, envName] of Object.entries(config.env_http_headers)) {
582
+ if (typeof envName === "string") descriptors[name] = { env: envName };
583
+ }
584
+ }
585
+ if (config.http_headers && typeof config.http_headers === "object") {
586
+ for (const [name, value] of Object.entries(config.http_headers)) {
587
+ if (typeof value === "string") descriptors[name] = { value };
588
+ }
589
+ }
590
+ return descriptors;
591
+ }
592
+
593
+ function descriptorsFromJsonConfig(config = {}) {
594
+ const descriptors = {};
595
+ if (!config?.headers || typeof config.headers !== "object") {
596
+ return descriptors;
597
+ }
598
+ for (const [name, value] of Object.entries(config.headers)) {
599
+ if (typeof value === "string") descriptors[name] = { value };
600
+ }
601
+ return descriptors;
602
+ }
603
+
604
+ function headerNames(headers = {}) {
605
+ return Object.keys(headers || {}).sort();
606
+ }
607
+
608
+ function sameHeaderDescriptors(left = {}, right = {}) {
609
+ const leftNames = headerNames(left);
610
+ const rightNames = headerNames(right);
611
+ if (JSON.stringify(leftNames) !== JSON.stringify(rightNames)) return false;
612
+ return leftNames.every(
613
+ (name) => JSON.stringify(left[name]) === JSON.stringify(right[name]),
614
+ );
615
+ }
616
+
617
+ function headerStatus(expected = {}, actual = {}) {
618
+ const expectedNames = headerNames(expected);
619
+ const actualNames = headerNames(actual);
620
+ if (expectedNames.length === 0 && actualNames.length === 0) return "ok";
621
+ if (expectedNames.some((name) => !Object.hasOwn(actual, name))) {
622
+ return "missing";
623
+ }
624
+ if (!sameHeaderDescriptors(expected, actual)) return "stale";
625
+ return "ok";
626
+ }
627
+
176
628
  function removeTomlSection(raw, sectionName) {
177
629
  const lines = String(raw || "").split(/\r?\n/);
178
630
  const output = [];
@@ -192,30 +644,113 @@ function removeTomlSection(raw, sectionName) {
192
644
  if (!skipping) output.push(line);
193
645
  }
194
646
 
195
- const cleaned = output.join("\n").replace(/\n{3,}$/g, "\n\n").replace(/^\n+/, "");
647
+ const cleaned = output
648
+ .join("\n")
649
+ .replace(/\n{3,}$/g, "\n\n")
650
+ .replace(/^\n+/, "");
196
651
  return cleaned.length > 0 ? cleaned.replace(/\n{3,}/g, "\n\n") : "";
197
652
  }
198
653
 
199
- function upsertTomlUrlServer(raw, name, url) {
200
- const section = [
201
- `[mcp_servers.${name}]`,
202
- `url = ${formatTomlString(url)}`,
203
- ];
204
- const withoutExisting = removeTomlSection(raw, name).trimEnd();
205
- return withoutExisting.length > 0
206
- ? `${withoutExisting}\n\n${section.join("\n")}\n`
207
- : `${section.join("\n")}\n`;
654
+ function formatTomlInlineTable(data = {}) {
655
+ const entries = Object.entries(data);
656
+ if (entries.length === 0) return null;
657
+ return `{ ${entries
658
+ .map(
659
+ ([key, value]) => `${formatTomlString(key)} = ${formatTomlString(value)}`,
660
+ )
661
+ .join(", ")} }`;
662
+ }
663
+
664
+ function formatTomlArray(values = []) {
665
+ return `[${values.map((value) => formatTomlString(value)).join(", ")}]`;
666
+ }
667
+
668
+ function upsertTomlServer(raw, name, config, codex = {}) {
669
+ const lines = String(raw || "").split(/\r?\n/);
670
+ const header = `[mcp_servers.${name}]`;
671
+ const managedKeys = new Set([
672
+ "url",
673
+ "transport",
674
+ "command",
675
+ "args",
676
+ "bearer_token_env_var",
677
+ "http_headers",
678
+ "env_http_headers",
679
+ "env",
680
+ ]);
681
+ const nextManagedLines = [];
682
+ if (typeof config.url === "string") {
683
+ nextManagedLines.push(`url = ${formatTomlString(config.url)}`);
684
+ }
685
+ if (typeof config.transport === "string") {
686
+ nextManagedLines.push(`transport = ${formatTomlString(config.transport)}`);
687
+ }
688
+ if (typeof config.command === "string") {
689
+ nextManagedLines.push(`command = ${formatTomlString(config.command)}`);
690
+ }
691
+ if (Array.isArray(config.args)) {
692
+ nextManagedLines.push(`args = ${formatTomlArray(config.args)}`);
693
+ }
694
+ const env = formatTomlInlineTable(config.env || {});
695
+ if (env) nextManagedLines.push(`env = ${env}`);
696
+ if (codex.bearer_token_env_var) {
697
+ nextManagedLines.push(
698
+ `bearer_token_env_var = ${formatTomlString(codex.bearer_token_env_var)}`,
699
+ );
700
+ }
701
+ const httpHeaders = formatTomlInlineTable(codex.http_headers || {});
702
+ if (httpHeaders) nextManagedLines.push(`http_headers = ${httpHeaders}`);
703
+ const envHttpHeaders = formatTomlInlineTable(codex.env_http_headers || {});
704
+ if (envHttpHeaders) {
705
+ nextManagedLines.push(`env_http_headers = ${envHttpHeaders}`);
706
+ }
707
+
708
+ const sectionStart = lines.findIndex((line) => line.trim() === header);
709
+ if (sectionStart < 0) {
710
+ const section = [header, ...nextManagedLines].join("\n");
711
+ const withoutTrailing = String(raw || "").trimEnd();
712
+ return withoutTrailing.length > 0
713
+ ? `${withoutTrailing}\n\n${section}\n`
714
+ : `${section}\n`;
715
+ }
716
+
717
+ const output = [];
718
+ for (let index = 0; index < lines.length; index += 1) {
719
+ output.push(lines[index]);
720
+ if (index !== sectionStart) continue;
721
+
722
+ index += 1;
723
+ const preserved = [];
724
+ while (index < lines.length && !/^\s*\[/.test(lines[index])) {
725
+ const keyMatch = lines[index].match(/^\s*([A-Za-z0-9_]+)\s*=/);
726
+ if (!keyMatch || !managedKeys.has(keyMatch[1])) {
727
+ preserved.push(lines[index]);
728
+ }
729
+ index += 1;
730
+ }
731
+ output.push(...nextManagedLines, ...preserved.filter(Boolean));
732
+ index -= 1;
733
+ }
734
+
735
+ return output.join("\n");
208
736
  }
209
737
 
210
738
  function getHubServerEntry(registry) {
211
739
  const entries = Object.entries(registry?.servers || {});
212
740
  if (entries.length === 0) {
213
- return ["tfx-hub", { url: `${registry?.defaults?.hub_base || "http://127.0.0.1:27888"}${DEFAULT_HUB_PATH}` }];
741
+ return [
742
+ "tfx-hub",
743
+ {
744
+ url: `${registry?.defaults?.hub_base || "http://127.0.0.1:27888"}${DEFAULT_HUB_PATH}`,
745
+ },
746
+ ];
214
747
  }
215
748
 
216
- return entries.find(([name]) => name === "tfx-hub")
217
- || entries.find(([, config]) => config?.transport === "hub-url")
218
- || entries[0];
749
+ return (
750
+ entries.find(([name]) => name === "tfx-hub") ||
751
+ entries.find(([, config]) => config?.transport === "hub-url") ||
752
+ entries[0]
753
+ );
219
754
  }
220
755
 
221
756
  function _makeHubRuntimeConfig() {
@@ -224,30 +759,157 @@ function _makeHubRuntimeConfig() {
224
759
 
225
760
  function serverTargets(serverConfig) {
226
761
  if (Array.isArray(serverConfig?.targets) && serverConfig.targets.length > 0) {
227
- return [...new Set(serverConfig.targets.map((value) => String(value).trim()).filter(Boolean))];
762
+ return [
763
+ ...new Set(
764
+ serverConfig.targets
765
+ .map((value) => String(value).trim())
766
+ .filter(Boolean),
767
+ ),
768
+ ];
228
769
  }
229
- return ["claude", "gemini", "codex"];
770
+ return ["claude", "gemini", "codex", "antigravity"];
230
771
  }
231
772
 
232
773
  function serverAppliesToClient(serverConfig, client) {
233
774
  return serverTargets(serverConfig).includes(client);
234
775
  }
235
776
 
236
- function buildDesiredServerRecord(name, serverConfig, filePath) {
237
- const url = serverConfig?.transport === "hub-url"
238
- ? resolveHubUrl()
239
- : normalizeUrl(serverConfig?.url || "");
777
+ function serverPolicy(serverConfig = {}, registry = {}) {
778
+ if (SERVER_POLICIES.has(serverConfig.policy)) return serverConfig.policy;
779
+ const transport = serverConfig.transport || registry?.defaults?.transport;
780
+ if (transport === "stdio") return "stdio";
781
+ return "hosted";
782
+ }
783
+
784
+ function gatewaySseUrl(name, serverConfig = {}) {
785
+ if (typeof serverConfig.url === "string" && serverConfig.url.trim()) {
786
+ return normalizeUrl(serverConfig.url);
787
+ }
788
+ const port =
789
+ Number(serverConfig.gateway_port || serverConfig.port || 0) ||
790
+ GATEWAY_PORTS[name];
791
+ if (!Number.isFinite(port) || port <= 0) return "";
792
+ return `http://127.0.0.1:${port}/sse`;
793
+ }
794
+
795
+ function syncDenylistEntries(policy = {}) {
796
+ if (!Array.isArray(policy?.sync_denylist)) return new Set();
797
+ return new Set(
798
+ policy.sync_denylist
799
+ .map((entry) =>
800
+ String(entry || "")
801
+ .trim()
802
+ .toLowerCase(),
803
+ )
804
+ .filter(Boolean),
805
+ );
806
+ }
807
+
808
+ function syncDenylistKey(client, serverName) {
809
+ return `${String(client || "")
810
+ .trim()
811
+ .toLowerCase()}:${String(serverName || "")
812
+ .trim()
813
+ .toLowerCase()}`;
814
+ }
815
+
816
+ export function buildDesiredServerRecord(name, serverConfig, filePath) {
817
+ const policy = serverPolicy(serverConfig);
818
+ if (policy === "stdio") {
819
+ const resolvedEnv = resolveEnvDescriptors(name, serverConfig);
820
+ const envConfig = hasOwnEntries(resolvedEnv.env)
821
+ ? { env: resolvedEnv.env }
822
+ : {};
823
+ return {
824
+ name,
825
+ config: {
826
+ command: serverConfig.command,
827
+ args: Array.isArray(serverConfig.args) ? [...serverConfig.args] : [],
828
+ ...envConfig,
829
+ },
830
+ envDescriptors: resolvedEnv.descriptors,
831
+ codex: {},
832
+ warnings: resolvedEnv.warnings,
833
+ };
834
+ }
835
+
836
+ const url =
837
+ policy === "gateway-sse"
838
+ ? gatewaySseUrl(name, serverConfig)
839
+ : serverConfig?.transport === "hub-url"
840
+ ? resolveHubUrl()
841
+ : normalizeUrl(serverConfig?.url || "");
240
842
  const basenameValue = pathBasename(filePath);
843
+ const resolvedHeaders = resolveHeaderDescriptors(
844
+ name,
845
+ serverConfig,
846
+ filePath,
847
+ );
848
+ const headerConfig = hasOwnEntries(resolvedHeaders.headers)
849
+ ? { headers: resolvedHeaders.headers }
850
+ : {};
851
+
852
+ if (policy === "gateway-sse") {
853
+ if (isCodexConfig(filePath)) {
854
+ return {
855
+ name,
856
+ config: { url, transport: "sse" },
857
+ headerDescriptors: {},
858
+ codex: {},
859
+ warnings: [],
860
+ };
861
+ }
862
+
863
+ return {
864
+ name,
865
+ config: { type: "sse", url },
866
+ headerDescriptors: {},
867
+ codex: {},
868
+ warnings: [],
869
+ };
870
+ }
241
871
 
242
- if (basenameValue === ".mcp.json") {
243
- return { name, config: { type: "url", url } };
872
+ if (
873
+ basenameValue === ".mcp.json" ||
874
+ isClaudeProjectMcpConfig(filePath) ||
875
+ isClaudeUserConfig(filePath)
876
+ ) {
877
+ return {
878
+ name,
879
+ config: { type: "http", url, ...headerConfig },
880
+ headerDescriptors: resolvedHeaders.descriptors,
881
+ codex: resolvedHeaders.codex,
882
+ warnings: resolvedHeaders.warnings,
883
+ };
884
+ }
885
+
886
+ if (isAntigravityConfig(filePath)) {
887
+ return {
888
+ name,
889
+ config: { url, type: "http", ...headerConfig },
890
+ headerDescriptors: resolvedHeaders.descriptors,
891
+ codex: resolvedHeaders.codex,
892
+ warnings: resolvedHeaders.warnings,
893
+ };
244
894
  }
245
895
 
246
896
  if (isCodexConfig(filePath)) {
247
- return { name, config: { url } };
897
+ return {
898
+ name,
899
+ config: { url, ...headerConfig },
900
+ headerDescriptors: resolvedHeaders.descriptors,
901
+ codex: resolvedHeaders.codex,
902
+ warnings: resolvedHeaders.warnings,
903
+ };
248
904
  }
249
905
 
250
- return { name, config: { url } };
906
+ return {
907
+ name,
908
+ config: { url, ...headerConfig },
909
+ headerDescriptors: resolvedHeaders.descriptors,
910
+ codex: resolvedHeaders.codex,
911
+ warnings: resolvedHeaders.warnings,
912
+ };
251
913
  }
252
914
 
253
915
  function scanJsonConfig(filePath) {
@@ -266,22 +928,54 @@ function scanJsonConfig(filePath) {
266
928
  try {
267
929
  const parsed = readJsonFile(filePath);
268
930
  const mcpServers = parsed?.mcpServers;
269
- const servers = !mcpServers || typeof mcpServers !== "object"
270
- ? []
271
- : Object.entries(mcpServers)
272
- .filter(([name, config]) => typeof name === "string" && config && typeof config === "object")
273
- .map(([name, config]) => ({
274
- name: name.trim(),
275
- url: typeof config.url === "string" ? normalizeUrl(config.url) : "",
276
- command: typeof config.command === "string" ? config.command : "",
277
- type: typeof config.type === "string" ? config.type : "",
278
- transport: typeof config.url === "string" && config.url
279
- ? "url"
280
- : typeof config.command === "string" && config.command
281
- ? "stdio"
282
- : "unknown",
283
- raw: config,
284
- }));
931
+ const servers =
932
+ !mcpServers || typeof mcpServers !== "object"
933
+ ? []
934
+ : Object.entries(mcpServers)
935
+ .filter(
936
+ ([name, config]) =>
937
+ typeof name === "string" &&
938
+ config &&
939
+ typeof config === "object",
940
+ )
941
+ .map(([name, config]) => ({
942
+ name: name.trim(),
943
+ url:
944
+ typeof config.url === "string" ? normalizeUrl(config.url) : "",
945
+ command: typeof config.command === "string" ? config.command : "",
946
+ args: Array.isArray(config.args)
947
+ ? config.args.filter((value) => typeof value === "string")
948
+ : [],
949
+ env:
950
+ config.env &&
951
+ typeof config.env === "object" &&
952
+ !Array.isArray(config.env)
953
+ ? Object.fromEntries(
954
+ Object.entries(config.env).filter(
955
+ ([, value]) => typeof value === "string",
956
+ ),
957
+ )
958
+ : {},
959
+ type: typeof config.type === "string" ? config.type : "",
960
+ headers:
961
+ config.headers &&
962
+ typeof config.headers === "object" &&
963
+ !Array.isArray(config.headers)
964
+ ? Object.fromEntries(
965
+ Object.entries(config.headers).filter(
966
+ ([, value]) => typeof value === "string",
967
+ ),
968
+ )
969
+ : {},
970
+ headerDescriptors: descriptorsFromJsonConfig(config),
971
+ transport:
972
+ typeof config.url === "string" && config.url
973
+ ? "url"
974
+ : typeof config.command === "string" && config.command
975
+ ? "stdio"
976
+ : "unknown",
977
+ raw: config,
978
+ }));
285
979
 
286
980
  return {
287
981
  filePath,
@@ -325,11 +1019,30 @@ function scanCodexConfig(filePath) {
325
1019
  name,
326
1020
  url: typeof config.url === "string" ? normalizeUrl(config.url) : "",
327
1021
  command: typeof config.command === "string" ? config.command : "",
328
- transport: typeof config.url === "string" && config.url
329
- ? "url"
330
- : typeof config.command === "string" && config.command
331
- ? "stdio"
332
- : "unknown",
1022
+ args: Array.isArray(config.args)
1023
+ ? config.args.filter((value) => typeof value === "string")
1024
+ : [],
1025
+ env:
1026
+ config.env &&
1027
+ typeof config.env === "object" &&
1028
+ !Array.isArray(config.env)
1029
+ ? Object.fromEntries(
1030
+ Object.entries(config.env).filter(
1031
+ ([, value]) => typeof value === "string",
1032
+ ),
1033
+ )
1034
+ : {},
1035
+ headers:
1036
+ config.http_headers && typeof config.http_headers === "object"
1037
+ ? config.http_headers
1038
+ : {},
1039
+ headerDescriptors: descriptorsFromCodexConfig(config),
1040
+ transport:
1041
+ typeof config.url === "string" && config.url
1042
+ ? "url"
1043
+ : typeof config.command === "string" && config.command
1044
+ ? "stdio"
1045
+ : "unknown",
333
1046
  raw: config,
334
1047
  }));
335
1048
 
@@ -368,7 +1081,11 @@ function updateJsonConfig(filePath, updates = [], removals = []) {
368
1081
  parsed = {};
369
1082
  }
370
1083
 
371
- if (!parsed.mcpServers || typeof parsed.mcpServers !== "object" || Array.isArray(parsed.mcpServers)) {
1084
+ if (
1085
+ !parsed.mcpServers ||
1086
+ typeof parsed.mcpServers !== "object" ||
1087
+ Array.isArray(parsed.mcpServers)
1088
+ ) {
372
1089
  parsed.mcpServers = {};
373
1090
  }
374
1091
 
@@ -383,8 +1100,34 @@ function updateJsonConfig(filePath, updates = [], removals = []) {
383
1100
 
384
1101
  for (const update of updates) {
385
1102
  const current = parsed.mcpServers[update.name];
386
- const nextConfig = { ...(current && typeof current === "object" ? current : {}), ...update.config };
387
- const changed = JSON.stringify(current || null) !== JSON.stringify(nextConfig);
1103
+ const nextConfig = {
1104
+ ...(current && typeof current === "object" ? current : {}),
1105
+ ...update.config,
1106
+ };
1107
+ if (Object.hasOwn(update.config, "command")) {
1108
+ delete nextConfig.url;
1109
+ delete nextConfig.type;
1110
+ delete nextConfig.headers;
1111
+ }
1112
+ if (Object.hasOwn(update.config, "url")) {
1113
+ delete nextConfig.command;
1114
+ delete nextConfig.args;
1115
+ delete nextConfig.env;
1116
+ }
1117
+ if (!Object.hasOwn(update.config, "type")) {
1118
+ delete nextConfig.type;
1119
+ }
1120
+ if (!Object.hasOwn(update.config, "transport")) {
1121
+ delete nextConfig.transport;
1122
+ }
1123
+ if (!Object.hasOwn(update.config, "headers")) {
1124
+ delete nextConfig.headers;
1125
+ }
1126
+ if (!Object.hasOwn(update.config, "env")) {
1127
+ delete nextConfig.env;
1128
+ }
1129
+ const changed =
1130
+ JSON.stringify(current || null) !== JSON.stringify(nextConfig);
388
1131
  if (changed) {
389
1132
  parsed.mcpServers[update.name] = nextConfig;
390
1133
  modified = true;
@@ -396,11 +1139,21 @@ function updateJsonConfig(filePath, updates = [], removals = []) {
396
1139
  }
397
1140
 
398
1141
  writeJsonFile(resolvedPath, parsed);
1142
+ lockJsonConfigPermissions(resolvedPath);
399
1143
  return { modified: true, filePath: resolvedPath };
400
1144
  }
401
1145
 
402
1146
  function updateCodexConfig(filePath, updates = [], removals = []) {
403
1147
  const resolvedPath = resolveFilePath(filePath);
1148
+ if (shouldSkipCodexConfigMutation()) {
1149
+ return {
1150
+ modified: false,
1151
+ filePath: resolvedPath,
1152
+ skipped: true,
1153
+ reason: "protected-env",
1154
+ };
1155
+ }
1156
+
404
1157
  let raw = existsSync(resolvedPath) ? readFileSync(resolvedPath, "utf8") : "";
405
1158
 
406
1159
  for (const name of removals) {
@@ -408,11 +1161,13 @@ function updateCodexConfig(filePath, updates = [], removals = []) {
408
1161
  }
409
1162
 
410
1163
  for (const update of updates) {
411
- raw = upsertTomlUrlServer(raw, update.name, update.config.url);
1164
+ raw = upsertTomlServer(raw, update.name, update.config, update.codex || {});
412
1165
  }
413
1166
 
414
1167
  const finalRaw = raw.trim().length > 0 ? `${raw.trimEnd()}\n` : "";
415
- const previousRaw = existsSync(resolvedPath) ? readFileSync(resolvedPath, "utf8") : "";
1168
+ const previousRaw = existsSync(resolvedPath)
1169
+ ? readFileSync(resolvedPath, "utf8")
1170
+ : "";
416
1171
  if (finalRaw === previousRaw) {
417
1172
  return { modified: false, filePath: resolvedPath };
418
1173
  }
@@ -439,7 +1194,7 @@ function scanConfig(filePath) {
439
1194
  }
440
1195
 
441
1196
  export function getRegistryPath() {
442
- return REGISTRY_PATH;
1197
+ return registryPath();
443
1198
  }
444
1199
 
445
1200
  export function createDefaultRegistry() {
@@ -460,7 +1215,11 @@ export function validateRegistry(registry) {
460
1215
  errors.push("registry.defaults must be an object");
461
1216
  }
462
1217
 
463
- if (!registry.servers || typeof registry.servers !== "object" || Array.isArray(registry.servers)) {
1218
+ if (
1219
+ !registry.servers ||
1220
+ typeof registry.servers !== "object" ||
1221
+ Array.isArray(registry.servers)
1222
+ ) {
464
1223
  errors.push("registry.servers must be an object");
465
1224
  } else {
466
1225
  for (const [name, server] of Object.entries(registry.servers)) {
@@ -472,23 +1231,233 @@ export function validateRegistry(registry) {
472
1231
  errors.push(`registry.servers.${name} must be an object`);
473
1232
  continue;
474
1233
  }
475
- if (typeof server.url !== "string" || !server.url.trim()) {
1234
+ const policy = serverPolicy(server, registry);
1235
+ if (
1236
+ typeof server.policy !== "string" ||
1237
+ !SERVER_POLICIES.has(server.policy)
1238
+ ) {
1239
+ errors.push(
1240
+ `registry.servers.${name}.policy must be hosted, gateway-sse, or stdio`,
1241
+ );
1242
+ }
1243
+ const transport = server.transport || registry.defaults?.transport;
1244
+ if (!["hub-url", "http", "stdio"].includes(transport)) {
1245
+ errors.push(
1246
+ `registry.servers.${name}.transport must be hub-url, http, or stdio`,
1247
+ );
1248
+ }
1249
+ if (
1250
+ policy === "hosted" &&
1251
+ (typeof server.url !== "string" || !server.url.trim())
1252
+ ) {
476
1253
  errors.push(`registry.servers.${name}.url must be a non-empty string`);
477
1254
  }
1255
+ if (policy === "gateway-sse") {
1256
+ const port = Number(server.gateway_port || server.port || 0);
1257
+ const hasGatewayUrl =
1258
+ typeof server.url === "string" && server.url.trim().length > 0;
1259
+ if (
1260
+ !hasGatewayUrl &&
1261
+ (!Number.isInteger(port) || port <= 0 || port > 65535)
1262
+ ) {
1263
+ errors.push(
1264
+ `registry.servers.${name}.gateway_port must be a valid TCP port for gateway-sse`,
1265
+ );
1266
+ }
1267
+ }
1268
+ if (policy === "stdio" && server.url !== undefined) {
1269
+ errors.push(`registry.servers.${name}.url is not used for stdio`);
1270
+ }
1271
+ if (policy === "stdio") {
1272
+ if (typeof server.command !== "string" || !server.command.trim()) {
1273
+ errors.push(
1274
+ `registry.servers.${name}.command must be a non-empty string for stdio`,
1275
+ );
1276
+ }
1277
+ if (
1278
+ !Array.isArray(server.args) ||
1279
+ server.args.some((arg) => typeof arg !== "string")
1280
+ ) {
1281
+ errors.push(
1282
+ `registry.servers.${name}.args must be an array of strings for stdio`,
1283
+ );
1284
+ }
1285
+ }
1286
+ if (policy !== "stdio" && server.command !== undefined) {
1287
+ errors.push(
1288
+ `registry.servers.${name}.command is allowed only for stdio transport`,
1289
+ );
1290
+ }
1291
+ if (policy !== "stdio" && server.args !== undefined) {
1292
+ errors.push(
1293
+ `registry.servers.${name}.args is allowed only for stdio transport`,
1294
+ );
1295
+ }
1296
+ if (server.env !== undefined) {
1297
+ if (policy !== "stdio") {
1298
+ errors.push(
1299
+ `registry.servers.${name}.env is allowed only for stdio transport`,
1300
+ );
1301
+ }
1302
+ if (
1303
+ !server.env ||
1304
+ typeof server.env !== "object" ||
1305
+ Array.isArray(server.env)
1306
+ ) {
1307
+ errors.push(`registry.servers.${name}.env must be an object`);
1308
+ } else {
1309
+ for (const [envKey, descriptor] of Object.entries(server.env)) {
1310
+ if (!isValidEnvName(envKey)) {
1311
+ errors.push(
1312
+ `registry.servers.${name}.env contains invalid env name ${envKey}`,
1313
+ );
1314
+ continue;
1315
+ }
1316
+ if (
1317
+ !descriptor ||
1318
+ typeof descriptor !== "object" ||
1319
+ Array.isArray(descriptor)
1320
+ ) {
1321
+ errors.push(
1322
+ `registry.servers.${name}.env.${envKey} must be a descriptor object`,
1323
+ );
1324
+ continue;
1325
+ }
1326
+ const keys = Object.keys(descriptor);
1327
+ if (
1328
+ keys.length !== 1 ||
1329
+ !Object.hasOwn(descriptor, "env") ||
1330
+ typeof descriptor.env !== "string" ||
1331
+ !descriptor.env.trim()
1332
+ ) {
1333
+ errors.push(
1334
+ `registry.servers.${name}.env.${envKey}.env must be a non-empty string`,
1335
+ );
1336
+ }
1337
+ }
1338
+ }
1339
+ }
1340
+ if (server.headers !== undefined) {
1341
+ if (policy !== "hosted") {
1342
+ errors.push(
1343
+ `registry.servers.${name}.headers is allowed only for hosted policy`,
1344
+ );
1345
+ }
1346
+ if (
1347
+ !server.headers ||
1348
+ typeof server.headers !== "object" ||
1349
+ Array.isArray(server.headers)
1350
+ ) {
1351
+ errors.push(`registry.servers.${name}.headers must be an object`);
1352
+ } else {
1353
+ for (const [headerName, descriptor] of Object.entries(
1354
+ server.headers,
1355
+ )) {
1356
+ if (!isValidHeaderName(headerName)) {
1357
+ errors.push(
1358
+ `registry.servers.${name}.headers contains invalid header name ${headerName}`,
1359
+ );
1360
+ continue;
1361
+ }
1362
+ if (
1363
+ !descriptor ||
1364
+ typeof descriptor !== "object" ||
1365
+ Array.isArray(descriptor)
1366
+ ) {
1367
+ errors.push(
1368
+ `registry.servers.${name}.headers.${headerName} must be a descriptor object`,
1369
+ );
1370
+ continue;
1371
+ }
1372
+ const keys = Object.keys(descriptor);
1373
+ const hasValue = Object.hasOwn(descriptor, "value");
1374
+ const hasEnv = Object.hasOwn(descriptor, "env");
1375
+ if (hasValue && hasEnv) {
1376
+ errors.push(
1377
+ `registry.servers.${name}.headers.${headerName} must use either value or env`,
1378
+ );
1379
+ } else if (hasValue) {
1380
+ if (
1381
+ keys.length !== 1 ||
1382
+ typeof descriptor.value !== "string" ||
1383
+ descriptor.value.length === 0
1384
+ ) {
1385
+ errors.push(
1386
+ `registry.servers.${name}.headers.${headerName}.value must be a non-empty string`,
1387
+ );
1388
+ }
1389
+ } else if (hasEnv) {
1390
+ if (
1391
+ !["env", "prefix"].every((key) => keys.includes(key)) &&
1392
+ keys.some((key) => !["env", "prefix"].includes(key))
1393
+ ) {
1394
+ errors.push(
1395
+ `registry.servers.${name}.headers.${headerName} supports only env and optional prefix`,
1396
+ );
1397
+ }
1398
+ if (
1399
+ typeof descriptor.env !== "string" ||
1400
+ !descriptor.env.trim()
1401
+ ) {
1402
+ errors.push(
1403
+ `registry.servers.${name}.headers.${headerName}.env must be a non-empty string`,
1404
+ );
1405
+ }
1406
+ if (
1407
+ descriptor.prefix !== undefined &&
1408
+ typeof descriptor.prefix !== "string"
1409
+ ) {
1410
+ errors.push(
1411
+ `registry.servers.${name}.headers.${headerName}.prefix must be a string`,
1412
+ );
1413
+ }
1414
+ } else {
1415
+ errors.push(
1416
+ `registry.servers.${name}.headers.${headerName} must use value or env`,
1417
+ );
1418
+ }
1419
+ }
1420
+ }
1421
+ }
478
1422
  if (server.targets !== undefined && !Array.isArray(server.targets)) {
479
1423
  errors.push(`registry.servers.${name}.targets must be an array`);
480
1424
  }
481
1425
  }
482
1426
  }
483
1427
 
484
- if (!registry.policies || typeof registry.policies !== "object" || Array.isArray(registry.policies)) {
1428
+ if (
1429
+ !registry.policies ||
1430
+ typeof registry.policies !== "object" ||
1431
+ Array.isArray(registry.policies)
1432
+ ) {
485
1433
  errors.push("registry.policies must be an object");
486
1434
  } else {
487
1435
  if (!Array.isArray(registry.policies.watched_paths)) {
488
1436
  errors.push("registry.policies.watched_paths must be an array");
489
1437
  }
490
- if (registry.policies.stdio_action && !["replace-with-hub", "warn"].includes(registry.policies.stdio_action)) {
491
- errors.push("registry.policies.stdio_action must be replace-with-hub or warn");
1438
+ if (
1439
+ registry.policies.sync_denylist !== undefined &&
1440
+ !Array.isArray(registry.policies.sync_denylist)
1441
+ ) {
1442
+ errors.push("registry.policies.sync_denylist must be an array");
1443
+ }
1444
+ if (Array.isArray(registry.policies.sync_denylist)) {
1445
+ for (const entry of registry.policies.sync_denylist) {
1446
+ if (typeof entry !== "string" || !entry.includes(":")) {
1447
+ errors.push(
1448
+ "registry.policies.sync_denylist entries must use client:server format",
1449
+ );
1450
+ break;
1451
+ }
1452
+ }
1453
+ }
1454
+ if (
1455
+ registry.policies.stdio_action &&
1456
+ !["replace-with-hub", "warn"].includes(registry.policies.stdio_action)
1457
+ ) {
1458
+ errors.push(
1459
+ "registry.policies.stdio_action must be replace-with-hub or warn",
1460
+ );
492
1461
  }
493
1462
  }
494
1463
 
@@ -496,9 +1465,9 @@ export function validateRegistry(registry) {
496
1465
  }
497
1466
 
498
1467
  export function inspectRegistry() {
499
- if (!existsSync(REGISTRY_PATH)) {
1468
+ if (!existsSync(registryPath())) {
500
1469
  return {
501
- path: REGISTRY_PATH,
1470
+ path: registryPath(),
502
1471
  exists: false,
503
1472
  valid: false,
504
1473
  errors: ["registry file missing"],
@@ -507,10 +1476,10 @@ export function inspectRegistry() {
507
1476
  }
508
1477
 
509
1478
  try {
510
- const registry = readJsonFile(REGISTRY_PATH);
1479
+ const registry = readJsonFile(registryPath());
511
1480
  const errors = validateRegistry(registry);
512
1481
  return {
513
- path: REGISTRY_PATH,
1482
+ path: registryPath(),
514
1483
  exists: true,
515
1484
  valid: errors.length === 0,
516
1485
  errors,
@@ -518,7 +1487,7 @@ export function inspectRegistry() {
518
1487
  };
519
1488
  } catch (error) {
520
1489
  return {
521
- path: REGISTRY_PATH,
1490
+ path: registryPath(),
522
1491
  exists: true,
523
1492
  valid: false,
524
1493
  errors: [error.message],
@@ -560,7 +1529,7 @@ export function saveRegistry(registry) {
560
1529
  if (errors.length > 0) {
561
1530
  throw new Error(`MCP registry invalid: ${errors.join("; ")}`);
562
1531
  }
563
- writeJsonFile(REGISTRY_PATH, registry);
1532
+ writeJsonFile(registryPath(), registry);
564
1533
  return registry;
565
1534
  }
566
1535
 
@@ -577,8 +1546,75 @@ export function listManagedConfigTargets(registry = loadRegistryOrDefault()) {
577
1546
  });
578
1547
  }
579
1548
 
1549
+ export function discoverProjectMcpTargets(options = {}) {
1550
+ const root = resolve(expandHome(options.root || defaultProjectsRoot()));
1551
+ const maxDepth = Number.isFinite(Number(options.maxDepth))
1552
+ ? Number(options.maxDepth)
1553
+ : 4;
1554
+ const excludePatterns = Array.isArray(options.exclude)
1555
+ ? options.exclude.filter(Boolean).map(String)
1556
+ : [];
1557
+ const targets = [];
1558
+ const seen = new Set();
1559
+
1560
+ function addTarget(filePath) {
1561
+ const normalized = normalizeForMatch(filePath);
1562
+ if (seen.has(normalized)) return;
1563
+ seen.add(normalized);
1564
+ targets.push({
1565
+ watchedPath: filePath,
1566
+ filePath,
1567
+ client: detectClient(filePath),
1568
+ label: detectLabel(filePath),
1569
+ exists: existsSync(filePath),
1570
+ });
1571
+ }
1572
+
1573
+ function walk(dirPath, depth) {
1574
+ if (depth > maxDepth) return;
1575
+ let entries;
1576
+ try {
1577
+ entries = readdirSync(dirPath, { withFileTypes: true });
1578
+ } catch {
1579
+ return;
1580
+ }
1581
+
1582
+ for (const entry of entries) {
1583
+ const entryPath = join(dirPath, entry.name);
1584
+ const relativePath = relative(root, entryPath).replace(/\\/g, "/");
1585
+ if (isExcludedProjectPath(relativePath, excludePatterns)) continue;
1586
+
1587
+ if (entry.isDirectory()) {
1588
+ if (depth < maxDepth) walk(entryPath, depth + 1);
1589
+ continue;
1590
+ }
1591
+
1592
+ if (!entry.isFile() || depth + 1 > maxDepth) continue;
1593
+ const normalized = entryPath.replace(/\\/g, "/");
1594
+ if (
1595
+ entry.name === ".mcp.json" ||
1596
+ normalized.endsWith("/.claude/mcp.json")
1597
+ ) {
1598
+ addTarget(resolve(entryPath));
1599
+ }
1600
+ }
1601
+ }
1602
+
1603
+ walk(root, 0);
1604
+ return {
1605
+ root,
1606
+ maxDepth,
1607
+ exclude: excludePatterns,
1608
+ targets: targets.sort((left, right) =>
1609
+ left.filePath.localeCompare(right.filePath),
1610
+ ),
1611
+ };
1612
+ }
1613
+
580
1614
  export function listPrimaryConfigTargets(registry = loadRegistryOrDefault()) {
581
- return listManagedConfigTargets(registry).filter((target) => isPrimaryConfigTarget(target.filePath));
1615
+ return listManagedConfigTargets(registry).filter((target) =>
1616
+ isPrimaryConfigTarget(target.filePath),
1617
+ );
582
1618
  }
583
1619
 
584
1620
  export function scanManagedConfigs(registry = loadRegistryOrDefault()) {
@@ -590,17 +1626,35 @@ export function scanManagedConfigs(registry = loadRegistryOrDefault()) {
590
1626
 
591
1627
  export function inspectRegistryStatus(registry = loadRegistryOrDefault()) {
592
1628
  const configs = scanManagedConfigs(registry);
593
- const primaryTargets = new Set(listPrimaryConfigTargets(registry).map((target) => normalizeForMatch(target.filePath)));
1629
+ const primaryTargets = new Set(
1630
+ listPrimaryConfigTargets(registry).map((target) =>
1631
+ normalizeForMatch(target.filePath),
1632
+ ),
1633
+ );
594
1634
  const rows = [];
595
1635
 
596
1636
  for (const config of configs) {
597
1637
  const isPrimary = primaryTargets.has(normalizeForMatch(config.filePath));
598
- const managedServers = Object.entries(registry.servers || {})
599
- .filter(([, serverConfig]) => isPrimary && serverAppliesToClient(serverConfig, config.client));
1638
+ const managedServers = Object.entries(registry.servers || {}).filter(
1639
+ ([, serverConfig]) =>
1640
+ isPrimary && serverAppliesToClient(serverConfig, config.client),
1641
+ );
600
1642
 
601
1643
  for (const [name, serverConfig] of managedServers) {
602
- const expectedUrl = buildDesiredServerRecord(name, serverConfig, config.filePath).config.url;
603
- const actual = config.servers.find((server) => server.name === name) || null;
1644
+ const desired = buildDesiredServerRecord(
1645
+ name,
1646
+ serverConfig,
1647
+ config.filePath,
1648
+ );
1649
+ const expectedUrl = desired.config.url || "";
1650
+ const expectedCommand = desired.config.command || "";
1651
+ const expectedHeaders = isCodexConfig(config.filePath)
1652
+ ? desired.headerDescriptors || {}
1653
+ : descriptorsFromJsonConfig(desired.config);
1654
+ const actual =
1655
+ config.servers.find((server) => server.name === name) || null;
1656
+ const actualHeaders = actual?.headerDescriptors || {};
1657
+ const currentHeaderStatus = headerStatus(expectedHeaders, actualHeaders);
604
1658
  let status = "missing";
605
1659
 
606
1660
  if (!config.exists) {
@@ -609,10 +1663,17 @@ export function inspectRegistryStatus(registry = loadRegistryOrDefault()) {
609
1663
  status = "invalid-config";
610
1664
  } else if (!actual) {
611
1665
  status = "missing";
1666
+ } else if (expectedCommand) {
1667
+ status =
1668
+ actual.command === expectedCommand &&
1669
+ JSON.stringify(actual.args || []) ===
1670
+ JSON.stringify(desired.config.args || [])
1671
+ ? "present"
1672
+ : "mismatch";
612
1673
  } else if (!actual.url) {
613
1674
  status = actual.transport === "stdio" ? "stdio" : "invalid";
614
1675
  } else if (normalizeUrl(actual.url) === normalizeUrl(expectedUrl)) {
615
- status = "present";
1676
+ status = currentHeaderStatus === "ok" ? "present" : "mismatch";
616
1677
  } else {
617
1678
  status = "mismatch";
618
1679
  }
@@ -624,8 +1685,13 @@ export function inspectRegistryStatus(registry = loadRegistryOrDefault()) {
624
1685
  label: config.label,
625
1686
  filePath: config.filePath,
626
1687
  expectedUrl,
1688
+ expectedCommand,
627
1689
  actualUrl: actual?.url || "",
1690
+ actualCommand: actual?.command || "",
628
1691
  status,
1692
+ headerStatus: currentHeaderStatus,
1693
+ expectedHeaderNames: headerNames(expectedHeaders),
1694
+ actualHeaderNames: headerNames(actualHeaders),
629
1695
  });
630
1696
  }
631
1697
 
@@ -658,7 +1724,9 @@ export function scanForStdioServers(filePath) {
658
1724
 
659
1725
  export function remediate(filePath, stdioServers, policy = {}) {
660
1726
  const resolvedPath = resolveFilePath(filePath);
661
- const offenders = Array.isArray(stdioServers) ? stdioServers.filter((server) => server?.name) : [];
1727
+ const offenders = Array.isArray(stdioServers)
1728
+ ? stdioServers.filter((server) => server?.name)
1729
+ : [];
662
1730
  const action = policy?.stdio_action || "warn";
663
1731
 
664
1732
  if (offenders.length === 0) {
@@ -722,10 +1790,19 @@ export function remediate(filePath, stdioServers, policy = {}) {
722
1790
  let replacement = null;
723
1791
 
724
1792
  if (action === "replace-with-hub") {
725
- const registry = loadRegistryOrDefault();
726
- const [hubServerName, hubServerConfig] = getHubServerEntry(registry);
727
- const desired = buildDesiredServerRecord(hubServerName, hubServerConfig, resolvedPath);
728
- replacement = { name: desired.name, ...desired.config };
1793
+ const registry = policy.registry || loadRegistryOrDefault();
1794
+ const matchingOffender = offenders.find((server) =>
1795
+ Object.hasOwn(registry.servers || {}, server.name),
1796
+ );
1797
+ const [hubServerName, hubServerConfig] = matchingOffender
1798
+ ? [matchingOffender.name, registry.servers[matchingOffender.name]]
1799
+ : getHubServerEntry(registry);
1800
+ const desired = buildDesiredServerRecord(
1801
+ hubServerName,
1802
+ hubServerConfig,
1803
+ resolvedPath,
1804
+ );
1805
+ replacement = { name: desired.name, ...redactServerConfig(desired.config) };
729
1806
  updates.push(desired);
730
1807
  }
731
1808
 
@@ -736,15 +1813,19 @@ export function remediate(filePath, stdioServers, policy = {}) {
736
1813
  backupPath,
737
1814
  removedServers: removals,
738
1815
  replacement,
739
- warnings: [],
1816
+ warnings: updates.flatMap((update) => update.warnings || []),
740
1817
  };
741
1818
  }
742
1819
 
743
1820
  export function resolveHubUrl() {
744
1821
  const registryState = inspectRegistry();
745
- const registry = registryState.valid ? registryState.registry : cloneDefaultRegistry();
1822
+ const registry = registryState.valid
1823
+ ? registryState.registry
1824
+ : cloneDefaultRegistry();
746
1825
  const [, hubServer] = getHubServerEntry(registry);
747
- const fallbackRaw = hubServer?.url || `${registry?.defaults?.hub_base || "http://127.0.0.1:27888"}${DEFAULT_HUB_PATH}`;
1826
+ const fallbackRaw =
1827
+ hubServer?.url ||
1828
+ `${registry?.defaults?.hub_base || "http://127.0.0.1:27888"}${DEFAULT_HUB_PATH}`;
748
1829
 
749
1830
  let fallback;
750
1831
  try {
@@ -754,22 +1835,25 @@ export function resolveHubUrl() {
754
1835
  }
755
1836
 
756
1837
  const envPortRaw = Number(process.env.TFX_HUB_PORT || "");
757
- const envPort = Number.isFinite(envPortRaw) && envPortRaw > 0 ? envPortRaw : null;
1838
+ const envPort =
1839
+ Number.isFinite(envPortRaw) && envPortRaw > 0 ? envPortRaw : null;
758
1840
  const target = {
759
1841
  protocol: fallback.protocol || "http:",
760
1842
  host: fallback.hostname || "127.0.0.1",
761
1843
  port: envPort || Number(fallback.port || 27888),
762
- pathname: fallback.pathname && fallback.pathname !== "/" ? fallback.pathname : DEFAULT_HUB_PATH,
1844
+ pathname:
1845
+ fallback.pathname && fallback.pathname !== "/"
1846
+ ? fallback.pathname
1847
+ : DEFAULT_HUB_PATH,
763
1848
  };
764
1849
 
1850
+ // PR #158 정책: port = TFX_HUB_PORT env (없으면 registry/default 27888) single source.
1851
+ // hub.pid 는 loopback host 힌트 전용. 과거 pid port cascade 는 오염된 port 영속화의
1852
+ // 원인이었고 hub-ensure.resolveHubTarget 에서 이미 제거됨. 여기도 일관 적용.
765
1853
  const hubPidPath = join(homedir(), ".claude", "cache", "tfx-hub", "hub.pid");
766
1854
  if (existsSync(hubPidPath)) {
767
1855
  try {
768
1856
  const info = readJsonFile(hubPidPath);
769
- if (!envPort) {
770
- const pidPort = Number(info?.port);
771
- if (Number.isFinite(pidPort) && pidPort > 0) target.port = pidPort;
772
- }
773
1857
  if (typeof info?.host === "string") {
774
1858
  const host = info.host.trim();
775
1859
  if (LOOPBACK_HOSTS.has(host)) target.host = host;
@@ -785,7 +1869,9 @@ export function resolveHubUrl() {
785
1869
 
786
1870
  export function isWatchedPath(filePath) {
787
1871
  const registryState = inspectRegistry();
788
- const registry = registryState.valid ? registryState.registry : cloneDefaultRegistry();
1872
+ const registry = registryState.valid
1873
+ ? registryState.registry
1874
+ : cloneDefaultRegistry();
789
1875
  const candidate = normalizeForMatch(filePath);
790
1876
 
791
1877
  return (registry?.policies?.watched_paths || []).some((watchedPath) => {
@@ -802,7 +1888,10 @@ export function isWatchedPath(filePath) {
802
1888
  return pathBasename(candidate) === trimmed.toLowerCase();
803
1889
  }
804
1890
 
805
- const suffix = trimmed.replace(/^[.][\\/]/, "").replace(/\\/g, "/").toLowerCase();
1891
+ const suffix = trimmed
1892
+ .replace(/^[.][\\/]/, "")
1893
+ .replace(/\\/g, "/")
1894
+ .toLowerCase();
806
1895
  return candidate.endsWith(`/${suffix}`);
807
1896
  });
808
1897
  }
@@ -814,16 +1903,34 @@ export function addRegistryServer(name, url, options = {}) {
814
1903
  if (!normalizedUrl) throw new Error("server url is required");
815
1904
 
816
1905
  const registryState = inspectRegistry();
817
- const registry = registryState.valid ? loadRegistry() : cloneDefaultRegistry();
818
- const transport = options.transport || (trimmedName === "tfx-hub" ? "hub-url" : "url");
1906
+ const registry = registryState.valid
1907
+ ? loadRegistry()
1908
+ : cloneDefaultRegistry();
1909
+ const transport =
1910
+ options.transport || (trimmedName === "tfx-hub" ? "hub-url" : "http");
1911
+ const policy =
1912
+ options.policy ||
1913
+ (transport === "stdio"
1914
+ ? "stdio"
1915
+ : options.gateway_port || options.port
1916
+ ? "gateway-sse"
1917
+ : "hosted");
819
1918
 
820
1919
  registry.servers[trimmedName] = {
1920
+ policy,
821
1921
  transport,
822
1922
  url: normalizedUrl,
823
1923
  safe: options.safe ?? true,
824
- targets: Array.isArray(options.targets) && options.targets.length > 0
825
- ? [...new Set(options.targets.map((value) => String(value).trim()).filter(Boolean))]
826
- : ["claude", "gemini", "codex"],
1924
+ targets:
1925
+ Array.isArray(options.targets) && options.targets.length > 0
1926
+ ? [
1927
+ ...new Set(
1928
+ options.targets
1929
+ .map((value) => String(value).trim())
1930
+ .filter(Boolean),
1931
+ ),
1932
+ ]
1933
+ : ["claude", "gemini", "codex", "antigravity"],
827
1934
  description: options.description || `${trimmedName} MCP 서버`,
828
1935
  };
829
1936
 
@@ -851,10 +1958,13 @@ export function removeServerFromTargets(name, options = {}) {
851
1958
  const trimmedName = String(name || "").trim();
852
1959
  if (!trimmedName) throw new Error("server name is required");
853
1960
 
854
- const registry = options.registry || (inspectRegistry().valid ? loadRegistry() : cloneDefaultRegistry());
855
- const targetsFilter = Array.isArray(options.targets) && options.targets.length > 0
856
- ? new Set(options.targets)
857
- : null;
1961
+ const registry =
1962
+ options.registry ||
1963
+ (inspectRegistry().valid ? loadRegistry() : cloneDefaultRegistry());
1964
+ const targetsFilter =
1965
+ Array.isArray(options.targets) && options.targets.length > 0
1966
+ ? new Set(options.targets)
1967
+ : null;
858
1968
  const actions = [];
859
1969
 
860
1970
  for (const target of listManagedConfigTargets(registry)) {
@@ -896,11 +2006,20 @@ export function removeServerFromTargets(name, options = {}) {
896
2006
 
897
2007
  export function syncRegistryTargets(options = {}) {
898
2008
  const registry = options.registry || loadRegistryOrDefault();
2009
+ const syncTargets = Array.isArray(options.targets)
2010
+ ? options.targets
2011
+ : listManagedConfigTargets(registry);
2012
+ const primaryTargets = syncTargets.filter((target) =>
2013
+ isPrimaryConfigTarget(target.filePath),
2014
+ );
899
2015
  const actions = [];
2016
+ const invalidConfigs = new Set();
2017
+ const denylist = syncDenylistEntries(registry.policies);
900
2018
 
901
- for (const target of listManagedConfigTargets(registry)) {
2019
+ for (const target of syncTargets) {
902
2020
  const snapshot = scanConfig(target.filePath);
903
2021
  if (snapshot.parseError) {
2022
+ invalidConfigs.add(normalizeForMatch(target.filePath));
904
2023
  actions.push({
905
2024
  type: "sync",
906
2025
  filePath: target.filePath,
@@ -911,8 +2030,14 @@ export function syncRegistryTargets(options = {}) {
911
2030
  continue;
912
2031
  }
913
2032
 
914
- if (snapshot.stdioServers.length > 0) {
915
- const remediation = remediate(target.filePath, snapshot.stdioServers, registry.policies);
2033
+ if (
2034
+ snapshot.stdioServers.length > 0 &&
2035
+ !isClaudeUserConfig(target.filePath)
2036
+ ) {
2037
+ const remediation = remediate(target.filePath, snapshot.stdioServers, {
2038
+ ...registry.policies,
2039
+ registry,
2040
+ });
916
2041
  actions.push({
917
2042
  type: "remediate",
918
2043
  filePath: target.filePath,
@@ -925,10 +2050,50 @@ export function syncRegistryTargets(options = {}) {
925
2050
  }
926
2051
  }
927
2052
 
928
- for (const target of listPrimaryConfigTargets(registry)) {
929
- const updates = Object.entries(registry.servers || {})
930
- .filter(([, serverConfig]) => serverAppliesToClient(serverConfig, target.client))
931
- .map(([name, serverConfig]) => buildDesiredServerRecord(name, serverConfig, target.filePath));
2053
+ for (const target of primaryTargets) {
2054
+ const snapshot = scanConfig(target.filePath);
2055
+ const targetKey = normalizeForMatch(target.filePath);
2056
+ if (snapshot.parseError) {
2057
+ if (!invalidConfigs.has(targetKey)) {
2058
+ actions.push({
2059
+ type: "sync",
2060
+ filePath: target.filePath,
2061
+ label: target.label,
2062
+ status: "invalid-config",
2063
+ message: snapshot.parseError.message,
2064
+ });
2065
+ }
2066
+ continue;
2067
+ }
2068
+
2069
+ const updates = [];
2070
+ for (const [name, serverConfig] of Object.entries(registry.servers || {})) {
2071
+ if (serverConfig?.safe !== true) continue;
2072
+ if (!serverAppliesToClient(serverConfig, target.client)) continue;
2073
+
2074
+ const denyKey = syncDenylistKey(target.client, name);
2075
+ if (denylist.has(denyKey)) {
2076
+ const existing = snapshot.servers.find(
2077
+ (server) => server.name === name,
2078
+ );
2079
+ if (!existing) {
2080
+ actions.push({
2081
+ type: "sync",
2082
+ filePath: target.filePath,
2083
+ label: target.label,
2084
+ status: "policy-skip",
2085
+ server: name,
2086
+ client: target.client,
2087
+ message: `[mcp-guard] policy skip: ${denyKey}`,
2088
+ });
2089
+ }
2090
+ continue;
2091
+ }
2092
+
2093
+ updates.push(
2094
+ buildDesiredServerRecord(name, serverConfig, target.filePath),
2095
+ );
2096
+ }
932
2097
 
933
2098
  if (updates.length === 0) continue;
934
2099
 
@@ -947,6 +2112,7 @@ export function syncRegistryTargets(options = {}) {
947
2112
  label: target.label,
948
2113
  status: result.modified ? "updated" : "ok",
949
2114
  serverCount: updates.length,
2115
+ warnings: updates.flatMap((update) => update.warnings || []),
950
2116
  });
951
2117
  }
952
2118