@mcp-b/webmcp-local-relay 6.0.0 → 6.0.2

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.
@@ -2,9 +2,6 @@ import { z } from "zod/v4";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { EventEmitter } from "node:events";
4
4
  import WebSocket, { WebSocketServer } from "ws";
5
- import { mkdir, readFile, writeFile } from "node:fs/promises";
6
- import { homedir } from "node:os";
7
- import { dirname, join } from "node:path";
8
5
  import { CallToolRequestParamsSchema, CallToolResultSchema, ToolAnnotationsSchema, ToolSchema } from "@modelcontextprotocol/core";
9
6
  import { parseArgs } from "node:util";
10
7
  import { execFile } from "node:child_process";
@@ -17,60 +14,6 @@ function isTcpAddress(address) {
17
14
  }
18
15
  const DEFAULT_RELAY_PORT = 9333;
19
16
  const DEFAULT_RELAY_PORT_RANGE_END = 9348;
20
- const RELAY_PORT_CACHE_MAX_AGE_MS = 1440 * 60 * 1e3;
21
- const PersistedRelayPortSchema = z.object({
22
- port: z.number().int().min(1).max(65535),
23
- host: z.string().min(1),
24
- updatedAt: z.string()
25
- });
26
- function defaultRelayPortPersistPath() {
27
- return join(homedir(), ".webmcp", "relay-port.json");
28
- }
29
- async function persistPort(port, path = defaultRelayPortPersistPath(), host = "127.0.0.1", now = Date.now()) {
30
- const payload = {
31
- port,
32
- host,
33
- updatedAt: new Date(now).toISOString()
34
- };
35
- await mkdir(dirname(path), { recursive: true });
36
- await writeFile(path, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
37
- }
38
- async function readPersistedPort(path, expectedHost) {
39
- try {
40
- const raw = await readFile(path, "utf8");
41
- const parsed = PersistedRelayPortSchema.parse(JSON.parse(raw));
42
- if (expectedHost && parsed.host !== expectedHost) return null;
43
- const updatedAtMs = Date.parse(parsed.updatedAt);
44
- if (!Number.isFinite(updatedAtMs) || Date.now() - updatedAtMs > RELAY_PORT_CACHE_MAX_AGE_MS) return null;
45
- return parsed.port;
46
- } catch {
47
- return null;
48
- }
49
- }
50
- async function buildPortCandidates(options) {
51
- const { defaultPort, fixedPort, host, persistPath = defaultRelayPortPersistPath(), rangeEnd } = options;
52
- if (fixedPort !== void 0) return [{
53
- port: fixedPort,
54
- wasFixed: true,
55
- fromCache: false
56
- }];
57
- const cachedPort = await readPersistedPort(persistPath, host);
58
- const seen = /* @__PURE__ */ new Set();
59
- const candidates = [];
60
- const pushCandidate = (port, fromCache) => {
61
- if (port < 1 || port > 65535 || seen.has(port)) return;
62
- seen.add(port);
63
- candidates.push({
64
- port,
65
- wasFixed: false,
66
- fromCache
67
- });
68
- };
69
- if (cachedPort !== null && cachedPort >= defaultPort && cachedPort <= rangeEnd) pushCandidate(cachedPort, true);
70
- pushCandidate(defaultPort, false);
71
- for (let port = defaultPort + 1; port <= rangeEnd; port += 1) pushCandidate(port, false);
72
- return candidates;
73
- }
74
17
 
75
18
  //#endregion
76
19
  //#region src/protocol.ts
@@ -91,7 +34,10 @@ const DEFAULT_TOOL_INPUT_SCHEMA = {
91
34
  properties: {}
92
35
  };
93
36
  /** SDK tool schema with a browser-compatible default input schema. */
94
- const InboundToolSchema = NormalizedToolSchema.extend({ inputSchema: NormalizedToolSchema.shape.inputSchema.optional() });
37
+ const InboundToolSchema = NormalizedToolSchema.extend({ inputSchema: z.looseObject({}).transform((schema) => ({
38
+ type: "object",
39
+ ...schema
40
+ })).pipe(NormalizedToolSchema.shape.inputSchema).optional() });
95
41
  /**
96
42
  * Applies the browser default input schema and validates the complete SDK tool.
97
43
  */
@@ -359,8 +305,17 @@ var RelayRegistry = class {
359
305
 
360
306
  //#endregion
361
307
  //#region src/schemas.ts
362
- function normalizeSupportedInboundTools(tools) {
363
- return tools.map(normalizeInboundTool).filter((tool) => tool.execution?.taskSupport !== "required");
308
+ /** Parses each tool on its own, so one malformed tool does not hide the page's other tools. */
309
+ function parseBrowserTools(tools) {
310
+ return tools.flatMap((raw) => {
311
+ const parsed = InboundToolSchema.safeParse(raw);
312
+ if (!parsed.success) {
313
+ process.stderr.write(`[webmcp-local-relay] warn: skipped a malformed page tool: ${parsed.error.message}\n`);
314
+ return [];
315
+ }
316
+ const tool = normalizeInboundTool(parsed.data);
317
+ return tool.execution?.taskSupport === "required" ? [] : [tool];
318
+ });
364
319
  }
365
320
  const BrowserHelloMessageSchema = z.object({
366
321
  type: z.literal("hello"),
@@ -372,11 +327,11 @@ const BrowserHelloMessageSchema = z.object({
372
327
  });
373
328
  const BrowserToolsListMessageSchema = z.object({
374
329
  type: z.literal("tools/list"),
375
- tools: z.array(InboundToolSchema).transform(normalizeSupportedInboundTools)
330
+ tools: z.array(z.unknown()).transform(parseBrowserTools)
376
331
  });
377
332
  const BrowserToolsChangedMessageSchema = z.object({
378
333
  type: z.literal("tools/changed"),
379
- tools: z.array(InboundToolSchema).transform(normalizeSupportedInboundTools)
334
+ tools: z.array(z.unknown()).transform(parseBrowserTools)
380
335
  });
381
336
  const BrowserToolResultMessageSchema = z.object({
382
337
  type: z.literal("result"),
@@ -431,6 +386,10 @@ const RelayInvokeMessageSchema = z.object({
431
386
  toolName: z.string().min(1),
432
387
  args: RelayInvokeArgsSchema
433
388
  });
389
+ const RelayCancelMessageSchema = z.object({
390
+ type: z.literal("cancel"),
391
+ callId: z.string().min(1)
392
+ });
434
393
  const RelayPingMessageSchema = z.object({ type: z.literal("ping") });
435
394
  const RelayReloadMessageSchema = z.object({ type: z.literal("reload") });
436
395
  /**
@@ -441,6 +400,7 @@ const RelayToBrowserMessageSchema = z.discriminatedUnion("type", [
441
400
  RelayHelloAcceptedMessageSchema,
442
401
  RelayHelloRejectedMessageSchema,
443
402
  RelayInvokeMessageSchema,
403
+ RelayCancelMessageSchema,
444
404
  RelayPingMessageSchema,
445
405
  RelayReloadMessageSchema
446
406
  ]);
@@ -466,13 +426,18 @@ const RelayClientInvokeSchema = z.object({
466
426
  toolName: z.string().min(1),
467
427
  args: RelayInvokeArgsSchema
468
428
  });
429
+ const RelayClientCancelSchema = z.object({
430
+ type: z.literal("relay/cancel"),
431
+ callId: z.string().min(1)
432
+ });
469
433
  /**
470
434
  * Union schema for all relay-client-to-server messages.
471
435
  */
472
436
  const RelayClientToServerMessageSchema = z.discriminatedUnion("type", [
473
437
  RelayClientHelloSchema,
474
438
  RelayClientListToolsSchema,
475
- RelayClientInvokeSchema
439
+ RelayClientInvokeSchema,
440
+ RelayClientCancelSchema
476
441
  ]);
477
442
  const RelayToolsPayloadFields = {
478
443
  tools: z.array(NormalizedToolSchema),
@@ -507,7 +472,11 @@ const RelayServerToClientMessageSchema = z.discriminatedUnion("type", [
507
472
  const RELAY_BROWSER_PROTOCOL = "webmcp.v1";
508
473
  const RELAY_DISCOVERY_PROTOCOL = "webmcp-discovery.v1";
509
474
  const RELAY_INTERNAL_PROTOCOL = "webmcp-relay.v1";
510
- const RELAY_SERVER_MESSAGE_TIMEOUT_MS = 750;
475
+ /**
476
+ * How long a joining relay waits for the relay on a port to greet it. Too short, and a busy
477
+ * relay is mistaken for another service, so a second relay starts and splits pages from clients.
478
+ */
479
+ const RELAY_SERVER_MESSAGE_TIMEOUT_MS = 5e3;
511
480
  const HEARTBEAT_INTERVAL_MS = 15e3;
512
481
  const HEARTBEAT_DEAD_THRESHOLD_MS = 25e3;
513
482
  const RawMessageTypeSchema = z.object({ type: z.unknown().optional() });
@@ -541,7 +510,6 @@ var RelayBridgeServer = class extends EventEmitter {
541
510
  desiredPort;
542
511
  portExplicitlySet;
543
512
  portRangeEnd;
544
- persistPath;
545
513
  allowedOrigins;
546
514
  maxPayloadBytes;
547
515
  invokeTimeoutMs;
@@ -555,6 +523,7 @@ var RelayBridgeServer = class extends EventEmitter {
555
523
  pendingInvocations = /* @__PURE__ */ new Map();
556
524
  browserClientConnectionIds = /* @__PURE__ */ new Set();
557
525
  relayClientConnectionIds = /* @__PURE__ */ new Set();
526
+ relayClientInvocations = /* @__PURE__ */ new Map();
558
527
  heartbeatIntervalByConnectionId = /* @__PURE__ */ new Map();
559
528
  lastPongByConnectionId = /* @__PURE__ */ new Map();
560
529
  onStateChangedPushRelay = () => {
@@ -568,8 +537,6 @@ var RelayBridgeServer = class extends EventEmitter {
568
537
  * Maximum delay for relay-to-relay reconnection backoff in client mode.
569
538
  */
570
539
  clientMaxReconnectDelay = 3e3;
571
- clientMaxReconnectAttempts = 100;
572
- clientReconnectAttempts = 0;
573
540
  clientPendingInvocations = /* @__PURE__ */ new Map();
574
541
  clientTools = [];
575
542
  clientSources = [];
@@ -586,7 +553,6 @@ var RelayBridgeServer = class extends EventEmitter {
586
553
  this.desiredPort = this.preferredPort;
587
554
  this.portExplicitlySet = options.portExplicitlySet ?? false;
588
555
  this.portRangeEnd = options.portRangeEnd ?? Math.max(9348, this.preferredPort);
589
- this.persistPath = options.persistPath ?? defaultRelayPortPersistPath();
590
556
  this.allowedOrigins = options.allowedOrigins ?? ["*"];
591
557
  this.maxPayloadBytes = options.maxPayloadBytes ?? 1e7;
592
558
  this.invokeTimeoutMs = options.invokeTimeoutMs ?? 65e3;
@@ -649,39 +615,21 @@ var RelayBridgeServer = class extends EventEmitter {
649
615
  await this.startAsServer(0);
650
616
  return;
651
617
  }
652
- const strategyOptions = {
653
- defaultPort: this.preferredPort,
654
- host: this.host,
655
- persistPath: this.persistPath,
656
- rangeEnd: this.portRangeEnd
657
- };
658
- if (this.portExplicitlySet) strategyOptions.fixedPort = this.preferredPort;
659
- const candidates = await buildPortCandidates(strategyOptions);
660
- for (const candidate of candidates) try {
661
- await this.startAsServer(candidate.port);
662
- await this.persistSelectedPort();
618
+ const lastPort = this.portExplicitlySet ? this.preferredPort : this.portRangeEnd;
619
+ for (let port = this.preferredPort; port <= lastPort; port++) try {
620
+ await this.startAsServer(port);
663
621
  return;
664
622
  } catch (err) {
665
623
  if (!this.isAddressInUseError(err)) throw err;
666
- if (await this.tryAttachToExistingRelay(candidate.port)) {
667
- await this.persistSelectedPort();
668
- return;
669
- }
670
- if (candidate.wasFixed) throw new Error(`Port ${candidate.port} is already in use by a non-WebMCP service and cannot be shared.`);
671
- process.stderr.write(`[webmcp-local-relay] info: port ${candidate.port} is occupied by a non-relay service, trying next port\n`);
624
+ if (await this.tryAttachToExistingRelay(port)) return;
625
+ if (this.portExplicitlySet) throw new Error(`Port ${port} is already in use by a non-WebMCP service and cannot be shared.`);
626
+ process.stderr.write(`[webmcp-local-relay] info: port ${port} is occupied by a non-relay service, trying next port\n`);
672
627
  }
673
628
  throw new Error(`No compatible relay port was available in the range ${this.preferredPort}-${this.portRangeEnd}.`);
674
629
  }
675
630
  isAddressInUseError(cause) {
676
631
  return cause instanceof Error && ("code" in cause ? cause.code === "EADDRINUSE" : cause.message.includes("EADDRINUSE"));
677
632
  }
678
- async persistSelectedPort() {
679
- try {
680
- await persistPort(this.port, this.persistPath, this.host);
681
- } catch (error) {
682
- process.stderr.write(`[webmcp-local-relay] warn: could not cache relay port: ${error instanceof Error ? error.message : String(error)}\n`);
683
- }
684
- }
685
633
  async tryAttachToExistingRelay(port) {
686
634
  try {
687
635
  await this.startAsClient(port);
@@ -702,11 +650,7 @@ var RelayBridgeServer = class extends EventEmitter {
702
650
  this.clientReconnectTimer = null;
703
651
  }
704
652
  if (this._mode === "client") {
705
- for (const pending of this.clientPendingInvocations.values()) {
706
- clearTimeout(pending.timeoutId);
707
- pending.reject(/* @__PURE__ */ new Error("Relay client stopped"));
708
- }
709
- this.clientPendingInvocations.clear();
653
+ for (const pending of this.clientPendingInvocations.values()) pending.cancel(/* @__PURE__ */ new Error("Relay client stopped"));
710
654
  this.clientTools = [];
711
655
  this.clientSources = [];
712
656
  this.clientToolSourceMap = {};
@@ -721,6 +665,9 @@ var RelayBridgeServer = class extends EventEmitter {
721
665
  return;
722
666
  }
723
667
  this.off("stateChanged", this.onStateChangedPushRelay);
668
+ for (const pending of this.pendingInvocations.values()) pending.cancel(/* @__PURE__ */ new Error("Relay server stopped before tool invocation completed"));
669
+ for (const invocations of this.relayClientInvocations.values()) for (const controller of invocations.values()) controller.abort();
670
+ this.relayClientInvocations.clear();
724
671
  for (const [connectionId, socket] of this.socketByConnectionId) {
725
672
  this.registry.removeConnection(connectionId);
726
673
  try {
@@ -736,20 +683,19 @@ var RelayBridgeServer = class extends EventEmitter {
736
683
  this.requestOriginByConnectionId.clear();
737
684
  this.browserClientConnectionIds.clear();
738
685
  this.relayClientConnectionIds.clear();
739
- for (const pending of this.pendingInvocations.values()) {
740
- clearTimeout(pending.timeoutId);
741
- pending.reject(/* @__PURE__ */ new Error("Relay server stopped before tool invocation completed"));
742
- }
743
- this.pendingInvocations.clear();
744
686
  const wss = this.wss;
745
687
  this.wss = null;
746
688
  if (!wss) return;
689
+ const lingering = setTimeout(() => {
690
+ for (const client of wss.clients) client.terminate();
691
+ }, 500);
747
692
  await new Promise((resolve) => {
748
693
  wss.close((err) => {
749
694
  if (err) process.stderr.write(`[webmcp-local-relay] warn: WebSocket server close error: ${err.message}\n`);
750
695
  resolve();
751
696
  });
752
697
  });
698
+ clearTimeout(lingering);
753
699
  }
754
700
  /**
755
701
  * Sends a reload message to a connected browser source.
@@ -771,7 +717,8 @@ var RelayBridgeServer = class extends EventEmitter {
771
717
  * (client mode).
772
718
  */
773
719
  async invokeTool(toolName, args, options = {}) {
774
- if (this._mode === "client") return this.invokeToolViaRelay(toolName, args);
720
+ options.signal?.throwIfAborted();
721
+ if (this._mode === "client") return this.invokeToolViaRelay(toolName, args, options.signal);
775
722
  return this.invokeToolLocally(toolName, args, options);
776
723
  }
777
724
  async startAsServer(port = this.desiredPort) {
@@ -861,32 +808,12 @@ var RelayBridgeServer = class extends EventEmitter {
861
808
  if (!resolved) throw new Error(`No active browser source provides tool "${toolName}"`);
862
809
  const socket = this.socketByConnectionId.get(resolved.connectionId);
863
810
  if (!socket || socket.readyState !== WebSocket.OPEN) throw new Error(`Tool source ${resolved.connectionId} disconnected before invocation of "${toolName}"`);
864
- const callId = randomUUID();
865
- return new Promise((resolve, reject) => {
866
- const timeoutId = setTimeout(() => {
867
- this.pendingInvocations.delete(callId);
868
- reject(/* @__PURE__ */ new Error(`Invocation for tool "${toolName}" timed out after ${this.invokeTimeoutMs}ms`));
869
- }, this.invokeTimeoutMs);
870
- this.pendingInvocations.set(callId, {
871
- connectionId: resolved.connectionId,
872
- timeoutId,
873
- resolve,
874
- reject
875
- });
876
- const message = {
877
- type: "invoke",
878
- callId,
879
- toolName: resolved.tool.name,
880
- args
881
- };
882
- try {
883
- socket.send(JSON.stringify(message));
884
- } catch (err) {
885
- clearTimeout(timeoutId);
886
- this.pendingInvocations.delete(callId);
887
- reject(/* @__PURE__ */ new Error(`Failed to send invocation for tool "${toolName}": ${err instanceof Error ? err.message : err}`));
888
- }
889
- });
811
+ return this.requestTool(socket, this.pendingInvocations, resolved.connectionId, {
812
+ type: "invoke",
813
+ callId: randomUUID(),
814
+ toolName: resolved.tool.name,
815
+ args
816
+ }, `Invocation for tool "${toolName}" timed out after ${this.invokeTimeoutMs}ms`, options.signal);
890
817
  }
891
818
  /** Handles a raw message according to the negotiated WebSocket protocol. */
892
819
  onSocketMessage(connectionId, raw) {
@@ -970,8 +897,6 @@ var RelayBridgeServer = class extends EventEmitter {
970
897
  process.stderr.write(`[webmcp-local-relay] warn: connection ${connectionId} returned result for another source's callId ${message.callId}\n`);
971
898
  break;
972
899
  }
973
- clearTimeout(pending.timeoutId);
974
- this.pendingInvocations.delete(message.callId);
975
900
  pending.resolve(this.normalizeCallToolResult(message.result));
976
901
  break;
977
902
  }
@@ -1004,15 +929,33 @@ var RelayBridgeServer = class extends EventEmitter {
1004
929
  }
1005
930
  case "relay/invoke": {
1006
931
  const { callId, toolName, args } = message;
1007
- this.invokeToolLocally(toolName, args ?? {}, {}).then((result) => this.sendRelayResult(connectionId, callId, result), (error) => this.sendRelayResult(connectionId, callId, {
1008
- content: [{
1009
- type: "text",
1010
- text: `Relay invocation failed: ${error instanceof Error ? error.message : String(error)}`
1011
- }],
1012
- isError: true
1013
- }));
932
+ let invocations = this.relayClientInvocations.get(connectionId);
933
+ if (!invocations) {
934
+ invocations = /* @__PURE__ */ new Map();
935
+ this.relayClientInvocations.set(connectionId, invocations);
936
+ }
937
+ if (invocations.has(callId)) break;
938
+ const controller = new AbortController();
939
+ invocations.set(callId, controller);
940
+ this.invokeToolLocally(toolName, args ?? {}, { signal: controller.signal }).then((result) => {
941
+ if (!controller.signal.aborted) this.sendRelayResult(connectionId, callId, result);
942
+ }, (error) => {
943
+ if (!controller.signal.aborted) this.sendRelayResult(connectionId, callId, {
944
+ content: [{
945
+ type: "text",
946
+ text: `Relay invocation failed: ${error instanceof Error ? error.message : String(error)}`
947
+ }],
948
+ isError: true
949
+ });
950
+ }).finally(() => {
951
+ invocations.delete(callId);
952
+ if (invocations.size === 0) this.relayClientInvocations.delete(connectionId);
953
+ });
1014
954
  break;
1015
955
  }
956
+ case "relay/cancel":
957
+ this.relayClientInvocations.get(connectionId)?.get(message.callId)?.abort();
958
+ break;
1016
959
  }
1017
960
  }
1018
961
  sendRelayResult(connectionId, callId, result) {
@@ -1059,14 +1002,14 @@ var RelayBridgeServer = class extends EventEmitter {
1059
1002
  this.requestOriginByConnectionId.delete(connectionId);
1060
1003
  this.browserClientConnectionIds.delete(connectionId);
1061
1004
  this.relayClientConnectionIds.delete(connectionId);
1005
+ for (const controller of this.relayClientInvocations.get(connectionId)?.values() ?? []) controller.abort();
1006
+ this.relayClientInvocations.delete(connectionId);
1062
1007
  this.registry.removeConnection(connectionId);
1063
1008
  this.socketByConnectionId.delete(connectionId);
1064
1009
  this.emit("stateChanged");
1065
1010
  const isPayloadExceeded = code === 1009;
1066
- for (const [callId, pending] of this.pendingInvocations.entries()) {
1011
+ for (const pending of this.pendingInvocations.values()) {
1067
1012
  if (pending.connectionId !== connectionId) continue;
1068
- clearTimeout(pending.timeoutId);
1069
- this.pendingInvocations.delete(callId);
1070
1013
  if (isPayloadExceeded) pending.reject(/* @__PURE__ */ new Error(`Tool result exceeded maximum payload size (${formatPayloadLimit(this.maxPayloadBytes)}). Use --max-payload to increase the limit.`));
1071
1014
  else pending.reject(/* @__PURE__ */ new Error(`Tool source ${connectionId} disconnected during invocation`));
1072
1015
  }
@@ -1084,7 +1027,7 @@ var RelayBridgeServer = class extends EventEmitter {
1084
1027
  if (Date.now() - lastPong > HEARTBEAT_DEAD_THRESHOLD_MS) {
1085
1028
  process.stderr.write(`[webmcp-local-relay] warn: connection ${connectionId} missed heartbeat, closing\n`);
1086
1029
  this.stopHeartbeat(connectionId);
1087
- socket.close(1001, "Heartbeat timeout");
1030
+ socket.terminate();
1088
1031
  return;
1089
1032
  }
1090
1033
  try {
@@ -1104,7 +1047,6 @@ var RelayBridgeServer = class extends EventEmitter {
1104
1047
  this.lastPongByConnectionId.delete(connectionId);
1105
1048
  }
1106
1049
  async startAsClient(port = this.desiredPort) {
1107
- this.stopping = false;
1108
1050
  const previousMode = this._mode;
1109
1051
  const previousPort = this.desiredPort;
1110
1052
  this._mode = "client";
@@ -1113,7 +1055,6 @@ var RelayBridgeServer = class extends EventEmitter {
1113
1055
  const { bufferedMessages, socket } = await this.connectToRelayServer(port);
1114
1056
  this.clientSocket = socket;
1115
1057
  this.clientReconnectDelay = 500;
1116
- this.clientReconnectAttempts = 0;
1117
1058
  this.setupClientHandlers(socket, bufferedMessages);
1118
1059
  } catch (error) {
1119
1060
  this._mode = previousMode;
@@ -1194,11 +1135,7 @@ var RelayBridgeServer = class extends EventEmitter {
1194
1135
  });
1195
1136
  ws.on("close", () => {
1196
1137
  this.clientSocket = null;
1197
- for (const [callId, pending] of this.clientPendingInvocations) {
1198
- clearTimeout(pending.timeoutId);
1199
- this.clientPendingInvocations.delete(callId);
1200
- pending.reject(/* @__PURE__ */ new Error("Relay server connection lost during invocation"));
1201
- }
1138
+ for (const pending of this.clientPendingInvocations.values()) pending.reject(/* @__PURE__ */ new Error("Relay server connection lost during invocation"));
1202
1139
  this.clientTools = [];
1203
1140
  this.clientSources = [];
1204
1141
  this.clientToolSourceMap = {};
@@ -1247,8 +1184,6 @@ var RelayBridgeServer = class extends EventEmitter {
1247
1184
  process.stderr.write(`[webmcp-local-relay] warn: received relay result for unknown callId ${message.callId}\n`);
1248
1185
  break;
1249
1186
  }
1250
- clearTimeout(pending.timeoutId);
1251
- this.clientPendingInvocations.delete(message.callId);
1252
1187
  pending.resolve(message.result);
1253
1188
  break;
1254
1189
  }
@@ -1256,11 +1191,6 @@ var RelayBridgeServer = class extends EventEmitter {
1256
1191
  }
1257
1192
  scheduleReconnect() {
1258
1193
  if (this.clientReconnectTimer || this.stopping) return;
1259
- this.clientReconnectAttempts++;
1260
- if (this.clientReconnectAttempts >= this.clientMaxReconnectAttempts) {
1261
- process.stderr.write(`[webmcp-local-relay] error: giving up reconnection after ${this.clientReconnectAttempts} attempts\n`);
1262
- return;
1263
- }
1264
1194
  const delay = this.clientReconnectDelay;
1265
1195
  this.clientReconnectDelay = Math.min(this.clientReconnectDelay * 1.5, this.clientMaxReconnectDelay);
1266
1196
  this.clientReconnectTimer = setTimeout(() => {
@@ -1276,8 +1206,11 @@ var RelayBridgeServer = class extends EventEmitter {
1276
1206
  async reconnectWithModePromotion() {
1277
1207
  try {
1278
1208
  await this.startUsingPortStrategy();
1209
+ if (this.stopping) {
1210
+ await this.stop();
1211
+ return;
1212
+ }
1279
1213
  this.clientReconnectDelay = 500;
1280
- this.clientReconnectAttempts = 0;
1281
1214
  if (this._mode === "server") {
1282
1215
  process.stderr.write("[webmcp-local-relay] info: promoted from client to server mode\n");
1283
1216
  this.emit("stateChanged");
@@ -1287,33 +1220,59 @@ var RelayBridgeServer = class extends EventEmitter {
1287
1220
  if (!this.stopping) this.scheduleReconnect();
1288
1221
  }
1289
1222
  }
1290
- async invokeToolViaRelay(toolName, args) {
1223
+ async invokeToolViaRelay(toolName, args, signal) {
1291
1224
  if (!this.clientSocket || this.clientSocket.readyState !== WebSocket.OPEN) throw new Error("Not connected to relay server");
1292
- const callId = randomUUID();
1293
- const socket = this.clientSocket;
1225
+ return this.requestTool(this.clientSocket, this.clientPendingInvocations, "relay-server", {
1226
+ type: "relay/invoke",
1227
+ callId: randomUUID(),
1228
+ toolName,
1229
+ args
1230
+ }, `Proxied invocation for tool "${toolName}" timed out after ${this.invokeTimeoutMs}ms`, signal);
1231
+ }
1232
+ requestTool(socket, invocations, connectionId, message, timeoutMessage, signal) {
1233
+ signal?.throwIfAborted();
1234
+ const { callId } = message;
1294
1235
  return new Promise((resolve, reject) => {
1295
1236
  const timeoutId = setTimeout(() => {
1296
- this.clientPendingInvocations.delete(callId);
1297
- reject(/* @__PURE__ */ new Error(`Proxied invocation for tool "${toolName}" timed out after ${this.invokeTimeoutMs}ms`));
1237
+ pending.cancel(new Error(timeoutMessage));
1298
1238
  }, this.invokeTimeoutMs);
1299
- this.clientPendingInvocations.set(callId, {
1300
- connectionId: "relay-server",
1301
- timeoutId,
1302
- resolve,
1303
- reject
1304
- });
1305
- const message = {
1306
- type: "relay/invoke",
1307
- callId,
1308
- toolName,
1309
- args
1239
+ const onAbort = () => pending.cancel(signal?.reason);
1240
+ const cleanup = () => {
1241
+ if (!invocations.delete(callId)) return false;
1242
+ clearTimeout(timeoutId);
1243
+ signal?.removeEventListener("abort", onAbort);
1244
+ return true;
1310
1245
  };
1246
+ const pending = {
1247
+ connectionId,
1248
+ resolve: (result) => {
1249
+ if (cleanup()) resolve(result);
1250
+ },
1251
+ reject: (reason) => {
1252
+ if (cleanup()) reject(reason);
1253
+ },
1254
+ cancel: (reason) => {
1255
+ if (!cleanup()) return;
1256
+ try {
1257
+ if (socket.readyState === WebSocket.OPEN) {
1258
+ const cancellation = {
1259
+ type: message.type === "invoke" ? "cancel" : "relay/cancel",
1260
+ callId
1261
+ };
1262
+ socket.send(JSON.stringify(cancellation));
1263
+ }
1264
+ } catch (error) {
1265
+ process.stderr.write(`[webmcp-local-relay] warn: failed to cancel call ${callId}: ${error instanceof Error ? error.message : String(error)}\n`);
1266
+ }
1267
+ reject(reason);
1268
+ }
1269
+ };
1270
+ invocations.set(callId, pending);
1271
+ signal?.addEventListener("abort", onAbort, { once: true });
1311
1272
  try {
1312
1273
  socket.send(JSON.stringify(message));
1313
1274
  } catch (err) {
1314
- clearTimeout(timeoutId);
1315
- this.clientPendingInvocations.delete(callId);
1316
- reject(/* @__PURE__ */ new Error(`Failed to send relay invocation: ${err instanceof Error ? err.message : err}`));
1275
+ pending.reject(/* @__PURE__ */ new Error(`Failed to send invocation: ${err instanceof Error ? err.message : String(err)}`));
1317
1276
  }
1318
1277
  });
1319
1278
  }
@@ -1435,11 +1394,11 @@ function parseCliOptions(argv) {
1435
1394
  port,
1436
1395
  portExplicitlySet: values.port !== void 0
1437
1396
  };
1438
- if (values.label !== void 0) options.label = values.label;
1397
+ if (values.label) options.label = values.label;
1439
1398
  if (invokeTimeoutMs !== void 0) options.invokeTimeoutMs = invokeTimeoutMs;
1440
1399
  if (maxPayloadBytes !== void 0) options.maxPayloadBytes = maxPayloadBytes;
1441
- if (values["relay-id"] !== void 0) options.relayId = values["relay-id"];
1442
- if (values.workspace !== void 0) options.workspace = values.workspace;
1400
+ if (values["relay-id"]) options.relayId = values["relay-id"];
1401
+ if (values.workspace) options.workspace = values.workspace;
1443
1402
  return options;
1444
1403
  }
1445
1404
  /**
@@ -1471,6 +1430,12 @@ function printHelp() {
1471
1430
  //#endregion
1472
1431
  //#region src/mcpRelayServer.ts
1473
1432
  const JsonSchemaObjectSchema = z.record(z.string(), z.unknown());
1433
+ /** Tools the relay registers itself, matching `registerStaticTools`. */
1434
+ const RELAY_TOOL_NAMES = new Set([
1435
+ "webmcp_list_sources",
1436
+ "webmcp_list_tools",
1437
+ "webmcp_open_page"
1438
+ ]);
1474
1439
  function textResult(text) {
1475
1440
  return { content: [{
1476
1441
  type: "text",
@@ -1497,6 +1462,8 @@ var LocalRelayMcpServer = class {
1497
1462
  mcpServer;
1498
1463
  launchBrowser;
1499
1464
  dynamicTools = /* @__PURE__ */ new Map();
1465
+ /** Page tools that share a name with a relay tool, already reported once. */
1466
+ shadowedToolNames = /* @__PURE__ */ new Set();
1500
1467
  syncDebounceTimer = null;
1501
1468
  connected = false;
1502
1469
  /**
@@ -1720,6 +1687,13 @@ var LocalRelayMcpServer = class {
1720
1687
  this.dynamicTools.delete(name);
1721
1688
  }
1722
1689
  for (const tool of tools) {
1690
+ if (RELAY_TOOL_NAMES.has(tool.name)) {
1691
+ if (!this.shadowedToolNames.has(tool.name)) {
1692
+ this.shadowedToolNames.add(tool.name);
1693
+ process.stderr.write(`[webmcp-local-relay] warn: skipped page tool "${tool.name}" because a built-in relay tool has that name\n`);
1694
+ }
1695
+ continue;
1696
+ }
1723
1697
  const signature = this.toolSignature(tool);
1724
1698
  const current = this.dynamicTools.get(tool.name);
1725
1699
  if (current?.signature === signature) continue;
@@ -1754,10 +1728,11 @@ var LocalRelayMcpServer = class {
1754
1728
  if (tool.annotations) registration.annotations = tool.annotations;
1755
1729
  if (tool.icons) registration.icons = tool.icons;
1756
1730
  if (tool._meta) registration._meta = tool._meta;
1757
- return this.mcpServer.registerTool(tool.name, registration, async (args) => {
1731
+ return this.mcpServer.registerTool(tool.name, registration, async (args, context) => {
1758
1732
  try {
1759
- return await this.bridge.invokeTool(tool.name, args);
1733
+ return await this.bridge.invokeTool(tool.name, args, { signal: context.mcpReq.signal });
1760
1734
  } catch (err) {
1735
+ if (context.mcpReq.signal.aborted) throw err;
1761
1736
  const message = err instanceof Error ? err.message : String(err);
1762
1737
  const details = err instanceof Error ? err.stack ?? err.message : String(err);
1763
1738
  process.stderr.write(`[webmcp-local-relay] error: dynamic tool "${tool.name}" invocation failed: ${details}\n`);
@@ -1800,4 +1775,4 @@ var LocalRelayMcpServer = class {
1800
1775
 
1801
1776
  //#endregion
1802
1777
  export { NormalizedToolSchema as C, normalizeInboundTool as D, ToolSchema as E, InboundToolSchema as S, ToolAnnotationsSchema as T, extractSanitizedDomain as _, BrowserToRelayMessageSchema as a, CallToolResultSchema as b, RelayHelloAcceptedMessageSchema as c, RelaySourceInfoSchema as d, RelayToBrowserMessageSchema as f, buildPublicToolName as g, RelayRegistry as h, RelayBridgeServer as i, RelayHelloRejectedMessageSchema as l, HelloRequiredError as m, parseCliOptions as n, RelayClientToServerMessageSchema as o, ServerHelloMessageSchema as p, printHelp as r, RelayDescriptorSchema as s, LocalRelayMcpServer as t, RelayServerToClientMessageSchema as u, sanitizeName as v, RelayInvokeArgsSchema as w, DEFAULT_TOOL_INPUT_SCHEMA as x, CallToolRequestParamsSchema as y };
1803
- //# sourceMappingURL=mcpRelayServer-DEgebuO2.mjs.map
1778
+ //# sourceMappingURL=mcpRelayServer-DkidP4o7.mjs.map