mcp-page-bridge 0.1.4 → 0.1.5

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/bridge.d.ts CHANGED
@@ -40,6 +40,13 @@ interface BridgeOptions {
40
40
  host?: string;
41
41
  /** If set, browsers must connect with `?token=<token>` or they're rejected. */
42
42
  token?: string;
43
+ /**
44
+ * If set (> 0), the bridge shuts itself down after this many ms with no
45
+ * connected browser providers AND no attached `/agent` connections.
46
+ */
47
+ idleTimeoutMs?: number;
48
+ /** Invoked after an idle-triggered shutdown completes (e.g. to exit a daemon). */
49
+ onIdleShutdown?: () => void;
43
50
  }
44
51
  declare function createBridge(opts?: BridgeOptions): Promise<Bridge>;
45
52
 
package/dist/bridge.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  createBridge
4
- } from "./chunk-RRWNJUKA.js";
4
+ } from "./chunk-EQUJLLID.js";
5
5
  export {
6
6
  createBridge
7
7
  };
@@ -21,7 +21,7 @@ import {
21
21
  } from "@modelcontextprotocol/sdk/types.js";
22
22
 
23
23
  // ../protocol/src/index.ts
24
- var MCP_PAGE_BRIDGE_VERSION = "0.1.4";
24
+ var MCP_PAGE_BRIDGE_VERSION = "0.1.5";
25
25
  var WS_SUBPROTOCOL = "mcp";
26
26
  var DEFAULT_PORT = 8787;
27
27
  var NAMESPACE_SEP = "__";
@@ -35,6 +35,52 @@ function namespaceName(label, name) {
35
35
  }
36
36
  var MCP_PAGE_BRIDGE_DASHBOARD_ACTIVATE_TAB = "mcpPageBridge/activateTab";
37
37
  var MCP_PAGE_BRIDGE_DASHBOARD_CLOSE_TAB = "mcpPageBridge/closeTab";
38
+ var DASHBOARD_HEADER = "x-mcp-page-bridge-dashboard";
39
+ var DASHBOARD_HEADER_VALUE = "1";
40
+ var TOKEN_HEADER = "x-mcp-page-bridge-token";
41
+ var SERVICE_ID = "mcp-page-bridge";
42
+ var BUILTIN_TOOL_NAMES = [
43
+ // Page inspection / interaction
44
+ "eval",
45
+ "dom_query",
46
+ "get_page_info",
47
+ "click",
48
+ "set_value",
49
+ "scroll",
50
+ "wait_for",
51
+ "get_html",
52
+ // Element selection
53
+ "get_selected_element",
54
+ "get_selected_elements",
55
+ "get_computed_style",
56
+ "highlight_element",
57
+ "show_selected_marker",
58
+ "hide_selected_marker",
59
+ "clear_selected_elements",
60
+ "remove_selected_element",
61
+ "update_selected_element",
62
+ // CSS patching
63
+ "apply_css",
64
+ "list_css_patches",
65
+ "remove_css_patch",
66
+ "clear_css_patches",
67
+ "export_css_patches",
68
+ "export_design_changes",
69
+ // Audits / diagnostics
70
+ "accessibility_audit",
71
+ "responsive_summary",
72
+ "debug_summary",
73
+ "console_logs",
74
+ // Design baseline
75
+ "capture_design_baseline",
76
+ "compare_design_baseline",
77
+ "clear_design_baseline",
78
+ // Service-worker delegated
79
+ "screenshot",
80
+ "navigate",
81
+ "reload"
82
+ ];
83
+ var BROWSER_TOOL_NAMES = ["list_tabs", "open_tab", "activate_tab", "navigate_tab", "close_tab"];
38
84
 
39
85
  // src/dashboard.ts
40
86
  var FAVICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><polygon points="22.39,6.00 22.39,18.00 12.00,24.00 1.61,18.00 1.61,6.00 12.00,0.00" fill="#E63946"/><svg x="3" y="3" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22v-5"/><path d="M15 8V2"/><path d="M17 8a1 1 0 0 1 1 1v4a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1z"/><path d="M9 8V2"/></svg></svg>';
@@ -139,7 +185,21 @@ var DASHBOARD_HTML = `<!doctype html>
139
185
  .status { display: flex; align-items: center; gap: 9px; color: var(--muted); }
140
186
  .dot { width: 8px; height: 8px; background: var(--brand); box-shadow: 0 0 0 4px var(--brand-soft); }
141
187
  .dot.on { background: var(--green); box-shadow: 0 0 0 4px var(--green-soft); }
188
+ .status-actions { display: flex; align-items: center; gap: 10px; }
142
189
  .refresh { color: var(--quiet); font-size: 12px; font-family: var(--mono); }
190
+ .shutdown-btn {
191
+ appearance: none;
192
+ border: 1px solid color-mix(in srgb, var(--brand) 42%, var(--line));
193
+ border-radius: 4px;
194
+ background: var(--brand-soft);
195
+ color: var(--brand);
196
+ padding: 6px 9px;
197
+ cursor: pointer;
198
+ font-size: 12px;
199
+ font-weight: 700;
200
+ }
201
+ .shutdown-btn:hover { border-color: var(--brand); }
202
+ .shutdown-btn:disabled { opacity: 0.58; cursor: wait; }
143
203
  .stats {
144
204
  display: grid;
145
205
  grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -334,7 +394,33 @@ var DASHBOARD_HTML = `<!doctype html>
334
394
  .empty.small { min-height: 96px; border: 1px dashed var(--line); border-radius: 5px; background: var(--panel-2); }
335
395
  .placeholder { color: var(--muted); }
336
396
  code { border: 1px solid var(--line); background: var(--panel-2); padding: 1px 5px; border-radius: 3px; font-family: var(--mono); }
