@johnhenry/wsh 0.16.0 → 0.17.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.
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Control-message subscription for the helper classes that compose their
3
+ * own wire messages — `WshMcpBridge` and `WshFileTransfer`.
4
+ *
5
+ * Both accept "a WshClient, or any object exposing sendControl() plus a
6
+ * way to observe inbound control messages", and both used to inline the
7
+ * same three-way registration. The third branch of it — the fallback for
8
+ * an object whose only hook is a settable `onControl` property, which is
9
+ * what a bare `WshTransport` is — wrapped that property and **never
10
+ * unwrapped it**:
11
+ *
12
+ * ```js
13
+ * const prev = client.onControl;
14
+ * client.onControl = (msg) => { prev?.(msg); listener(msg); };
15
+ * ```
16
+ *
17
+ * The matching `cleanup()` handled `removeControlListener` and
18
+ * `_controlListeners` and did nothing at all for this branch. So every
19
+ * `discover()` / `call()` / `list()` left another wrapper permanently in
20
+ * the chain. Measured against a real `WshTransport`, the number of frames
21
+ * an inbound control message traverses to reach the connection's own
22
+ * handler grew one-for-one with the number of operations — 1, 5, 20 and
23
+ * 50 operations gave 5, 9, 24 and 54 frames — with no ceiling.
24
+ *
25
+ * Attaching once per subscriber and detaching on cleanup keeps that flat.
26
+ */
27
+
28
+ /**
29
+ * Subscribe `listener` to a client's inbound control messages.
30
+ *
31
+ * Supports, in order of preference:
32
+ * 1. `addControlListener()` / `removeControlListener()` — what `WshClient`
33
+ * exposes, and the only shape that can deregister cleanly.
34
+ * 2. A `_controlListeners` array.
35
+ * 3. A settable `onControl` property (a bare `WshTransport`), wrapped
36
+ * once and restored on detach.
37
+ *
38
+ * @param {object} client
39
+ * @param {function(object): void} listener
40
+ * @returns {function(): void} detach — idempotent; safe to call twice.
41
+ */
42
+ export function attachControlListener(client, listener) {
43
+ if (typeof client.addControlListener === 'function') {
44
+ client.addControlListener(listener);
45
+ let detached = false;
46
+ return () => {
47
+ if (detached) return;
48
+ detached = true;
49
+ client.removeControlListener?.(listener);
50
+ };
51
+ }
52
+
53
+ if (Array.isArray(client._controlListeners)) {
54
+ client._controlListeners.push(listener);
55
+ let detached = false;
56
+ return () => {
57
+ if (detached) return;
58
+ detached = true;
59
+ const idx = client._controlListeners.indexOf(listener);
60
+ if (idx !== -1) client._controlListeners.splice(idx, 1);
61
+ };
62
+ }
63
+
64
+ // Fallback: wrap `onControl`. Restoring `prev` is only correct while our
65
+ // wrapper is still the installed handler — if something else has wrapped
66
+ // it since, putting `prev` back would drop that handler. In that case the
67
+ // wrapper stays but is made inert, so the chain stops growing per
68
+ // operation even though this one link cannot be spliced out.
69
+ const prev = client.onControl;
70
+ let live = true;
71
+ const wrapper = (msg) => {
72
+ prev?.(msg);
73
+ if (live) listener(msg);
74
+ };
75
+ client.onControl = wrapper;
76
+
77
+ let detached = false;
78
+ return () => {
79
+ if (detached) return;
80
+ detached = true;
81
+ live = false;
82
+ if (client.onControl === wrapper) client.onControl = prev;
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Wait for the first inbound control message matching `predicate`.
88
+ *
89
+ * @param {object} client
90
+ * @param {function(object): boolean} predicate
91
+ * @param {number} timeoutMs
92
+ * @param {string} label - Used in the timeout message.
93
+ * @returns {Promise<object>}
94
+ */
95
+ export function waitForControlMessage(client, predicate, timeoutMs, label) {
96
+ return new Promise((resolve, reject) => {
97
+ let settled = false;
98
+ let detach = () => {};
99
+ let timer = null;
100
+
101
+ const cleanup = () => {
102
+ settled = true;
103
+ if (timer !== null) clearTimeout(timer);
104
+ detach();
105
+ };
106
+
107
+ // `detach` is assigned after attach() returns, but a client that
108
+ // dispatches synchronously from inside attach() would fire the
109
+ // listener first — hence the `settled` guard rather than relying on
110
+ // detach() having been wired up.
111
+ detach = attachControlListener(client, (msg) => {
112
+ if (settled) return;
113
+ let matches = false;
114
+ try {
115
+ matches = predicate(msg);
116
+ } catch {
117
+ matches = false;
118
+ }
119
+ if (!matches) return;
120
+ cleanup();
121
+ resolve(msg);
122
+ });
123
+
124
+ timer = setTimeout(() => {
125
+ if (settled) return;
126
+ cleanup();
127
+ reject(new Error(`${label} timed out after ${timeoutMs}ms`));
128
+ }, timeoutMs);
129
+ });
130
+ }
package/src/e2e-frame.mjs CHANGED
@@ -138,13 +138,44 @@ export async function sealFrame(key, sessionId, roleTag, counter, plaintext) {
138
138
  * @param {{nonce: Uint8Array, ciphertext: Uint8Array}} frame
139
139
  * @returns {Promise<Uint8Array>} plaintext
140
140
  */
141
- export async function openFrame(key, sessionId, expectedCounter, { nonce, ciphertext }) {
141
+ export async function openFrame(key, sessionId, expectedCounter, { nonce, ciphertext, expectedRoleTag }) {
142
142
  if (!key || typeof key !== 'object') {
143
143
  throw new Error('e2e-frame: openFrame requires a CryptoKey');
144
144
  }
145
145
  if (!(nonce instanceof Uint8Array) || nonce.length !== NONCE_LENGTH) {
146
146
  throw new Error(`e2e-frame: nonce must be a ${NONCE_LENGTH}-byte Uint8Array`);
147
147
  }
148
+ /*
149
+ * The role tag is REQUIRED, and a caller that omits it gets an error rather
150
+ * than a skipped check.
151
+ *
152
+ * The tag exists so the two directions of one session are structurally
153
+ * distinguishable -- it is the reason a colliding nonce is impossible when
154
+ * both directions share one AES-GCM key. WshSession computed the peer's
155
+ * expected tag and stored it, and nothing ever read it: `grep -rn
156
+ * e2eRecvRoleTag` returned the declaration and the assignment and no third
157
+ * line. So the receiver accepted a frame sealed with its OWN tag, and the
158
+ * relay this layer exists to distrust could echo a peer's sealed frame
159
+ * straight back at it. Both counters start at zero, so the first frame of a
160
+ * session reflects cleanly.
161
+ *
162
+ * Optional would have reproduced the defect: a security check nobody is
163
+ * forced to pass is the same as no check, and this file already had one --
164
+ * see TimestampProof.verify's ignored verifyFn for the same shape.
165
+ */
166
+ if (!(expectedRoleTag instanceof Uint8Array) || expectedRoleTag.length !== ROLE_TAG_LENGTH) {
167
+ throw new Error(
168
+ `e2e-frame: openFrame requires expectedRoleTag, a ${ROLE_TAG_LENGTH}-byte Uint8Array`
169
+ );
170
+ }
171
+ const actualRoleTag = nonce.subarray(COUNTER_LENGTH);
172
+ for (let i = 0; i < ROLE_TAG_LENGTH; i += 1) {
173
+ if (actualRoleTag[i] !== expectedRoleTag[i]) {
174
+ throw new Error(
175
+ 'e2e-frame: role tag mismatch -- this frame was sealed by the wrong side of the session'
176
+ );
177
+ }
178
+ }
148
179
  const actualCounter = Number(new DataView(nonce.buffer, nonce.byteOffset, nonce.byteLength).getBigUint64(0, false));
149
180
  if (actualCounter !== expectedCounter) {
150
181
  throw new Error(
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { MSG, open, CHANNEL_KIND } from './messages.mjs';
11
+ import { waitForControlMessage } from './control-listener.mjs';
11
12
 
12
13
  /** Default timeout for waiting on control messages (30 seconds). */
13
14
  const RESPONSE_TIMEOUT_MS = 30_000;
@@ -30,6 +31,10 @@ export class WshFileTransfer {
30
31
  * - openStream(): open a new bidirectional stream
31
32
  * - onControl: settable callback for incoming control messages
32
33
  * (or a method to add a listener — see _waitForMessage)
34
+ *
35
+ * Only `list()` needs the control-channel members, so they are checked
36
+ * there rather than here: uploading and downloading through a client
37
+ * that has `upload()`/`download()` and nothing else stays valid.
33
38
  */
34
39
  constructor(client) {
35
40
  if (!client) throw new Error('WshFileTransfer requires a client');
@@ -104,6 +109,11 @@ export class WshFileTransfer {
104
109
  if (!remotePath || typeof remotePath !== 'string') {
105
110
  throw new Error('remotePath is required');
106
111
  }
112
+ if (typeof this.#client.sendControl !== 'function' || typeof this.#client.openStream !== 'function') {
113
+ throw new TypeError(
114
+ 'WshFileTransfer.list() requires a client exposing sendControl() and openStream() (e.g. WshClient)'
115
+ );
116
+ }
107
117
 
108
118
  // Use an exec channel to run ls
109
119
  const openMsg = open({
@@ -154,51 +164,7 @@ export class WshFileTransfer {
154
164
  * @returns {Promise<object>}
155
165
  */
156
166
  _waitForMessage(predicate, timeoutMs) {
157
- return new Promise((resolve, reject) => {
158
- let timer = null;
159
- let settled = false;
160
-
161
- const cleanup = () => {
162
- settled = true;
163
- if (timer !== null) clearTimeout(timer);
164
- // Remove listener
165
- if (this.#client.removeControlListener) {
166
- this.#client.removeControlListener(listener);
167
- } else if (this.#client._controlListeners) {
168
- const idx = this.#client._controlListeners.indexOf(listener);
169
- if (idx !== -1) this.#client._controlListeners.splice(idx, 1);
170
- }
171
- };
172
-
173
- const listener = (msg) => {
174
- if (settled) return;
175
- if (predicate(msg)) {
176
- cleanup();
177
- resolve(msg);
178
- }
179
- };
180
-
181
- // Register listener on the client
182
- if (this.#client.addControlListener) {
183
- this.#client.addControlListener(listener);
184
- } else if (this.#client._controlListeners) {
185
- this.#client._controlListeners.push(listener);
186
- } else {
187
- // Fallback: wrap existing onControl
188
- const prev = this.#client.onControl;
189
- this.#client.onControl = (msg) => {
190
- prev?.(msg);
191
- listener(msg);
192
- };
193
- }
194
-
195
- timer = setTimeout(() => {
196
- if (!settled) {
197
- cleanup();
198
- reject(new Error(`Timeout waiting for response (${timeoutMs}ms)`));
199
- }
200
- }, timeoutMs);
201
- });
167
+ return waitForControlMessage(this.#client, predicate, timeoutMs, 'File transfer response');
202
168
  }
203
169
 
204
170
  /**
package/src/index.d.ts CHANGED
@@ -228,6 +228,16 @@ export const AUTH_METHOD: {
228
228
  /** Protocol version string. */
229
229
  export const PROTOCOL_VERSION: 'wsh-v1';
230
230
 
231
+ /**
232
+ * ServerHello feature advertising that McpResult echoes McpCall's call_id.
233
+ *
234
+ * Compare against `WshClient.hasFeature()` before setting `callId`:
235
+ * McpCallPayload is `deny_unknown_fields` server-side, so sending the field
236
+ * to a server predating it makes the server reject the call rather than
237
+ * ignore the field.
238
+ */
239
+ export const MCP_CALL_ID_FEATURE: 'mcp-call-id';
240
+
231
241
  /** A wsh protocol control message (all messages have a numeric `type`). */
232
242
  export interface WshMessage {
233
243
  type: number;
@@ -396,10 +406,14 @@ export function mcpTools(opts?: {
396
406
  export function mcpCall(opts?: {
397
407
  tool?: string;
398
408
  arguments?: Record<string, unknown>;
409
+ /** Correlates this call with its McpResult. Omitted from the message entirely when undefined. */
410
+ callId?: string;
399
411
  }): WshMessage;
400
412
 
401
413
  export function mcpResult(opts?: {
402
414
  result?: unknown;
415
+ /** Echoed verbatim from the McpCall this answers. */
416
+ callId?: string;
403
417
  }): WshMessage;
404
418
 
405
419
  // -- Message constructors (reverse) --
@@ -817,7 +831,19 @@ export function isValidMessage(msg: unknown): boolean;
817
831
  // ============================================================================
818
832
 
819
833
  /**
820
- * Generate a new Ed25519 key pair.
834
+ * Whether this runtime's WebCrypto implements Ed25519, measured by
835
+ * generating a key rather than sniffed from a version string.
836
+ *
837
+ * There is no pure-JS fallback: one would need the private scalar as
838
+ * ordinary bytes, giving up the non-extractable `CryptoKey` property
839
+ * `WshKeyStore` is built around. Check this before committing to pubkey
840
+ * auth and fall back to password auth where it returns false.
841
+ */
842
+ export function isEd25519Supported(): Promise<boolean>;
843
+
844
+ /**
845
+ * Generate a new Ed25519 key pair. Throws with an actionable message
846
+ * where WebCrypto has no Ed25519 -- see `isEd25519Supported()`.
821
847
  * @param extractable - Whether private key can be exported (default false)
822
848
  */
823
849
  export function generateKeyPair(extractable?: boolean): Promise<CryptoKeyPair>;
@@ -1017,7 +1043,7 @@ export class WshTransport {
1017
1043
  readonly state: 'disconnected' | 'connecting' | 'connected' | 'closed';
1018
1044
 
1019
1045
  /** Connect to a wsh server. */
1020
- connect(url: string): Promise<void>;
1046
+ connect(url: string, options?: object): Promise<void>;
1021
1047
 
1022
1048
  /** Gracefully close the transport. */
1023
1049
  close(): Promise<void>;
@@ -1044,7 +1070,7 @@ export class WshTransport {
1044
1070
  protected _emitError(err: Error): void;
1045
1071
 
1046
1072
  /** @protected Must be overridden by subclasses. */
1047
- protected _doConnect(url: string): Promise<void>;
1073
+ protected _doConnect(url: string, options?: object): Promise<void>;
1048
1074
 
1049
1075
  /** @protected Must be overridden by subclasses. */
1050
1076
  protected _doClose(): Promise<void>;
@@ -1064,7 +1090,65 @@ export class WshTransport {
1064
1090
  * - Subsequent streams carry raw byte data (no framing).
1065
1091
  * - Server-initiated streams are surfaced via onStreamOpen.
1066
1092
  */
1067
- export class WebTransportTransport extends WshTransport {}
1093
+ export class WebTransportTransport extends WshTransport {
1094
+ /**
1095
+ * @param options Forwarded to the `WebTransport` constructor. Options
1096
+ * passed to `connect()` override these per call.
1097
+ */
1098
+ constructor(options?: WshWebTransportOptions);
1099
+
1100
+ connect(url: string, options?: WshWebTransportOptions): Promise<void>;
1101
+ }
1102
+
1103
+ /**
1104
+ * One entry of `serverCertificateHashes`. The digest may be given as raw
1105
+ * bytes, as hex (with or without `:` separators -- what
1106
+ * `openssl x509 -fingerprint -sha256` prints), or as base64/base64url.
1107
+ */
1108
+ export interface WshCertificateHash {
1109
+ /** Defaults to `'sha-256'`, the only algorithm the platform accepts today. */
1110
+ algorithm?: string;
1111
+ value: BufferSource | string;
1112
+ }
1113
+
1114
+ /**
1115
+ * Options forwarded to the `WebTransport` constructor.
1116
+ *
1117
+ * `serverCertificateHashes` is the interesting one: it pins a specific
1118
+ * self-signed certificate by digest, which is the only mechanism in the
1119
+ * web platform that lets page JavaScript reach a server whose certificate
1120
+ * no certificate authority signed. The platform's own constraints apply --
1121
+ * `https:` URL, HTTP/3 only, ECDSA P-256 key, at most 14 days validity,
1122
+ * no connection pooling.
1123
+ *
1124
+ * Every other key is forwarded verbatim, so platform options not listed
1125
+ * here still work.
1126
+ */
1127
+ export interface WshWebTransportOptions {
1128
+ serverCertificateHashes?: Array<WshCertificateHash | BufferSource | string>;
1129
+ allowPooling?: boolean;
1130
+ requireUnreliable?: boolean;
1131
+ congestionControl?: 'default' | 'throughput' | 'low-latency';
1132
+ anticipatedConcurrentIncomingUnidirectionalStreams?: number | null;
1133
+ anticipatedConcurrentIncomingBidirectionalStreams?: number | null;
1134
+ protocols?: string[];
1135
+ [key: string]: unknown;
1136
+ }
1137
+
1138
+ /**
1139
+ * Decode one `serverCertificateHashes` digest into bytes. Accepts a
1140
+ * `BufferSource`, hex (separators and an `... Fingerprint=` prefix are
1141
+ * stripped), or base64/base64url. Throws a `TypeError` on an
1142
+ * unrecognised shape and a `RangeError` on a digest of the wrong length
1143
+ * for the algorithm.
1144
+ */
1145
+ export function parseCertificateHash(value: BufferSource | string, algorithm?: string): Uint8Array;
1146
+
1147
+ /**
1148
+ * Normalise a caller-supplied options bag into a `WebTransportOptions`
1149
+ * dictionary, or `undefined` when there is nothing to pass.
1150
+ */
1151
+ export function normalizeWebTransportOptions(options?: WshWebTransportOptions): object | undefined;
1068
1152
 
1069
1153
  /**
1070
1154
  * Dispatch a fixed, already-available batch of items one at a time,
@@ -1307,8 +1391,21 @@ export class WshSession {
1307
1391
  /** Called when the remote process exits. */
1308
1392
  onExit: ((code: number) => void) | null;
1309
1393
 
1310
- /** Called when the session is fully closed. */
1311
- onClose: (() => void) | null;
1394
+ /**
1395
+ * Called when the session is fully closed.
1396
+ *
1397
+ * Receives the same value as `closeReason`: an Error for a torn-down
1398
+ * session, null for an ordinary close.
1399
+ */
1400
+ onClose: ((reason: Error | null) => void) | null;
1401
+
1402
+ /**
1403
+ * Why the session closed, or null for an ordinary close.
1404
+ *
1405
+ * A torn-down session and a clean exit are otherwise indistinguishable
1406
+ * from the outside.
1407
+ */
1408
+ readonly closeReason: Error | null;
1312
1409
 
1313
1410
  /** Called when the remote peer acknowledges speculative local echo. */
1314
1411
  onEchoAck: ((payload: Record<string, unknown>) => void) | null;
@@ -1427,6 +1524,12 @@ export interface WshConnectOptions {
1427
1524
  keyPair?: CryptoKeyPair;
1428
1525
  password?: string;
1429
1526
  transport?: 'wt' | 'ws';
1527
+ /**
1528
+ * Forwarded to the `WebTransport` constructor when the WebTransport
1529
+ * rung of the transport ladder is tried; ignored by the WebSocket rung,
1530
+ * which has no `serverCertificateHashes` equivalent.
1531
+ */
1532
+ webTransport?: WshWebTransportOptions;
1430
1533
  timeout?: number;
1431
1534
  }
1432
1535
 
@@ -1466,6 +1569,8 @@ export interface WshConnectReverseOptions {
1466
1569
  username: string;
1467
1570
  keyPair?: CryptoKeyPair;
1468
1571
  password?: string;
1572
+ /** Forwarded to the underlying `connect()` -- see `WshConnectOptions`. */
1573
+ webTransport?: WshWebTransportOptions;
1469
1574
  expose?: {
1470
1575
  shell?: boolean;
1471
1576
  exec?: boolean;
@@ -1697,6 +1802,31 @@ export class WshClient {
1697
1802
  */
1698
1803
  sendRelayControl(msg: WshMessage): Promise<void>;
1699
1804
 
1805
+ /**
1806
+ * Send a raw control message on the authenticated connection.
1807
+ *
1808
+ * Prefer the typed methods. This is the hook `WshMcpBridge` and
1809
+ * `WshFileTransfer` compose their own messages through.
1810
+ */
1811
+ sendControl(msg: WshMessage): Promise<void>;
1812
+
1813
+ /**
1814
+ * Open a raw bidirectional stream on the underlying transport.
1815
+ */
1816
+ openStream(): Promise<WshStream>;
1817
+
1818
+ /**
1819
+ * Observe every inbound control message this client handles. Listeners
1820
+ * are called past the RelayForward trust gate, so a message from an
1821
+ * unaccepted peer never reaches one.
1822
+ */
1823
+ addControlListener(fn: (msg: WshMessage) => void): void;
1824
+
1825
+ /**
1826
+ * Stop observing inbound control messages.
1827
+ */
1828
+ removeControlListener(fn: (msg: WshMessage) => void): void;
1829
+
1700
1830
  /**
1701
1831
  * Initiate end-to-end encryption for a session: `'X25519'` (default,
1702
1832
  * classical ECDH) or `'X25519+ML-KEM-768'` (hybrid, with automatic
package/src/index.mjs CHANGED
@@ -42,6 +42,7 @@ export {
42
42
 
43
43
  // Auth + crypto
44
44
  export {
45
+ isEd25519Supported,
45
46
  generateKeyPair, exportPublicKeyRaw, exportPublicKeySSH,
46
47
  importPublicKeyRaw, exportPrivateKeyPKCS8, importPrivateKeyPKCS8,
47
48
  sign, verify, buildTranscript, signChallenge, verifyChallenge,
@@ -51,7 +52,10 @@ export {
51
52
  } from './auth.mjs';
52
53
 
53
54
  // Transport
54
- export { WshTransport, WebTransportTransport, dispatchSerially, SerialQueue } from './transport.mjs';
55
+ export {
56
+ WshTransport, WebTransportTransport, dispatchSerially, SerialQueue,
57
+ parseCertificateHash, normalizeWebTransportOptions,
58
+ } from './transport.mjs';
55
59
  export { WebSocketTransport, WS_FRAME_TYPE } from './transport-ws.mjs';
56
60
 
57
61
  // QMux (QUIC-v1 frames over an ordered byte stream, draft-ietf-quic-qmux-02)
@@ -68,7 +72,7 @@ export {
68
72
  // Session + Client
69
73
  export { WshSession } from './session.mjs';
70
74
  export { WshVirtualSessionBackend, normalizeSessionData } from './virtual-session.mjs';
71
- export { WshClient } from './client.mjs';
75
+ export { WshClient, MCP_CALL_ID_FEATURE } from './client.mjs';
72
76
 
73
77
  // Key storage
74
78
  export { WshKeyStore } from './keystore.mjs';
@@ -7,6 +7,8 @@
7
7
  */
8
8
 
9
9
  import { MSG, mcpDiscover, mcpCall } from './messages.mjs';
10
+ import { waitForControlMessage } from './control-listener.mjs';
11
+ import { MCP_CALL_ID_FEATURE } from './client.mjs';
10
12
 
11
13
  /** Default timeout for MCP operations (15 seconds). */
12
14
  const MCP_TIMEOUT_MS = 15_000;
@@ -18,14 +20,35 @@ export class WshMcpBridge {
18
20
  /** @type {Map<string, object>} Cached tool specs: name -> { name, description, parameters } */
19
21
  #tools = new Map();
20
22
 
23
+ /** @type {number} Monotonically increasing counter for call_id. */
24
+ #callCounter = 0;
25
+
26
+ /**
27
+ * Tail of the serialised call chain, used only against servers that do not
28
+ * advertise `mcp-call-id`. Never rejects.
29
+ * @type {Promise<void>}
30
+ */
31
+ #queue = Promise.resolve();
32
+
21
33
  /**
22
- * @param {object} client - A WshClient or transport exposing:
34
+ * @param {object} client - A `WshClient`, a `WshTransport`, or any object
35
+ * exposing:
23
36
  * - sendControl(msg): send a control message
24
37
  * - addControlListener(fn) / removeControlListener(fn): message listeners
25
- * (or _controlListeners array, or onControl callback)
38
+ * (or a `_controlListeners` array, or a settable `onControl` callback)
39
+ *
40
+ * `sendControl` is checked here rather than at first use: without it
41
+ * `discover()` failed with `TypeError: this.#client.sendControl is not
42
+ * a function` from inside a promise, naming a private field and not
43
+ * the argument that was actually wrong.
26
44
  */
27
45
  constructor(client) {
28
46
  if (!client) throw new Error('WshMcpBridge requires a client');
47
+ if (typeof client.sendControl !== 'function') {
48
+ throw new TypeError(
49
+ 'WshMcpBridge requires a client exposing sendControl() — pass a WshClient or a WshTransport'
50
+ );
51
+ }
29
52
  this.#client = client;
30
53
  }
31
54
 
@@ -73,6 +96,13 @@ export class WshMcpBridge {
73
96
  *
74
97
  * Sends an MCP_CALL message and waits for the MCP_RESULT response.
75
98
  *
99
+ * Safe to call concurrently. Against a server advertising `mcp-call-id`
100
+ * each call carries a correlation id and takes only its own reply;
101
+ * otherwise calls are queued one at a time, because without an id on the
102
+ * wire the replies are indistinguishable and `{ success, output }`
103
+ * normalisation strips whatever the payload might have identified itself
104
+ * by.
105
+ *
76
106
  * @param {string} toolName - Name of the tool to invoke
77
107
  * @param {object} [args={}] - Arguments to pass to the tool
78
108
  * @param {object} [opts]
@@ -91,10 +121,40 @@ export class WshMcpBridge {
91
121
  );
92
122
  }
93
123
 
94
- await this.#client.sendControl(mcpCall({ tool: toolName, arguments: args }));
124
+ if (this.#correlates()) return this.#callOnce(toolName, args, timeout);
125
+
126
+ const run = this.#queue.then(
127
+ () => this.#callOnce(toolName, args, timeout),
128
+ () => this.#callOnce(toolName, args, timeout),
129
+ );
130
+ this.#queue = run.then(() => undefined, () => undefined);
131
+ return run;
132
+ }
133
+
134
+ /** True when the connected server echoes call_id. @private */
135
+ #correlates() {
136
+ return this.#client?.hasFeature?.(MCP_CALL_ID_FEATURE) === true;
137
+ }
138
+
139
+ /** @private */
140
+ async #callOnce(toolName, args, timeout) {
141
+ const callId = this.#correlates()
142
+ ? `bridge-${++this.#callCounter}-${Date.now()}`
143
+ : undefined;
144
+
145
+ await this.#client.sendControl(
146
+ mcpCall({ tool: toolName, arguments: args, callId })
147
+ );
95
148
 
96
149
  const response = await this._waitForMessage(
97
- (msg) => msg.type === MSG.MCP_RESULT || msg.type === MSG.ERROR,
150
+ (msg) => {
151
+ // ERROR carries no call_id and cannot be attributed to one call;
152
+ // it is taken by whichever call is waiting.
153
+ if (msg.type === MSG.ERROR) return true;
154
+ if (msg.type !== MSG.MCP_RESULT) return false;
155
+ if (callId === undefined) return true;
156
+ return msg.call_id === undefined || msg.call_id === callId;
157
+ },
98
158
  timeout
99
159
  );
100
160
 
@@ -171,58 +231,17 @@ export class WshMcpBridge {
171
231
  /**
172
232
  * Wait for a control message matching a predicate.
173
233
  *
174
- * Temporarily hooks into the client's control message flow and resolves
175
- * when a matching message arrives (or rejects on timeout).
234
+ * Subscribes to the client's control message flow and resolves when a
235
+ * matching message arrives (or rejects on timeout). Deregistration is
236
+ * `control-listener.mjs`'s job — the version inlined here leaked one
237
+ * permanent `onControl` wrapper per operation against any client whose
238
+ * only hook is that property.
176
239
  *
177
240
  * @param {function(object): boolean} predicate
178
241
  * @param {number} timeoutMs
179
242
  * @returns {Promise<object>}
180
243
  */
181
244
  _waitForMessage(predicate, timeoutMs) {
182
- return new Promise((resolve, reject) => {
183
- let timer = null;
184
- let settled = false;
185
-
186
- const cleanup = () => {
187
- settled = true;
188
- if (timer !== null) clearTimeout(timer);
189
- // Remove listener
190
- if (this.#client.removeControlListener) {
191
- this.#client.removeControlListener(listener);
192
- } else if (this.#client._controlListeners) {
193
- const idx = this.#client._controlListeners.indexOf(listener);
194
- if (idx !== -1) this.#client._controlListeners.splice(idx, 1);
195
- }
196
- };
197
-
198
- const listener = (msg) => {
199
- if (settled) return;
200
- if (predicate(msg)) {
201
- cleanup();
202
- resolve(msg);
203
- }
204
- };
205
-
206
- // Register listener on the client
207
- if (this.#client.addControlListener) {
208
- this.#client.addControlListener(listener);
209
- } else if (this.#client._controlListeners) {
210
- this.#client._controlListeners.push(listener);
211
- } else {
212
- // Fallback: wrap existing onControl
213
- const prev = this.#client.onControl;
214
- this.#client.onControl = (msg) => {
215
- prev?.(msg);
216
- listener(msg);
217
- };
218
- }
219
-
220
- timer = setTimeout(() => {
221
- if (!settled) {
222
- cleanup();
223
- reject(new Error(`MCP operation timed out after ${timeoutMs}ms`));
224
- }
225
- }, timeoutMs);
226
- });
245
+ return waitForControlMessage(this.#client, predicate, timeoutMs, 'MCP operation');
227
246
  }
228
247
  }
@@ -445,15 +445,16 @@ export function mcpTools({ tools } = {}) {
445
445
  };
446
446
  }
447
447
 
448
- export function mcpCall({ tool, arguments: args } = {}) {
449
- return { type: MSG.MCP_CALL, tool, arguments: args };
448
+ export function mcpCall({ tool, arguments: args, callId } = {}) {
449
+ const m = { type: MSG.MCP_CALL, tool, arguments: args };
450
+ if (callId !== undefined) m.call_id = callId;
451
+ return m;
450
452
  }
451
453
 
452
- export function mcpResult({ result } = {}) {
453
- return {
454
- type: MSG.MCP_RESULT,
455
- result,
456
- };
454
+ export function mcpResult({ result, callId } = {}) {
455
+ const msg = { type: MSG.MCP_RESULT, result };
456
+ if (callId !== undefined) msg.call_id = callId;
457
+ return msg;
457
458
  }
458
459
 
459
460
  export function reverseRegister({ username, capabilities = [], peerType = "host", shellBackend = "pty", supportsAttach = false, supportsReplay = false, supportsEcho = false, supportsTermSync = false, publicKey, seq, recordSignature } = {}) {