@alfe.ai/mcp-bundler 0.0.1 → 0.1.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/dist/index.cjs +592 -0
- package/dist/index.d.cts +234 -1
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +234 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +587 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let node_fs = require("node:fs");
|
|
3
|
+
let node_path = require("node:path");
|
|
4
|
+
let node_os = require("node:os");
|
|
5
|
+
let node_child_process = require("node:child_process");
|
|
6
|
+
let node_util = require("node:util");
|
|
2
7
|
//#region src/tool-naming.ts
|
|
3
8
|
/**
|
|
4
9
|
* Tool name sanitization and collision handling.
|
|
@@ -450,11 +455,598 @@ var McpBundler = class {
|
|
|
450
455
|
}
|
|
451
456
|
};
|
|
452
457
|
//#endregion
|
|
458
|
+
//#region src/store.ts
|
|
459
|
+
const DEFAULT_STORE_PATH = (0, node_path.join)((0, node_path.join)((0, node_os.homedir)(), ".alfe", "mcp"), "servers.json");
|
|
460
|
+
/** Inter-process lock tunings — exported as constants so tests can override. */
|
|
461
|
+
const LOCK_WAIT_MS = 5e3;
|
|
462
|
+
const LOCK_RETRY_INTERVAL_MS = 25;
|
|
463
|
+
const LOCK_STALE_MS = 1e4;
|
|
464
|
+
/**
|
|
465
|
+
* On-disk source of truth for the bundler's configured servers.
|
|
466
|
+
*
|
|
467
|
+
* Mutations go through `update()` (read-modify-write with atomic
|
|
468
|
+
* temp+rename) so a concurrent writer (e.g. two `alfe mcp add` invocations
|
|
469
|
+
* racing) can't lose data — the second writer reads the first's state.
|
|
470
|
+
*
|
|
471
|
+
* Schema is owner-tagged so `removeServersByOwner` can implement
|
|
472
|
+
* integration uninstall without touching CLI-owned or manual entries.
|
|
473
|
+
*/
|
|
474
|
+
var Store = class {
|
|
475
|
+
storePath;
|
|
476
|
+
logger;
|
|
477
|
+
watcher;
|
|
478
|
+
watcherListeners = /* @__PURE__ */ new Set();
|
|
479
|
+
rewatchTimer;
|
|
480
|
+
constructor(opts = {}) {
|
|
481
|
+
this.storePath = opts.path ?? DEFAULT_STORE_PATH;
|
|
482
|
+
this.logger = opts.logger;
|
|
483
|
+
}
|
|
484
|
+
get path() {
|
|
485
|
+
return this.storePath;
|
|
486
|
+
}
|
|
487
|
+
read() {
|
|
488
|
+
if (!(0, node_fs.existsSync)(this.storePath)) return cloneEmpty();
|
|
489
|
+
try {
|
|
490
|
+
const raw = (0, node_fs.readFileSync)(this.storePath, "utf8");
|
|
491
|
+
return normalize(JSON.parse(raw));
|
|
492
|
+
} catch (err) {
|
|
493
|
+
this.logger?.warn("[mcp-bundler/store] failed to read store; returning empty", {
|
|
494
|
+
err: errMsg$1(err),
|
|
495
|
+
path: this.storePath
|
|
496
|
+
});
|
|
497
|
+
return cloneEmpty();
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Read-modify-write with atomic temp+rename, guarded by an
|
|
502
|
+
* inter-process lock file. Caller passes a pure function that
|
|
503
|
+
* produces the next state; this serialises the mutation to disk in
|
|
504
|
+
* one rename, which is atomic on POSIX and on Windows when the
|
|
505
|
+
* target path is on the same volume.
|
|
506
|
+
*
|
|
507
|
+
* The lock guards the read-then-rename window so two processes
|
|
508
|
+
* (e.g. two `alfe mcp add` shells, or the CLI racing the daemon)
|
|
509
|
+
* can't drop each other's writes. The lock file is at
|
|
510
|
+
* `<storePath>.lock`; stale locks (older than `LOCK_STALE_MS`) are
|
|
511
|
+
* stolen so a crashed writer doesn't wedge the store.
|
|
512
|
+
*
|
|
513
|
+
* Pure-function shape (instead of a `read()` then `write(next)`
|
|
514
|
+
* pair) intentionally — it keeps the read-modify-write contract
|
|
515
|
+
* local to each caller so two updates back-to-back never see each
|
|
516
|
+
* other's partial state.
|
|
517
|
+
*/
|
|
518
|
+
update(fn) {
|
|
519
|
+
(0, node_fs.mkdirSync)((0, node_path.dirname)(this.storePath), { recursive: true });
|
|
520
|
+
const release = this.acquireLock();
|
|
521
|
+
try {
|
|
522
|
+
const next = fn(this.read());
|
|
523
|
+
const tempPath = `${this.storePath}.${String(process.pid)}.${String(Date.now())}.tmp`;
|
|
524
|
+
(0, node_fs.writeFileSync)(tempPath, JSON.stringify(next, null, 2), {
|
|
525
|
+
encoding: "utf8",
|
|
526
|
+
mode: 384
|
|
527
|
+
});
|
|
528
|
+
try {
|
|
529
|
+
(0, node_fs.renameSync)(tempPath, this.storePath);
|
|
530
|
+
} catch (err) {
|
|
531
|
+
try {
|
|
532
|
+
(0, node_fs.unlinkSync)(tempPath);
|
|
533
|
+
} catch {}
|
|
534
|
+
throw err;
|
|
535
|
+
}
|
|
536
|
+
return next;
|
|
537
|
+
} finally {
|
|
538
|
+
release();
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Acquire an inter-process file lock by atomically creating a
|
|
543
|
+
* sentinel via `openSync(lockPath, 'wx')`. Spins with bounded
|
|
544
|
+
* backoff up to `LOCK_WAIT_MS`. If the lock file is older than
|
|
545
|
+
* `LOCK_STALE_MS` it's assumed orphaned (writer crashed mid-update)
|
|
546
|
+
* and stolen — the write window is sub-second in practice, so
|
|
547
|
+
* holding the lock for >5s means something went wrong.
|
|
548
|
+
*
|
|
549
|
+
* Returns the release function. Single-process callers are
|
|
550
|
+
* unaffected — re-entering the same process spins briefly while
|
|
551
|
+
* the prior call's `finally` runs.
|
|
552
|
+
*/
|
|
553
|
+
acquireLock() {
|
|
554
|
+
const lockPath = `${this.storePath}.lock`;
|
|
555
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
556
|
+
let fd = -1;
|
|
557
|
+
for (;;) try {
|
|
558
|
+
fd = (0, node_fs.openSync)(lockPath, "wx", 384);
|
|
559
|
+
break;
|
|
560
|
+
} catch (err) {
|
|
561
|
+
if (err.code !== "EEXIST") throw err;
|
|
562
|
+
if (this.lockIsStale(lockPath)) {
|
|
563
|
+
try {
|
|
564
|
+
(0, node_fs.unlinkSync)(lockPath);
|
|
565
|
+
} catch {}
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
if (Date.now() >= deadline) throw new Error(`Store.update: timed out waiting for ${lockPath} (held by another writer or stale lock)`);
|
|
569
|
+
const sleepUntil = Date.now() + LOCK_RETRY_INTERVAL_MS;
|
|
570
|
+
while (Date.now() < sleepUntil);
|
|
571
|
+
}
|
|
572
|
+
const held = fd;
|
|
573
|
+
return () => {
|
|
574
|
+
try {
|
|
575
|
+
(0, node_fs.closeSync)(held);
|
|
576
|
+
} catch {}
|
|
577
|
+
try {
|
|
578
|
+
(0, node_fs.unlinkSync)(lockPath);
|
|
579
|
+
} catch {}
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
lockIsStale(lockPath) {
|
|
583
|
+
try {
|
|
584
|
+
const st = (0, node_fs.statSync)(lockPath);
|
|
585
|
+
return Date.now() - st.mtimeMs > LOCK_STALE_MS;
|
|
586
|
+
} catch {
|
|
587
|
+
return false;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Watch the store file for external changes (e.g. another `alfe mcp add`
|
|
592
|
+
* shelling out from a separate process). Returns an unsubscribe fn.
|
|
593
|
+
*
|
|
594
|
+
* Coalesces bursts via a 50 ms debounce — editors and atomic-rename
|
|
595
|
+
* writers commonly fire multiple events per logical save.
|
|
596
|
+
*/
|
|
597
|
+
watch(cb) {
|
|
598
|
+
this.watcherListeners.add(cb);
|
|
599
|
+
this.ensureWatcher();
|
|
600
|
+
return () => {
|
|
601
|
+
this.watcherListeners.delete(cb);
|
|
602
|
+
if (this.watcherListeners.size === 0) this.disposeWatcher();
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
dispose() {
|
|
606
|
+
this.watcherListeners.clear();
|
|
607
|
+
this.disposeWatcher();
|
|
608
|
+
}
|
|
609
|
+
ensureWatcher() {
|
|
610
|
+
if (this.watcher) return;
|
|
611
|
+
(0, node_fs.mkdirSync)((0, node_path.dirname)(this.storePath), { recursive: true });
|
|
612
|
+
const dir = (0, node_path.dirname)(this.storePath);
|
|
613
|
+
const basename = this.storePath.slice(dir.length + 1);
|
|
614
|
+
let pending;
|
|
615
|
+
const fire = () => {
|
|
616
|
+
pending = void 0;
|
|
617
|
+
for (const cb of this.watcherListeners) try {
|
|
618
|
+
cb();
|
|
619
|
+
} catch (err) {
|
|
620
|
+
this.logger?.warn("[mcp-bundler/store] watcher listener threw", { err: errMsg$1(err) });
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
try {
|
|
624
|
+
this.watcher = (0, node_fs.watch)(dir, (_event, fn) => {
|
|
625
|
+
if (fn !== basename) return;
|
|
626
|
+
if (pending) clearTimeout(pending);
|
|
627
|
+
pending = setTimeout(fire, 50);
|
|
628
|
+
});
|
|
629
|
+
this.watcher.on("error", (err) => {
|
|
630
|
+
this.logger?.warn("[mcp-bundler/store] watcher error; retrying in 1s", { err: errMsg$1(err) });
|
|
631
|
+
this.disposeWatcher();
|
|
632
|
+
if (!this.rewatchTimer && this.watcherListeners.size > 0) {
|
|
633
|
+
this.rewatchTimer = setTimeout(() => {
|
|
634
|
+
this.rewatchTimer = void 0;
|
|
635
|
+
this.ensureWatcher();
|
|
636
|
+
}, 1e3);
|
|
637
|
+
this.rewatchTimer.unref();
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
} catch (err) {
|
|
641
|
+
this.logger?.warn("[mcp-bundler/store] failed to start watcher", { err: errMsg$1(err) });
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
disposeWatcher() {
|
|
645
|
+
if (this.watcher) {
|
|
646
|
+
try {
|
|
647
|
+
this.watcher.close();
|
|
648
|
+
} catch {}
|
|
649
|
+
this.watcher = void 0;
|
|
650
|
+
}
|
|
651
|
+
if (this.rewatchTimer) {
|
|
652
|
+
clearTimeout(this.rewatchTimer);
|
|
653
|
+
this.rewatchTimer = void 0;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
};
|
|
657
|
+
function defaultStorePath() {
|
|
658
|
+
return DEFAULT_STORE_PATH;
|
|
659
|
+
}
|
|
660
|
+
/** Pull the runtime config (transport + transport-specific fields) out of a stored entry. */
|
|
661
|
+
function toServerConfig(entry) {
|
|
662
|
+
if (entry.transport === "stdio") {
|
|
663
|
+
const { command, args, env, cwd } = entry;
|
|
664
|
+
const cfg = { command };
|
|
665
|
+
if (args) cfg.args = args;
|
|
666
|
+
if (env) cfg.env = env;
|
|
667
|
+
if (cwd) cfg.cwd = cwd;
|
|
668
|
+
return cfg;
|
|
669
|
+
}
|
|
670
|
+
const { url, transport, headers, connectionTimeoutMs } = entry;
|
|
671
|
+
const cfg = {
|
|
672
|
+
url,
|
|
673
|
+
transport
|
|
674
|
+
};
|
|
675
|
+
if (headers) cfg.headers = headers;
|
|
676
|
+
if (connectionTimeoutMs !== void 0) cfg.connectionTimeoutMs = connectionTimeoutMs;
|
|
677
|
+
return cfg;
|
|
678
|
+
}
|
|
679
|
+
/** Build a stored entry from a runtime config + ownership metadata. */
|
|
680
|
+
function toStoredEntry(config, meta) {
|
|
681
|
+
const addedAt = meta.addedAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
682
|
+
if ("command" in config) return {
|
|
683
|
+
transport: "stdio",
|
|
684
|
+
owner: meta.owner,
|
|
685
|
+
addedAt,
|
|
686
|
+
...meta.version !== void 0 ? { version: meta.version } : {},
|
|
687
|
+
...config
|
|
688
|
+
};
|
|
689
|
+
const transport = meta.transport ?? config.transport ?? "sse";
|
|
690
|
+
if (transport === "stdio") throw new Error("toStoredEntry: transport=stdio specified but config is remote-shaped");
|
|
691
|
+
return {
|
|
692
|
+
transport,
|
|
693
|
+
owner: meta.owner,
|
|
694
|
+
addedAt,
|
|
695
|
+
...meta.version !== void 0 ? { version: meta.version } : {},
|
|
696
|
+
...config
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
function cloneEmpty() {
|
|
700
|
+
return {
|
|
701
|
+
servers: {},
|
|
702
|
+
config: {},
|
|
703
|
+
_ownedOpenclawKeys: []
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
function normalize(raw) {
|
|
707
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return cloneEmpty();
|
|
708
|
+
const r = raw;
|
|
709
|
+
return {
|
|
710
|
+
servers: r.servers && typeof r.servers === "object" ? r.servers : {},
|
|
711
|
+
config: r.config && typeof r.config === "object" ? r.config : {},
|
|
712
|
+
_ownedOpenclawKeys: Array.isArray(r._ownedOpenclawKeys) ? r._ownedOpenclawKeys.slice() : []
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
function errMsg$1(err) {
|
|
716
|
+
return err instanceof Error ? err.message : String(err);
|
|
717
|
+
}
|
|
718
|
+
//#endregion
|
|
719
|
+
//#region src/manager.ts
|
|
720
|
+
const execFileAsync = (0, node_util.promisify)(node_child_process.execFile);
|
|
721
|
+
const defaultOpenclawExecutor = {
|
|
722
|
+
async setBatch(batch) {
|
|
723
|
+
if (batch.length === 0) return;
|
|
724
|
+
await execFileAsync("openclaw", [
|
|
725
|
+
"config",
|
|
726
|
+
"set",
|
|
727
|
+
"--batch-json",
|
|
728
|
+
JSON.stringify(batch)
|
|
729
|
+
], { timeout: 1e4 });
|
|
730
|
+
},
|
|
731
|
+
async unset(path) {
|
|
732
|
+
await execFileAsync("openclaw", [
|
|
733
|
+
"config",
|
|
734
|
+
"unset",
|
|
735
|
+
path
|
|
736
|
+
], { timeout: 1e4 });
|
|
737
|
+
}
|
|
738
|
+
};
|
|
739
|
+
const DEFAULT_MIRROR_DEBOUNCE_MS = 250;
|
|
740
|
+
/**
|
|
741
|
+
* Bundler manager — owns the alfe store, mirrors it into openclaw.json,
|
|
742
|
+
* and surfaces a small CRUD API the CLI and integration applier both
|
|
743
|
+
* call into.
|
|
744
|
+
*
|
|
745
|
+
* The store is the Alfe-owned source of truth; openclaw.json is a
|
|
746
|
+
* derived mirror so the runtime keeps consuming its existing format.
|
|
747
|
+
*/
|
|
748
|
+
var Manager = class {
|
|
749
|
+
store;
|
|
750
|
+
executor;
|
|
751
|
+
logger;
|
|
752
|
+
mirrorDebounceMs;
|
|
753
|
+
mirrorTimer;
|
|
754
|
+
mirrorPromise = Promise.resolve();
|
|
755
|
+
mirrorPending;
|
|
756
|
+
bundler;
|
|
757
|
+
changeListeners = /* @__PURE__ */ new Set();
|
|
758
|
+
storeUnsubscribe;
|
|
759
|
+
constructor(opts = {}) {
|
|
760
|
+
this.store = opts.store ?? new Store({ logger: opts.logger });
|
|
761
|
+
this.executor = opts.executor ?? defaultOpenclawExecutor;
|
|
762
|
+
this.logger = opts.logger;
|
|
763
|
+
this.mirrorDebounceMs = opts.mirrorDebounceMs ?? DEFAULT_MIRROR_DEBOUNCE_MS;
|
|
764
|
+
}
|
|
765
|
+
/** Direct accessor — handy for tests and the CLI's `alfe mcp list`. */
|
|
766
|
+
getStore() {
|
|
767
|
+
return this.store;
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Register or overwrite a server entry. Resolves as soon as the store
|
|
771
|
+
* mutation is committed to disk — the openclaw.json mirror runs async
|
|
772
|
+
* in the background and is debounced so back-to-back calls coalesce
|
|
773
|
+
* into one runtime restart. Call `flush()` to await the mirror.
|
|
774
|
+
*/
|
|
775
|
+
async addServer(config, opts) {
|
|
776
|
+
if (!opts.id) throw new Error("Manager.addServer: id is required");
|
|
777
|
+
const owner = opts.owner ?? "manual";
|
|
778
|
+
this.store.update((cur) => {
|
|
779
|
+
const previousAddedAt = lookupAddedAt(cur.servers, opts.id);
|
|
780
|
+
const entry = toStoredEntry(config, {
|
|
781
|
+
owner,
|
|
782
|
+
transport: opts.transport,
|
|
783
|
+
version: opts.version,
|
|
784
|
+
addedAt: previousAddedAt
|
|
785
|
+
});
|
|
786
|
+
return {
|
|
787
|
+
...cur,
|
|
788
|
+
servers: {
|
|
789
|
+
...cur.servers,
|
|
790
|
+
[opts.id]: entry
|
|
791
|
+
}
|
|
792
|
+
};
|
|
793
|
+
});
|
|
794
|
+
this.scheduleMirror();
|
|
795
|
+
this.fireChange();
|
|
796
|
+
return Promise.resolve();
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Remove a single server entry. No-op if the id isn't in the store.
|
|
800
|
+
* Refuses to remove an entry whose owner doesn't match `expectedOwner`
|
|
801
|
+
* when supplied — the CLI uses this to guard `alfe mcp remove` from
|
|
802
|
+
* accidentally clobbering integration- or cli-owned entries.
|
|
803
|
+
*
|
|
804
|
+
* Resolves as soon as the store mutation is committed. Mirror runs
|
|
805
|
+
* async; call `flush()` to await it.
|
|
806
|
+
*/
|
|
807
|
+
removeServer(id, opts = {}) {
|
|
808
|
+
const existing = lookupEntry(this.store.read().servers, id);
|
|
809
|
+
if (!existing) return Promise.resolve(false);
|
|
810
|
+
if (opts.expectedOwner && existing.owner !== opts.expectedOwner) return Promise.reject(/* @__PURE__ */ new Error(`Manager.removeServer: server "${id}" is owned by "${existing.owner}", not "${opts.expectedOwner}"`));
|
|
811
|
+
this.store.update((cur) => ({
|
|
812
|
+
...cur,
|
|
813
|
+
servers: Object.fromEntries(Object.entries(cur.servers).filter(([k]) => k !== id))
|
|
814
|
+
}));
|
|
815
|
+
this.scheduleMirror();
|
|
816
|
+
this.fireChange();
|
|
817
|
+
return Promise.resolve(true);
|
|
818
|
+
}
|
|
819
|
+
/** Drop every entry whose owner matches — used by integration uninstall. */
|
|
820
|
+
async removeServersByOwner(owner) {
|
|
821
|
+
const removed = [];
|
|
822
|
+
this.store.update((cur) => {
|
|
823
|
+
const next = {};
|
|
824
|
+
for (const [id, entry] of Object.entries(cur.servers)) if (entry.owner === owner) removed.push(id);
|
|
825
|
+
else next[id] = entry;
|
|
826
|
+
if (removed.length === 0) return cur;
|
|
827
|
+
return {
|
|
828
|
+
...cur,
|
|
829
|
+
servers: next
|
|
830
|
+
};
|
|
831
|
+
});
|
|
832
|
+
if (removed.length > 0) {
|
|
833
|
+
this.scheduleMirror();
|
|
834
|
+
this.fireChange();
|
|
835
|
+
}
|
|
836
|
+
return Promise.resolve(removed);
|
|
837
|
+
}
|
|
838
|
+
/** Read-only snapshot for `alfe mcp list` and similar UIs. */
|
|
839
|
+
listServers() {
|
|
840
|
+
const snap = this.store.read();
|
|
841
|
+
return Object.entries(snap.servers).map(([id, entry]) => ({
|
|
842
|
+
id,
|
|
843
|
+
entry
|
|
844
|
+
}));
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Push the current store contents into a bundler instance (which owns
|
|
848
|
+
* connections / tools). Wires up a store watcher so external mutations
|
|
849
|
+
* (e.g. another shell running `alfe mcp add`) re-reconcile.
|
|
850
|
+
*/
|
|
851
|
+
async loadIntoBundler(bundler) {
|
|
852
|
+
this.bundler = bundler;
|
|
853
|
+
await this.reconcileBundler();
|
|
854
|
+
this.storeUnsubscribe ??= this.store.watch(() => {
|
|
855
|
+
this.reconcileBundler().catch((err) => {
|
|
856
|
+
this.logger?.warn("[mcp-bundler/manager] watcher reconcile failed", { err: errMsg(err) });
|
|
857
|
+
});
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
/** Subscribe to store mutations. Returns an unsubscribe fn. */
|
|
861
|
+
onChange(cb) {
|
|
862
|
+
this.changeListeners.add(cb);
|
|
863
|
+
return () => {
|
|
864
|
+
this.changeListeners.delete(cb);
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Cancel any pending mirror-write, flush the in-flight one, and stop
|
|
869
|
+
* watching the store. Safe to call multiple times.
|
|
870
|
+
*/
|
|
871
|
+
async dispose() {
|
|
872
|
+
if (this.mirrorTimer) {
|
|
873
|
+
clearTimeout(this.mirrorTimer);
|
|
874
|
+
this.mirrorTimer = void 0;
|
|
875
|
+
await this.runMirror();
|
|
876
|
+
}
|
|
877
|
+
await this.mirrorPromise.catch(() => void 0);
|
|
878
|
+
if (this.storeUnsubscribe) {
|
|
879
|
+
this.storeUnsubscribe();
|
|
880
|
+
this.storeUnsubscribe = void 0;
|
|
881
|
+
}
|
|
882
|
+
this.store.dispose();
|
|
883
|
+
this.changeListeners.clear();
|
|
884
|
+
this.bundler = void 0;
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* Force the debounced mirror to run now and wait for it to finish.
|
|
888
|
+
* Surfaces the executor error if the mirror failed — callers wrap in
|
|
889
|
+
* try/catch (or .rejects in tests) if they need to handle it.
|
|
890
|
+
*/
|
|
891
|
+
async flush() {
|
|
892
|
+
if (this.mirrorTimer) {
|
|
893
|
+
clearTimeout(this.mirrorTimer);
|
|
894
|
+
this.mirrorTimer = void 0;
|
|
895
|
+
await this.runMirror();
|
|
896
|
+
}
|
|
897
|
+
await this.mirrorPromise;
|
|
898
|
+
}
|
|
899
|
+
scheduleMirror() {
|
|
900
|
+
if (this.mirrorPending) return;
|
|
901
|
+
this.mirrorPromise = new Promise((resolve, reject) => {
|
|
902
|
+
this.mirrorPending = {
|
|
903
|
+
resolve,
|
|
904
|
+
reject
|
|
905
|
+
};
|
|
906
|
+
});
|
|
907
|
+
this.mirrorTimer = setTimeout(() => {
|
|
908
|
+
this.mirrorTimer = void 0;
|
|
909
|
+
this.runMirror();
|
|
910
|
+
}, this.mirrorDebounceMs);
|
|
911
|
+
this.mirrorTimer.unref();
|
|
912
|
+
}
|
|
913
|
+
async runMirror() {
|
|
914
|
+
const pending = this.mirrorPending;
|
|
915
|
+
this.mirrorPending = void 0;
|
|
916
|
+
try {
|
|
917
|
+
await this.applyMirror();
|
|
918
|
+
pending?.resolve();
|
|
919
|
+
} catch (err) {
|
|
920
|
+
pending?.reject(err);
|
|
921
|
+
this.logger?.error("[mcp-bundler/manager] mirror write failed", { err: errMsg(err) });
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Compute the diff between this manager's owned set and what the store
|
|
926
|
+
* declares now, then apply the openclaw config delta. Foreign keys
|
|
927
|
+
* (entries in openclaw.json#mcp.servers.* not in our store) are
|
|
928
|
+
* preserved — we only touch the names we previously claimed.
|
|
929
|
+
*/
|
|
930
|
+
async applyMirror() {
|
|
931
|
+
let previousOwned = [];
|
|
932
|
+
let desiredOwned = [];
|
|
933
|
+
let batch = [];
|
|
934
|
+
this.store.update((cur) => {
|
|
935
|
+
previousOwned = cur._ownedOpenclawKeys.slice();
|
|
936
|
+
const entries = Object.entries(cur.servers);
|
|
937
|
+
desiredOwned = entries.map(([id]) => id).sort();
|
|
938
|
+
batch = entries.flatMap(([id, entry]) => renderEntryToBatch(id, entry));
|
|
939
|
+
return {
|
|
940
|
+
...cur,
|
|
941
|
+
_ownedOpenclawKeys: desiredOwned
|
|
942
|
+
};
|
|
943
|
+
});
|
|
944
|
+
await this.executor.setBatch(batch);
|
|
945
|
+
const toUnset = previousOwned.filter((k) => !desiredOwned.includes(k));
|
|
946
|
+
for (const id of toUnset) try {
|
|
947
|
+
await this.executor.unset(`mcp.servers.${id}`);
|
|
948
|
+
} catch (err) {
|
|
949
|
+
this.logger?.warn("[mcp-bundler/manager] mirror unset failed (continuing)", {
|
|
950
|
+
err: errMsg(err),
|
|
951
|
+
key: `mcp.servers.${id}`
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
if (this.bundler) try {
|
|
955
|
+
await this.reconcileBundler();
|
|
956
|
+
} catch (err) {
|
|
957
|
+
this.logger?.warn("[mcp-bundler/manager] post-mirror reconcile failed", { err: errMsg(err) });
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
async reconcileBundler() {
|
|
961
|
+
if (!this.bundler) return;
|
|
962
|
+
const snap = this.store.read();
|
|
963
|
+
const servers = {};
|
|
964
|
+
for (const [id, entry] of Object.entries(snap.servers)) servers[id] = toServerConfig(entry);
|
|
965
|
+
await this.bundler.reconcile(servers);
|
|
966
|
+
}
|
|
967
|
+
fireChange() {
|
|
968
|
+
for (const cb of this.changeListeners) try {
|
|
969
|
+
cb();
|
|
970
|
+
} catch (err) {
|
|
971
|
+
this.logger?.warn("[mcp-bundler/manager] onChange listener threw", { err: errMsg(err) });
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
};
|
|
975
|
+
/**
|
|
976
|
+
* Render one store entry as the set of dotted-path batch entries that
|
|
977
|
+
* `openclaw config set --batch-json` expects.
|
|
978
|
+
*
|
|
979
|
+
* Mirrors the format the integrations applier already emits today so the
|
|
980
|
+
* runtime sees the same shape regardless of which writer produced it.
|
|
981
|
+
*/
|
|
982
|
+
function renderEntryToBatch(id, entry) {
|
|
983
|
+
const prefix = `mcp.servers.${id}`;
|
|
984
|
+
const out = [];
|
|
985
|
+
if (entry.transport === "stdio") {
|
|
986
|
+
out.push({
|
|
987
|
+
path: `${prefix}.command`,
|
|
988
|
+
value: entry.command
|
|
989
|
+
});
|
|
990
|
+
if (entry.args && entry.args.length > 0) out.push({
|
|
991
|
+
path: `${prefix}.args`,
|
|
992
|
+
value: entry.args
|
|
993
|
+
});
|
|
994
|
+
if (entry.env) for (const [k, v] of Object.entries(entry.env)) out.push({
|
|
995
|
+
path: `${prefix}.env.${k}`,
|
|
996
|
+
value: v
|
|
997
|
+
});
|
|
998
|
+
if (entry.cwd) out.push({
|
|
999
|
+
path: `${prefix}.cwd`,
|
|
1000
|
+
value: entry.cwd
|
|
1001
|
+
});
|
|
1002
|
+
return out;
|
|
1003
|
+
}
|
|
1004
|
+
out.push({
|
|
1005
|
+
path: `${prefix}.url`,
|
|
1006
|
+
value: entry.url
|
|
1007
|
+
});
|
|
1008
|
+
out.push({
|
|
1009
|
+
path: `${prefix}.transport`,
|
|
1010
|
+
value: entry.transport
|
|
1011
|
+
});
|
|
1012
|
+
if (entry.headers) for (const [k, v] of Object.entries(entry.headers)) out.push({
|
|
1013
|
+
path: `${prefix}.headers.${k}`,
|
|
1014
|
+
value: v
|
|
1015
|
+
});
|
|
1016
|
+
if (entry.connectionTimeoutMs !== void 0) out.push({
|
|
1017
|
+
path: `${prefix}.connectionTimeoutMs`,
|
|
1018
|
+
value: entry.connectionTimeoutMs
|
|
1019
|
+
});
|
|
1020
|
+
return out;
|
|
1021
|
+
}
|
|
1022
|
+
function errMsg(err) {
|
|
1023
|
+
return err instanceof Error ? err.message : String(err);
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Indexed access on `Record<string, T>` returns `T` (not `T | undefined`)
|
|
1027
|
+
* unless `noUncheckedIndexedAccess` is set in tsconfig. These helpers
|
|
1028
|
+
* make the optional-ness explicit so the lint rules that hate
|
|
1029
|
+
* always-truthy conditionals stop firing on real lookups.
|
|
1030
|
+
*/
|
|
1031
|
+
function lookupEntry(servers, id) {
|
|
1032
|
+
return Object.hasOwn(servers, id) ? servers[id] : void 0;
|
|
1033
|
+
}
|
|
1034
|
+
function lookupAddedAt(servers, id) {
|
|
1035
|
+
const entry = lookupEntry(servers, id);
|
|
1036
|
+
return entry ? entry.addedAt : void 0;
|
|
1037
|
+
}
|
|
1038
|
+
//#endregion
|
|
453
1039
|
exports.Connection = Connection;
|
|
1040
|
+
exports.Manager = Manager;
|
|
454
1041
|
exports.McpBundler = McpBundler;
|
|
455
1042
|
exports.STDIO_ENV_DENYLIST = STDIO_ENV_DENYLIST;
|
|
1043
|
+
exports.Store = Store;
|
|
456
1044
|
exports.buildNamespacedToolName = buildNamespacedToolName;
|
|
457
1045
|
exports.defaultConnect = defaultConnect;
|
|
1046
|
+
exports.defaultOpenclawExecutor = defaultOpenclawExecutor;
|
|
1047
|
+
exports.defaultStorePath = defaultStorePath;
|
|
458
1048
|
exports.disambiguateAgainst = disambiguateAgainst;
|
|
459
1049
|
exports.sanitizeNameSegment = sanitizeNameSegment;
|
|
460
1050
|
exports.sanitizeStdioEnv = sanitizeStdioEnv;
|
|
1051
|
+
exports.toServerConfig = toServerConfig;
|
|
1052
|
+
exports.toStoredEntry = toStoredEntry;
|