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
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 };
|
|
|