diff --git a/bridge/mcp-tools.js b/bridge/mcp-tools.js index cb09b80..5377dc7 100644 --- a/bridge/mcp-tools.js +++ b/bridge/mcp-tools.js @@ -4,7 +4,7 @@ const net = require('net'); const fs = require('fs'); const path = require('path'); -const { spawn } = require('child_process'); +const { spawn, execFile } = require('child_process'); const { McpClient } = require('./mcp-client'); const { buildSpecs, guiLaunchArgs } = require('./mcp-registry'); @@ -25,6 +25,28 @@ function checkPort(port, timeoutMs) { }); } +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) { @@ -227,13 +249,42 @@ class McpManager { try { return await p; } finally { delete this.launching[name]; } } - // Закрыть MCP-клиент приложения (окно не трогаем). - stop(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; - return { app: name, stopped: true }; + + 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() { diff --git a/bridge/server.js b/bridge/server.js index 3e457e0..1b47a5f 100644 --- a/bridge/server.js +++ b/bridge/server.js @@ -167,7 +167,7 @@ async function handle(req, res, pathname, body) { if (req.method !== 'POST') throw new ToolError('E_ARGS_INVALID', 'only POST'); const app = body && body.app; if (!app || typeof app !== 'string') throw new ToolError('E_ARGS_INVALID', 'missing app'); - return { ok: true, ...mcp.stop(app) }; + return { ok: true, ...(await mcp.stop(app)) }; } case '/tool': { const { tool, args, workspace, confirmToken } = body || {};