337
- footer { margin-top: 20px; color: var(--quiet); font-size: 12px; }
397
+ .search {
398
+ width: 100%;
399
+ margin-bottom: 12px;
400
+ border: 1px solid var(--line);
401
+ border-radius: 6px;
402
+ background: var(--panel-2);
403
+ color: var(--fg);
404
+ padding: 9px 11px;
405
+ font: inherit;
406
+ font-size: 13px;
407
+ }
408
+ .search:focus { outline: none; border-color: var(--line-strong); }
409
+ .search::placeholder { color: var(--quiet); }
410
+ .provider-meta { margin-top: 6px; display: flex; flex-wrap: wrap; gap: 10px; color: var(--quiet); font-family: var(--mono); font-size: 11px; }
411
+ .detail-head-right { display: flex; align-items: center; gap: 10px; }
412
+ .copy-btn {
413
+ appearance: none;
414
+ border: 1px solid var(--line);
415
+ border-radius: 4px;
416
+ background: var(--panel-2);
417
+ color: var(--muted);
418
+ padding: 4px 8px;
419
+ cursor: pointer;
420
+ font-size: 11px;
421
+ }
422
+ .copy-btn:hover { color: var(--fg); border-color: var(--line-strong); }
423
+ mark { background: var(--brand-soft); color: inherit; border-radius: 2px; padding: 0 1px; }
338
424
  @media (max-width: 900px) {
339
425
  .topbar, .status-row { align-items: flex-start; flex-direction: column; }
340
426
  .endpoint { justify-content: flex-start; }
@@ -374,7 +460,10 @@ var DASHBOARD_HTML = `<!doctype html>
374
460
  <span class="dot" id="dot"></span>
375
461
  <span id="statusText">connecting</span>
376
462
  </div>
377
- <div class="refresh">auto refresh: 1.5s</div>
463
+ <div class="status-actions">
464
+ <div class="refresh">auto refresh: 1.5s</div>
465
+ <button class="shutdown-btn" id="shutdownBtn" type="button">Shutdown bridge</button>
466
+ </div>
378
467
  </div>
379
468
 
380
469
  <section class="stats" aria-label="Connected provider summary">
@@ -385,14 +474,13 @@ var DASHBOARD_HTML = `<!doctype html>
385
474
  <main class="layout">
386
475
  <section>
387
476
  <div class="panel-title"><span>Providers</span><span id="listCount">0 connected</span></div>
477
+ <input class="search" id="search" type="search" placeholder="Filter tools by name or description\u2026" autocomplete="off" spellcheck="false" />
388
478
  <div id="list"></div>
389
479
  </section>
390
480
  <aside class="detail" id="detail">
391
481
  <div class="detail-inner"><div class="empty placeholder">Select an item to inspect its schema and source.</div></div>
392
482
  </aside>
393
483
  </main>
394
-
395
- <footer>Dashboard is served by the local bridge on the same port as WebSocket transport.</footer>
396
484
  </div>
397
485
 
398
486
  <script>
@@ -400,28 +488,52 @@ var DASHBOARD_HTML = `<!doctype html>
400
488
  const esc = (s) =>
401
489
  String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
402
490
 
491
+ // The bridge requires the shared token (if configured) on HTTP requests too.
492
+ // Operators open the dashboard as http://127.0.0.1:<port>/?token=<secret>.
493
+ const BRIDGE_TOKEN = new URLSearchParams(location.search).get("token") || "";
494
+ const TOKEN_HEADER = "x-mcp-page-bridge-token";
495
+ const DASHBOARD_HEADER = "x-mcp-page-bridge-dashboard";
496
+ function authHeaders(extra) {
497
+ const headers = Object.assign({}, extra || {});
498
+ if (BRIDGE_TOKEN) headers[TOKEN_HEADER] = BRIDGE_TOKEN;
499
+ return headers;
500
+ }
501
+ function withToken(path) {
502
+ return BRIDGE_TOKEN ? path + (path.includes("?") ? "&" : "?") + "token=" + encodeURIComponent(BRIDGE_TOKEN) : path;
503
+ }
504
+
403
505
  const EMPTY = "No tools published.";
404
- const BUILTIN_TOOLS = new Set([
405
- "eval",
406
- "dom_query",
407
- "get_html",
408
- "get_page_info",
409
- "click",
410
- "set_value",
411
- "scroll",
412
- "wait_for",
413
- "console_logs",
414
- "screenshot",
415
- "navigate",
416
- "reload",
417
- ]);
418
- const BROWSER_TOOLS = new Set(["list_tabs", "open_tab", "activate_tab", "navigate_tab", "close_tab"]);
506
+ const BUILTIN_TOOLS = new Set(${JSON.stringify([...BUILTIN_TOOL_NAMES])});
507
+ const BROWSER_TOOLS = new Set(${JSON.stringify([...BROWSER_TOOL_NAMES])});
419
508
 
420
509
  let data = { version: "", port: 8787, providers: [] };
421
510
  let selected = null; // { kind, provider, key }
511
+ let filter = ""; // lowercased tool filter query
512
+ let bridgeOnline = false;
513
+ let shutdownRequested = false;
422
514
  const openProviders = Object.create(null);
423
515
  const openGroups = Object.create(null);
424
516
 
517
+ function timeAgo(iso) {
518
+ const t = Date.parse(iso);
519
+ if (!Number.isFinite(t)) return "";
520
+ const s = Math.max(0, Math.round((Date.now() - t) / 1000));
521
+ if (s < 60) return s + "s";
522
+ const m = Math.floor(s / 60);
523
+ if (m < 60) return m + "m";
524
+ const h = Math.floor(m / 60);
525
+ if (h < 24) return h + "h";
526
+ return Math.floor(h / 24) + "d";
527
+ }
528
+
529
+ function highlight(text) {
530
+ const s = esc(text);
531
+ if (!filter) return s;
532
+ const i = s.toLowerCase().indexOf(filter);
533
+ if (i < 0) return s;
534
+ return s.slice(0, i) + "<mark>" + s.slice(i, i + filter.length) + "</mark>" + s.slice(i + filter.length);
535
+ }
536
+
425
537
  function shortName(provider, key) {
426
538
  const raw = String(key || "");
427
539
  const prefix = provider.label + "__";
@@ -468,9 +580,16 @@ var DASHBOARD_HTML = `<!doctype html>
468
580
  return "Page tools";
469
581
  }
470
582
 
