@mcp-b/webmcp-local-relay 4.0.0 → 5.0.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.
@@ -4,12 +4,12 @@ import WebSocket, { WebSocketServer } from "ws";
4
4
  import { mkdir, readFile, writeFile } from "node:fs/promises";
5
5
  import { homedir } from "node:os";
6
6
  import { dirname, join } from "node:path";
7
- import { CallToolRequestParamsSchema, CallToolResultSchema, ListToolsRequestSchema, ToolAnnotationsSchema, ToolSchema } from "@modelcontextprotocol/sdk/types.js";
7
+ import { CallToolRequestParamsSchema, CallToolResultSchema, ToolAnnotationsSchema, ToolSchema } from "@modelcontextprotocol/core";
8
8
  import { z } from "zod/v4";
9
+ import { parseArgs } from "node:util";
9
10
  import { execFile } from "node:child_process";
10
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
12
- import { toJsonSchemaCompat } from "@modelcontextprotocol/sdk/server/zod-json-schema-compat.js";
11
+ import { McpServer, fromJsonSchema } from "@modelcontextprotocol/server";
12
+ import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
13
13
 
14
14
  //#region src/portStrategy.ts
15
15
  const DEFAULT_RELAY_PORT = 9333;
@@ -34,7 +34,7 @@ async function readPersistedPort(path = defaultRelayPortPersistPath(), options =
34
34
  if (typeof parsed.port !== "number" || !Number.isInteger(parsed.port) || parsed.port < 1 || parsed.port > 65535) return null;
35
35
  if (typeof parsed.host !== "string" || parsed.host.length === 0) return null;
36
36
  if (options.expectedHost && parsed.host !== options.expectedHost) return null;
37
- const maxAgeMs = options.maxAgeMs ?? 864e5;
37
+ const maxAgeMs = options.maxAgeMs ?? RELAY_PORT_CACHE_MAX_AGE_MS;
38
38
  const now = options.now ?? Date.now();
39
39
  const updatedAtMs = typeof parsed.updatedAt === "string" ? Date.parse(parsed.updatedAt) : NaN;
40
40
  if (!Number.isFinite(updatedAtMs) || now - updatedAtMs > maxAgeMs) return null;
@@ -75,13 +75,6 @@ async function buildPortCandidates(options) {
75
75
  */
76
76
  const NormalizedToolSchema = ToolSchema.extend({ name: ToolSchema.shape.name.min(1) });
77
77
  /**
78
- * Permissive inbound tool shape from browser/widget payloads.
79
- *
80
- * Only enforces a non-empty name at ingest. All other fields are normalized
81
- * against SDK schemas by {@link normalizeInboundTool}.
82
- */
83
- const InboundToolSchema = z.object({ name: z.string().min(1) }).passthrough();
84
- /**
85
78
  * SDK-derived argument schema for tool invocation payloads.
86
79
  */
87
80
  const RelayInvokeArgsSchema = CallToolRequestParamsSchema.shape.arguments;
@@ -93,30 +86,16 @@ const DEFAULT_TOOL_INPUT_SCHEMA = {
93
86
  type: "object",
94
87
  properties: {}
95
88
  };
89
+ /** SDK tool schema with a browser-compatible default input schema. */
90
+ const InboundToolSchema = NormalizedToolSchema.extend({ inputSchema: NormalizedToolSchema.shape.inputSchema.optional() });
96
91
  /**
97
- * Normalizes permissive inbound tool payloads into SDK-compliant Tool objects.
98
- *
99
- * Invalid optional metadata (description, output schema, annotations, etc.)
100
- * is dropped. Invalid/missing inputSchema falls back to an empty object schema.
92
+ * Applies the browser default input schema and validates the complete SDK tool.
101
93
  */
102
94
  function normalizeInboundTool(inbound) {
103
- const inputSchemaParsed = ToolSchema.shape.inputSchema.safeParse(inbound.inputSchema);
104
- const outputSchemaParsed = ToolSchema.shape.outputSchema.safeParse(inbound.outputSchema);
105
- const annotationsParsed = ToolAnnotationsSchema.safeParse(inbound.annotations);
106
- const normalizedCandidate = {
107
- name: inbound.name,
108
- inputSchema: inputSchemaParsed.success ? inputSchemaParsed.data : DEFAULT_TOOL_INPUT_SCHEMA
109
- };
110
- if (typeof inbound.description === "string") normalizedCandidate.description = inbound.description;
111
- if (typeof inbound.title === "string") normalizedCandidate.title = inbound.title;
112
- if (outputSchemaParsed.success && outputSchemaParsed.data !== void 0) normalizedCandidate.outputSchema = outputSchemaParsed.data;
113
- if (annotationsParsed.success && annotationsParsed.data !== void 0) normalizedCandidate.annotations = annotationsParsed.data;
114
- const normalizedParsed = NormalizedToolSchema.safeParse(normalizedCandidate);
115
- if (normalizedParsed.success) return normalizedParsed.data;
116
- return {
117
- name: inbound.name,
118
- inputSchema: DEFAULT_TOOL_INPUT_SCHEMA
119
- };
95
+ return NormalizedToolSchema.parse({
96
+ ...inbound,
97
+ inputSchema: inbound.inputSchema ?? DEFAULT_TOOL_INPUT_SCHEMA
98
+ });
120
99
  }
121
100
 
122
101
  //#endregion
@@ -156,12 +135,10 @@ function extractSanitizedDomain(originOrUrl) {
156
135
  */
157
136
  function buildPublicToolName(options) {
158
137
  const safeName = sanitizeName(options.originalToolName);
159
- if (!options.disambiguate || !options.tabId) return safeName.slice(0, MAX_MCP_TOOL_NAME_LENGTH);
138
+ if (!options.disambiguate || !options.tabId) return safeName.slice(0, 128);
160
139
  const suffix = `_${sanitizeName(options.tabId).slice(0, TAB_ID_DISAMBIGUATION_LENGTH)}`;
161
- const base = `${safeName}${suffix}`;
162
- if (base.length <= MAX_MCP_TOOL_NAME_LENGTH) return base;
163
- const available = MAX_MCP_TOOL_NAME_LENGTH - suffix.length;
164
- return `${safeName.slice(0, Math.max(1, available))}${suffix}`;
140
+ const available = 128 - suffix.length;
141
+ return `${safeName.slice(0, available)}${suffix}`;
165
142
  }
166
143
 
167
144
  //#endregion
@@ -217,7 +194,6 @@ var RelayRegistry = class {
217
194
  const source = this.sourceByConnectionId.get(connectionId);
218
195
  if (!source) throw new HelloRequiredError(connectionId);
219
196
  this.touchConnection(connectionId);
220
- this.removeConnectionTools(connectionId);
221
197
  const providers = tools.map((tool) => ({
222
198
  sourceId: connectionId,
223
199
  tabId: source.tabId,
@@ -231,7 +207,7 @@ var RelayRegistry = class {
231
207
  * Removes a source and all of its tool registrations.
232
208
  */
233
209
  removeConnection(connectionId) {
234
- this.removeConnectionTools(connectionId);
210
+ this.toolsByConnectionId.delete(connectionId);
235
211
  this.sourceByConnectionId.delete(connectionId);
236
212
  this.rebuildPublicNames();
237
213
  }
@@ -283,31 +259,10 @@ var RelayRegistry = class {
283
259
  const providers = this.providersByPublicToolName.get(options.toolName);
284
260
  if (!providers || providers.length === 0) return null;
285
261
  const rankedProviders = this.sortProvidersByRecency(providers);
286
- if (options.sourceId) {
287
- const byConnection = rankedProviders.find((provider) => provider.sourceId === options.sourceId);
288
- if (byConnection) return {
289
- connectionId: byConnection.sourceId,
290
- tool: byConnection.tool,
291
- publicToolName: byConnection.publicToolName
292
- };
293
- const byTabId = rankedProviders.find((provider) => provider.tabId === options.sourceId);
294
- if (byTabId) return {
295
- connectionId: byTabId.sourceId,
296
- tool: byTabId.tool,
297
- publicToolName: byTabId.publicToolName
298
- };
299
- return null;
300
- }
301
- if (options.requestTabId) {
302
- const byRequestTab = rankedProviders.find((provider) => provider.tabId === options.requestTabId);
303
- if (!byRequestTab) return null;
304
- return {
305
- connectionId: byRequestTab.sourceId,
306
- tool: byRequestTab.tool,
307
- publicToolName: byRequestTab.publicToolName
308
- };
309
- }
310
- const provider = rankedProviders[0];
262
+ let provider;
263
+ if (options.sourceId) provider = rankedProviders.find((candidate) => candidate.sourceId === options.sourceId) ?? rankedProviders.find((candidate) => candidate.tabId === options.sourceId);
264
+ else if (options.requestTabId) provider = rankedProviders.find((candidate) => candidate.tabId === options.requestTabId);
265
+ else provider = rankedProviders[0];
311
266
  if (!provider) return null;
312
267
  return {
313
268
  connectionId: provider.sourceId,
@@ -325,12 +280,6 @@ var RelayRegistry = class {
325
280
  };
326
281
  }
327
282
  /**
328
- * Removes all tool providers for a connection.
329
- */
330
- removeConnectionTools(connectionId) {
331
- this.toolsByConnectionId.delete(connectionId);
332
- }
333
- /**
334
283
  * Rebuilds public tool names after source set changes.
335
284
  *
336
285
  * When the same original tool name appears in multiple tabs, names are
@@ -345,18 +294,36 @@ var RelayRegistry = class {
345
294
  byOriginalName.set(key, group);
346
295
  }
347
296
  this.providersByPublicToolName.clear();
348
- for (const [, providers] of byOriginalName) {
297
+ const allocations = [];
298
+ for (const [originalName, providers] of byOriginalName) {
349
299
  const disambiguate = new Set(providers.map((p) => p.tabId)).size > 1;
350
- for (const provider of providers) {
351
- provider.publicToolName = buildPublicToolName({
352
- originalToolName: provider.tool.name,
300
+ for (const provider of providers) allocations.push({
301
+ candidate: buildPublicToolName({
302
+ originalToolName: originalName,
353
303
  tabId: provider.tabId,
354
304
  disambiguate
355
- });
356
- const existing = this.providersByPublicToolName.get(provider.publicToolName) ?? [];
357
- existing.push(provider);
358
- this.providersByPublicToolName.set(provider.publicToolName, this.sortProvidersByRecency(existing));
305
+ }),
306
+ key: disambiguate ? `${originalName}\0${provider.tabId}` : originalName,
307
+ provider
308
+ });
309
+ }
310
+ const nameByKey = /* @__PURE__ */ new Map();
311
+ const usedNames = /* @__PURE__ */ new Set();
312
+ for (const { candidate, key, provider } of allocations.sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0)) {
313
+ let publicToolName = nameByKey.get(key);
314
+ if (!publicToolName) {
315
+ publicToolName = candidate;
316
+ for (let index = 2; usedNames.has(publicToolName); index += 1) {
317
+ const suffix = `_${String(index)}`;
318
+ publicToolName = `${candidate.slice(0, 128 - suffix.length)}${suffix}`;
319
+ }
320
+ nameByKey.set(key, publicToolName);
321
+ usedNames.add(publicToolName);
359
322
  }
323
+ provider.publicToolName = publicToolName;
324
+ const existing = this.providersByPublicToolName.get(publicToolName) ?? [];
325
+ existing.push(provider);
326
+ this.providersByPublicToolName.set(publicToolName, existing);
360
327
  }
361
328
  }
362
329
  /**
@@ -383,9 +350,9 @@ var RelayRegistry = class {
383
350
 
384
351
  //#endregion
385
352
  //#region src/schemas.ts
386
- /**
387
- * Schema for source identity bootstrap message.
388
- */
353
+ function normalizeSupportedInboundTools(tools) {
354
+ return tools.map(normalizeInboundTool).filter((tool) => tool.execution?.taskSupport !== "required");
355
+ }
389
356
  const BrowserHelloMessageSchema = z.object({
390
357
  type: z.literal("hello"),
391
358
  tabId: z.string().min(1),
@@ -394,56 +361,21 @@ const BrowserHelloMessageSchema = z.object({
394
361
  title: z.string().optional(),
395
362
  iconUrl: z.string().optional()
396
363
  });
397
- /**
398
- * Schema for full tool-list synchronization message.
399
- *
400
- * Inbound tools are permissively parsed then normalized to SDK Tool shape.
401
- */
402
364
  const BrowserToolsListMessageSchema = z.object({
403
365
  type: z.literal("tools/list"),
404
- tools: z.array(InboundToolSchema).transform((tools) => tools.map(normalizeInboundTool))
366
+ tools: z.array(InboundToolSchema).transform(normalizeSupportedInboundTools)
405
367
  });
406
- /**
407
- * Schema for tool-set replacement notification pushed after initial registration.
408
- *
409
- * Processing is identical to `tools/list` — the full tool set replaces any
410
- * previously registered tools — but the distinct type signals a dynamic update
411
- * rather than an initial handshake.
412
- */
413
368
  const BrowserToolsChangedMessageSchema = z.object({
414
369
  type: z.literal("tools/changed"),
415
- tools: z.array(InboundToolSchema).transform((tools) => tools.map(normalizeInboundTool))
370
+ tools: z.array(InboundToolSchema).transform(normalizeSupportedInboundTools)
416
371
  });
417
- /**
418
- * Schema for invocation response payloads sent by browser sources.
419
- *
420
- * `result` remains permissive and is normalized later to preserve runtime
421
- * behavior for malformed tool responses.
422
- */
423
372
  const BrowserToolResultMessageSchema = z.object({
424
373
  type: z.literal("result"),
425
374
  callId: z.string().min(1),
426
375
  result: z.unknown()
427
376
  });
428
- /**
429
- * Schema for browser heartbeat acknowledgement.
430
- */
431
377
  const BrowserPongMessageSchema = z.object({ type: z.literal("pong") });
432
378
  /**
433
- * Schema for elicitation request forwarded from a browser tool handler.
434
- *
435
- * When a WebMCP tool handler calls `elicitInput()`, the embed script
436
- * intercepts the call and forwards the parameters to the relay via this
437
- * message type. The relay then sends `elicitation/create` to the MCP
438
- * client (e.g. Claude Code) and returns the result via an
439
- * `elicitation-response` message.
440
- */
441
- const BrowserElicitationRequestSchema = z.object({
442
- type: z.literal("elicitation-request"),
443
- callId: z.string().min(1),
444
- params: z.record(z.string(), z.unknown())
445
- });
446
- /**
447
379
  * Union schema for all browser-to-relay protocol messages.
448
380
  */
449
381
  const BrowserToRelayMessageSchema = z.discriminatedUnion("type", [
@@ -451,8 +383,7 @@ const BrowserToRelayMessageSchema = z.discriminatedUnion("type", [
451
383
  BrowserToolsListMessageSchema,
452
384
  BrowserToolsChangedMessageSchema,
453
385
  BrowserToolResultMessageSchema,
454
- BrowserPongMessageSchema,
455
- BrowserElicitationRequestSchema
386
+ BrowserPongMessageSchema
456
387
  ]);
457
388
  /**
458
389
  * Shared relay identity used for discovery and attach verification.
@@ -486,34 +417,15 @@ const RelayHelloRejectedMessageSchema = z.object({
486
417
  reason: z.string().min(1),
487
418
  message: z.string().min(1)
488
419
  });
489
- /**
490
- * Schema for relay invocation messages sent to browser sources.
491
- */
492
420
  const RelayInvokeMessageSchema = z.object({
493
421
  type: z.literal("invoke"),
494
422
  callId: z.string().min(1),
495
423
  toolName: z.string().min(1),
496
424
  args: RelayInvokeArgsSchema
497
425
  });
498
- /**
499
- * Schema for relay heartbeat messages sent to browser sources.
500
- */
501
426
  const RelayPingMessageSchema = z.object({ type: z.literal("ping") });
502
- /**
503
- * Schema for relay reload messages sent to browser sources.
504
- */
505
427
  const RelayReloadMessageSchema = z.object({ type: z.literal("reload") });
506
428
  /**
507
- * Schema for elicitation response sent from relay to browser.
508
- *
509
- * Contains the MCP client's response to an `elicitation/create` request.
510
- */
511
- const RelayElicitationResponseSchema = z.object({
512
- type: z.literal("elicitation-response"),
513
- callId: z.string().min(1),
514
- result: z.record(z.string(), z.unknown())
515
- });
516
- /**
517
429
  * Union schema for all relay-to-browser protocol messages.
518
430
  */
519
431
  const RelayToBrowserMessageSchema = z.discriminatedUnion("type", [
@@ -522,13 +434,9 @@ const RelayToBrowserMessageSchema = z.discriminatedUnion("type", [
522
434
  RelayHelloRejectedMessageSchema,
523
435
  RelayInvokeMessageSchema,
524
436
  RelayPingMessageSchema,
525
- RelayReloadMessageSchema,
526
- RelayElicitationResponseSchema
437
+ RelayReloadMessageSchema
527
438
  ]);
528
439
  /**
529
- * Relay-to-relay protocol (client mode <-> server mode).
530
- */
531
- /**
532
440
  * Schema for source metadata transmitted in relay-to-relay messages.
533
441
  */
534
442
  const RelaySourceInfoSchema = z.object({
@@ -542,17 +450,8 @@ const RelaySourceInfoSchema = z.object({
542
450
  lastSeenAt: z.number(),
543
451
  toolCount: z.number()
544
452
  });
545
- /**
546
- * Schema for relay client identification message.
547
- */
548
453
  const RelayClientHelloSchema = z.object({ type: z.literal("relay/hello") });
549
- /**
550
- * Schema for relay client tool list request.
551
- */
552
454
  const RelayClientListToolsSchema = z.object({ type: z.literal("relay/list-tools") });
553
- /**
554
- * Schema for relay client tool invocation request.
555
- */
556
455
  const RelayClientInvokeSchema = z.object({
557
456
  type: z.literal("relay/invoke"),
558
457
  callId: z.string().min(1),
@@ -569,27 +468,18 @@ const RelayClientToServerMessageSchema = z.discriminatedUnion("type", [
569
468
  ]);
570
469
  const RelayToolsPayloadFields = {
571
470
  tools: z.array(NormalizedToolSchema),
572
- sources: z.array(RelaySourceInfoSchema).optional().default([]),
573
- toolSourceMap: z.record(z.string(), z.array(z.string())).optional().default({})
471
+ sources: z.array(RelaySourceInfoSchema),
472
+ toolSourceMap: z.record(z.string(), z.array(z.string()))
574
473
  };
575
- /**
576
- * Schema for relay server tool list response.
577
- */
578
474
  const RelayServerToolsSchema = z.object({
579
475
  type: z.literal("relay/tools"),
580
476
  ...RelayToolsPayloadFields
581
477
  });
582
- /**
583
- * Schema for relay server invocation result response.
584
- */
585
478
  const RelayServerResultSchema = z.object({
586
479
  type: z.literal("relay/result"),
587
480
  callId: z.string().min(1),
588
481
  result: CallToolResultSchema
589
482
  });
590
- /**
591
- * Schema for relay server push notification when tools change.
592
- */
593
483
  const RelayServerToolsChangedSchema = z.object({
594
484
  type: z.literal("relay/tools-changed"),
595
485
  ...RelayToolsPayloadFields
@@ -651,7 +541,9 @@ var RelayBridgeServer = class extends EventEmitter {
651
541
  instanceId;
652
542
  wss = null;
653
543
  socketByConnectionId = /* @__PURE__ */ new Map();
544
+ requestOriginByConnectionId = /* @__PURE__ */ new Map();
654
545
  pendingInvocations = /* @__PURE__ */ new Map();
546
+ browserClientConnectionIds = /* @__PURE__ */ new Set();
655
547
  relayClientConnectionIds = /* @__PURE__ */ new Set();
656
548
  heartbeatIntervalByConnectionId = /* @__PURE__ */ new Map();
657
549
  lastPongByConnectionId = /* @__PURE__ */ new Map();
@@ -687,15 +579,15 @@ var RelayBridgeServer = class extends EventEmitter {
687
579
  this.persistPath = options.persistPath ?? defaultRelayPortPersistPath();
688
580
  this.allowedOrigins = options.allowedOrigins ?? ["*"];
689
581
  this.maxPayloadBytes = options.maxPayloadBytes ?? 1e7;
690
- this.invokeTimeoutMs = options.invokeTimeoutMs ?? 25e3;
582
+ this.invokeTimeoutMs = options.invokeTimeoutMs ?? 65e3;
691
583
  this.label = options.label;
692
584
  this.workspace = options.workspace;
693
585
  this.relayId = options.relayId;
694
586
  this.instanceId = randomUUID();
695
- 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.`);
696
- 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.`);
587
+ if (!Number.isInteger(this.preferredPort) || this.preferredPort < 0 || this.preferredPort > 65535) throw new Error(`Invalid port ${this.preferredPort}. Port must be 0 (auto-assign) or between 1 and 65535.`);
588
+ if (!Number.isInteger(this.portRangeEnd) || this.portRangeEnd < 1 || this.portRangeEnd > 65535 || this.portRangeEnd < this.preferredPort && this.preferredPort !== 0) throw new Error(`Invalid port range ${this.preferredPort}-${this.portRangeEnd}. rangeEnd must be an integer between the preferred port and 65535.`);
697
589
  if (!Number.isInteger(this.maxPayloadBytes) || this.maxPayloadBytes <= 0) throw new Error(`Invalid maxPayloadBytes ${this.maxPayloadBytes}. Must be a positive integer.`);
698
- if (this.invokeTimeoutMs <= 0) throw new Error(`Invalid invokeTimeoutMs ${this.invokeTimeoutMs}. Must be greater than 0.`);
590
+ if (!Number.isInteger(this.invokeTimeoutMs) || this.invokeTimeoutMs <= 0) throw new Error(`Invalid invokeTimeoutMs ${this.invokeTimeoutMs}. Must be a positive integer.`);
699
591
  }
700
592
  /**
701
593
  * Current operating mode.
@@ -756,12 +648,12 @@ var RelayBridgeServer = class extends EventEmitter {
756
648
  });
757
649
  for (const candidate of candidates) try {
758
650
  await this.startAsServer(candidate.port);
759
- await persistPort(this.port, this.persistPath, this.host);
651
+ await this.persistSelectedPort();
760
652
  return;
761
653
  } catch (err) {
762
654
  if (!this.isAddressInUseError(err)) throw err;
763
655
  if (await this.tryAttachToExistingRelay(candidate.port)) {
764
- await persistPort(this.port, this.persistPath, this.host);
656
+ await this.persistSelectedPort();
765
657
  return;
766
658
  }
767
659
  if (candidate.wasFixed) throw new Error(`Port ${candidate.port} is already in use by a non-WebMCP service and cannot be shared.`);
@@ -772,12 +664,20 @@ var RelayBridgeServer = class extends EventEmitter {
772
664
  isAddressInUseError(error) {
773
665
  return error instanceof Error && ("code" in error ? error.code === "EADDRINUSE" : error.message.includes("EADDRINUSE"));
774
666
  }
667
+ async persistSelectedPort() {
668
+ try {
669
+ await persistPort(this.port, this.persistPath, this.host);
670
+ } catch (error) {
671
+ process.stderr.write(`[webmcp-local-relay] warn: could not cache relay port: ${error instanceof Error ? error.message : String(error)}\n`);
672
+ }
673
+ }
775
674
  async tryAttachToExistingRelay(port) {
776
675
  try {
777
676
  await this.startAsClient(port);
778
677
  process.stderr.write(`[webmcp-local-relay] info: discovered compatible relay on port ${port}, switching to client mode\n`);
779
678
  return true;
780
- } catch {
679
+ } catch (error) {
680
+ process.stderr.write(`[webmcp-local-relay] info: port ${port} did not complete a relay handshake: ${error instanceof Error ? error.message : String(error)}\n`);
781
681
  return false;
782
682
  }
783
683
  }
@@ -819,6 +719,8 @@ var RelayBridgeServer = class extends EventEmitter {
819
719
  this.heartbeatIntervalByConnectionId.clear();
820
720
  this.lastPongByConnectionId.clear();
821
721
  this.socketByConnectionId.clear();
722
+ this.requestOriginByConnectionId.clear();
723
+ this.browserClientConnectionIds.clear();
822
724
  this.relayClientConnectionIds.clear();
823
725
  for (const pending of this.pendingInvocations.values()) {
824
726
  clearTimeout(pending.timeoutId);
@@ -880,16 +782,15 @@ var RelayBridgeServer = class extends EventEmitter {
880
782
  server.once("listening", onListening);
881
783
  server.once("error", onError);
882
784
  });
883
- wss.on("connection", (socket) => {
884
- const connectionId = randomUUID();
885
- this.socketByConnectionId.set(connectionId, socket);
886
- try {
887
- socket.send(JSON.stringify(this.buildServerHello()));
888
- } catch (err) {
889
- process.stderr.write(`[webmcp-local-relay] warn: failed to send server hello to connection ${connectionId}: ${err instanceof Error ? err.message : String(err)}\n`);
890
- socket.close(1011, "Failed to send server hello");
785
+ wss.on("connection", (socket, request) => {
786
+ const requestOrigin = request.headers.origin;
787
+ if (requestOrigin && socket.protocol === RELAY_INTERNAL_PROTOCOL) {
788
+ socket.close(1008, "Browser connections cannot use the relay protocol");
891
789
  return;
892
790
  }
791
+ const connectionId = randomUUID();
792
+ this.socketByConnectionId.set(connectionId, socket);
793
+ if (requestOrigin) this.requestOriginByConnectionId.set(connectionId, requestOrigin);
893
794
  socket.on("message", (raw) => {
894
795
  this.onSocketMessage(connectionId, raw);
895
796
  });
@@ -898,14 +799,20 @@ var RelayBridgeServer = class extends EventEmitter {
898
799
  });
899
800
  socket.on("error", (err) => {
900
801
  process.stderr.write(`[webmcp-local-relay] warn: socket error for connection ${connectionId}: ${err.message}\n`);
901
- const code = err.code === "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH" ? 1009 : void 0;
802
+ const code = "code" in err && err.code === "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH" ? 1009 : void 0;
902
803
  this.onSocketClose(connectionId, code);
903
804
  });
805
+ try {
806
+ socket.send(JSON.stringify(this.buildServerHello()));
807
+ } catch (err) {
808
+ process.stderr.write(`[webmcp-local-relay] warn: failed to send server hello to connection ${connectionId}: ${err instanceof Error ? err.message : String(err)}\n`);
809
+ socket.close(1011, "Failed to send server hello");
810
+ return;
811
+ }
904
812
  this.startHeartbeat(connectionId);
905
813
  });
906
814
  wss.on("error", (err) => {
907
815
  process.stderr.write(`[webmcp-local-relay] error: WebSocket server error: ${err.message}\n`);
908
- this.emit("error", err);
909
816
  });
910
817
  this.wss = wss;
911
818
  this._mode = "server";
@@ -926,11 +833,18 @@ var RelayBridgeServer = class extends EventEmitter {
926
833
  workspace: this.workspace
927
834
  };
928
835
  }
929
- invokeToolLocally(toolName, args, options) {
930
- const resolveOptions = { toolName };
931
- if (options.sourceId !== void 0) resolveOptions.sourceId = options.sourceId;
932
- if (options.requestTabId !== void 0) resolveOptions.requestTabId = options.requestTabId;
933
- const resolved = this.registry.resolveInvocation(resolveOptions);
836
+ /**
837
+ * `async` is load-bearing: {@link onRelayClientMessage} consumes this through
838
+ * `.then(onResult, onError)`, so a synchronous throw would escape the socket
839
+ * message handler as an uncaught exception and leave the relay client waiting
840
+ * for a `relay/result` that never arrives.
841
+ */
842
+ async invokeToolLocally(toolName, args, options) {
843
+ const resolved = this.registry.resolveInvocation({
844
+ toolName,
845
+ ...options.sourceId === void 0 ? {} : { sourceId: options.sourceId },
846
+ ...options.requestTabId === void 0 ? {} : { requestTabId: options.requestTabId }
847
+ });
934
848
  if (!resolved) throw new Error(`No active browser source provides tool "${toolName}"`);
935
849
  const socket = this.socketByConnectionId.get(resolved.connectionId);
936
850
  if (!socket || socket.readyState !== WebSocket.OPEN) throw new Error(`Tool source ${resolved.connectionId} disconnected before invocation of "${toolName}"`);
@@ -941,7 +855,6 @@ var RelayBridgeServer = class extends EventEmitter {
941
855
  reject(/* @__PURE__ */ new Error(`Invocation for tool "${toolName}" timed out after ${this.invokeTimeoutMs}ms`));
942
856
  }, this.invokeTimeoutMs);
943
857
  this.pendingInvocations.set(callId, {
944
- callId,
945
858
  connectionId: resolved.connectionId,
946
859
  timeoutId,
947
860
  resolve,
@@ -962,12 +875,7 @@ var RelayBridgeServer = class extends EventEmitter {
962
875
  }
963
876
  });
964
877
  }
965
- /**
966
- * Handles a raw WebSocket message from a connected source.
967
- *
968
- * Routes relay-protocol messages (`relay/*`) to the relay client handler
969
- * and browser-protocol messages to the existing browser handler.
970
- */
878
+ /** Handles a raw message according to the negotiated WebSocket protocol. */
971
879
  onSocketMessage(connectionId, raw) {
972
880
  const text = this.rawDataToUtf8(raw);
973
881
  let parsedJson;
@@ -978,26 +886,40 @@ var RelayBridgeServer = class extends EventEmitter {
978
886
  process.stderr.write(`[webmcp-local-relay] warn: invalid JSON from connection ${connectionId} (${err instanceof Error ? err.message : "parse error"}): ${preview}\n`);
979
887
  return;
980
888
  }
981
- const typeField = typeof parsedJson === "object" && parsedJson !== null ? parsedJson.type : void 0;
982
- if (typeof typeField === "string" && typeField.startsWith("relay/")) {
889
+ const typeField = typeof parsedJson === "object" && parsedJson !== null && "type" in parsedJson ? parsedJson.type : void 0;
890
+ const socket = this.socketByConnectionId.get(connectionId);
891
+ if (!socket) return;
892
+ if (socket.protocol === RELAY_INTERNAL_PROTOCOL) {
983
893
  const relayMsg = RelayClientToServerMessageSchema.safeParse(parsedJson);
984
894
  if (relayMsg.success) this.onRelayClientMessage(connectionId, relayMsg.data);
985
895
  else process.stderr.write(`[webmcp-local-relay] warn: invalid relay message from ${connectionId}: ${relayMsg.error.message}\n`);
986
896
  return;
987
897
  }
988
- this.registry.touchConnection(connectionId);
898
+ if (typeof typeField === "string" && typeField.startsWith("relay/")) {
899
+ process.stderr.write(`[webmcp-local-relay] warn: connection ${connectionId} used the relay protocol without negotiating it\n`);
900
+ socket.close(1008, "Relay protocol was not negotiated");
901
+ return;
902
+ }
989
903
  const parsedMessage = BrowserToRelayMessageSchema.safeParse(parsedJson);
990
904
  if (!parsedMessage.success) {
991
905
  process.stderr.write(`[webmcp-local-relay] warn: invalid message from connection ${connectionId} (type=${typeField}): ${parsedMessage.error.message}\n`);
992
906
  return;
993
907
  }
994
- const message = parsedMessage.data;
908
+ this.registry.touchConnection(connectionId);
909
+ this.onBrowserClientMessage(connectionId, parsedMessage.data);
910
+ }
911
+ onBrowserClientMessage(connectionId, message) {
912
+ if (message.type !== "hello" && !this.browserClientConnectionIds.has(connectionId)) {
913
+ process.stderr.write(`[webmcp-local-relay] warn: connection ${connectionId} sent ${message.type} before hello, ignoring\n`);
914
+ return;
915
+ }
995
916
  switch (message.type) {
996
917
  case "hello":
997
918
  try {
998
919
  const socket = this.socketByConnectionId.get(connectionId);
999
- if (!this.isHostOriginAllowed(message.origin)) {
1000
- process.stderr.write(`[webmcp-local-relay] warn: rejecting source ${connectionId} with disallowed host origin: ${message.origin ?? "missing"}\n`);
920
+ const origin = this.requestOriginByConnectionId.get(connectionId) ?? message.origin;
921
+ if (!this.isHostOriginAllowed(origin)) {
922
+ process.stderr.write(`[webmcp-local-relay] warn: rejecting source ${connectionId} with disallowed host origin: ${origin ?? "missing"}\n`);
1001
923
  if (socket) this.sendHelloRejected(socket, {
1002
924
  type: "hello/rejected",
1003
925
  reason: "host-origin-not-allowed",
@@ -1005,7 +927,11 @@ var RelayBridgeServer = class extends EventEmitter {
1005
927
  }, 1008, "Host origin not allowed");
1006
928
  break;
1007
929
  }
1008
- this.registry.upsertSource(connectionId, message);
930
+ this.registry.upsertSource(connectionId, {
931
+ ...message,
932
+ origin
933
+ });
934
+ this.browserClientConnectionIds.add(connectionId);
1009
935
  if (socket) this.sendHelloAccepted(socket, { type: "hello/accepted" });
1010
936
  this.emit("stateChanged");
1011
937
  } catch (err) {
@@ -1018,11 +944,8 @@ var RelayBridgeServer = class extends EventEmitter {
1018
944
  this.registry.registerTools(connectionId, message.tools);
1019
945
  this.emit("stateChanged");
1020
946
  } catch (err) {
1021
- if (err instanceof HelloRequiredError) process.stderr.write(`[webmcp-local-relay] warn: connection ${connectionId} sent tools before hello, ignoring\n`);
1022
- else {
1023
- process.stderr.write(`[webmcp-local-relay] error: failed to register tools for connection ${connectionId}: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`);
1024
- this.socketByConnectionId.get(connectionId)?.close(1011, "Failed to register tools");
1025
- }
947
+ process.stderr.write(`[webmcp-local-relay] error: failed to register tools for connection ${connectionId}: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`);
948
+ this.socketByConnectionId.get(connectionId)?.close(1008, "Invalid tool list");
1026
949
  }
1027
950
  break;
1028
951
  case "result": {
@@ -1031,6 +954,10 @@ var RelayBridgeServer = class extends EventEmitter {
1031
954
  process.stderr.write(`[webmcp-local-relay] warn: received result for unknown callId ${message.callId}\n`);
1032
955
  break;
1033
956
  }
957
+ if (pending.connectionId !== connectionId) {
958
+ process.stderr.write(`[webmcp-local-relay] warn: connection ${connectionId} returned result for another source's callId ${message.callId}\n`);
959
+ break;
960
+ }
1034
961
  clearTimeout(pending.timeoutId);
1035
962
  this.pendingInvocations.delete(message.callId);
1036
963
  pending.resolve(this.normalizeCallToolResult(message.result));
@@ -1039,39 +966,16 @@ var RelayBridgeServer = class extends EventEmitter {
1039
966
  case "pong":
1040
967
  this.lastPongByConnectionId.set(connectionId, Date.now());
1041
968
  break;
1042
- case "elicitation-request":
1043
- this.emit("elicitationRequest", {
1044
- callId: message.callId,
1045
- connectionId,
1046
- params: message.params
1047
- });
1048
- break;
1049
- }
1050
- }
1051
- /**
1052
- * Sends an elicitation response back to the browser source that requested it.
1053
- */
1054
- sendElicitationResponse(connectionId, callId, result) {
1055
- const socket = this.socketByConnectionId.get(connectionId);
1056
- if (!socket || socket.readyState !== WebSocket.OPEN) {
1057
- process.stderr.write(`[webmcp-local-relay] warn: cannot send elicitation response: source ${connectionId} disconnected\n`);
1058
- return;
1059
- }
1060
- const message = {
1061
- type: "elicitation-response",
1062
- callId,
1063
- result
1064
- };
1065
- try {
1066
- socket.send(JSON.stringify(message));
1067
- } catch (err) {
1068
- process.stderr.write(`[webmcp-local-relay] warn: failed to send elicitation response: ${err instanceof Error ? err.message : String(err)}\n`);
1069
969
  }
1070
970
  }
1071
971
  /**
1072
972
  * Handles relay-protocol messages from relay client connections.
1073
973
  */
1074
974
  onRelayClientMessage(connectionId, message) {
975
+ if (message.type !== "relay/hello" && !this.relayClientConnectionIds.has(connectionId)) {
976
+ process.stderr.write(`[webmcp-local-relay] warn: connection ${connectionId} sent ${message.type} before relay/hello, ignoring\n`);
977
+ return;
978
+ }
1075
979
  switch (message.type) {
1076
980
  case "relay/hello":
1077
981
  this.relayClientConnectionIds.add(connectionId);
@@ -1088,45 +992,33 @@ var RelayBridgeServer = class extends EventEmitter {
1088
992
  }
1089
993
  case "relay/invoke": {
1090
994
  const { callId, toolName, args } = message;
1091
- (async () => {
1092
- try {
1093
- const response = {
1094
- type: "relay/result",
1095
- callId,
1096
- result: await this.invokeToolLocally(toolName, args ?? {}, {})
1097
- };
1098
- const socket = this.socketByConnectionId.get(connectionId);
1099
- if (socket?.readyState === WebSocket.OPEN) try {
1100
- socket.send(JSON.stringify(response));
1101
- } catch (sendErr) {
1102
- process.stderr.write(`[webmcp-local-relay] warn: failed to send relay result to ${connectionId}: ${sendErr instanceof Error ? sendErr.message : String(sendErr)}\n`);
1103
- }
1104
- else process.stderr.write(`[webmcp-local-relay] warn: relay client ${connectionId} disconnected before result for callId ${callId} could be delivered\n`);
1105
- } catch (err) {
1106
- const response = {
1107
- type: "relay/result",
1108
- callId,
1109
- result: {
1110
- content: [{
1111
- type: "text",
1112
- text: `Relay invocation failed: ${err instanceof Error ? err.message : String(err)}`
1113
- }],
1114
- isError: true
1115
- }
1116
- };
1117
- const socket = this.socketByConnectionId.get(connectionId);
1118
- if (socket?.readyState === WebSocket.OPEN) try {
1119
- socket.send(JSON.stringify(response));
1120
- } catch (sendErr) {
1121
- process.stderr.write(`[webmcp-local-relay] warn: failed to send relay error result to ${connectionId}: ${sendErr instanceof Error ? sendErr.message : String(sendErr)}\n`);
1122
- }
1123
- else process.stderr.write(`[webmcp-local-relay] warn: relay client ${connectionId} disconnected before error result for callId ${callId} could be delivered\n`);
1124
- }
1125
- })();
995
+ this.invokeToolLocally(toolName, args ?? {}, {}).then((result) => this.sendRelayResult(connectionId, callId, result), (error) => this.sendRelayResult(connectionId, callId, {
996
+ content: [{
997
+ type: "text",
998
+ text: `Relay invocation failed: ${error instanceof Error ? error.message : String(error)}`
999
+ }],
1000
+ isError: true
1001
+ }));
1126
1002
  break;
1127
1003
  }
1128
1004
  }
1129
1005
  }
1006
+ sendRelayResult(connectionId, callId, result) {
1007
+ const socket = this.socketByConnectionId.get(connectionId);
1008
+ if (socket?.readyState !== WebSocket.OPEN) {
1009
+ process.stderr.write(`[webmcp-local-relay] warn: relay client ${connectionId} disconnected before result for callId ${callId} could be delivered\n`);
1010
+ return;
1011
+ }
1012
+ try {
1013
+ socket.send(JSON.stringify({
1014
+ type: "relay/result",
1015
+ callId,
1016
+ result
1017
+ }));
1018
+ } catch (error) {
1019
+ process.stderr.write(`[webmcp-local-relay] warn: failed to send relay result to ${connectionId}: ${error instanceof Error ? error.message : String(error)}\n`);
1020
+ }
1021
+ }
1130
1022
  /**
1131
1023
  * Pushes current tool state to all connected relay clients.
1132
1024
  */
@@ -1152,6 +1044,8 @@ var RelayBridgeServer = class extends EventEmitter {
1152
1044
  onSocketClose(connectionId, code) {
1153
1045
  if (!this.socketByConnectionId.has(connectionId)) return;
1154
1046
  this.stopHeartbeat(connectionId);
1047
+ this.requestOriginByConnectionId.delete(connectionId);
1048
+ this.browserClientConnectionIds.delete(connectionId);
1155
1049
  this.relayClientConnectionIds.delete(connectionId);
1156
1050
  this.registry.removeConnection(connectionId);
1157
1051
  this.socketByConnectionId.delete(connectionId);
@@ -1216,10 +1110,9 @@ var RelayBridgeServer = class extends EventEmitter {
1216
1110
  }
1217
1111
  }
1218
1112
  async connectToRelayServer(port) {
1219
- this.stopping = false;
1220
1113
  return new Promise((resolve, reject) => {
1221
1114
  const wsUrl = `ws://${this.host}:${port}`;
1222
- const ws = new WebSocket(wsUrl, [RELAY_INTERNAL_PROTOCOL, RELAY_BROWSER_PROTOCOL]);
1115
+ const ws = new WebSocket(wsUrl, RELAY_INTERNAL_PROTOCOL);
1223
1116
  const bufferedMessages = [];
1224
1117
  const cleanup = () => {
1225
1118
  clearTimeout(timeoutId);
@@ -1229,8 +1122,9 @@ var RelayBridgeServer = class extends EventEmitter {
1229
1122
  ws.off("open", onOpen);
1230
1123
  };
1231
1124
  const rejectWith = (error) => {
1232
- ws.once("error", () => {});
1233
1125
  cleanup();
1126
+ ws.once("error", () => {});
1127
+ ws.terminate();
1234
1128
  reject(error);
1235
1129
  };
1236
1130
  const finish = () => {
@@ -1319,7 +1213,7 @@ var RelayBridgeServer = class extends EventEmitter {
1319
1213
  }
1320
1214
  const message = RelayServerToClientMessageSchema.safeParse(parsed);
1321
1215
  if (!message.success) {
1322
- const typeField = typeof parsed === "object" && parsed !== null ? parsed.type : "unknown";
1216
+ const typeField = typeof parsed === "object" && parsed !== null && "type" in parsed ? parsed.type : "unknown";
1323
1217
  process.stderr.write(`[webmcp-local-relay] warn: invalid relay server message (type=${typeField}): ${message.error.message}\n`);
1324
1218
  return null;
1325
1219
  }
@@ -1360,10 +1254,7 @@ var RelayBridgeServer = class extends EventEmitter {
1360
1254
  this.clientReconnectTimer = setTimeout(() => {
1361
1255
  this.clientReconnectTimer = null;
1362
1256
  if (this.stopping) return;
1363
- this.reconnectWithModePromotion().catch((err) => {
1364
- process.stderr.write(`[webmcp-local-relay] error: unexpected failure during reconnection: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`);
1365
- if (!this.stopping) this.scheduleReconnect();
1366
- });
1257
+ this.reconnectWithModePromotion();
1367
1258
  }, delay);
1368
1259
  }
1369
1260
  /**
@@ -1384,7 +1275,7 @@ var RelayBridgeServer = class extends EventEmitter {
1384
1275
  if (!this.stopping) this.scheduleReconnect();
1385
1276
  }
1386
1277
  }
1387
- invokeToolViaRelay(toolName, args) {
1278
+ async invokeToolViaRelay(toolName, args) {
1388
1279
  if (!this.clientSocket || this.clientSocket.readyState !== WebSocket.OPEN) throw new Error("Not connected to relay server");
1389
1280
  const callId = randomUUID();
1390
1281
  const socket = this.clientSocket;
@@ -1394,7 +1285,6 @@ var RelayBridgeServer = class extends EventEmitter {
1394
1285
  reject(/* @__PURE__ */ new Error(`Proxied invocation for tool "${toolName}" timed out after ${this.invokeTimeoutMs}ms`));
1395
1286
  }, this.invokeTimeoutMs);
1396
1287
  this.clientPendingInvocations.set(callId, {
1397
- callId,
1398
1288
  connectionId: "relay-server",
1399
1289
  timeoutId,
1400
1290
  resolve,
@@ -1425,17 +1315,11 @@ var RelayBridgeServer = class extends EventEmitter {
1425
1315
  for (const tool of tools) toolSourceMap[tool.name] = tool.sources.map((s) => s.sourceId);
1426
1316
  return {
1427
1317
  type,
1428
- tools: this.toWireTools(tools),
1318
+ tools: tools.map(({ originalName: _originalName, sources: _sources, ...tool }) => tool),
1429
1319
  sources,
1430
1320
  toolSourceMap
1431
1321
  };
1432
1322
  }
1433
- /**
1434
- * Maps aggregated tools to the wire format used by the relay protocol.
1435
- */
1436
- toWireTools(tools) {
1437
- return tools.map(({ originalName: _originalName, sources: _sources, ...tool }) => tool);
1438
- }
1439
1323
  isHostOriginAllowed(origin) {
1440
1324
  if (this.allowedOrigins.includes("*")) return true;
1441
1325
  if (!origin) return false;
@@ -1482,86 +1366,68 @@ var RelayBridgeServer = class extends EventEmitter {
1482
1366
  * Converts WebSocket raw data variants to a UTF-8 string payload.
1483
1367
  */
1484
1368
  rawDataToUtf8(raw) {
1485
- if (typeof raw === "string") return raw;
1486
- if (Buffer.isBuffer(raw)) return raw.toString("utf8");
1487
- if (raw instanceof ArrayBuffer) return Buffer.from(raw).toString("utf8");
1488
1369
  if (Array.isArray(raw)) return Buffer.concat(raw).toString("utf8");
1489
- return String(raw);
1370
+ if (Buffer.isBuffer(raw)) return raw.toString("utf8");
1371
+ return Buffer.from(raw).toString("utf8");
1490
1372
  }
1491
1373
  };
1492
1374
 
1493
1375
  //#endregion
1494
1376
  //#region src/cli-utils.ts
1377
+ function parsePositiveInteger(name, value) {
1378
+ if (value === void 0) return void 0;
1379
+ const parsed = Number(value);
1380
+ if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`Invalid ${name} "${value}". Must be a positive integer.`);
1381
+ return parsed;
1382
+ }
1495
1383
  /**
1496
1384
  * Parses supported CLI flags for relay startup.
1497
1385
  */
1498
1386
  function parseCliOptions(argv) {
1499
- const options = {
1500
- host: "127.0.0.1",
1501
- port: 9333,
1502
- portExplicitlySet: false,
1503
- allowedOrigins: ["*"]
1504
- };
1505
- const readFlagValue = (flag, index) => {
1506
- const next = argv[index + 1];
1507
- if (!next || next.startsWith("-")) throw new Error(`Missing value for ${flag}`);
1508
- return next;
1387
+ const { values } = parseArgs({
1388
+ args: argv,
1389
+ options: {
1390
+ help: {
1391
+ type: "boolean",
1392
+ short: "h"
1393
+ },
1394
+ host: {
1395
+ type: "string",
1396
+ short: "H"
1397
+ },
1398
+ "allowed-origin": { type: "string" },
1399
+ "invoke-timeout": { type: "string" },
1400
+ label: { type: "string" },
1401
+ "max-payload": { type: "string" },
1402
+ port: {
1403
+ type: "string",
1404
+ short: "p"
1405
+ },
1406
+ "relay-id": { type: "string" },
1407
+ "widget-origin": { type: "string" },
1408
+ workspace: { type: "string" },
1409
+ "ws-origin": { type: "string" }
1410
+ },
1411
+ strict: true
1412
+ });
1413
+ const port = values.port === void 0 ? DEFAULT_RELAY_PORT : Number(values.port);
1414
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) throw new Error(`Invalid port "${values.port}". Port must be an integer between 1 and 65535.`);
1415
+ const invokeTimeoutMs = parsePositiveInteger("invoke-timeout", values["invoke-timeout"]);
1416
+ const maxPayloadBytes = parsePositiveInteger("max-payload", values["max-payload"]);
1417
+ const allowedOrigins = (values["widget-origin"] ?? values["allowed-origin"] ?? values["ws-origin"])?.split(",").map((origin) => origin.trim()).filter(Boolean) ?? ["*"];
1418
+ if (allowedOrigins.length === 0) throw new Error("--widget-origin must include at least one origin.");
1419
+ return {
1420
+ allowedOrigins,
1421
+ help: values.help ?? false,
1422
+ host: values.host ?? "127.0.0.1",
1423
+ port,
1424
+ portExplicitlySet: values.port !== void 0,
1425
+ ...values.label === void 0 ? {} : { label: values.label },
1426
+ ...invokeTimeoutMs === void 0 ? {} : { invokeTimeoutMs },
1427
+ ...maxPayloadBytes === void 0 ? {} : { maxPayloadBytes },
1428
+ ...values["relay-id"] === void 0 ? {} : { relayId: values["relay-id"] },
1429
+ ...values.workspace === void 0 ? {} : { workspace: values.workspace }
1509
1430
  };
1510
- for (let i = 0; i < argv.length; i++) {
1511
- const token = argv[i];
1512
- if (!token) continue;
1513
- if (token === "--host" || token === "-H") {
1514
- options.host = readFlagValue(token, i);
1515
- i += 1;
1516
- continue;
1517
- }
1518
- if (token === "--port" || token === "-p") {
1519
- const raw = readFlagValue(token, i);
1520
- i += 1;
1521
- const value = Number.parseInt(raw, 10);
1522
- if (!Number.isFinite(value) || value <= 0 || value > 65535) throw new Error(`Invalid port "${raw}". Port must be a number between 1 and 65535.`);
1523
- options.port = value;
1524
- options.portExplicitlySet = true;
1525
- continue;
1526
- }
1527
- if (token === "--widget-origin" || token === "--allowed-origin" || token === "--ws-origin") {
1528
- const raw = readFlagValue(token, i);
1529
- i += 1;
1530
- const split = raw.split(",").map((part) => part.trim()).filter(Boolean);
1531
- if (split.length > 0) options.allowedOrigins = split;
1532
- continue;
1533
- }
1534
- if (token === "--label") {
1535
- options.label = readFlagValue(token, i);
1536
- i += 1;
1537
- continue;
1538
- }
1539
- if (token === "--workspace") {
1540
- options.workspace = readFlagValue(token, i);
1541
- i += 1;
1542
- continue;
1543
- }
1544
- if (token === "--relay-id") {
1545
- options.relayId = readFlagValue(token, i);
1546
- i += 1;
1547
- continue;
1548
- }
1549
- if (token === "--max-payload") {
1550
- const raw = readFlagValue(token, i);
1551
- i += 1;
1552
- const value = Number(raw);
1553
- if (!Number.isInteger(value) || value <= 0) throw new Error(`Invalid max-payload "${raw}". Must be a positive integer (bytes).`);
1554
- options.maxPayloadBytes = value;
1555
- continue;
1556
- }
1557
- if (token === "--help" || token === "-h") {
1558
- printHelp();
1559
- process.exit(0);
1560
- }
1561
- if (token.startsWith("-")) process.stderr.write(`[webmcp-local-relay] warn: unrecognized argument "${token}"\n`);
1562
- else process.stderr.write(`[webmcp-local-relay] warn: unrecognized argument "${token}" (positional arguments are not supported)\n`);
1563
- }
1564
- return options;
1565
1431
  }
1566
1432
  /**
1567
1433
  * Prints CLI usage to stderr.
@@ -1577,48 +1443,18 @@ function printHelp() {
1577
1443
  " --host, -H Bind host for local websocket relay (default: 127.0.0.1)",
1578
1444
  " --port, -p Preferred root port for the local relay cluster (default: 9333)",
1579
1445
  " --widget-origin Allowed host page origin(s), comma-separated (default: *)",
1580
- " --allowed-origin Alias for --widget-origin",
1581
- " --ws-origin Alias for --widget-origin",
1446
+ " --allowed-origin Deprecated alias for --widget-origin",
1447
+ " --ws-origin Deprecated alias for --widget-origin",
1582
1448
  " --label Human-readable relay label reported during discovery",
1583
1449
  " --workspace Optional workspace name reported during discovery",
1584
1450
  " --relay-id Stable relay identifier reported during discovery",
1451
+ " --invoke-timeout Browser tool invocation timeout in milliseconds (default: 65000)",
1585
1452
  " --max-payload Maximum WebSocket payload size in bytes (default: 10000000)",
1586
1453
  " --help, -h Show help",
1587
1454
  ""
1588
1455
  ].join("\n"));
1589
1456
  }
1590
1457
 
1591
- //#endregion
1592
- //#region src/staticToolSchemas.ts
1593
- const EMPTY_STATIC_TOOL_INPUT_SHAPE = {};
1594
- const WEBMCP_CALL_TOOL_INPUT_SHAPE = {
1595
- name: z.string().describe("The tool name to call. Use webmcp_list_tools to see available tool names."),
1596
- 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.")
1597
- };
1598
- const WEBMCP_OPEN_PAGE_INPUT_SHAPE = {
1599
- url: z.string().describe("URL to open or match for refresh."),
1600
- refresh: z.boolean().optional().describe("If true, refresh the connected source matching this URL instead of opening a new tab.")
1601
- };
1602
- function isRecord(value) {
1603
- return value !== null && typeof value === "object" && !Array.isArray(value);
1604
- }
1605
- function stripJsonSchemaMetadata(value) {
1606
- if (Array.isArray(value)) return value.map((item) => stripJsonSchemaMetadata(item));
1607
- if (!isRecord(value)) return value;
1608
- const next = {};
1609
- for (const [key, nestedValue] of Object.entries(value)) {
1610
- if (key === "$schema") continue;
1611
- next[key] = stripJsonSchemaMetadata(nestedValue);
1612
- }
1613
- return next;
1614
- }
1615
- function publicInputSchemaFromZodShape(inputShape) {
1616
- return stripJsonSchemaMetadata(toJsonSchemaCompat(z.object(inputShape), {
1617
- strictUnions: true,
1618
- pipeStrategy: "input"
1619
- }));
1620
- }
1621
-
1622
1458
  //#endregion
1623
1459
  //#region src/mcpRelayServer.ts
1624
1460
  /**
@@ -1630,12 +1466,7 @@ var LocalRelayMcpServer = class {
1630
1466
  */
1631
1467
  bridge;
1632
1468
  mcpServer;
1633
- staticToolData = /* @__PURE__ */ new Map();
1634
- dynamicToolHandles = /* @__PURE__ */ new Map();
1635
- dynamicToolData = /* @__PURE__ */ new Map();
1636
- dynamicToolSignature = /* @__PURE__ */ new Map();
1637
- syncing = false;
1638
- syncRequested = false;
1469
+ dynamicTools = /* @__PURE__ */ new Map();
1639
1470
  syncDebounceTimer = null;
1640
1471
  connected = false;
1641
1472
  /**
@@ -1650,31 +1481,14 @@ var LocalRelayMcpServer = class {
1650
1481
  this.bridge.on("stateChanged", () => {
1651
1482
  this.debouncedSyncDynamicTools();
1652
1483
  });
1653
- this.bridge.on("elicitationRequest", (request) => {
1654
- (async () => {
1655
- try {
1656
- const result = await this.mcpServer.server.elicitInput(request.params);
1657
- this.bridge.sendElicitationResponse(request.connectionId, request.callId, result);
1658
- } catch (err) {
1659
- const message = err instanceof Error ? err.message : String(err);
1660
- process.stderr.write(`[webmcp-local-relay] warn: elicitation forwarding failed: ${message}\n`);
1661
- this.bridge.sendElicitationResponse(request.connectionId, request.callId, {
1662
- action: "decline",
1663
- content: null,
1664
- error: message
1665
- });
1666
- }
1667
- })();
1668
- });
1669
1484
  this.registerStaticTools();
1670
- this.overrideListToolsHandler();
1671
1485
  }
1672
1486
  /**
1673
1487
  * Starts the browser bridge and synchronizes dynamic MCP tools.
1674
1488
  */
1675
1489
  async start() {
1676
1490
  await this.bridge.start();
1677
- await this.syncDynamicTools();
1491
+ this.syncDynamicTools();
1678
1492
  }
1679
1493
  /**
1680
1494
  * Connects the MCP server to a transport.
@@ -1718,289 +1532,147 @@ var LocalRelayMcpServer = class {
1718
1532
  * Returns dynamic tool names currently registered in MCP.
1719
1533
  */
1720
1534
  listDynamicToolNames() {
1721
- return Array.from(this.dynamicToolHandles.keys()).sort();
1722
- }
1723
- /**
1724
- * Overrides the SDK's ListTools handler to return real JSON Schema
1725
- * for dynamic tools instead of the empty `z.object({}).passthrough()`
1726
- * schema that the SDK would otherwise convert to `{ type: 'object' }`.
1727
- *
1728
- * The MCP SDK's McpServer internally runs `toJsonSchemaCompat()` on
1729
- * registered inputSchema values, which strips plain JSON Schema objects
1730
- * (non-Zod) to empty schemas. This override bypasses that conversion
1731
- * for dynamic (relayed) tools while preserving static tools as-is.
1732
- */
1733
- overrideListToolsHandler() {
1734
- this.mcpServer.server.setRequestHandler(ListToolsRequestSchema, () => {
1735
- return { tools: [...Array.from(this.staticToolData.values()).map((tool) => ({ ...tool })), ...Array.from(this.dynamicToolData.entries()).map(([name, tool]) => ({
1736
- name,
1737
- description: this.dynamicToolDescription(tool),
1738
- inputSchema: tool.inputSchema,
1739
- ...tool.outputSchema ? { outputSchema: tool.outputSchema } : {},
1740
- ...tool.annotations ? { annotations: tool.annotations } : {}
1741
- }))].sort((left, right) => String(left.name).localeCompare(String(right.name))) };
1742
- });
1743
- }
1744
- registerStaticTool({ name, description, inputShape, annotations, handler }) {
1745
- this.mcpServer.registerTool(name, {
1746
- description,
1747
- inputSchema: inputShape,
1748
- ...annotations ? { annotations } : {}
1749
- }, handler);
1750
- this.staticToolData.set(name, {
1751
- name,
1752
- description,
1753
- inputSchema: publicInputSchemaFromZodShape(inputShape),
1754
- ...annotations ? { annotations } : {}
1755
- });
1535
+ return Array.from(this.dynamicTools.keys()).sort();
1756
1536
  }
1757
1537
  /**
1758
1538
  * Registers built-in management tools exposed by the relay.
1759
1539
  */
1760
1540
  registerStaticTools() {
1761
- this.registerStaticTool({
1762
- name: "webmcp_list_sources",
1541
+ this.mcpServer.registerTool("webmcp_list_sources", {
1763
1542
  description: "List connected browser tool sources and their metadata.",
1764
- inputShape: EMPTY_STATIC_TOOL_INPUT_SHAPE,
1543
+ inputSchema: z.object({}),
1765
1544
  annotations: {
1766
1545
  readOnlyHint: true,
1767
1546
  idempotentHint: true
1768
- },
1769
- handler: async () => {
1770
- if (this.bridge.mode === "client") {
1771
- const sources = this.bridge.listSourcesFromRelay();
1772
- const info = {
1773
- mode: "client",
1774
- count: sources.length,
1775
- sources
1776
- };
1777
- return {
1778
- content: [{
1779
- type: "text",
1780
- text: JSON.stringify(info, null, 2)
1781
- }],
1782
- structuredContent: info
1783
- };
1784
- }
1785
- const sources = this.bridge.registry.listSources();
1786
- return {
1787
- content: [{
1788
- type: "text",
1789
- text: JSON.stringify({
1790
- count: sources.length,
1791
- sources
1792
- }, null, 2)
1793
- }],
1794
- structuredContent: {
1795
- count: sources.length,
1796
- sources
1797
- }
1798
- };
1799
1547
  }
1548
+ }, async () => {
1549
+ const clientMode = this.bridge.mode === "client";
1550
+ const sources = clientMode ? this.bridge.listSourcesFromRelay() : this.bridge.registry.listSources();
1551
+ const info = {
1552
+ ...clientMode ? { mode: "client" } : {},
1553
+ count: sources.length,
1554
+ sources
1555
+ };
1556
+ return {
1557
+ content: [{
1558
+ type: "text",
1559
+ text: JSON.stringify(info, null, 2)
1560
+ }],
1561
+ structuredContent: info
1562
+ };
1800
1563
  });
1801
- this.registerStaticTool({
1802
- name: "webmcp_list_tools",
1803
- 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.",
1804
- inputShape: EMPTY_STATIC_TOOL_INPUT_SHAPE,
1564
+ this.mcpServer.registerTool("webmcp_list_tools", {
1565
+ description: "List WebMCP tools available from connected browser sources. Returns tool definitions including name, description, input schema, and source info.",
1566
+ inputSchema: z.object({}),
1805
1567
  annotations: {
1806
1568
  readOnlyHint: true,
1807
1569
  idempotentHint: true
1808
- },
1809
- handler: async () => {
1810
- const tools = this.listAggregatedTools();
1570
+ }
1571
+ }, async () => {
1572
+ const tools = this.listAggregatedTools();
1573
+ const info = {
1574
+ count: tools.length,
1575
+ tools
1576
+ };
1577
+ return {
1578
+ content: [{
1579
+ type: "text",
1580
+ text: JSON.stringify(info, null, 2)
1581
+ }],
1582
+ structuredContent: info
1583
+ };
1584
+ });
1585
+ this.mcpServer.registerTool("webmcp_open_page", {
1586
+ 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.",
1587
+ inputSchema: z.object({
1588
+ url: z.string().describe("URL to open or match for refresh."),
1589
+ refresh: z.boolean().optional().describe("If true, refresh the connected source matching this URL instead of opening a new tab.")
1590
+ }),
1591
+ annotations: { readOnlyHint: false }
1592
+ }, async ({ url, refresh }) => {
1593
+ let parsed;
1594
+ try {
1595
+ parsed = new URL(url);
1596
+ } catch {
1811
1597
  return {
1812
1598
  content: [{
1813
1599
  type: "text",
1814
- text: JSON.stringify({
1815
- count: tools.length,
1816
- tools
1817
- }, null, 2)
1600
+ text: `Invalid URL: ${url}`
1818
1601
  }],
1819
- structuredContent: {
1820
- count: tools.length,
1821
- tools
1822
- }
1602
+ isError: true
1823
1603
  };
1824
1604
  }
1825
- });
1826
- this.registerStaticTool({
1827
- name: "webmcp_call_tool",
1828
- description: "Call a WebMCP tool registered by a connected browser page. Use webmcp_list_tools first to see available tools and their input schemas.",
1829
- inputShape: WEBMCP_CALL_TOOL_INPUT_SHAPE,
1830
- annotations: { readOnlyHint: false },
1831
- handler: async ({ name, arguments: args }) => {
1832
- const tools = this.listAggregatedTools();
1833
- const toolSummary = this.buildToolSummary(tools);
1834
- if (!tools.find((t) => t.name === name)) {
1835
- const errorLines = [`Tool "${name}" not found.`];
1836
- if (toolSummary) errorLines.push("", "Available tools:", toolSummary);
1837
- else errorLines.push("", "No tools are currently available. Ensure a browser page with WebMCP is connected.");
1838
- return {
1839
- content: [{
1840
- type: "text",
1841
- text: errorLines.join("\n")
1842
- }],
1843
- isError: true
1844
- };
1845
- }
1846
- try {
1847
- const result = await this.bridge.invokeTool(name, args ?? {});
1848
- const updatedTools = this.bridge.mode === "client" ? this.buildAggregatedToolsFromRelay() : this.bridge.registry.listTools();
1849
- const updatedSummary = this.buildToolSummary(updatedTools);
1850
- if (updatedSummary) result.content = [...result.content, {
1605
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return {
1606
+ content: [{
1607
+ type: "text",
1608
+ text: `Only http: and https: URLs are allowed. Got: ${parsed.protocol}`
1609
+ }],
1610
+ isError: true
1611
+ };
1612
+ const existing = this.bridge.mode === "server" ? this.bridge.registry.listSources().find((source) => source.url && URL.parse(source.url)?.origin === parsed.origin) : void 0;
1613
+ if (refresh) {
1614
+ if (this.bridge.mode === "client") return {
1615
+ content: [{
1851
1616
  type: "text",
1852
- text: `\n---\nAvailable tools:\n${updatedSummary}`
1853
- }];
1854
- return result;
1855
- } catch (err) {
1856
- const errorLines = [`Failed to call tool "${name}": ${err instanceof Error ? err.message : String(err)}`];
1857
- if (toolSummary) errorLines.push("", "Available tools:", toolSummary);
1858
- return {
1859
- content: [{
1860
- type: "text",
1861
- text: errorLines.join("\n")
1862
- }],
1863
- isError: true
1864
- };
1865
- }
1866
- }
1867
- });
1868
- this.registerStaticTool({
1869
- name: "webmcp_open_page",
1870
- 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.",
1871
- inputShape: WEBMCP_OPEN_PAGE_INPUT_SHAPE,
1872
- annotations: { readOnlyHint: false },
1873
- handler: async ({ url, refresh }) => {
1874
- let parsed;
1875
- try {
1876
- parsed = new URL(url);
1877
- } catch {
1878
- return {
1879
- content: [{
1880
- type: "text",
1881
- text: `Invalid URL: ${url}`
1882
- }],
1883
- isError: true
1884
- };
1885
- }
1886
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return {
1617
+ text: "Refresh is not supported in client mode. Only the server relay can reload sources."
1618
+ }],
1619
+ isError: true
1620
+ };
1621
+ if (!existing) return {
1887
1622
  content: [{
1888
1623
  type: "text",
1889
- text: `Only http: and https: URLs are allowed. Got: ${parsed.protocol}`
1624
+ text: `No connected source matches origin ${parsed.origin}. The page may not be open or connected.`
1890
1625
  }],
1891
1626
  isError: true
1892
1627
  };
1893
- if (refresh) {
1894
- if (this.bridge.mode === "client") return {
1895
- content: [{
1896
- type: "text",
1897
- text: "Refresh is not supported in client mode. Only the server relay can reload sources."
1898
- }],
1899
- isError: true
1900
- };
1901
- const matched = this.bridge.registry.listSources().find((s) => {
1902
- if (!s.url) return false;
1903
- try {
1904
- return new URL(s.url).origin === parsed.origin;
1905
- } catch {
1906
- process.stderr.write(`[webmcp-local-relay] warn: source ${s.sourceId} has unparseable URL: ${s.url}\n`);
1907
- return false;
1908
- }
1909
- });
1910
- if (!matched) return {
1911
- content: [{
1912
- type: "text",
1913
- text: `No connected source matches origin ${parsed.origin}. The page may not be open or connected.`
1914
- }],
1915
- isError: true
1916
- };
1917
- try {
1918
- this.bridge.reloadSource(matched.sourceId);
1919
- return { content: [{
1920
- type: "text",
1921
- text: `Reload sent to source ${matched.sourceId} (${matched.url ?? matched.origin}).`
1922
- }] };
1923
- } catch (err) {
1924
- return {
1925
- content: [{
1926
- type: "text",
1927
- text: `Failed to reload source: ${err instanceof Error ? err.message : String(err)}`
1928
- }],
1929
- isError: true
1930
- };
1931
- }
1932
- }
1933
1628
  try {
1934
- await this.openInBrowser(url);
1629
+ this.bridge.reloadSource(existing.sourceId);
1630
+ return { content: [{
1631
+ type: "text",
1632
+ text: `Reload sent to source ${existing.sourceId} (${existing.url ?? existing.origin}).`
1633
+ }] };
1935
1634
  } catch (err) {
1936
1635
  return {
1937
1636
  content: [{
1938
1637
  type: "text",
1939
- text: `Failed to open browser: ${err instanceof Error ? err.message : String(err)}`
1638
+ text: `Failed to reload source: ${err instanceof Error ? err.message : String(err)}`
1940
1639
  }],
1941
1640
  isError: true
1942
1641
  };
1943
1642
  }
1944
- if (this.bridge.mode === "server") {
1945
- const existing = this.bridge.registry.listSources().find((s) => {
1946
- if (!s.url) return false;
1947
- try {
1948
- return new URL(s.url).origin === parsed.origin;
1949
- } catch {
1950
- process.stderr.write(`[webmcp-local-relay] warn: source ${s.sourceId} has unparseable URL: ${s.url}\n`);
1951
- return false;
1952
- }
1953
- });
1954
- if (existing) return { content: [{
1643
+ }
1644
+ try {
1645
+ await this.openInBrowser(url);
1646
+ } catch (err) {
1647
+ return {
1648
+ content: [{
1955
1649
  type: "text",
1956
- text: `Opened ${url} in the default browser. Note: a source from ${existing.url ?? existing.origin} is already connected.`
1957
- }] };
1958
- }
1959
- return { content: [{
1960
- type: "text",
1961
- text: `Opened ${url} in the default browser.`
1962
- }] };
1650
+ text: `Failed to open browser: ${err instanceof Error ? err.message : String(err)}`
1651
+ }],
1652
+ isError: true
1653
+ };
1963
1654
  }
1655
+ if (existing) return { content: [{
1656
+ type: "text",
1657
+ text: `Opened ${url} in the default browser. Note: a source from ${existing.url ?? existing.origin} is already connected.`
1658
+ }] };
1659
+ return { content: [{
1660
+ type: "text",
1661
+ text: `Opened ${url} in the default browser.`
1662
+ }] };
1964
1663
  });
1965
1664
  }
1966
1665
  /**
1967
- * Builds a concise plain-text list of available tools.
1968
- */
1969
- buildToolSummary(tools) {
1970
- if (tools.length === 0) return null;
1971
- return tools.map((t) => {
1972
- const desc = t.description ? ` - ${t.description.split("\n")[0]}` : "";
1973
- return ` ${t.name}${desc}`;
1974
- }).join("\n");
1975
- }
1976
- /**
1977
1666
  * Opens a URL in the user's default browser using the platform open command.
1978
1667
  *
1979
- * Uses the re-serialized `URL.href` to prevent injection of shell metacharacters.
1980
- * On Windows, PowerShell `Start-Process` is used instead of `cmd /c start`
1981
- * because cmd.exe interprets `&`, `|`, and other metacharacters in URLs.
1668
+ * Passes the re-serialized `URL.href` directly to the platform launcher.
1669
+ * No shell or command-language string is involved.
1982
1670
  */
1983
1671
  openInBrowser(url) {
1984
1672
  const safeUrl = new URL(url).href;
1673
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open";
1985
1674
  return new Promise((resolve, reject) => {
1986
- const platform = process.platform;
1987
- let command;
1988
- let args;
1989
- if (platform === "darwin") {
1990
- command = "open";
1991
- args = [safeUrl];
1992
- } else if (platform === "win32") {
1993
- command = "powershell.exe";
1994
- args = [
1995
- "-NoProfile",
1996
- "-Command",
1997
- `Start-Process "${safeUrl}"`
1998
- ];
1999
- } else {
2000
- command = "xdg-open";
2001
- args = [safeUrl];
2002
- }
2003
- execFile(command, args, (err) => {
1675
+ execFile(command, [safeUrl], (err) => {
2004
1676
  if (err) reject(err);
2005
1677
  else resolve();
2006
1678
  });
@@ -2018,36 +1690,15 @@ var LocalRelayMcpServer = class {
2018
1690
  if (this.syncDebounceTimer) return;
2019
1691
  this.syncDebounceTimer = setTimeout(() => {
2020
1692
  this.syncDebounceTimer = null;
2021
- this.syncDynamicTools().catch((err) => {
2022
- this.logSyncError(err);
2023
- });
1693
+ try {
1694
+ this.syncDynamicTools();
1695
+ } catch (err) {
1696
+ const details = err instanceof Error ? err.stack ?? err.message : String(err);
1697
+ process.stderr.write(`[webmcp-local-relay] error: failed to sync dynamic tools: ${details}\n`);
1698
+ }
2024
1699
  }, 16);
2025
1700
  }
2026
1701
  /**
2027
- * Coalesces concurrent sync requests into a single serialized sync loop.
2028
- */
2029
- async syncDynamicTools() {
2030
- if (this.syncing) {
2031
- this.syncRequested = true;
2032
- return;
2033
- }
2034
- this.syncing = true;
2035
- try {
2036
- do {
2037
- this.syncRequested = false;
2038
- const tools = this.listAggregatedTools();
2039
- this.applyDynamicTools(tools);
2040
- } while (this.syncRequested);
2041
- } finally {
2042
- const retryRequested = this.syncRequested;
2043
- this.syncRequested = false;
2044
- this.syncing = false;
2045
- if (retryRequested) this.syncDynamicTools().catch((err) => {
2046
- this.logSyncError(err);
2047
- });
2048
- }
2049
- }
2050
- /**
2051
1702
  * Returns the current aggregated tool list, dispatching based on bridge mode.
2052
1703
  */
2053
1704
  listAggregatedTools() {
@@ -2077,52 +1728,51 @@ var LocalRelayMcpServer = class {
2077
1728
  }).sort((left, right) => left.name.localeCompare(right.name));
2078
1729
  }
2079
1730
  /**
2080
- * Applies registry tool state to MCP dynamic registrations.
1731
+ * Applies current aggregated tool state to MCP dynamic registrations.
2081
1732
  */
2082
- applyDynamicTools(tools) {
1733
+ syncDynamicTools() {
1734
+ const tools = this.listAggregatedTools();
2083
1735
  const nextNames = new Set(tools.map((tool) => tool.name));
2084
- let changed = false;
2085
- for (const [name, handle] of this.dynamicToolHandles.entries()) {
1736
+ for (const [name, registration] of this.dynamicTools) {
2086
1737
  if (nextNames.has(name)) continue;
2087
- handle.remove();
2088
- this.dynamicToolHandles.delete(name);
2089
- this.dynamicToolData.delete(name);
2090
- this.dynamicToolSignature.delete(name);
2091
- changed = true;
1738
+ registration.handle.remove();
1739
+ this.dynamicTools.delete(name);
2092
1740
  }
2093
1741
  for (const tool of tools) {
2094
1742
  const signature = this.toolSignature(tool);
2095
- if (this.dynamicToolSignature.get(tool.name) === signature && this.dynamicToolHandles.has(tool.name)) continue;
2096
- const existing = this.dynamicToolHandles.get(tool.name);
2097
- if (existing) {
2098
- existing.remove();
2099
- this.dynamicToolHandles.delete(tool.name);
1743
+ const current = this.dynamicTools.get(tool.name);
1744
+ if (current?.signature === signature) continue;
1745
+ if (current) {
1746
+ current.handle.remove();
1747
+ this.dynamicTools.delete(tool.name);
1748
+ }
1749
+ try {
1750
+ const handle = this.registerDynamicTool(tool);
1751
+ this.dynamicTools.set(tool.name, {
1752
+ handle,
1753
+ signature
1754
+ });
1755
+ } catch (err) {
1756
+ const details = err instanceof Error ? err.stack ?? err.message : String(err);
1757
+ process.stderr.write(`[webmcp-local-relay] warn: skipped dynamic tool "${tool.name}" because its schema could not be compiled: ${details}\n`);
2100
1758
  }
2101
- const handle = this.registerDynamicTool(tool);
2102
- this.dynamicToolHandles.set(tool.name, handle);
2103
- this.dynamicToolData.set(tool.name, tool);
2104
- this.dynamicToolSignature.set(tool.name, signature);
2105
- changed = true;
2106
- }
2107
- if (changed && this.connected) try {
2108
- this.mcpServer.sendToolListChanged();
2109
- } catch (err) {
2110
- process.stderr.write(`[webmcp-local-relay] warn: failed to send tool list changed notification: ${err instanceof Error ? err.message : String(err)}\n`);
2111
1759
  }
2112
1760
  }
2113
1761
  /**
2114
1762
  * Registers a single dynamic tool and returns a removal handle.
2115
1763
  */
2116
1764
  registerDynamicTool(tool) {
2117
- const inputSchema = z.object({}).passthrough();
2118
- const config = {
1765
+ const inputSchema = fromJsonSchema(tool.inputSchema);
1766
+ const outputSchema = tool.outputSchema ? fromJsonSchema(tool.outputSchema) : void 0;
1767
+ return this.mcpServer.registerTool(tool.name, {
1768
+ ...tool.title !== void 0 ? { title: tool.title } : {},
2119
1769
  description: this.dynamicToolDescription(tool),
2120
- inputSchema
2121
- };
2122
- const handle = this.mcpServer.registerTool(tool.name, tool.annotations ? {
2123
- ...config,
2124
- annotations: tool.annotations
2125
- } : config, async (args) => {
1770
+ inputSchema,
1771
+ ...outputSchema ? { outputSchema } : {},
1772
+ ...tool.annotations ? { annotations: tool.annotations } : {},
1773
+ ...tool.icons ? { icons: tool.icons } : {},
1774
+ ...tool._meta ? { _meta: tool._meta } : {}
1775
+ }, async (args) => {
2126
1776
  try {
2127
1777
  return await this.bridge.invokeTool(tool.name, args);
2128
1778
  } catch (err) {
@@ -2138,9 +1788,6 @@ var LocalRelayMcpServer = class {
2138
1788
  };
2139
1789
  }
2140
1790
  });
2141
- return { remove: () => {
2142
- handle.remove();
2143
- } };
2144
1791
  }
2145
1792
  /**
2146
1793
  * Builds a display description for relayed tools including source context.
@@ -2159,28 +1806,22 @@ var LocalRelayMcpServer = class {
2159
1806
  */
2160
1807
  toolSignature(tool) {
2161
1808
  return JSON.stringify({
2162
- name: tool.name,
2163
1809
  originalName: tool.originalName,
2164
1810
  title: tool.title,
2165
1811
  description: tool.description,
2166
1812
  inputSchema: tool.inputSchema,
2167
1813
  outputSchema: tool.outputSchema,
2168
1814
  annotations: tool.annotations,
2169
- execution: tool.execution,
2170
1815
  _meta: tool._meta,
2171
1816
  icons: tool.icons,
2172
- sources: tool.sources.map((source) => ({ tabId: source.tabId }))
1817
+ source: tool.sources[0] ? {
1818
+ tabId: tool.sources[0].tabId,
1819
+ title: tool.sources[0].title
1820
+ } : void 0
2173
1821
  });
2174
1822
  }
2175
- /**
2176
- * Logs tool sync errors with full detail.
2177
- */
2178
- logSyncError(err) {
2179
- const details = err instanceof Error ? err.stack ?? err.message : String(err);
2180
- process.stderr.write(`[webmcp-local-relay] error: failed to sync dynamic tools: ${details}\n`);
2181
- }
2182
1823
  };
2183
1824
 
2184
1825
  //#endregion
2185
1826
  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 };
2186
- //# sourceMappingURL=mcpRelayServer-BT1ct1Cc.mjs.map
1827
+ //# sourceMappingURL=mcpRelayServer-BxI6zEc2.mjs.map