@mcp-b/webmcp-local-relay 2.0.12 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser/embed.js +1 -480
- package/dist/browser/widget.html +1 -335
- package/dist/browser/widget.js +1 -0
- package/dist/cli.js +7 -3
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +130 -15
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/{mcpRelayServer-DAp7h9b1.js → mcpRelayServer-rbay_DSr.js} +605 -270
- package/dist/mcpRelayServer-rbay_DSr.js.map +1 -0
- package/package.json +6 -4
- package/dist/mcpRelayServer-DAp7h9b1.js.map +0 -1
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { CallToolRequestParamsSchema, CallToolResultSchema, ListToolsRequestSchema, ToolAnnotationsSchema, ToolSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
4
5
|
import { z } from "zod/v4";
|
|
5
6
|
import { randomUUID } from "node:crypto";
|
|
6
7
|
import { EventEmitter } from "node:events";
|
|
7
8
|
import WebSocket, { WebSocketServer } from "ws";
|
|
8
|
-
import {
|
|
9
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { toJsonSchemaCompat } from "@modelcontextprotocol/sdk/server/zod-json-schema-compat.js";
|
|
9
13
|
|
|
10
14
|
//#region src/cli-utils.ts
|
|
11
15
|
/**
|
|
@@ -15,6 +19,7 @@ function parseCliOptions(argv) {
|
|
|
15
19
|
const options = {
|
|
16
20
|
host: "127.0.0.1",
|
|
17
21
|
port: 9333,
|
|
22
|
+
portExplicitlySet: false,
|
|
18
23
|
allowedOrigins: ["*"]
|
|
19
24
|
};
|
|
20
25
|
const readFlagValue = (flag, index) => {
|
|
@@ -36,6 +41,7 @@ function parseCliOptions(argv) {
|
|
|
36
41
|
const value = Number.parseInt(raw, 10);
|
|
37
42
|
if (!Number.isFinite(value) || value <= 0 || value > 65535) throw new Error(`Invalid port "${raw}". Port must be a number between 1 and 65535.`);
|
|
38
43
|
options.port = value;
|
|
44
|
+
options.portExplicitlySet = true;
|
|
39
45
|
continue;
|
|
40
46
|
}
|
|
41
47
|
if (token === "--widget-origin" || token === "--allowed-origin" || token === "--ws-origin") {
|
|
@@ -45,6 +51,21 @@ function parseCliOptions(argv) {
|
|
|
45
51
|
if (split.length > 0) options.allowedOrigins = split;
|
|
46
52
|
continue;
|
|
47
53
|
}
|
|
54
|
+
if (token === "--label") {
|
|
55
|
+
options.label = readFlagValue(token, i);
|
|
56
|
+
i += 1;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (token === "--workspace") {
|
|
60
|
+
options.workspace = readFlagValue(token, i);
|
|
61
|
+
i += 1;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (token === "--relay-id") {
|
|
65
|
+
options.relayId = readFlagValue(token, i);
|
|
66
|
+
i += 1;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
48
69
|
if (token === "--help" || token === "-h") {
|
|
49
70
|
printHelp();
|
|
50
71
|
process.exit(0);
|
|
@@ -66,15 +87,76 @@ function printHelp() {
|
|
|
66
87
|
"",
|
|
67
88
|
"Options:",
|
|
68
89
|
" --host, -H Bind host for local websocket relay (default: 127.0.0.1)",
|
|
69
|
-
" --port, -p
|
|
90
|
+
" --port, -p Preferred root port for the local relay cluster (default: 9333)",
|
|
70
91
|
" --widget-origin Allowed host page origin(s), comma-separated (default: *)",
|
|
71
92
|
" --allowed-origin Alias for --widget-origin",
|
|
72
93
|
" --ws-origin Alias for --widget-origin",
|
|
94
|
+
" --label Human-readable relay label reported during discovery",
|
|
95
|
+
" --workspace Optional workspace name reported during discovery",
|
|
96
|
+
" --relay-id Stable relay identifier reported during discovery",
|
|
73
97
|
" --help, -h Show help",
|
|
74
98
|
""
|
|
75
99
|
].join("\n"));
|
|
76
100
|
}
|
|
77
101
|
|
|
102
|
+
//#endregion
|
|
103
|
+
//#region src/portStrategy.ts
|
|
104
|
+
const DEFAULT_RELAY_PORT = 9333;
|
|
105
|
+
const DEFAULT_RELAY_PORT_RANGE_END = 9348;
|
|
106
|
+
const RELAY_PORT_CACHE_MAX_AGE_MS = 1440 * 60 * 1e3;
|
|
107
|
+
function defaultRelayPortPersistPath() {
|
|
108
|
+
return join(homedir(), ".webmcp", "relay-port.json");
|
|
109
|
+
}
|
|
110
|
+
async function persistPort(port, path = defaultRelayPortPersistPath(), host = "127.0.0.1", now = Date.now()) {
|
|
111
|
+
const payload = {
|
|
112
|
+
port,
|
|
113
|
+
host,
|
|
114
|
+
updatedAt: new Date(now).toISOString()
|
|
115
|
+
};
|
|
116
|
+
await mkdir(dirname(path), { recursive: true });
|
|
117
|
+
await writeFile(path, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
118
|
+
}
|
|
119
|
+
async function readPersistedPort(path = defaultRelayPortPersistPath(), options = {}) {
|
|
120
|
+
try {
|
|
121
|
+
const raw = await readFile(path, "utf8");
|
|
122
|
+
const parsed = JSON.parse(raw);
|
|
123
|
+
if (typeof parsed.port !== "number" || !Number.isInteger(parsed.port) || parsed.port < 1 || parsed.port > 65535) return null;
|
|
124
|
+
if (typeof parsed.host !== "string" || parsed.host.length === 0) return null;
|
|
125
|
+
if (options.expectedHost && parsed.host !== options.expectedHost) return null;
|
|
126
|
+
const maxAgeMs = options.maxAgeMs ?? RELAY_PORT_CACHE_MAX_AGE_MS;
|
|
127
|
+
const now = options.now ?? Date.now();
|
|
128
|
+
const updatedAtMs = typeof parsed.updatedAt === "string" ? Date.parse(parsed.updatedAt) : NaN;
|
|
129
|
+
if (!Number.isFinite(updatedAtMs) || now - updatedAtMs > maxAgeMs) return null;
|
|
130
|
+
return parsed.port;
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
async function buildPortCandidates(options) {
|
|
136
|
+
const { defaultPort, fixedPort, host, persistPath = defaultRelayPortPersistPath(), rangeEnd } = options;
|
|
137
|
+
if (fixedPort !== void 0) return [{
|
|
138
|
+
port: fixedPort,
|
|
139
|
+
wasFixed: true,
|
|
140
|
+
fromCache: false
|
|
141
|
+
}];
|
|
142
|
+
const cachedPort = await readPersistedPort(persistPath, { expectedHost: host });
|
|
143
|
+
const seen = /* @__PURE__ */ new Set();
|
|
144
|
+
const candidates = [];
|
|
145
|
+
const pushCandidate = (port, fromCache) => {
|
|
146
|
+
if (port < 1 || port > 65535 || seen.has(port)) return;
|
|
147
|
+
seen.add(port);
|
|
148
|
+
candidates.push({
|
|
149
|
+
port,
|
|
150
|
+
wasFixed: false,
|
|
151
|
+
fromCache
|
|
152
|
+
});
|
|
153
|
+
};
|
|
154
|
+
if (cachedPort !== null && cachedPort >= defaultPort && cachedPort <= rangeEnd) pushCandidate(cachedPort, true);
|
|
155
|
+
pushCandidate(defaultPort, false);
|
|
156
|
+
for (let port = defaultPort + 1; port <= rangeEnd; port += 1) pushCandidate(port, false);
|
|
157
|
+
return candidates;
|
|
158
|
+
}
|
|
159
|
+
|
|
78
160
|
//#endregion
|
|
79
161
|
//#region src/protocol.ts
|
|
80
162
|
/**
|
|
@@ -447,6 +529,26 @@ const BrowserToRelayMessageSchema = z.discriminatedUnion("type", [
|
|
|
447
529
|
BrowserPongMessageSchema
|
|
448
530
|
]);
|
|
449
531
|
/**
|
|
532
|
+
* Shared relay identity used for discovery and attach verification.
|
|
533
|
+
*/
|
|
534
|
+
const RelayDescriptorSchema = z.object({
|
|
535
|
+
host: z.string().min(1),
|
|
536
|
+
instanceId: z.string().min(1),
|
|
537
|
+
label: z.string().min(1).optional(),
|
|
538
|
+
port: z.number().int().min(1).max(65535),
|
|
539
|
+
relayId: z.string().min(1).optional(),
|
|
540
|
+
workspace: z.string().min(1).optional()
|
|
541
|
+
});
|
|
542
|
+
/**
|
|
543
|
+
* Schema for server hello messages sent immediately after WebSocket connect.
|
|
544
|
+
*/
|
|
545
|
+
const ServerHelloMessageSchema = z.object({
|
|
546
|
+
type: z.literal("server-hello"),
|
|
547
|
+
service: z.literal("webmcp-local-relay"),
|
|
548
|
+
version: z.literal(1),
|
|
549
|
+
...RelayDescriptorSchema.shape
|
|
550
|
+
});
|
|
551
|
+
/**
|
|
450
552
|
* Schema for relay invocation messages sent to browser sources.
|
|
451
553
|
*/
|
|
452
554
|
const RelayInvokeMessageSchema = z.object({
|
|
@@ -467,6 +569,7 @@ const RelayReloadMessageSchema = z.object({ type: z.literal("reload") });
|
|
|
467
569
|
* Union schema for all relay-to-browser protocol messages.
|
|
468
570
|
*/
|
|
469
571
|
const RelayToBrowserMessageSchema = z.discriminatedUnion("type", [
|
|
572
|
+
ServerHelloMessageSchema,
|
|
470
573
|
RelayInvokeMessageSchema,
|
|
471
574
|
RelayPingMessageSchema,
|
|
472
575
|
RelayReloadMessageSchema
|
|
@@ -515,8 +618,8 @@ const RelayClientToServerMessageSchema = z.discriminatedUnion("type", [
|
|
|
515
618
|
]);
|
|
516
619
|
const RelayToolsPayloadFields = {
|
|
517
620
|
tools: z.array(NormalizedToolSchema),
|
|
518
|
-
sources: z.array(RelaySourceInfoSchema),
|
|
519
|
-
toolSourceMap: z.record(z.string(), z.array(z.string()))
|
|
621
|
+
sources: z.array(RelaySourceInfoSchema).optional().default([]),
|
|
622
|
+
toolSourceMap: z.record(z.string(), z.array(z.string())).optional().default({})
|
|
520
623
|
};
|
|
521
624
|
/**
|
|
522
625
|
* Schema for relay server tool list response.
|
|
@@ -544,6 +647,7 @@ const RelayServerToolsChangedSchema = z.object({
|
|
|
544
647
|
* Union schema for all relay-server-to-client messages.
|
|
545
648
|
*/
|
|
546
649
|
const RelayServerToClientMessageSchema = z.discriminatedUnion("type", [
|
|
650
|
+
ServerHelloMessageSchema,
|
|
547
651
|
RelayServerToolsSchema,
|
|
548
652
|
RelayServerResultSchema,
|
|
549
653
|
RelayServerToolsChangedSchema
|
|
@@ -551,6 +655,17 @@ const RelayServerToClientMessageSchema = z.discriminatedUnion("type", [
|
|
|
551
655
|
|
|
552
656
|
//#endregion
|
|
553
657
|
//#region src/bridgeServer.ts
|
|
658
|
+
const RELAY_BROWSER_PROTOCOL = "webmcp.v1";
|
|
659
|
+
const RELAY_DISCOVERY_PROTOCOL = "webmcp-discovery.v1";
|
|
660
|
+
const RELAY_INTERNAL_PROTOCOL = "webmcp-relay.v1";
|
|
661
|
+
const RELAY_SERVER_MESSAGE_TIMEOUT_MS = 750;
|
|
662
|
+
const HEARTBEAT_INTERVAL_MS = 15e3;
|
|
663
|
+
const HEARTBEAT_DEAD_THRESHOLD_MS = 25e3;
|
|
664
|
+
const SUPPORTED_SUBPROTOCOLS = new Set([
|
|
665
|
+
RELAY_BROWSER_PROTOCOL,
|
|
666
|
+
RELAY_DISCOVERY_PROTOCOL,
|
|
667
|
+
RELAY_INTERNAL_PROTOCOL
|
|
668
|
+
]);
|
|
554
669
|
/**
|
|
555
670
|
* WebSocket relay between browser widget frames and MCP server calls.
|
|
556
671
|
*
|
|
@@ -567,14 +682,24 @@ var RelayBridgeServer = class extends EventEmitter {
|
|
|
567
682
|
*/
|
|
568
683
|
registry;
|
|
569
684
|
host;
|
|
685
|
+
preferredPort;
|
|
570
686
|
desiredPort;
|
|
687
|
+
portExplicitlySet;
|
|
688
|
+
portRangeEnd;
|
|
689
|
+
persistPath;
|
|
571
690
|
allowedOrigins;
|
|
572
691
|
maxPayloadBytes;
|
|
573
692
|
invokeTimeoutMs;
|
|
693
|
+
label;
|
|
694
|
+
workspace;
|
|
695
|
+
relayId;
|
|
696
|
+
instanceId;
|
|
574
697
|
wss = null;
|
|
575
698
|
socketByConnectionId = /* @__PURE__ */ new Map();
|
|
576
699
|
pendingInvocations = /* @__PURE__ */ new Map();
|
|
577
700
|
relayClientConnectionIds = /* @__PURE__ */ new Set();
|
|
701
|
+
heartbeatIntervalByConnectionId = /* @__PURE__ */ new Map();
|
|
702
|
+
lastPongByConnectionId = /* @__PURE__ */ new Map();
|
|
578
703
|
onStateChangedPushRelay = () => {
|
|
579
704
|
this.pushToolsToRelayClients();
|
|
580
705
|
};
|
|
@@ -600,11 +725,20 @@ var RelayBridgeServer = class extends EventEmitter {
|
|
|
600
725
|
super();
|
|
601
726
|
this.registry = registry ?? new RelayRegistry();
|
|
602
727
|
this.host = options.host ?? "127.0.0.1";
|
|
603
|
-
this.
|
|
728
|
+
this.preferredPort = options.port ?? DEFAULT_RELAY_PORT;
|
|
729
|
+
this.desiredPort = this.preferredPort;
|
|
730
|
+
this.portExplicitlySet = options.portExplicitlySet ?? false;
|
|
731
|
+
this.portRangeEnd = options.portRangeEnd ?? Math.max(DEFAULT_RELAY_PORT_RANGE_END, this.preferredPort);
|
|
732
|
+
this.persistPath = options.persistPath ?? defaultRelayPortPersistPath();
|
|
604
733
|
this.allowedOrigins = options.allowedOrigins ?? ["*"];
|
|
605
734
|
this.maxPayloadBytes = options.maxPayloadBytes ?? 1e6;
|
|
606
735
|
this.invokeTimeoutMs = options.invokeTimeoutMs ?? 25e3;
|
|
607
|
-
|
|
736
|
+
this.label = options.label;
|
|
737
|
+
this.workspace = options.workspace;
|
|
738
|
+
this.relayId = options.relayId;
|
|
739
|
+
this.instanceId = randomUUID();
|
|
740
|
+
if (this.preferredPort !== 0 && (this.preferredPort < 1 || this.preferredPort > 65535)) throw new Error(`Invalid port ${this.preferredPort}. Port must be 0 (auto-assign) or between 1 and 65535.`);
|
|
741
|
+
if (this.portRangeEnd < this.preferredPort && this.preferredPort !== 0) throw new Error(`Invalid port range ${this.preferredPort}-${this.portRangeEnd}. rangeEnd must be greater than or equal to the preferred port.`);
|
|
608
742
|
if (this.maxPayloadBytes <= 0) throw new Error(`Invalid maxPayloadBytes ${this.maxPayloadBytes}. Must be greater than 0.`);
|
|
609
743
|
if (this.invokeTimeoutMs <= 0) throw new Error(`Invalid invokeTimeoutMs ${this.invokeTimeoutMs}. Must be greater than 0.`);
|
|
610
744
|
}
|
|
@@ -645,19 +779,51 @@ var RelayBridgeServer = class extends EventEmitter {
|
|
|
645
779
|
}
|
|
646
780
|
/**
|
|
647
781
|
* Starts the bridge. Attempts to bind a WebSocket server (server mode).
|
|
648
|
-
* If
|
|
649
|
-
*
|
|
782
|
+
* If a compatible relay already owns a candidate port, joins it in client mode.
|
|
783
|
+
* If a non-relay process owns the port, continues searching the reserved range.
|
|
650
784
|
*/
|
|
651
785
|
async start() {
|
|
652
786
|
if (this.wss || this.clientSocket) return;
|
|
653
787
|
this.stopping = false;
|
|
654
|
-
|
|
655
|
-
|
|
788
|
+
await this.startUsingPortStrategy();
|
|
789
|
+
}
|
|
790
|
+
async startUsingPortStrategy() {
|
|
791
|
+
if (this.preferredPort === 0) {
|
|
792
|
+
await this.startAsServer(0);
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
const candidates = await buildPortCandidates({
|
|
796
|
+
defaultPort: this.preferredPort,
|
|
797
|
+
...this.portExplicitlySet ? { fixedPort: this.preferredPort } : {},
|
|
798
|
+
host: this.host,
|
|
799
|
+
persistPath: this.persistPath,
|
|
800
|
+
rangeEnd: this.portRangeEnd
|
|
801
|
+
});
|
|
802
|
+
for (const candidate of candidates) try {
|
|
803
|
+
await this.startAsServer(candidate.port);
|
|
804
|
+
await persistPort(this.port, this.persistPath, this.host);
|
|
805
|
+
return;
|
|
656
806
|
} catch (err) {
|
|
657
|
-
if (!(err
|
|
658
|
-
this.
|
|
659
|
-
|
|
660
|
-
|
|
807
|
+
if (!this.isAddressInUseError(err)) throw err;
|
|
808
|
+
if (await this.tryAttachToExistingRelay(candidate.port)) {
|
|
809
|
+
await persistPort(this.port, this.persistPath, this.host);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
if (candidate.wasFixed) throw new Error(`Port ${candidate.port} is already in use by a non-WebMCP service and cannot be shared.`);
|
|
813
|
+
process.stderr.write(`[webmcp-local-relay] info: port ${candidate.port} is occupied by a non-relay service, trying next port\n`);
|
|
814
|
+
}
|
|
815
|
+
throw new Error(`No compatible relay port was available in the range ${this.preferredPort}-${this.portRangeEnd}.`);
|
|
816
|
+
}
|
|
817
|
+
isAddressInUseError(error) {
|
|
818
|
+
return error instanceof Error && ("code" in error ? error.code === "EADDRINUSE" : error.message.includes("EADDRINUSE"));
|
|
819
|
+
}
|
|
820
|
+
async tryAttachToExistingRelay(port) {
|
|
821
|
+
try {
|
|
822
|
+
await this.startAsClient(port);
|
|
823
|
+
process.stderr.write(`[webmcp-local-relay] info: discovered compatible relay on port ${port}, switching to client mode\n`);
|
|
824
|
+
return true;
|
|
825
|
+
} catch {
|
|
826
|
+
return false;
|
|
661
827
|
}
|
|
662
828
|
}
|
|
663
829
|
/**
|
|
@@ -675,6 +841,9 @@ var RelayBridgeServer = class extends EventEmitter {
|
|
|
675
841
|
pending.reject(/* @__PURE__ */ new Error("Relay client stopped"));
|
|
676
842
|
}
|
|
677
843
|
this.clientPendingInvocations.clear();
|
|
844
|
+
this.clientTools = [];
|
|
845
|
+
this.clientSources = [];
|
|
846
|
+
this.clientToolSourceMap = {};
|
|
678
847
|
if (this.clientSocket) {
|
|
679
848
|
try {
|
|
680
849
|
this.clientSocket.close(1e3, "Relay client shutting down");
|
|
@@ -691,6 +860,9 @@ var RelayBridgeServer = class extends EventEmitter {
|
|
|
691
860
|
} catch (err) {
|
|
692
861
|
process.stderr.write(`[webmcp-local-relay] warn: error closing socket during shutdown: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
693
862
|
}
|
|
863
|
+
for (const intervalId of this.heartbeatIntervalByConnectionId.values()) clearInterval(intervalId);
|
|
864
|
+
this.heartbeatIntervalByConnectionId.clear();
|
|
865
|
+
this.lastPongByConnectionId.clear();
|
|
694
866
|
this.socketByConnectionId.clear();
|
|
695
867
|
this.relayClientConnectionIds.clear();
|
|
696
868
|
for (const pending of this.pendingInvocations.values()) {
|
|
@@ -731,11 +903,15 @@ var RelayBridgeServer = class extends EventEmitter {
|
|
|
731
903
|
if (this._mode === "client") return this.invokeToolViaRelay(toolName, args);
|
|
732
904
|
return this.invokeToolLocally(toolName, args, options);
|
|
733
905
|
}
|
|
734
|
-
async startAsServer() {
|
|
906
|
+
async startAsServer(port = this.desiredPort) {
|
|
735
907
|
const wss = await new Promise((resolve, reject) => {
|
|
736
908
|
const server = new WebSocketServer({
|
|
909
|
+
handleProtocols: (protocols) => {
|
|
910
|
+
for (const protocol of protocols) if (SUPPORTED_SUBPROTOCOLS.has(protocol)) return protocol;
|
|
911
|
+
return false;
|
|
912
|
+
},
|
|
737
913
|
host: this.host,
|
|
738
|
-
port
|
|
914
|
+
port,
|
|
739
915
|
maxPayload: this.maxPayloadBytes
|
|
740
916
|
});
|
|
741
917
|
const onListening = () => {
|
|
@@ -752,6 +928,13 @@ var RelayBridgeServer = class extends EventEmitter {
|
|
|
752
928
|
wss.on("connection", (socket) => {
|
|
753
929
|
const connectionId = randomUUID();
|
|
754
930
|
this.socketByConnectionId.set(connectionId, socket);
|
|
931
|
+
try {
|
|
932
|
+
socket.send(JSON.stringify(this.buildServerHello()));
|
|
933
|
+
} catch (err) {
|
|
934
|
+
process.stderr.write(`[webmcp-local-relay] warn: failed to send server hello to connection ${connectionId}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
935
|
+
socket.close(1011, "Failed to send server hello");
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
755
938
|
socket.on("message", (raw) => {
|
|
756
939
|
this.onSocketMessage(connectionId, raw);
|
|
757
940
|
});
|
|
@@ -762,6 +945,7 @@ var RelayBridgeServer = class extends EventEmitter {
|
|
|
762
945
|
process.stderr.write(`[webmcp-local-relay] warn: socket error for connection ${connectionId}: ${err.message}\n`);
|
|
763
946
|
this.onSocketClose(connectionId);
|
|
764
947
|
});
|
|
948
|
+
this.startHeartbeat(connectionId);
|
|
765
949
|
});
|
|
766
950
|
wss.on("error", (err) => {
|
|
767
951
|
process.stderr.write(`[webmcp-local-relay] error: WebSocket server error: ${err.message}\n`);
|
|
@@ -773,6 +957,19 @@ var RelayBridgeServer = class extends EventEmitter {
|
|
|
773
957
|
if (address && typeof address !== "string") this.desiredPort = address.port;
|
|
774
958
|
this.on("stateChanged", this.onStateChangedPushRelay);
|
|
775
959
|
}
|
|
960
|
+
buildServerHello() {
|
|
961
|
+
return {
|
|
962
|
+
type: "server-hello",
|
|
963
|
+
service: "webmcp-local-relay",
|
|
964
|
+
version: 1,
|
|
965
|
+
host: this.host,
|
|
966
|
+
instanceId: this.instanceId,
|
|
967
|
+
label: this.label,
|
|
968
|
+
port: this.desiredPort,
|
|
969
|
+
relayId: this.relayId ?? this.instanceId,
|
|
970
|
+
workspace: this.workspace
|
|
971
|
+
};
|
|
972
|
+
}
|
|
776
973
|
invokeToolLocally(toolName, args, options) {
|
|
777
974
|
const resolveOptions = { toolName };
|
|
778
975
|
if (options.sourceId !== void 0) resolveOptions.sourceId = options.sourceId;
|
|
@@ -877,7 +1074,9 @@ var RelayBridgeServer = class extends EventEmitter {
|
|
|
877
1074
|
pending.resolve(this.normalizeCallToolResult(message.result));
|
|
878
1075
|
break;
|
|
879
1076
|
}
|
|
880
|
-
case "pong":
|
|
1077
|
+
case "pong":
|
|
1078
|
+
this.lastPongByConnectionId.set(connectionId, Date.now());
|
|
1079
|
+
break;
|
|
881
1080
|
}
|
|
882
1081
|
}
|
|
883
1082
|
/**
|
|
@@ -959,6 +1158,7 @@ var RelayBridgeServer = class extends EventEmitter {
|
|
|
959
1158
|
* Handles source disconnection and rejects in-flight calls owned by that source.
|
|
960
1159
|
*/
|
|
961
1160
|
onSocketClose(connectionId) {
|
|
1161
|
+
this.stopHeartbeat(connectionId);
|
|
962
1162
|
this.relayClientConnectionIds.delete(connectionId);
|
|
963
1163
|
this.registry.removeConnection(connectionId);
|
|
964
1164
|
this.socketByConnectionId.delete(connectionId);
|
|
@@ -970,72 +1170,126 @@ var RelayBridgeServer = class extends EventEmitter {
|
|
|
970
1170
|
pending.reject(/* @__PURE__ */ new Error(`Tool source ${connectionId} disconnected during invocation`));
|
|
971
1171
|
}
|
|
972
1172
|
}
|
|
973
|
-
|
|
1173
|
+
startHeartbeat(connectionId) {
|
|
1174
|
+
this.lastPongByConnectionId.set(connectionId, Date.now());
|
|
1175
|
+
const intervalId = setInterval(() => {
|
|
1176
|
+
const socket = this.socketByConnectionId.get(connectionId);
|
|
1177
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
|
1178
|
+
this.stopHeartbeat(connectionId);
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
if (this.relayClientConnectionIds.has(connectionId)) return;
|
|
1182
|
+
const lastPong = this.lastPongByConnectionId.get(connectionId) ?? 0;
|
|
1183
|
+
if (Date.now() - lastPong > HEARTBEAT_DEAD_THRESHOLD_MS) {
|
|
1184
|
+
process.stderr.write(`[webmcp-local-relay] warn: connection ${connectionId} missed heartbeat, closing\n`);
|
|
1185
|
+
this.stopHeartbeat(connectionId);
|
|
1186
|
+
socket.close(1001, "Heartbeat timeout");
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
try {
|
|
1190
|
+
socket.send(JSON.stringify({ type: "ping" }));
|
|
1191
|
+
} catch (err) {
|
|
1192
|
+
process.stderr.write(`[webmcp-local-relay] warn: failed to send heartbeat ping to ${connectionId}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1193
|
+
}
|
|
1194
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
1195
|
+
this.heartbeatIntervalByConnectionId.set(connectionId, intervalId);
|
|
1196
|
+
}
|
|
1197
|
+
stopHeartbeat(connectionId) {
|
|
1198
|
+
const intervalId = this.heartbeatIntervalByConnectionId.get(connectionId);
|
|
1199
|
+
if (intervalId !== void 0) {
|
|
1200
|
+
clearInterval(intervalId);
|
|
1201
|
+
this.heartbeatIntervalByConnectionId.delete(connectionId);
|
|
1202
|
+
}
|
|
1203
|
+
this.lastPongByConnectionId.delete(connectionId);
|
|
1204
|
+
}
|
|
1205
|
+
async startAsClient(port = this.desiredPort) {
|
|
1206
|
+
this.stopping = false;
|
|
1207
|
+
const previousMode = this._mode;
|
|
1208
|
+
const previousPort = this.desiredPort;
|
|
1209
|
+
this._mode = "client";
|
|
1210
|
+
this.desiredPort = port;
|
|
1211
|
+
try {
|
|
1212
|
+
const { bufferedMessages, socket } = await this.connectToRelayServer(port);
|
|
1213
|
+
this.clientSocket = socket;
|
|
1214
|
+
this.clientReconnectDelay = 500;
|
|
1215
|
+
this.clientReconnectAttempts = 0;
|
|
1216
|
+
this.setupClientHandlers(socket, bufferedMessages);
|
|
1217
|
+
} catch (error) {
|
|
1218
|
+
this._mode = previousMode;
|
|
1219
|
+
this.desiredPort = previousPort;
|
|
1220
|
+
throw error;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
async connectToRelayServer(port) {
|
|
974
1224
|
this.stopping = false;
|
|
975
1225
|
return new Promise((resolve, reject) => {
|
|
976
|
-
const
|
|
1226
|
+
const wsUrl = `ws://${this.host}:${port}`;
|
|
1227
|
+
const ws = new WebSocket(wsUrl, [RELAY_INTERNAL_PROTOCOL, RELAY_BROWSER_PROTOCOL]);
|
|
1228
|
+
const bufferedMessages = [];
|
|
1229
|
+
const cleanup = () => {
|
|
1230
|
+
clearTimeout(timeoutId);
|
|
1231
|
+
ws.off("close", onCloseBeforeReady);
|
|
1232
|
+
ws.off("error", onErrorBeforeReady);
|
|
1233
|
+
ws.off("message", onMessageBeforeReady);
|
|
1234
|
+
ws.off("open", onOpen);
|
|
1235
|
+
};
|
|
1236
|
+
const rejectWith = (error) => {
|
|
1237
|
+
ws.once("error", () => {});
|
|
1238
|
+
cleanup();
|
|
1239
|
+
reject(error);
|
|
1240
|
+
};
|
|
1241
|
+
const finish = () => {
|
|
1242
|
+
cleanup();
|
|
1243
|
+
resolve({
|
|
1244
|
+
bufferedMessages,
|
|
1245
|
+
socket: ws
|
|
1246
|
+
});
|
|
1247
|
+
};
|
|
977
1248
|
const onOpen = () => {
|
|
978
|
-
ws.off("error", onFirstError);
|
|
979
|
-
this.clientSocket = ws;
|
|
980
|
-
this.clientReconnectDelay = 500;
|
|
981
1249
|
try {
|
|
982
|
-
|
|
983
|
-
ws.send(JSON.stringify({ type: "relay/list-tools" }));
|
|
1250
|
+
this.sendRelayClientHandshake(ws);
|
|
984
1251
|
} catch (err) {
|
|
985
|
-
|
|
986
|
-
this.clientSocket = null;
|
|
987
|
-
ws.close();
|
|
988
|
-
reject(/* @__PURE__ */ new Error("Failed to send handshake to relay server"));
|
|
989
|
-
return;
|
|
1252
|
+
rejectWith(/* @__PURE__ */ new Error(`Failed to send handshake to relay server at ${wsUrl}: ${err instanceof Error ? err.message : String(err)}`));
|
|
990
1253
|
}
|
|
991
|
-
this.setupClientHandlers(ws);
|
|
992
|
-
resolve();
|
|
993
1254
|
};
|
|
994
|
-
const
|
|
995
|
-
|
|
996
|
-
|
|
1255
|
+
const onErrorBeforeReady = (err) => {
|
|
1256
|
+
rejectWith(/* @__PURE__ */ new Error(`Failed to connect to relay server at ${wsUrl}: ${err.message}`));
|
|
1257
|
+
};
|
|
1258
|
+
const onCloseBeforeReady = () => {
|
|
1259
|
+
rejectWith(/* @__PURE__ */ new Error(`Connection to ${wsUrl} closed before relay verification completed`));
|
|
1260
|
+
};
|
|
1261
|
+
const onMessageBeforeReady = (raw) => {
|
|
1262
|
+
const message = this.parseRelayServerMessage(raw);
|
|
1263
|
+
if (!message) {
|
|
1264
|
+
rejectWith(/* @__PURE__ */ new Error(`Received a non-relay response while probing ${wsUrl}`));
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
bufferedMessages.push(message);
|
|
1268
|
+
if (message.type === "server-hello") {
|
|
1269
|
+
if (message.service !== "webmcp-local-relay") {
|
|
1270
|
+
rejectWith(/* @__PURE__ */ new Error(`Unexpected relay service "${message.service}" at ${wsUrl}`));
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
finish();
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
if (message.type === "relay/tools" || message.type === "relay/tools-changed") finish();
|
|
997
1277
|
};
|
|
1278
|
+
const timeoutId = setTimeout(() => {
|
|
1279
|
+
rejectWith(/* @__PURE__ */ new Error(`Timed out waiting for relay hello from ${wsUrl}`));
|
|
1280
|
+
}, RELAY_SERVER_MESSAGE_TIMEOUT_MS);
|
|
998
1281
|
ws.once("open", onOpen);
|
|
999
|
-
ws.once("error",
|
|
1282
|
+
ws.once("error", onErrorBeforeReady);
|
|
1283
|
+
ws.once("close", onCloseBeforeReady);
|
|
1284
|
+
ws.on("message", onMessageBeforeReady);
|
|
1000
1285
|
});
|
|
1001
1286
|
}
|
|
1002
|
-
setupClientHandlers(ws) {
|
|
1287
|
+
setupClientHandlers(ws, bufferedMessages = []) {
|
|
1288
|
+
for (const message of bufferedMessages) this.processRelayServerMessage(message);
|
|
1003
1289
|
ws.on("message", (raw) => {
|
|
1004
|
-
const
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
parsed = JSON.parse(text);
|
|
1008
|
-
} catch (err) {
|
|
1009
|
-
const preview = text.length > 200 ? `${text.slice(0, 200)}...` : text;
|
|
1010
|
-
process.stderr.write(`[webmcp-local-relay] warn: invalid JSON from relay server (${err instanceof Error ? err.message : "parse error"}): ${preview}\n`);
|
|
1011
|
-
return;
|
|
1012
|
-
}
|
|
1013
|
-
const msg = RelayServerToClientMessageSchema.safeParse(parsed);
|
|
1014
|
-
if (!msg.success) {
|
|
1015
|
-
const typeField = typeof parsed === "object" && parsed !== null ? parsed.type : "unknown";
|
|
1016
|
-
process.stderr.write(`[webmcp-local-relay] warn: invalid relay server message (type=${typeField}): ${msg.error.message}\n`);
|
|
1017
|
-
return;
|
|
1018
|
-
}
|
|
1019
|
-
switch (msg.data.type) {
|
|
1020
|
-
case "relay/tools":
|
|
1021
|
-
case "relay/tools-changed":
|
|
1022
|
-
this.clientTools = msg.data.tools;
|
|
1023
|
-
this.clientSources = msg.data.sources;
|
|
1024
|
-
this.clientToolSourceMap = msg.data.toolSourceMap;
|
|
1025
|
-
this.emit("stateChanged");
|
|
1026
|
-
break;
|
|
1027
|
-
case "relay/result": {
|
|
1028
|
-
const pending = this.clientPendingInvocations.get(msg.data.callId);
|
|
1029
|
-
if (!pending) {
|
|
1030
|
-
process.stderr.write(`[webmcp-local-relay] warn: received relay result for unknown callId ${msg.data.callId}\n`);
|
|
1031
|
-
break;
|
|
1032
|
-
}
|
|
1033
|
-
clearTimeout(pending.timeoutId);
|
|
1034
|
-
this.clientPendingInvocations.delete(msg.data.callId);
|
|
1035
|
-
pending.resolve(msg.data.result);
|
|
1036
|
-
break;
|
|
1037
|
-
}
|
|
1038
|
-
}
|
|
1290
|
+
const message = this.parseRelayServerMessage(raw);
|
|
1291
|
+
if (!message) return;
|
|
1292
|
+
this.processRelayServerMessage(message);
|
|
1039
1293
|
});
|
|
1040
1294
|
ws.on("close", () => {
|
|
1041
1295
|
this.clientSocket = null;
|
|
@@ -1054,6 +1308,51 @@ var RelayBridgeServer = class extends EventEmitter {
|
|
|
1054
1308
|
process.stderr.write(`[webmcp-local-relay] warn: relay client socket error: ${err.message}\n`);
|
|
1055
1309
|
});
|
|
1056
1310
|
}
|
|
1311
|
+
sendRelayClientHandshake(ws) {
|
|
1312
|
+
ws.send(JSON.stringify({ type: "relay/hello" }));
|
|
1313
|
+
ws.send(JSON.stringify({ type: "relay/list-tools" }));
|
|
1314
|
+
}
|
|
1315
|
+
parseRelayServerMessage(raw) {
|
|
1316
|
+
const text = this.rawDataToUtf8(raw);
|
|
1317
|
+
let parsed;
|
|
1318
|
+
try {
|
|
1319
|
+
parsed = JSON.parse(text);
|
|
1320
|
+
} catch (err) {
|
|
1321
|
+
const preview = text.length > 200 ? `${text.slice(0, 200)}...` : text;
|
|
1322
|
+
process.stderr.write(`[webmcp-local-relay] warn: invalid JSON from relay server (${err instanceof Error ? err.message : "parse error"}): ${preview}\n`);
|
|
1323
|
+
return null;
|
|
1324
|
+
}
|
|
1325
|
+
const message = RelayServerToClientMessageSchema.safeParse(parsed);
|
|
1326
|
+
if (!message.success) {
|
|
1327
|
+
const typeField = typeof parsed === "object" && parsed !== null ? parsed.type : "unknown";
|
|
1328
|
+
process.stderr.write(`[webmcp-local-relay] warn: invalid relay server message (type=${typeField}): ${message.error.message}\n`);
|
|
1329
|
+
return null;
|
|
1330
|
+
}
|
|
1331
|
+
return message.data;
|
|
1332
|
+
}
|
|
1333
|
+
processRelayServerMessage(message) {
|
|
1334
|
+
switch (message.type) {
|
|
1335
|
+
case "server-hello": break;
|
|
1336
|
+
case "relay/tools":
|
|
1337
|
+
case "relay/tools-changed":
|
|
1338
|
+
this.clientTools = message.tools;
|
|
1339
|
+
this.clientSources = message.sources;
|
|
1340
|
+
this.clientToolSourceMap = message.toolSourceMap;
|
|
1341
|
+
this.emit("stateChanged");
|
|
1342
|
+
break;
|
|
1343
|
+
case "relay/result": {
|
|
1344
|
+
const pending = this.clientPendingInvocations.get(message.callId);
|
|
1345
|
+
if (!pending) {
|
|
1346
|
+
process.stderr.write(`[webmcp-local-relay] warn: received relay result for unknown callId ${message.callId}\n`);
|
|
1347
|
+
break;
|
|
1348
|
+
}
|
|
1349
|
+
clearTimeout(pending.timeoutId);
|
|
1350
|
+
this.clientPendingInvocations.delete(message.callId);
|
|
1351
|
+
pending.resolve(message.result);
|
|
1352
|
+
break;
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1057
1356
|
scheduleReconnect() {
|
|
1058
1357
|
if (this.clientReconnectTimer || this.stopping) return;
|
|
1059
1358
|
this.clientReconnectAttempts++;
|
|
@@ -1074,55 +1373,19 @@ var RelayBridgeServer = class extends EventEmitter {
|
|
|
1074
1373
|
}
|
|
1075
1374
|
/**
|
|
1076
1375
|
* Attempts to promote from client to server mode when reconnecting.
|
|
1077
|
-
*
|
|
1376
|
+
* Re-runs the same attach-or-bind strategy used during startup.
|
|
1078
1377
|
*/
|
|
1079
1378
|
async reconnectWithModePromotion() {
|
|
1080
1379
|
try {
|
|
1081
|
-
await this.
|
|
1082
|
-
this._mode = "server";
|
|
1380
|
+
await this.startUsingPortStrategy();
|
|
1083
1381
|
this.clientReconnectDelay = 500;
|
|
1084
1382
|
this.clientReconnectAttempts = 0;
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
if (!(err instanceof Error && ("code" in err ? err.code === "EADDRINUSE" : err.message.includes("EADDRINUSE")))) {
|
|
1089
|
-
process.stderr.write(`[webmcp-local-relay] warn: server promotion failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1090
|
-
if (!this.stopping) this.scheduleReconnect();
|
|
1091
|
-
return;
|
|
1383
|
+
if (this._mode === "server") {
|
|
1384
|
+
process.stderr.write("[webmcp-local-relay] info: promoted from client to server mode\n");
|
|
1385
|
+
this.emit("stateChanged");
|
|
1092
1386
|
}
|
|
1093
|
-
this.reconnectAsClient();
|
|
1094
|
-
}
|
|
1095
|
-
}
|
|
1096
|
-
/**
|
|
1097
|
-
* Reconnects as a client to an existing relay server.
|
|
1098
|
-
*/
|
|
1099
|
-
reconnectAsClient() {
|
|
1100
|
-
try {
|
|
1101
|
-
const ws = new WebSocket(`ws://${this.host}:${this.desiredPort}`);
|
|
1102
|
-
const onReconnectError = (err) => {
|
|
1103
|
-
process.stderr.write(`[webmcp-local-relay] warn: relay client reconnection failed: ${err.message}\n`);
|
|
1104
|
-
if (!this.stopping) this.scheduleReconnect();
|
|
1105
|
-
};
|
|
1106
|
-
ws.once("open", () => {
|
|
1107
|
-
ws.off("error", onReconnectError);
|
|
1108
|
-
this.clientSocket = ws;
|
|
1109
|
-
this.clientReconnectDelay = 500;
|
|
1110
|
-
this.clientReconnectAttempts = 0;
|
|
1111
|
-
try {
|
|
1112
|
-
ws.send(JSON.stringify({ type: "relay/hello" }));
|
|
1113
|
-
ws.send(JSON.stringify({ type: "relay/list-tools" }));
|
|
1114
|
-
} catch (err) {
|
|
1115
|
-
process.stderr.write(`[webmcp-local-relay] error: failed to send handshake during reconnection: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1116
|
-
this.clientSocket = null;
|
|
1117
|
-
ws.close();
|
|
1118
|
-
this.scheduleReconnect();
|
|
1119
|
-
return;
|
|
1120
|
-
}
|
|
1121
|
-
this.setupClientHandlers(ws);
|
|
1122
|
-
});
|
|
1123
|
-
ws.once("error", onReconnectError);
|
|
1124
1387
|
} catch (err) {
|
|
1125
|
-
process.stderr.write(`[webmcp-local-relay]
|
|
1388
|
+
process.stderr.write(`[webmcp-local-relay] warn: reconnection failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1126
1389
|
if (!this.stopping) this.scheduleReconnect();
|
|
1127
1390
|
}
|
|
1128
1391
|
}
|
|
@@ -1212,6 +1475,37 @@ var RelayBridgeServer = class extends EventEmitter {
|
|
|
1212
1475
|
}
|
|
1213
1476
|
};
|
|
1214
1477
|
|
|
1478
|
+
//#endregion
|
|
1479
|
+
//#region src/staticToolSchemas.ts
|
|
1480
|
+
const EMPTY_STATIC_TOOL_INPUT_SHAPE = {};
|
|
1481
|
+
const WEBMCP_CALL_TOOL_INPUT_SHAPE = {
|
|
1482
|
+
name: z.string().describe("The tool name to call. Use webmcp_list_tools to see available tool names."),
|
|
1483
|
+
arguments: z.record(z.string(), z.unknown()).optional().describe("Arguments to pass to the tool as a JSON object. Check the tool inputSchema from webmcp_list_tools for expected fields.")
|
|
1484
|
+
};
|
|
1485
|
+
const WEBMCP_OPEN_PAGE_INPUT_SHAPE = {
|
|
1486
|
+
url: z.string().describe("URL to open or match for refresh."),
|
|
1487
|
+
refresh: z.boolean().optional().describe("If true, refresh the connected source matching this URL instead of opening a new tab.")
|
|
1488
|
+
};
|
|
1489
|
+
function isRecord(value) {
|
|
1490
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1491
|
+
}
|
|
1492
|
+
function stripJsonSchemaMetadata(value) {
|
|
1493
|
+
if (Array.isArray(value)) return value.map((item) => stripJsonSchemaMetadata(item));
|
|
1494
|
+
if (!isRecord(value)) return value;
|
|
1495
|
+
const next = {};
|
|
1496
|
+
for (const [key, nestedValue] of Object.entries(value)) {
|
|
1497
|
+
if (key === "$schema") continue;
|
|
1498
|
+
next[key] = stripJsonSchemaMetadata(nestedValue);
|
|
1499
|
+
}
|
|
1500
|
+
return next;
|
|
1501
|
+
}
|
|
1502
|
+
function publicInputSchemaFromZodShape(inputShape) {
|
|
1503
|
+
return stripJsonSchemaMetadata(toJsonSchemaCompat(z.object(inputShape), {
|
|
1504
|
+
strictUnions: true,
|
|
1505
|
+
pipeStrategy: "input"
|
|
1506
|
+
}));
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1215
1509
|
//#endregion
|
|
1216
1510
|
//#region src/mcpRelayServer.ts
|
|
1217
1511
|
/**
|
|
@@ -1223,7 +1517,9 @@ var LocalRelayMcpServer = class {
|
|
|
1223
1517
|
*/
|
|
1224
1518
|
bridge;
|
|
1225
1519
|
mcpServer;
|
|
1520
|
+
staticToolData = /* @__PURE__ */ new Map();
|
|
1226
1521
|
dynamicToolHandles = /* @__PURE__ */ new Map();
|
|
1522
|
+
dynamicToolData = /* @__PURE__ */ new Map();
|
|
1227
1523
|
dynamicToolSignature = /* @__PURE__ */ new Map();
|
|
1228
1524
|
syncing = false;
|
|
1229
1525
|
syncRequested = false;
|
|
@@ -1243,6 +1539,7 @@ var LocalRelayMcpServer = class {
|
|
|
1243
1539
|
});
|
|
1244
1540
|
});
|
|
1245
1541
|
this.registerStaticTools();
|
|
1542
|
+
this.overrideListToolsHandler();
|
|
1246
1543
|
}
|
|
1247
1544
|
/**
|
|
1248
1545
|
* Starts the browser bridge and synchronizes dynamic MCP tools.
|
|
@@ -1289,210 +1586,246 @@ var LocalRelayMcpServer = class {
|
|
|
1289
1586
|
return Array.from(this.dynamicToolHandles.keys()).sort();
|
|
1290
1587
|
}
|
|
1291
1588
|
/**
|
|
1589
|
+
* Overrides the SDK's ListTools handler to return real JSON Schema
|
|
1590
|
+
* for dynamic tools instead of the empty `z.object({}).passthrough()`
|
|
1591
|
+
* schema that the SDK would otherwise convert to `{ type: 'object' }`.
|
|
1592
|
+
*
|
|
1593
|
+
* The MCP SDK's McpServer internally runs `toJsonSchemaCompat()` on
|
|
1594
|
+
* registered inputSchema values, which strips plain JSON Schema objects
|
|
1595
|
+
* (non-Zod) to empty schemas. This override bypasses that conversion
|
|
1596
|
+
* for dynamic (relayed) tools while preserving static tools as-is.
|
|
1597
|
+
*/
|
|
1598
|
+
overrideListToolsHandler() {
|
|
1599
|
+
this.mcpServer.server.setRequestHandler(ListToolsRequestSchema, () => {
|
|
1600
|
+
return { tools: [...Array.from(this.staticToolData.values()).map((tool) => ({ ...tool })), ...Array.from(this.dynamicToolData.entries()).map(([name, tool]) => ({
|
|
1601
|
+
name,
|
|
1602
|
+
description: this.dynamicToolDescription(tool),
|
|
1603
|
+
inputSchema: tool.inputSchema,
|
|
1604
|
+
...tool.outputSchema ? { outputSchema: tool.outputSchema } : {},
|
|
1605
|
+
...tool.annotations ? { annotations: tool.annotations } : {}
|
|
1606
|
+
}))].sort((left, right) => String(left.name).localeCompare(String(right.name))) };
|
|
1607
|
+
});
|
|
1608
|
+
}
|
|
1609
|
+
registerStaticTool({ name, description, inputShape, annotations, handler }) {
|
|
1610
|
+
this.mcpServer.registerTool(name, {
|
|
1611
|
+
description,
|
|
1612
|
+
inputSchema: inputShape,
|
|
1613
|
+
...annotations ? { annotations } : {}
|
|
1614
|
+
}, handler);
|
|
1615
|
+
this.staticToolData.set(name, {
|
|
1616
|
+
name,
|
|
1617
|
+
description,
|
|
1618
|
+
inputSchema: publicInputSchemaFromZodShape(inputShape),
|
|
1619
|
+
...annotations ? { annotations } : {}
|
|
1620
|
+
});
|
|
1621
|
+
}
|
|
1622
|
+
/**
|
|
1292
1623
|
* Registers built-in management tools exposed by the relay.
|
|
1293
1624
|
*/
|
|
1294
1625
|
registerStaticTools() {
|
|
1295
|
-
this.
|
|
1626
|
+
this.registerStaticTool({
|
|
1627
|
+
name: "webmcp_list_sources",
|
|
1296
1628
|
description: "List connected browser tool sources and their metadata.",
|
|
1297
|
-
|
|
1629
|
+
inputShape: EMPTY_STATIC_TOOL_INPUT_SHAPE,
|
|
1298
1630
|
annotations: {
|
|
1299
1631
|
readOnlyHint: true,
|
|
1300
1632
|
idempotentHint: true
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1633
|
+
},
|
|
1634
|
+
handler: async () => {
|
|
1635
|
+
if (this.bridge.mode === "client") {
|
|
1636
|
+
const sources$1 = this.bridge.listSourcesFromRelay();
|
|
1637
|
+
const info = {
|
|
1638
|
+
mode: "client",
|
|
1639
|
+
count: sources$1.length,
|
|
1640
|
+
sources: sources$1
|
|
1641
|
+
};
|
|
1642
|
+
return {
|
|
1643
|
+
content: [{
|
|
1644
|
+
type: "text",
|
|
1645
|
+
text: JSON.stringify(info, null, 2)
|
|
1646
|
+
}],
|
|
1647
|
+
structuredContent: info
|
|
1648
|
+
};
|
|
1649
|
+
}
|
|
1650
|
+
const sources = this.bridge.registry.listSources();
|
|
1310
1651
|
return {
|
|
1311
1652
|
content: [{
|
|
1312
1653
|
type: "text",
|
|
1313
|
-
text: JSON.stringify(
|
|
1654
|
+
text: JSON.stringify({
|
|
1655
|
+
count: sources.length,
|
|
1656
|
+
sources
|
|
1657
|
+
}, null, 2)
|
|
1314
1658
|
}],
|
|
1315
|
-
structuredContent:
|
|
1316
|
-
};
|
|
1317
|
-
}
|
|
1318
|
-
const sources = this.bridge.registry.listSources();
|
|
1319
|
-
return {
|
|
1320
|
-
content: [{
|
|
1321
|
-
type: "text",
|
|
1322
|
-
text: JSON.stringify({
|
|
1659
|
+
structuredContent: {
|
|
1323
1660
|
count: sources.length,
|
|
1324
1661
|
sources
|
|
1325
|
-
}
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
count: sources.length,
|
|
1329
|
-
sources
|
|
1330
|
-
}
|
|
1331
|
-
};
|
|
1662
|
+
}
|
|
1663
|
+
};
|
|
1664
|
+
}
|
|
1332
1665
|
});
|
|
1333
|
-
this.
|
|
1666
|
+
this.registerStaticTool({
|
|
1667
|
+
name: "webmcp_list_tools",
|
|
1334
1668
|
description: "List WebMCP tools available from connected browser sources. Returns tool definitions including name, description, input schema, and source info. Use webmcp_call_tool to invoke a tool by name.",
|
|
1335
|
-
|
|
1669
|
+
inputShape: EMPTY_STATIC_TOOL_INPUT_SHAPE,
|
|
1336
1670
|
annotations: {
|
|
1337
1671
|
readOnlyHint: true,
|
|
1338
1672
|
idempotentHint: true
|
|
1339
|
-
}
|
|
1340
|
-
}, async () => {
|
|
1341
|
-
const tools = this.listAggregatedTools();
|
|
1342
|
-
return {
|
|
1343
|
-
content: [{
|
|
1344
|
-
type: "text",
|
|
1345
|
-
text: JSON.stringify({
|
|
1346
|
-
count: tools.length,
|
|
1347
|
-
tools
|
|
1348
|
-
}, null, 2)
|
|
1349
|
-
}],
|
|
1350
|
-
structuredContent: {
|
|
1351
|
-
count: tools.length,
|
|
1352
|
-
tools
|
|
1353
|
-
}
|
|
1354
|
-
};
|
|
1355
|
-
});
|
|
1356
|
-
this.mcpServer.registerTool("webmcp_call_tool", {
|
|
1357
|
-
description: "Call a WebMCP tool registered by a connected browser page. Use webmcp_list_tools first to see available tools and their input schemas.",
|
|
1358
|
-
inputSchema: {
|
|
1359
|
-
name: z.string().describe("The tool name to call. Use webmcp_list_tools to see available tool names."),
|
|
1360
|
-
arguments: z.record(z.string(), z.unknown()).optional().describe("Arguments to pass to the tool as a JSON object. Check the tool inputSchema from webmcp_list_tools for expected fields.")
|
|
1361
1673
|
},
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
const tools = this.listAggregatedTools();
|
|
1365
|
-
const toolSummary = this.buildToolSummary(tools);
|
|
1366
|
-
if (!tools.find((t) => t.name === name)) {
|
|
1367
|
-
const errorLines = [`Tool "${name}" not found.`];
|
|
1368
|
-
if (toolSummary) errorLines.push("", "Available tools:", toolSummary);
|
|
1369
|
-
else errorLines.push("", "No tools are currently available. Ensure a browser page with WebMCP is connected.");
|
|
1674
|
+
handler: async () => {
|
|
1675
|
+
const tools = this.listAggregatedTools();
|
|
1370
1676
|
return {
|
|
1371
1677
|
content: [{
|
|
1372
1678
|
type: "text",
|
|
1373
|
-
text:
|
|
1679
|
+
text: JSON.stringify({
|
|
1680
|
+
count: tools.length,
|
|
1681
|
+
tools
|
|
1682
|
+
}, null, 2)
|
|
1374
1683
|
}],
|
|
1375
|
-
|
|
1684
|
+
structuredContent: {
|
|
1685
|
+
count: tools.length,
|
|
1686
|
+
tools
|
|
1687
|
+
}
|
|
1376
1688
|
};
|
|
1377
1689
|
}
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1690
|
+
});
|
|
1691
|
+
this.registerStaticTool({
|
|
1692
|
+
name: "webmcp_call_tool",
|
|
1693
|
+
description: "Call a WebMCP tool registered by a connected browser page. Use webmcp_list_tools first to see available tools and their input schemas.",
|
|
1694
|
+
inputShape: WEBMCP_CALL_TOOL_INPUT_SHAPE,
|
|
1695
|
+
annotations: { readOnlyHint: false },
|
|
1696
|
+
handler: async ({ name, arguments: args }) => {
|
|
1697
|
+
const tools = this.listAggregatedTools();
|
|
1698
|
+
const toolSummary = this.buildToolSummary(tools);
|
|
1699
|
+
if (!tools.find((t) => t.name === name)) {
|
|
1700
|
+
const errorLines = [`Tool "${name}" not found.`];
|
|
1701
|
+
if (toolSummary) errorLines.push("", "Available tools:", toolSummary);
|
|
1702
|
+
else errorLines.push("", "No tools are currently available. Ensure a browser page with WebMCP is connected.");
|
|
1703
|
+
return {
|
|
1704
|
+
content: [{
|
|
1705
|
+
type: "text",
|
|
1706
|
+
text: errorLines.join("\n")
|
|
1707
|
+
}],
|
|
1708
|
+
isError: true
|
|
1709
|
+
};
|
|
1710
|
+
}
|
|
1711
|
+
try {
|
|
1712
|
+
const result = await this.bridge.invokeTool(name, args ?? {});
|
|
1713
|
+
const updatedTools = this.bridge.mode === "client" ? this.buildAggregatedToolsFromRelay() : this.bridge.registry.listTools();
|
|
1714
|
+
const updatedSummary = this.buildToolSummary(updatedTools);
|
|
1715
|
+
if (updatedSummary) result.content = [...result.content, {
|
|
1392
1716
|
type: "text",
|
|
1393
|
-
text:
|
|
1394
|
-
}]
|
|
1395
|
-
|
|
1396
|
-
}
|
|
1717
|
+
text: `\n---\nAvailable tools:\n${updatedSummary}`
|
|
1718
|
+
}];
|
|
1719
|
+
return result;
|
|
1720
|
+
} catch (err) {
|
|
1721
|
+
const errorLines = [`Failed to call tool "${name}": ${err instanceof Error ? err.message : String(err)}`];
|
|
1722
|
+
if (toolSummary) errorLines.push("", "Available tools:", toolSummary);
|
|
1723
|
+
return {
|
|
1724
|
+
content: [{
|
|
1725
|
+
type: "text",
|
|
1726
|
+
text: errorLines.join("\n")
|
|
1727
|
+
}],
|
|
1728
|
+
isError: true
|
|
1729
|
+
};
|
|
1730
|
+
}
|
|
1397
1731
|
}
|
|
1398
1732
|
});
|
|
1399
|
-
this.
|
|
1733
|
+
this.registerStaticTool({
|
|
1734
|
+
name: "webmcp_open_page",
|
|
1400
1735
|
description: "Open a URL in the user's default browser, or refresh a connected source page. Use to launch WebMCP-enabled pages or reload stale connections.",
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
};
|
|
1418
|
-
}
|
|
1419
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return {
|
|
1420
|
-
content: [{
|
|
1421
|
-
type: "text",
|
|
1422
|
-
text: `Only http: and https: URLs are allowed. Got: ${parsed.protocol}`
|
|
1423
|
-
}],
|
|
1424
|
-
isError: true
|
|
1425
|
-
};
|
|
1426
|
-
if (refresh) {
|
|
1427
|
-
if (this.bridge.mode === "client") return {
|
|
1736
|
+
inputShape: WEBMCP_OPEN_PAGE_INPUT_SHAPE,
|
|
1737
|
+
annotations: { readOnlyHint: false },
|
|
1738
|
+
handler: async ({ url, refresh }) => {
|
|
1739
|
+
let parsed;
|
|
1740
|
+
try {
|
|
1741
|
+
parsed = new URL(url);
|
|
1742
|
+
} catch {
|
|
1743
|
+
return {
|
|
1744
|
+
content: [{
|
|
1745
|
+
type: "text",
|
|
1746
|
+
text: `Invalid URL: ${url}`
|
|
1747
|
+
}],
|
|
1748
|
+
isError: true
|
|
1749
|
+
};
|
|
1750
|
+
}
|
|
1751
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return {
|
|
1428
1752
|
content: [{
|
|
1429
1753
|
type: "text",
|
|
1430
|
-
text:
|
|
1754
|
+
text: `Only http: and https: URLs are allowed. Got: ${parsed.protocol}`
|
|
1431
1755
|
}],
|
|
1432
1756
|
isError: true
|
|
1433
1757
|
};
|
|
1434
|
-
|
|
1435
|
-
if (
|
|
1758
|
+
if (refresh) {
|
|
1759
|
+
if (this.bridge.mode === "client") return {
|
|
1760
|
+
content: [{
|
|
1761
|
+
type: "text",
|
|
1762
|
+
text: "Refresh is not supported in client mode. Only the server relay can reload sources."
|
|
1763
|
+
}],
|
|
1764
|
+
isError: true
|
|
1765
|
+
};
|
|
1766
|
+
const matched = this.bridge.registry.listSources().find((s) => {
|
|
1767
|
+
if (!s.url) return false;
|
|
1768
|
+
try {
|
|
1769
|
+
return new URL(s.url).origin === parsed.origin;
|
|
1770
|
+
} catch {
|
|
1771
|
+
process.stderr.write(`[webmcp-local-relay] warn: source ${s.sourceId} has unparseable URL: ${s.url}\n`);
|
|
1772
|
+
return false;
|
|
1773
|
+
}
|
|
1774
|
+
});
|
|
1775
|
+
if (!matched) return {
|
|
1776
|
+
content: [{
|
|
1777
|
+
type: "text",
|
|
1778
|
+
text: `No connected source matches origin ${parsed.origin}. The page may not be open or connected.`
|
|
1779
|
+
}],
|
|
1780
|
+
isError: true
|
|
1781
|
+
};
|
|
1436
1782
|
try {
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1783
|
+
this.bridge.reloadSource(matched.sourceId);
|
|
1784
|
+
return { content: [{
|
|
1785
|
+
type: "text",
|
|
1786
|
+
text: `Reload sent to source ${matched.sourceId} (${matched.url ?? matched.origin}).`
|
|
1787
|
+
}] };
|
|
1788
|
+
} catch (err) {
|
|
1789
|
+
return {
|
|
1790
|
+
content: [{
|
|
1791
|
+
type: "text",
|
|
1792
|
+
text: `Failed to reload source: ${err instanceof Error ? err.message : String(err)}`
|
|
1793
|
+
}],
|
|
1794
|
+
isError: true
|
|
1795
|
+
};
|
|
1441
1796
|
}
|
|
1442
|
-
}
|
|
1443
|
-
if (!matched) return {
|
|
1444
|
-
content: [{
|
|
1445
|
-
type: "text",
|
|
1446
|
-
text: `No connected source matches origin ${parsed.origin}. The page may not be open or connected.`
|
|
1447
|
-
}],
|
|
1448
|
-
isError: true
|
|
1449
|
-
};
|
|
1797
|
+
}
|
|
1450
1798
|
try {
|
|
1451
|
-
this.
|
|
1452
|
-
return { content: [{
|
|
1453
|
-
type: "text",
|
|
1454
|
-
text: `Reload sent to source ${matched.sourceId} (${matched.url ?? matched.origin}).`
|
|
1455
|
-
}] };
|
|
1799
|
+
await this.openInBrowser(url);
|
|
1456
1800
|
} catch (err) {
|
|
1457
1801
|
return {
|
|
1458
1802
|
content: [{
|
|
1459
1803
|
type: "text",
|
|
1460
|
-
text: `Failed to
|
|
1804
|
+
text: `Failed to open browser: ${err instanceof Error ? err.message : String(err)}`
|
|
1461
1805
|
}],
|
|
1462
1806
|
isError: true
|
|
1463
1807
|
};
|
|
1464
1808
|
}
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1809
|
+
if (this.bridge.mode === "server") {
|
|
1810
|
+
const existing = this.bridge.registry.listSources().find((s) => {
|
|
1811
|
+
if (!s.url) return false;
|
|
1812
|
+
try {
|
|
1813
|
+
return new URL(s.url).origin === parsed.origin;
|
|
1814
|
+
} catch {
|
|
1815
|
+
process.stderr.write(`[webmcp-local-relay] warn: source ${s.sourceId} has unparseable URL: ${s.url}\n`);
|
|
1816
|
+
return false;
|
|
1817
|
+
}
|
|
1818
|
+
});
|
|
1819
|
+
if (existing) return { content: [{
|
|
1471
1820
|
type: "text",
|
|
1472
|
-
text: `
|
|
1473
|
-
}]
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
}
|
|
1477
|
-
if (this.bridge.mode === "server") {
|
|
1478
|
-
const existing = this.bridge.registry.listSources().find((s) => {
|
|
1479
|
-
if (!s.url) return false;
|
|
1480
|
-
try {
|
|
1481
|
-
return new URL(s.url).origin === parsed.origin;
|
|
1482
|
-
} catch {
|
|
1483
|
-
process.stderr.write(`[webmcp-local-relay] warn: source ${s.sourceId} has unparseable URL: ${s.url}\n`);
|
|
1484
|
-
return false;
|
|
1485
|
-
}
|
|
1486
|
-
});
|
|
1487
|
-
if (existing) return { content: [{
|
|
1821
|
+
text: `Opened ${url} in the default browser. Note: a source from ${existing.url ?? existing.origin} is already connected.`
|
|
1822
|
+
}] };
|
|
1823
|
+
}
|
|
1824
|
+
return { content: [{
|
|
1488
1825
|
type: "text",
|
|
1489
|
-
text: `Opened ${url} in the default browser
|
|
1826
|
+
text: `Opened ${url} in the default browser.`
|
|
1490
1827
|
}] };
|
|
1491
1828
|
}
|
|
1492
|
-
return { content: [{
|
|
1493
|
-
type: "text",
|
|
1494
|
-
text: `Opened ${url} in the default browser.`
|
|
1495
|
-
}] };
|
|
1496
1829
|
});
|
|
1497
1830
|
}
|
|
1498
1831
|
/**
|
|
@@ -1601,6 +1934,7 @@ var LocalRelayMcpServer = class {
|
|
|
1601
1934
|
if (nextNames.has(name)) continue;
|
|
1602
1935
|
handle.remove();
|
|
1603
1936
|
this.dynamicToolHandles.delete(name);
|
|
1937
|
+
this.dynamicToolData.delete(name);
|
|
1604
1938
|
this.dynamicToolSignature.delete(name);
|
|
1605
1939
|
changed = true;
|
|
1606
1940
|
}
|
|
@@ -1614,6 +1948,7 @@ var LocalRelayMcpServer = class {
|
|
|
1614
1948
|
}
|
|
1615
1949
|
const handle = this.registerDynamicTool(tool);
|
|
1616
1950
|
this.dynamicToolHandles.set(tool.name, handle);
|
|
1951
|
+
this.dynamicToolData.set(tool.name, tool);
|
|
1617
1952
|
this.dynamicToolSignature.set(tool.name, signature);
|
|
1618
1953
|
changed = true;
|
|
1619
1954
|
}
|
|
@@ -1694,5 +2029,5 @@ var LocalRelayMcpServer = class {
|
|
|
1694
2029
|
};
|
|
1695
2030
|
|
|
1696
2031
|
//#endregion
|
|
1697
|
-
export {
|
|
1698
|
-
//# sourceMappingURL=mcpRelayServer-
|
|
2032
|
+
export { normalizeInboundTool as C, ToolSchema as S, printHelp as T, DEFAULT_TOOL_INPUT_SCHEMA as _, RelayDescriptorSchema as a, RelayInvokeArgsSchema as b, RelayToBrowserMessageSchema as c, RelayRegistry as d, buildPublicToolName as f, CallToolResultSchema as g, CallToolRequestParamsSchema as h, RelayClientToServerMessageSchema as i, ServerHelloMessageSchema as l, sanitizeName as m, RelayBridgeServer as n, RelayServerToClientMessageSchema as o, extractSanitizedDomain as p, BrowserToRelayMessageSchema as r, RelaySourceInfoSchema as s, LocalRelayMcpServer as t, HelloRequiredError as u, InboundToolSchema as v, parseCliOptions as w, ToolAnnotationsSchema as x, NormalizedToolSchema as y };
|
|
2033
|
+
//# sourceMappingURL=mcpRelayServer-rbay_DSr.js.map
|