583
+ function matchesFilter(provider, item) {
584
+ if (!filter) return true;
585
+ const hay = (shortName(provider, item.name) + " " + item.name + " " + (item.description || "")).toLowerCase();
586
+ return hay.includes(filter);
587
+ }
588
+
471
589
  function groupsFor(provider) {
472
590
  const buckets = new Map();
473
591
  for (const item of itemList(provider)) {
592
+ if (!matchesFilter(provider, item)) continue;
474
593
  const title = classifyTool(provider, item);
475
594
  if (!buckets.has(title)) buckets.set(title, []);
476
595
  buckets.get(title).push(item);
@@ -491,12 +610,12 @@ var DASHBOARD_HTML = `<!doctype html>
491
610
  esc(key) +
492
611
  '">' +
493
612
  '<span><span class="nm">' +
494
- esc(local) +
613
+ highlight(local) +
495
614
  '</span><span class="full">' +
496
615
  esc(key) +
497
616
  "</span></span>" +
498
617
  '<span class="ds">' +
499
- esc(itemDescription(item)) +
618
+ highlight(itemDescription(item)) +
500
619
  "</span></button>"
501
620
  );
502
621
  }
@@ -507,7 +626,7 @@ var DASHBOARD_HTML = `<!doctype html>
507
626
  return groups
508
627
  .map((group) => {
509
628
  const id = groupId(provider, group.title);
510
- const open = openGroups[id] !== false;
629
+ const open = filter ? true : openGroups[id] !== false;
511
630
  return (
512
631
  '<details class="group" data-group="' +
513
632
  esc(id) +
@@ -527,8 +646,16 @@ var DASHBOARD_HTML = `<!doctype html>
527
646
 
528
647
  function renderProvider(provider) {
529
648
  const c = counts(provider);
649
+ if (filter && !groupsFor(provider).length) return "";
530
650
  const title = provider.title || provider.name || provider.label;
531
- const open = openProviders[provider.label] === true;
651
+ const open = filter ? true : openProviders[provider.label] === true;
652
+ const metaParts = [];
653
+ if (provider.version) metaParts.push("v" + esc(provider.version));
654
+ const ago = timeAgo(provider.connectedAt);
655
+ if (ago) metaParts.push("connected " + ago + " ago");
656
+ const metaHtml = metaParts.length
657
+ ? '<div class="provider-meta">' + metaParts.map((m) => "<span>" + m + "</span>").join("") + "</div>"
658
+ : "";
532
659
  const actions =
533
660
  provider.tabId === undefined
534
661
  ? ""
@@ -550,6 +677,7 @@ var DASHBOARD_HTML = `<!doctype html>
550
677
  esc(title) +
551
678
  "</span></div>" +
552
679
  (provider.url ? '<div class="url">' + esc(provider.url) + "</div>" : "") +
680
+ metaHtml +
553
681
  '</div><div class="provider-side"><div class="metrics"><span class="metric">Tools ' +
554
682
  c.tool +
555
683
  "</span></div>" +
@@ -571,9 +699,9 @@ var DASHBOARD_HTML = `<!doctype html>
571
699
 
572
700
  async function callProviderAction(provider, action) {
573
701
  if (action === "close" && !confirm("Close tab for provider " + provider + "?")) return;
574
- const res = await fetch("/api/providers/" + encodeURIComponent(provider) + "/" + action, {
702
+ const res = await fetch(withToken("/api/providers/" + encodeURIComponent(provider) + "/" + action), {
575
703
  method: "POST",
576
- headers: { "x-mcp-page-bridge-dashboard": "1" },
704
+ headers: authHeaders({ [DASHBOARD_HEADER]: "1" }),
577
705
  });
578
706
  const body = await res.json().catch(() => ({}));
579
707
  if (!res.ok || body.ok === false) throw new Error(body.error || "action failed");
@@ -581,14 +709,47 @@ var DASHBOARD_HTML = `<!doctype html>
581
709
  await tick();
582
710
  }
583
711
 
712
+ async function shutdownBridge() {
713
+ if (!confirm("Shutdown the local mcp-page-bridge daemon? Connected agents and browser tabs will disconnect.")) return;
714
+ const btn = $("shutdownBtn");
715
+ btn.disabled = true;
716
+ $("statusText").textContent = "shutting down bridge";
717
+ const res = await fetch(withToken("/api/shutdown"), {
718
+ method: "POST",
719
+ headers: authHeaders({ [DASHBOARD_HEADER]: "1" }),
720
+ });
721
+ const body = await res.json().catch(() => ({}));
722
+ if (!res.ok || body.ok === false) throw new Error(body.error || "shutdown failed");
723
+ shutdownRequested = true;
724
+ bridgeOnline = false;
725
+ selected = null;
726
+ data = { version: data.version, port: data.port, providers: [] };
727
+ $("dot").className = "dot";
728
+ $("statusText").textContent = "bridge shutdown requested";
729
+ updateStats();
730
+ renderList();
731
+ renderDetail();
732
+ }
733
+
584
734
  function renderList() {
585
735
  const list = $("list");
736
+ if (!bridgeOnline) {
737
+ list.innerHTML = shutdownRequested
738
+ ? '<div class="card"><div class="empty">Bridge shutdown requested.<br/>Restart mcp-page-bridge to use the dashboard again.</div></div>'
739
+ : '<div class="card"><div class="empty">Bridge is not reachable.<br/>Start mcp-page-bridge and refresh this page.</div></div>';
740
+ return;
741
+ }
586
742
  if (!data.providers.length) {
587
743
  list.innerHTML =
588
744
  '<div class="card"><div class="empty">No browsers connected yet.<br/>Enable the mcp-page-bridge extension on a tab.</div></div>';
589
745
  return;
590
746
  }
591
- list.innerHTML = data.providers.map(renderProvider).join("");
747
+ const html = data.providers.map(renderProvider).join("");
748
+ if (!html) {
749
+ list.innerHTML = '<div class="card"><div class="empty">No tools match "' + esc(filter) + '".</div></div>';
750
+ return;
751
+ }
752
+ list.innerHTML = html;
592
753
 
593
754
  for (const card of document.querySelectorAll(".provider-card")) {
594
755
  card.addEventListener("toggle", () => {
@@ -654,6 +815,10 @@ var DASHBOARD_HTML = `<!doctype html>
654
815
 
655
816
  function renderDetail() {
656
817
  const el = $("detail");
818
+ if (!bridgeOnline) {
819
+ el.innerHTML = '<div class="detail-inner"><div class="empty placeholder">Bridge is offline.</div></div>';
820
+ return;
821
+ }
657
822
  if (!selected) {
658
823
  el.innerHTML = '<div class="detail-inner"><div class="empty placeholder">Select an item to inspect its schema and source.</div></div>';
659
824
  return;
@@ -681,13 +846,28 @@ var DASHBOARD_HTML = `<!doctype html>
681
846
  el.innerHTML =
682
847
  '<div class="detail-inner"><div class="detail-head"><span class="kind">' +
683
848
  kind +
684
- '</span><span class="from">' +
849
+ '</span><div class="detail-head-right"><button class="copy-btn" id="copyName" type="button">Copy name</button><span class="from">' +
685
850
  esc(source) +
686
- "</span></div><h2>" +
851
+ "</span></div></div><h2>" +
687
852
  esc(key) +
688
853
  "</h2>" +
689
854
  body +
690
855
  "</div>";
856
+
857
+ const copyBtn = $("copyName");
858
+ if (copyBtn) {
859
+ copyBtn.addEventListener("click", () => {
860
+ const done = () => {
861
+ copyBtn.textContent = "Copied";
862
+ setTimeout(() => {
863
+ copyBtn.textContent = "Copy name";
864
+ }, 1200);
865
+ };
866
+ if (navigator.clipboard?.writeText) {
867
+ navigator.clipboard.writeText(key).then(done).catch(() => {});
868
+ }
869
+ });
870
+ }
691
871
  }
692
872
 
693
873
  function updateStats() {
@@ -699,8 +879,21 @@ var DASHBOARD_HTML = `<!doctype html>
699
879
 
700
880
  async function tick() {
701
881
  try {
702
- const res = await fetch("/api/providers", { cache: "no-store" });
882
+ const res = await fetch(withToken("/api/providers"), { cache: "no-store", headers: authHeaders() });
703
883
  data = await res.json();
884
+ if (shutdownRequested) {
885
+ bridgeOnline = false;
886
+ selected = null;
887
+ data = { version: data.version, port: data.port, providers: [] };
888
+ $("dot").className = "dot";
889
+ $("statusText").textContent = "bridge shutdown requested";
890
+ updateStats();
891
+ renderList();
892
+ renderDetail();
893
+ return;
894
+ }
895
+ bridgeOnline = true;
896
+ $("shutdownBtn").disabled = false;
704
897
  $("dot").className = "dot on";
705
898
  $("version").textContent = "v" + data.version;
706
899
  $("endpoint").textContent = "ws://127.0.0.1:" + data.port;
@@ -710,13 +903,31 @@ var DASHBOARD_HTML = `<!doctype html>
710
903
  renderList();
711
904
  renderDetail();
712
905
  } catch {
906
+ bridgeOnline = false;
907
+ selected = null;
713
908
  data = { version: "", port: 8787, providers: [] };
714
909
  $("dot").className = "dot";
715
- $("statusText").textContent = "bridge not reachable";
910
+ $("statusText").textContent = shutdownRequested ? "bridge shut down" : "bridge not reachable";
911
+ $("shutdownBtn").disabled = true;
716
912
  updateStats();
913
+ renderList();
914
+ renderDetail();
717
915
  }
718
916
  }
719
917
 
918
+ $("search").addEventListener("input", (event) => {
919
+ filter = event.target.value.trim().toLowerCase();
920
+ renderList();
921
+ });
922
+
923
+ $("shutdownBtn").addEventListener("click", () => {
924
+ shutdownBridge().catch((error) => {
925
+ shutdownRequested = false;
926
+ $("shutdownBtn").disabled = false;
927
+ $("statusText").textContent = error instanceof Error ? error.message : String(error);
928
+ });
929
+ });
930
+
720
931
  tick();
721
932
  setInterval(tick, 1500);
722
933
  </script>
@@ -743,11 +954,11 @@ var WebSocketServerTransport = class {
743
954
  this.onerror?.(error);
744
955
  return;
745
956
  }
746
- if (!this.started || !this.onmessage) {
957
+ if (!this.started || !this._onmessage) {
747
958
  this.buffer.push(message);
748
959
  return;
749
960
  }
750
- this.onmessage(message);
961
+ this._onmessage(message);
751
962
  });
752
963
  this.socket.on("close", () => this.onclose?.());
753
964
  this.socket.on("error", (error) => this.onerror?.(error));
@@ -755,29 +966,74 @@ var WebSocketServerTransport = class {
755
966
  socket;
756
967
  onclose;
757
968
  onerror;
758
- onmessage;
759
969
  sessionId;
760
970
  started = false;
761
971
  buffer = [];
972
+ _onmessage;
973
+ /** Flush any buffered messages as soon as both started and a consumer exist. */
974
+ get onmessage() {
975
+ return this._onmessage;
976
+ }
977
+ set onmessage(handler) {
978
+ this._onmessage = handler;
979
+ if (handler && this.started) this.flush();
980
+ }
762
981
  async start() {
763
982
  this.started = true;
983
+ this.flush();
984
+ }
985
+ flush() {
986
+ if (!this._onmessage) return;
764
987
  const pending = this.buffer;
765
988
  this.buffer = [];
766
- for (const message of pending) this.onmessage?.(message);
989
+ for (const message of pending) this._onmessage(message);
767
990
  }
768
991
  async send(message) {
769
- this.socket.send(JSON.stringify(message));
992
+ if (this.socket.readyState !== this.socket.OPEN) {
993
+ throw new Error("cannot send on a non-open WebSocket");
994
+ }
995
+ await new Promise((resolve, reject) => {
996
+ this.socket.send(JSON.stringify(message), (error) => error ? reject(error) : resolve());
997
+ });
770
998
  }
771
999
  async close() {
772
- this.socket.close();
1000
+ try {
1001
+ this.socket.close();
1002
+ } catch {
1003
+ }
1004
+ setTimeout(() => {
1005
+ try {
1006
+ this.socket.terminate();
1007
+ } catch {
1008
+ }
1009
+ }, 1e3).unref?.();
773
1010
  }
774
1011
  };
775
1012
 
776
1013
  // src/bridge.ts
777
1014
  var META_LIST_CLIENTS = "mcp_page_bridge_list_clients";
1015
+ var MAX_LABEL_RESERVATIONS = 1e3;
778
1016
  async function createBridge(opts = {}) {
779
1017
  const host = opts.host ?? "127.0.0.1";
780
1018
  const providers = /* @__PURE__ */ new Map();
1019
+ const labelReservations = /* @__PURE__ */ new Map();
1020
+ let agentConnections = 0;
1021
+ let idleTimer;
1022
+ function checkIdle() {
1023
+ const idleMs = opts.idleTimeoutMs ?? 0;
1024
+ if (idleMs <= 0) return;
1025
+ const idle = providers.size === 0 && agentConnections === 0;
1026
+ if (idle) {
1027
+ if (idleTimer) return;
1028
+ idleTimer = setTimeout(() => {
1029
+ void closeBridge().then(() => opts.onIdleShutdown?.());
1030
+ }, idleMs);
1031
+ idleTimer.unref?.();
1032
+ } else if (idleTimer) {
1033
+ clearTimeout(idleTimer);
1034
+ idleTimer = void 0;
1035
+ }
1036
+ }
781
1037
  const toolRoutes = /* @__PURE__ */ new Map();
782
1038
  const promptRoutes = /* @__PURE__ */ new Map();
783
1039
  const resourceRoutes = /* @__PURE__ */ new Map();
@@ -871,13 +1127,59 @@ async function createBridge(opts = {}) {
871
1127
  });
872
1128
  }
873
1129
  }
874
- function uniqueLabel(base) {
875
- const used = new Set([...providers.values()].map((p) => p.label));
1130
+ function uniqueLabel(base, used) {
876
1131
  if (!used.has(base)) return base;
877
1132
  let i = 2;
878
1133
  while (used.has(`${base}-${i}`)) i += 1;
879
1134
  return `${base}-${i}`;
880
1135
  }
1136
+ function reservationKeys(base, meta) {
1137
+ const exact = [];
1138
+ const fallback = [];
1139
+ if (meta.tabId !== void 0 && meta.providerId) {
1140
+ exact.push(`tab:${meta.tabId}:provider:${meta.providerId}:name:${base}`);
1141
+ } else if (meta.providerId) {
1142
+ exact.push(`provider:${meta.providerId}:name:${base}`);
1143
+ }
1144
+ if (meta.tabId !== void 0) fallback.push(`tab:${meta.tabId}:name:${base}`);
1145
+ if (meta.url) fallback.push(`url:${meta.url}:name:${base}`);
1146
+ return { exact, fallback };
1147
+ }
1148
+ function touchReservation(key, label) {
1149
+ if (labelReservations.has(key)) labelReservations.delete(key);
1150
+ labelReservations.set(key, label);
1151
+ }
1152
+ function pruneReservations() {
1153
+ if (labelReservations.size <= MAX_LABEL_RESERVATIONS) return;
1154
+ const inUse = new Set([...providers.values()].map((p) => p.label));
1155
+ for (const [key, label] of labelReservations) {
1156
+ if (labelReservations.size <= MAX_LABEL_RESERVATIONS) break;
1157
+ if (inUse.has(label)) continue;
1158
+ labelReservations.delete(key);
1159
+ }
1160
+ }
1161
+ function rememberLabel(keys, label) {
1162
+ for (const key of keys.exact) touchReservation(key, label);
1163
+ for (const key of keys.fallback) {
1164
+ touchReservation(key, labelReservations.get(key) ?? label);
1165
+ }
1166
+ pruneReservations();
1167
+ }
1168
+ function assignLabel(rawName, meta) {
1169
+ const base = sanitizeLabel(rawName);
1170
+ const keys = reservationKeys(base, meta);
1171
+ const used = new Set([...providers.values()].map((p) => p.label));
1172
+ for (const key of [...keys.exact, ...keys.fallback]) {
1173
+ const reserved = labelReservations.get(key);
1174
+ if (reserved && !used.has(reserved)) {
1175
+ rememberLabel(keys, reserved);
1176
+ return reserved;
1177
+ }
1178
+ }
1179
+ const label = uniqueLabel(base, used);
1180
+ rememberLabel(keys, label);
1181
+ return label;
1182
+ }
881
1183
  function createAgentServer() {
882
1184
  const agentServer = new Server(
883
1185
  { name: "mcp-page-bridge", version: MCP_PAGE_BRIDGE_VERSION },
@@ -937,6 +1239,7 @@ async function createBridge(opts = {}) {
937
1239
  return agentServer;
938
1240
  }
939
1241
  const server = createAgentServer();
1242
+ let closing;
940
1243
  function providerSummary() {
941
1244
  return [...providers.values()].map((p) => ({
942
1245
  label: p.label,
@@ -977,12 +1280,53 @@ async function createBridge(opts = {}) {
977
1280
  return {};
978
1281
  }
979
1282
  }
980
- async function handleProviderAction(req, res, label, action) {
981
- if (req.headers["x-mcp-page-bridge-dashboard"] !== "1") {
982
- res.writeHead(403, { "content-type": "application/json" });
983
- res.end(JSON.stringify({ ok: false, error: "missing dashboard header" }));
984
- return;
1283
+ function localAuthorities() {
1284
+ return /* @__PURE__ */ new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]);
1285
+ }
1286
+ function hostAllowed(req) {
1287
+ const allowed = localAuthorities();
1288
+ const hostHeader = req.headers.host;
1289
+ if (!hostHeader || !allowed.has(hostHeader)) return false;
1290
+ const origin = req.headers.origin;
1291
+ if (origin && origin !== "null") {
1292
+ try {
1293
+ if (!allowed.has(new URL(origin).host)) return false;
1294
+ } catch {
1295
+ return false;
1296
+ }
1297
+ }
1298
+ return true;
1299
+ }
1300
+ function tokenOk(req) {
1301
+ if (!token) return true;
1302
+ if (req.headers[TOKEN_HEADER] === token) return true;
1303
+ try {
1304
+ return new URL(req.url ?? "/", "http://localhost").searchParams.get("token") === token;
1305
+ } catch {
1306
+ return false;
985
1307
  }
1308
+ }
1309
+ function denyJson(res, status, error) {
1310
+ res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
1311
+ res.end(JSON.stringify({ ok: false, error }));
1312
+ }
1313
+ function authorizeStateChange(req, res) {
1314
+ if (!hostAllowed(req)) {
1315
+ denyJson(res, 403, "request is not local (bad Host/Origin)");
1316
+ return false;
1317
+ }
1318
+ if (req.headers[DASHBOARD_HEADER] !== DASHBOARD_HEADER_VALUE) {
1319
+ denyJson(res, 403, "missing dashboard header");
1320
+ return false;
1321
+ }
1322
+ if (!tokenOk(req)) {
1323
+ denyJson(res, 401, "missing or invalid token");
1324
+ return false;
1325
+ }
1326
+ return true;
1327
+ }
1328
+ async function handleProviderAction(req, res, label, action) {
1329
+ if (!authorizeStateChange(req, res)) return;
986
1330
  const provider = [...providers.values()].find((p) => p.label === label);
987
1331
  if (!provider) {
988
1332
  res.writeHead(404, { "content-type": "application/json" });
@@ -996,7 +1340,7 @@ async function createBridge(opts = {}) {
996
1340
  }
997
1341
  const method = action === "activate" ? MCP_PAGE_BRIDGE_DASHBOARD_ACTIVATE_TAB : MCP_PAGE_BRIDGE_DASHBOARD_CLOSE_TAB;
998
1342
  try {
999
- await provider.client.request({ method }, EmptyResultSchema);
1343
+ await provider.client.request({ method }, EmptyResultSchema, { timeout: 5e3 });
1000
1344
  res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
1001
1345
  res.end(JSON.stringify({ ok: true }));
1002
1346
  } catch (error) {
@@ -1006,10 +1350,55 @@ async function createBridge(opts = {}) {
1006
1350
  );
1007
1351
  }
1008
1352
  }
1353
+ async function handleShutdown(req, res) {
1354
+ if (!authorizeStateChange(req, res)) return;
1355
+ res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store", connection: "close" });
1356
+ res.end(JSON.stringify({ ok: true }));
1357
+ setImmediate(() => {
1358
+ void closeBridge().catch((error) => {
1359
+ console.error(`[mcp-page-bridge] shutdown failed: ${error.message}`);
1360
+ });
1361
+ });
1362
+ }
1363
+ async function closeBridge() {
1364
+ if (closing) return closing;
1365
+ if (idleTimer) {
1366
+ clearTimeout(idleTimer);
1367
+ idleTimer = void 0;
1368
+ }
1369
+ closing = (async () => {
1370
+ for (const p of providers.values()) {
1371
+ try {
1372
+ await p.client.close();
1373
+ } catch {
1374
+ }
1375
+ }
1376
+ for (const agentServer of [...agentServers]) {
1377
+ agentServers.delete(agentServer);
1378
+ try {
1379
+ await agentServer.close();
1380
+ } catch {
1381
+ }
1382
+ }
1383
+ for (const client of wss.clients) {
1384
+ try {
1385
+ client.terminate();
1386
+ } catch {
1387
+ }
1388
+ }
1389
+ await new Promise((resolve) => wss.close(() => resolve()));
1390
+ await new Promise((resolve) => httpServer.close(() => resolve()));
1391
+ })();
1392
+ return closing;
1393
+ }
1009
1394
  const token = opts.token;
1010
1395
  const httpServer = createHttpServer((req, res) => {
1011
1396
  const path = (req.url ?? "/").split("?")[0] ?? "/";
1012
1397
  const actionMatch = path.match(/^\/api\/providers\/([^/]+)\/(activate|close)$/);
1398
+ if (req.method === "POST" && path === "/api/shutdown") {
1399
+ void handleShutdown(req, res);
1400
+ return;
1401
+ }
1013
1402
  if (req.method === "POST" && actionMatch) {
1014
1403
  void handleProviderAction(
1015
1404
  req,
@@ -1020,7 +1409,11 @@ async function createBridge(opts = {}) {
1020
1409
  return;
1021
1410
  }
1022
1411
  if (req.method === "GET" && (path === "/" || path === "/ui")) {
1023
- res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
1412
+ if (!hostAllowed(req)) {
1413
+ denyJson(res, 403, "request is not local (bad Host/Origin)");
1414
+ return;
1415
+ }
1416
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
1024
1417
  res.end(DASHBOARD_HTML);
1025
1418
  return;
1026
1419
  }
@@ -1029,18 +1422,55 @@ async function createBridge(opts = {}) {
1029
1422
  res.end(FAVICON_SVG);
1030
1423
  return;
1031
1424
  }
1425
+ if (req.method === "GET" && path === "/api/health") {
1426
+ if (!hostAllowed(req)) {
1427
+ denyJson(res, 403, "request is not local (bad Host/Origin)");
1428
+ return;
1429
+ }
1430
+ res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
1431
+ res.end(
1432
+ JSON.stringify({
1433
+ service: SERVICE_ID,
1434
+ version: MCP_PAGE_BRIDGE_VERSION,
1435
+ requiresToken: !!token,
1436
+ port
1437
+ })
1438
+ );
1439
+ return;
1440
+ }
1032
1441
  if (req.method === "GET" && (path === "/api/providers" || path === "/providers.json")) {
1033
- res.writeHead(200, {
1034
- "content-type": "application/json",
1035
- "cache-control": "no-store",
1036
- "access-control-allow-origin": "*"
1037
- });
1038
- res.end(JSON.stringify({ version: MCP_PAGE_BRIDGE_VERSION, port, providers: providerSummary() }));
1442
+ if (!hostAllowed(req)) {
1443
+ denyJson(res, 403, "request is not local (bad Host/Origin)");
1444
+ return;
1445
+ }
1446
+ if (!tokenOk(req)) {
1447
+ denyJson(res, 401, "missing or invalid token");
1448
+ return;
1449
+ }
1450
+ res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
1451
+ res.end(
1452
+ JSON.stringify({
1453
+ service: SERVICE_ID,
1454
+ version: MCP_PAGE_BRIDGE_VERSION,
1455
+ port,
1456
+ providers: providerSummary()
1457
+ })
1458
+ );
1039
1459
  return;
1040
1460
  }
1041
1461
  res.writeHead(404, { "content-type": "text/plain" });
1042
1462
  res.end("not found");
1043
1463
  });
1464
+ await new Promise((resolve, reject) => {
1465
+ httpServer.once("listening", resolve);
1466
+ httpServer.once("error", reject);
1467
+ httpServer.listen(opts.port ?? DEFAULT_PORT, host);
1468
+ });
1469
+ httpServer.on("error", (error) => {
1470
+ console.error(`[mcp-page-bridge] http server error: ${error.message}`);
1471
+ });
1472
+ const address = httpServer.address();
1473
+ const port = typeof address === "object" && address ? address.port : opts.port ?? DEFAULT_PORT;
1044
1474
  const wss = new WebSocketServer({
1045
1475
  server: httpServer,
1046
1476
  handleProtocols: (protocols) => protocols.has(WS_SUBPROTOCOL) ? WS_SUBPROTOCOL : false,
@@ -1053,13 +1483,9 @@ async function createBridge(opts = {}) {
1053
1483
  }
1054
1484
  } : void 0
1055
1485
  });
1056
- await new Promise((resolve, reject) => {
1057
- httpServer.once("listening", resolve);
1058
- httpServer.once("error", reject);
1059
- httpServer.listen(opts.port ?? DEFAULT_PORT, host);
1486
+ wss.on("error", (error) => {
1487
+ console.error(`[mcp-page-bridge] websocket server error: ${error.message}`);
1060
1488
  });
1061
- const address = httpServer.address();
1062
- const port = typeof address === "object" && address ? address.port : opts.port ?? DEFAULT_PORT;
1063
1489
  wss.on("connection", async (ws, req) => {
1064
1490
  const path = (() => {
1065
1491
  try {
@@ -1071,7 +1497,15 @@ async function createBridge(opts = {}) {
1071
1497
  if (path === "/agent") {
1072
1498
  const agentServer = createAgentServer();
1073
1499
  const transport2 = new WebSocketServerTransport(ws);
1500
+ agentConnections += 1;
1501
+ checkIdle();
1502
+ let counted = true;
1074
1503
  const cleanupAgent = () => {
1504
+ if (counted) {
1505
+ counted = false;
1506
+ agentConnections -= 1;
1507
+ checkIdle();
1508
+ }
1075
1509
  if (!agentServers.delete(agentServer)) return;
1076
1510
  void agentServer.close().catch(() => {
1077
1511
  });
@@ -1103,20 +1537,21 @@ async function createBridge(opts = {}) {
1103
1537
  const info = client.getServerVersion();
1104
1538
  const caps = client.getServerCapabilities();
1105
1539
  const rawName = info?.name ?? "browser";
1540
+ const meta = {
1541
+ title: info?.title,
1542
+ url: info?.websiteUrl,
1543
+ ...requestMeta(req.url)
1544
+ };
1106
1545
  const provider = {
1107
1546
  id,
1108
- label: uniqueLabel(sanitizeLabel(rawName)),
1547
+ label: assignLabel(rawName, meta),
1109
1548
  rawName,
1110
1549
  version: info?.version ?? "0.0.0",
1111
1550
  client,
1112
1551
  tools: [],
1113
1552
  prompts: [],
1114
1553
  resources: [],
1115
- meta: {
1116
- title: info?.title,
1117
- url: info?.websiteUrl,
1118
- ...requestMeta(req.url)
1119
- },
1554
+ meta,
1120
1555
  connectedAt: Date.now()
1121
1556
  };
1122
1557
  providers.set(id, provider);
@@ -1178,12 +1613,15 @@ async function createBridge(opts = {}) {
1178
1613
  notifyChanged("tools");
1179
1614
  notifyChanged("prompts");
1180
1615
  notifyChanged("resources");
1616
+ checkIdle();
1181
1617
  }
1182
1618
  };
1183
1619
  client.onclose = cleanup;
1184
1620
  ws.on("close", cleanup);
1621
+ checkIdle();
1185
1622
  await Promise.all([refreshTools(), refreshPrompts(), refreshResources()]);
1186
1623
  });
1624
+ checkIdle();
1187
1625
  return {
1188
1626
  server,
1189
1627
  wss,
@@ -1192,21 +1630,7 @@ async function createBridge(opts = {}) {
1192
1630
  return [...providers.values()].map(({ client: _client, ...rest }) => rest);
1193
1631
  },
1194
1632
  async close() {
1195
- for (const p of providers.values()) {
1196
- try {
1197
- await p.client.close();
1198
- } catch {
1199
- }
1200
- }
1201
- await new Promise((resolve) => wss.close(() => resolve()));
1202
- await new Promise((resolve) => httpServer.close(() => resolve()));
1203
- for (const agentServer of [...agentServers]) {
1204
- agentServers.delete(agentServer);
1205
- try {
1206
- await agentServer.close();
1207
- } catch {
1208
- }
1209
- }
1633
+ await closeBridge();
1210
1634
  }
1211
1635
  };
1212
1636
  }
@@ -1214,5 +1638,9 @@ async function createBridge(opts = {}) {
1214
1638
  export {
1215
1639
  MCP_PAGE_BRIDGE_VERSION,
1216
1640
  DEFAULT_PORT,
1641
+ DASHBOARD_HEADER,
1642
+ DASHBOARD_HEADER_VALUE,
1643
+ TOKEN_HEADER,
1644
+ SERVICE_ID,
1217
1645
  createBridge
1218
1646
  };
package/dist/cli.d.ts CHANGED
@@ -1,2 +1,18 @@
1
+ type BridgeProbe = {
2
+ status: "none";
3
+ } | {
4
+ status: "foreign";
5
+ } | {
6
+ status: "bridge";
7
+ requiresToken: boolean;
8
+ };
9
+ /** Identify what (if anything) is listening on the bridge HTTP port. */
10
+ declare function probeBridge(port: number): Promise<BridgeProbe>;
11
+ declare function parsePort(argv: string[]): number;
12
+ /**
13
+ * Verify that a bridge already running on `port` is compatible with this agent's
14
+ * token before we attach. Throws a clear, actionable error on mismatch.
15
+ */
16
+ declare function assertCompatibleToken(port: number, token: string | undefined, probe: BridgeProbe): Promise<void>;
1
17
 
2
- export { }
18
+ export { assertCompatibleToken, parsePort, probeBridge };
package/dist/cli.js CHANGED
@@ -1,13 +1,101 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ DASHBOARD_HEADER,
4
+ DASHBOARD_HEADER_VALUE,
3
5
  DEFAULT_PORT,
4
6
  MCP_PAGE_BRIDGE_VERSION,
7
+ SERVICE_ID,
8
+ TOKEN_HEADER,
5
9
  createBridge
6
- } from "./chunk-RRWNJUKA.js";
10
+ } from "./chunk-EQUJLLID.js";
7
11
 
8
12
  // src/cli.ts
9
13
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
10
14
  import { WebSocketClientTransport } from "@modelcontextprotocol/sdk/client/websocket.js";
15
+ import { spawn } from "child_process";
16
+ import { readFile, rm, writeFile } from "fs/promises";
17
+ import { tmpdir } from "os";
18
+ import { join } from "path";
19
+ import { setTimeout as delay } from "timers/promises";
20
+ import { fileURLToPath, pathToFileURL } from "url";
21
+ var DAEMON_FLAG = "--daemon";
22
+ var DAEMON_READY_TIMEOUT_MS = 5e3;
23
+ var DAEMON_READY_INTERVAL_MS = 100;
24
+ function pidFilePath(port) {
25
+ return process.env.MCP_PAGE_BRIDGE_DAEMON_PID_FILE ?? join(tmpdir(), `mcp-page-bridge-${port}.pid`);
26
+ }
27
+ async function writePidFile(path) {
28
+ try {
29
+ await writeFile(path, `${process.pid}
30
+ `);
31
+ } catch (error) {
32
+ console.error(`[mcp-page-bridge] warning: failed to write pid file: ${error.message}`);
33
+ }
34
+ }
35
+ async function removePidFile(path) {
36
+ await rm(path, { force: true }).catch(() => {
37
+ });
38
+ }
39
+ async function readPidFile(port) {
40
+ try {
41
+ const pid = Number((await readFile(pidFilePath(port), "utf8")).trim());
42
+ return Number.isInteger(pid) && pid > 0 ? pid : void 0;
43
+ } catch {
44
+ return void 0;
45
+ }
46
+ }
47
+ function parseIdleTimeoutMs(argv) {
48
+ const raw = parseFlag(argv, "--idle-timeout") ?? process.env.MCP_PAGE_BRIDGE_IDLE_TIMEOUT;
49
+ if (raw === void 0) return 0;
50
+ const n = Number(raw);
51
+ if (!Number.isFinite(n) || n < 0) {
52
+ throw new Error(`invalid --idle-timeout "${raw}": expected a non-negative number of seconds`);
53
+ }
54
+ return Math.round(n * 1e3);
55
+ }
56
+ async function fetchWithTimeout(url, init = {}, ms = 500) {
57
+ const controller = new AbortController();
58
+ const timer = setTimeout(() => controller.abort(), ms);
59
+ try {
60
+ return await fetch(url, { ...init, signal: controller.signal });
61
+ } finally {
62
+ clearTimeout(timer);
63
+ }
64
+ }
65
+ async function probeBridge(port) {
66
+ try {
67
+ const res = await fetchWithTimeout(`http://127.0.0.1:${port}/api/health`);
68
+ if (res.ok) {
69
+ const body = await res.json().catch(() => ({}));
70
+ if (body.service === SERVICE_ID) {
71
+ return { status: "bridge", requiresToken: !!body.requiresToken };
72
+ }
73
+ return { status: "foreign" };
74
+ }
75
+ if (res.status === 404) {
76
+ const legacy = await fetchWithTimeout(`http://127.0.0.1:${port}/api/providers`);
77
+ if (legacy.status === 401) return { status: "bridge", requiresToken: true };
78
+ if (legacy.ok) {
79
+ const body = await legacy.json().catch(() => ({}));
80
+ if (Array.isArray(body.providers)) return { status: "bridge", requiresToken: false };
81
+ }
82
+ return { status: "foreign" };
83
+ }
84
+ return { status: "foreign" };
85
+ } catch {
86
+ return { status: "none" };
87
+ }
88
+ }
89
+ async function tokenAccepted(port, token) {
90
+ try {
91
+ const res = await fetchWithTimeout(`http://127.0.0.1:${port}/api/providers`, {
92
+ headers: { [TOKEN_HEADER]: token }
93
+ });
94
+ return res.ok;
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
11
99
  function parseFlag(argv, flag) {
12
100
  const idx = argv.indexOf(flag);
13
101
  if (idx >= 0 && argv[idx + 1]) return argv[idx + 1];
@@ -15,11 +103,109 @@ function parseFlag(argv, flag) {
15
103
  }
16
104
  function parsePort(argv) {
17
105
  const fromFlag = parseFlag(argv, "--port") ?? process.env.MCP_PAGE_BRIDGE_PORT;
106
+ if (fromFlag === void 0) return DEFAULT_PORT;
18
107
  const n = Number(fromFlag);
19
- return Number.isFinite(n) && n > 0 ? n : DEFAULT_PORT;
108
+ if (!Number.isInteger(n) || n < 1 || n > 65535) {
109
+ throw new Error(`invalid --port "${fromFlag}": expected an integer in 1..65535`);
110
+ }
111
+ return n;
112
+ }
113
+ function hasFlag(argv, flag) {
114
+ return argv.includes(flag);
115
+ }
116
+ function daemonExecArgv() {
117
+ return process.execArgv.filter((arg) => !arg.startsWith("--inspect") && !arg.startsWith("--debug"));
118
+ }
119
+ async function bridgeReady(port) {
120
+ return (await probeBridge(port)).status === "bridge";
121
+ }
122
+ async function assertCompatibleToken(port, token, probe) {
123
+ if (probe.status !== "bridge") return;
124
+ if (probe.requiresToken) {
125
+ if (!token) {
126
+ throw new Error(
127
+ `a bridge is already running on port ${port} and requires a token; pass --token <secret> (or set MCP_PAGE_BRIDGE_TOKEN) to attach`
128
+ );
129
+ }
130
+ if (!await tokenAccepted(port, token)) {
131
+ throw new Error(
132
+ `a bridge is already running on port ${port} but rejected the provided token; every agent on this port must use the same --token`
133
+ );
134
+ }
135
+ } else if (token) {
136
+ console.error(
137
+ `[mcp-page-bridge] note: a tokenless bridge is already running on port ${port}; the provided token is ignored for this attach`
138
+ );
139
+ }
140
+ }
141
+ async function startDaemon(port, token, idleTimeoutMs) {
142
+ const pidFile = pidFilePath(port);
143
+ const bridge = await createBridge({
144
+ port,
145
+ token,
146
+ idleTimeoutMs,
147
+ onIdleShutdown: () => {
148
+ console.error(`[mcp-page-bridge] idle for ${idleTimeoutMs}ms with no agents/providers; shutting down`);
149
+ void removePidFile(pidFile).finally(() => process.exit(0));
150
+ }
151
+ });
152
+ await writePidFile(pidFile);
153
+ console.error(
154
+ `[mcp-page-bridge] daemon v${MCP_PAGE_BRIDGE_VERSION} \u2014 ws://127.0.0.1:${bridge.port} \xB7 dashboard http://127.0.0.1:${bridge.port}/` + (token ? " (token required)" : "") + (idleTimeoutMs > 0 ? ` \xB7 idle-timeout ${idleTimeoutMs / 1e3}s` : "")
155
+ );
156
+ const shutdown = async () => {
157
+ await bridge.close();
158
+ await removePidFile(pidFile);
159
+ process.exit(0);
160
+ };
161
+ process.on("SIGINT", shutdown);
162
+ process.on("SIGTERM", shutdown);
20
163
  }
21
- function isAddrInUse(error) {
22
- return !!error && typeof error === "object" && error.code === "EADDRINUSE";
164
+ async function ensureDaemon(port, token, idleTimeoutMs) {
165
+ const existing = await probeBridge(port);
166
+ if (existing.status === "bridge") {
167
+ await assertCompatibleToken(port, token, existing);
168
+ return;
169
+ }
170
+ if (existing.status === "foreign") {
171
+ throw new Error(
172
+ `port ${port} is in use by a non-mcp-page-bridge server; choose another port with --port <n>`
173
+ );
174
+ }
175
+ const script = fileURLToPath(import.meta.url);
176
+ const args = [...daemonExecArgv(), script, DAEMON_FLAG, "--port", String(port)];
177
+ if (idleTimeoutMs > 0) args.push("--idle-timeout", String(idleTimeoutMs / 1e3));
178
+ const env = { ...process.env, MCP_PAGE_BRIDGE_PORT: String(port) };
179
+ if (token) env.MCP_PAGE_BRIDGE_TOKEN = token;
180
+ let spawnError;
181
+ let exit;
182
+ const child = spawn(process.execPath, args, {
183
+ detached: true,
184
+ env,
185
+ stdio: "ignore"
186
+ });
187
+ child.once("error", (error) => {
188
+ spawnError = error;
189
+ });
190
+ child.once("exit", (code, signal) => {
191
+ exit = { code, signal };
192
+ });
193
+ child.unref();
194
+ const deadline = Date.now() + DAEMON_READY_TIMEOUT_MS;
195
+ while (Date.now() < deadline) {
196
+ if (spawnError) throw spawnError;
197
+ if (exit) {
198
+ throw new Error(
199
+ `bridge daemon exited before it was ready` + (exit.signal ? ` (signal ${exit.signal})` : ` (code ${exit.code ?? "unknown"})`)
200
+ );
201
+ }
202
+ if (await bridgeReady(port)) {
203
+ console.error(`[mcp-page-bridge] started background bridge daemon on port ${port}`);
204
+ return;
205
+ }
206
+ await delay(DAEMON_READY_INTERVAL_MS);
207
+ }
208
+ throw new Error(`timed out waiting for bridge daemon on port ${port}`);
23
209
  }
24
210
  async function connectProxy(port, token) {
25
211
  const url = new URL(`ws://127.0.0.1:${port}/agent`);
@@ -53,35 +239,67 @@ async function connectProxy(port, token) {
53
239
  process.on("SIGINT", shutdown);
54
240
  process.on("SIGTERM", shutdown);
55
241
  }
242
+ async function stopDaemon(port, token) {
243
+ const probe = await probeBridge(port);
244
+ if (probe.status === "none") {
245
+ console.error(`[mcp-page-bridge] no bridge is running on port ${port}`);
246
+ return;
247
+ }
248
+ if (probe.status === "foreign") {
249
+ throw new Error(`port ${port} is held by a non-mcp-page-bridge server; refusing to stop it`);
250
+ }
251
+ if (probe.requiresToken && !token) {
252
+ throw new Error(`the bridge on port ${port} requires a token; pass --token <secret> to stop it`);
253
+ }
254
+ const headers = { [DASHBOARD_HEADER]: DASHBOARD_HEADER_VALUE };
255
+ if (token) headers[TOKEN_HEADER] = token;
256
+ try {
257
+ const res = await fetchWithTimeout(
258
+ `http://127.0.0.1:${port}/api/shutdown`,
259
+ { method: "POST", headers },
260
+ 2e3
261
+ );
262
+ if (!res.ok) throw new Error(`shutdown endpoint returned ${res.status}`);
263
+ console.error(`[mcp-page-bridge] shutdown requested on port ${port}`);
264
+ return;
265
+ } catch (error) {
266
+ const pid = await readPidFile(port);
267
+ if (pid) {
268
+ try {
269
+ process.kill(pid, "SIGTERM");
270
+ console.error(`[mcp-page-bridge] sent SIGTERM to daemon pid ${pid} on port ${port}`);
271
+ return;
272
+ } catch {
273
+ }
274
+ }
275
+ throw error;
276
+ }
277
+ }
56
278
  async function main() {
57
279
  const argv = process.argv.slice(2);
280
+ const command = argv[0] && !argv[0].startsWith("-") ? argv[0] : void 0;
58
281
  const port = parsePort(argv);
59
282
  const token = parseFlag(argv, "--token") ?? process.env.MCP_PAGE_BRIDGE_TOKEN;
60
- let bridge;
61
- try {
62
- bridge = await createBridge({ port, token });
63
- } catch (error) {
64
- if (!isAddrInUse(error)) throw error;
65
- console.error(
66
- `[mcp-page-bridge] port ${port} is already in use; attaching this agent to the existing bridge`
67
- );
68
- await connectProxy(port, token);
283
+ if (command === "stop") {
284
+ await stopDaemon(port, token);
69
285
  return;
70
286
  }
71
- console.error(
72
- `[mcp-page-bridge] v${MCP_PAGE_BRIDGE_VERSION} \u2014 ws://127.0.0.1:${bridge.port} \xB7 dashboard http://127.0.0.1:${bridge.port}/` + (token ? " (token required)" : "")
73
- );
74
- const transport = new StdioServerTransport();
75
- await bridge.server.connect(transport);
76
- console.error("[mcp-page-bridge] MCP stdio server ready (waiting for agent + browser connections)");
77
- const shutdown = async () => {
78
- await bridge.close();
79
- process.exit(0);
80
- };
81
- process.on("SIGINT", shutdown);
82
- process.on("SIGTERM", shutdown);
287
+ if (hasFlag(argv, DAEMON_FLAG)) {
288
+ await startDaemon(port, token, parseIdleTimeoutMs(argv));
289
+ return;
290
+ }
291
+ await ensureDaemon(port, token, parseIdleTimeoutMs(argv));
292
+ await connectProxy(port, token);
293
+ }
294
+ var invokedDirectly = !!process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
295
+ if (invokedDirectly) {
296
+ main().catch((error) => {
297
+ console.error("[mcp-page-bridge] fatal:", error);
298
+ process.exit(1);
299
+ });
83
300
  }
84
- main().catch((error) => {
85
- console.error("[mcp-page-bridge] fatal:", error);
86
- process.exit(1);
87
- });
301
+ export {
302
+ assertCompatibleToken,
303
+ parsePort,
304
+ probeBridge
305
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-page-bridge",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "MCP bridge that aggregates live browser-page MCP servers and exposes them to a coding agent over stdio",
6
6
  "license": "MIT",
@@ -47,7 +47,7 @@
47
47
  "typescript": "^5.7.3",
48
48
  "vitest": "^2.1.8",
49
49
  "zod": "^3.25.0",
50
- "mcp-page-bridge-protocol": "0.1.4"
50
+ "mcp-page-bridge-protocol": "0.1.5"
51
51
  },
52
52
  "scripts": {
53
53
  "start": "tsx src/cli.ts",