@teapotz/electron-mcp 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,288 @@
1
+ # Electron MCP
2
+
3
+ MCP (Model Context Protocol) server for testing Electron applications using Playwright. Enables AI models like Claude to interact with and test your Electron apps.
4
+
5
+ ## 🚀 Quick Start
6
+
7
+ ```bash
8
+ # Run directly with npx
9
+ npx @teapotz/electron-mcp
10
+
11
+ # Or install globally
12
+ npm install -g @teapotz/electron-mcp
13
+ electron-mcp
14
+ ```
15
+
16
+ ## Features
17
+
18
+ - **Two Connection Modes**
19
+ - **CDP Mode**: Connect to a running Electron app via Chrome DevTools Protocol
20
+ - **Launch Mode**: Launch a fresh Electron app instance for testing
21
+ - **Full Playwright API**: screenshot, click, fill, type, hover, press, wait, evaluate, and more
22
+ - **Accessibility Snapshots**: Get the accessibility tree for element discovery
23
+ - **Main Process Access**: Execute code in Electron's main process (launch mode only)
24
+
25
+ ## How It Works
26
+
27
+ ```
28
+ User <--> AI Model (Claude) <--> MCP Protocol <--> electron-mcp <--> Electron App
29
+ ```
30
+
31
+ 1. **User**: "Click the login button and fill in the email field"
32
+ 2. **AI Model**: Determines which MCP tools to use
33
+ 3. **MCP Protocol**: Standardized communication
34
+ 4. **electron-mcp**: Executes Playwright commands on the Electron app
35
+ 5. **Electron App**: Actions are performed in the actual application
36
+
37
+ ## Configuration
38
+
39
+ ### Claude Desktop / MCP Clients
40
+
41
+ Add to your MCP configuration file:
42
+
43
+ ```json
44
+ {
45
+ "mcpServers": {
46
+ "electron-test": {
47
+ "command": "npx",
48
+ "args": ["@teapotz/electron-mcp"]
49
+ }
50
+ }
51
+ }
52
+ ```
53
+
54
+ ### OpenCode
55
+
56
+ ```json
57
+ {
58
+ "mcp": {
59
+ "electron-test": {
60
+ "type": "local",
61
+ "command": ["npx", "@teapotz/electron-mcp"],
62
+ "enabled": true
63
+ }
64
+ }
65
+ }
66
+ ```
67
+
68
+ ## Connection Modes
69
+
70
+ ### CDP Mode (Recommended for Development)
71
+
72
+ Connect to an already running Electron app with remote debugging enabled:
73
+
74
+ ```bash
75
+ # Start your Electron app with debugging port
76
+ electron your-app --remote-debugging-port=9222
77
+
78
+ # Or with electron-vite
79
+ electron-vite dev -- --remote-debugging-port=9222
80
+ ```
81
+
82
+ Then use the `connect` tool:
83
+
84
+ ```
85
+ connect({ port: 9222 })
86
+ ```
87
+
88
+ **Advantages:**
89
+
90
+ - Works with your existing dev workflow
91
+ - App state preserved between tests
92
+ - Hot reload still works
93
+
94
+ ### Launch Mode
95
+
96
+ Launch a fresh Electron app instance:
97
+
98
+ ```
99
+ launch({ appPath: "./out/main/index.js" })
100
+
101
+ # With headless mode for CI
102
+ launch({ appPath: "./out/main/index.js", headless: true })
103
+ ```
104
+
105
+ **Advantages:**
106
+
107
+ - Clean state for each test
108
+ - Access to main process via `evaluateMain`
109
+ - Can pass custom environment variables
110
+ - Supports headless mode for CI/automation
111
+
112
+ ## Headless Mode (CI/Automation)
113
+
114
+ ### Launch Mode
115
+
116
+ Pass `headless: true` to run without a visible window:
117
+
118
+ ```
119
+ launch({ appPath: "./out/main/index.js", headless: true })
120
+ ```
121
+
122
+ ### CDP Mode
123
+
124
+ Start your Electron app with headless flags before connecting:
125
+
126
+ ```bash
127
+ # Option 1: Electron headless flag (Electron 28+)
128
+ electron your-app --headless=new --remote-debugging-port=9222
129
+
130
+ # Option 2: xvfb (Linux) - virtual framebuffer
131
+ xvfb-run electron your-app --remote-debugging-port=9222
132
+
133
+ # Option 3: xvfb with specific display (CI environments)
134
+ Xvfb :99 -screen 0 1920x1080x24 &
135
+ DISPLAY=:99 electron your-app --remote-debugging-port=9222
136
+ ```
137
+
138
+ Then connect normally:
139
+
140
+ ```
141
+ connect({ port: 9222 })
142
+ ```
143
+
144
+ ## Available Tools
145
+
146
+ ### Connection
147
+
148
+ | Tool | Description |
149
+ | ------------ | --------------------------------------- |
150
+ | `connect` | Connect to running app via CDP |
151
+ | `disconnect` | Disconnect from CDP (app keeps running) |
152
+ | `launch` | Launch new Electron app instance |
153
+ | `close` | Close launched app |
154
+
155
+ ### Interaction
156
+
157
+ | Tool | Description |
158
+ | -------------- | ----------------------------------- |
159
+ | `click` | Click an element |
160
+ | `dblclick` | Double-click an element |
161
+ | `fill` | Fill text into input (clears first) |
162
+ | `type` | Type text character by character |
163
+ | `hover` | Hover over an element |
164
+ | `press` | Press keyboard key |
165
+ | `drag` | Drag and drop |
166
+ | `selectOption` | Select from dropdown |
167
+
168
+ ### Scroll
169
+
170
+ | Tool | Description |
171
+ | ----------------- | ------------------------------------ |
172
+ | `scroll` | Scroll using mouse wheel (deltaX/Y) |
173
+ | `scrollTo` | Scroll to absolute position |
174
+ | `scrollIntoView` | Scroll element into view |
175
+
176
+ ### Mouse
177
+
178
+ | Tool | Description |
179
+ | -------------- | ---------------------------------------- |
180
+ | `mouseMove` | Move mouse cursor to coordinates |
181
+ | `mouseDown` | Press mouse button down |
182
+ | `mouseUp` | Release mouse button |
183
+ | `mouseClick` | Click at specific coordinates |
184
+
185
+ ### Inspection
186
+
187
+ | Tool | Description |
188
+ | -------------- | -------------------------------------- |
189
+ | `screenshot` | Take screenshot (returns base64 image) |
190
+ | `snapshot` | Get accessibility tree |
191
+ | `getText` | Get element text content |
192
+ | `getAttribute` | Get element attribute |
193
+ | `isVisible` | Check if element is visible |
194
+ | `count` | Count matching elements |
195
+
196
+ ### Advanced
197
+
198
+ | Tool | Description |
199
+ | -------------- | ------------------------------------------- |
200
+ | `wait` | Wait for element state |
201
+ | `evaluate` | Run JS in renderer process |
202
+ | `evaluateMain` | Run code in main process (launch mode only) |
203
+
204
+ ## Selectors
205
+
206
+ Supports all Playwright selectors:
207
+
208
+ ```
209
+ # CSS selectors
210
+ [data-testid="submit-btn"]
211
+ .my-class
212
+ #my-id
213
+
214
+ # Text selectors
215
+ text=Submit
216
+ text="Exact Match"
217
+
218
+ # Role selectors
219
+ role=button[name="Submit"]
220
+
221
+ # Combining
222
+ .form >> text=Submit
223
+ ```
224
+
225
+ ## Usage Examples
226
+
227
+ ### Basic Test Flow
228
+
229
+ ```
230
+ 1. connect({ port: 9222 })
231
+ 2. snapshot() // See the page structure
232
+ 3. click('[data-testid="login-btn"]')
233
+ 4. fill('[data-testid="email"]', 'test@example.com')
234
+ 5. fill('[data-testid="password"]', 'password123')
235
+ 6. click('text=Sign In')
236
+ 7. wait({ selector: '[data-testid="dashboard"]' })
237
+ 8. screenshot()
238
+ ```
239
+
240
+ ### Main Process Access (Launch Mode)
241
+
242
+ ```javascript
243
+ // Get app version
244
+ evaluateMain({
245
+ script: "({ app }) => app.getVersion()",
246
+ });
247
+
248
+ // Show dialog
249
+ evaluateMain({
250
+ script: "({ dialog }) => dialog.showMessageBox({ message: 'Hello!' })",
251
+ });
252
+ ```
253
+
254
+ ### With AI Assistant
255
+
256
+ You can ask Claude or other AI assistants to test your Electron app:
257
+
258
+ ```
259
+ Connect to my Electron app running on port 9222 and:
260
+ 1. Take a screenshot of the current state
261
+ 2. Click the "Settings" button in the sidebar
262
+ 3. Change the theme to dark mode
263
+ 4. Verify the theme changed by checking the background color
264
+ ```
265
+
266
+ ## Tips for Testable Electron Apps
267
+
268
+ 1. **Add `data-testid` attributes** to important elements
269
+ 2. **Enable remote debugging** in development: `--remote-debugging-port=9222`
270
+ 3. **Use semantic HTML** for better accessibility snapshots
271
+ 4. **Keep selectors stable** - prefer `data-testid` over classes
272
+
273
+ ## Development
274
+
275
+ ```bash
276
+ # Clone repository
277
+ git clone https://github.com/teapotz/electron-mcp.git
278
+ cd electron-mcp
279
+
280
+ # Install dependencies
281
+ npm install
282
+
283
+ # Build
284
+ npm run build
285
+
286
+ # Run locally
287
+ node dist/index.js
288
+ ```
@@ -0,0 +1,11 @@
1
+ export declare class NotConnectedError extends Error {
2
+ constructor(message?: string);
3
+ }
4
+ export declare class NotLaunchedError extends Error {
5
+ constructor(message?: string);
6
+ }
7
+ export declare function classifyPlaywrightError(error: unknown): {
8
+ message: string;
9
+ category: string;
10
+ remediation: string;
11
+ };
package/dist/errors.js ADDED
@@ -0,0 +1,48 @@
1
+ export class NotConnectedError extends Error {
2
+ constructor(message = "Not connected. Call connect or launch first.") {
3
+ super(message);
4
+ this.name = "NotConnectedError";
5
+ }
6
+ }
7
+ export class NotLaunchedError extends Error {
8
+ constructor(message = "Not connected. Call launch first.") {
9
+ super(message);
10
+ this.name = "NotLaunchedError";
11
+ }
12
+ }
13
+ export function classifyPlaywrightError(error) {
14
+ const message = error instanceof Error ? error.message : String(error);
15
+ if (/timeout/i.test(message)) {
16
+ return {
17
+ message,
18
+ category: "Timeout",
19
+ remediation: "Increase timeout or check selector",
20
+ };
21
+ }
22
+ if (/no node found|no element/i.test(message)) {
23
+ return {
24
+ message,
25
+ category: "ElementNotFound",
26
+ remediation: "Verify selector with snapshot()",
27
+ };
28
+ }
29
+ if (/target closed|target page/i.test(message)) {
30
+ return {
31
+ message,
32
+ category: "TargetClosed",
33
+ remediation: "Reconnect to the application",
34
+ };
35
+ }
36
+ if (/protocol error/i.test(message)) {
37
+ return {
38
+ message,
39
+ category: "ProtocolError",
40
+ remediation: "Check that the application is still running",
41
+ };
42
+ }
43
+ return {
44
+ message,
45
+ category: "Unknown",
46
+ remediation: "Check the error message for details",
47
+ };
48
+ }
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { SessionController } from "./session/SessionController.js";
4
+ export declare function createServer(): {
5
+ server: Server;
6
+ session: SessionController;
7
+ };
package/dist/index.js ADDED
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+ import { realpathSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
7
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
9
+ import { SessionController } from "./session/SessionController.js";
10
+ import { buildRegistry, dispatch } from "./tools/registry.js";
11
+ const require = createRequire(import.meta.url);
12
+ const { name: PKG_NAME, version: PKG_VERSION } = require("../package.json");
13
+ // ---------------------------------------------------------------------------
14
+ // Server factory (exported for testing)
15
+ // ---------------------------------------------------------------------------
16
+ export function createServer() {
17
+ const session = new SessionController();
18
+ const registry = buildRegistry();
19
+ const server = new Server({ name: PKG_NAME, version: PKG_VERSION }, { capabilities: { tools: {} } });
20
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
21
+ tools: [...registry.values()].map((spec) => spec.definition),
22
+ }));
23
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
24
+ const { name, arguments: args } = request.params;
25
+ return dispatch(registry, session, name, args);
26
+ });
27
+ return { server, session };
28
+ }
29
+ // ---------------------------------------------------------------------------
30
+ // Main entry point (only runs when executed directly, not when imported)
31
+ // ---------------------------------------------------------------------------
32
+ const modulePath = fileURLToPath(import.meta.url);
33
+ const isMainModule = (() => {
34
+ if (typeof process === "undefined" || !process.argv[1])
35
+ return false;
36
+ try {
37
+ // npm/npx binaries are often symlinks; compare real paths first.
38
+ return realpathSync(process.argv[1]) === realpathSync(modulePath);
39
+ }
40
+ catch {
41
+ return path.resolve(process.argv[1]) === path.resolve(modulePath);
42
+ }
43
+ })();
44
+ if (isMainModule) {
45
+ const flag = process.argv[2];
46
+ if (flag === "--version" || flag === "-v") {
47
+ console.error(`${PKG_NAME} ${PKG_VERSION}`);
48
+ process.exit(0);
49
+ }
50
+ if (flag === "--help" || flag === "-h") {
51
+ console.error(`${PKG_NAME} ${PKG_VERSION}
52
+ Electron UI automation via Playwright, exposed as an MCP server.
53
+
54
+ Usage:
55
+ electron-mcp Start the MCP server (communicates over stdio)
56
+ electron-mcp --help Show this help message
57
+ electron-mcp --version Show version
58
+
59
+ Environment variables:
60
+ ELECTRON_MCP_TIMEOUT_MS Default timeout for Playwright operations (default: 30000)
61
+
62
+ Connection modes:
63
+ connect Attach to a running Electron app via CDP (--remote-debugging-port)
64
+ launch Launch a fresh Electron instance with full main-process access
65
+
66
+ Documentation: https://github.com/teapotznet/electron-mcp`);
67
+ process.exit(0);
68
+ }
69
+ const { server, session } = createServer();
70
+ let shuttingDown = false;
71
+ async function cleanup() {
72
+ if (shuttingDown)
73
+ return;
74
+ shuttingDown = true;
75
+ await session.cleanup();
76
+ }
77
+ process.on("SIGTERM", () => void cleanup().finally(() => process.exit(143)));
78
+ process.on("SIGINT", () => void cleanup().finally(() => process.exit(130)));
79
+ const transport = new StdioServerTransport();
80
+ server.connect(transport).catch(console.error);
81
+ }
@@ -0,0 +1,4 @@
1
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2
+ export declare function toolOk(text: string): CallToolResult;
3
+ export declare function toolError(text: string): CallToolResult;
4
+ export declare function toolImage(base64: string): CallToolResult;
@@ -0,0 +1,22 @@
1
+ export function toolOk(text) {
2
+ return {
3
+ content: [{ type: "text", text }],
4
+ };
5
+ }
6
+ export function toolError(text) {
7
+ return {
8
+ content: [{ type: "text", text }],
9
+ isError: true,
10
+ };
11
+ }
12
+ export function toolImage(base64) {
13
+ return {
14
+ content: [
15
+ {
16
+ type: "image",
17
+ data: base64,
18
+ mimeType: "image/png",
19
+ },
20
+ ],
21
+ };
22
+ }
@@ -0,0 +1,19 @@
1
+ import { type ElectronApplication, type Page } from "playwright";
2
+ import type { ConnectionMode, LaunchOptions } from "./types.js";
3
+ export declare class SessionController {
4
+ private electronApp;
5
+ private cdpBrowser;
6
+ private page;
7
+ private mode;
8
+ private mutex;
9
+ requirePage(): Page;
10
+ requireElectronApp(): ElectronApplication;
11
+ get connectionMode(): ConnectionMode;
12
+ get isConnected(): boolean;
13
+ private withMutex;
14
+ connectCdp(port: number): Promise<string>;
15
+ launch(opts: LaunchOptions): Promise<string>;
16
+ disconnect(): Promise<void>;
17
+ close(): Promise<void>;
18
+ cleanup(): Promise<void>;
19
+ }
@@ -0,0 +1,145 @@
1
+ import { _electron, chromium, } from "playwright";
2
+ import { NotConnectedError, NotLaunchedError } from "../errors.js";
3
+ const DEFAULT_TIMEOUT_MS = Number(process.env.ELECTRON_MCP_TIMEOUT_MS ?? 30_000);
4
+ export class SessionController {
5
+ electronApp = null;
6
+ cdpBrowser = null;
7
+ page = null;
8
+ mode = null;
9
+ mutex = Promise.resolve();
10
+ // ----- Guards -----
11
+ requirePage() {
12
+ if (!this.page)
13
+ throw new NotConnectedError();
14
+ return this.page;
15
+ }
16
+ requireElectronApp() {
17
+ if (!this.electronApp)
18
+ throw new NotLaunchedError();
19
+ return this.electronApp;
20
+ }
21
+ get connectionMode() {
22
+ return this.mode;
23
+ }
24
+ get isConnected() {
25
+ return this.page !== null;
26
+ }
27
+ // ----- Mutex for state-mutating operations -----
28
+ withMutex(fn) {
29
+ const result = this.mutex.then(fn);
30
+ this.mutex = result.then(() => { }, () => { });
31
+ return result;
32
+ }
33
+ // ----- Connection lifecycle -----
34
+ async connectCdp(port) {
35
+ return this.withMutex(async () => {
36
+ if (this.page) {
37
+ throw new Error("Already connected. Disconnect first.");
38
+ }
39
+ const browser = await chromium.connectOverCDP(`http://localhost:${port}`);
40
+ try {
41
+ browser.on("disconnected", () => {
42
+ this.cdpBrowser = null;
43
+ this.page = null;
44
+ this.mode = null;
45
+ });
46
+ const contexts = browser.contexts();
47
+ if (contexts.length === 0) {
48
+ throw new Error("No browser contexts found.");
49
+ }
50
+ const pages = contexts[0].pages();
51
+ if (pages.length === 0) {
52
+ throw new Error("No pages found.");
53
+ }
54
+ this.cdpBrowser = browser;
55
+ this.page = pages[0];
56
+ this.page.setDefaultTimeout(DEFAULT_TIMEOUT_MS);
57
+ this.page.setDefaultNavigationTimeout(DEFAULT_TIMEOUT_MS);
58
+ this.mode = "cdp";
59
+ return await this.page.title();
60
+ }
61
+ catch (error) {
62
+ await browser.close().catch(() => { });
63
+ throw error;
64
+ }
65
+ });
66
+ }
67
+ async launch(opts) {
68
+ return this.withMutex(async () => {
69
+ if (this.page) {
70
+ throw new Error("Already connected. Disconnect/close first.");
71
+ }
72
+ const launchArgs = [];
73
+ if (process.platform === "linux") {
74
+ const ozone = process.env.ELECTRON_OZONE_PLATFORM ??
75
+ (process.env.WAYLAND_DISPLAY ? "wayland" : "x11");
76
+ launchArgs.push(`--ozone-platform=${ozone}`);
77
+ }
78
+ if (opts.headless) {
79
+ launchArgs.push("--headless=new", "--disable-gpu");
80
+ }
81
+ launchArgs.push(opts.appPath);
82
+ const app = await _electron.launch({
83
+ args: launchArgs,
84
+ env: { ...process.env, ...opts.env, TEST_MODE: "true" },
85
+ });
86
+ try {
87
+ const page = await app.firstWindow();
88
+ page.setDefaultTimeout(DEFAULT_TIMEOUT_MS);
89
+ page.setDefaultNavigationTimeout(DEFAULT_TIMEOUT_MS);
90
+ await page.waitForLoadState("domcontentloaded");
91
+ this.electronApp = app;
92
+ this.page = page;
93
+ this.mode = "electron";
94
+ return await page.title();
95
+ }
96
+ catch (error) {
97
+ await app.close().catch(() => { });
98
+ throw error;
99
+ }
100
+ });
101
+ }
102
+ async disconnect() {
103
+ return this.withMutex(async () => {
104
+ if (this.cdpBrowser) {
105
+ await this.cdpBrowser.close();
106
+ this.cdpBrowser = null;
107
+ this.page = null;
108
+ this.mode = null;
109
+ }
110
+ });
111
+ }
112
+ async close() {
113
+ return this.withMutex(async () => {
114
+ if (this.mode === "cdp") {
115
+ throw new Error("Cannot close CDP connection. Use disconnect instead (app keeps running).");
116
+ }
117
+ if (this.electronApp) {
118
+ await this.electronApp.close();
119
+ this.electronApp = null;
120
+ this.page = null;
121
+ this.mode = null;
122
+ }
123
+ });
124
+ }
125
+ async cleanup() {
126
+ try {
127
+ if (this.electronApp)
128
+ await this.electronApp.close();
129
+ }
130
+ catch {
131
+ /* best-effort */
132
+ }
133
+ try {
134
+ if (this.cdpBrowser)
135
+ await this.cdpBrowser.close();
136
+ }
137
+ catch {
138
+ /* best-effort */
139
+ }
140
+ this.electronApp = null;
141
+ this.cdpBrowser = null;
142
+ this.page = null;
143
+ this.mode = null;
144
+ }
145
+ }
@@ -0,0 +1,6 @@
1
+ export type ConnectionMode = "electron" | "cdp" | null;
2
+ export interface LaunchOptions {
3
+ appPath: string;
4
+ env: Record<string, string>;
5
+ headless: boolean;
6
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ import type { ToolSpec } from "./registry.js";
2
+ export declare const connectionTools: ToolSpec[];