@johnhenry/wsh 0.24.0 → 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.
- package/README.md +107 -0
- package/package.json +10 -4
- package/src/client.mjs +141 -1
- package/src/index.d.ts +161 -1
- package/src/index.mjs +7 -0
- package/src/rpc-mcp.mjs +153 -0
- package/src/rpc.mjs +617 -0
- package/src/server/connection.mjs +62 -0
- package/src/server/fs.mjs +36 -3
- package/src/server/index.d.ts +35 -0
- package/src/server/index.mjs +11 -3
- package/src/server/rpc.mjs +171 -0
package/src/rpc-mcp.mjs
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP over an `rpc` channel -- wsh #85 / #71.
|
|
3
|
+
*
|
|
4
|
+
* `RpcMcpTransport` is a Model Context Protocol SDK `Transport`
|
|
5
|
+
* (`start` / `send` / `close` + `onmessage` / `onclose` / `onerror`) built on an
|
|
6
|
+
* `RpcChannel`, so an MCP `Server` or `Client` can speak across a wsh host:
|
|
7
|
+
*
|
|
8
|
+
* host: createWshServer({ rpc: { mcp: mcpServerAdapter(server) } }) // `@johnhenry/wsh/server`
|
|
9
|
+
* client: await mcpClient.connect(mcpClientTransport(await wsh.openRpc('mcp')))
|
|
10
|
+
*
|
|
11
|
+
* MCP messages already are JSON-RPC 2.0, so this is a pass-through: a request
|
|
12
|
+
* keeps the id the SDK chose, results and errors map 1:1, and notifications
|
|
13
|
+
* (including `notifications/progress`) are forwarded verbatim. The one
|
|
14
|
+
* translation is cancellation, where MCP's `notifications/cancelled` and wsh's
|
|
15
|
+
* `$/cancel` are two spellings of one thing:
|
|
16
|
+
* - outgoing `notifications/cancelled { requestId }` -> `$/cancel { id }` (and the local request is dropped);
|
|
17
|
+
* - an incoming `$/cancel` -> a synthetic `notifications/cancelled` delivered to the SDK, so its tool handler's
|
|
18
|
+
* `signal` fires; the channel itself answers the caller with `-32001`.
|
|
19
|
+
*
|
|
20
|
+
* Nothing here imports the SDK: the transport is structural, so the package
|
|
21
|
+
* root stays dependency-free and browser-safe. `@modelcontextprotocol/sdk` is
|
|
22
|
+
* an optional peer, needed only by whoever supplies the `Server`/`Client`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { RpcError, RPC_ERROR } from './rpc.mjs';
|
|
26
|
+
|
|
27
|
+
const rpcErrorFromMcp = (e) => new RpcError(Number.isInteger(e?.code) ? e.code : RPC_ERROR.INTERNAL, String(e?.message ?? 'error'), e?.data);
|
|
28
|
+
|
|
29
|
+
export class RpcMcpTransport {
|
|
30
|
+
#channel;
|
|
31
|
+
#started = false;
|
|
32
|
+
#closed = false;
|
|
33
|
+
/** Inbound requests handed to the SDK, awaiting its response, by JSON-RPC id. */
|
|
34
|
+
#awaiting = new Map();
|
|
35
|
+
/** Messages that arrived before the SDK attached `onmessage`. */
|
|
36
|
+
#queue = [];
|
|
37
|
+
#onmessage = null;
|
|
38
|
+
|
|
39
|
+
/** @type {(() => void) | undefined} */ onclose;
|
|
40
|
+
/** @type {((error: Error) => void) | undefined} */ onerror;
|
|
41
|
+
/** @type {string | undefined} */ sessionId;
|
|
42
|
+
|
|
43
|
+
/** @param {import('./rpc.mjs').RpcChannel} channel */
|
|
44
|
+
constructor(channel) {
|
|
45
|
+
this.#channel = channel;
|
|
46
|
+
// Installed now, not in start(): a peer's first request can land before the SDK has called start().
|
|
47
|
+
channel.setFallbackRequestHandler((method, params, ctx) => this.#inboundRequest(method, params, ctx));
|
|
48
|
+
channel.setFallbackNotificationHandler((method, params) => this.#deliver({ jsonrpc: '2.0', method, ...(params !== undefined ? { params } : {}) }));
|
|
49
|
+
const prior = channel.onClose;
|
|
50
|
+
channel.onClose = (reason) => {
|
|
51
|
+
this.#closed = true;
|
|
52
|
+
for (const w of this.#awaiting.values()) w.reject(new RpcError(RPC_ERROR.CANCELLED, 'rpc channel closed', { reason: 'channel-closed' }));
|
|
53
|
+
this.#awaiting.clear();
|
|
54
|
+
try { prior?.(reason); } catch { /* ignore */ }
|
|
55
|
+
this.onclose?.();
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
get channel() { return this.#channel; }
|
|
60
|
+
|
|
61
|
+
get onmessage() { return this.#onmessage; }
|
|
62
|
+
|
|
63
|
+
set onmessage(fn) {
|
|
64
|
+
this.#onmessage = fn;
|
|
65
|
+
if (fn) for (const m of this.#queue.splice(0)) this.#safeDeliver(m);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async start() {
|
|
69
|
+
if (this.#started) throw new Error('RpcMcpTransport already started');
|
|
70
|
+
this.#started = true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {{ jsonrpc: '2.0', id?: string | number, method?: string, params?: any, result?: any, error?: any }} message
|
|
75
|
+
*/
|
|
76
|
+
async send(message) {
|
|
77
|
+
if (this.#closed || this.#channel.closed) throw new Error('rpc channel is closed');
|
|
78
|
+
const hasId = message.id !== undefined && message.id !== null;
|
|
79
|
+
if (typeof message.method === 'string') {
|
|
80
|
+
if (!hasId) return this.#sendNotification(message);
|
|
81
|
+
this.#sendRequest(message);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// A response to a request we handed the SDK.
|
|
85
|
+
const waiter = this.#awaiting.get(message.id);
|
|
86
|
+
if (!waiter) return; // cancelled, or already answered
|
|
87
|
+
this.#awaiting.delete(message.id);
|
|
88
|
+
if (message.error) waiter.reject(rpcErrorFromMcp(message.error));
|
|
89
|
+
else waiter.resolve(message.result ?? null);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async close() {
|
|
93
|
+
this.#closed = true;
|
|
94
|
+
await this.#channel.close('mcp-transport-closed');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#sendNotification(message) {
|
|
98
|
+
const { method, params } = message;
|
|
99
|
+
if (method === 'notifications/cancelled' && params && params.requestId !== undefined && this.#channel.cancel(params.requestId, 'cancelled')) return;
|
|
100
|
+
return this.#channel.notify(method, params);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A request the SDK is making of the peer: keep its id, hand the answer back as a response message. */
|
|
104
|
+
#sendRequest(message) {
|
|
105
|
+
const { id, method, params } = message;
|
|
106
|
+
this.#channel.request(method, params, { id }).then(
|
|
107
|
+
(result) => this.#deliver({ jsonrpc: '2.0', id, result }),
|
|
108
|
+
(e) => {
|
|
109
|
+
// A request the SDK itself abandoned (cancelled) must not resurface as an error response.
|
|
110
|
+
if (e instanceof RpcError && e.code === RPC_ERROR.CANCELLED && e.reason !== 'channel-closed' && e.reason !== 'timeout') return;
|
|
111
|
+
const error = e instanceof RpcError ? e.toJSON() : { code: RPC_ERROR.INTERNAL, message: String(e?.message ?? e) };
|
|
112
|
+
this.#deliver({ jsonrpc: '2.0', id, error });
|
|
113
|
+
},
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A request from the peer: pass it to the SDK and wait for the response it sends back through `send()`. */
|
|
118
|
+
#inboundRequest(method, params, ctx) {
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
const id = ctx.id;
|
|
121
|
+
this.#awaiting.set(id, { resolve, reject });
|
|
122
|
+
ctx.signal.addEventListener('abort', () => {
|
|
123
|
+
if (!this.#awaiting.delete(id)) return;
|
|
124
|
+
reject(new RpcError(RPC_ERROR.CANCELLED, 'request cancelled', { reason: 'cancelled' }));
|
|
125
|
+
if (!this.#closed) this.#deliver({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: id, reason: 'cancelled' } });
|
|
126
|
+
}, { once: true });
|
|
127
|
+
this.#deliver({ jsonrpc: '2.0', id, method, ...(params !== undefined ? { params } : {}) });
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
#deliver(message) {
|
|
132
|
+
if (!this.#onmessage) { this.#queue.push(message); return; }
|
|
133
|
+
this.#safeDeliver(message);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#safeDeliver(message) {
|
|
137
|
+
try {
|
|
138
|
+
this.#onmessage(message, {});
|
|
139
|
+
} catch (e) {
|
|
140
|
+
this.onerror?.(e instanceof Error ? e : new Error(String(e)));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** A transport for an MCP `Client` (`@modelcontextprotocol/sdk`), over a channel from `client.openRpc('mcp')`. */
|
|
146
|
+
export function mcpClientTransport(channel) {
|
|
147
|
+
return new RpcMcpTransport(channel);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A transport for an MCP `Server` over a server-side channel. `mcpServerAdapter` (server entry) wraps this. */
|
|
151
|
+
export function mcpServerTransport(channel) {
|
|
152
|
+
return new RpcMcpTransport(channel);
|
|
153
|
+
}
|