@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.
@@ -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 { CallToolRequestParamsSchema, CallToolResultSchema, ToolAnnotationsSchema, ToolSchema } from "@modelcontextprotocol/sdk/types.js";
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 Bind port for local websocket relay (default: 9333)",
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.desiredPort = options.port ?? 9333;
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
- if (this.desiredPort !== 0 && (this.desiredPort < 1 || this.desiredPort > 65535)) throw new Error(`Invalid port ${this.desiredPort}. Port must be 0 (auto-assign) or between 1 and 65535.`);
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 the port is already in use, automatically falls back to client mode
649
- * and proxies through the existing relay.
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
- try {
655
- await this.startAsServer();
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 instanceof Error && ("code" in err ? err.code === "EADDRINUSE" : err.message.includes("EADDRINUSE")))) throw err;
658
- this._mode = "client";
659
- process.stderr.write(`[webmcp-local-relay] info: port ${this.desiredPort} in use, switching to client mode\n`);
660
- await this.startAsClient();
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: this.desiredPort,
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": break;
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
- async startAsClient() {
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 ws = new WebSocket(`ws://${this.host}:${this.desiredPort}`);
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
- ws.send(JSON.stringify({ type: "relay/hello" }));
983
- ws.send(JSON.stringify({ type: "relay/list-tools" }));
1250
+ this.sendRelayClientHandshake(ws);
984
1251
  } catch (err) {
985
- process.stderr.write(`[webmcp-local-relay] error: failed to send handshake to relay server: ${err instanceof Error ? err.message : String(err)}\n`);
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 onFirstError = (err) => {
995
- ws.off("open", onOpen);
996
- reject(/* @__PURE__ */ new Error(`Failed to connect to relay server at ws://${this.host}:${this.desiredPort}: ${err.message}`));
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", onFirstError);
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 text = this.rawDataToUtf8(raw);
1005
- let parsed;
1006
- try {
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
- * Tries to bind the port first; if EADDRINUSE, falls back to client mode.
1376
+ * Re-runs the same attach-or-bind strategy used during startup.
1078
1377
  */
1079
1378
  async reconnectWithModePromotion() {
1080
1379
  try {
1081
- await this.startAsServer();
1082
- this._mode = "server";
1380
+ await this.startUsingPortStrategy();
1083
1381
  this.clientReconnectDelay = 500;
1084
1382
  this.clientReconnectAttempts = 0;
1085
- process.stderr.write("[webmcp-local-relay] info: promoted from client to server mode\n");
1086
- this.emit("stateChanged");
1087
- } catch (err) {
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] error: failed to create reconnection socket: ${err instanceof Error ? err.message : String(err)}\n`);
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.mcpServer.registerTool("webmcp_list_sources", {
1626
+ this.registerStaticTool({
1627
+ name: "webmcp_list_sources",
1296
1628
  description: "List connected browser tool sources and their metadata.",
1297
- inputSchema: {},
1629
+ inputShape: EMPTY_STATIC_TOOL_INPUT_SHAPE,
1298
1630
  annotations: {
1299
1631
  readOnlyHint: true,
1300
1632
  idempotentHint: true
1301
- }
1302
- }, async () => {
1303
- if (this.bridge.mode === "client") {
1304
- const sources$1 = this.bridge.listSourcesFromRelay();
1305
- const info = {
1306
- mode: "client",
1307
- count: sources$1.length,
1308
- sources: sources$1
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(info, null, 2)
1654
+ text: JSON.stringify({
1655
+ count: sources.length,
1656
+ sources
1657
+ }, null, 2)
1314
1658
  }],
1315
- structuredContent: info
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
- }, null, 2)
1326
- }],
1327
- structuredContent: {
1328
- count: sources.length,
1329
- sources
1330
- }
1331
- };
1662
+ }
1663
+ };
1664
+ }
1332
1665
  });
1333
- this.mcpServer.registerTool("webmcp_list_tools", {
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
- inputSchema: {},
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
- annotations: { readOnlyHint: false }
1363
- }, async ({ name, arguments: args }) => {
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: errorLines.join("\n")
1679
+ text: JSON.stringify({
1680
+ count: tools.length,
1681
+ tools
1682
+ }, null, 2)
1374
1683
  }],
1375
- isError: true
1684
+ structuredContent: {
1685
+ count: tools.length,
1686
+ tools
1687
+ }
1376
1688
  };
1377
1689
  }
1378
- try {
1379
- const result = await this.bridge.invokeTool(name, args ?? {});
1380
- const updatedTools = this.bridge.mode === "client" ? this.buildAggregatedToolsFromRelay() : this.bridge.registry.listTools();
1381
- const updatedSummary = this.buildToolSummary(updatedTools);
1382
- if (updatedSummary) result.content = [...result.content, {
1383
- type: "text",
1384
- text: `\n---\nAvailable tools:\n${updatedSummary}`
1385
- }];
1386
- return result;
1387
- } catch (err) {
1388
- const errorLines = [`Failed to call tool "${name}": ${err instanceof Error ? err.message : String(err)}`];
1389
- if (toolSummary) errorLines.push("", "Available tools:", toolSummary);
1390
- return {
1391
- content: [{
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: errorLines.join("\n")
1394
- }],
1395
- isError: true
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.mcpServer.registerTool("webmcp_open_page", {
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
- inputSchema: {
1402
- url: z.string().describe("URL to open or match for refresh."),
1403
- refresh: z.boolean().optional().describe("If true, refresh the connected source matching this URL instead of opening a new tab.")
1404
- },
1405
- annotations: { readOnlyHint: false }
1406
- }, async ({ url, refresh }) => {
1407
- let parsed;
1408
- try {
1409
- parsed = new URL(url);
1410
- } catch {
1411
- return {
1412
- content: [{
1413
- type: "text",
1414
- text: `Invalid URL: ${url}`
1415
- }],
1416
- isError: true
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: "Refresh is not supported in client mode. Only the server relay can reload sources."
1754
+ text: `Only http: and https: URLs are allowed. Got: ${parsed.protocol}`
1431
1755
  }],
1432
1756
  isError: true
1433
1757
  };
1434
- const matched = this.bridge.registry.listSources().find((s) => {
1435
- if (!s.url) return false;
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
- return new URL(s.url).origin === parsed.origin;
1438
- } catch {
1439
- process.stderr.write(`[webmcp-local-relay] warn: source ${s.sourceId} has unparseable URL: ${s.url}\n`);
1440
- return false;
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.bridge.reloadSource(matched.sourceId);
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 reload source: ${err instanceof Error ? err.message : String(err)}`
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
- try {
1467
- await this.openInBrowser(url);
1468
- } catch (err) {
1469
- return {
1470
- content: [{
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: `Failed to open browser: ${err instanceof Error ? err.message : String(err)}`
1473
- }],
1474
- isError: true
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. Note: a source from ${existing.url ?? existing.origin} is already connected.`
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 { printHelp as C, parseCliOptions as S, NormalizedToolSchema as _, RelayServerToClientMessageSchema as a, ToolSchema as b, HelloRequiredError as c, extractSanitizedDomain as d, sanitizeName as f, InboundToolSchema as g, DEFAULT_TOOL_INPUT_SCHEMA as h, RelayClientToServerMessageSchema as i, RelayRegistry as l, CallToolResultSchema as m, RelayBridgeServer as n, RelaySourceInfoSchema as o, CallToolRequestParamsSchema as p, BrowserToRelayMessageSchema as r, RelayToBrowserMessageSchema as s, LocalRelayMcpServer as t, buildPublicToolName as u, RelayInvokeArgsSchema as v, normalizeInboundTool as x, ToolAnnotationsSchema as y };
1698
- //# sourceMappingURL=mcpRelayServer-DAp7h9b1.js.map
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