@johnhenry/wsh 0.19.0 → 0.20.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.
package/README.md CHANGED
@@ -186,7 +186,67 @@ paths and symlinks pointing out of it are refused -- for `write` and `rename`
186
186
  (both paths) exactly as for `read`.
187
187
 
188
188
  Not implemented (each has a tracking issue): WebTransport, relay/reverse
189
- mode, attach/resume of server-side sessions, MCP.
189
+ mode, attach/resume of server-side sessions.
190
+
191
+ ### MCP tools
192
+
193
+ `createWshServer({ mcp })` answers `McpDiscover` and `McpCall`, so the stock
194
+ `client.discoverTools()` / `client.callTool()` and `WshMcpBridge` work against
195
+ it. Off unless you pass it.
196
+
197
+ ```js
198
+ const server = createWshServer({
199
+ auth,
200
+ mcp: {
201
+ tools: [{
202
+ name: 'read_note',
203
+ description: 'Read a note by id',
204
+ inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'], additionalProperties: false },
205
+ call: async ({ id }, { user, signal }) => ({ success: true, output: await readNote(id, { signal }) }),
206
+ }],
207
+ authorize: (user, tool) => tool.name !== 'admin_only' || user === 'root', // optional
208
+ // client: mcpSdkClient, // optional: proxy an @modelcontextprotocol/sdk Client's tools too
209
+ // maxConcurrent: 8, timeoutMs: 30_000,
210
+ },
211
+ });
212
+ ```
213
+
214
+ - **Tool shape:** `tools` is an array of `{ name, description, inputSchema,
215
+ call(args, { user, fingerprint, signal }) }` or a `{ name: { ... } }` map.
216
+ `inputSchema` is advertised to clients as `parameters` (the `McpToolSpec`
217
+ field).
218
+ - **Arguments are untrusted.** They are validated against `inputSchema` before
219
+ `call()` runs; invalid arguments never reach the tool. The package has no
220
+ runtime dependencies, so the validator covers the keywords tool schemas use
221
+ (`type`, `enum`, `const`, `properties`, `required`, `additionalProperties`,
222
+ `items`, length/range/`pattern`/`multipleOf` bounds, `allOf`/`anyOf`/`oneOf`/
223
+ `not`, local `$ref`); a schema using any other keyword (`if`,
224
+ `patternProperties`, ...) is **refused when the server is created** rather than
225
+ silently enforced less strictly. Only tools you list are reachable.
226
+ - **Results:** whatever `call()` returns is `McpResult.result` verbatim (the
227
+ stock `WshMcpBridge` normalizes the `{ success, output, error }` shape, and
228
+ wraps anything else as `{ success: true, output }`). A tool that throws, an
229
+ unknown tool, invalid arguments, a timeout or the concurrency cap all answer
230
+ `{ success: false, error }` -- the error text is the thrown `message`, so
231
+ throw messages you are happy for the caller to read.
232
+ - **Correlation:** every `McpResult` echoes the call's `call_id`, and the
233
+ server advertises `mcp-call-id` (when `mcp` is configured), so concurrent
234
+ calls from one client get their own results, and a slow tool does not hold up
235
+ the calls behind it.
236
+ - **Limits:** `maxConcurrent` in-flight calls per connection (default 8) and a
237
+ `timeoutMs` per call (default 30000). On timeout or disconnect the tool's
238
+ `signal` aborts; a tool that ignores it keeps running but its reply is dropped.
239
+ - **Access:** discovery and calls are answered after authentication only.
240
+ `authorize(user, tool, { username, fingerprint })` filters both, and a tool
241
+ hidden from a caller is indistinguishable from one that does not exist.
242
+ - **Proxying:** `client` takes anything with `listTools()` / `callTool()` (an
243
+ `@modelcontextprotocol/sdk` `Client`; this package does not import the SDK).
244
+ Its tools are re-listed on every discovery, validated against their own
245
+ `inputSchema`s here, and `isError` results become `{ success: false, error }`,
246
+ else the result is `structuredContent` or the `content` array. A proxied tool
247
+ whose schema uses an unsupported keyword is not exposed.
248
+ - Without `mcp`, `McpDiscover` answers an empty list and `McpCall` an
249
+ `{ success: false, error: 'mcp is not enabled on this server' }` result.
190
250
 
191
251
  ### Host key (fingerprint / TOFU)
