@mcp-b/webmcp-local-relay 6.0.0 → 6.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -15
- package/dist/browser/embed.js +1 -2
- package/dist/browser/widget.html +2 -1
- package/dist/browser/widget.js +2 -1
- package/dist/cli.mjs +6 -6
- package/dist/cli.mjs.map +1 -1
- package/dist/index.d.mts +24 -147
- package/dist/index.mjs +1 -1
- package/dist/{mcpRelayServer-DEgebuO2.mjs → mcpRelayServer-DkidP4o7.mjs} +157 -182
- package/dist/mcpRelayServer-DkidP4o7.mjs.map +1 -0
- package/package.json +5 -5
- package/dist/mcpRelayServer-DEgebuO2.mjs.map +0 -1
|
@@ -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:
|
|
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
|
-
|
|
363
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
653
|
-
|
|
654
|
-
|
|
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(
|
|
667
|
-
|
|
668
|
-
|
|
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
|
-
|
|
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
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
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
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
1293
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
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
|
-
|
|
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
|
|
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"]
|
|
1442
|
-
if (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-
|
|
1778
|
+
//# sourceMappingURL=mcpRelayServer-DkidP4o7.mjs.map
|