@johnhenry/wsh 0.19.1 → 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 +61 -1
- package/package.json +1 -1
- package/src/server/connection.mjs +71 -2
- package/src/server/index.d.ts +41 -0
- package/src/server/index.mjs +7 -2
- package/src/server/json-schema.mjs +204 -0
- package/src/server/mcp.mjs +111 -0
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
|
|
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
|
@@ -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) {
|
package/src/server/index.d.ts
CHANGED
|
@@ -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;
|
package/src/server/index.mjs
CHANGED
|
@@ -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
|
+
}
|