192
252
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnhenry/wsh",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Web Shell \u2014 browser-native remote command execution over WebTransport and WebSocket with Ed25519 authentication",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
package/src/client.mjs CHANGED
@@ -1810,6 +1810,9 @@ export class WshClient {
1810
1810
  async fileOperation(op, path, opts = {}, timeout = DEFAULT_OPEN_TIMEOUT) {
1811
1811
  this.#assertAuthenticated('fileOperation');
1812
1812
  const channelId = this._nextChannelId();
1813
+ // FileOp and FileResult both carry channel_id; each call claims only the
1814
+ // result for its own id, so overlapping operations cannot swap replies (#72).
1815
+ const byChannel = (m) => m.channel_id === channelId;
1813
1816
 
1814
1817
  if (op === 'write' || op === 'rename') {
1815
1818
  const feature = op === 'write' ? 'file-write' : 'file-rename';
@@ -1824,7 +1827,7 @@ export class WshClient {
1824
1827
  }
1825
1828
  // Registered before sending: the host may fail fast (read-only, too big)
1826
1829
  // and answer before the last chunk goes out.
1827
- const result = this.#waitForMessage([MSG.FILE_RESULT], timeout, `Timed out waiting for file ${op} result`);
1830
+ const result = this.#waitForMessage([MSG.FILE_RESULT], timeout, `Timed out waiting for file ${op} result`, byChannel);
1828
1831
  result.catch(() => {});
1829
1832
  await this.#transport.sendControl(fileOpMsg({
1830
1833
  channelId, op, path, offset: op === 'write' ? opts.offset : undefined, length: data.byteLength,
@@ -1846,7 +1849,8 @@ export class WshClient {
1846
1849
  return this.#waitForMessage(
1847
1850
  [MSG.FILE_RESULT],
1848
1851
  timeout,
1849
- `Timed out waiting for file ${op} result`
1852
+ `Timed out waiting for file ${op} result`,
1853
+ byChannel
1850
1854
  );
1851
1855
  }
1852
1856
 
@@ -18,12 +18,13 @@ import { QMuxConnection } from '../qmux-connection.mjs';
18
18
  import { FrameDecoder, frameEncode } from '../cbor.mjs';
19
19
  import {
20
20
  MSG, serverHello, challenge, authOk, authFail, openOk, openFail, sessionData,
21
- exit as exitMsg, close as closeMsg, pong, fileResult, fileChunk,
21
+ exit as exitMsg, close as closeMsg, pong, fileResult, fileChunk, mcpTools, mcpResult,
22
22
  } from '../messages.gen.mjs';
23
23
  import {
24
24
  generateNonce, verifyChallenge, fingerprint, importPublicKeyRaw,
25
25
  } from '../auth.mjs';
26
26
  import { FILE_CHUNK_BYTES } from './fs.mjs';
27
+ import { MCP_CALL_ID_FEATURE } from '../client.mjs';
27
28
  import { HOST_KEY_PREFIX, HOST_KEY_SIG_PREFIX, findClientNonce, hostKeyProofMessage, toHex } from '../host-key.mjs';
28
29
 
29
30
  export const STREAM_ANNOUNCE = 'stream-announce';
@@ -47,6 +48,7 @@ const toBytes = (d) => (typeof d === 'string' ? enc.encode(d) : d);
47
48
  * @param {object | null} cfg.files - from createFileAccess
48
49
  * @param {{ pubkey: boolean, password: Function | null }} cfg.methods - which auth methods are on
49
50
  * @param {object | null} cfg.hostKey - from loadHostKey
51
+ * @param {object | null} cfg.mcp - from createMcpHost
50
52
  * @param {{ limiter: object, key: Function, failureDelayMs: number } | null} cfg.rateLimit - password throttle
51
53
  * @param {number} cfg.bindTimeoutMs - how long exec output waits for the data stream
52
54
  * @param {(line: string) => void} cfg.log
@@ -59,12 +61,14 @@ export function createConnectionFactory(cfg) {
59
61
  const log = (m) => cfg.log(`[conn ${cid}] ${m}`);
60
62
  const state = {
61
63
  sessionId: randomUUID(), nonce: null, username: null, authed: false,
62
- closed: false, nextChannel: 0,
64
+ closed: false, nextChannel: 0, fingerprint: null,
63
65
  };
64
66
  /** @type {Map<number, any>} */
65
67
  const channels = new Map();
66
68
  /** FileOp `write`/`rename`s awaiting their FileChunk frames, by client-chosen channel id. */
67
69
  const fileWrites = new Map();
70
+ /** AbortControllers of in-flight MCP calls: aborted on disconnect, counted for the cap. */
71
+ const mcpCalls = new Set();
68
72
  /** Channel ids awaiting their client-opened data stream, in OpenOk order. */
69
73
  const pendingStreams = [];
70
74
  const decoder = new FrameDecoder();
@@ -87,6 +91,8 @@ export function createConnectionFactory(cfg) {
87
91
  state.closed = true;
88
92
  for (const ch of channels.values()) ch.kill?.();
89
93
  channels.clear();
94
+ for (const c of mcpCalls) c.abort(new Error('connection closed'));
95
+ mcpCalls.clear();
90
96
  for (const w of fileWrites.values()) clearTimeout(w.timer);
91
97
  fileWrites.clear();
92
98
  try { qmux.destroy(new Error(why)); } catch { /* already gone */ }
@@ -130,6 +136,10 @@ export function createConnectionFactory(cfg) {
130
136
  case MSG.CLOSE: { const ch = channels.get(m.channel_id); if (ch) { ch.kill?.(); } return; }
131
137
  case MSG.FILE_OP: return handleFileOp(m);
132
138
  case MSG.FILE_CHUNK: return (fileWrites.has(m.channel_id) ? handleWriteChunk(m) : channels.get(m.channel_id)?.chunk?.(m));
139
+ case MSG.MCP_DISCOVER: return handleMcpDiscover();
140
+ // Not awaited: the handler chain is serial, and a slow tool must not hold up
141
+ // the next call (or a Close) behind it.
142
+ case MSG.MCP_CALL: handleMcpCall(m); return;
133
143
  default: log(`ignored message type 0x${m.type.toString(16)}`);
134
144
  }
135
145
  }
@@ -155,6 +165,7 @@ export function createConnectionFactory(cfg) {
155
165
  state.username = user;
156
166
  state.method = method;
157
167
  const features = [STREAM_ANNOUNCE];
168
+ if (cfg.mcp) features.push(MCP_CALL_ID_FEATURE);
158
169
  if (cfg.files) features.push('file-transfer', 'file-write', 'file-rename');
159
170
  let hostFingerprint;
160
171
  if (cfg.hostKey) {
@@ -199,6 +210,7 @@ export function createConnectionFactory(cfg) {
199
210
  } catch (e) { log(`authorize threw: ${e.message}`); }
200
211
  if (!allowed) return refuse('key not authorized');
201
212
  state.authed = true;
213
+ state.fingerprint = fp;
202
214
  state.nonce = null;
203
215
  await send(authOk({ sessionId: state.sessionId, token: randomBytes(16), ttl: 3600 }));
204
216
  log(`authenticated ${state.username} (${fp.slice(0, 12)}...)`);
@@ -228,6 +240,63 @@ export function createConnectionFactory(cfg) {
228
240
  log(`authenticated ${state.username} (password)`);
229
241
  }
230
242
 
243
+ // ── MCP ───────────────────────────────────────────────────────────
244
+
245
+ const principal = () => ({ username: state.username, fingerprint: state.fingerprint });
246
+
247
+ async function handleMcpDiscover() {
248
+ let tools = [];
249
+ if (cfg.mcp) {
250
+ try { tools = await cfg.mcp.list(state.username, principal()); } catch (e) { log(`mcp discover failed: ${e.message}`); }
251
+ }
252
+ return send(mcpTools({ tools }));
253
+ }
254
+
255
+ function handleMcpCall(m) {
256
+ const callId = typeof m.call_id === 'string' ? m.call_id : undefined;
257
+ const reply = (result) => {
258
+ try { return send(mcpResult({ result, callId })); } catch (e) {
259
+ log(`mcp result for ${m.tool} not encodable: ${e.message}`);
260
+ return send(mcpResult({ result: { success: false, error: 'tool result could not be encoded' }, callId }));
261
+ }
262
+ };
263
+ const fail = (error) => reply({ success: false, error });
264
+ if (!cfg.mcp) { fail('mcp is not enabled on this server'); return; }
265
+ if (mcpCalls.size >= cfg.mcp.maxConcurrent) { fail('too many concurrent MCP calls'); return; }
266
+
267
+ const abort = new AbortController();
268
+ mcpCalls.add(abort);
269
+ (async () => {
270
+ const name = typeof m.tool === 'string' ? m.tool : '';
271
+ const tool = name ? await cfg.mcp.find(name, state.username, principal()) : null;
272
+ if (!tool) return fail(`unknown tool: ${name || String(m.tool)}`);
273
+ const args = m.arguments ?? {};
274
+ const problem = cfg.mcp.validate(tool, args);
275
+ if (problem) return fail(`invalid arguments: ${problem}`);
276
+
277
+ let timer = null;
278
+ const aborted = new Promise((_, reject) => {
279
+ abort.signal.addEventListener('abort', () => reject(abort.signal.reason ?? new Error('aborted')), { once: true });
280
+ });
281
+ aborted.catch(() => {});
282
+ if (cfg.mcp.timeoutMs > 0) {
283
+ timer = setTimeout(() => abort.abort(new Error(`tool ${name} timed out after ${cfg.mcp.timeoutMs}ms`)), cfg.mcp.timeoutMs);
284
+ }
285
+ try {
286
+ const ctx = { user: state.username, fingerprint: state.fingerprint, signal: abort.signal };
287
+ const out = await Promise.race([Promise.resolve().then(() => tool.call(args, ctx)), aborted]);
288
+ return reply(out === undefined ? null : out);
289
+ } catch (e) {
290
+ if (!state.closed) log(`mcp tool ${name} failed: ${e?.message ?? e}`);
291
+ return fail(String(e?.message ?? e));
292
+ } finally {
293
+ clearTimeout(timer);
294
+ }
295
+ })()
296
+ .catch((e) => log(`mcp call ${m.tool}: ${e.message}`))
297
+ .finally(() => mcpCalls.delete(abort));
298
+ }
299
+
231
300
  // ── Open ──────────────────────────────────────────────────────────
232
301
 
233
302
  function handleOpen(m) {
@@ -119,6 +119,45 @@ export interface WshServerFsOptions {
119
119
  maxFileBytes?: number;
120
120
  }
121
121
 
122
+ /** Context handed to an MCP tool's `call()`. */
123
+ export interface WshMcpCallContext {
124
+ /** Authenticated username. */
125
+ user: string;
126
+ /** Hex SHA-256 of the client's key (`null` for a password login). */
127
+ fingerprint: string | null;
128
+ /** Aborts on client disconnect or `timeoutMs`. A tool that ignores it keeps running but its reply is dropped. */
129
+ signal: AbortSignal;
130
+ }
131
+
132
+ /** One tool exposed over `McpDiscover` / `McpCall`. */
133
+ export interface WshMcpTool {
134
+ name: string;
135
+ description?: string;
136
+ /** JSON Schema for the arguments (default `{ type: 'object' }`). Enforced before `call()` runs; keywords outside the supported subset are refused at startup. Advertised to clients as `parameters`. */
137
+ inputSchema?: Record<string, unknown> | boolean;
138
+ /** The result is sent as `McpResult.result` verbatim (any CBOR-encodable value). Throwing sends `{ success: false, error: message }`. */
139
+ call(args: any, ctx: WshMcpCallContext): unknown | Promise<unknown>;
140
+ }
141
+
142
+ /** The part of an `@modelcontextprotocol/sdk` `Client` that the server proxies. */
143
+ export interface WshMcpClientLike {
144
+ listTools(): Promise<{ tools: Array<{ name: string; description?: string; inputSchema?: Record<string, unknown> }> }>;
145
+ callTool(params: { name: string; arguments: unknown }, resultSchema?: unknown, options?: { signal?: AbortSignal }): Promise<{ content?: unknown; structuredContent?: unknown; isError?: boolean }>;
146
+ }
147
+
148
+ export interface WshServerMcpOptions {
149
+ /** Tools to serve: a list, or a `{ name: { description, inputSchema, call } }` map. */
150
+ tools?: WshMcpTool[] | Record<string, Omit<WshMcpTool, 'name'>>;
151
+ /** An MCP client whose tools are proxied (local `tools` win on a name clash). */
152
+ client?: WshMcpClientLike;
153
+ /** Per-principal filter, applied to discovery and to calls alike (a hidden tool looks unknown). */
154
+ authorize?: (user: string, tool: { name: string; description: string; inputSchema: unknown }, who: { username: string; fingerprint: string | null }) => boolean | Promise<boolean>;
155
+ /** In-flight calls per connection (default 8); further calls get an error result. */
156
+ maxConcurrent?: number;
157
+ /** Per-call time limit in ms (default 30000; 0 = none). */
158
+ timeoutMs?: number;
159
+ }
160
+
122
161
  export interface WshServerOptions {
123
162
  /** Bind address (default `127.0.0.1`). */
124
163
  host?: string;
@@ -138,6 +177,8 @@ export interface WshServerOptions {
138
177
  hostKey?: true | { file: string } | CryptoKeyPair;
139
178
  /** Enable file ops and uploads/downloads under one directory. Off by default. */
140
179
  fs?: WshServerFsOptions;
180
+ /** Serve MCP tools (`McpDiscover` / `McpCall`) and advertise `mcp-call-id`. Off by default. */
181
+ mcp?: WshServerMcpOptions;
141
182
  /** How long exec output waits for a client that has not yet opened its data stream (default 3000). */
142
183
  bindTimeoutMs?: number;
143
184
  onLog?: (line: string) => void;
@@ -13,6 +13,7 @@ import { loadHostKey } from './host-key.mjs';
13
13
  import { spawnRunner } from './exec.mjs';
14
14
  import { normalizePty } from './pty.mjs';
15
15
  import { createFileAccess } from './fs.mjs';
16
+ import { createMcpHost } from './mcp.mjs';
16
17
  import { createConnectionFactory, STREAM_ANNOUNCE } from './connection.mjs';
17
18
 
18
19
  export { parseAuthorizedKeys, STREAM_ANNOUNCE };
@@ -26,6 +27,9 @@ export { parseAuthorizedKeys, STREAM_ANNOUNCE };
26
27
  * `{ cwd, env, shell, timeoutMs, clientEnv }`, or a custom `run(command, io)` function.
27
28
  * @param {{ spawn: Function, shell?: string, cwd?: string, env?: object }} [options.pty]
28
29
  * @param {{ root: string, readOnly?: boolean, maxFileBytes?: number }} [options.fs]
30
+ * @param {{ tools?: object[] | object, client?: object, authorize?: Function, maxConcurrent?: number, timeoutMs?: number }} [options.mcp]
31
+ * MCP tools served over `McpDiscover` / `McpCall`: `tools` (`{ name, description, inputSchema, call(args, { user, signal }) }`),
32
+ * and/or `client` (an `@modelcontextprotocol/sdk` Client to proxy). Off unless given.
29
33
  * @param {true | { file: string } | CryptoKeyPair} [options.hostKey] - The server's own Ed25519
30
34
  * identity, advertised (with proof of possession) so clients can pin it. See `src/host-key.mjs`.
31
35
  * @param {object} [options.auth.rateLimit] - Password-failure throttle: `{ maxFailures=5, windowMs=60000,
@@ -35,7 +39,7 @@ export { parseAuthorizedKeys, STREAM_ANNOUNCE };
35
39
  * @returns {{ listen(): Promise<{address: string, port: number}>, close(): Promise<void>, address(): ({address: string, port: number} | null) }}
36
40
  */
37
41
  export function createWshServer({
38
- host = '127.0.0.1', port = 0, auth, exec, pty, fs, hostKey, bindTimeoutMs = 3000, onLog = () => {},
42
+ host = '127.0.0.1', port = 0, auth, exec, pty, fs, hostKey, mcp, bindTimeoutMs = 3000, onLog = () => {},
39
43
  } = {}) {
40
44
  let wss = null;
41
45
  let bound = null;
@@ -50,6 +54,7 @@ export function createWshServer({
50
54
  }
51
55
  const ptyConfig = pty ? normalizePty(pty) : null;
52
56
  const files = fs ? createFileAccess(fs) : null;
57
+ const mcpHost = mcp ? createMcpHost(mcp) : null;
53
58
  const methods = authMethods(auth);
54
59
  const rl = auth?.rateLimit ?? {};
55
60
  const rateLimit = {
@@ -72,7 +77,7 @@ export function createWshServer({
72
77
  host_ = hostKey ? await loadHostKey(hostKey) : null;
73
78
  const attach = createConnectionFactory({
74
79
  authorize, execRunner, execOptions, pty: ptyConfig, files, bindTimeoutMs, log: onLog,
75
- methods, hostKey: host_, rateLimit,
80
+ methods, hostKey: host_, rateLimit, mcp: mcpHost,
76
81
  });
77
82
 
78
83
  wss = new WebSocketServer({ host, port });
@@ -0,0 +1,204 @@
1
+ /**
2
+ * A small JSON Schema validator for `@johnhenry/wsh/server`'s MCP tools.
3
+ *
4
+ * MCP arguments come off the wire from an authenticated-but-untrusted client,
5
+ * so the tool's `inputSchema` is enforced before `call()` runs. The package has
6
+ * no runtime dependencies, so this covers the keywords tool schemas actually
7
+ * use rather than all of JSON Schema: `type`, `enum`, `const`, `properties`,
8
+ * `required`, `additionalProperties`, `items`, `min/maxItems`, `uniqueItems`,
9
+ * `min/maxLength`, `pattern`, `minimum`/`maximum` (and exclusive forms),
10
+ * `multipleOf`, `allOf`/`anyOf`/`oneOf`/`not`, and local `$ref` (`#/...`).
11
+ * `format`, `title`, `description`, `default` and other annotations are ignored.
12
+ *
13
+ * A schema that uses a keyword this does not understand is refused up front
14
+ * (`assertSupportedSchema`) rather than silently validating less than the
15
+ * operator wrote.
16
+ */
17
+
18
+ const TYPES = new Set(['string', 'number', 'integer', 'boolean', 'null', 'object', 'array']);
19
+ const IGNORED = new Set([
20
+ '$schema', '$id', '$comment', 'title', 'description', 'default', 'examples', 'format',
21
+ 'definitions', '$defs', 'deprecated', 'readOnly', 'writeOnly',
22
+ ]);
23
+ const KNOWN = new Set([
24
+ 'type', 'enum', 'const', 'properties', 'required', 'additionalProperties', 'items',
25
+ 'minItems', 'maxItems', 'uniqueItems', 'minLength', 'maxLength', 'pattern', 'minimum',
26
+ 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf', 'allOf', 'anyOf',
27
+ 'oneOf', 'not', '$ref', 'minProperties', 'maxProperties',
28
+ ]);
29
+ const MAX_DEPTH = 64;
30
+
31
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
32
+
33
+ function typeOf(v) {
34
+ if (v === null) return 'null';
35
+ if (Array.isArray(v)) return 'array';
36
+ if (typeof v === 'number') return Number.isInteger(v) ? 'integer' : 'number';
37
+ return typeof v;
38
+ }
39
+
40
+ function matchesType(v, t) {
41
+ const actual = typeOf(v);
42
+ return actual === t || (t === 'number' && actual === 'integer');
43
+ }
44
+
45
+ /** Structural JSON equality (for `enum` / `const` / `uniqueItems`). */
46
+ function deepEqual(a, b) {
47
+ if (a === b) return true;
48
+ if (typeof a !== typeof b || a === null || b === null || typeof a !== 'object') return false;
49
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
50
+ if (Array.isArray(a)) return a.length === b.length && a.every((x, i) => deepEqual(x, b[i]));
51
+ const ka = Object.keys(a);
52
+ const kb = Object.keys(b);
53
+ return ka.length === kb.length && ka.every((k) => Object.hasOwn(b, k) && deepEqual(a[k], b[k]));
54
+ }
55
+
56
+ /**
57
+ * Throw if `schema` uses a keyword this validator does not implement, or is
58
+ * otherwise malformed. Run once when the operator's tools are configured.
59
+ */
60
+ export function assertSupportedSchema(schema, path = '#', depth = 0) {
61
+ if (depth > MAX_DEPTH) throw new TypeError(`inputSchema at ${path} is nested too deeply`);
62
+ if (schema === true || schema === false) return;
63
+ if (!isObject(schema)) throw new TypeError(`inputSchema at ${path} must be an object`);
64
+ for (const key of Object.keys(schema)) {
65
+ if (!KNOWN.has(key) && !IGNORED.has(key)) {
66
+ throw new TypeError(`inputSchema at ${path} uses unsupported keyword "${key}"`);
67
+ }
68
+ }
69
+ if (schema.type !== undefined) {
70
+ const types = Array.isArray(schema.type) ? schema.type : [schema.type];
71
+ for (const t of types) if (!TYPES.has(t)) throw new TypeError(`inputSchema at ${path} has unknown type ${JSON.stringify(t)}`);
72
+ }
73
+ if (schema.pattern !== undefined) {
74
+ try { new RegExp(schema.pattern, 'u'); } catch { throw new TypeError(`inputSchema at ${path} has an invalid pattern`); }
75
+ }
76
+ if (schema.required !== undefined && !(Array.isArray(schema.required) && schema.required.every((r) => typeof r === 'string'))) {
77
+ throw new TypeError(`inputSchema at ${path}: required must be an array of strings`);
78
+ }
79
+ if (isObject(schema.properties)) {
80
+ for (const [k, s] of Object.entries(schema.properties)) assertSupportedSchema(s, `${path}/properties/${k}`, depth + 1);
81
+ } else if (schema.properties !== undefined) {
82
+ throw new TypeError(`inputSchema at ${path}: properties must be an object`);
83
+ }
84
+ if (schema.additionalProperties !== undefined && typeof schema.additionalProperties !== 'boolean') {
85
+ assertSupportedSchema(schema.additionalProperties, `${path}/additionalProperties`, depth + 1);
86
+ }
87
+ if (schema.items !== undefined) assertSupportedSchema(schema.items, `${path}/items`, depth + 1);
88
+ for (const k of ['allOf', 'anyOf', 'oneOf']) {
89
+ if (schema[k] === undefined) continue;
90
+ if (!Array.isArray(schema[k])) throw new TypeError(`inputSchema at ${path}: ${k} must be an array`);
91
+ schema[k].forEach((s, i) => assertSupportedSchema(s, `${path}/${k}/${i}`, depth + 1));
92
+ }
93
+ if (schema.not !== undefined) assertSupportedSchema(schema.not, `${path}/not`, depth + 1);
94
+ for (const k of ['definitions', '$defs']) {
95
+ if (isObject(schema[k])) for (const [n, s] of Object.entries(schema[k])) assertSupportedSchema(s, `${path}/${k}/${n}`, depth + 1);
96
+ }
97
+ if (schema.$ref !== undefined && !(typeof schema.$ref === 'string' && schema.$ref.startsWith('#'))) {
98
+ throw new TypeError(`inputSchema at ${path}: only local "#..." $ref is supported`);
99
+ }
100
+ }
101
+
102
+ function resolveRef(root, ref) {
103
+ if (ref === '#') return root;
104
+ if (!ref.startsWith('#/')) return undefined;
105
+ let node = root;
106
+ for (const raw of ref.slice(2).split('/')) {
107
+ const seg = raw.replace(/~1/g, '/').replace(/~0/g, '~');
108
+ if (!isObject(node) && !Array.isArray(node)) return undefined;
109
+ if (!Object.hasOwn(node, seg)) return undefined;
110
+ node = node[seg];
111
+ }
112
+ return node;
113
+ }
114
+
115
+ /**
116
+ * Validate `value` against `schema`.
117
+ * @returns {string | null} `null` when valid, otherwise a short reason naming the path.
118
+ */
119
+ export function validateSchema(schema, value) {
120
+ const fail = (path, msg) => `${path || '/'}: ${msg}`;
121
+
122
+ function check(s, v, path, depth) {
123
+ if (depth > MAX_DEPTH) return fail(path, 'schema or value nested too deeply');
124
+ if (s === true) return null;
125
+ if (s === false) return fail(path, 'no value is allowed here');
126
+ if (s.$ref !== undefined) {
127
+ const target = resolveRef(schema, s.$ref);
128
+ if (target === undefined) return fail(path, `unresolvable $ref ${s.$ref}`);
129
+ const r = check(target, v, path, depth + 1);
130
+ if (r) return r;
131
+ }
132
+ if (s.type !== undefined) {
133
+ const types = Array.isArray(s.type) ? s.type : [s.type];
134
+ if (!types.some((t) => matchesType(v, t))) return fail(path, `expected ${types.join(' | ')}, got ${typeOf(v)}`);
135
+ }
136
+ if (s.enum !== undefined && !s.enum.some((e) => deepEqual(e, v))) return fail(path, 'not one of the allowed values');
137
+ if (s.const !== undefined && !deepEqual(s.const, v)) return fail(path, 'does not equal the required constant');
138
+
139
+ if (typeof v === 'string') {
140
+ const len = [...v].length;
141
+ if (s.minLength !== undefined && len < s.minLength) return fail(path, `shorter than ${s.minLength}`);
142
+ if (s.maxLength !== undefined && len > s.maxLength) return fail(path, `longer than ${s.maxLength}`);
143
+ if (s.pattern !== undefined && !new RegExp(s.pattern, 'u').test(v)) return fail(path, 'does not match the required pattern');
144
+ }
145
+ if (typeof v === 'number') {
146
+ if (!Number.isFinite(v)) return fail(path, 'must be a finite number');
147
+ if (s.minimum !== undefined && v < s.minimum) return fail(path, `below the minimum ${s.minimum}`);
148
+ if (s.maximum !== undefined && v > s.maximum) return fail(path, `above the maximum ${s.maximum}`);
149
+ if (typeof s.exclusiveMinimum === 'number' && v <= s.exclusiveMinimum) return fail(path, `must be above ${s.exclusiveMinimum}`);
150
+ if (typeof s.exclusiveMaximum === 'number' && v >= s.exclusiveMaximum) return fail(path, `must be below ${s.exclusiveMaximum}`);
151
+ if (s.multipleOf !== undefined) {
152
+ const q = v / s.multipleOf;
153
+ if (Math.abs(q - Math.round(q)) > 1e-9) return fail(path, `not a multiple of ${s.multipleOf}`);
154
+ }
155
+ }
156
+ if (Array.isArray(v)) {
157
+ if (s.minItems !== undefined && v.length < s.minItems) return fail(path, `fewer than ${s.minItems} items`);
158
+ if (s.maxItems !== undefined && v.length > s.maxItems) return fail(path, `more than ${s.maxItems} items`);
159
+ if (s.uniqueItems === true) {
160
+ for (let i = 0; i < v.length; i++) {
161
+ for (let j = i + 1; j < v.length; j++) if (deepEqual(v[i], v[j])) return fail(path, 'items are not unique');
162
+ }
163
+ }
164
+ if (s.items !== undefined) {
165
+ for (let i = 0; i < v.length; i++) {
166
+ const r = check(s.items, v[i], `${path}/${i}`, depth + 1);
167
+ if (r) return r;
168
+ }
169
+ }
170
+ }
171
+ if (isObject(v)) {
172
+ const keys = Object.keys(v);
173
+ if (s.minProperties !== undefined && keys.length < s.minProperties) return fail(path, `fewer than ${s.minProperties} properties`);
174
+ if (s.maxProperties !== undefined && keys.length > s.maxProperties) return fail(path, `more than ${s.maxProperties} properties`);
175
+ for (const r of s.required ?? []) if (!Object.hasOwn(v, r)) return fail(path, `missing required property "${r}"`);
176
+ const props = isObject(s.properties) ? s.properties : {};
177
+ for (const k of keys) {
178
+ if (Object.hasOwn(props, k)) {
179
+ const r = check(props[k], v[k], `${path}/${k}`, depth + 1);
180
+ if (r) return r;
181
+ } else if (s.additionalProperties === false) {
182
+ return fail(path, `unexpected property "${k}"`);
183
+ } else if (s.additionalProperties !== undefined && s.additionalProperties !== true) {
184
+ const r = check(s.additionalProperties, v[k], `${path}/${k}`, depth + 1);
185
+ if (r) return r;
186
+ }
187
+ }
188
+ }
189
+
190
+ for (const sub of s.allOf ?? []) {
191
+ const r = check(sub, v, path, depth + 1);
192
+ if (r) return r;
193
+ }
194
+ if (s.anyOf && !s.anyOf.some((sub) => check(sub, v, path, depth + 1) === null)) return fail(path, 'matches none of the anyOf alternatives');
195
+ if (s.oneOf) {
196
+ const n = s.oneOf.filter((sub) => check(sub, v, path, depth + 1) === null).length;
197
+ if (n !== 1) return fail(path, `matches ${n} of the oneOf alternatives, expected exactly 1`);
198
+ }
199
+ if (s.not !== undefined && check(s.not, v, path, depth + 1) === null) return fail(path, 'matches a schema it must not match');
200
+ return null;
201
+ }
202
+
203
+ return check(schema, value, '', 0);
204
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * MCP tools for `@johnhenry/wsh/server`: what `createWshServer({ mcp })`
3
+ * exposes through `McpDiscover` / `McpCall`.
4
+ *
5
+ * Two sources, which may be combined:
6
+ * - `mcp.tools` -- an array of `{ name, description, inputSchema, call }`, or
7
+ * a `{ name: { description, inputSchema, call } }` map.
8
+ * - `mcp.client` -- anything shaped like an `@modelcontextprotocol/sdk`
9
+ * `Client` (`listTools()` / `callTool({ name, arguments })`); its tools are
10
+ * proxied. Duck-typed, so the SDK is never imported by this package.
11
+ *
12
+ * Tools the operator did not list are never reachable: a call is looked up by
13
+ * exact name in the set built here, after the optional `authorize` filter.
14
+ */
15
+
16
+ import { assertSupportedSchema, validateSchema } from './json-schema.mjs';
17
+
18
+ const DEFAULT_SCHEMA = Object.freeze({ type: 'object' });
19
+ export const MCP_DEFAULTS = Object.freeze({ maxConcurrent: 8, timeoutMs: 30_000 });
20
+
21
+ function normalizeTool(name, spec, origin) {
22
+ if (typeof name !== 'string' || !name) throw new TypeError('createWshServer: every mcp tool needs a name');
23
+ if (!spec || typeof spec.call !== 'function') {
24
+ throw new TypeError(`createWshServer: mcp tool "${name}" needs a call(args, ctx) function`);
25
+ }
26
+ const inputSchema = spec.inputSchema ?? spec.parameters ?? DEFAULT_SCHEMA;
27
+ assertSupportedSchema(inputSchema, `#(${name})`);
28
+ return { name, description: String(spec.description ?? ''), inputSchema, call: spec.call, origin };
29
+ }
30
+
31
+ /**
32
+ * @param {object} mcp - the `createWshServer` `mcp` option
33
+ * @returns {{ list(user: string, who: object): Promise<object[]>, find(name: string, user: string, who: object): Promise<object | null>, maxConcurrent: number, timeoutMs: number }}
34
+ */
35
+ export function createMcpHost(mcp) {
36
+ if (!mcp || typeof mcp !== 'object') throw new TypeError('createWshServer: mcp must be an object');
37
+ const local = new Map();
38
+ const entries = Array.isArray(mcp.tools)
39
+ ? mcp.tools.map((t) => [t?.name, t])
40
+ : Object.entries(mcp.tools ?? {});
41
+ for (const [name, spec] of entries) {
42
+ if (local.has(name)) throw new TypeError(`createWshServer: duplicate mcp tool "${name}"`);
43
+ local.set(name, normalizeTool(name, spec, 'local'));
44
+ }
45
+ const client = mcp.client ?? null;
46
+ if (client && (typeof client.listTools !== 'function' || typeof client.callTool !== 'function')) {
47
+ throw new TypeError('createWshServer: mcp.client needs listTools() and callTool() (an @modelcontextprotocol/sdk Client)');
48
+ }
49
+ if (typeof mcp.authorize !== 'undefined' && typeof mcp.authorize !== 'function') {
50
+ throw new TypeError('createWshServer: mcp.authorize must be a function');
51
+ }
52
+
53
+ /** Remote tools, refreshed on every discover; calls use the most recent listing. */
54
+ let proxied = new Map();
55
+ async function refreshProxied() {
56
+ if (!client) return;
57
+ const next = new Map();
58
+ const { tools = [] } = await client.listTools();
59
+ for (const t of tools) {
60
+ if (!t || typeof t.name !== 'string' || local.has(t.name)) continue; // local tools win
61
+ try {
62
+ next.set(t.name, normalizeTool(t.name, {
63
+ description: t.description,
64
+ inputSchema: t.inputSchema,
65
+ call: (args, ctx) => callProxied(t.name, args, ctx),
66
+ }, 'proxy'));
67
+ } catch { /* a schema we cannot enforce is not exposed */ }
68
+ }
69
+ proxied = next;
70
+ }
71
+
72
+ async function callProxied(name, args, { signal }) {
73
+ const res = await client.callTool({ name, arguments: args }, undefined, { signal });
74
+ if (res?.isError) {
75
+ const text = Array.isArray(res.content) ? res.content.filter((c) => c?.type === 'text').map((c) => c.text).join('\n') : '';
76
+ throw new Error(text || `tool ${name} failed`);
77
+ }
78
+ return res?.structuredContent ?? res?.content ?? null;
79
+ }
80
+
81
+ const allowed = async (tool, user, who) => {
82
+ if (!mcp.authorize) return true;
83
+ try { return (await mcp.authorize(user, { name: tool.name, description: tool.description, inputSchema: tool.inputSchema }, who)) === true; } catch { return false; }
84
+ };
85
+
86
+ return {
87
+ maxConcurrent: mcp.maxConcurrent > 0 ? mcp.maxConcurrent : MCP_DEFAULTS.maxConcurrent,
88
+ timeoutMs: mcp.timeoutMs >= 0 ? mcp.timeoutMs : MCP_DEFAULTS.timeoutMs,
89
+
90
+ async list(user, who) {
91
+ await refreshProxied();
92
+ const out = [];
93
+ for (const t of [...local.values(), ...proxied.values()]) {
94
+ if (await allowed(t, user, who)) out.push({ name: t.name, description: t.description, parameters: t.inputSchema });
95
+ }
96
+ return out;
97
+ },
98
+
99
+ /** A tool this principal may call, or `null` -- indistinguishable for hidden and absent. */
100
+ async find(name, user, who) {
101
+ let tool = local.get(name) ?? proxied.get(name);
102
+ if (!tool && client && !local.has(name)) {
103
+ await refreshProxied();
104
+ tool = proxied.get(name);
105
+ }
106
+ return tool && await allowed(tool, user, who) ? tool : null;
107
+ },
108
+
109
+ validate: (tool, args) => validateSchema(tool.inputSchema, args),
110
+ };
111
+ }