@adversity/coding-tool-x 2.5.0 → 2.6.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/CHANGELOG.md +22 -0
- package/dist/web/assets/icons-CNM9_Fh0.js +1 -0
- package/dist/web/assets/index-BcmuQT-z.css +41 -0
- package/dist/web/assets/index-Ej0MPDUI.js +14 -0
- package/dist/web/index.html +3 -3
- package/package.json +4 -2
- package/src/commands/plugin.js +585 -0
- package/src/config/default.js +22 -3
- package/src/config/loader.js +6 -1
- package/src/index.js +229 -1
- package/src/server/api/config-export.js +122 -32
- package/src/server/api/dashboard.js +4 -3
- package/src/server/api/mcp.js +63 -0
- package/src/server/api/plugins.js +276 -0
- package/src/server/index.js +1 -0
- package/src/server/proxy-server.js +6 -3
- package/src/server/services/config-export-service.js +331 -5
- package/src/server/services/mcp-client.js +775 -0
- package/src/server/services/mcp-service.js +203 -0
- package/src/server/services/model-detector.js +350 -0
- package/src/server/services/plugins-service.js +177 -0
- package/src/server/services/pty-manager.js +65 -2
- package/src/server/services/speed-test.js +68 -37
- package/src/server/services/ui-config.js +2 -0
- package/src/server/utils/pricing.js +32 -1
- package/src/ui/menu.js +1 -0
- package/dist/web/assets/icons-BALJo7bE.js +0 -1
- package/dist/web/assets/index-CcYz-Mcz.css +0 -41
- package/dist/web/assets/index-k9b43kTe.js +0 -14
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP JSON-RPC Client Wrapper
|
|
3
|
+
*
|
|
4
|
+
* Reusable client for communicating with MCP servers over stdio or HTTP/SSE
|
|
5
|
+
* transports using the JSON-RPC 2.0 protocol.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const client = new McpClient({ type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-time'] });
|
|
9
|
+
* await client.connect();
|
|
10
|
+
* await client.initialize();
|
|
11
|
+
* const tools = await client.listTools();
|
|
12
|
+
* const result = await client.callTool('get_current_time', { timezone: 'UTC' });
|
|
13
|
+
* await client.disconnect();
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { spawn } = require('child_process');
|
|
17
|
+
const http = require('http');
|
|
18
|
+
const https = require('https');
|
|
19
|
+
const { EventEmitter } = require('events');
|
|
20
|
+
|
|
21
|
+
const DEFAULT_TIMEOUT = 10000; // 10 seconds
|
|
22
|
+
const JSONRPC_VERSION = '2.0';
|
|
23
|
+
const MCP_PROTOCOL_VERSION = '2024-11-05';
|
|
24
|
+
|
|
25
|
+
// ============================================================================
|
|
26
|
+
// McpClient
|
|
27
|
+
// ============================================================================
|
|
28
|
+
|
|
29
|
+
class McpClient extends EventEmitter {
|
|
30
|
+
/**
|
|
31
|
+
* @param {object} serverSpec - Server specification
|
|
32
|
+
* @param {string} [serverSpec.type='stdio'] - Transport type: 'stdio' | 'http' | 'sse'
|
|
33
|
+
* @param {string} [serverSpec.command] - Command for stdio transport
|
|
34
|
+
* @param {string[]} [serverSpec.args] - Args for stdio transport
|
|
35
|
+
* @param {object} [serverSpec.env] - Additional env vars for stdio transport
|
|
36
|
+
* @param {string} [serverSpec.cwd] - Working directory for stdio transport
|
|
37
|
+
* @param {string} [serverSpec.url] - URL for http/sse transport
|
|
38
|
+
* @param {object} [serverSpec.headers] - Additional headers for http/sse transport
|
|
39
|
+
* @param {object} [options] - Client options
|
|
40
|
+
* @param {number} [options.timeout=10000] - Operation timeout in ms
|
|
41
|
+
*/
|
|
42
|
+
constructor(serverSpec, options = {}) {
|
|
43
|
+
super();
|
|
44
|
+
this._spec = serverSpec;
|
|
45
|
+
this._type = serverSpec.type || 'stdio';
|
|
46
|
+
this._timeout = options.timeout || DEFAULT_TIMEOUT;
|
|
47
|
+
|
|
48
|
+
// Internal state
|
|
49
|
+
this._nextId = 1;
|
|
50
|
+
this._pending = new Map(); // id -> { resolve, reject, timer }
|
|
51
|
+
this._connected = false;
|
|
52
|
+
this._initialized = false;
|
|
53
|
+
this._serverCapabilities = null;
|
|
54
|
+
this._serverInfo = null;
|
|
55
|
+
|
|
56
|
+
// Stdio transport state
|
|
57
|
+
this._child = null;
|
|
58
|
+
this._stdoutBuffer = '';
|
|
59
|
+
|
|
60
|
+
// HTTP/SSE transport state
|
|
61
|
+
this._sseAbortController = null;
|
|
62
|
+
this._httpSessionUrl = null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// --------------------------------------------------------------------------
|
|
66
|
+
// Public API
|
|
67
|
+
// --------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Connect to the MCP server (spawn process or open HTTP/SSE connection).
|
|
71
|
+
* @returns {Promise<void>}
|
|
72
|
+
*/
|
|
73
|
+
async connect() {
|
|
74
|
+
if (this._connected) {
|
|
75
|
+
throw new McpClientError('Already connected');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (this._type === 'stdio') {
|
|
79
|
+
await this._connectStdio();
|
|
80
|
+
} else if (this._type === 'http' || this._type === 'sse') {
|
|
81
|
+
await this._connectHttp();
|
|
82
|
+
} else {
|
|
83
|
+
throw new McpClientError(`Unsupported transport type: ${this._type}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
this._connected = true;
|
|
87
|
+
this.emit('connected');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Perform the MCP initialize handshake.
|
|
92
|
+
* Sends initialize request and waits for the server response,
|
|
93
|
+
* then sends the initialized notification.
|
|
94
|
+
* @returns {Promise<object>} Server capabilities
|
|
95
|
+
*/
|
|
96
|
+
async initialize() {
|
|
97
|
+
this._assertConnected();
|
|
98
|
+
|
|
99
|
+
const result = await this._request('initialize', {
|
|
100
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
101
|
+
capabilities: {},
|
|
102
|
+
clientInfo: {
|
|
103
|
+
name: 'coding-tool-x',
|
|
104
|
+
version: '1.0.0'
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
this._serverCapabilities = result.capabilities || {};
|
|
109
|
+
this._serverInfo = result.serverInfo || {};
|
|
110
|
+
|
|
111
|
+
// Send initialized notification (no response expected)
|
|
112
|
+
this._notify('notifications/initialized', {});
|
|
113
|
+
|
|
114
|
+
this._initialized = true;
|
|
115
|
+
this.emit('initialized', result);
|
|
116
|
+
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* List available tools from the server.
|
|
122
|
+
* @returns {Promise<object[]>} Array of tool definitions
|
|
123
|
+
*/
|
|
124
|
+
async listTools() {
|
|
125
|
+
this._assertInitialized();
|
|
126
|
+
const result = await this._request('tools/list', {});
|
|
127
|
+
return result.tools || [];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Call a tool on the server.
|
|
132
|
+
* @param {string} name - Tool name
|
|
133
|
+
* @param {object} [args={}] - Tool arguments
|
|
134
|
+
* @returns {Promise<object>} Tool result
|
|
135
|
+
*/
|
|
136
|
+
async callTool(name, args = {}) {
|
|
137
|
+
this._assertInitialized();
|
|
138
|
+
const result = await this._request('tools/call', {
|
|
139
|
+
name,
|
|
140
|
+
arguments: args
|
|
141
|
+
});
|
|
142
|
+
return result;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* List available resources from the server.
|
|
147
|
+
* @returns {Promise<object[]>} Array of resource definitions
|
|
148
|
+
*/
|
|
149
|
+
async listResources() {
|
|
150
|
+
this._assertInitialized();
|
|
151
|
+
const result = await this._request('resources/list', {});
|
|
152
|
+
return result.resources || [];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* List available prompts from the server.
|
|
157
|
+
* @returns {Promise<object[]>} Array of prompt definitions
|
|
158
|
+
*/
|
|
159
|
+
async listPrompts() {
|
|
160
|
+
this._assertInitialized();
|
|
161
|
+
const result = await this._request('prompts/list', {});
|
|
162
|
+
return result.prompts || [];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Disconnect and clean up all resources.
|
|
167
|
+
* @returns {Promise<void>}
|
|
168
|
+
*/
|
|
169
|
+
async disconnect() {
|
|
170
|
+
if (!this._connected) return;
|
|
171
|
+
|
|
172
|
+
// Reject all pending requests
|
|
173
|
+
for (const [id, pending] of this._pending) {
|
|
174
|
+
clearTimeout(pending.timer);
|
|
175
|
+
pending.reject(new McpClientError('Client disconnected'));
|
|
176
|
+
}
|
|
177
|
+
this._pending.clear();
|
|
178
|
+
|
|
179
|
+
if (this._type === 'stdio') {
|
|
180
|
+
await this._disconnectStdio();
|
|
181
|
+
} else {
|
|
182
|
+
this._disconnectHttp();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
this._connected = false;
|
|
186
|
+
this._initialized = false;
|
|
187
|
+
this.emit('disconnected');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Whether the client is currently connected.
|
|
192
|
+
* @returns {boolean}
|
|
193
|
+
*/
|
|
194
|
+
get connected() {
|
|
195
|
+
return this._connected;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Whether the client has completed initialization.
|
|
200
|
+
* @returns {boolean}
|
|
201
|
+
*/
|
|
202
|
+
get initialized() {
|
|
203
|
+
return this._initialized;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Server capabilities returned during initialization.
|
|
208
|
+
* @returns {object|null}
|
|
209
|
+
*/
|
|
210
|
+
get serverCapabilities() {
|
|
211
|
+
return this._serverCapabilities;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Server info returned during initialization.
|
|
216
|
+
* @returns {object|null}
|
|
217
|
+
*/
|
|
218
|
+
get serverInfo() {
|
|
219
|
+
return this._serverInfo;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// --------------------------------------------------------------------------
|
|
223
|
+
// Stdio transport
|
|
224
|
+
// --------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
/** @private */
|
|
227
|
+
async _connectStdio() {
|
|
228
|
+
const { command, args = [], env, cwd } = this._spec;
|
|
229
|
+
|
|
230
|
+
if (!command) {
|
|
231
|
+
throw new McpClientError('stdio transport requires a "command" field');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return new Promise((resolve, reject) => {
|
|
235
|
+
const timer = setTimeout(() => {
|
|
236
|
+
this._killChild();
|
|
237
|
+
reject(new McpClientError(`Connection timeout after ${this._timeout}ms`));
|
|
238
|
+
}, this._timeout);
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
this._child = spawn(command, args, {
|
|
242
|
+
env: { ...process.env, ...env },
|
|
243
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
244
|
+
cwd: cwd || process.cwd()
|
|
245
|
+
});
|
|
246
|
+
} catch (err) {
|
|
247
|
+
clearTimeout(timer);
|
|
248
|
+
throw new McpClientError(`Failed to spawn "${command}": ${err.message}`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Once we get the spawn event (or first stdout), consider connected
|
|
252
|
+
let settled = false;
|
|
253
|
+
|
|
254
|
+
const settle = (err) => {
|
|
255
|
+
if (settled) return;
|
|
256
|
+
settled = true;
|
|
257
|
+
clearTimeout(timer);
|
|
258
|
+
if (err) reject(err);
|
|
259
|
+
else resolve();
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
this._child.on('spawn', () => {
|
|
263
|
+
// Process spawned successfully - set up data handlers then resolve
|
|
264
|
+
this._setupStdioHandlers();
|
|
265
|
+
settle(null);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
this._child.on('error', (err) => {
|
|
269
|
+
if (err.code === 'ENOENT') {
|
|
270
|
+
settle(new McpClientError(`Command "${command}" not found. Ensure it is installed and in PATH.`));
|
|
271
|
+
} else {
|
|
272
|
+
settle(new McpClientError(`Failed to start process: ${err.message}`));
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// If the process exits before we consider it connected
|
|
277
|
+
this._child.on('close', (code, signal) => {
|
|
278
|
+
settle(new McpClientError(
|
|
279
|
+
`Process exited before connection established (code=${code}, signal=${signal})`
|
|
280
|
+
));
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** @private */
|
|
286
|
+
_setupStdioHandlers() {
|
|
287
|
+
const child = this._child;
|
|
288
|
+
|
|
289
|
+
child.stdout.on('data', (chunk) => {
|
|
290
|
+
this._stdoutBuffer += chunk.toString();
|
|
291
|
+
this._processStdoutBuffer();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
child.stderr.on('data', (chunk) => {
|
|
295
|
+
const text = chunk.toString().trim();
|
|
296
|
+
if (text) {
|
|
297
|
+
this.emit('stderr', text);
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// Remove the initial 'close' listener that was for connection detection
|
|
302
|
+
child.removeAllListeners('close');
|
|
303
|
+
child.removeAllListeners('error');
|
|
304
|
+
|
|
305
|
+
child.on('close', (code, signal) => {
|
|
306
|
+
if (this._connected) {
|
|
307
|
+
this._connected = false;
|
|
308
|
+
this._initialized = false;
|
|
309
|
+
|
|
310
|
+
// Reject all pending with crash error
|
|
311
|
+
for (const [id, pending] of this._pending) {
|
|
312
|
+
clearTimeout(pending.timer);
|
|
313
|
+
pending.reject(new McpClientError(
|
|
314
|
+
`Server process exited unexpectedly (code=${code}, signal=${signal})`
|
|
315
|
+
));
|
|
316
|
+
}
|
|
317
|
+
this._pending.clear();
|
|
318
|
+
|
|
319
|
+
this.emit('crash', { code, signal });
|
|
320
|
+
this.emit('disconnected');
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
child.on('error', (err) => {
|
|
325
|
+
this.emit('error', err);
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** @private */
|
|
330
|
+
_processStdoutBuffer() {
|
|
331
|
+
// JSON-RPC over stdio uses newline-delimited JSON
|
|
332
|
+
let newlineIdx;
|
|
333
|
+
while ((newlineIdx = this._stdoutBuffer.indexOf('\n')) !== -1) {
|
|
334
|
+
const line = this._stdoutBuffer.slice(0, newlineIdx).trim();
|
|
335
|
+
this._stdoutBuffer = this._stdoutBuffer.slice(newlineIdx + 1);
|
|
336
|
+
|
|
337
|
+
if (!line) continue;
|
|
338
|
+
|
|
339
|
+
try {
|
|
340
|
+
const msg = JSON.parse(line);
|
|
341
|
+
this._handleMessage(msg);
|
|
342
|
+
} catch (err) {
|
|
343
|
+
// Not valid JSON - could be a log line from the server, ignore
|
|
344
|
+
this.emit('stderr', `[non-json stdout]: ${line}`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** @private */
|
|
350
|
+
_sendStdio(msg) {
|
|
351
|
+
if (!this._child || this._child.killed) {
|
|
352
|
+
throw new McpClientError('Process is not running');
|
|
353
|
+
}
|
|
354
|
+
const data = JSON.stringify(msg) + '\n';
|
|
355
|
+
this._child.stdin.write(data);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** @private */
|
|
359
|
+
async _disconnectStdio() {
|
|
360
|
+
return new Promise((resolve) => {
|
|
361
|
+
if (!this._child || this._child.killed) {
|
|
362
|
+
resolve();
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const child = this._child;
|
|
367
|
+
this._child = null;
|
|
368
|
+
|
|
369
|
+
// Give the process a chance to exit gracefully
|
|
370
|
+
const forceTimer = setTimeout(() => {
|
|
371
|
+
if (!child.killed) {
|
|
372
|
+
child.kill('SIGKILL');
|
|
373
|
+
}
|
|
374
|
+
resolve();
|
|
375
|
+
}, 2000);
|
|
376
|
+
|
|
377
|
+
child.on('close', () => {
|
|
378
|
+
clearTimeout(forceTimer);
|
|
379
|
+
resolve();
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// Close stdin to signal EOF, then SIGTERM
|
|
383
|
+
try {
|
|
384
|
+
child.stdin.end();
|
|
385
|
+
} catch (e) {
|
|
386
|
+
// stdin might already be closed
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
setTimeout(() => {
|
|
390
|
+
if (!child.killed) {
|
|
391
|
+
child.kill('SIGTERM');
|
|
392
|
+
}
|
|
393
|
+
}, 500);
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** @private */
|
|
398
|
+
_killChild() {
|
|
399
|
+
if (this._child && !this._child.killed) {
|
|
400
|
+
try {
|
|
401
|
+
this._child.kill('SIGKILL');
|
|
402
|
+
} catch (e) {
|
|
403
|
+
// ignore
|
|
404
|
+
}
|
|
405
|
+
this._child = null;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// --------------------------------------------------------------------------
|
|
410
|
+
// HTTP/SSE transport
|
|
411
|
+
// --------------------------------------------------------------------------
|
|
412
|
+
|
|
413
|
+
/** @private */
|
|
414
|
+
async _connectHttp() {
|
|
415
|
+
const { url } = this._spec;
|
|
416
|
+
|
|
417
|
+
if (!url) {
|
|
418
|
+
throw new McpClientError('http/sse transport requires a "url" field');
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// For HTTP transport, we verify the server is reachable with a GET request.
|
|
422
|
+
// The MCP Streamable HTTP transport uses a single endpoint for both
|
|
423
|
+
// POST (JSON-RPC requests) and GET (SSE event stream).
|
|
424
|
+
return new Promise((resolve, reject) => {
|
|
425
|
+
const timer = setTimeout(() => {
|
|
426
|
+
reject(new McpClientError(`HTTP connection timeout after ${this._timeout}ms`));
|
|
427
|
+
}, this._timeout);
|
|
428
|
+
|
|
429
|
+
try {
|
|
430
|
+
const parsedUrl = new URL(url);
|
|
431
|
+
const client = parsedUrl.protocol === 'https:' ? https : http;
|
|
432
|
+
|
|
433
|
+
const options = {
|
|
434
|
+
hostname: parsedUrl.hostname,
|
|
435
|
+
port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
|
|
436
|
+
path: parsedUrl.pathname + parsedUrl.search,
|
|
437
|
+
method: 'GET',
|
|
438
|
+
timeout: this._timeout,
|
|
439
|
+
headers: {
|
|
440
|
+
'Accept': 'text/event-stream',
|
|
441
|
+
...this._spec.headers
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
const req = client.request(options, (res) => {
|
|
446
|
+
clearTimeout(timer);
|
|
447
|
+
|
|
448
|
+
if (res.statusCode >= 200 && res.statusCode < 400) {
|
|
449
|
+
// Store the base URL for sending requests
|
|
450
|
+
this._httpSessionUrl = url;
|
|
451
|
+
// We don't keep this SSE connection open during connect;
|
|
452
|
+
// we will open a new one per request or use POST.
|
|
453
|
+
res.destroy();
|
|
454
|
+
resolve();
|
|
455
|
+
} else {
|
|
456
|
+
res.destroy();
|
|
457
|
+
reject(new McpClientError(`HTTP server returned status ${res.statusCode}`));
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
req.on('error', (err) => {
|
|
462
|
+
clearTimeout(timer);
|
|
463
|
+
reject(new McpClientError(`HTTP connection failed: ${err.message}`));
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
req.on('timeout', () => {
|
|
467
|
+
req.destroy();
|
|
468
|
+
clearTimeout(timer);
|
|
469
|
+
reject(new McpClientError(`HTTP connection timeout after ${this._timeout}ms`));
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
req.end();
|
|
473
|
+
} catch (err) {
|
|
474
|
+
clearTimeout(timer);
|
|
475
|
+
reject(new McpClientError(`Invalid URL: ${err.message}`));
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** @private */
|
|
481
|
+
_sendHttp(msg) {
|
|
482
|
+
return new Promise((resolve, reject) => {
|
|
483
|
+
const timer = setTimeout(() => {
|
|
484
|
+
reject(new McpClientError(`HTTP send timeout after ${this._timeout}ms`));
|
|
485
|
+
}, this._timeout);
|
|
486
|
+
|
|
487
|
+
try {
|
|
488
|
+
const parsedUrl = new URL(this._httpSessionUrl);
|
|
489
|
+
const client = parsedUrl.protocol === 'https:' ? https : http;
|
|
490
|
+
|
|
491
|
+
const body = JSON.stringify(msg);
|
|
492
|
+
|
|
493
|
+
const options = {
|
|
494
|
+
hostname: parsedUrl.hostname,
|
|
495
|
+
port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
|
|
496
|
+
path: parsedUrl.pathname + parsedUrl.search,
|
|
497
|
+
method: 'POST',
|
|
498
|
+
timeout: this._timeout,
|
|
499
|
+
headers: {
|
|
500
|
+
'Content-Type': 'application/json',
|
|
501
|
+
'Content-Length': Buffer.byteLength(body),
|
|
502
|
+
'Accept': 'application/json, text/event-stream',
|
|
503
|
+
...this._spec.headers
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
const req = client.request(options, (res) => {
|
|
508
|
+
let data = '';
|
|
509
|
+
res.on('data', (chunk) => { data += chunk.toString(); });
|
|
510
|
+
res.on('end', () => {
|
|
511
|
+
clearTimeout(timer);
|
|
512
|
+
|
|
513
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
514
|
+
reject(new McpClientError(`HTTP ${res.statusCode}: ${data}`));
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const contentType = res.headers['content-type'] || '';
|
|
519
|
+
|
|
520
|
+
// JSON response (direct response to JSON-RPC)
|
|
521
|
+
if (contentType.includes('application/json')) {
|
|
522
|
+
try {
|
|
523
|
+
const parsed = JSON.parse(data);
|
|
524
|
+
this._handleMessage(parsed);
|
|
525
|
+
resolve();
|
|
526
|
+
} catch (err) {
|
|
527
|
+
reject(new McpClientError(`Invalid JSON response: ${err.message}`));
|
|
528
|
+
}
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// SSE response (streamed events)
|
|
533
|
+
if (contentType.includes('text/event-stream')) {
|
|
534
|
+
this._parseSsePayload(data);
|
|
535
|
+
resolve();
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Accepted with no body (202, notifications)
|
|
540
|
+
if (res.statusCode === 202 || !data.trim()) {
|
|
541
|
+
resolve();
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Try parsing as JSON anyway
|
|
546
|
+
try {
|
|
547
|
+
const parsed = JSON.parse(data);
|
|
548
|
+
this._handleMessage(parsed);
|
|
549
|
+
resolve();
|
|
550
|
+
} catch (err) {
|
|
551
|
+
resolve(); // Non-JSON, non-SSE - just accept it
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
req.on('error', (err) => {
|
|
557
|
+
clearTimeout(timer);
|
|
558
|
+
reject(new McpClientError(`HTTP request failed: ${err.message}`));
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
req.on('timeout', () => {
|
|
562
|
+
req.destroy();
|
|
563
|
+
clearTimeout(timer);
|
|
564
|
+
reject(new McpClientError(`HTTP request timeout`));
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
req.write(body);
|
|
568
|
+
req.end();
|
|
569
|
+
} catch (err) {
|
|
570
|
+
clearTimeout(timer);
|
|
571
|
+
reject(new McpClientError(`HTTP send error: ${err.message}`));
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** @private */
|
|
577
|
+
_parseSsePayload(data) {
|
|
578
|
+
// Parse Server-Sent Events format
|
|
579
|
+
const lines = data.split('\n');
|
|
580
|
+
let eventData = '';
|
|
581
|
+
|
|
582
|
+
for (const line of lines) {
|
|
583
|
+
if (line.startsWith('data: ')) {
|
|
584
|
+
eventData += line.slice(6);
|
|
585
|
+
} else if (line === '' && eventData) {
|
|
586
|
+
try {
|
|
587
|
+
const msg = JSON.parse(eventData);
|
|
588
|
+
this._handleMessage(msg);
|
|
589
|
+
} catch (err) {
|
|
590
|
+
this.emit('stderr', `[invalid SSE data]: ${eventData}`);
|
|
591
|
+
}
|
|
592
|
+
eventData = '';
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Handle trailing data without a final empty line
|
|
597
|
+
if (eventData) {
|
|
598
|
+
try {
|
|
599
|
+
const msg = JSON.parse(eventData);
|
|
600
|
+
this._handleMessage(msg);
|
|
601
|
+
} catch (err) {
|
|
602
|
+
this.emit('stderr', `[invalid SSE data]: ${eventData}`);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** @private */
|
|
608
|
+
_disconnectHttp() {
|
|
609
|
+
this._httpSessionUrl = null;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// --------------------------------------------------------------------------
|
|
613
|
+
// JSON-RPC 2.0 framing
|
|
614
|
+
// --------------------------------------------------------------------------
|
|
615
|
+
|
|
616
|
+
/** @private */
|
|
617
|
+
_request(method, params) {
|
|
618
|
+
return new Promise((resolve, reject) => {
|
|
619
|
+
const id = this._nextId++;
|
|
620
|
+
|
|
621
|
+
const timer = setTimeout(() => {
|
|
622
|
+
this._pending.delete(id);
|
|
623
|
+
reject(new McpClientError(`Request "${method}" timed out after ${this._timeout}ms`));
|
|
624
|
+
}, this._timeout);
|
|
625
|
+
|
|
626
|
+
this._pending.set(id, { resolve, reject, timer, method });
|
|
627
|
+
|
|
628
|
+
const msg = {
|
|
629
|
+
jsonrpc: JSONRPC_VERSION,
|
|
630
|
+
id,
|
|
631
|
+
method,
|
|
632
|
+
params: params || {}
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
try {
|
|
636
|
+
if (this._type === 'stdio') {
|
|
637
|
+
this._sendStdio(msg);
|
|
638
|
+
} else {
|
|
639
|
+
this._sendHttp(msg).catch((err) => {
|
|
640
|
+
if (this._pending.has(id)) {
|
|
641
|
+
clearTimeout(timer);
|
|
642
|
+
this._pending.delete(id);
|
|
643
|
+
reject(err);
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
} catch (err) {
|
|
648
|
+
clearTimeout(timer);
|
|
649
|
+
this._pending.delete(id);
|
|
650
|
+
reject(new McpClientError(`Failed to send request: ${err.message}`));
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** @private */
|
|
656
|
+
_notify(method, params) {
|
|
657
|
+
const msg = {
|
|
658
|
+
jsonrpc: JSONRPC_VERSION,
|
|
659
|
+
method,
|
|
660
|
+
params: params || {}
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
try {
|
|
664
|
+
if (this._type === 'stdio') {
|
|
665
|
+
this._sendStdio(msg);
|
|
666
|
+
} else {
|
|
667
|
+
// Fire-and-forget for HTTP notifications
|
|
668
|
+
this._sendHttp(msg).catch((err) => {
|
|
669
|
+
this.emit('error', new McpClientError(`Notification send failed: ${err.message}`));
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
} catch (err) {
|
|
673
|
+
this.emit('error', new McpClientError(`Notification send failed: ${err.message}`));
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** @private */
|
|
678
|
+
_handleMessage(msg) {
|
|
679
|
+
// JSON-RPC response (has id, has result or error)
|
|
680
|
+
if (msg.id !== undefined && msg.id !== null) {
|
|
681
|
+
const pending = this._pending.get(msg.id);
|
|
682
|
+
if (!pending) {
|
|
683
|
+
// Could be a server-initiated request; emit for external handling
|
|
684
|
+
this.emit('server-request', msg);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
clearTimeout(pending.timer);
|
|
689
|
+
this._pending.delete(msg.id);
|
|
690
|
+
|
|
691
|
+
if (msg.error) {
|
|
692
|
+
const err = new McpClientError(
|
|
693
|
+
msg.error.message || 'Unknown server error',
|
|
694
|
+
msg.error.code,
|
|
695
|
+
msg.error.data
|
|
696
|
+
);
|
|
697
|
+
pending.reject(err);
|
|
698
|
+
} else {
|
|
699
|
+
pending.resolve(msg.result);
|
|
700
|
+
}
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// JSON-RPC notification (has method, no id)
|
|
705
|
+
if (msg.method) {
|
|
706
|
+
this.emit('notification', { method: msg.method, params: msg.params });
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Unknown message shape
|
|
711
|
+
this.emit('unknown-message', msg);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// --------------------------------------------------------------------------
|
|
715
|
+
// Assertions
|
|
716
|
+
// --------------------------------------------------------------------------
|
|
717
|
+
|
|
718
|
+
/** @private */
|
|
719
|
+
_assertConnected() {
|
|
720
|
+
if (!this._connected) {
|
|
721
|
+
throw new McpClientError('Not connected. Call connect() first.');
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** @private */
|
|
726
|
+
_assertInitialized() {
|
|
727
|
+
this._assertConnected();
|
|
728
|
+
if (!this._initialized) {
|
|
729
|
+
throw new McpClientError('Not initialized. Call initialize() first.');
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// ============================================================================
|
|
735
|
+
// McpClientError
|
|
736
|
+
// ============================================================================
|
|
737
|
+
|
|
738
|
+
class McpClientError extends Error {
|
|
739
|
+
/**
|
|
740
|
+
* @param {string} message - Error message
|
|
741
|
+
* @param {number} [code] - JSON-RPC error code
|
|
742
|
+
* @param {*} [data] - Additional error data
|
|
743
|
+
*/
|
|
744
|
+
constructor(message, code, data) {
|
|
745
|
+
super(message);
|
|
746
|
+
this.name = 'McpClientError';
|
|
747
|
+
this.code = code || undefined;
|
|
748
|
+
this.data = data || undefined;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// ============================================================================
|
|
753
|
+
// Convenience factory
|
|
754
|
+
// ============================================================================
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Create and connect an McpClient in one call.
|
|
758
|
+
* Returns a fully initialized client ready for tool calls.
|
|
759
|
+
*
|
|
760
|
+
* @param {object} serverSpec - Server specification (same as McpClient constructor)
|
|
761
|
+
* @param {object} [options] - Client options
|
|
762
|
+
* @returns {Promise<McpClient>} Connected and initialized client
|
|
763
|
+
*/
|
|
764
|
+
async function createClient(serverSpec, options = {}) {
|
|
765
|
+
const client = new McpClient(serverSpec, options);
|
|
766
|
+
await client.connect();
|
|
767
|
+
await client.initialize();
|
|
768
|
+
return client;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
module.exports = {
|
|
772
|
+
McpClient,
|
|
773
|
+
McpClientError,
|
|
774
|
+
createClient
|
|
775
|
+
};
|