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

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