mcp-page-bridge 0.1.4 → 0.1.6
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 +7 -0
- package/dist/bridge.js +1 -1
- package/dist/{chunk-RRWNJUKA.js → chunk-XUK2DGX4.js} +507 -79
- package/dist/cli.d.ts +18 -1
- package/dist/cli.js +256 -29
- package/package.json +2 -2
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
|
@@ -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.
|
|
24
|
+
var MCP_PAGE_BRIDGE_VERSION = "0.1.6";
|
|
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
|
-
|
|
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="
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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
|
-
|
|
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
|
-
|
|
613
|
+
highlight(local) +
|
|
495
614
|
'</span><span class="full">' +
|
|
496
615
|
esc(key) +
|
|
497
616
|
"</span></span>" +
|
|
498
617
|
'<span class="ds">' +
|
|
499
|
-
|
|
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: {
|
|
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
|
-
|
|
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.
|
|
957
|
+
if (!this.started || !this._onmessage) {
|
|
747
958
|
this.buffer.push(message);
|
|
748
959
|
return;
|
|
749
960
|
}
|
|
750
|
-
this.
|
|
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.
|
|
989
|
+
for (const message of pending) this._onmessage(message);
|
|
767
990
|
}
|
|
768
991
|
async send(message) {
|
|
769
|
-
this.socket.
|
|
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
|
-
|
|
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
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1034
|
-
"
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
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
|
-
|
|
1057
|
-
|
|
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:
|
|
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
|
-
|
|
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,19 @@
|
|
|
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>;
|
|
17
|
+
declare function isDirectInvocation(entry?: string | undefined, moduleUrl?: string): boolean;
|
|
1
18
|
|
|
2
|
-
export {
|
|
19
|
+
export { assertCompatibleToken, isDirectInvocation, parsePort, probeBridge };
|
package/dist/cli.js
CHANGED
|
@@ -1,13 +1,102 @@
|
|
|
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-
|
|
10
|
+
} from "./chunk-XUK2DGX4.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 { realpathSync } from "fs";
|
|
17
|
+
import { readFile, rm, writeFile } from "fs/promises";
|
|
18
|
+
import { tmpdir } from "os";
|
|
19
|
+
import { join } from "path";
|
|
20
|
+
import { setTimeout as delay } from "timers/promises";
|
|
21
|
+
import { fileURLToPath, pathToFileURL } from "url";
|
|
22
|
+
var DAEMON_FLAG = "--daemon";
|
|
23
|
+
var DAEMON_READY_TIMEOUT_MS = 5e3;
|
|
24
|
+
var DAEMON_READY_INTERVAL_MS = 100;
|
|
25
|
+
function pidFilePath(port) {
|
|
26
|
+
return process.env.MCP_PAGE_BRIDGE_DAEMON_PID_FILE ?? join(tmpdir(), `mcp-page-bridge-${port}.pid`);
|
|
27
|
+
}
|
|
28
|
+
async function writePidFile(path) {
|
|
29
|
+
try {
|
|
30
|
+
await writeFile(path, `${process.pid}
|
|
31
|
+
`);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
console.error(`[mcp-page-bridge] warning: failed to write pid file: ${error.message}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async function removePidFile(path) {
|
|
37
|
+
await rm(path, { force: true }).catch(() => {
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
async function readPidFile(port) {
|
|
41
|
+
try {
|
|
42
|
+
const pid = Number((await readFile(pidFilePath(port), "utf8")).trim());
|
|
43
|
+
return Number.isInteger(pid) && pid > 0 ? pid : void 0;
|
|
44
|
+
} catch {
|
|
45
|
+
return void 0;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function parseIdleTimeoutMs(argv) {
|
|
49
|
+
const raw = parseFlag(argv, "--idle-timeout") ?? process.env.MCP_PAGE_BRIDGE_IDLE_TIMEOUT;
|
|
50
|
+
if (raw === void 0) return 0;
|
|
51
|
+
const n = Number(raw);
|
|
52
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
53
|
+
throw new Error(`invalid --idle-timeout "${raw}": expected a non-negative number of seconds`);
|
|
54
|
+
}
|
|
55
|
+
return Math.round(n * 1e3);
|
|
56
|
+
}
|
|
57
|
+
async function fetchWithTimeout(url, init = {}, ms = 500) {
|
|
58
|
+
const controller = new AbortController();
|
|
59
|
+
const timer = setTimeout(() => controller.abort(), ms);
|
|
60
|
+
try {
|
|
61
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
62
|
+
} finally {
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function probeBridge(port) {
|
|
67
|
+
try {
|
|
68
|
+
const res = await fetchWithTimeout(`http://127.0.0.1:${port}/api/health`);
|
|
69
|
+
if (res.ok) {
|
|
70
|
+
const body = await res.json().catch(() => ({}));
|
|
71
|
+
if (body.service === SERVICE_ID) {
|
|
72
|
+
return { status: "bridge", requiresToken: !!body.requiresToken };
|
|
73
|
+
}
|
|
74
|
+
return { status: "foreign" };
|
|
75
|
+
}
|
|
76
|
+
if (res.status === 404) {
|
|
77
|
+
const legacy = await fetchWithTimeout(`http://127.0.0.1:${port}/api/providers`);
|
|
78
|
+
if (legacy.status === 401) return { status: "bridge", requiresToken: true };
|
|
79
|
+
if (legacy.ok) {
|
|
80
|
+
const body = await legacy.json().catch(() => ({}));
|
|
81
|
+
if (Array.isArray(body.providers)) return { status: "bridge", requiresToken: false };
|
|
82
|
+
}
|
|
83
|
+
return { status: "foreign" };
|
|
84
|
+
}
|
|
85
|
+
return { status: "foreign" };
|
|
86
|
+
} catch {
|
|
87
|
+
return { status: "none" };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async function tokenAccepted(port, token) {
|
|
91
|
+
try {
|
|
92
|
+
const res = await fetchWithTimeout(`http://127.0.0.1:${port}/api/providers`, {
|
|
93
|
+
headers: { [TOKEN_HEADER]: token }
|
|
94
|
+
});
|
|
95
|
+
return res.ok;
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
11
100
|
function parseFlag(argv, flag) {
|
|
12
101
|
const idx = argv.indexOf(flag);
|
|
13
102
|
if (idx >= 0 && argv[idx + 1]) return argv[idx + 1];
|
|
@@ -15,11 +104,109 @@ function parseFlag(argv, flag) {
|
|
|
15
104
|
}
|
|
16
105
|
function parsePort(argv) {
|
|
17
106
|
const fromFlag = parseFlag(argv, "--port") ?? process.env.MCP_PAGE_BRIDGE_PORT;
|
|
107
|
+
if (fromFlag === void 0) return DEFAULT_PORT;
|
|
18
108
|
const n = Number(fromFlag);
|
|
19
|
-
|
|
109
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535) {
|
|
110
|
+
throw new Error(`invalid --port "${fromFlag}": expected an integer in 1..65535`);
|
|
111
|
+
}
|
|
112
|
+
return n;
|
|
113
|
+
}
|
|
114
|
+
function hasFlag(argv, flag) {
|
|
115
|
+
return argv.includes(flag);
|
|
20
116
|
}
|
|
21
|
-
function
|
|
22
|
-
return
|
|
117
|
+
function daemonExecArgv() {
|
|
118
|
+
return process.execArgv.filter((arg) => !arg.startsWith("--inspect") && !arg.startsWith("--debug"));
|
|
119
|
+
}
|
|
120
|
+
async function bridgeReady(port) {
|
|
121
|
+
return (await probeBridge(port)).status === "bridge";
|
|
122
|
+
}
|
|
123
|
+
async function assertCompatibleToken(port, token, probe) {
|
|
124
|
+
if (probe.status !== "bridge") return;
|
|
125
|
+
if (probe.requiresToken) {
|
|
126
|
+
if (!token) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`a bridge is already running on port ${port} and requires a token; pass --token <secret> (or set MCP_PAGE_BRIDGE_TOKEN) to attach`
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
if (!await tokenAccepted(port, token)) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`a bridge is already running on port ${port} but rejected the provided token; every agent on this port must use the same --token`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
} else if (token) {
|
|
137
|
+
console.error(
|
|
138
|
+
`[mcp-page-bridge] note: a tokenless bridge is already running on port ${port}; the provided token is ignored for this attach`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async function startDaemon(port, token, idleTimeoutMs) {
|
|
143
|
+
const pidFile = pidFilePath(port);
|
|
144
|
+
const bridge = await createBridge({
|
|
145
|
+
port,
|
|
146
|
+
token,
|
|
147
|
+
idleTimeoutMs,
|
|
148
|
+
onIdleShutdown: () => {
|
|
149
|
+
console.error(`[mcp-page-bridge] idle for ${idleTimeoutMs}ms with no agents/providers; shutting down`);
|
|
150
|
+
void removePidFile(pidFile).finally(() => process.exit(0));
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
await writePidFile(pidFile);
|
|
154
|
+
console.error(
|
|
155
|
+
`[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` : "")
|
|
156
|
+
);
|
|
157
|
+
const shutdown = async () => {
|
|
158
|
+
await bridge.close();
|
|
159
|
+
await removePidFile(pidFile);
|
|
160
|
+
process.exit(0);
|
|
161
|
+
};
|
|
162
|
+
process.on("SIGINT", shutdown);
|
|
163
|
+
process.on("SIGTERM", shutdown);
|
|
164
|
+
}
|
|
165
|
+
async function ensureDaemon(port, token, idleTimeoutMs) {
|
|
166
|
+
const existing = await probeBridge(port);
|
|
167
|
+
if (existing.status === "bridge") {
|
|
168
|
+
await assertCompatibleToken(port, token, existing);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (existing.status === "foreign") {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`port ${port} is in use by a non-mcp-page-bridge server; choose another port with --port <n>`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
const script = fileURLToPath(import.meta.url);
|
|
177
|
+
const args = [...daemonExecArgv(), script, DAEMON_FLAG, "--port", String(port)];
|
|
178
|
+
if (idleTimeoutMs > 0) args.push("--idle-timeout", String(idleTimeoutMs / 1e3));
|
|
179
|
+
const env = { ...process.env, MCP_PAGE_BRIDGE_PORT: String(port) };
|
|
180
|
+
if (token) env.MCP_PAGE_BRIDGE_TOKEN = token;
|
|
181
|
+
let spawnError;
|
|
182
|
+
let exit;
|
|
183
|
+
const child = spawn(process.execPath, args, {
|
|
184
|
+
detached: true,
|
|
185
|
+
env,
|
|
186
|
+
stdio: "ignore"
|
|
187
|
+
});
|
|
188
|
+
child.once("error", (error) => {
|
|
189
|
+
spawnError = error;
|
|
190
|
+
});
|
|
191
|
+
child.once("exit", (code, signal) => {
|
|
192
|
+
exit = { code, signal };
|
|
193
|
+
});
|
|
194
|
+
child.unref();
|
|
195
|
+
const deadline = Date.now() + DAEMON_READY_TIMEOUT_MS;
|
|
196
|
+
while (Date.now() < deadline) {
|
|
197
|
+
if (spawnError) throw spawnError;
|
|
198
|
+
if (exit) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`bridge daemon exited before it was ready` + (exit.signal ? ` (signal ${exit.signal})` : ` (code ${exit.code ?? "unknown"})`)
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
if (await bridgeReady(port)) {
|
|
204
|
+
console.error(`[mcp-page-bridge] started background bridge daemon on port ${port}`);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
await delay(DAEMON_READY_INTERVAL_MS);
|
|
208
|
+
}
|
|
209
|
+
throw new Error(`timed out waiting for bridge daemon on port ${port}`);
|
|
23
210
|
}
|
|
24
211
|
async function connectProxy(port, token) {
|
|
25
212
|
const url = new URL(`ws://127.0.0.1:${port}/agent`);
|
|
@@ -53,35 +240,75 @@ async function connectProxy(port, token) {
|
|
|
53
240
|
process.on("SIGINT", shutdown);
|
|
54
241
|
process.on("SIGTERM", shutdown);
|
|
55
242
|
}
|
|
243
|
+
async function stopDaemon(port, token) {
|
|
244
|
+
const probe = await probeBridge(port);
|
|
245
|
+
if (probe.status === "none") {
|
|
246
|
+
console.error(`[mcp-page-bridge] no bridge is running on port ${port}`);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (probe.status === "foreign") {
|
|
250
|
+
throw new Error(`port ${port} is held by a non-mcp-page-bridge server; refusing to stop it`);
|
|
251
|
+
}
|
|
252
|
+
if (probe.requiresToken && !token) {
|
|
253
|
+
throw new Error(`the bridge on port ${port} requires a token; pass --token <secret> to stop it`);
|
|
254
|
+
}
|
|
255
|
+
const headers = { [DASHBOARD_HEADER]: DASHBOARD_HEADER_VALUE };
|
|
256
|
+
if (token) headers[TOKEN_HEADER] = token;
|
|
257
|
+
try {
|
|
258
|
+
const res = await fetchWithTimeout(
|
|
259
|
+
`http://127.0.0.1:${port}/api/shutdown`,
|
|
260
|
+
{ method: "POST", headers },
|
|
261
|
+
2e3
|
|
262
|
+
);
|
|
263
|
+
if (!res.ok) throw new Error(`shutdown endpoint returned ${res.status}`);
|
|
264
|
+
console.error(`[mcp-page-bridge] shutdown requested on port ${port}`);
|
|
265
|
+
return;
|
|
266
|
+
} catch (error) {
|
|
267
|
+
const pid = await readPidFile(port);
|
|
268
|
+
if (pid) {
|
|
269
|
+
try {
|
|
270
|
+
process.kill(pid, "SIGTERM");
|
|
271
|
+
console.error(`[mcp-page-bridge] sent SIGTERM to daemon pid ${pid} on port ${port}`);
|
|
272
|
+
return;
|
|
273
|
+
} catch {
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
56
279
|
async function main() {
|
|
57
280
|
const argv = process.argv.slice(2);
|
|
281
|
+
const command = argv[0] && !argv[0].startsWith("-") ? argv[0] : void 0;
|
|
58
282
|
const port = parsePort(argv);
|
|
59
283
|
const token = parseFlag(argv, "--token") ?? process.env.MCP_PAGE_BRIDGE_TOKEN;
|
|
60
|
-
|
|
61
|
-
|
|
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);
|
|
284
|
+
if (command === "stop") {
|
|
285
|
+
await stopDaemon(port, token);
|
|
69
286
|
return;
|
|
70
287
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
await
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
288
|
+
if (hasFlag(argv, DAEMON_FLAG)) {
|
|
289
|
+
await startDaemon(port, token, parseIdleTimeoutMs(argv));
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
await ensureDaemon(port, token, parseIdleTimeoutMs(argv));
|
|
293
|
+
await connectProxy(port, token);
|
|
294
|
+
}
|
|
295
|
+
function isDirectInvocation(entry = process.argv[1], moduleUrl = import.meta.url) {
|
|
296
|
+
if (!entry) return false;
|
|
297
|
+
try {
|
|
298
|
+
return realpathSync(entry) === realpathSync(fileURLToPath(moduleUrl));
|
|
299
|
+
} catch {
|
|
300
|
+
return moduleUrl === pathToFileURL(entry).href;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (isDirectInvocation()) {
|
|
304
|
+
main().catch((error) => {
|
|
305
|
+
console.error("[mcp-page-bridge] fatal:", error);
|
|
306
|
+
process.exit(1);
|
|
307
|
+
});
|
|
83
308
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
309
|
+
export {
|
|
310
|
+
assertCompatibleToken,
|
|
311
|
+
isDirectInvocation,
|
|
312
|
+
parsePort,
|
|
313
|
+
probeBridge
|
|
314
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-page-bridge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
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.
|
|
50
|
+
"mcp-page-bridge-protocol": "0.1.6"
|
|
51
51
|
},
|
|
52
52
|
"scripts": {
|
|
53
53
|
"start": "tsx src/cli.ts",
|