-
Notifications
You must be signed in to change notification settings - Fork 968
Superset Electron MCP built on Puppeteer #1481
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| { | ||
| "name": "@superset/desktop-mcp", | ||
| "version": "0.1.0", | ||
| "private": true, | ||
| "type": "module", | ||
| "bin": { | ||
| "desktop-mcp": "./src/bin.ts" | ||
| }, | ||
| "exports": { | ||
| ".": { | ||
| "types": "./src/index.ts", | ||
| "default": "./src/index.ts" | ||
| } | ||
| }, | ||
| "scripts": { | ||
| "typecheck": "tsc --noEmit --emitDeclarationOnly false" | ||
| }, | ||
| "dependencies": { | ||
| "@modelcontextprotocol/sdk": "^1.25.3", | ||
| "puppeteer-core": "^24.37.3", | ||
| "zod": "^4.3.5" | ||
| }, | ||
| "devDependencies": { | ||
| "@superset/typescript": "workspace:*", | ||
| "@types/node": "^24.9.1", | ||
| "typescript": "^5.9.3" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| #!/usr/bin/env node | ||
| import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; | ||
| import { createMcpServer } from "./mcp/index.js"; | ||
|
|
||
| const server = createMcpServer(); | ||
| const transport = new StdioServerTransport(); | ||
| await server.connect(transport); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| export { createMcpServer } from "./mcp/index.js"; | ||
| export type { | ||
| ClickResponse, | ||
| ConsoleLogEntry, | ||
| ConsoleLogsResponse, | ||
| DomElement, | ||
| DomResponse, | ||
| EvaluateResponse, | ||
| NavigateResponse, | ||
| ScreenshotResponse, | ||
| TypeResponse, | ||
| WindowInfoResponse, | ||
| } from "./zod.js"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| import puppeteer, { type Browser, type Page } from "puppeteer-core"; | ||
| import { ConsoleCapture } from "../console-capture/index.js"; | ||
| import { FocusLock } from "../focus-lock/index.js"; | ||
|
|
||
| const CDP_PORT = Number(process.env.DESKTOP_AUTOMATION_PORT) || 9223; | ||
|
|
||
| /** | ||
| * Manages a CDP connection to the Electron renderer via puppeteer-core. | ||
| * | ||
| * - Lazy connect on first tool call (Electron might not be running yet) | ||
| * - Auto-reconnect if connection drops (Electron restart/hot reload) | ||
| * - Re-injects focus lock and console capture on reconnect | ||
| */ | ||
| export class ConnectionManager { | ||
| private browser: Browser | null = null; | ||
| private page: Page | null = null; | ||
|
|
||
| readonly consoleCapture = new ConsoleCapture(); | ||
| readonly focusLock = new FocusLock(); | ||
|
|
||
| async getPage(): Promise<Page> { | ||
| if (this.page && this.browser?.connected) { | ||
| await this.focusLock.inject(this.page); | ||
| return this.page; | ||
| } | ||
| return this.connect(); | ||
| } | ||
|
Comment on lines
+21
to
+27
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Race condition: concurrent If two MCP tool calls arrive simultaneously while A simple connection promise guard resolves this: Proposed fix export class ConnectionManager {
private browser: Browser | null = null;
private page: Page | null = null;
+ private connecting: Promise<Page> | null = null;
readonly consoleCapture = new ConsoleCapture();
readonly focusLock = new FocusLock();
async getPage(): Promise<Page> {
if (this.page && this.browser?.connected) {
await this.focusLock.inject(this.page);
return this.page;
}
- return this.connect();
+ if (!this.connecting) {
+ this.connecting = this.connect().finally(() => {
+ this.connecting = null;
+ });
+ }
+ return this.connecting;
}🤖 Prompt for AI Agents |
||
|
|
||
| private async connect(): Promise<Page> { | ||
| this.browser = await puppeteer.connect({ | ||
| browserURL: `http://127.0.0.1:${CDP_PORT}`, | ||
| protocolTimeout: 60_000, | ||
| defaultViewport: null, | ||
| }); | ||
| const pages = await this.browser.pages(); | ||
|
|
||
| // Find the actual app page, skipping chrome-extension:// background pages | ||
| const appPage = pages.find( | ||
| (p) => !p.url().startsWith("chrome-extension://"), | ||
| ); | ||
| if (!appPage) { | ||
| throw new Error( | ||
| `[desktop-mcp] No app pages found via CDP (found ${pages.length} pages, all extensions)`, | ||
| ); | ||
| } | ||
| this.page = appPage; | ||
|
|
||
| this.consoleCapture.attach(this.page); | ||
| this.focusLock.attach(this.page); | ||
| await this.focusLock.inject(this.page); | ||
|
|
||
| this.browser.on("disconnected", () => { | ||
| this.browser = null; | ||
| this.page = null; | ||
| }); | ||
|
|
||
| return this.page; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { ConnectionManager } from "./connection-manager.js"; |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,53 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import type { ConsoleMessage, Page } from "puppeteer-core"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import type { ConsoleLogEntry } from "../../zod.js"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const LEVEL_MAP: Record<string, number> = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| verbose: 0, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| debug: 0, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| info: 1, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| log: 1, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| warning: 2, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| warn: 2, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| error: 3, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export class ConsoleCapture { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private logs: ConsoleLogEntry[] = []; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private maxSize = 500; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| attach(page: Page) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| page.on("console", (msg: ConsoleMessage) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const level = LEVEL_MAP[msg.type()] ?? 1; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const location = msg.location(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.logs.push({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| level, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| message: msg.text(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| source: location.url ?? "", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| line: location.lineNumber ?? 0, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| timestamp: Date.now(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (this.logs.length > this.maxSize) this.logs.shift(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| getLogs({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| level, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| limit, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| level?: number; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| limit?: number; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }): ConsoleLogEntry[] { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let filtered = this.logs; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (level !== undefined) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| filtered = filtered.filter((log) => log.level === level); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (limit !== undefined) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| filtered = filtered.slice(-limit); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return filtered; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+33
to
+48
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
On Line 40, Proposed fix getLogs({
level,
limit,
}: {
level?: number;
limit?: number;
}): ConsoleLogEntry[] {
- let filtered = this.logs;
+ let filtered: ConsoleLogEntry[] = this.logs;
if (level !== undefined) {
filtered = filtered.filter((log) => log.level === level);
}
if (limit !== undefined) {
filtered = filtered.slice(-limit);
}
+ // Return a copy if no filter was applied to avoid leaking internal state
+ return filtered === this.logs ? [...filtered] : filtered;
- return filtered;
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| clear() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.logs = []; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { ConsoleCapture } from "./console-capture.js"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| /** | ||
| * JavaScript source to inject into the renderer via page.evaluate(). | ||
| * Walks the DOM and returns a flat list of visible elements with metadata. | ||
| */ | ||
| export const DOM_INSPECTOR_SCRIPT = `function inspectDom({ selector, interactiveOnly }) { | ||
| const INTERACTIVE_TAGS = new Set([ | ||
| 'A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'DETAILS', 'SUMMARY' | ||
| ]); | ||
| const INTERACTIVE_ROLES = new Set([ | ||
| 'button', 'link', 'checkbox', 'radio', 'tab', 'menuitem', | ||
| 'switch', 'textbox', 'combobox', 'listbox', 'option', 'slider', 'spinbutton' | ||
| ]); | ||
|
|
||
| const root = selector ? document.querySelector(selector) : document.body; | ||
| if (!root) return []; | ||
|
|
||
| const elements = []; | ||
| const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); | ||
|
|
||
| let node = walker.currentNode; | ||
| while (node) { | ||
| if (node instanceof HTMLElement) { | ||
| const rect = node.getBoundingClientRect(); | ||
| const style = window.getComputedStyle(node); | ||
|
|
||
| // Skip invisible elements | ||
| if (rect.width === 0 && rect.height === 0) { node = walker.nextNode(); continue; } | ||
| if (style.display === 'none' || style.visibility === 'hidden') { node = walker.nextNode(); continue; } | ||
| if (parseFloat(style.opacity) === 0) { node = walker.nextNode(); continue; } | ||
|
|
||
| const tag = node.tagName.toLowerCase(); | ||
| const role = node.getAttribute('role') || undefined; | ||
| const testId = node.getAttribute('data-testid') || undefined; | ||
| const isInteractive = INTERACTIVE_TAGS.has(node.tagName) | ||
| || INTERACTIVE_ROLES.has(role || '') | ||
| || node.hasAttribute('onclick') | ||
| || node.getAttribute('tabindex') !== null; | ||
|
|
||
| if (interactiveOnly && !isInteractive) { node = walker.nextNode(); continue; } | ||
|
|
||
| // Build a unique CSS selector | ||
| let cssSelector; | ||
| if (node.id) { | ||
| cssSelector = '#' + CSS.escape(node.id); | ||
| } else if (testId) { | ||
| cssSelector = '[data-testid="' + testId + '"]'; | ||
| } else { | ||
|
Comment on lines
+45
to
+47
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If Proposed fix- cssSelector = '[data-testid="' + testId + '"]';
+ cssSelector = '[data-testid="' + CSS.escape(testId) + '"]';🤖 Prompt for AI Agents |
||
| const path = []; | ||
| let el = node; | ||
| while (el && el !== document.body) { | ||
| const parent = el.parentElement; | ||
| if (!parent) break; | ||
| const siblings = Array.from(parent.children).filter(s => s.tagName === el.tagName); | ||
| if (siblings.length > 1) { | ||
| const idx = siblings.indexOf(el) + 1; | ||
| path.unshift(el.tagName.toLowerCase() + ':nth-of-type(' + idx + ')'); | ||
| } else { | ||
| path.unshift(el.tagName.toLowerCase()); | ||
| } | ||
| el = parent; | ||
| } | ||
| cssSelector = path.join(' > '); | ||
| } | ||
|
|
||
| const text = (node.textContent || '').trim().slice(0, 200); | ||
|
|
||
| elements.push({ | ||
| tag, | ||
| id: node.id || undefined, | ||
| classes: Array.from(node.classList), | ||
| text, | ||
| selector: cssSelector, | ||
| bounds: { | ||
| x: Math.round(rect.x), | ||
| y: Math.round(rect.y), | ||
| width: Math.round(rect.width), | ||
| height: Math.round(rect.height), | ||
| }, | ||
| role, | ||
| testId, | ||
| interactive: isInteractive, | ||
| disabled: node.hasAttribute('disabled'), | ||
| checked: 'checked' in node ? node.checked : undefined, | ||
| focused: document.activeElement === node, | ||
| visible: true, | ||
| }); | ||
| } | ||
| node = walker.nextNode(); | ||
| } | ||
|
|
||
| return elements; | ||
| }`; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { DOM_INSPECTOR_SCRIPT } from "./dom-inspector.js"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use the parsed
envobject instead of rawprocess.env.Line 88 reads
process.env.DESKTOP_AUTOMATION_PORTdirectly, bypassing the Zod-validatedenvalready imported on line 7 and used elsewhere in this file. This duplicates the default9223(already inenv.shared.ts) and skips coercion/validation. Also, the comment says "playwright-core" but this PR uses puppeteer-core.Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents