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.
337 lines
14 KiB
337 lines
14 KiB
'use strict'; |
|
// MCP-менеджер: держит McpClient на каждое приложение ArtCraft, отдаёт TOOL_DEFS и проксирует вызовы. |
|
// Ленивый старт: клиент поднимается при первом вызове приложения, не на старте моста. |
|
const net = require('net'); |
|
const fs = require('fs'); |
|
const path = require('path'); |
|
const { spawn, execFile } = require('child_process'); |
|
const { McpClient } = require('./mcp-client'); |
|
const { buildSpecs, guiLaunchArgs } = require('./mcp-registry'); |
|
|
|
const IDLE_MS = 300000; |
|
const REQUEST_TIMEOUT_MS = 60000; |
|
const BRIDGE_DIR = path.join(__dirname); |
|
|
|
function checkPort(port, timeoutMs) { |
|
return new Promise((resolve) => { |
|
if (port == null) return resolve(false); |
|
const s = net.connect({ host: '127.0.0.1', port: Number(port) }); |
|
let done = false; |
|
const finish = (ok) => { if (done) return; done = true; try { s.destroy(); } catch {} resolve(ok); }; |
|
s.setTimeout(timeoutMs || 800); |
|
s.on('connect', () => finish(true)); |
|
s.on('timeout', () => finish(false)); |
|
s.on('error', () => finish(false)); |
|
}); |
|
} |
|
|
|
function findPidByPort(port) { |
|
return new Promise((resolve) => { |
|
if (port == null) return resolve(null); |
|
const ps = 'Get-NetTCPConnection -LocalPort ' + Number(port) + ' -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty OwningProcess'; |
|
execFile('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', ps], { timeout: 8000, windowsHide: true }, (err, stdout) => { |
|
if (err) return resolve(null); |
|
const pid = parseInt(String(stdout).trim(), 10); |
|
resolve(Number.isFinite(pid) && pid > 0 ? pid : null); |
|
}); |
|
}); |
|
} |
|
|
|
function softKill(pid) { |
|
return new Promise((resolve) => { |
|
if (!pid) return resolve({ ok: false, reason: 'no pid' }); |
|
// No /F: sends WM_CLOSE so the app can ask to save. |
|
execFile('taskkill.exe', ['/PID', String(pid)], { timeout: 8000, windowsHide: true }, (err, stdout, stderr) => { |
|
resolve({ ok: !err, out: String(stdout || '').trim(), err: String(stderr || '').trim() }); |
|
}); |
|
}); |
|
} |
|
|
|
async function waitPort(port, totalMs) { |
|
const deadline = Date.now() + (totalMs || 15000); |
|
while (Date.now() < deadline) { |
|
if (await checkPort(port, 700)) return true; |
|
await new Promise(r => setTimeout(r, 500)); |
|
} |
|
return false; |
|
} |
|
|
|
class McpManager { |
|
constructor(config) { |
|
this.config = config; |
|
this.root = null; |
|
this.apps = {}; |
|
this.enabled = []; |
|
this.clients = {}; |
|
this.starting = {}; |
|
this.lastError = {}; |
|
this.launching = {}; |
|
this.ready = false; |
|
} |
|
|
|
_rebuild() { |
|
const { root, apps } = buildSpecs(this.config); |
|
this.root = root; |
|
this.apps = apps; |
|
this.enabled = Object.values(apps).filter(a => a.enabled); |
|
} |
|
|
|
async init() { |
|
this._rebuild(); |
|
if (this.enabled.length === 0) { |
|
console.log('[mcp] no ArtCraft apps found under ' + this.root); |
|
this.ready = true; |
|
return; |
|
} |
|
const names = this.enabled.map(a => a.name + (a.auto ? '*' : '')); |
|
console.log('[mcp] discovered ' + this.enabled.length + ' app(s) under ' + this.root + ': ' + names.join(', ')); |
|
const missing = Object.values(this.apps).filter(a => !a.enabled && a.reason !== 'disabled in config').map(a => a.name); |
|
if (missing.length) console.log('[mcp] not installed: ' + missing.join(', ')); |
|
const eager = this._eagerList(); |
|
for (const name of eager) { |
|
try { await this.ensure(name); } |
|
catch (e) { console.warn('[mcp] eager ' + name + ' failed: ' + e.message); } |
|
} |
|
this.ready = true; |
|
} |
|
|
|
_eagerList() { |
|
const c = this.config && this.config.mcpServers && this.config.mcpServers.artcraft; |
|
const list = (c && c.eager) || []; |
|
if (!Array.isArray(list)) return []; |
|
return list.map(String).filter(n => this.apps[n] && this.apps[n].enabled); |
|
} |
|
|
|
_spec(name) { |
|
const spec = this.apps[name]; |
|
if (!spec) throw new Error('MCP app unknown: ' + name); |
|
if (!spec.enabled) throw new Error('MCP app not available: ' + name + (spec.reason ? ' (' + spec.reason + ')' : '')); |
|
return spec; |
|
} |
|
|
|
_client(spec) { |
|
let c = this.clients[spec.name]; |
|
if (!c) { |
|
c = new McpClient(spec.name, { |
|
bin: spec.bin, |
|
args: spec.args, |
|
cwd: spec.cwd, |
|
idleMs: IDLE_MS, |
|
requestTimeoutMs: REQUEST_TIMEOUT_MS, |
|
}); |
|
this.clients[spec.name] = c; |
|
} |
|
return c; |
|
} |
|
|
|
async ensure(name) { |
|
const spec = this._spec(name); |
|
const c = this._client(spec); |
|
if (c.running && c.tools) return c; |
|
if (this.starting[name]) return this.starting[name]; |
|
const p = (async () => { |
|
try { |
|
await c.start(); |
|
const tools = await c.listTools(); |
|
this.lastError[name] = null; |
|
console.log('[mcp] ' + name + ' ready, tools: ' + (tools ? tools.length : 0) + (spec.connectKnown ? ' (live port ' + spec.port + ')' : ' (headless)')); |
|
return c; |
|
} catch (e) { |
|
this.lastError[name] = e.message; |
|
console.warn('[mcp] ' + name + ' failed: ' + e.message); |
|
try { c.close(); } catch {} |
|
throw e; |
|
} finally { |
|
delete this.starting[name]; |
|
} |
|
})(); |
|
this.starting[name] = p; |
|
return p; |
|
} |
|
|
|
// Список приложений для UI: только установленные (enabled), со статусом. |
|
// guiUp — порт окна открыт; running — MCP-клиент поднят. Точка в UI горит, если хоть одно. |
|
async list() { |
|
const items = this.enabled.map(spec => { |
|
const c = this.clients[spec.name]; |
|
const running = !!(c && c.running); |
|
const tools = running && c.tools ? c.tools.length : 0; |
|
return { |
|
name: spec.name, |
|
dir: spec.dir, |
|
port: spec.port, |
|
hasGui: !!spec.gui, |
|
canLaunch: !!(spec.gui && spec.connectKnown && spec.port != null), |
|
gui: spec.gui || null, |
|
connectKnown: !!spec.connectKnown, |
|
auto: !!spec.auto, |
|
running, |
|
tools, |
|
starting: !!this.starting[spec.name], |
|
launching: !!this.launching[spec.name], |
|
lastError: this.lastError[spec.name] || null, |
|
}; |
|
}); |
|
await Promise.all(items.map(async (it) => { |
|
if (it.port != null && it.hasGui) { |
|
try { it.guiUp = await checkPort(it.port, 500); } catch { it.guiUp = false; } |
|
} else { |
|
it.guiUp = false; |
|
} |
|
})); |
|
return items; |
|
} |
|
|
|
// Пересканировать папку: подхватить новые приложения без перезапуска моста. |
|
async rescan() { |
|
this._rebuild(); |
|
const names = this.enabled.map(a => a.name); |
|
console.log('[mcp] rescan under ' + this.root + ': ' + (names.length ? names.join(', ') : 'none')); |
|
return await this.list(); |
|
} |
|
|
|
// Сменить корневую папку ArtCraft и сохранить в bridge/config.json. |
|
async setRoot(newRoot) { |
|
if (!newRoot || typeof newRoot !== 'string') throw new Error('root path is required'); |
|
const cfgPath = path.join(BRIDGE_DIR, 'config.json'); |
|
let disk = {}; |
|
try { disk = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch { throw new Error('cannot read bridge/config.json'); } |
|
disk.mcpServers = disk.mcpServers && typeof disk.mcpServers === 'object' ? disk.mcpServers : {}; |
|
disk.mcpServers.artcraft = disk.mcpServers.artcraft && typeof disk.mcpServers.artcraft === 'object' ? disk.mcpServers.artcraft : {}; |
|
disk.mcpServers.artcraft.root = newRoot; |
|
fs.writeFileSync(cfgPath, JSON.stringify(disk, null, 2) + '\n', 'utf8'); |
|
this.config.mcpServers = disk.mcpServers; |
|
this._rebuild(); |
|
console.log('[mcp] root changed to ' + this.root + ', apps: ' + this.enabled.length); |
|
return { root: this.root, apps: await this.list() }; |
|
} |
|
|
|
// Запустить GUI приложения с --control <port>, дождаться порта, перезапустить CLI для коннекта. |
|
async launchGui(name) { |
|
const spec = this._spec(name); |
|
if (!spec.gui) throw new Error('no GUI binary for ' + name); |
|
if (!spec.connectKnown || spec.port == null) throw new Error('live control is not supported for ' + name); |
|
const args = guiLaunchArgs(spec); |
|
if (args === null) throw new Error('no launch args for ' + name); |
|
if (spec.port != null && await checkPort(spec.port, 800)) { |
|
// Окно уже открыто. Закрыть возможно устаревший headless-клиент, чтобы следующий вызов переподключился. |
|
const old = this.clients[name]; |
|
if (old) { try { old.close(); } catch {} delete this.clients[name]; } |
|
this.ensure(name).catch(() => {}); |
|
return { app: name, already: true, port: spec.port, gui: spec.gui, ready: true }; |
|
} |
|
if (this.launching[name]) return this.launching[name]; |
|
const p = (async () => { |
|
console.log('[mcp] launching ' + name + ': ' + spec.gui + ' ' + args.join(' ')); |
|
const child = spawn(spec.gui, args, { |
|
cwd: spec.cwd, |
|
detached: true, |
|
stdio: 'ignore', |
|
windowsHide: false, |
|
}); |
|
child.on('error', (e) => { this.lastError[name] = 'launch: ' + e.message; }); |
|
child.unref(); |
|
let ready = false; |
|
if (spec.port != null) ready = await waitPort(spec.port, 20000); |
|
// CLI стартовал раньше окна и мог уйти в headless — закрыть, следующий вызов переподключится. |
|
const c = this.clients[name]; |
|
if (c) { try { c.close(); } catch {} delete this.clients[name]; } |
|
if (!ready) { |
|
this.lastError[name] = 'GUI started but port ' + spec.port + ' did not open in 20s'; |
|
console.warn('[mcp] ' + name + ' launched but port ' + spec.port + ' not open'); |
|
} else { |
|
console.log('[mcp] ' + name + ' GUI is up on port ' + spec.port + ', attaching MCP client'); |
|
this.ensure(name).catch(e => console.warn('[mcp] ' + name + ' auto-attach failed: ' + e.message)); |
|
} |
|
return { app: name, launched: true, port: spec.port, gui: spec.gui, ready }; |
|
})(); |
|
this.launching[name] = p; |
|
try { return await p; } finally { delete this.launching[name]; } |
|
} |
|
|
|
// Закрыть MCP-клиент; если клиента нет, но окно открыто — мягко закрыть окно. |
|
async stop(name) { |
|
const spec = this._spec(name); |
|
const c = this.clients[name]; |
|
const clientStopped = !!c; |
|
if (c) { try { c.close(); } catch {} delete this.clients[name]; } |
|
delete this.starting[name]; |
|
this.lastError[name] = null; |
|
|
|
const guiUp = spec.port != null ? await checkPort(spec.port, 600) : false; |
|
if (!guiUp) { |
|
return { app: name, stopped: clientStopped, windowClosed: false }; |
|
} |
|
const pid = await findPidByPort(spec.port); |
|
if (!pid) { |
|
return { app: name, stopped: clientStopped, windowClosed: false, warning: 'port is open but no owning process found' }; |
|
} |
|
console.log('[mcp] closing ' + name + ' window, pid ' + pid + ' (port ' + spec.port + ')'); |
|
const r = await softKill(pid); |
|
if (!r.ok) { |
|
this.lastError[name] = 'close window failed: ' + (r.err || 'unknown'); |
|
return { app: name, stopped: clientStopped, windowClosed: false, error: r.err || 'taskkill failed' }; |
|
} |
|
const closed = await new Promise((resolve) => { |
|
const t0 = Date.now(); |
|
const iv = setInterval(async () => { |
|
if (!(await checkPort(spec.port, 500))) { clearInterval(iv); resolve(true); } |
|
else if (Date.now() - t0 > 5000) { clearInterval(iv); resolve(false); } |
|
}, 400); |
|
}); |
|
if (!closed) { |
|
this.lastError[name] = 'window did not close in 5s (app may be waiting to save)'; |
|
return { app: name, stopped: clientStopped, windowClosed: false, warning: 'app did not close within 5s, it may be asking to save' }; |
|
} |
|
console.log('[mcp] ' + name + ' window closed'); |
|
return { app: name, stopped: clientStopped, windowClosed: true }; |
|
} |
|
|
|
toolDefs() { |
|
const defs = []; |
|
for (const spec of this.enabled) { |
|
const name = spec.name; |
|
defs.push({ |
|
name: name + '_mcp_tools', |
|
description: 'List MCP tools of ArtCraft ' + name + ' (filterable, brief)', |
|
params: { filter: 'string?', brief: 'bool?' }, |
|
}); |
|
defs.push({ |
|
name: name + '_mcp_call', |
|
description: 'Call an MCP tool of ArtCraft ' + name + ' (see ' + name + '_mcp_tools for ids and schemas)', |
|
params: { tool: 'string', arguments: 'object?' }, |
|
}); |
|
} |
|
return defs; |
|
} |
|
|
|
async proxy(toolName, args) { |
|
const m = String(toolName).match(/^([a-z]+)_mcp_(tools|call)$/); |
|
if (!m) return null; |
|
const app = m[1]; |
|
const op = m[2]; |
|
const c = await this.ensure(app); |
|
if (op === 'tools') { |
|
let tools = (await c.listTools()).slice(); |
|
if (args && args.filter) { |
|
const f = String(args.filter).toLowerCase(); |
|
tools = tools.filter(t => (String(t.name) + ' ' + String(t.title || '') + ' ' + String(t.description || '')).toLowerCase().includes(f)); |
|
} |
|
if (args && args.brief) tools = tools.map(t => ({ name: t.name, title: t.title, description: String(t.description || '').slice(0, 240) })); |
|
return { app, count: tools.length, tools }; |
|
} |
|
if (!args || typeof args.tool !== 'string' || !args.tool.trim()) { |
|
throw new Error('argument "tool" is required (call ' + app + '_mcp_tools to list tools)'); |
|
} |
|
const r = await c.callTool(args.tool, args.arguments || {}); |
|
return { app, tool: args.tool, text: r.text, isError: r.isError }; |
|
} |
|
|
|
closeAll() { |
|
for (const c of Object.values(this.clients)) { try { c.close(); } catch {} } |
|
this.clients = {}; |
|
this.starting = {}; |
|
} |
|
} |
|
|
|
module.exports = { McpManager };
|
|
|