@johnhenry/wsh 0.23.1 → 0.25.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.
@@ -27,6 +27,7 @@ import {
27
27
  import { FILE_CHUNK_BYTES } from './fs.mjs';
28
28
  import { MAX_ATTACHMENTS } from './sessions.mjs';
29
29
  import { MCP_CALL_ID_FEATURE } from '../client.mjs';
30
+ import { RpcChannel, RPC_FEATURE, RPC_MAX_MESSAGE_PREFIX, rpcProtocolFeature } from '../rpc.mjs';
30
31
  import { HOST_KEY_PREFIX, HOST_KEY_SIG_PREFIX, findClientNonce, hostKeyProofMessage, toHex } from '../host-key.mjs';
31
32
 
32
33
  export const STREAM_ANNOUNCE = 'stream-announce';
@@ -52,6 +53,7 @@ const toBytes = (d) => (typeof d === 'string' ? enc.encode(d) : d);
52
53
  * @param {{ pubkey: boolean, password: Function | null }} cfg.methods - which auth methods are on
53
54
  * @param {object | null} cfg.hostKey - from loadHostKey
54
55
  * @param {object | null} cfg.mcp - from createMcpHost
56
+ * @param {{ protocols: Map<string, Function>, maxMessageBytes: number, maxInflight: number } | null} cfg.rpc - from createRpcHost
55
57
  * @param {object | null} cfg.relay - a RelayHub (see relay.mjs); null = not a relay
56
58
  * @param {object | null} cfg.sessions - a SessionRegistry (see sessions.mjs); null = sessions die with their connection
57
59
  * @param {{ limiter: object, key: Function, failureDelayMs: number } | null} cfg.rateLimit - password throttle
@@ -202,6 +204,9 @@ export function createConnectionFactory(cfg) {
202
204
  const features = [STREAM_ANNOUNCE];
203
205
  if (cfg.mcp) features.push(MCP_CALL_ID_FEATURE);
204
206
  if (cfg.files) features.push('file-transfer', 'file-write', 'file-rename');
207
+ if (cfg.rpc && dataStreams && cfg.rpc.protocols.size) {
208
+ features.push(RPC_FEATURE, ...[...cfg.rpc.protocols.keys()].map(rpcProtocolFeature), RPC_MAX_MESSAGE_PREFIX + cfg.rpc.maxMessageBytes);
209
+ }
205
210
  let hostFingerprint;
206
211
  if (cfg.hostKey) {
207
212
  hostFingerprint = cfg.hostKey.fingerprint;
@@ -211,6 +216,8 @@ export function createConnectionFactory(cfg) {
211
216
  features.push(HOST_KEY_PREFIX + toHex(cfg.hostKey.publicKey), HOST_KEY_SIG_PREFIX + toHex(sig));
212
217
  }
213
218
  }
219
+ state.features = features;
220
+ state.hostFingerprint = hostFingerprint ?? null;
214
221
  await send(serverHello({ sessionId: state.sessionId, features, hostFingerprint }));
215
222
  if (method === 'pubkey') {
216
223
  state.nonce = generateNonce();
@@ -390,6 +397,7 @@ export function createConnectionFactory(cfg) {
390
397
  case 'exec': return openExec(m);
391
398
  case 'pty': return openPty(m);
392
399
  case 'file': return openFile(m);
400
+ case 'rpc': return openRpc(m);
393
401
  default: return send(openFail({ reason: `kind "${m.kind}" is not supported by this server` }));
394
402
  }
395
403
  }
@@ -650,6 +658,60 @@ export function createConnectionFactory(cfg) {
650
658
  }
651
659
  }
652
660
 
661
+ // A typed RPC channel (wsh #85): `Open { kind: 'rpc', command: <protocol> }` is answered like an exec channel's -- OpenOk,
662
+ // then the client's data stream binds in OpenOk order -- but the stream carries a CBOR sequence of JSON-RPC messages
663
+ // handled by the protocol's handler, not process I/O.
664
+ async function openRpc(m) {
665
+ const rpc = cfg.rpc;
666
+ const protocol = typeof m.command === 'string' ? m.command : '';
667
+ const handler = rpc?.protocols.get(protocol);
668
+ if (!rpc || !dataStreams || !handler) {
669
+ const why = !rpc || !dataStreams ? 'rpc is not enabled on this server' : `protocol "${protocol}" is not supported by this server`;
670
+ return send(openFail({ reason: `UNSUPPORTED_PROTOCOL: ${why}` }));
671
+ }
672
+ const channelId = ++state.nextChannel;
673
+ let chan = null;
674
+ let stream = null;
675
+ const ch = {
676
+ // The peer closed the channel (or the connection is going away).
677
+ kill: () => {
678
+ channels.delete(channelId);
679
+ chan?.handleClose('peer-closed');
680
+ try { stream?.close().catch(() => {}); } catch { /* gone */ }
681
+ },
682
+ bind(s) {
683
+ stream = s;
684
+ chan = new RpcChannel({
685
+ maxMessageBytes: rpc.maxMessageBytes,
686
+ maxInflight: rpc.maxInflight,
687
+ write: (bytes) => s.write(bytes),
688
+ // The host ended it: close our end of the stream, then tell the client the channel is over.
689
+ close: async () => { try { await s.close(); } catch { /* peer gone */ } await finishChannel(channelId, 0); },
690
+ });
691
+ s.onData = (d) => chan.feed(d);
692
+ s.onEnd = () => { void chan.close('stream-end'); };
693
+ s.onReset = () => chan.handleClose('stream-reset');
694
+ const ctx = {
695
+ protocol, channelId, user: state.username, fingerprint: state.fingerprint, remote,
696
+ features: state.features ?? [], hostFingerprint: state.hostFingerprint,
697
+ log: (line) => log(`rpc ${protocol} channel ${channelId}: ${line}`),
698
+ };
699
+ // Inbound messages wait until the handler (possibly async) has registered its methods.
700
+ chan.hold();
701
+ let ready;
702
+ try { ready = Promise.resolve(handler(chan, ctx)); } catch (e) { ready = Promise.reject(e); }
703
+ ready.then(() => chan.release(), (e) => {
704
+ log(`rpc ${protocol} channel ${channelId}: handler failed: ${e?.message ?? e}`);
705
+ void chan.close('handler-error');
706
+ });
707
+ },
708
+ };
709
+ channels.set(channelId, ch);
710
+ pendingStreams.push(channelId);
711
+ await send(openOk({ channelId, dataMode: 'stream', capabilities: [] }));
712
+ log(`rpc channel ${channelId}: ${protocol}`);
713
+ }
714
+
653
715
  async function openPty(m) {
654
716
  if (!cfg.pty) return send(openFail({ reason: 'pty is not enabled on this server' }));
655
717
  const channelId = ++state.nextChannel;
package/src/server/fs.mjs CHANGED
@@ -25,6 +25,8 @@ export function createFileAccess({ root, readOnly = false, maxFileBytes = DEFAUL
25
25
  let realBase = null;
26
26
 
27
27
  const fail = (message) => Object.assign(new Error(message), { wsh: true });
28
+ /** A refusal on policy grounds (confinement, read-only): `wsh-fs` answers these `-32003`. */
29
+ const deny = (message) => Object.assign(new Error(message), { wsh: true, denied: true });
28
30
 
29
31
  /** Resolve a client path to an absolute path inside `root`, or throw. */
30
32
  async function resolveInside(clientPath) {
@@ -32,13 +34,13 @@ export function createFileAccess({ root, readOnly = false, maxFileBytes = DEFAUL
32
34
  if (clientPath.includes('\0')) throw fail('illegal path');
33
35
  realBase ??= await realpath(base);
34
36
  const target = path.resolve(base, '.' + path.sep + clientPath.replace(/^[/\\]+/, ''));
35
- if (target !== base && !target.startsWith(base + path.sep)) throw fail('path escapes the file root');
37
+ if (target !== base && !target.startsWith(base + path.sep)) throw deny('path escapes the file root');
36
38
  // Symlink escape: the deepest existing ancestor must still be inside the real root.
37
39
  let probe = target;
38
40
  for (;;) {
39
41
  try {
40
42
  const real = await realpath(probe);
41
- if (real !== realBase && !real.startsWith(realBase + path.sep)) throw fail('path escapes the file root');
43
+ if (real !== realBase && !real.startsWith(realBase + path.sep)) throw deny('path escapes the file root');
42
44
  break;
43
45
  } catch (err) {
44
46
  if (err.wsh) throw err;
@@ -66,7 +68,7 @@ export function createFileAccess({ root, readOnly = false, maxFileBytes = DEFAUL
66
68
  return entry;
67
69
  }
68
70
 
69
- const assertWritable = () => { if (readOnly) throw fail('file root is read-only'); };
71
+ const assertWritable = () => { if (readOnly) throw deny('file root is read-only'); };
70
72
 
71
73
  return {
72
74
  maxFileBytes,
@@ -137,6 +139,37 @@ export function createFileAccess({ root, readOnly = false, maxFileBytes = DEFAUL
137
139
  }
138
140
  },
139
141
 
142
+ /**
143
+ * Stream a byte range of a regular file in `chunkBytes` (default `FILE_CHUNK_BYTES`) pieces (for `wsh-fs` `read`/`download`).
144
+ * `length` omitted = to the end of the file; a range longer than `maxFileBytes` is refused up front.
145
+ * @returns {Promise<{ size: number, offset: number, length: number, chunks: AsyncGenerator<Uint8Array> }>}
146
+ */
147
+ async openRange(clientPath, { offset, length, chunkBytes = FILE_CHUNK_BYTES } = {}) {
148
+ const full = await resolveInside(clientPath);
149
+ const st = await stat(full);
150
+ if (!st.isFile()) throw fail('not a regular file');
151
+ const start = offset === undefined || offset === null ? 0 : Number(offset);
152
+ if (!Number.isSafeInteger(start) || start < 0) throw fail('illegal offset');
153
+ if (length !== undefined && length !== null && (!Number.isSafeInteger(Number(length)) || Number(length) < 0)) throw fail('illegal length');
154
+ const available = Math.max(0, st.size - start);
155
+ const want = length === undefined || length === null ? available : Math.min(Number(length), available);
156
+ if (want > maxFileBytes) throw fail(`read of ${want} bytes exceeds the ${maxFileBytes} byte limit`);
157
+ async function* chunks() {
158
+ const fh = await open(full, 'r');
159
+ try {
160
+ let pos = start; let left = want;
161
+ while (left > 0) {
162
+ const buf = new Uint8Array(Math.min(chunkBytes, left));
163
+ const { bytesRead } = await fh.read(buf, 0, buf.length, pos);
164
+ if (bytesRead === 0) break; // truncated underneath us
165
+ yield bytesRead === buf.length ? buf : buf.subarray(0, bytesRead);
166
+ pos += bytesRead; left -= bytesRead;
167
+ }
168
+ } finally { await fh.close(); }
169
+ }
170
+ return { size: st.size, offset: start, length: want, chunks: chunks() };
171
+ },
172
+
140
173
  async readWhole(clientPath) {
141
174
  const full = await resolveInside(clientPath);
142
175
  const st = await stat(full);
@@ -3,6 +3,8 @@
3
3
  * root (`@johnhenry/wsh`) never imports it.
4
4
  */
5
5
 
6
+ import type { RpcChannel } from '@johnhenry/wsh';
7
+
6
8
  /** Who is asking to authenticate. */
7
9
  export interface WshServerPrincipal {
8
10
  username: string;
@@ -290,11 +292,44 @@ export interface WshServerOptions {
290
292
  webTransport?: WshServerWebTransportOptions;
291
293
  /** Serve MCP tools (`McpDiscover` / `McpCall`) and advertise `mcp-call-id`. Off by default. */
292
294
  mcp?: WshServerMcpOptions;
295
+ /**
296
+ * Typed RPC channels (wsh #85): `{ [protocol]: handler }`. Each is advertised as `rpc-protocol:<name>` (plus `rpc` and
297
+ * `rpc-max-message:<n>`). Built-ins: `'wsh-host': true` (`host.info`, `host.ping`), `'wsh-fs': true` (the server's
298
+ * `fs`) or `{ root, readOnly?, maxFileBytes? }` (`stat list read write upload download rename mkdir remove`). Anything
299
+ * else is a function run per opened channel, e.g. `mcp: mcpServerAdapter(server)`. Off by default.
300
+ */
301
+ rpc?: Record<string, true | WshServerFsOptions | WshRpcHandler | false | null | undefined>;
302
+ /** Largest single rpc message (default 1048576). */
303
+ rpcMaxMessageBytes?: number;
304
+ /** Concurrent requests per rpc channel before `-32002` (default 64). */
305
+ rpcMaxInflight?: number;
293
306
  /** How long exec output waits for a client that has not yet opened its data stream (default 3000). */
294
307
  bindTimeoutMs?: number;
295
308
  onLog?: (line: string) => void;
296
309
  }
297
310
 
311
+ /** Per-channel context handed to an rpc protocol handler. */
312
+ export interface WshRpcContext {
313
+ protocol: string;
314
+ channelId: number;
315
+ user: string | null;
316
+ fingerprint: string | null;
317
+ remote: { address?: string; headers?: Record<string, unknown> };
318
+ /** The exact `ServerHello.features` this connection received. */
319
+ features: string[];
320
+ hostFingerprint: string | null;
321
+ log(line: string): void;
322
+ }
323
+
324
+ /** Runs once per opened channel; register methods on `channel`. Inbound messages wait until the returned promise settles. */
325
+ export type WshRpcHandler = (channel: RpcChannel, ctx: WshRpcContext) => void | Promise<void>;
326
+
327
+ /**
328
+ * Expose an MCP `Server` (`@modelcontextprotocol/sdk`) over an rpc channel: `rpc: { mcp: mcpServerAdapter(server) }`.
329
+ * An instance serves one channel at a time; pass a factory for one `Server` per channel. The SDK is not imported.
330
+ */
331
+ export function mcpServerAdapter(serverOrFactory: { connect(transport: any): Promise<void>; close?(): Promise<void> } | ((ctx: WshRpcContext) => any)): WshRpcHandler;
332
+
298
333
  export interface WshServerAddress {
299
334
  address: string;
300
335
  port: number;
@@ -15,8 +15,9 @@ import { SessionRegistry } from './sessions.mjs';
15
15
  import { RelayHub } from './relay.mjs';
16
16
  import { startWebTransport } from './webtransport.mjs';
17
17
  import { createConnectionFactory, STREAM_ANNOUNCE } from './connection.mjs';
18
+ import { createRpcHost, mcpServerAdapter } from './rpc.mjs';
18
19
 
19
- export { parseAuthorizedKeys, STREAM_ANNOUNCE };
20
+ export { parseAuthorizedKeys, STREAM_ANNOUNCE, mcpServerAdapter };
20
21
  export { createReverseHost } from './reverse-host.mjs';
21
22
  export { generateSelfSignedCertificate } from './self-signed.mjs';
22
23
 
@@ -45,6 +46,12 @@ export { generateSelfSignedCertificate } from './self-signed.mjs';
45
46
  * Keep pty/exec sessions alive across disconnects so a client can `resumeSession()` / `attachSession()`
46
47
  * them: a per-server registry, a ring buffer of the newest `ringBytes` (default 1 MiB) of output, and
47
48
  * `detachTtlMs` (default 300000; 0 = kill on disconnect) / `maxDetached` (default 16) bounds. Off unless given.
49
+ * @param {Record<string, true | object | Function>} [options.rpc] - Typed RPC channels (wsh #85): `{ [protocol]: handler }`.
50
+ * Built-ins: `'wsh-host': true`, `'wsh-fs': true` (confined by `fs`) or `{ root, readOnly?, maxFileBytes? }`; anything else is a
51
+ * function `(channel, ctx) => void` run per opened channel -- e.g. `mcp: mcpServerAdapter(server)`. Each protocol is
52
+ * advertised as `rpc-protocol:<name>`. Off unless given.
53
+ * @param {number} [options.rpcMaxMessageBytes=1048576] - Largest single rpc message (advertised as `rpc-max-message:<n>`).
54
+ * @param {number} [options.rpcMaxInflight=64] - Concurrent requests per rpc channel before `-32002`.
48
55
  * @param {string | Uint8Array} [options.sessionSecret] - Alias for `sessions.sessionSecret`.
49
56
  * @param {true | { file: string } | CryptoKeyPair} [options.hostKey] - The server's own Ed25519
50
57
  * identity, advertised (with proof of possession) so clients can pin it. See `src/host-key.mjs`.
@@ -55,7 +62,7 @@ export { generateSelfSignedCertificate } from './self-signed.mjs';
55
62
  * @returns {{ listen(): Promise<{address: string, port: number}>, close(): Promise<void>, address(): ({address: string, port: number} | null) }}
56
63
  */
57
64
  export function createWshServer({
58
- host = '127.0.0.1', port = 0, auth, exec, pty, fs, hostKey, mcp, sessions, sessionSecret, relay, webTransport, bindTimeoutMs = 3000, onLog = () => {},
65
+ host = '127.0.0.1', port = 0, auth, exec, pty, fs, hostKey, mcp, rpc, rpcMaxMessageBytes, rpcMaxInflight, sessions, sessionSecret, relay, webTransport, bindTimeoutMs = 3000, onLog = () => {},
59
66
  } = {}) {
60
67
  let wss = null;
61
68
  let bound = null;
@@ -68,6 +75,7 @@ export function createWshServer({
68
75
  key: typeof rl.key === 'function' ? rl.key : ({ address }) => String(address ?? 'unknown'),
69
76
  failureDelayMs: rl.failureDelayMs ?? 250,
70
77
  };
78
+ const rpcHost = rpc ? createRpcHost(rpc, { files, maxMessageBytes: rpcMaxMessageBytes, maxInflight: rpcMaxInflight }) : null;
71
79
  let host_ = null;
72
80
  let registry = null;
73
81
  let hub = null;
@@ -98,7 +106,7 @@ export function createWshServer({
98
106
  if (relay) hub = new RelayHub({ ...relay, log: onLog });
99
107
  const attach = createConnectionFactory({
100
108
  authorize, execRunner, execOptions, pty: ptyConfig, files, bindTimeoutMs, log: onLog,
101
- methods, hostKey: host_, rateLimit, mcp: mcpHost, sessions: registry, relay: hub,
109
+ methods, hostKey: host_, rateLimit, mcp: mcpHost, rpc: rpcHost, sessions: registry, relay: hub,
102
110
  });
103
111
 
104
112
  wss = new WebSocketServer({ host, port });
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Typed RPC channels for `@johnhenry/wsh/server` -- wsh #85.
3
+ *
4
+ * `createWshServer({ rpc: { [protocol]: handler } })`. A handler is
5
+ * - `true` for a built-in (`wsh-host`; `wsh-fs`, confined by the server's `fs` option), or `{ root, ... }` for
6
+ * `wsh-fs` with its own root;
7
+ * - a function `(channel, ctx) => void | Promise<void>` for anything else (including `mcpServerAdapter(server)`),
8
+ * called once per opened channel with an `RpcChannel` to register methods on.
9
+ *
10
+ * Built-ins:
11
+ * wsh-host host.info, host.ping
12
+ * wsh-fs stat, list, read, write, upload, download, rename, mkdir, remove
13
+ */
14
+
15
+ import { createRequire } from 'node:module';
16
+ import { RpcError, RPC_ERROR, RPC_PROTOCOL_NAME_RE, RPC_DEFAULT_MAX_MESSAGE } from '../rpc.mjs';
17
+ import { RpcMcpTransport } from '../rpc-mcp.mjs';
18
+ import { createFileAccess } from './fs.mjs';
19
+
20
+ const { version: PACKAGE_VERSION } = createRequire(import.meta.url)('../../package.json');
21
+
22
+ export const WSH_HOST = 'wsh-host';
23
+ export const WSH_FS = 'wsh-fs';
24
+ const BUILT_INS = new Set([WSH_HOST, WSH_FS, 'mcp']);
25
+
26
+ const needString = (params, key) => {
27
+ const v = params?.[key];
28
+ if (typeof v !== 'string') throw new RpcError(RPC_ERROR.INVALID_PARAMS, `"${key}" (string) is required`);
29
+ return v;
30
+ };
31
+
32
+ const needBytes = (params, key) => {
33
+ const v = params?.[key];
34
+ if (!(v instanceof Uint8Array)) throw new RpcError(RPC_ERROR.INVALID_PARAMS, `"${key}" (byte string) is required`);
35
+ return v;
36
+ };
37
+
38
+ /** Map a file-access failure onto an RpcError. */
39
+ function fsError(e) {
40
+ if (e instanceof RpcError) return e;
41
+ if (e?.denied) return new RpcError(RPC_ERROR.UNAUTHORIZED, e.message);
42
+ if (e?.wsh) return new RpcError(RPC_ERROR.INVALID_PARAMS, e.message);
43
+ if (e?.code === 'ENOENT') return new RpcError(RPC_ERROR.INTERNAL, 'no such file or directory', { code: 'ENOENT' });
44
+ return new RpcError(RPC_ERROR.INTERNAL, 'file operation failed', e?.code ? { code: e.code } : undefined);
45
+ }
46
+
47
+ /** @param {ReturnType<typeof createFileAccess>} files */
48
+ function fsProtocol(files) {
49
+ return (channel, ctx) => {
50
+ const guarded = (fn) => async (params, rctx) => {
51
+ try { return await fn(params ?? {}, rctx); } catch (e) {
52
+ if (!(e instanceof RpcError)) ctx.log?.(`wsh-fs: ${e.wsh ? e.message : (e.stack ?? e)}`);
53
+ throw fsError(e);
54
+ }
55
+ };
56
+ channel.onRequest('stat', guarded(async (p) => (await files.operate('stat', needString(p, 'path'))).metadata));
57
+ channel.onRequest('list', guarded(async (p) => {
58
+ const r = await files.operate('list', typeof p.path === 'string' ? p.path : '/');
59
+ return { path: r.metadata?.path ?? p.path ?? '/', entries: r.entries ?? [] };
60
+ }));
61
+ channel.onRequest('mkdir', guarded(async (p) => (await files.operate('mkdir', needString(p, 'path'))).metadata));
62
+ channel.onRequest('remove', guarded(async (p) => (await files.operate('remove', needString(p, 'path'))).metadata));
63
+ channel.onRequest('rename', guarded(async (p) => (await files.operate('rename', needString(p, 'path'), { newPath: needString(p, 'newPath') })).metadata));
64
+ channel.onRequest('write', guarded(async (p) => (await files.operate('write', needString(p, 'path'), { offset: p.offset, data: needBytes(p, 'data') })).metadata));
65
+ // upload: the chunk-friendly write. offset omitted/0 creates or truncates the file; offset > 0 continues it in place.
66
+ channel.onRequest('upload', guarded(async (p) => {
67
+ const data = needBytes(p, 'data');
68
+ const offset = p.offset === undefined || p.offset === null ? 0 : Number(p.offset);
69
+ if (!Number.isSafeInteger(offset) || offset < 0) throw new RpcError(RPC_ERROR.INVALID_PARAMS, 'illegal offset');
70
+ if (offset === 0) await files.writeAt(needString(p, 'path'), data, undefined, { mkdirs: true });
71
+ else await files.writeAt(needString(p, 'path'), data, offset);
72
+ return { written: data.byteLength, offset };
73
+ }));
74
+ // read / download: chunks arrive as `$/progress`, the result is a summary.
75
+ const stream = async (p, rctx) => {
76
+ const range = await files.openRange(needString(p, 'path'), {
77
+ offset: p.offset, length: p.length, chunkBytes: Math.max(64, Math.min(64 * 1024, rctx.channel.maxMessageBytes - 512)),
78
+ });
79
+ let sent = 0;
80
+ for await (const chunk of range.chunks) {
81
+ if (rctx.signal.aborted) break;
82
+ await rctx.progress(chunk);
83
+ sent += chunk.byteLength;
84
+ }
85
+ return { size: range.size, offset: range.offset, length: sent, eof: range.offset + sent >= range.size };
86
+ };
87
+ channel.onRequest('read', guarded(stream));
88
+ channel.onRequest('download', guarded((p, rctx) => stream({ path: p.path }, rctx)));
89
+ };
90
+ }
91
+
92
+ function hostProtocol(info) {
93
+ return (channel, ctx) => {
94
+ channel.onRequest('host.info', () => ({
95
+ version: PACKAGE_VERSION,
96
+ protocol: 'wsh-v1',
97
+ features: [...ctx.features],
98
+ hostFingerprint: ctx.hostFingerprint ?? null,
99
+ user: ctx.user,
100
+ rpc: { protocols: info.protocols(), maxMessageBytes: info.maxMessageBytes },
101
+ }));
102
+ channel.onRequest('host.ping', () => ({ time: Date.now() }));
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Validate the `rpc` option and build the per-connection protocol table.
108
+ * @param {Record<string, true | object | Function>} rpc
109
+ * @param {{ files: object | null, maxMessageBytes?: number, maxInflight?: number }} deps
110
+ * @returns {{ protocols: Map<string, Function>, maxMessageBytes: number, maxInflight: number }}
111
+ */
112
+ export function createRpcHost(rpc, { files, maxMessageBytes = RPC_DEFAULT_MAX_MESSAGE, maxInflight = 64 } = {}) {
113
+ if (!rpc || typeof rpc !== 'object' || Array.isArray(rpc)) throw new TypeError('createWshServer: rpc must be an object of { [protocol]: handler }');
114
+ if (!(maxMessageBytes >= 256)) throw new TypeError('createWshServer: rpcMaxMessageBytes must be at least 256');
115
+ const protocols = new Map();
116
+ const info = { protocols: () => [...protocols.keys()], maxMessageBytes };
117
+ for (const [name, spec] of Object.entries(rpc)) {
118
+ if (spec === undefined || spec === false || spec === null) continue;
119
+ if (!RPC_PROTOCOL_NAME_RE.test(name)) throw new TypeError(`createWshServer: rpc protocol name ${JSON.stringify(name)} is not allowed (letters, digits, ".", "_", "-")`);
120
+ if (typeof spec === 'function') { protocols.set(name, spec); continue; }
121
+ if (name === WSH_HOST && spec === true) { protocols.set(name, hostProtocol(info)); continue; }
122
+ if (name === WSH_FS && (spec === true || (spec && typeof spec === 'object'))) {
123
+ if (spec === true && !files) throw new TypeError('createWshServer: rpc["wsh-fs"]: true needs the fs option (or pass { root })');
124
+ protocols.set(name, fsProtocol(spec === true ? files : createFileAccess(spec)));
125
+ continue;
126
+ }
127
+ if (name === 'mcp') throw new TypeError('createWshServer: rpc.mcp needs a handler: mcpServerAdapter(server)');
128
+ throw new TypeError(`createWshServer: rpc[${JSON.stringify(name)}] needs a handler function (channel, ctx) => void${BUILT_INS.has(name) ? ' or a valid built-in config' : ''}`);
129
+ }
130
+ return { protocols, maxMessageBytes, maxInflight };
131
+ }
132
+
133
+ /**
134
+ * Expose an MCP `Server` (`@modelcontextprotocol/sdk`) over an `rpc` channel: `rpc: { mcp: mcpServerAdapter(server) }`.
135
+ *
136
+ * Pass a Server instance to serve one channel at a time (a second concurrent channel is closed with an error),
137
+ * or a factory `(ctx) => Server` to give every channel its own. The SDK is not imported here; any object with
138
+ * `connect(transport)` works.
139
+ *
140
+ * @param {{ connect(transport: object): Promise<void>, close?(): Promise<void> } | ((ctx: object) => any)} serverOrFactory
141
+ * @returns {(channel: import('../rpc.mjs').RpcChannel, ctx: object) => Promise<void>}
142
+ */
143
+ export function mcpServerAdapter(serverOrFactory) {
144
+ const isFactory = typeof serverOrFactory === 'function' && typeof serverOrFactory.connect !== 'function';
145
+ if (!isFactory && typeof serverOrFactory?.connect !== 'function') {
146
+ throw new TypeError('mcpServerAdapter: expected an MCP Server (with connect(transport)) or a factory returning one');
147
+ }
148
+ let busy = false;
149
+ return async (channel, ctx) => {
150
+ if (!isFactory && busy) {
151
+ // The SDK's Server speaks to one transport at a time; saying so beats silently stealing the first channel.
152
+ throw new Error('mcp server instance is already serving another channel (pass a factory for concurrent channels)');
153
+ }
154
+ const server = isFactory ? await serverOrFactory(ctx) : serverOrFactory;
155
+ if (typeof server?.connect !== 'function') throw new TypeError('mcpServerAdapter: the factory must return an MCP Server');
156
+ const transport = new RpcMcpTransport(channel);
157
+ if (!isFactory) busy = true;
158
+ const prior = channel.onClose;
159
+ channel.onClose = (reason) => {
160
+ if (!isFactory) busy = false;
161
+ try { prior?.(reason); } catch { /* ignore */ }
162
+ if (isFactory) Promise.resolve(server.close?.()).catch(() => {});
163
+ };
164
+ try {
165
+ await server.connect(transport);
166
+ } catch (e) {
167
+ if (!isFactory) busy = false;
168
+ throw e;
169
+ }
170
+ };
171
+ }