@triflux/remote 10.1.0 → 10.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hub/pipe.mjs +239 -121
- package/hub/public/dashboard.html +61 -0
- package/hub/server.mjs +2179 -709
- package/hub/store-adapter.mjs +59 -26
- package/hub/store.mjs +356 -153
- package/hub/team/agent-map.json +26 -7
- package/hub/team/ansi.mjs +245 -146
- package/hub/team/backend.mjs +78 -17
- package/hub/team/build-worker-prompt.mjs +73 -0
- package/hub/team/check-mcp-hub.mjs +49 -0
- package/hub/team/claude-daemon-control.mjs +1130 -0
- package/hub/team/claude-native-bridge.mjs +1011 -0
- package/hub/team/claude-session-projection.mjs +74 -0
- package/hub/team/cli/commands/attach.mjs +57 -37
- package/hub/team/cli/commands/control.mjs +29 -8
- package/hub/team/cli/commands/debug.mjs +95 -74
- package/hub/team/cli/commands/focus.mjs +80 -53
- package/hub/team/cli/commands/interrupt.mjs +18 -6
- package/hub/team/cli/commands/kill.mjs +16 -5
- package/hub/team/cli/commands/list.mjs +31 -24
- package/hub/team/cli/commands/send.mjs +19 -6
- package/hub/team/cli/commands/start/index.mjs +151 -33
- package/hub/team/cli/commands/start/parse-args.mjs +97 -13
- package/hub/team/cli/commands/start/start-headless.mjs +155 -37
- package/hub/team/cli/commands/start/start-in-process.mjs +50 -40
- package/hub/team/cli/commands/start/start-mux.mjs +122 -73
- package/hub/team/cli/commands/start/start-wt.mjs +86 -69
- package/hub/team/cli/commands/status.mjs +43 -14
- package/hub/team/cli/commands/stop.mjs +11 -4
- package/hub/team/cli/commands/task.mjs +8 -3
- package/hub/team/cli/commands/tasks.mjs +13 -13
- package/hub/team/cli/help.mjs +1 -1
- package/hub/team/cli/index.mjs +2 -2
- package/hub/team/cli/manifest.mjs +38 -8
- package/hub/team/cli/render.mjs +52 -30
- package/hub/team/cli/services/attach-fallback.mjs +74 -54
- package/hub/team/cli/services/hub-client.mjs +44 -16
- package/hub/team/cli/services/member-selector.mjs +37 -30
- package/hub/team/cli/services/native-control.mjs +144 -116
- package/hub/team/cli/services/runtime-mode.mjs +61 -4
- package/hub/team/cli/services/state-store.mjs +66 -9
- package/hub/team/cli/services/task-model.mjs +40 -30
- package/hub/team/codex-review.mjs +521 -0
- package/hub/team/conductor-mesh-bridge.mjs +25 -24
- package/hub/team/conductor-registry.mjs +121 -0
- package/hub/team/conductor.mjs +1379 -671
- package/hub/team/consensus-meta.mjs +176 -0
- package/hub/team/dashboard-anchor.mjs +4 -5
- package/hub/team/dashboard-layout.mjs +3 -1
- package/hub/team/dashboard-open.mjs +20 -118
- package/hub/team/dashboard.mjs +76 -28
- package/hub/team/event-log.mjs +18 -10
- package/hub/team/execution-mode.mjs +256 -0
- package/hub/team/extract-completion-payload.mjs +110 -0
- package/hub/team/git-preflight.mjs +286 -0
- package/hub/team/handoff.mjs +49 -18
- package/hub/team/headless-bridge-session.mjs +17 -0
- package/hub/team/headless.mjs +2213 -1156
- package/hub/team/health-probe.mjs +232 -69
- package/hub/team/interactive-native-launcher.mjs +106 -0
- package/hub/team/interactive-native-worker.mjs +80 -0
- package/hub/team/interactive-tui-transport.mjs +465 -0
- package/hub/team/launcher-template.mjs +49 -17
- package/hub/team/lead-control.mjs +91 -4
- package/hub/team/mcp-selector.mjs +145 -0
- package/hub/team/native-supervisor.mjs +90 -26
- package/hub/team/native.mjs +120 -42
- package/hub/team/nativeProxy.mjs +184 -113
- package/hub/team/notify.mjs +147 -78
- package/hub/team/orchestrator.mjs +173 -161
- package/hub/team/pane.mjs +43 -20
- package/hub/team/process-cleanup.mjs +69 -25
- package/hub/team/psmux.mjs +1022 -281
- package/hub/team/recovery-store.mjs +149 -0
- package/hub/team/remote-probe.mjs +68 -52
- package/hub/team/remote-session.mjs +131 -59
- package/hub/team/remote-watcher.mjs +61 -33
- package/hub/team/retry-state-machine.mjs +336 -0
- package/hub/team/routing.mjs +51 -25
- package/hub/team/runtime-strategy.mjs +200 -0
- package/hub/team/schemas/worker-completion.json +39 -0
- package/hub/team/sentinel-capture.mjs +127 -0
- package/hub/team/session-sync.mjs +153 -6
- package/hub/team/session.mjs +574 -611
- package/hub/team/shared.mjs +13 -13
- package/hub/team/staleState.mjs +72 -30
- package/hub/team/swarm-cli.mjs +390 -0
- package/hub/team/swarm-hypervisor.mjs +1544 -193
- package/hub/team/swarm-intent.mjs +216 -0
- package/hub/team/swarm-locks.mjs +140 -25
- package/hub/team/swarm-planner.mjs +121 -21
- package/hub/team/swarm-preflight.mjs +319 -0
- package/hub/team/swarm-reconciler.mjs +128 -23
- package/hub/team/synapse-cli.mjs +458 -0
- package/hub/team/synapse-http.mjs +60 -0
- package/hub/team/synapse-registry.mjs +329 -0
- package/hub/team/terminal-opener.mjs +216 -0
- package/hub/team/tui-core.mjs +292 -0
- package/hub/team/tui-lite.mjs +237 -148
- package/hub/team/tui-remote-adapter.mjs +18 -34
- package/hub/team/tui-synapse.mjs +223 -0
- package/hub/team/tui-viewer.mjs +108 -48
- package/hub/team/tui-widgets.mjs +262 -0
- package/hub/team/tui.mjs +931 -439
- package/hub/team/worker-completion-validator.mjs +84 -0
- package/hub/team/worker-sandbox.mjs +97 -0
- package/hub/team/worker-signal.mjs +263 -0
- package/hub/team/worker-signal.types.d.ts +52 -0
- package/hub/team/worktree-lifecycle.mjs +517 -63
- package/hub/team/wt-manager.mjs +297 -26
- package/hub/tools.mjs +477 -252
- package/hub/tray.mjs +411 -368
- package/hub/workers/antigravity-route-worker.mjs +169 -0
- package/hub/workers/claude-worker.mjs +162 -118
- package/hub/workers/codex-app-server-worker.mjs +1015 -0
- package/hub/workers/codex-mcp.mjs +724 -507
- package/hub/workers/delegator-mcp.mjs +570 -499
- package/hub/workers/factory.mjs +103 -21
- package/hub/workers/gemini-worker.mjs +126 -284
- package/hub/workers/interface.mjs +24 -2
- package/hub/workers/lib/jsonrpc-stdio.mjs +463 -0
- package/hub/workers/worker-utils.mjs +23 -17
- package/package.json +4 -3
- package/scripts/lib/agent-json.mjs +27 -0
- package/scripts/lib/async.mjs +174 -0
- package/scripts/lib/claudemd-scanner.mjs +223 -0
- package/scripts/lib/cli-agy.mjs +62 -0
- package/scripts/lib/cli-claude.mjs +78 -0
- package/scripts/lib/cli-codex.mjs +199 -0
- package/scripts/lib/cli-gemini.mjs +37 -0
- package/scripts/lib/codex-recovery.sh +50 -0
- package/scripts/lib/context.mjs +67 -0
- package/scripts/lib/cross-review-utils.mjs +59 -0
- package/scripts/lib/doctor-env-checks.mjs +121 -0
- package/scripts/lib/dynamic-route-cli.mjs +107 -0
- package/scripts/lib/env-probe.mjs +269 -0
- package/scripts/lib/env.mjs +14 -0
- package/scripts/lib/gemini-profiles.mjs +119 -0
- package/scripts/lib/handoff.mjs +187 -0
- package/scripts/lib/hook-utils.mjs +16 -0
- package/scripts/lib/hub.mjs +48 -0
- package/scripts/lib/keyword-rules.mjs +195 -0
- package/scripts/lib/logger.mjs +105 -0
- package/scripts/lib/mcp-filter.mjs +883 -0
- package/scripts/lib/mcp-gateway-health-check.mjs +159 -0
- package/scripts/lib/mcp-gateway-servers.mjs +70 -0
- package/scripts/lib/mcp-gateway-wrapper-check.mjs +35 -0
- package/scripts/lib/mcp-guard-engine.mjs +2120 -0
- package/scripts/lib/mcp-health.mjs +773 -0
- package/scripts/lib/mcp-manifest.mjs +89 -0
- package/scripts/lib/mcp-server-catalog.mjs +355 -0
- package/scripts/lib/pid.mjs +63 -0
- package/scripts/lib/process-utils.mjs +26 -0
- package/scripts/lib/psmux-info.mjs +224 -0
- package/scripts/lib/quota.mjs +47 -0
- package/scripts/lib/remote-spawn-transfer.mjs +226 -0
- package/scripts/lib/skill-state.mjs +220 -0
- package/scripts/lib/skill-template.mjs +375 -0
- package/scripts/lib/team.mjs +75 -0
- package/scripts/lib/timeout.mjs +66 -0
- package/scripts/lib/tmp.mjs +26 -0
- package/scripts/lib/toml.mjs +113 -0
|
@@ -0,0 +1,2120 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
copyFileSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import {
|
|
12
|
+
basename,
|
|
13
|
+
dirname,
|
|
14
|
+
isAbsolute,
|
|
15
|
+
join,
|
|
16
|
+
relative,
|
|
17
|
+
resolve,
|
|
18
|
+
} from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
|
|
21
|
+
const PROJECT_ROOT = fileURLToPath(new URL("../../", import.meta.url));
|
|
22
|
+
const DEFAULT_REGISTRY_PATH = join(PROJECT_ROOT, "config", "mcp-registry.json");
|
|
23
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
|
|
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
|
+
});
|
|
36
|
+
const DEFAULT_REGISTRY = Object.freeze({
|
|
37
|
+
$schema: "mcp-registry-schema",
|
|
38
|
+
version: 1,
|
|
39
|
+
description: "MCP 서버 중앙 레지스트리 — 진실의 원천",
|
|
40
|
+
defaults: {
|
|
41
|
+
transport: "hub-url",
|
|
42
|
+
hub_base: "http://127.0.0.1:27888",
|
|
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
|
+
},
|
|
52
|
+
servers: {
|
|
53
|
+
"tfx-hub": {
|
|
54
|
+
policy: "hosted",
|
|
55
|
+
transport: "hub-url",
|
|
56
|
+
url: "http://127.0.0.1:27888/mcp",
|
|
57
|
+
safe: true,
|
|
58
|
+
targets: ["claude", "gemini", "codex", "antigravity"],
|
|
59
|
+
description: "triflux Hub MCP 서버",
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
policies: {
|
|
63
|
+
stdio_action: "replace-with-hub",
|
|
64
|
+
unknown_server_action: "warn",
|
|
65
|
+
sync_denylist: [],
|
|
66
|
+
watched_paths: [
|
|
67
|
+
"~/.gemini/settings.json",
|
|
68
|
+
"~/.codex/config.toml",
|
|
69
|
+
"~/.claude.json",
|
|
70
|
+
"~/.claude/settings.json",
|
|
71
|
+
"~/.claude/settings.local.json",
|
|
72
|
+
".claude/mcp.json",
|
|
73
|
+
".mcp.json",
|
|
74
|
+
"~/.gemini/config/mcp_config.json",
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
function cloneDefaultRegistry() {
|
|
80
|
+
return JSON.parse(JSON.stringify(DEFAULT_REGISTRY));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function expandHome(filePath) {
|
|
84
|
+
if (typeof filePath !== "string") return "";
|
|
85
|
+
if (!filePath.startsWith("~/") && !filePath.startsWith("~\\"))
|
|
86
|
+
return filePath;
|
|
87
|
+
return join(homedir(), filePath.slice(2));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function resolveFilePath(filePath) {
|
|
91
|
+
const expanded = expandHome(filePath);
|
|
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
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function normalizeForMatch(filePath) {
|
|
121
|
+
return resolveFilePath(filePath).replace(/\\/g, "/").toLowerCase();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function pathBasename(filePath) {
|
|
125
|
+
return basename(filePath.replace(/\\/g, "/")).toLowerCase();
|
|
126
|
+
}
|
|
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
|
+
|
|
136
|
+
function readJsonFile(filePath) {
|
|
137
|
+
return JSON.parse(readFileSync(filePath, "utf8"));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function writeJsonFile(filePath, data) {
|
|
141
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
142
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
143
|
+
}
|
|
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
|
+
|
|
151
|
+
function ensureBackup(filePath) {
|
|
152
|
+
const backupPath = `${filePath}.bak`;
|
|
153
|
+
copyFileSync(filePath, backupPath);
|
|
154
|
+
return backupPath;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function isJsonMcpConfig(filePath) {
|
|
158
|
+
const name = pathBasename(filePath);
|
|
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
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function isCodexConfig(filePath) {
|
|
171
|
+
const normalized = normalizeForMatch(filePath);
|
|
172
|
+
return normalized.endsWith("/.codex/config.toml");
|
|
173
|
+
}
|
|
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
|
+
|
|
216
|
+
function detectClient(filePath) {
|
|
217
|
+
const normalized = normalizeForMatch(filePath);
|
|
218
|
+
if (normalized.endsWith("/.gemini/config/mcp_config.json"))
|
|
219
|
+
return "antigravity";
|
|
220
|
+
if (normalized.endsWith("/.gemini/settings.json")) return "gemini";
|
|
221
|
+
if (normalized.endsWith("/.codex/config.toml")) return "codex";
|
|
222
|
+
if (
|
|
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")
|
|
228
|
+
) {
|
|
229
|
+
return "claude";
|
|
230
|
+
}
|
|
231
|
+
return "unknown";
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function detectLabel(filePath) {
|
|
235
|
+
const normalized = normalizeForMatch(filePath);
|
|
236
|
+
if (normalized.endsWith("/.gemini/config/mcp_config.json"))
|
|
237
|
+
return "Antigravity";
|
|
238
|
+
if (normalized.endsWith("/.gemini/settings.json")) return "Gemini";
|
|
239
|
+
if (normalized.endsWith("/.gemini/config/mcp_config.json"))
|
|
240
|
+
return "Antigravity";
|
|
241
|
+
if (normalized.endsWith("/.codex/config.toml")) return "Codex";
|
|
242
|
+
if (normalized.endsWith("/.claude.json")) return "Claude User MCP";
|
|
243
|
+
if (normalized.endsWith("/.claude/settings.json")) return "Claude User";
|
|
244
|
+
if (normalized.endsWith("/.claude/settings.local.json"))
|
|
245
|
+
return "Claude Local";
|
|
246
|
+
if (normalized.endsWith("/.claude/mcp.json")) return "Claude Project MCP";
|
|
247
|
+
if (normalized.endsWith("/.mcp.json")) return "Project MCP";
|
|
248
|
+
return basename(filePath);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function isPrimaryConfigTarget(filePath) {
|
|
252
|
+
const normalized = normalizeForMatch(filePath);
|
|
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
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function normalizeUrl(value) {
|
|
264
|
+
if (typeof value !== "string" || !value.trim()) return "";
|
|
265
|
+
try {
|
|
266
|
+
const url = new URL(value.trim());
|
|
267
|
+
if (url.pathname !== "/" && url.pathname.endsWith("/")) {
|
|
268
|
+
url.pathname = url.pathname.replace(/\/+$/, "");
|
|
269
|
+
}
|
|
270
|
+
return url.toString();
|
|
271
|
+
} catch {
|
|
272
|
+
return value.trim();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function formatTomlString(value) {
|
|
277
|
+
return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function parseTomlScalar(rawValue) {
|
|
281
|
+
const value = String(rawValue || "").trim();
|
|
282
|
+
if (!value) return "";
|
|
283
|
+
if (value === "true") return true;
|
|
284
|
+
if (value === "false") return false;
|
|
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
|
+
}
|
|
292
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
293
|
+
return value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
|
294
|
+
}
|
|
295
|
+
return value;
|
|
296
|
+
}
|
|
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
|
+
|
|
375
|
+
function parseCodexMcpServers(raw) {
|
|
376
|
+
const lines = String(raw || "").split(/\r?\n/);
|
|
377
|
+
const servers = {};
|
|
378
|
+
let currentName = null;
|
|
379
|
+
|
|
380
|
+
for (const line of lines) {
|
|
381
|
+
const sectionMatch = line.match(/^\s*\[mcp_servers\.([^\]]+)\]\s*$/);
|
|
382
|
+
if (sectionMatch) {
|
|
383
|
+
currentName = sectionMatch[1];
|
|
384
|
+
servers[currentName] = {};
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (/^\s*\[/.test(line)) {
|
|
389
|
+
currentName = null;
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (!currentName) continue;
|
|
394
|
+
|
|
395
|
+
const kvMatch = line.match(/^\s*([A-Za-z0-9_]+)\s*=\s*(.+?)\s*$/);
|
|
396
|
+
if (!kvMatch) continue;
|
|
397
|
+
servers[currentName][kvMatch[1]] = parseTomlScalar(kvMatch[2]);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return servers;
|
|
401
|
+
}
|
|
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
|
+
|
|
628
|
+
function removeTomlSection(raw, sectionName) {
|
|
629
|
+
const lines = String(raw || "").split(/\r?\n/);
|
|
630
|
+
const output = [];
|
|
631
|
+
const header = `[mcp_servers.${sectionName}]`;
|
|
632
|
+
let skipping = false;
|
|
633
|
+
|
|
634
|
+
for (const line of lines) {
|
|
635
|
+
if (line.trim() === header) {
|
|
636
|
+
skipping = true;
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (skipping && /^\s*\[/.test(line)) {
|
|
641
|
+
skipping = false;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (!skipping) output.push(line);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const cleaned = output
|
|
648
|
+
.join("\n")
|
|
649
|
+
.replace(/\n{3,}$/g, "\n\n")
|
|
650
|
+
.replace(/^\n+/, "");
|
|
651
|
+
return cleaned.length > 0 ? cleaned.replace(/\n{3,}/g, "\n\n") : "";
|
|
652
|
+
}
|
|
653
|
+
|
|
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");
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function getHubServerEntry(registry) {
|
|
739
|
+
const entries = Object.entries(registry?.servers || {});
|
|
740
|
+
if (entries.length === 0) {
|
|
741
|
+
return [
|
|
742
|
+
"tfx-hub",
|
|
743
|
+
{
|
|
744
|
+
url: `${registry?.defaults?.hub_base || "http://127.0.0.1:27888"}${DEFAULT_HUB_PATH}`,
|
|
745
|
+
},
|
|
746
|
+
];
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
return (
|
|
750
|
+
entries.find(([name]) => name === "tfx-hub") ||
|
|
751
|
+
entries.find(([, config]) => config?.transport === "hub-url") ||
|
|
752
|
+
entries[0]
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function _makeHubRuntimeConfig() {
|
|
757
|
+
return { url: resolveHubUrl() };
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function serverTargets(serverConfig) {
|
|
761
|
+
if (Array.isArray(serverConfig?.targets) && serverConfig.targets.length > 0) {
|
|
762
|
+
return [
|
|
763
|
+
...new Set(
|
|
764
|
+
serverConfig.targets
|
|
765
|
+
.map((value) => String(value).trim())
|
|
766
|
+
.filter(Boolean),
|
|
767
|
+
),
|
|
768
|
+
];
|
|
769
|
+
}
|
|
770
|
+
return ["claude", "gemini", "codex", "antigravity"];
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function serverAppliesToClient(serverConfig, client) {
|
|
774
|
+
return serverTargets(serverConfig).includes(client);
|
|
775
|
+
}
|
|
776
|
+
|
|
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 || "");
|
|
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
|
+
}
|
|
871
|
+
|
|
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
|
+
};
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
if (isCodexConfig(filePath)) {
|
|
897
|
+
return {
|
|
898
|
+
name,
|
|
899
|
+
config: { url, ...headerConfig },
|
|
900
|
+
headerDescriptors: resolvedHeaders.descriptors,
|
|
901
|
+
codex: resolvedHeaders.codex,
|
|
902
|
+
warnings: resolvedHeaders.warnings,
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
return {
|
|
907
|
+
name,
|
|
908
|
+
config: { url, ...headerConfig },
|
|
909
|
+
headerDescriptors: resolvedHeaders.descriptors,
|
|
910
|
+
codex: resolvedHeaders.codex,
|
|
911
|
+
warnings: resolvedHeaders.warnings,
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function scanJsonConfig(filePath) {
|
|
916
|
+
if (!existsSync(filePath)) {
|
|
917
|
+
return {
|
|
918
|
+
filePath,
|
|
919
|
+
client: detectClient(filePath),
|
|
920
|
+
label: detectLabel(filePath),
|
|
921
|
+
exists: false,
|
|
922
|
+
parseError: null,
|
|
923
|
+
servers: [],
|
|
924
|
+
stdioServers: [],
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
try {
|
|
929
|
+
const parsed = readJsonFile(filePath);
|
|
930
|
+
const mcpServers = parsed?.mcpServers;
|
|
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
|
+
}));
|
|
979
|
+
|
|
980
|
+
return {
|
|
981
|
+
filePath,
|
|
982
|
+
client: detectClient(filePath),
|
|
983
|
+
label: detectLabel(filePath),
|
|
984
|
+
exists: true,
|
|
985
|
+
parseError: null,
|
|
986
|
+
servers,
|
|
987
|
+
stdioServers: servers.filter((server) => server.transport === "stdio"),
|
|
988
|
+
};
|
|
989
|
+
} catch (error) {
|
|
990
|
+
return {
|
|
991
|
+
filePath,
|
|
992
|
+
client: detectClient(filePath),
|
|
993
|
+
label: detectLabel(filePath),
|
|
994
|
+
exists: true,
|
|
995
|
+
parseError: error,
|
|
996
|
+
servers: [],
|
|
997
|
+
stdioServers: [],
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function scanCodexConfig(filePath) {
|
|
1003
|
+
if (!existsSync(filePath)) {
|
|
1004
|
+
return {
|
|
1005
|
+
filePath,
|
|
1006
|
+
client: detectClient(filePath),
|
|
1007
|
+
label: detectLabel(filePath),
|
|
1008
|
+
exists: false,
|
|
1009
|
+
parseError: null,
|
|
1010
|
+
servers: [],
|
|
1011
|
+
stdioServers: [],
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
try {
|
|
1016
|
+
const raw = readFileSync(filePath, "utf8");
|
|
1017
|
+
const parsed = parseCodexMcpServers(raw);
|
|
1018
|
+
const servers = Object.entries(parsed).map(([name, config]) => ({
|
|
1019
|
+
name,
|
|
1020
|
+
url: typeof config.url === "string" ? normalizeUrl(config.url) : "",
|
|
1021
|
+
command: typeof config.command === "string" ? config.command : "",
|
|
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",
|
|
1046
|
+
raw: config,
|
|
1047
|
+
}));
|
|
1048
|
+
|
|
1049
|
+
return {
|
|
1050
|
+
filePath,
|
|
1051
|
+
client: detectClient(filePath),
|
|
1052
|
+
label: detectLabel(filePath),
|
|
1053
|
+
exists: true,
|
|
1054
|
+
parseError: null,
|
|
1055
|
+
servers,
|
|
1056
|
+
// Wave 1/2-B: Codex stdio servers are observed but not auto-remediated.
|
|
1057
|
+
stdioServers: [],
|
|
1058
|
+
};
|
|
1059
|
+
} catch (error) {
|
|
1060
|
+
return {
|
|
1061
|
+
filePath,
|
|
1062
|
+
client: detectClient(filePath),
|
|
1063
|
+
label: detectLabel(filePath),
|
|
1064
|
+
exists: true,
|
|
1065
|
+
parseError: error,
|
|
1066
|
+
servers: [],
|
|
1067
|
+
stdioServers: [],
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function updateJsonConfig(filePath, updates = [], removals = []) {
|
|
1073
|
+
const resolvedPath = resolveFilePath(filePath);
|
|
1074
|
+
let parsed = {};
|
|
1075
|
+
|
|
1076
|
+
if (existsSync(resolvedPath)) {
|
|
1077
|
+
parsed = readJsonFile(resolvedPath);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1081
|
+
parsed = {};
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
if (
|
|
1085
|
+
!parsed.mcpServers ||
|
|
1086
|
+
typeof parsed.mcpServers !== "object" ||
|
|
1087
|
+
Array.isArray(parsed.mcpServers)
|
|
1088
|
+
) {
|
|
1089
|
+
parsed.mcpServers = {};
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
let modified = false;
|
|
1093
|
+
|
|
1094
|
+
for (const name of removals) {
|
|
1095
|
+
if (Object.hasOwn(parsed.mcpServers, name)) {
|
|
1096
|
+
delete parsed.mcpServers[name];
|
|
1097
|
+
modified = true;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
for (const update of updates) {
|
|
1102
|
+
const current = parsed.mcpServers[update.name];
|
|
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);
|
|
1131
|
+
if (changed) {
|
|
1132
|
+
parsed.mcpServers[update.name] = nextConfig;
|
|
1133
|
+
modified = true;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
if (!modified) {
|
|
1138
|
+
return { modified: false, filePath: resolvedPath };
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
writeJsonFile(resolvedPath, parsed);
|
|
1142
|
+
lockJsonConfigPermissions(resolvedPath);
|
|
1143
|
+
return { modified: true, filePath: resolvedPath };
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
function updateCodexConfig(filePath, updates = [], removals = []) {
|
|
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
|
+
|
|
1157
|
+
let raw = existsSync(resolvedPath) ? readFileSync(resolvedPath, "utf8") : "";
|
|
1158
|
+
|
|
1159
|
+
for (const name of removals) {
|
|
1160
|
+
raw = removeTomlSection(raw, name);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
for (const update of updates) {
|
|
1164
|
+
raw = upsertTomlServer(raw, update.name, update.config, update.codex || {});
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
const finalRaw = raw.trim().length > 0 ? `${raw.trimEnd()}\n` : "";
|
|
1168
|
+
const previousRaw = existsSync(resolvedPath)
|
|
1169
|
+
? readFileSync(resolvedPath, "utf8")
|
|
1170
|
+
: "";
|
|
1171
|
+
if (finalRaw === previousRaw) {
|
|
1172
|
+
return { modified: false, filePath: resolvedPath };
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
mkdirSync(dirname(resolvedPath), { recursive: true });
|
|
1176
|
+
writeFileSync(resolvedPath, finalRaw, "utf8");
|
|
1177
|
+
return { modified: true, filePath: resolvedPath };
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function scanConfig(filePath) {
|
|
1181
|
+
const resolvedPath = resolveFilePath(filePath);
|
|
1182
|
+
if (isCodexConfig(resolvedPath)) return scanCodexConfig(resolvedPath);
|
|
1183
|
+
if (isJsonMcpConfig(resolvedPath)) return scanJsonConfig(resolvedPath);
|
|
1184
|
+
|
|
1185
|
+
return {
|
|
1186
|
+
filePath: resolvedPath,
|
|
1187
|
+
client: detectClient(resolvedPath),
|
|
1188
|
+
label: detectLabel(resolvedPath),
|
|
1189
|
+
exists: existsSync(resolvedPath),
|
|
1190
|
+
parseError: null,
|
|
1191
|
+
servers: [],
|
|
1192
|
+
stdioServers: [],
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
export function getRegistryPath() {
|
|
1197
|
+
return registryPath();
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
export function createDefaultRegistry() {
|
|
1201
|
+
return cloneDefaultRegistry();
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
export function validateRegistry(registry) {
|
|
1205
|
+
const errors = [];
|
|
1206
|
+
if (!registry || typeof registry !== "object" || Array.isArray(registry)) {
|
|
1207
|
+
return ["registry must be an object"];
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
if (registry.version !== 1) {
|
|
1211
|
+
errors.push("registry.version must be 1");
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
if (!registry.defaults || typeof registry.defaults !== "object") {
|
|
1215
|
+
errors.push("registry.defaults must be an object");
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
if (
|
|
1219
|
+
!registry.servers ||
|
|
1220
|
+
typeof registry.servers !== "object" ||
|
|
1221
|
+
Array.isArray(registry.servers)
|
|
1222
|
+
) {
|
|
1223
|
+
errors.push("registry.servers must be an object");
|
|
1224
|
+
} else {
|
|
1225
|
+
for (const [name, server] of Object.entries(registry.servers)) {
|
|
1226
|
+
if (!name.trim()) {
|
|
1227
|
+
errors.push("registry.servers contains an empty name");
|
|
1228
|
+
continue;
|
|
1229
|
+
}
|
|
1230
|
+
if (!server || typeof server !== "object" || Array.isArray(server)) {
|
|
1231
|
+
errors.push(`registry.servers.${name} must be an object`);
|
|
1232
|
+
continue;
|
|
1233
|
+
}
|
|
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
|
+
) {
|
|
1253
|
+
errors.push(`registry.servers.${name}.url must be a non-empty string`);
|
|
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
|
+
}
|
|
1422
|
+
if (server.targets !== undefined && !Array.isArray(server.targets)) {
|
|
1423
|
+
errors.push(`registry.servers.${name}.targets must be an array`);
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
if (
|
|
1429
|
+
!registry.policies ||
|
|
1430
|
+
typeof registry.policies !== "object" ||
|
|
1431
|
+
Array.isArray(registry.policies)
|
|
1432
|
+
) {
|
|
1433
|
+
errors.push("registry.policies must be an object");
|
|
1434
|
+
} else {
|
|
1435
|
+
if (!Array.isArray(registry.policies.watched_paths)) {
|
|
1436
|
+
errors.push("registry.policies.watched_paths must be an array");
|
|
1437
|
+
}
|
|
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
|
+
);
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
return errors;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
export function inspectRegistry() {
|
|
1468
|
+
if (!existsSync(registryPath())) {
|
|
1469
|
+
return {
|
|
1470
|
+
path: registryPath(),
|
|
1471
|
+
exists: false,
|
|
1472
|
+
valid: false,
|
|
1473
|
+
errors: ["registry file missing"],
|
|
1474
|
+
registry: null,
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
try {
|
|
1479
|
+
const registry = readJsonFile(registryPath());
|
|
1480
|
+
const errors = validateRegistry(registry);
|
|
1481
|
+
return {
|
|
1482
|
+
path: registryPath(),
|
|
1483
|
+
exists: true,
|
|
1484
|
+
valid: errors.length === 0,
|
|
1485
|
+
errors,
|
|
1486
|
+
registry: errors.length === 0 ? registry : null,
|
|
1487
|
+
};
|
|
1488
|
+
} catch (error) {
|
|
1489
|
+
return {
|
|
1490
|
+
path: registryPath(),
|
|
1491
|
+
exists: true,
|
|
1492
|
+
valid: false,
|
|
1493
|
+
errors: [error.message],
|
|
1494
|
+
registry: null,
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
export function loadRegistry() {
|
|
1500
|
+
const state = inspectRegistry();
|
|
1501
|
+
if (!state.exists) {
|
|
1502
|
+
throw new Error(`MCP registry missing: ${state.path}`);
|
|
1503
|
+
}
|
|
1504
|
+
if (!state.valid) {
|
|
1505
|
+
throw new Error(`MCP registry invalid: ${state.errors.join("; ")}`);
|
|
1506
|
+
}
|
|
1507
|
+
return {
|
|
1508
|
+
...state.registry,
|
|
1509
|
+
defaults: { ...(state.registry?.defaults || {}) },
|
|
1510
|
+
servers: { ...(state.registry?.servers || {}) },
|
|
1511
|
+
policies: { ...(state.registry?.policies || {}) },
|
|
1512
|
+
};
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
export function loadRegistryOrDefault() {
|
|
1516
|
+
const state = inspectRegistry();
|
|
1517
|
+
if (!state.exists) return cloneDefaultRegistry();
|
|
1518
|
+
if (!state.valid) return cloneDefaultRegistry();
|
|
1519
|
+
return {
|
|
1520
|
+
...state.registry,
|
|
1521
|
+
defaults: { ...(state.registry?.defaults || {}) },
|
|
1522
|
+
servers: { ...(state.registry?.servers || {}) },
|
|
1523
|
+
policies: { ...(state.registry?.policies || {}) },
|
|
1524
|
+
};
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
export function saveRegistry(registry) {
|
|
1528
|
+
const errors = validateRegistry(registry);
|
|
1529
|
+
if (errors.length > 0) {
|
|
1530
|
+
throw new Error(`MCP registry invalid: ${errors.join("; ")}`);
|
|
1531
|
+
}
|
|
1532
|
+
writeJsonFile(registryPath(), registry);
|
|
1533
|
+
return registry;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
export function listManagedConfigTargets(registry = loadRegistryOrDefault()) {
|
|
1537
|
+
return (registry?.policies?.watched_paths || []).map((watchedPath) => {
|
|
1538
|
+
const filePath = resolveFilePath(watchedPath);
|
|
1539
|
+
return {
|
|
1540
|
+
watchedPath,
|
|
1541
|
+
filePath,
|
|
1542
|
+
client: detectClient(filePath),
|
|
1543
|
+
label: detectLabel(filePath),
|
|
1544
|
+
exists: existsSync(filePath),
|
|
1545
|
+
};
|
|
1546
|
+
});
|
|
1547
|
+
}
|
|
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
|
+
|
|
1614
|
+
export function listPrimaryConfigTargets(registry = loadRegistryOrDefault()) {
|
|
1615
|
+
return listManagedConfigTargets(registry).filter((target) =>
|
|
1616
|
+
isPrimaryConfigTarget(target.filePath),
|
|
1617
|
+
);
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
export function scanManagedConfigs(registry = loadRegistryOrDefault()) {
|
|
1621
|
+
return listManagedConfigTargets(registry).map((target) => ({
|
|
1622
|
+
...target,
|
|
1623
|
+
...scanConfig(target.filePath),
|
|
1624
|
+
}));
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
export function inspectRegistryStatus(registry = loadRegistryOrDefault()) {
|
|
1628
|
+
const configs = scanManagedConfigs(registry);
|
|
1629
|
+
const primaryTargets = new Set(
|
|
1630
|
+
listPrimaryConfigTargets(registry).map((target) =>
|
|
1631
|
+
normalizeForMatch(target.filePath),
|
|
1632
|
+
),
|
|
1633
|
+
);
|
|
1634
|
+
const rows = [];
|
|
1635
|
+
|
|
1636
|
+
for (const config of configs) {
|
|
1637
|
+
const isPrimary = primaryTargets.has(normalizeForMatch(config.filePath));
|
|
1638
|
+
const managedServers = Object.entries(registry.servers || {}).filter(
|
|
1639
|
+
([, serverConfig]) =>
|
|
1640
|
+
isPrimary && serverAppliesToClient(serverConfig, config.client),
|
|
1641
|
+
);
|
|
1642
|
+
|
|
1643
|
+
for (const [name, serverConfig] of managedServers) {
|
|
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);
|
|
1658
|
+
let status = "missing";
|
|
1659
|
+
|
|
1660
|
+
if (!config.exists) {
|
|
1661
|
+
status = "missing-file";
|
|
1662
|
+
} else if (config.parseError) {
|
|
1663
|
+
status = "invalid-config";
|
|
1664
|
+
} else if (!actual) {
|
|
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";
|
|
1673
|
+
} else if (!actual.url) {
|
|
1674
|
+
status = actual.transport === "stdio" ? "stdio" : "invalid";
|
|
1675
|
+
} else if (normalizeUrl(actual.url) === normalizeUrl(expectedUrl)) {
|
|
1676
|
+
status = currentHeaderStatus === "ok" ? "present" : "mismatch";
|
|
1677
|
+
} else {
|
|
1678
|
+
status = "mismatch";
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
rows.push({
|
|
1682
|
+
type: "registry",
|
|
1683
|
+
name,
|
|
1684
|
+
client: config.client,
|
|
1685
|
+
label: config.label,
|
|
1686
|
+
filePath: config.filePath,
|
|
1687
|
+
expectedUrl,
|
|
1688
|
+
expectedCommand,
|
|
1689
|
+
actualUrl: actual?.url || "",
|
|
1690
|
+
actualCommand: actual?.command || "",
|
|
1691
|
+
status,
|
|
1692
|
+
headerStatus: currentHeaderStatus,
|
|
1693
|
+
expectedHeaderNames: headerNames(expectedHeaders),
|
|
1694
|
+
actualHeaderNames: headerNames(actualHeaders),
|
|
1695
|
+
});
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
for (const server of config.stdioServers) {
|
|
1699
|
+
if (Object.hasOwn(registry.servers || {}, server.name)) continue;
|
|
1700
|
+
rows.push({
|
|
1701
|
+
type: "stdio",
|
|
1702
|
+
name: server.name,
|
|
1703
|
+
client: config.client,
|
|
1704
|
+
label: config.label,
|
|
1705
|
+
filePath: config.filePath,
|
|
1706
|
+
expectedUrl: "",
|
|
1707
|
+
actualUrl: "",
|
|
1708
|
+
status: "warning",
|
|
1709
|
+
command: server.command,
|
|
1710
|
+
});
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
return {
|
|
1715
|
+
registry,
|
|
1716
|
+
configs,
|
|
1717
|
+
rows,
|
|
1718
|
+
};
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
export function scanForStdioServers(filePath) {
|
|
1722
|
+
return scanConfig(filePath).stdioServers;
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
export function remediate(filePath, stdioServers, policy = {}) {
|
|
1726
|
+
const resolvedPath = resolveFilePath(filePath);
|
|
1727
|
+
const offenders = Array.isArray(stdioServers)
|
|
1728
|
+
? stdioServers.filter((server) => server?.name)
|
|
1729
|
+
: [];
|
|
1730
|
+
const action = policy?.stdio_action || "warn";
|
|
1731
|
+
|
|
1732
|
+
if (offenders.length === 0) {
|
|
1733
|
+
return {
|
|
1734
|
+
action: "noop",
|
|
1735
|
+
modified: false,
|
|
1736
|
+
backupPath: null,
|
|
1737
|
+
removedServers: [],
|
|
1738
|
+
warnings: [],
|
|
1739
|
+
};
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
if (action === "warn") {
|
|
1743
|
+
return {
|
|
1744
|
+
action,
|
|
1745
|
+
modified: false,
|
|
1746
|
+
backupPath: null,
|
|
1747
|
+
removedServers: [],
|
|
1748
|
+
warnings: [
|
|
1749
|
+
`[mcp-guard] stdio MCP 감지: ${offenders.map((server) => server.name).join(", ")}`,
|
|
1750
|
+
],
|
|
1751
|
+
};
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
if (isCodexConfig(resolvedPath)) {
|
|
1755
|
+
return {
|
|
1756
|
+
action,
|
|
1757
|
+
modified: false,
|
|
1758
|
+
backupPath: null,
|
|
1759
|
+
removedServers: [],
|
|
1760
|
+
warnings: ["[mcp-guard] Codex TOML 자동 수정은 Wave 2-B 범위 밖입니다."],
|
|
1761
|
+
};
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
const snapshot = scanConfig(resolvedPath);
|
|
1765
|
+
if (snapshot.parseError) {
|
|
1766
|
+
return {
|
|
1767
|
+
action,
|
|
1768
|
+
modified: false,
|
|
1769
|
+
backupPath: null,
|
|
1770
|
+
removedServers: [],
|
|
1771
|
+
warnings: [`[mcp-guard] 설정 파싱 실패: ${snapshot.parseError.message}`],
|
|
1772
|
+
};
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
let backupPath = null;
|
|
1776
|
+
try {
|
|
1777
|
+
if (existsSync(resolvedPath)) backupPath = ensureBackup(resolvedPath);
|
|
1778
|
+
} catch (error) {
|
|
1779
|
+
return {
|
|
1780
|
+
action,
|
|
1781
|
+
modified: false,
|
|
1782
|
+
backupPath: null,
|
|
1783
|
+
removedServers: [],
|
|
1784
|
+
warnings: [`[mcp-guard] 백업 생성 실패: ${error.message}`],
|
|
1785
|
+
};
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
const removals = offenders.map((server) => server.name);
|
|
1789
|
+
const updates = [];
|
|
1790
|
+
let replacement = null;
|
|
1791
|
+
|
|
1792
|
+
if (action === "replace-with-hub") {
|
|
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) };
|
|
1806
|
+
updates.push(desired);
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
const result = updateJsonConfig(resolvedPath, updates, removals);
|
|
1810
|
+
return {
|
|
1811
|
+
action,
|
|
1812
|
+
modified: result.modified,
|
|
1813
|
+
backupPath,
|
|
1814
|
+
removedServers: removals,
|
|
1815
|
+
replacement,
|
|
1816
|
+
warnings: updates.flatMap((update) => update.warnings || []),
|
|
1817
|
+
};
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
export function resolveHubUrl() {
|
|
1821
|
+
const registryState = inspectRegistry();
|
|
1822
|
+
const registry = registryState.valid
|
|
1823
|
+
? registryState.registry
|
|
1824
|
+
: cloneDefaultRegistry();
|
|
1825
|
+
const [, hubServer] = getHubServerEntry(registry);
|
|
1826
|
+
const fallbackRaw =
|
|
1827
|
+
hubServer?.url ||
|
|
1828
|
+
`${registry?.defaults?.hub_base || "http://127.0.0.1:27888"}${DEFAULT_HUB_PATH}`;
|
|
1829
|
+
|
|
1830
|
+
let fallback;
|
|
1831
|
+
try {
|
|
1832
|
+
fallback = new URL(fallbackRaw);
|
|
1833
|
+
} catch {
|
|
1834
|
+
fallback = new URL(`http://127.0.0.1:27888${DEFAULT_HUB_PATH}`);
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
const envPortRaw = Number(process.env.TFX_HUB_PORT || "");
|
|
1838
|
+
const envPort =
|
|
1839
|
+
Number.isFinite(envPortRaw) && envPortRaw > 0 ? envPortRaw : null;
|
|
1840
|
+
const target = {
|
|
1841
|
+
protocol: fallback.protocol || "http:",
|
|
1842
|
+
host: fallback.hostname || "127.0.0.1",
|
|
1843
|
+
port: envPort || Number(fallback.port || 27888),
|
|
1844
|
+
pathname:
|
|
1845
|
+
fallback.pathname && fallback.pathname !== "/"
|
|
1846
|
+
? fallback.pathname
|
|
1847
|
+
: DEFAULT_HUB_PATH,
|
|
1848
|
+
};
|
|
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 에서 이미 제거됨. 여기도 일관 적용.
|
|
1853
|
+
const hubPidPath = join(homedir(), ".claude", "cache", "tfx-hub", "hub.pid");
|
|
1854
|
+
if (existsSync(hubPidPath)) {
|
|
1855
|
+
try {
|
|
1856
|
+
const info = readJsonFile(hubPidPath);
|
|
1857
|
+
if (typeof info?.host === "string") {
|
|
1858
|
+
const host = info.host.trim();
|
|
1859
|
+
if (LOOPBACK_HOSTS.has(host)) target.host = host;
|
|
1860
|
+
}
|
|
1861
|
+
} catch {
|
|
1862
|
+
// pid 파일 파싱 실패 시 registry 기본값 사용
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
const hostPart = target.host.includes(":") ? `[${target.host}]` : target.host;
|
|
1867
|
+
return `${target.protocol}//${hostPart}:${target.port}${target.pathname}`;
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
export function isWatchedPath(filePath) {
|
|
1871
|
+
const registryState = inspectRegistry();
|
|
1872
|
+
const registry = registryState.valid
|
|
1873
|
+
? registryState.registry
|
|
1874
|
+
: cloneDefaultRegistry();
|
|
1875
|
+
const candidate = normalizeForMatch(filePath);
|
|
1876
|
+
|
|
1877
|
+
return (registry?.policies?.watched_paths || []).some((watchedPath) => {
|
|
1878
|
+
if (typeof watchedPath !== "string" || !watchedPath.trim()) return false;
|
|
1879
|
+
|
|
1880
|
+
const trimmed = watchedPath.trim();
|
|
1881
|
+
const expanded = expandHome(trimmed);
|
|
1882
|
+
|
|
1883
|
+
if (trimmed !== expanded || isAbsolute(expanded)) {
|
|
1884
|
+
return candidate === normalizeForMatch(expanded);
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
if (!trimmed.includes("/") && !trimmed.includes("\\")) {
|
|
1888
|
+
return pathBasename(candidate) === trimmed.toLowerCase();
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
const suffix = trimmed
|
|
1892
|
+
.replace(/^[.][\\/]/, "")
|
|
1893
|
+
.replace(/\\/g, "/")
|
|
1894
|
+
.toLowerCase();
|
|
1895
|
+
return candidate.endsWith(`/${suffix}`);
|
|
1896
|
+
});
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
export function addRegistryServer(name, url, options = {}) {
|
|
1900
|
+
const trimmedName = String(name || "").trim();
|
|
1901
|
+
const normalizedUrl = normalizeUrl(url);
|
|
1902
|
+
if (!trimmedName) throw new Error("server name is required");
|
|
1903
|
+
if (!normalizedUrl) throw new Error("server url is required");
|
|
1904
|
+
|
|
1905
|
+
const registryState = inspectRegistry();
|
|
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");
|
|
1918
|
+
|
|
1919
|
+
registry.servers[trimmedName] = {
|
|
1920
|
+
policy,
|
|
1921
|
+
transport,
|
|
1922
|
+
url: normalizedUrl,
|
|
1923
|
+
safe: options.safe ?? true,
|
|
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"],
|
|
1934
|
+
description: options.description || `${trimmedName} MCP 서버`,
|
|
1935
|
+
};
|
|
1936
|
+
|
|
1937
|
+
saveRegistry(registry);
|
|
1938
|
+
return registry.servers[trimmedName];
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
export function removeRegistryServer(name) {
|
|
1942
|
+
const trimmedName = String(name || "").trim();
|
|
1943
|
+
if (!trimmedName) throw new Error("server name is required");
|
|
1944
|
+
|
|
1945
|
+
const state = inspectRegistry();
|
|
1946
|
+
if (!state.exists || !state.valid) return null;
|
|
1947
|
+
const registry = loadRegistry();
|
|
1948
|
+
const existing = registry.servers[trimmedName] || null;
|
|
1949
|
+
if (existing) {
|
|
1950
|
+
delete registry.servers[trimmedName];
|
|
1951
|
+
saveRegistry(registry);
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
return existing;
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
export function removeServerFromTargets(name, options = {}) {
|
|
1958
|
+
const trimmedName = String(name || "").trim();
|
|
1959
|
+
if (!trimmedName) throw new Error("server name is required");
|
|
1960
|
+
|
|
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;
|
|
1968
|
+
const actions = [];
|
|
1969
|
+
|
|
1970
|
+
for (const target of listManagedConfigTargets(registry)) {
|
|
1971
|
+
if (targetsFilter && !targetsFilter.has(target.client)) continue;
|
|
1972
|
+
|
|
1973
|
+
const snapshot = scanConfig(target.filePath);
|
|
1974
|
+
if (snapshot.parseError) {
|
|
1975
|
+
actions.push({
|
|
1976
|
+
type: "remove",
|
|
1977
|
+
name: trimmedName,
|
|
1978
|
+
filePath: target.filePath,
|
|
1979
|
+
label: target.label,
|
|
1980
|
+
status: "invalid-config",
|
|
1981
|
+
message: snapshot.parseError.message,
|
|
1982
|
+
});
|
|
1983
|
+
continue;
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
let result;
|
|
1987
|
+
if (isCodexConfig(target.filePath)) {
|
|
1988
|
+
result = updateCodexConfig(target.filePath, [], [trimmedName]);
|
|
1989
|
+
} else if (isJsonMcpConfig(target.filePath)) {
|
|
1990
|
+
result = updateJsonConfig(target.filePath, [], [trimmedName]);
|
|
1991
|
+
} else {
|
|
1992
|
+
continue;
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
actions.push({
|
|
1996
|
+
type: "remove",
|
|
1997
|
+
name: trimmedName,
|
|
1998
|
+
filePath: target.filePath,
|
|
1999
|
+
label: target.label,
|
|
2000
|
+
status: result.modified ? "removed" : "noop",
|
|
2001
|
+
});
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
return { actions };
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
export function syncRegistryTargets(options = {}) {
|
|
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
|
+
);
|
|
2015
|
+
const actions = [];
|
|
2016
|
+
const invalidConfigs = new Set();
|
|
2017
|
+
const denylist = syncDenylistEntries(registry.policies);
|
|
2018
|
+
|
|
2019
|
+
for (const target of syncTargets) {
|
|
2020
|
+
const snapshot = scanConfig(target.filePath);
|
|
2021
|
+
if (snapshot.parseError) {
|
|
2022
|
+
invalidConfigs.add(normalizeForMatch(target.filePath));
|
|
2023
|
+
actions.push({
|
|
2024
|
+
type: "sync",
|
|
2025
|
+
filePath: target.filePath,
|
|
2026
|
+
label: target.label,
|
|
2027
|
+
status: "invalid-config",
|
|
2028
|
+
message: snapshot.parseError.message,
|
|
2029
|
+
});
|
|
2030
|
+
continue;
|
|
2031
|
+
}
|
|
2032
|
+
|
|
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
|
+
});
|
|
2041
|
+
actions.push({
|
|
2042
|
+
type: "remediate",
|
|
2043
|
+
filePath: target.filePath,
|
|
2044
|
+
label: target.label,
|
|
2045
|
+
status: remediation.modified ? "updated" : "warning",
|
|
2046
|
+
removedServers: remediation.removedServers,
|
|
2047
|
+
replacement: remediation.replacement || null,
|
|
2048
|
+
warnings: remediation.warnings || [],
|
|
2049
|
+
});
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
|
|
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
|
+
}
|
|
2097
|
+
|
|
2098
|
+
if (updates.length === 0) continue;
|
|
2099
|
+
|
|
2100
|
+
let result;
|
|
2101
|
+
if (isCodexConfig(target.filePath)) {
|
|
2102
|
+
result = updateCodexConfig(target.filePath, updates, []);
|
|
2103
|
+
} else if (isJsonMcpConfig(target.filePath)) {
|
|
2104
|
+
result = updateJsonConfig(target.filePath, updates, []);
|
|
2105
|
+
} else {
|
|
2106
|
+
continue;
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
actions.push({
|
|
2110
|
+
type: "sync",
|
|
2111
|
+
filePath: target.filePath,
|
|
2112
|
+
label: target.label,
|
|
2113
|
+
status: result.modified ? "updated" : "ok",
|
|
2114
|
+
serverCount: updates.length,
|
|
2115
|
+
warnings: updates.flatMap((update) => update.warnings || []),
|
|
2116
|
+
});
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
return { actions };
|
|
2120
|
+
}
|