@agent-native/skills 0.0.0-beta-20260819155526

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.
@@ -0,0 +1,722 @@
1
+ /**
2
+ * Shared MCP client-config writers.
3
+ *
4
+ * Extracted so both `agent-native mcp install` (see `mcp.ts`) and
5
+ * `agent-native connect` (see `connect.ts`) write the EXACT same on-disk
6
+ * config file targets and formats for every supported client.
7
+ *
8
+ * Supported clients and their config files:
9
+ * - claude-code / claude-code-cli → `.mcp.json` (project) or
10
+ * `~/.claude.json` (user). JSON `mcpServers[name] = entry`.
11
+ * - cowork → `~/.cowork/mcp.json`. Same JSON shape.
12
+ * - cursor → `.cursor/mcp.json` (project) or
13
+ * `~/.cursor/mcp.json` (user). JSON `mcpServers[name] = entry`.
14
+ * - opencode → `opencode.json` (project) or
15
+ * `~/.config/opencode/opencode.json` (user). JSON `mcp[name] = entry`.
16
+ * - github-copilot → `.vscode/mcp.json` (project) or the
17
+ * VS Code user `mcp.json`. JSON `servers[name] = entry`.
18
+ * - codex → `$CODEX_HOME/config.toml` when set,
19
+ * otherwise `~/.codex/config.toml`.
20
+ * `[mcp_servers.<name>]` block.
21
+ *
22
+ * Node-only. No new npm deps — hand-rolled JSON merge + minimal TOML block
23
+ * merge, mirroring `mcp.ts`.
24
+ */
25
+ import fs from "node:fs";
26
+ import os from "node:os";
27
+ import path from "node:path";
28
+ const MCP_LEGACY_ROUTE_PREFIX = "/_agent-native/mcp";
29
+ const MCP_PUBLIC_ROUTE_PREFIX = "/mcp";
30
+ export const CLIENTS = [
31
+ "claude-code",
32
+ "claude-code-cli",
33
+ "codex",
34
+ "cowork",
35
+ "cursor",
36
+ "opencode",
37
+ "github-copilot",
38
+ ];
39
+ /** Build the HTTP MCP server entry for a deployed agent-native app. */
40
+ export function buildHttpMcpEntry(mcpUrl, token, headers) {
41
+ const mergedHeaders = {
42
+ ...(headers ?? {}),
43
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
44
+ };
45
+ return {
46
+ type: "http",
47
+ url: mcpUrl,
48
+ ...(Object.keys(mergedHeaders).length ? { headers: mergedHeaders } : {}),
49
+ };
50
+ }
51
+ function mergedHeadersFor(token, headers) {
52
+ return {
53
+ ...(headers ?? {}),
54
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
55
+ };
56
+ }
57
+ export function buildHttpMcpEntryForClient(client, mcpUrl, token, headers) {
58
+ const mergedHeaders = mergedHeadersFor(token, headers);
59
+ if (client === "cursor") {
60
+ return {
61
+ url: mcpUrl,
62
+ ...(Object.keys(mergedHeaders).length ? { headers: mergedHeaders } : {}),
63
+ };
64
+ }
65
+ if (client === "opencode") {
66
+ return {
67
+ type: "remote",
68
+ url: mcpUrl,
69
+ enabled: true,
70
+ ...(Object.keys(mergedHeaders).length ? { headers: mergedHeaders } : {}),
71
+ };
72
+ }
73
+ if (client === "github-copilot") {
74
+ return {
75
+ type: "http",
76
+ url: mcpUrl,
77
+ ...(Object.keys(mergedHeaders).length
78
+ ? { requestInit: { headers: mergedHeaders } }
79
+ : {}),
80
+ };
81
+ }
82
+ return buildHttpMcpEntry(mcpUrl, token, headers);
83
+ }
84
+ export function buildLocalMcpEntryForClient(client, args, env, command = "agent-native") {
85
+ const cleanEnv = env ? Object.fromEntries(Object.entries(env)) : {};
86
+ if (client === "opencode") {
87
+ return {
88
+ type: "local",
89
+ command: [command, ...args],
90
+ enabled: true,
91
+ ...(Object.keys(cleanEnv).length ? { environment: cleanEnv } : {}),
92
+ };
93
+ }
94
+ if (client === "github-copilot") {
95
+ return {
96
+ type: "stdio",
97
+ command,
98
+ args,
99
+ ...(Object.keys(cleanEnv).length ? { env: cleanEnv } : {}),
100
+ };
101
+ }
102
+ return {
103
+ command,
104
+ args,
105
+ ...(Object.keys(cleanEnv).length ? { env: cleanEnv } : {}),
106
+ };
107
+ }
108
+ // ---------------------------------------------------------------------------
109
+ // Config file locations — kept identical to `mcp.ts`.
110
+ // ---------------------------------------------------------------------------
111
+ /**
112
+ * Cowork consumes MCP exactly like Claude Code (same JSON server-entry
113
+ * shape). Resolved lazily so `os.homedir()` reflects the current `$HOME`.
114
+ */
115
+ export function coworkConfigPath() {
116
+ return path.join(os.homedir(), ".cowork", "mcp.json");
117
+ }
118
+ export function claudeCodeProjectConfig(baseDir) {
119
+ return path.join(baseDir, ".mcp.json");
120
+ }
121
+ export function claudeCodeUserConfig() {
122
+ return path.join(os.homedir(), ".claude.json");
123
+ }
124
+ export function codexConfigPath() {
125
+ const codexHome = process.env.CODEX_HOME?.trim();
126
+ if (codexHome)
127
+ return path.join(codexHome, "config.toml");
128
+ return path.join(os.homedir(), ".codex", "config.toml");
129
+ }
130
+ export function cursorProjectConfig(baseDir) {
131
+ return path.join(baseDir, ".cursor", "mcp.json");
132
+ }
133
+ export function cursorUserConfig() {
134
+ return path.join(os.homedir(), ".cursor", "mcp.json");
135
+ }
136
+ export function opencodeProjectConfig(baseDir) {
137
+ return path.join(baseDir, "opencode.json");
138
+ }
139
+ export function opencodeUserConfig() {
140
+ const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim();
141
+ const configRoot = xdgConfigHome || path.join(os.homedir(), ".config");
142
+ return path.join(configRoot, "opencode", "opencode.json");
143
+ }
144
+ export function githubCopilotProjectConfig(baseDir) {
145
+ return path.join(baseDir, ".vscode", "mcp.json");
146
+ }
147
+ export function githubCopilotUserConfig() {
148
+ if (process.platform === "darwin") {
149
+ return path.join(os.homedir(), "Library", "Application Support", "Code", "User", "mcp.json");
150
+ }
151
+ if (process.platform === "win32") {
152
+ const appData = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
153
+ return path.join(appData, "Code", "User", "mcp.json");
154
+ }
155
+ const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim();
156
+ const configRoot = xdgConfigHome || path.join(os.homedir(), ".config");
157
+ return path.join(configRoot, "Code", "User", "mcp.json");
158
+ }
159
+ /**
160
+ * Resolve the on-disk config path for a client.
161
+ *
162
+ * `scope` only affects Claude Code / Claude Code CLI: `"user"` → the global
163
+ * `~/.claude.json`, anything else → the project-local `.mcp.json` rooted at
164
+ * `baseDir`.
165
+ */
166
+ export function configPathFor(client, baseDir, scope) {
167
+ switch (client) {
168
+ case "claude-code":
169
+ case "claude-code-cli":
170
+ return scope === "user"
171
+ ? claudeCodeUserConfig()
172
+ : claudeCodeProjectConfig(baseDir);
173
+ case "cowork":
174
+ return coworkConfigPath();
175
+ case "codex":
176
+ return codexConfigPath();
177
+ case "cursor":
178
+ return scope === "user"
179
+ ? cursorUserConfig()
180
+ : cursorProjectConfig(baseDir);
181
+ case "opencode":
182
+ return scope === "user"
183
+ ? opencodeUserConfig()
184
+ : opencodeProjectConfig(baseDir);
185
+ case "github-copilot":
186
+ return scope === "user"
187
+ ? githubCopilotUserConfig()
188
+ : githubCopilotProjectConfig(baseDir);
189
+ }
190
+ }
191
+ // ---------------------------------------------------------------------------
192
+ // JSON client configs (Claude Code, Claude Code CLI, Cowork)
193
+ // ---------------------------------------------------------------------------
194
+ /**
195
+ * Read an existing MCP config without conflating missing and unreadable files.
196
+ * Only ENOENT means the config is absent; any other read failure must abort
197
+ * before a caller can overwrite existing user configuration.
198
+ */
199
+ function readExistingConfigFile(file) {
200
+ try {
201
+ return fs.readFileSync(file, "utf-8");
202
+ }
203
+ catch (error) {
204
+ if (error.code === "ENOENT")
205
+ return undefined;
206
+ const detail = error instanceof Error ? ` (${error.message})` : "";
207
+ throw new Error(`Cannot read MCP config file: ${file}\n` +
208
+ `Check that the file is readable and re-run. The file has not been modified.${detail}`);
209
+ }
210
+ }
211
+ /**
212
+ * Read and parse a JSON config file.
213
+ *
214
+ * - Missing file → returns `{}` (fresh config).
215
+ * - Empty file → returns `{}` (treat as not-yet-initialised).
216
+ * - Non-empty file that fails to parse → throws a descriptive Error so the
217
+ * caller can surface it to the user instead of silently overwriting the
218
+ * file with only the new MCP entry (data-loss hazard).
219
+ */
220
+ function readJsonFile(file) {
221
+ const raw = readExistingConfigFile(file);
222
+ if (raw === undefined)
223
+ return {};
224
+ if (!raw.trim())
225
+ return {};
226
+ try {
227
+ const parsed = JSON.parse(raw);
228
+ return parsed && typeof parsed === "object" ? parsed : {};
229
+ }
230
+ catch {
231
+ throw new Error(`Cannot parse JSON config file: ${file}\n` +
232
+ `Fix or move the file and re-run. The file has not been modified.`);
233
+ }
234
+ }
235
+ /**
236
+ * Write `data` to `file` atomically: write a sibling temp file, then rename it
237
+ * over the target. `rename(2)` is atomic on the same filesystem, so a crash or
238
+ * `kill` mid-write can never leave a half-written/truncated file. This matters
239
+ * most for `~/.claude.json`, which is Claude Code's entire user state (projects,
240
+ * history, auth) — a torn write there would corrupt the user's whole config,
241
+ * not just our MCP entry. The temp file lives in the target's directory so the
242
+ * rename stays within one filesystem.
243
+ */
244
+ export function writeFileAtomic(file, data) {
245
+ const dir = path.dirname(file);
246
+ fs.mkdirSync(dir, { recursive: true });
247
+ // Preserve the target's existing permission bits. A fresh temp file would
248
+ // otherwise be created with the umask default (typically 0644), silently
249
+ // loosening a secret-bearing file the user locked down to 0600 (e.g. .env).
250
+ let mode;
251
+ try {
252
+ mode = fs.statSync(file).mode & 0o777;
253
+ }
254
+ catch {
255
+ // Target doesn't exist yet — let the default creation mode apply.
256
+ }
257
+ const tmp = path.join(dir, `.${path.basename(file)}.tmp-${process.pid}`);
258
+ try {
259
+ fs.writeFileSync(tmp, data, "utf-8");
260
+ if (mode !== undefined)
261
+ fs.chmodSync(tmp, mode);
262
+ fs.renameSync(tmp, file);
263
+ }
264
+ catch (err) {
265
+ try {
266
+ fs.rmSync(tmp, { force: true });
267
+ }
268
+ catch { }
269
+ throw err;
270
+ }
271
+ }
272
+ /**
273
+ * Idempotently write `mcpServers[name] = entry` into a JSON config file.
274
+ * Pass `entry === null` to delete the named entry. Re-running with the same
275
+ * name replaces the existing entry in place — never duplicates.
276
+ */
277
+ export function jsonMcpConfigKeyForClient(client) {
278
+ if (client === "opencode")
279
+ return "mcp";
280
+ if (client === "github-copilot")
281
+ return "servers";
282
+ return "mcpServers";
283
+ }
284
+ function writeJsonMcpEntryAtKey(file, serversKey, name, entry) {
285
+ const config = readJsonFile(file);
286
+ if (!config[serversKey] || typeof config[serversKey] !== "object") {
287
+ config[serversKey] = {};
288
+ }
289
+ const servers = config[serversKey];
290
+ if (entry === null) {
291
+ delete servers[name];
292
+ }
293
+ else {
294
+ servers[name] = entry;
295
+ }
296
+ writeFileAtomic(file, JSON.stringify(config, null, 2) + "\n");
297
+ }
298
+ export function writeJsonMcpEntry(file, name, entry) {
299
+ writeJsonMcpEntryAtKey(file, "mcpServers", name, entry);
300
+ }
301
+ export function writeJsonMcpEntryForClient(client, file, name, entry) {
302
+ writeJsonMcpEntryAtKey(file, jsonMcpConfigKeyForClient(client), name, entry);
303
+ }
304
+ export function hasJsonMcpEntry(file, name) {
305
+ const config = readJsonFile(file);
306
+ return !!config?.mcpServers && name in config.mcpServers;
307
+ }
308
+ export function hasJsonMcpEntryForClient(client, file, name) {
309
+ const config = readJsonFile(file);
310
+ const servers = config?.[jsonMcpConfigKeyForClient(client)];
311
+ return !!servers && typeof servers === "object" && name in servers;
312
+ }
313
+ // ---------------------------------------------------------------------------
314
+ // Codex TOML (hand-rolled minimal block merge, no new dep)
315
+ // ---------------------------------------------------------------------------
316
+ function tomlQuote(s) {
317
+ return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
318
+ }
319
+ function codexMcpHeader(name) {
320
+ return `[mcp_servers.${tomlQuote(name)}]`;
321
+ }
322
+ /**
323
+ * Parse a TOML table-header line such as `[mcp_servers."plan".http_headers]`
324
+ * or `[mcp_servers.plan] # local note` into its decoded dotted-key components
325
+ * (`["mcp_servers", "plan", "http_headers"]`). Returns `null` when the line is
326
+ * not a single `[table]` header — blank lines, key/value pairs, comments,
327
+ * invalid trailing content, and `[[array-of-tables]]` headers all return `null`,
328
+ * so the result doubles as a reliable "is this line a table boundary?" check.
329
+ *
330
+ * Handles bare keys (`A-Za-z0-9_-`) and quoted keys (basic `"..."` with `\`
331
+ * escapes, and literal `'...'`), tolerating whitespace around the dot
332
+ * separators per the TOML spec. Quoted-key escape handling mirrors the inverse
333
+ * of `tomlQuote` (`\"` → `"`, `\\` → `\`).
334
+ */
335
+ function parseTomlTableHeader(line) {
336
+ const trimmed = line.trim();
337
+ if (trimmed.length < 2 || trimmed[0] !== "[")
338
+ return null;
339
+ // `[[array-of-tables]]` is a different construct — leave it untouched.
340
+ if (trimmed[1] === "[")
341
+ return null;
342
+ let quote = null;
343
+ let escaped = false;
344
+ let closeIndex = -1;
345
+ for (let i = 1; i < trimmed.length; i++) {
346
+ const ch = trimmed[i];
347
+ if (quote === '"') {
348
+ if (escaped) {
349
+ escaped = false;
350
+ continue;
351
+ }
352
+ if (ch === "\\") {
353
+ escaped = true;
354
+ continue;
355
+ }
356
+ if (ch === '"')
357
+ quote = null;
358
+ continue;
359
+ }
360
+ if (quote === "'") {
361
+ if (ch === "'")
362
+ quote = null;
363
+ continue;
364
+ }
365
+ if (ch === '"' || ch === "'") {
366
+ quote = ch;
367
+ continue;
368
+ }
369
+ if (ch === "]") {
370
+ closeIndex = i;
371
+ break;
372
+ }
373
+ }
374
+ if (closeIndex < 0)
375
+ return null;
376
+ const trailing = trimmed.slice(closeIndex + 1).trim();
377
+ if (trailing && !trailing.startsWith("#"))
378
+ return null;
379
+ const inner = trimmed.slice(1, closeIndex);
380
+ const keys = [];
381
+ const isWs = (c) => c === " " || c === "\t";
382
+ const isBare = (c) => /[A-Za-z0-9_-]/.test(c);
383
+ let i = 0;
384
+ let expectKey = true;
385
+ while (i < inner.length) {
386
+ while (i < inner.length && isWs(inner[i]))
387
+ i++;
388
+ if (i >= inner.length)
389
+ break;
390
+ const ch = inner[i];
391
+ if (ch === ".") {
392
+ if (expectKey)
393
+ return null; // leading or doubled dot
394
+ expectKey = true;
395
+ i++;
396
+ continue;
397
+ }
398
+ if (!expectKey)
399
+ return null; // two keys with no dot between them
400
+ if (ch === '"' || ch === "'") {
401
+ const quote = ch;
402
+ i++;
403
+ let key = "";
404
+ while (i < inner.length && inner[i] !== quote) {
405
+ if (quote === '"' && inner[i] === "\\" && i + 1 < inner.length) {
406
+ const next = inner[i + 1];
407
+ key += next === '"' ? '"' : next === "\\" ? "\\" : `\\${next}`;
408
+ i += 2;
409
+ continue;
410
+ }
411
+ key += inner[i];
412
+ i++;
413
+ }
414
+ if (i >= inner.length)
415
+ return null; // unterminated quote
416
+ i++; // consume the closing quote
417
+ keys.push(key);
418
+ expectKey = false;
419
+ }
420
+ else if (isBare(ch)) {
421
+ let key = "";
422
+ while (i < inner.length && isBare(inner[i])) {
423
+ key += inner[i];
424
+ i++;
425
+ }
426
+ keys.push(key);
427
+ expectKey = false;
428
+ }
429
+ else {
430
+ return null; // unexpected character — not a table header
431
+ }
432
+ }
433
+ if (expectKey)
434
+ return null; // empty header or trailing dot
435
+ return keys.length ? keys : null;
436
+ }
437
+ /**
438
+ * The MCP server name a TOML table header belongs to, or `undefined` when the
439
+ * header is not under `[mcp_servers.*]`. The server's own table
440
+ * (`[mcp_servers.<name>]`) AND every sub-table of it
441
+ * (`[mcp_servers.<name>.http_headers]`, `[mcp_servers.<name>.env]`, …) resolve
442
+ * to the same `<name>`, so callers can treat a server and all its sub-tables as
443
+ * one removable unit. This is what prevents a re-install from orphaning a stale
444
+ * `[mcp_servers.<name>.http_headers]` sub-table that would then collide with the
445
+ * freshly written inline `http_headers` (a TOML duplicate-key error).
446
+ */
447
+ function codexServerNameOfHeader(line) {
448
+ const keys = parseTomlTableHeader(line);
449
+ if (!keys || keys.length < 2 || keys[0] !== "mcp_servers")
450
+ return undefined;
451
+ return keys[1];
452
+ }
453
+ /** Build a `[mcp_servers.<name>]` block for an HTTP-type MCP server. */
454
+ export function buildCodexHttpBlock(name, mcpUrl, token, headers) {
455
+ const lines = [codexMcpHeader(name)];
456
+ lines.push(`url = ${tomlQuote(mcpUrl)}`);
457
+ const mergedHeaders = {
458
+ ...(headers ?? {}),
459
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
460
+ };
461
+ const headerEntries = Object.entries(mergedHeaders);
462
+ if (headerEntries.length) {
463
+ lines.push(`http_headers = { ${headerEntries
464
+ .map(([key, value]) => `${tomlQuote(key)} = ${tomlQuote(value)}`)
465
+ .join(", ")} }`);
466
+ }
467
+ return lines.join("\n") + "\n";
468
+ }
469
+ export function buildCodexLocalBlock(name, args, env, command = "agent-native") {
470
+ const lines = [codexMcpHeader(name)];
471
+ lines.push(`command = ${tomlQuote(command)}`);
472
+ lines.push(`args = [${args.map(tomlQuote).join(", ")}]`);
473
+ const cleanEnv = env ? Object.fromEntries(Object.entries(env)) : {};
474
+ if (Object.keys(cleanEnv).length) {
475
+ const inline = Object.entries(cleanEnv)
476
+ .map(([key, value]) => `${key} = ${tomlQuote(value)}`)
477
+ .join(", ");
478
+ lines.push(`env = { ${inline} }`);
479
+ }
480
+ return lines.join("\n") + "\n";
481
+ }
482
+ /**
483
+ * Replace (or append) the entire `[mcp_servers.<name>]` footprint in a TOML
484
+ * file without disturbing other content. The footprint is the server's own
485
+ * table AND every sub-table of it (`[mcp_servers.<name>.http_headers]`,
486
+ * `[mcp_servers.<name>.env]`, …), each spanning its header line plus every
487
+ * following line until the next table header or EOF. Removing the whole
488
+ * footprint — wherever the pieces sit in the file — is what keeps a re-install
489
+ * from leaving a stale sub-table that collides with the freshly written inline
490
+ * block (a TOML duplicate-key error). Matches both the canonical quoted header
491
+ * and the legacy bare header. Pass `block === null` to remove the footprint.
492
+ * Identical algorithm to `mcp.ts`'s `writeCodexBlock` so the two never diverge.
493
+ */
494
+ export function writeCodexBlock(file, name, block) {
495
+ const content = readExistingConfigFile(file) ?? "";
496
+ const lines = content.split(/\r?\n/);
497
+ const out = [];
498
+ let i = 0;
499
+ let removed = false;
500
+ while (i < lines.length) {
501
+ const line = lines[i];
502
+ if (codexServerNameOfHeader(line) === name) {
503
+ // Skip this table and its body (until the next table header), covering
504
+ // the server table itself and any of its sub-tables.
505
+ removed = true;
506
+ i++;
507
+ while (i < lines.length && parseTomlTableHeader(lines[i]) === null)
508
+ i++;
509
+ continue;
510
+ }
511
+ out.push(line);
512
+ i++;
513
+ }
514
+ let next = out
515
+ .join("\n")
516
+ .replace(/\n{3,}/g, "\n\n")
517
+ .replace(/\n*$/, "\n");
518
+ if (block !== null) {
519
+ next = next.replace(/\n*$/, "\n");
520
+ if (next.trim().length)
521
+ next += "\n";
522
+ next += block;
523
+ }
524
+ if (block === null && !removed)
525
+ return; // nothing to do
526
+ writeFileAtomic(file, next);
527
+ }
528
+ export function codexHasBlock(file, name) {
529
+ const content = readExistingConfigFile(file);
530
+ return (content
531
+ ?.split(/\r?\n/)
532
+ .some((line) => codexServerNameOfHeader(line) === name) ?? false);
533
+ }
534
+ // ---------------------------------------------------------------------------
535
+ // Unified write helper
536
+ // ---------------------------------------------------------------------------
537
+ /**
538
+ * Idempotently write the HTTP MCP server entry for `serverName` into the
539
+ * given client's config file and return the file path that was written.
540
+ * Re-running replaces the same named entry — never duplicates.
541
+ */
542
+ export function writeHttpEntryForClient(client, serverName, mcpUrl, token, baseDir, scope, headers) {
543
+ const file = configPathFor(client, baseDir, scope);
544
+ if (client === "codex") {
545
+ writeCodexBlock(file, serverName, buildCodexHttpBlock(serverName, mcpUrl, token, headers));
546
+ }
547
+ else {
548
+ writeJsonMcpEntryForClient(client, file, serverName, buildHttpMcpEntryForClient(client, mcpUrl, token, headers));
549
+ }
550
+ return file;
551
+ }
552
+ // ---------------------------------------------------------------------------
553
+ // Same-URL duplicate removal
554
+ // ---------------------------------------------------------------------------
555
+ /**
556
+ * Canonicalise a URL for comparison: strip hash, search params, and trailing
557
+ * slashes. Returns `undefined` for invalid URLs.
558
+ */
559
+ export function canonicalUrl(value) {
560
+ if (!value)
561
+ return undefined;
562
+ try {
563
+ const u = new URL(value);
564
+ u.hash = "";
565
+ u.search = "";
566
+ const pathname = u.pathname.replace(/\/+$/, "");
567
+ if (pathname === MCP_LEGACY_ROUTE_PREFIX ||
568
+ pathname.endsWith(MCP_LEGACY_ROUTE_PREFIX)) {
569
+ u.pathname = `${pathname.slice(0, -MCP_LEGACY_ROUTE_PREFIX.length)}${MCP_PUBLIC_ROUTE_PREFIX}`;
570
+ }
571
+ return u.toString().replace(/\/+$/, "");
572
+ }
573
+ catch {
574
+ return undefined;
575
+ }
576
+ }
577
+ /**
578
+ * After writing the canonical `keepName` entry into a JSON config file,
579
+ * remove any OTHER entries whose URL normalises to the same value as
580
+ * `mcpUrl`. This cleans up stale alias names, legacy default names, and
581
+ * leftover custom names that all pointed at the same server.
582
+ *
583
+ * Returns the list of entry names that were removed.
584
+ */
585
+ function removeJsonSameUrlDuplicatesAtKey(file, serversKey, mcpUrl, keepName) {
586
+ let config;
587
+ try {
588
+ const raw = fs.readFileSync(file, "utf-8");
589
+ if (!raw.trim())
590
+ return [];
591
+ config = JSON.parse(raw);
592
+ }
593
+ catch {
594
+ return [];
595
+ }
596
+ const servers = config?.[serversKey];
597
+ if (!servers || typeof servers !== "object" || Array.isArray(servers)) {
598
+ return [];
599
+ }
600
+ const targetCanonical = canonicalUrl(mcpUrl);
601
+ if (!targetCanonical)
602
+ return [];
603
+ const toRemove = [];
604
+ for (const name of Object.keys(servers)) {
605
+ if (name === keepName)
606
+ continue;
607
+ const entry = servers[name];
608
+ if (!entry || typeof entry !== "object")
609
+ continue;
610
+ const entryUrl = typeof entry.url === "string" ? entry.url : undefined;
611
+ if (canonicalUrl(entryUrl) === targetCanonical) {
612
+ toRemove.push(name);
613
+ }
614
+ }
615
+ if (toRemove.length === 0)
616
+ return [];
617
+ for (const name of toRemove) {
618
+ delete servers[name];
619
+ }
620
+ writeFileAtomic(file, JSON.stringify(config, null, 2) + "\n");
621
+ return toRemove;
622
+ }
623
+ export function removeJsonSameUrlDuplicates(file, mcpUrl, keepName) {
624
+ return removeJsonSameUrlDuplicatesAtKey(file, "mcpServers", mcpUrl, keepName);
625
+ }
626
+ /**
627
+ * After writing the canonical `keepName` Codex block, remove any OTHER
628
+ * `[mcp_servers.*]` servers in the same TOML file whose `url =` line normalises
629
+ * to the same value as `mcpUrl`. A server's entire footprint is removed — its
630
+ * own table AND any sub-tables (`.http_headers`, `.env`, …), even when they are
631
+ * not contiguous — so cleanup never leaves an orphaned sub-table behind.
632
+ * Returns removed entry names in first-seen order.
633
+ */
634
+ export function removeCodexSameUrlDuplicates(file, mcpUrl, keepName) {
635
+ let content = "";
636
+ try {
637
+ content = fs.readFileSync(file, "utf-8");
638
+ }
639
+ catch {
640
+ return [];
641
+ }
642
+ const targetCanonical = canonicalUrl(mcpUrl);
643
+ if (!targetCanonical)
644
+ return [];
645
+ const lines = content.split(/\r?\n/);
646
+ // Pass 1: map each server name to the canonical URL declared on its own
647
+ // table, preserving first-seen order. Sub-tables don't carry the URL, so they
648
+ // only register the name.
649
+ const serverUrls = new Map();
650
+ let i = 0;
651
+ while (i < lines.length) {
652
+ const keys = parseTomlTableHeader(lines[i]);
653
+ const name = keys && keys.length >= 2 && keys[0] === "mcp_servers"
654
+ ? keys[1]
655
+ : undefined;
656
+ if (name === undefined) {
657
+ i++;
658
+ continue;
659
+ }
660
+ const isServerTable = keys.length === 2;
661
+ const body = [];
662
+ i++;
663
+ while (i < lines.length && parseTomlTableHeader(lines[i]) === null) {
664
+ body.push(lines[i]);
665
+ i++;
666
+ }
667
+ if (isServerTable) {
668
+ const urlMatch = body
669
+ .join("\n")
670
+ .match(/^\s*url\s*=\s*"((?:\\.|[^"])*)"/m);
671
+ const blockUrl = urlMatch
672
+ ? urlMatch[1].replace(/\\"/g, '"').replace(/\\\\/g, "\\")
673
+ : undefined;
674
+ serverUrls.set(name, canonicalUrl(blockUrl));
675
+ }
676
+ else if (!serverUrls.has(name)) {
677
+ serverUrls.set(name, undefined);
678
+ }
679
+ }
680
+ const removeSet = new Set();
681
+ for (const [name, canonical] of serverUrls) {
682
+ if (name !== keepName && canonical && canonical === targetCanonical) {
683
+ removeSet.add(name);
684
+ }
685
+ }
686
+ if (removeSet.size === 0)
687
+ return [];
688
+ // Pass 2: rebuild, dropping every table/sub-table belonging to a removed
689
+ // server.
690
+ const out = [];
691
+ i = 0;
692
+ while (i < lines.length) {
693
+ const name = codexServerNameOfHeader(lines[i]);
694
+ if (name !== undefined && removeSet.has(name)) {
695
+ i++;
696
+ while (i < lines.length && parseTomlTableHeader(lines[i]) === null)
697
+ i++;
698
+ continue;
699
+ }
700
+ out.push(lines[i]);
701
+ i++;
702
+ }
703
+ const next = out
704
+ .join("\n")
705
+ .replace(/\n{3,}/g, "\n\n")
706
+ .replace(/\n*$/, "\n");
707
+ writeFileAtomic(file, next);
708
+ return [...removeSet];
709
+ }
710
+ /**
711
+ * Unified helper: after writing the canonical `serverName` entry for the
712
+ * given `client`, remove same-URL duplicates from its config file.
713
+ * Returns the list of removed names (empty if nothing was cleaned up).
714
+ */
715
+ export function removeSameUrlDuplicatesForClient(client, serverName, mcpUrl, baseDir, scope) {
716
+ const file = configPathFor(client, baseDir, scope);
717
+ if (client === "codex") {
718
+ return removeCodexSameUrlDuplicates(file, mcpUrl, serverName);
719
+ }
720
+ return removeJsonSameUrlDuplicatesAtKey(file, jsonMcpConfigKeyForClient(client), mcpUrl, serverName);
721
+ }
722
+ //# sourceMappingURL=mcp-config-writers.js.map