mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-21 04:02:22 +02:00
118 lines
7.7 KiB
JavaScript
118 lines
7.7 KiB
JavaScript
#!/usr/bin/env node
|
|
// MCP server giving a Claude Code instance a Playwright hand on the real packaged app (launch/click/type/screenshot/eval/read-log); wraps Electron _electron since @playwright/mcp is browser-only. App held across calls. See e2e/mcp/README.md.
|
|
|
|
'use strict';
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const { _electron } = require('@playwright/test');
|
|
const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
|
|
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
const { ListToolsRequestSchema, CallToolRequestSchema } = require('@modelcontextprotocol/sdk/types.js');
|
|
|
|
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
|
|
function packagedAppPath(explicit) {
|
|
if (explicit) return explicit;
|
|
if (process.env.E2E_APP_PATH) return process.env.E2E_APP_PATH;
|
|
const dist = path.join(REPO_ROOT, 'electron', 'dist');
|
|
const candidates = process.platform === 'win32'
|
|
? [path.join(dist, 'win-unpacked', 'OpenSwarm.exe')]
|
|
: process.platform === 'darwin'
|
|
? ['mac-arm64', 'mac', 'mac-universal'].map((d) => path.join(dist, d, 'OpenSwarm.app', 'Contents', 'MacOS', 'OpenSwarm'))
|
|
: [path.join(dist, 'linux-unpacked', 'openswarm')];
|
|
const found = candidates.find((c) => { try { return fs.statSync(c).isFile(); } catch { return false; } });
|
|
if (!found) throw new Error(`packaged app not found; build first or pass appPath. Looked in:\n ${candidates.join('\n ')}`);
|
|
return found;
|
|
}
|
|
|
|
function backendLogPath() {
|
|
if (process.platform === 'darwin') return path.join(os.homedir(), 'Library', 'Application Support', 'OpenSwarm', 'data', 'backend.log');
|
|
if (process.platform === 'win32') return path.join(process.env.APPDATA || os.homedir(), 'OpenSwarm', 'data', 'backend.log');
|
|
const xdg = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
|
|
return path.join(xdg, 'OpenSwarm', 'data', 'backend.log');
|
|
}
|
|
|
|
let app = null; // ElectronApplication
|
|
let page = null; // main Page
|
|
|
|
async function findMainWindow(timeoutMs = 120000) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
for (const w of app.windows()) {
|
|
try {
|
|
const ready = await w.evaluate(() => {
|
|
const hasBridge = typeof window.openswarm?.getBackendPort === 'function';
|
|
const root = document.getElementById('root');
|
|
return hasBridge && !!root && root.childElementCount > 0;
|
|
});
|
|
if (ready) return w;
|
|
} catch { /* navigating */ }
|
|
}
|
|
await new Promise((r) => setTimeout(r, 500));
|
|
}
|
|
throw new Error('main window with a mounted React root never appeared');
|
|
}
|
|
|
|
function text(s) { return { content: [{ type: 'text', text: typeof s === 'string' ? s : JSON.stringify(s, null, 2) }] }; }
|
|
function err(s) { return { content: [{ type: 'text', text: `ERROR: ${s}` }], isError: true }; }
|
|
function needPage() { if (!page) throw new Error('no app open; call app_launch first'); }
|
|
|
|
const TOOLS = [
|
|
{ name: 'app_launch', description: 'Launch the packaged OpenSwarm app and wait for the main window. Returns backend port + build provenance.', inputSchema: { type: 'object', properties: { appPath: { type: 'string', description: 'Optional explicit path to the packaged binary' } } } },
|
|
{ name: 'app_close', description: 'Close the running app.', inputSchema: { type: 'object', properties: {} } },
|
|
{ name: 'screenshot', description: 'Capture a PNG screenshot of the current main window (what a user would see).', inputSchema: { type: 'object', properties: { fullPage: { type: 'boolean' } } } },
|
|
{ name: 'snapshot', description: 'Return the accessibility tree of the page (structured "what is on screen" without pixels).', inputSchema: { type: 'object', properties: {} } },
|
|
{ name: 'click', description: 'Click an element by Playwright selector (CSS, text=..., role=..., etc.).', inputSchema: { type: 'object', properties: { selector: { type: 'string' } }, required: ['selector'] } },
|
|
{ name: 'fill', description: 'Type text into an input/textarea by selector (clears first).', inputSchema: { type: 'object', properties: { selector: { type: 'string' }, text: { type: 'string' } }, required: ['selector', 'text'] } },
|
|
{ name: 'press', description: 'Press a keyboard key (e.g. Enter, Escape, Control+A) on the focused element.', inputSchema: { type: 'object', properties: { key: { type: 'string' } }, required: ['key'] } },
|
|
{ name: 'wait_for', description: 'Wait until a selector is visible (default 30s).', inputSchema: { type: 'object', properties: { selector: { type: 'string' }, timeoutMs: { type: 'number' } }, required: ['selector'] } },
|
|
{ name: 'eval', description: 'Evaluate a JS expression in the renderer and return the JSON result (powerful: inspect anything, incl. window.openswarm).', inputSchema: { type: 'object', properties: { expression: { type: 'string' } }, required: ['expression'] } },
|
|
{ name: 'read_log', description: 'Return the tail of the app backend.log (provenance + [perf] marks + errors land here).', inputSchema: { type: 'object', properties: { tailLines: { type: 'number' } } } },
|
|
];
|
|
|
|
async function handle(name, a) {
|
|
if (name === 'app_launch') {
|
|
if (app) { try { await app.close(); } catch { /* */ } app = null; page = null; }
|
|
app = await _electron.launch({ executablePath: packagedAppPath(a.appPath), args: [] });
|
|
page = await findMainWindow();
|
|
const info = await page.evaluate(async () => ({
|
|
port: window.openswarm.getBackendPort ? await window.openswarm.getBackendPort() : null,
|
|
build: window.openswarm.getBuildInfo ? await window.openswarm.getBuildInfo() : null,
|
|
}));
|
|
return text({ launched: true, ...info });
|
|
}
|
|
if (name === 'app_close') {
|
|
if (app) { try { await app.close(); } catch { /* */ } }
|
|
app = null; page = null;
|
|
return text('closed');
|
|
}
|
|
if (name === 'screenshot') { needPage(); const buf = await page.screenshot({ fullPage: !!a.fullPage }); return { content: [{ type: 'image', data: buf.toString('base64'), mimeType: 'image/png' }] }; }
|
|
if (name === 'snapshot') { needPage(); return text(await page.accessibility.snapshot()); }
|
|
if (name === 'click') { needPage(); await page.click(a.selector, { timeout: 15000 }); return text(`clicked ${a.selector}`); }
|
|
if (name === 'fill') { needPage(); await page.fill(a.selector, a.text, { timeout: 15000 }); return text(`filled ${a.selector}`); }
|
|
if (name === 'press') { needPage(); await page.keyboard.press(a.key); return text(`pressed ${a.key}`); }
|
|
if (name === 'wait_for') { needPage(); await page.waitForSelector(a.selector, { state: 'visible', timeout: a.timeoutMs || 30000 }); return text(`visible: ${a.selector}`); }
|
|
if (name === 'eval') { needPage(); const r = await page.evaluate((expr) => eval(expr), a.expression); return text(r === undefined ? 'undefined' : r); }
|
|
if (name === 'read_log') {
|
|
const raw = (() => { try { return fs.readFileSync(backendLogPath(), 'utf8'); } catch { return ''; } })();
|
|
const lines = raw.split(/\r?\n/);
|
|
const n = a.tailLines || 80;
|
|
return text(lines.slice(-n).join('\n') || '(backend.log empty or missing)');
|
|
}
|
|
throw new Error(`unknown tool ${name}`);
|
|
}
|
|
|
|
async function main() {
|
|
const server = new Server({ name: 'openswarm-gui', version: '0.1.0' }, { capabilities: { tools: {} } });
|
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
try { return await handle(req.params.name, req.params.arguments || {}); }
|
|
catch (e) { return err(e && e.message || String(e)); }
|
|
});
|
|
process.on('exit', () => { try { app && app.close(); } catch { /* */ } });
|
|
await server.connect(new StdioServerTransport());
|
|
}
|
|
|
|
main().catch((e) => { process.stderr.write(`electron-mcp fatal: ${e && e.stack || e}\n`); process.exit(1); });
|