@johnhenry/wsh 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,228 @@
1
+ /**
2
+ * WshMcpBridge — bridges remote MCP tools over the wsh meta channel.
3
+ *
4
+ * Sends MCP_DISCOVER and MCP_CALL messages through the wsh control channel,
5
+ * enabling a wsh client to discover and invoke MCP tools hosted on the
6
+ * remote server. Tool specs are cached after discovery for efficient reuse.
7
+ */
8
+
9
+ import { MSG, mcpDiscover, mcpCall } from './messages.mjs';
10
+
11
+ /** Default timeout for MCP operations (15 seconds). */
12
+ const MCP_TIMEOUT_MS = 15_000;
13
+
14
+ export class WshMcpBridge {
15
+ /** @type {object} WshClient reference. */
16
+ #client;
17
+
18
+ /** @type {Map<string, object>} Cached tool specs: name -> { name, description, parameters } */
19
+ #tools = new Map();
20
+
21
+ /**
22
+ * @param {object} client - A WshClient or transport exposing:
23
+ * - sendControl(msg): send a control message
24
+ * - addControlListener(fn) / removeControlListener(fn): message listeners
25
+ * (or _controlListeners array, or onControl callback)
26
+ */
27
+ constructor(client) {
28
+ if (!client) throw new Error('WshMcpBridge requires a client');
29
+ this.#client = client;
30
+ }
31
+
32
+ /**
33
+ * Discover available MCP tools on the remote server.
34
+ *
35
+ * Sends an MCP_DISCOVER message and waits for the MCP_TOOLS response
36
+ * containing the list of available tools with their schemas.
37
+ *
38
+ * @param {object} [opts]
39
+ * @param {number} [opts.timeout] - Timeout in ms (default 15s)
40
+ * @returns {Promise<Array<{ name: string, description: string, parameters: object }>>}
41
+ */
42
+ async discover({ timeout = MCP_TIMEOUT_MS } = {}) {
43
+ await this.#client.sendControl(mcpDiscover());
44
+
45
+ const response = await this._waitForMessage(
46
+ (msg) => msg.type === MSG.MCP_TOOLS || msg.type === MSG.ERROR,
47
+ timeout
48
+ );
49
+
50
+ if (response.type === MSG.ERROR) {
51
+ throw new Error(`MCP discovery failed: ${response.message || response.code || 'unknown error'}`);
52
+ }
53
+
54
+ const tools = response.tools || [];
55
+
56
+ // Cache the discovered tools
57
+ this.#tools.clear();
58
+ for (const tool of tools) {
59
+ if (tool && tool.name) {
60
+ this.#tools.set(tool.name, {
61
+ name: tool.name,
62
+ description: tool.description || '',
63
+ parameters: tool.parameters || {},
64
+ });
65
+ }
66
+ }
67
+
68
+ return Array.from(this.#tools.values());
69
+ }
70
+
71
+ /**
72
+ * Call a remote MCP tool by name.
73
+ *
74
+ * Sends an MCP_CALL message and waits for the MCP_RESULT response.
75
+ *
76
+ * @param {string} toolName - Name of the tool to invoke
77
+ * @param {object} [args={}] - Arguments to pass to the tool
78
+ * @param {object} [opts]
79
+ * @param {number} [opts.timeout] - Timeout in ms (default 15s)
80
+ * @returns {Promise<{ success: boolean, output: *, error?: string }>}
81
+ */
82
+ async call(toolName, args = {}, { timeout = MCP_TIMEOUT_MS } = {}) {
83
+ if (!toolName || typeof toolName !== 'string') {
84
+ throw new Error('toolName is required');
85
+ }
86
+
87
+ // Validate against cached tools if discovery has been performed
88
+ if (this.#tools.size > 0 && !this.#tools.has(toolName)) {
89
+ throw new Error(
90
+ `Unknown tool "${toolName}". Available tools: ${Array.from(this.#tools.keys()).join(', ')}`
91
+ );
92
+ }
93
+
94
+ await this.#client.sendControl(mcpCall({ tool: toolName, arguments: args }));
95
+
96
+ const response = await this._waitForMessage(
97
+ (msg) => msg.type === MSG.MCP_RESULT || msg.type === MSG.ERROR,
98
+ timeout
99
+ );
100
+
101
+ if (response.type === MSG.ERROR) {
102
+ return {
103
+ success: false,
104
+ output: null,
105
+ error: response.message || `Error code: ${response.code}`,
106
+ };
107
+ }
108
+
109
+ const result = response.result;
110
+
111
+ // Normalize result into the standard { success, output, error? } shape
112
+ if (result && typeof result === 'object' && 'success' in result) {
113
+ return {
114
+ success: Boolean(result.success),
115
+ output: result.output ?? result.data ?? null,
116
+ error: result.error || undefined,
117
+ };
118
+ }
119
+
120
+ // If the server returned a raw value, wrap it
121
+ return {
122
+ success: true,
123
+ output: result,
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Get cached tool specs (after discover() has been called).
129
+ *
130
+ * Returns tool specifications in a format compatible with the BrowserTool
131
+ * interface used by the Clawser agent, making it easy to register remote
132
+ * tools alongside local ones.
133
+ *
134
+ * @returns {Array<{ name: string, description: string, parameters: object }>}
135
+ */
136
+ getToolSpecs() {
137
+ return Array.from(this.#tools.values()).map((tool) => ({
138
+ name: tool.name,
139
+ description: tool.description,
140
+ parameters: tool.parameters,
141
+ }));
142
+ }
143
+
144
+ /**
145
+ * Check whether a specific tool is available (based on cached discovery).
146
+ *
147
+ * @param {string} toolName
148
+ * @returns {boolean}
149
+ */
150
+ hasTool(toolName) {
151
+ return this.#tools.has(toolName);
152
+ }
153
+
154
+ /**
155
+ * Get the number of cached tools.
156
+ * @returns {number}
157
+ */
158
+ get toolCount() {
159
+ return this.#tools.size;
160
+ }
161
+
162
+ /**
163
+ * Clear the cached tool specs. Call discover() again to refresh.
164
+ */
165
+ clearCache() {
166
+ this.#tools.clear();
167
+ }
168
+
169
+ // ── Internal ──────────────────────────────────────────────────────
170
+
171
+ /**
172
+ * Wait for a control message matching a predicate.
173
+ *
174
+ * Temporarily hooks into the client's control message flow and resolves
175
+ * when a matching message arrives (or rejects on timeout).
176
+ *
177
+ * @param {function(object): boolean} predicate
178
+ * @param {number} timeoutMs
179
+ * @returns {Promise<object>}
180
+ */
181
+ _waitForMessage(predicate, timeoutMs) {
182
+ return new Promise((resolve, reject) => {
183
+ let timer = null;
184
+ let settled = false;
185
+
186
+ const cleanup = () => {
187
+ settled = true;
188
+ if (timer !== null) clearTimeout(timer);
189
+ // Remove listener
190
+ if (this.#client.removeControlListener) {
191
+ this.#client.removeControlListener(listener);
192
+ } else if (this.#client._controlListeners) {
193
+ const idx = this.#client._controlListeners.indexOf(listener);
194
+ if (idx !== -1) this.#client._controlListeners.splice(idx, 1);
195
+ }
196
+ };
197
+
198
+ const listener = (msg) => {
199
+ if (settled) return;
200
+ if (predicate(msg)) {
201
+ cleanup();
202
+ resolve(msg);
203
+ }
204
+ };
205
+
206
+ // Register listener on the client
207
+ if (this.#client.addControlListener) {
208
+ this.#client.addControlListener(listener);
209
+ } else if (this.#client._controlListeners) {
210
+ this.#client._controlListeners.push(listener);
211
+ } else {
212
+ // Fallback: wrap existing onControl
213
+ const prev = this.#client.onControl;
214
+ this.#client.onControl = (msg) => {
215
+ prev?.(msg);
216
+ listener(msg);
217
+ };
218
+ }
219
+
220
+ timer = setTimeout(() => {
221
+ if (!settled) {
222
+ cleanup();
223
+ reject(new Error(`MCP operation timed out after ${timeoutMs}ms`));
224
+ }
225
+ }, timeoutMs);
226
+ });
227
+ }
228
+ }