diff --git a/README.md b/README.md index 106fc02..5d3cf23 100644 --- a/README.md +++ b/README.md @@ -465,13 +465,58 @@ net use, net user, net localgroup, net share, net stop, net start, net session, ## MCP: ArtCraft -Расширение умеет управлять приложениями ArtCraft (VectorCraft, PhotoCraft, FigCraft, DesignCraft, DeckCraft) через протокол MCP. Это не генератор картинок «из головы» — это руки в реальном редакторе: примитивы, пути с кривыми Безье, текст, заливки, обводки, эффекты, pathfinder, экспорт в SVG/PDF/DXF/PNG. +Расширение умеет управлять приложениями ArtCraft через протокол MCP. Это не генератор картинок «из головы» — это руки в реальном редакторе: примитивы, пути с кривыми Безье, текст, заливки, обводки, эффекты, pathfinder, экспорт в SVG/PDF/DXF/PNG. + +Поддерживаются все приложения ArtCraft, у которых есть CLI с подкомандой `mcp`: + +| Инструмент | Приложение | Профиль | +|---|---|---| +| `vectorcraft_mcp_*` | VectorCraft | векторная графика | +| `photocraft_mcp_*` | PhotoCraft | растровая графика | +| `figcraft_mcp_*` | FigCraft | интерфейсы, макеты | +| `designcraft_mcp_*` | DesignCraft | дизайн | +| `deckcraft_mcp_*` | DeckCraft | презентации | +| `cadcraft_mcp_*` | CADCraft | CAD | +| `craftanything_mcp_*` | Craft Anything | универсальные инструменты | +| `effectcraft_mcp_*` | EffectCraft | эффекты, видео-композиции | +| `filmcraft_mcp_*` | FilmCraft | видео | +| `gridcraft_mcp_*` | GridCraft | сетки | +| `lightcraft_mcp_*` | LightCraft | свет | +| `materialcraft_mcp_*` | MaterialCraft | материалы | +| `pdfcraft_mcp_*` | PdfCraft | PDF (189 инструментов) | +| `solidcraft_mcp_*` | SolidCraft | 3D, CAD-ядро | +| `soundcraft_mcp_*` | SoundCraft | звук | +| `texturecraft_mcp_*` | TextureCraft | текстуры | +| `wordcraft_mcp_*` | WordCraft | текст, документы | + +Список не жёсткий: любая новая папка в `V:\artcraft` с единственным `*-cli.exe` подхватывается автоматически (имя инструмента — имя папки в нижнем регистре без не-букв). ### Как это работает -Мост при старте поднимает по одному MCP-клиенту на каждое найденное приложение. Клиент — дочерний процесс `-cli.exe mcp`, общение по stdio строками JSON-RPC 2.0. Никакого mcp-sidecar и сетевых портов между мостом и CLI нет. +Клиент — дочерний процесс `-cli.exe mcp`, общение по stdio строками JSON-RPC 2.0. Никакого mcp-sidecar и сетевых портов между мостом и CLI нет. + +**Ленивый старт.** Инструменты всех найденных приложений видны в `/tools` сразу, но CLI-процесс поднимается при первом вызове этого приложения. Это важно при 17 приложениях: eager-старт дал бы ~2.3 ГБ RAM и полминуты ожидания. Параллельные вызовы одного приложения не дублируют старт — второй ждёт первый. Простаивающий клиент закрывается через 5 минут. + +Если нужно поднять приложения сразу при старте моста — перечислить их в `config.json`: + +```json +"mcpServers": { + "artcraft": { + "root": "V:/artcraft", + "eager": ["vectorcraft", "figcraft"], + "disabled": ["soundcraft"] + } +} +``` + +`eager` — стартовать сразу, `disabled` — не регистрировать вовсе, `root` — переопределить путь (по умолчанию `V:\artcraft` или `ARTCRAFT_ROOT`). + +Discovery двухуровневый: + +1. **Явный список** в `mcp-registry.js` — 17 известных приложений с точными именами бинарников. +2. **Автообнаружение** — любая папка в корне с единственным `*-cli.exe`, которой нет в явном списке. Имя инструмента = имя папки в нижнем регистре без не-букв (`Craft Anything` → `craftanything`). В логе такие помечаются звёздочкой. -Discovery: `mcp-registry.js` ищет бинарники в `V:\artcraft\\-cli.exe` (путь настраивается через `config.mcpServers.artcraft.root` или переменную `ARTCRAFT_ROOT`). Найденные приложения регистрируются, ненайденные тихо пропускаются с причиной в логе. +Ненайденные приложения тихо пропускаются с причиной в логе (`not installed: ...`). В `V:\artcraft\SolidCraft` лежат два exe (`solidcraft-cli.exe` и `solvecraft-cli.exe`) — берётся первый, второй игнорируется. ### Два режима работы CLI diff --git a/bridge/mcp-registry.js b/bridge/mcp-registry.js index f27a040..9381eb9 100644 --- a/bridge/mcp-registry.js +++ b/bridge/mcp-registry.js @@ -3,12 +3,26 @@ const fs = require('fs'); const path = require('path'); +// Явный список: имя инструмента -> папка, бинарь, аргументы. +// Имя обязано быть [a-z]+ — на него завязан regex в tools.js (^[a-z]+_mcp_(tools|call)$). const DEFAULT_APPS = { - vectorcraft: { dir: 'VectorCraft', bin: 'vectorcraft-cli.exe', args: ['mcp'] }, - photocraft: { dir: 'PhotoCraft', bin: 'photocraft-cli.exe', args: ['mcp'] }, - figcraft: { dir: 'FigCraft', bin: 'figcraft-cli.exe', args: ['mcp'] }, - designcraft: { dir: 'DesignCraft', bin: 'designcraft-cli.exe', args: ['mcp'] }, - deckcraft: { dir: 'DeckCraft', bin: 'deckcraft-cli.exe', args: ['mcp'] }, + vectorcraft: { dir: 'VectorCraft', bin: 'vectorcraft-cli.exe', args: ['mcp'] }, + photocraft: { dir: 'PhotoCraft', bin: 'photocraft-cli.exe', args: ['mcp'] }, + figcraft: { dir: 'FigCraft', bin: 'figcraft-cli.exe', args: ['mcp'] }, + designcraft: { dir: 'DesignCraft', bin: 'designcraft-cli.exe', args: ['mcp'] }, + deckcraft: { dir: 'DeckCraft', bin: 'deckcraft-cli.exe', args: ['mcp'] }, + cadcraft: { dir: 'CADCraft', bin: 'cadcraft-cli.exe', args: ['mcp'] }, + craftanything: { dir: 'Craft Anything', bin: 'craft-anything-cli.exe', args: ['mcp'] }, + effectcraft: { dir: 'EffectCraft', bin: 'effectcraft-cli.exe', args: ['mcp'] }, + filmcraft: { dir: 'FilmCraft', bin: 'filmcraft-cli.exe', args: ['mcp'] }, + gridcraft: { dir: 'GridCraft', bin: 'gridcraft-cli.exe', args: ['mcp'] }, + lightcraft: { dir: 'LightCraft', bin: 'lightcraft-cli.exe', args: ['mcp'] }, + materialcraft: { dir: 'MaterialCraft', bin: 'materialcraft-cli.exe', args: ['mcp'] }, + pdfcraft: { dir: 'PdfCraft', bin: 'pdfcraft-cli.exe', args: ['mcp'] }, + solidcraft: { dir: 'SolidCraft', bin: 'solidcraft-cli.exe', args: ['mcp'] }, + soundcraft: { dir: 'SoundCraft', bin: 'soundcraft-cli.exe', args: ['mcp'] }, + texturecraft: { dir: 'TextureCraft', bin: 'texturecraft-cli.exe', args: ['mcp'] }, + wordcraft: { dir: 'WordCraft', bin: 'wordcraft-cli.exe', args: ['mcp'] }, }; function resolveRoot(config) { @@ -16,20 +30,54 @@ function resolveRoot(config) { return (c && c.root) || process.env.ARTCRAFT_ROOT || 'V:\\artcraft'; } +function resolveDisabled(config) { + const c = config && config.mcpServers && config.mcpServers.artcraft; + const list = (c && c.disabled) || []; + return Array.isArray(list) ? list.map(String) : []; +} + function discover(root, spec) { const full = path.join(root, spec.dir, spec.bin); try { return fs.existsSync(full) ? full : null; } catch { return null; } } +function toolNameFromDir(dirName) { + return String(dirName).toLowerCase().replace(/[^a-z]/g, ''); +} + +// Автообнаружение: любая папка в root с единственным *-cli.exe, которой нет в списке. +// Нужно, чтобы новая установка ArtCraft подхватывалась без правки кода. +function autodiscover(root, known) { + const found = {}; + let entries = []; + try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return found; } + for (const it of entries) { + if (!it.isDirectory()) continue; + const dir = path.join(root, it.name); + let exes = []; + try { exes = fs.readdirSync(dir).filter(n => /-cli\.exe$/i.test(n)); } catch { continue; } + if (exes.length !== 1) continue; + const bin = path.join(dir, exes[0]); + if (Object.values(known).some(s => path.join(root, s.dir, s.bin).toLowerCase() === bin.toLowerCase())) continue; + const name = toolNameFromDir(it.name); + if (!name || known[name] || found[name]) continue; + found[name] = { dir: it.name, bin: exes[0], args: ['mcp'], auto: true }; + } + return found; +} + function buildSpecs(config) { const root = resolveRoot(config); + const disabled = resolveDisabled(config); + const known = { ...DEFAULT_APPS, ...autodiscover(root, DEFAULT_APPS) }; const out = {}; - for (const [name, spec] of Object.entries(DEFAULT_APPS)) { + for (const [name, spec] of Object.entries(known)) { + if (disabled.includes(name)) { out[name] = { name, enabled: false, reason: 'disabled in config' }; continue; } const bin = discover(root, spec); if (!bin) { out[name] = { name, enabled: false, reason: 'binary not found: ' + path.join(root, spec.dir, spec.bin) }; continue; } - out[name] = { name, enabled: true, bin, args: spec.args, cwd: path.dirname(bin) }; + out[name] = { name, enabled: true, bin, args: spec.args, cwd: path.dirname(bin), auto: !!spec.auto }; } return { root, apps: out }; } -module.exports = { DEFAULT_APPS, resolveRoot, buildSpecs }; +module.exports = { DEFAULT_APPS, resolveRoot, resolveDisabled, buildSpecs, toolNameFromDir }; diff --git a/bridge/mcp-tools.js b/bridge/mcp-tools.js index ab41fe9..774dd8e 100644 --- a/bridge/mcp-tools.js +++ b/bridge/mcp-tools.js @@ -1,52 +1,110 @@ 'use strict'; // MCP-менеджер: держит McpClient на каждое приложение ArtCraft, отдаёт TOOL_DEFS и проксирует вызовы. +// Ленивый старт: клиент поднимается при первом вызове приложения, не на старте моста. const { McpClient } = require('./mcp-client'); const { buildSpecs } = require('./mcp-registry'); +const IDLE_MS = 300000; +const REQUEST_TIMEOUT_MS = 60000; + class McpManager { constructor(config) { this.config = config; - const { root, apps } = buildSpecs(config); - this.root = root; - this.apps = apps; + this.root = null; + this.apps = {}; + this.enabled = []; this.clients = {}; + this.starting = {}; + this.lastError = {}; this.ready = false; } async init() { - const enabled = Object.values(this.apps).filter(a => a.enabled); - if (enabled.length === 0) { + const { root, apps } = buildSpecs(this.config); + this.root = root; + this.apps = apps; + this.enabled = Object.values(apps).filter(a => a.enabled); + if (this.enabled.length === 0) { console.log('[mcp] no ArtCraft apps found under ' + this.root); this.ready = true; return; } - for (const spec of enabled) { - const c = new McpClient(spec.name, { + 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(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(); + if (eager.length) { + 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: 300000, - requestTimeoutMs: 60000, + 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(); - await c.listTools(); - this.clients[spec.name] = c; - console.log('[mcp] ' + spec.name + ' ready, tools: ' + (c.tools ? c.tools.length : 0)); + const tools = await c.listTools(); + this.lastError[name] = null; + console.log('[mcp] ' + name + ' ready, tools: ' + (tools ? tools.length : 0)); + return c; } catch (e) { - console.warn('[mcp] ' + spec.name + ' failed: ' + e.message); + this.lastError[name] = e.message; + console.warn('[mcp] ' + name + ' failed: ' + e.message); try { c.close(); } catch {} + throw e; + } finally { + delete this.starting[name]; } - } - this.ready = true; + })(); + this.starting[name] = p; + return p; } toolDefs() { const defs = []; - for (const name of Object.keys(this.clients)) { + for (const spec of this.enabled) { + const name = spec.name; defs.push({ name: name + '_mcp_tools', - description: 'List MCP tools of ArtCraft ' + name + ' (33+ tools, filterable)', + description: 'List MCP tools of ArtCraft ' + name + ' (filterable, brief)', params: { filter: 'string?', brief: 'bool?' }, }); defs.push({ @@ -63,9 +121,7 @@ class McpManager { if (!m) return null; const app = m[1]; const op = m[2]; - const c = this.clients[app]; - if (!c) throw new Error('MCP app not ready: ' + app + (this.apps[app] && this.apps[app].reason ? ' (' + this.apps[app].reason + ')' : '')); - await c.start(); + const c = await this.ensure(app); if (op === 'tools') { let tools = (await c.listTools()).slice(); if (args && args.filter) { @@ -85,6 +141,7 @@ class McpManager { closeAll() { for (const c of Object.values(this.clients)) { try { c.close(); } catch {} } this.clients = {}; + this.starting = {}; } }