@bridge_gpt/mcp-server 0.2.68 → 0.2.69

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,598 @@
1
+ /**
2
+ * Pure, conservative reader and surgical splicer for the Bridge launcher in
3
+ * Codex's global `config.toml` (BAPI-1445).
4
+ *
5
+ * Codex keeps its MCP registrations in one per-user TOML file that also holds
6
+ * unrelated settings, comments, and a Bridge `.env` table full of credentials.
7
+ * `upgrade` must move the package pin inside that file without ever
8
+ * re-serializing it. So this module does not parse TOML into a document model.
9
+ * It runs a small lexical scanner that finds exactly one supported
10
+ * `[mcp_servers.<name>]` base table, where `<name>` is any recognized key,
11
+ * reads its `args` array, and reports the character offsets of the one package
12
+ * specifier worth changing. {@link applyCodexRepin} then splices only those
13
+ * characters; every other byte of the file is carried through unchanged.
14
+ *
15
+ * The contract is "refuse when unsure". Any structure the scanner cannot read
16
+ * with confidence — inline tables, dotted keys, repeated tables, escapes inside
17
+ * the package token, multiline strings in the Bridge table, malformed arrays —
18
+ * comes back as `unlocatable`, never as a guess.
19
+ *
20
+ * Purity and secret safety:
21
+ * - No file I/O, network, process spawning, or logging. Imports are limited to
22
+ * identity-level constants and types from `mcp-identity.ts`; this module must
23
+ * never import `launcher-config-inspection.ts` or `mcp-host-config.ts`.
24
+ * - A result carries only the registration name, the parsed launcher
25
+ * arguments, and splice coordinates. It never carries the file text, the
26
+ * command, any environment value, a parse excerpt, or a raw error. The
27
+ * `.env` table is scanned only to keep lexical state and table boundaries.
28
+ * - Offsets are half-open JavaScript string offsets in UTF-16 code units, not
29
+ * UTF-8 byte positions. Callers must read and write the file as UTF-8 text so
30
+ * string offsets and file content stay in step.
31
+ */
32
+ import { MCP_PACKAGE_NAME, RECOGNIZED_SERVER_NAMES, WORKTREE_SHIM_TOKEN, } from "./mcp-identity.js";
33
+ /** The only error {@link applyCodexRepin} throws. Carries no source text. */
34
+ export const CODEX_SPLICE_ERROR_MESSAGE = "the Codex launcher splice no longer matches the inspected configuration";
35
+ // ---------------------------------------------------------------------------
36
+ // Lexical scanner
37
+ // ---------------------------------------------------------------------------
38
+ /** Internal: the scanner met a construct it will not interpret. Never escapes. */
39
+ class ScanFailure extends Error {
40
+ }
41
+ const BARE_KEY_CHAR = /[A-Za-z0-9_-]/;
42
+ /** Scan the whole document into headers, key/values, and multiline-string starts. */
43
+ function scanDocument(content) {
44
+ const items = [];
45
+ const multilineStarts = [];
46
+ const n = content.length;
47
+ const skipHorizontal = (from) => {
48
+ let i = from;
49
+ while (i < n && (content[i] === " " || content[i] === "\t"))
50
+ i++;
51
+ return i;
52
+ };
53
+ /** Skip whitespace, newlines, and comments. */
54
+ const skipBlank = (from) => {
55
+ let i = from;
56
+ while (i < n) {
57
+ const c = content[i];
58
+ if (c === " " || c === "\t" || c === "\r" || c === "\n") {
59
+ i++;
60
+ }
61
+ else if (c === "#") {
62
+ while (i < n && content[i] !== "\n")
63
+ i++;
64
+ }
65
+ else {
66
+ break;
67
+ }
68
+ }
69
+ return i;
70
+ };
71
+ const readString = (from) => {
72
+ const quote = content[from];
73
+ const triple = content.startsWith(quote.repeat(3), from);
74
+ const kind = quote === '"' ? "basic" : "literal";
75
+ if (triple) {
76
+ multilineStarts.push(from);
77
+ let j = from + 3;
78
+ while (j < n) {
79
+ if (kind === "basic" && content[j] === "\\") {
80
+ j += 2;
81
+ continue;
82
+ }
83
+ if (content.startsWith(quote.repeat(3), j)) {
84
+ let end = j + 3;
85
+ let extra = 0;
86
+ while (extra < 2 && content[end] === quote) {
87
+ end++;
88
+ extra++;
89
+ }
90
+ return {
91
+ start: from,
92
+ end,
93
+ innerStart: from + 3,
94
+ innerEnd: end - 3,
95
+ kind,
96
+ multiline: true,
97
+ };
98
+ }
99
+ j++;
100
+ }
101
+ throw new ScanFailure();
102
+ }
103
+ let j = from + 1;
104
+ while (j < n) {
105
+ const c = content[j];
106
+ if (c === "\n" || c === "\r")
107
+ throw new ScanFailure();
108
+ if (kind === "basic" && c === "\\") {
109
+ j += 2;
110
+ continue;
111
+ }
112
+ if (c === quote) {
113
+ return {
114
+ start: from,
115
+ end: j + 1,
116
+ innerStart: from + 1,
117
+ innerEnd: j,
118
+ kind,
119
+ multiline: false,
120
+ };
121
+ }
122
+ j++;
123
+ }
124
+ throw new ScanFailure();
125
+ };
126
+ const parseKeyPath = (from) => {
127
+ const segments = [];
128
+ let i = from;
129
+ for (;;) {
130
+ i = skipHorizontal(i);
131
+ const c = content[i];
132
+ if (c === '"' || c === "'") {
133
+ const token = readString(i);
134
+ if (token.multiline)
135
+ throw new ScanFailure();
136
+ const raw = content.slice(token.innerStart, token.innerEnd);
137
+ segments.push(token.kind === "basic" ? decodeBasicString(raw) : raw);
138
+ i = token.end;
139
+ }
140
+ else {
141
+ let j = i;
142
+ while (j < n && BARE_KEY_CHAR.test(content[j]))
143
+ j++;
144
+ if (j === i)
145
+ throw new ScanFailure();
146
+ segments.push(content.slice(i, j));
147
+ i = j;
148
+ }
149
+ i = skipHorizontal(i);
150
+ if (content[i] === ".") {
151
+ i++;
152
+ continue;
153
+ }
154
+ return { segments, next: i };
155
+ }
156
+ };
157
+ /** Skip one value of any TOML type, returning the offset just past it. */
158
+ const skipValue = (from) => {
159
+ const c = content[from];
160
+ if (c === '"' || c === "'")
161
+ return readString(from).end;
162
+ if (c === "[") {
163
+ let i = from + 1;
164
+ for (;;) {
165
+ i = skipBlank(i);
166
+ if (i >= n)
167
+ throw new ScanFailure();
168
+ if (content[i] === "]")
169
+ return i + 1;
170
+ i = skipBlank(skipValue(i));
171
+ if (content[i] === ",") {
172
+ i++;
173
+ continue;
174
+ }
175
+ if (content[i] === "]")
176
+ return i + 1;
177
+ throw new ScanFailure();
178
+ }
179
+ }
180
+ if (c === "{") {
181
+ let i = from + 1;
182
+ for (;;) {
183
+ i = skipBlank(i);
184
+ if (i >= n)
185
+ throw new ScanFailure();
186
+ if (content[i] === "}")
187
+ return i + 1;
188
+ const { next } = parseKeyPath(i);
189
+ i = skipHorizontal(next);
190
+ if (content[i] !== "=")
191
+ throw new ScanFailure();
192
+ i = skipBlank(i + 1);
193
+ i = skipBlank(skipValue(i));
194
+ if (content[i] === ",") {
195
+ i++;
196
+ continue;
197
+ }
198
+ if (content[i] === "}")
199
+ return i + 1;
200
+ throw new ScanFailure();
201
+ }
202
+ }
203
+ // A bare scalar: number, boolean, or date-time, ending at a delimiter.
204
+ let j = from;
205
+ while (j < n && !/[\s,\]}#]/.test(content[j]))
206
+ j++;
207
+ if (j === from)
208
+ throw new ScanFailure();
209
+ return j;
210
+ };
211
+ /** After a statement, only horizontal space and a comment may precede the newline. */
212
+ const expectLineEnd = (from) => {
213
+ let i = skipHorizontal(from);
214
+ if (content[i] === "#") {
215
+ while (i < n && content[i] !== "\n")
216
+ i++;
217
+ }
218
+ if (i < n && content[i] === "\r")
219
+ i++;
220
+ if (i < n && content[i] !== "\n")
221
+ throw new ScanFailure();
222
+ return i;
223
+ };
224
+ let i = 0;
225
+ while (i < n) {
226
+ i = skipBlank(i);
227
+ if (i >= n)
228
+ break;
229
+ const start = i;
230
+ if (content[i] === "[") {
231
+ const isArrayTable = content[i + 1] === "[";
232
+ const { segments, next } = parseKeyPath(i + (isArrayTable ? 2 : 1));
233
+ let j = skipHorizontal(next);
234
+ const closer = isArrayTable ? "]]" : "]";
235
+ if (!content.startsWith(closer, j))
236
+ throw new ScanFailure();
237
+ j += closer.length;
238
+ items.push({ type: "header", start, end: j, segments, isArrayTable });
239
+ i = expectLineEnd(j);
240
+ continue;
241
+ }
242
+ const { segments, next } = parseKeyPath(i);
243
+ let j = skipHorizontal(next);
244
+ if (content[j] !== "=")
245
+ throw new ScanFailure();
246
+ j = skipHorizontal(j + 1);
247
+ const valueStart = j;
248
+ const valueEnd = skipValue(valueStart);
249
+ items.push({ type: "kv", start, keySegments: segments, valueStart, valueEnd });
250
+ i = expectLineEnd(valueEnd);
251
+ }
252
+ return { items, multilineStarts };
253
+ }
254
+ /** Decode a TOML basic-string body. Throws {@link ScanFailure} on a bad escape. */
255
+ function decodeBasicString(raw) {
256
+ let out = "";
257
+ for (let i = 0; i < raw.length; i++) {
258
+ const c = raw[i];
259
+ if (c !== "\\") {
260
+ out += c;
261
+ continue;
262
+ }
263
+ const e = raw[++i];
264
+ switch (e) {
265
+ case "b":
266
+ out += "\b";
267
+ break;
268
+ case "t":
269
+ out += "\t";
270
+ break;
271
+ case "n":
272
+ out += "\n";
273
+ break;
274
+ case "f":
275
+ out += "\f";
276
+ break;
277
+ case "r":
278
+ out += "\r";
279
+ break;
280
+ case '"':
281
+ out += '"';
282
+ break;
283
+ case "\\":
284
+ out += "\\";
285
+ break;
286
+ case "u":
287
+ case "U": {
288
+ const width = e === "u" ? 4 : 8;
289
+ const hex = raw.slice(i + 1, i + 1 + width);
290
+ if (hex.length !== width || !/^[0-9A-Fa-f]+$/.test(hex))
291
+ throw new ScanFailure();
292
+ try {
293
+ out += String.fromCodePoint(parseInt(hex, 16));
294
+ }
295
+ catch {
296
+ throw new ScanFailure();
297
+ }
298
+ i += width;
299
+ break;
300
+ }
301
+ default:
302
+ throw new ScanFailure();
303
+ }
304
+ }
305
+ return out;
306
+ }
307
+ /** Read a single- or multi-line array of single-line strings, strictly. */
308
+ function readArgsArray(content, valueStart, valueEnd) {
309
+ const n = content.length;
310
+ if (content[valueStart] !== "[")
311
+ throw new ScanFailure();
312
+ const elements = [];
313
+ let i = valueStart + 1;
314
+ const skipBlank = (from) => {
315
+ let j = from;
316
+ while (j < n) {
317
+ const c = content[j];
318
+ if (c === " " || c === "\t" || c === "\r" || c === "\n") {
319
+ j++;
320
+ }
321
+ else if (c === "#") {
322
+ while (j < n && content[j] !== "\n")
323
+ j++;
324
+ }
325
+ else {
326
+ break;
327
+ }
328
+ }
329
+ return j;
330
+ };
331
+ for (;;) {
332
+ i = skipBlank(i);
333
+ if (i >= valueEnd)
334
+ throw new ScanFailure();
335
+ if (content[i] === "]") {
336
+ if (i + 1 !== valueEnd)
337
+ throw new ScanFailure();
338
+ return elements;
339
+ }
340
+ const quote = content[i];
341
+ if (quote !== '"' && quote !== "'")
342
+ throw new ScanFailure();
343
+ if (content.startsWith(quote.repeat(3), i))
344
+ throw new ScanFailure();
345
+ let j = i + 1;
346
+ let closed = false;
347
+ while (j < valueEnd) {
348
+ const c = content[j];
349
+ if (c === "\n" || c === "\r")
350
+ throw new ScanFailure();
351
+ if (quote === '"' && c === "\\") {
352
+ j += 2;
353
+ continue;
354
+ }
355
+ if (c === quote) {
356
+ closed = true;
357
+ break;
358
+ }
359
+ j++;
360
+ }
361
+ if (!closed)
362
+ throw new ScanFailure();
363
+ const raw = content.slice(i + 1, j);
364
+ elements.push({
365
+ value: quote === '"' ? decodeBasicString(raw) : raw,
366
+ innerStart: i + 1,
367
+ innerEnd: j,
368
+ hasBackslash: quote === '"' && raw.includes("\\"),
369
+ });
370
+ i = skipBlank(j + 1);
371
+ if (content[i] === ",") {
372
+ i++;
373
+ continue;
374
+ }
375
+ if (content[i] === "]") {
376
+ if (i + 1 !== valueEnd)
377
+ throw new ScanFailure();
378
+ return elements;
379
+ }
380
+ throw new ScanFailure();
381
+ }
382
+ }
383
+ // ---------------------------------------------------------------------------
384
+ // Inspection
385
+ // ---------------------------------------------------------------------------
386
+ const SERVERS_ROOT = "mcp_servers";
387
+ const NONE = { kind: "none" };
388
+ const UNLOCATABLE = { kind: "unlocatable" };
389
+ function recognizedName(name) {
390
+ return RECOGNIZED_SERVER_NAMES.includes(name ?? "")
391
+ ? name
392
+ : null;
393
+ }
394
+ /** True when a package token names the Bridge package, bare or with an `@` suffix. */
395
+ function isBridgePackageToken(arg) {
396
+ return arg === MCP_PACKAGE_NAME || arg.startsWith(`${MCP_PACKAGE_NAME}@`);
397
+ }
398
+ /**
399
+ * Locate the one supported Bridge launcher in a Codex `config.toml`.
400
+ *
401
+ * Returns `none` only when the file holds no Bridge declaration of any kind.
402
+ * A file the scanner cannot read at all is `none` when it never mentions Bridge
403
+ * and `unlocatable` when it does — a broken file that might hide a registration
404
+ * is never reported as "nothing to do".
405
+ */
406
+ export function inspectCodexLauncher(content) {
407
+ let doc;
408
+ try {
409
+ doc = scanDocument(content);
410
+ }
411
+ catch (err) {
412
+ if (!(err instanceof ScanFailure))
413
+ throw err;
414
+ return /bridge/i.test(content) ? UNLOCATABLE : NONE;
415
+ }
416
+ try {
417
+ return classifyDocument(content, doc);
418
+ }
419
+ catch (err) {
420
+ if (!(err instanceof ScanFailure))
421
+ throw err;
422
+ return UNLOCATABLE;
423
+ }
424
+ }
425
+ function classifyDocument(content, doc) {
426
+ const { items, multilineStarts } = doc;
427
+ const baseTables = [];
428
+ const subtableNames = new Set();
429
+ let sawUnsupportedDeclaration = false;
430
+ let current = [];
431
+ for (let index = 0; index < items.length; index++) {
432
+ const item = items[index];
433
+ if (item.type === "header") {
434
+ current = item.segments;
435
+ if (item.segments[0] !== SERVERS_ROOT || item.segments.length < 2)
436
+ continue;
437
+ const name = recognizedName(item.segments[1]);
438
+ if (!name)
439
+ continue;
440
+ if (item.isArrayTable) {
441
+ sawUnsupportedDeclaration = true;
442
+ }
443
+ else if (item.segments.length === 2) {
444
+ baseTables.push({ name, headerIndex: index });
445
+ }
446
+ else {
447
+ subtableNames.add(name);
448
+ }
449
+ continue;
450
+ }
451
+ // A key/value. Its full path is the enclosing table path plus its own key.
452
+ const full = [...current, ...item.keySegments];
453
+ if (full[0] !== SERVERS_ROOT)
454
+ continue;
455
+ if (full.length === 1) {
456
+ // `mcp_servers = { ... }`: an inline servers table. Refuse it if it could
457
+ // be declaring Bridge.
458
+ if (/bridge/i.test(content.slice(item.valueStart, item.valueEnd))) {
459
+ sawUnsupportedDeclaration = true;
460
+ }
461
+ continue;
462
+ }
463
+ const name = recognizedName(full[1]);
464
+ if (!name)
465
+ continue;
466
+ const insideOwnTable = current[0] === SERVERS_ROOT && current[1] === full[1];
467
+ if (!insideOwnTable)
468
+ sawUnsupportedDeclaration = true;
469
+ }
470
+ const names = new Set(baseTables.map((t) => t.name));
471
+ for (const name of subtableNames) {
472
+ if (!names.has(name))
473
+ return UNLOCATABLE; // an orphan `.env` or other subtable
474
+ }
475
+ if (sawUnsupportedDeclaration)
476
+ return UNLOCATABLE;
477
+ if (names.size > 1)
478
+ return { kind: "duplicate" };
479
+ if (baseTables.length === 0)
480
+ return NONE;
481
+ if (baseTables.length > 1)
482
+ return UNLOCATABLE; // the same base table repeated
483
+ const [base] = baseTables;
484
+ const bodyStart = items[base.headerIndex].end;
485
+ const nextHeader = items.slice(base.headerIndex + 1).find((i) => i.type === "header");
486
+ const bodyEnd = nextHeader ? nextHeader.start : content.length;
487
+ if (multilineStarts.some((s) => s >= bodyStart && s < bodyEnd))
488
+ return UNLOCATABLE;
489
+ const argsItems = [];
490
+ for (let k = base.headerIndex + 1; k < items.length; k++) {
491
+ const item = items[k];
492
+ if (item.type === "header")
493
+ break;
494
+ if (item.keySegments[0] !== "args")
495
+ continue;
496
+ if (item.keySegments.length > 1)
497
+ return UNLOCATABLE;
498
+ argsItems.push(item);
499
+ }
500
+ if (argsItems.length !== 1)
501
+ return UNLOCATABLE;
502
+ const elements = readArgsArray(content, argsItems[0].valueStart, argsItems[0].valueEnd);
503
+ const args = elements.map((e) => e.value);
504
+ const tokenElements = elements.filter((e) => isBridgePackageToken(e.value));
505
+ if (tokenElements.some((e) => e.hasBackslash))
506
+ return UNLOCATABLE;
507
+ if (tokenElements.length > 1)
508
+ return UNLOCATABLE;
509
+ if (args.includes(WORKTREE_SHIM_TOKEN)) {
510
+ return { kind: "worktree-shim", registrationName: base.name, args };
511
+ }
512
+ if (tokenElements.length === 0)
513
+ return UNLOCATABLE;
514
+ const [token] = tokenElements;
515
+ return {
516
+ kind: "located",
517
+ registrationName: base.name,
518
+ args,
519
+ splice: spliceFor(token),
520
+ };
521
+ }
522
+ function spliceFor(token) {
523
+ const tokenText = token.value;
524
+ const common = {
525
+ tokenStart: token.innerStart,
526
+ tokenEnd: token.innerEnd,
527
+ tokenText,
528
+ };
529
+ if (tokenText === MCP_PACKAGE_NAME) {
530
+ return {
531
+ ...common,
532
+ replaceStart: token.innerEnd,
533
+ replaceEnd: token.innerEnd,
534
+ replacedText: "",
535
+ needsAtSign: true,
536
+ };
537
+ }
538
+ const specStart = token.innerStart + MCP_PACKAGE_NAME.length + 1;
539
+ const rest = tokenText.slice(MCP_PACKAGE_NAME.length + 1);
540
+ const trimmed = rest.trim();
541
+ if (trimmed.length === 0) {
542
+ return {
543
+ ...common,
544
+ replaceStart: specStart,
545
+ replaceEnd: specStart,
546
+ replacedText: "",
547
+ needsAtSign: false,
548
+ };
549
+ }
550
+ const lead = rest.length - rest.trimStart().length;
551
+ return {
552
+ ...common,
553
+ replaceStart: specStart + lead,
554
+ replaceEnd: specStart + lead + trimmed.length,
555
+ replacedText: trimmed,
556
+ needsAtSign: false,
557
+ };
558
+ }
559
+ // ---------------------------------------------------------------------------
560
+ // Surgical splice
561
+ // ---------------------------------------------------------------------------
562
+ /** A target version safe to write inside a TOML string without escaping. */
563
+ const SAFE_VERSION = /^[0-9A-Za-z][0-9A-Za-z.+-]*$/;
564
+ /**
565
+ * Splice `version` into `content` at the coordinates `inspection` located.
566
+ *
567
+ * Everything outside the replaced range — comments, CRLF, table names, the whole
568
+ * `.env` table, non-ASCII text — is copied through unchanged. Throws the one
569
+ * fixed {@link CODEX_SPLICE_ERROR_MESSAGE} when the inspection is not a located
570
+ * launcher, the coordinates fall outside `content`, or the text at them no longer
571
+ * matches what was inspected. The error never contains source text.
572
+ */
573
+ export function applyCodexRepin(content, inspection, version) {
574
+ if (inspection.kind !== "located" || !SAFE_VERSION.test(version)) {
575
+ throw new Error(CODEX_SPLICE_ERROR_MESSAGE);
576
+ }
577
+ const s = inspection.splice;
578
+ const offsets = [s.tokenStart, s.tokenEnd, s.replaceStart, s.replaceEnd];
579
+ const inRange = offsets.every((o) => Number.isInteger(o) && o >= 0 && o <= content.length) &&
580
+ s.tokenStart <= s.replaceStart &&
581
+ s.replaceStart <= s.replaceEnd &&
582
+ s.replaceEnd <= s.tokenEnd;
583
+ if (!inRange)
584
+ throw new Error(CODEX_SPLICE_ERROR_MESSAGE);
585
+ if (content.slice(s.tokenStart, s.tokenEnd) !== s.tokenText ||
586
+ content.slice(s.replaceStart, s.replaceEnd) !== s.replacedText) {
587
+ throw new Error(CODEX_SPLICE_ERROR_MESSAGE);
588
+ }
589
+ const shapeOk = s.needsAtSign
590
+ ? s.tokenText === MCP_PACKAGE_NAME && s.replaceStart === s.tokenEnd && s.replaceEnd === s.tokenEnd
591
+ : s.tokenText.startsWith(`${MCP_PACKAGE_NAME}@`);
592
+ if (!shapeOk)
593
+ throw new Error(CODEX_SPLICE_ERROR_MESSAGE);
594
+ return (content.slice(0, s.replaceStart) +
595
+ (s.needsAtSign ? "@" : "") +
596
+ version +
597
+ content.slice(s.replaceEnd));
598
+ }