You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

142 lines
4.8 KiB

'use strict';
// MCP stdio client for ArtCraft McpSidecar (and any MCP stdio server).
// JSON-RPC 2.0 over newline-delimited stdin/stdout.
const { spawn } = require('child_process');
class McpClient {
constructor(name, opts) {
this.name = name;
this.bin = opts.bin;
this.args = opts.args || [];
this.cwd = opts.cwd || undefined;
this.env = opts.env || undefined;
this.idleMs = opts.idleMs || 300000;
this.requestTimeoutMs = opts.requestTimeoutMs || 60000;
this.child = null;
this.buf = '';
this.nextId = 1;
this.pending = new Map();
this.tools = null;
this.serverInfo = null;
this.idleTimer = null;
this.stderrTail = [];
}
get running() { return !!(this.child && this.child.exitCode === null); }
async start() {
if (this.running) return this.serverInfo;
this.child = spawn(this.bin, this.args, {
stdio: ['pipe', 'pipe', 'pipe'],
env: this.env ? { ...process.env, ...this.env } : { ...process.env, NO_COLOR: '1', TERM: 'dumb' },
windowsHide: true,
cwd: this.cwd,
});
this.child.stdout.setEncoding('utf8');
this.child.stdout.on('data', (c) => this._onData(c));
this.child.stderr.setEncoding('utf8');
this.child.stderr.on('data', (c) => {
for (const line of String(c).trim().split('\n')) {
const t = line.trim();
if (!t) continue;
this.stderrTail.push(t);
if (this.stderrTail.length > 12) this.stderrTail.shift();
}
});
this.child.on('exit', (code) => {
const reason = this.stderrTail.slice(-2).join(' | ');
this.child = null;
for (const [, p] of this.pending) { clearTimeout(p.timer); p.reject(new Error(`${this.name}: exited code=${code} ${reason}`)); }
this.pending.clear();
});
this.child.on('error', (e) => {
this.child = null;
for (const [, p] of this.pending) { clearTimeout(p.timer); p.reject(e); }
this.pending.clear();
});
const init = await this._request('initialize', {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'dsh-memory', version: '1.0.0' },
});
this.serverInfo = init && init.serverInfo ? init.serverInfo : null;
this._notify('notifications/initialized', {});
return this.serverInfo;
}
async listTools() {
if (this.tools) return this.tools;
if (!this.running) await this.start();
const r = await this._request('tools/list', {});
this.tools = (r && r.tools) || [];
return this.tools;
}
async callTool(name, args, timeoutMs) {
if (!this.running) await this.start();
const r = await this._request('tools/call', { name, arguments: args || {} }, timeoutMs);
const parts = [];
for (const block of (r && r.content) || []) {
if (block.type === 'text') parts.push(block.text || '');
else if (block.type === 'image') parts.push(`[image ${block.mimeType || ''}]`);
else parts.push(`[${block.type}]`);
}
return { text: parts.join('\n').trim(), isError: !!(r && r.isError), raw: r };
}
close() {
if (this.idleTimer) { clearTimeout(this.idleTimer); this.idleTimer = null; }
if (this.child && this.child.exitCode === null) {
try { this.child.kill(); } catch {}
}
this.child = null;
}
_notify(method, params) {
if (!this.running) return;
this.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n');
this._armIdle();
}
_request(method, params, timeoutMs) {
if (!this.running) return Promise.reject(new Error(`${this.name}: not running`));
const id = this.nextId++;
const t = timeoutMs || this.requestTimeoutMs;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`${this.name}: ${method} timeout ${t}ms`));
}, t);
this.pending.set(id, { resolve, reject, timer });
this.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
this._armIdle();
});
}
_onData(chunk) {
this.buf += chunk;
let idx;
while ((idx = this.buf.indexOf('\n')) !== -1) {
const line = this.buf.slice(0, idx).trim();
this.buf = this.buf.slice(idx + 1);
if (!line) continue;
let msg;
try { msg = JSON.parse(line); } catch { continue; }
if (msg.id === undefined || msg.id === null) continue;
const p = this.pending.get(msg.id);
if (!p) continue;
this.pending.delete(msg.id);
clearTimeout(p.timer);
if (msg.error) p.reject(new Error(msg.error.message || JSON.stringify(msg.error)));
else p.resolve(msg.result);
}
}
_armIdle() {
if (this.idleTimer) clearTimeout(this.idleTimer);
this.idleTimer = setTimeout(() => this.close(), this.idleMs);
if (this.idleTimer.unref) this.idleTimer.unref();
}
}
module.exports = { McpClient };