truepx-mcp 0.1.0

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.
Files changed (3) hide show
  1. package/README.md +60 -0
  2. package/package.json +28 -0
  3. package/server.mjs +846 -0
package/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # truepx-mcp
2
+
3
+ Local bridge between MCP-compatible AI agents (Claude Code, Cursor, Windsurf,
4
+ Cline, Continue) and the TruePx browser extension.
5
+
6
+ ```
7
+ agent (MCP stdio) ⇄ truepx-mcp ⇄ ws://127.0.0.1:47830 ⇄ extension
8
+ ```
9
+
10
+ ## Connect
11
+
12
+ 1. Add the bridge to your agent. Claude Code:
13
+
14
+ ```sh
15
+ claude mcp add truepx -- npx -y truepx-mcp
16
+ ```
17
+
18
+ Other clients (`mcp.json`):
19
+
20
+ ```json
21
+ { "mcpServers": { "truepx": { "command": "npx", "args": ["-y", "truepx-mcp"] } } }
22
+ ```
23
+
24
+ 2. Restart the AI client, then open TruePx Settings → AI agents → Connect.
25
+ 3. Turn TruePx on in the tabs the agent should see, then ask it to list tabs.
26
+
27
+ Port override (e.g. a second bridge): `TRUEPX_MCP_PORT=18732 npx -y truepx-mcp`.
28
+ From a checkout: `node packages/mcp-bridge/server.mjs`.
29
+
30
+ Rules: the agent only sees tabs where TruePx is on (`tabs_list`); only one AI
31
+ client drives the extension at a time (extra clients relay through the
32
+ first instead of fighting over the port).
33
+
34
+ ## Protocol
35
+
36
+ - stdin/stdout: MCP over stdio via `@modelcontextprotocol/sdk`
37
+ (`initialize`, `tools/list`, `tools/call`).
38
+ - Bridge → extension: `{"id","type","payload"}` (`type` = tool name).
39
+ First frame per socket is a `hello` (`{id:"__handshake__", type:"handshake",
40
+ version, client}`) carrying the bridge version + agent identity.
41
+ - Extension → bridge: `{"id","payload"}` where payload is data,
42
+ `{error}`, or `{success:false,message}`; screenshots arrive as
43
+ `{ok,dataUrl,note?}` and are re-emitted as MCP image blocks.
44
+ - Tool catalog mirrors `src/mcp/catalog.ts` (48 tools). Every tab-bound
45
+ tool requires a numeric `tabId` from `tabs_list`; element ids are
46
+ per-tab/per-viewport strings from the element tools.
47
+ - Multi-client: the first bridge to bind the port becomes leader; later
48
+ instances join as followers and relay (`relay` / `relay_result`).
49
+ - Security: the port binds `127.0.0.1` and the handshake gates on
50
+ `Origin` — absent (node followers) or `chrome-extension://` pass;
51
+ `http(s)` / `file:` / `null` origins are rejected, since WebSocket
52
+ handshakes ignore CORS and any local page could otherwise reach the
53
+ port. `css_fetch_stylesheet` additionally blocks non-http(s) URLs and
54
+ localhost/private IPs.
55
+
56
+ ## Smoke test
57
+
58
+ ```sh
59
+ node packages/mcp-bridge/smoke.mjs # also wired as tests/e2e/mcp-smoke.mjs
60
+ ```
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "truepx-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Local MCP stdio bridge for TruePx (connects AI agents to the extension over a localhost WebSocket)",
5
+ "type": "module",
6
+ "bin": {
7
+ "truepx-mcp": "./server.mjs"
8
+ },
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "license": "ISC",
13
+ "dependencies": {
14
+ "@modelcontextprotocol/sdk": "1.20.0",
15
+ "ws": "8.18.0"
16
+ },
17
+ "files": [
18
+ "server.mjs",
19
+ "README.md"
20
+ ],
21
+ "keywords": [
22
+ "mcp",
23
+ "truepx",
24
+ "chrome-extension",
25
+ "css",
26
+ "ai-agent"
27
+ ]
28
+ }
package/server.mjs ADDED
@@ -0,0 +1,846 @@
1
+ #!/usr/bin/env node
2
+ // truepx-mcp — MCP stdio ↔ extension WebSocket bridge.
3
+ //
4
+ // TruePx MCP protocol (original implementation):
5
+ //
6
+ // agent (MCP stdio) ⇄ this process ⇄ ws://127.0.0.1:47830 ⇄ extension
7
+ //
8
+ // Wire format bridge → extension: {"id","type","payload"} (type = tool)
9
+ // Wire format extension → bridge: {"id","payload"} (payload = data
10
+ // | {error} | {success:false,message}); screenshots: {ok,dataUrl,note?}
11
+ // Control frames: handshake (version + agent identity), agent_info,
12
+ // relay / relay_result (multi-client), __takeover__ / __follower__ (legacy).
13
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
14
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
15
+ import {
16
+ CallToolRequestSchema,
17
+ ListToolsRequestSchema,
18
+ } from '@modelcontextprotocol/sdk/types.js';
19
+ import { WebSocketServer, WebSocket } from 'ws';
20
+
21
+ const BRIDGE_VERSION = '0.1.0';
22
+ const args = process.argv.slice(2);
23
+ const portIdx = args.indexOf('--port');
24
+ const WS_PORT =
25
+ Number(process.env.TRUEPX_MCP_PORT) ||
26
+ (portIdx >= 0 ? Number(args[portIdx + 1]) || 47830 : 47830);
27
+ const BIND_HOST = '127.0.0.1';
28
+
29
+ const FORWARD_TIMEOUT_MS = 15000;
30
+ const FORWARD_TIMEOUT_LONG_MS = 45000;
31
+ const LONG_TIMEOUT_TOOLS = new Set([
32
+ 'capture_screenshot',
33
+ 'viewports_open',
34
+ 'viewports_close',
35
+ 'viewport_update',
36
+ 'viewports_set',
37
+ 'viewport_add',
38
+ 'breakpoints_overlay',
39
+ 'sweep_breakpoints',
40
+ ]);
41
+ const HEARTBEAT_MS = 10000;
42
+ const RELAY_SLACK_MS = 5000;
43
+ const RELAY_TIMEOUT_MAX_MS = 120000;
44
+ const HELLO_WAIT_MS = 1500;
45
+ const OPEN_WAIT_MS = 3000;
46
+ const ACQUIRE_RETRY_MS = 500;
47
+ const ACQUIRE_RETRY_MAX_MS = 2000;
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Tool catalog. Every tab-bound tool requires a numeric tabId from
51
+ // tabs_list, except css_fetch_stylesheet which takes a stylesheet URL.
52
+ // ---------------------------------------------------------------------------
53
+ const TABID = {
54
+ tabId: { type: 'number', description: 'Id of a tab where TruePx is on, from tabs_list.' },
55
+ };
56
+ const ELID = {
57
+ elementId: { type: 'string', description: 'Element id from an earlier element tool.' },
58
+ };
59
+ const VIEWPORT_ID = {
60
+ viewportId: {
61
+ type: 'number',
62
+ description:
63
+ 'Responsive mode only: viewport from viewport_state that should answer. Element ids are per-viewport — only pass ids that came from this viewport.',
64
+ },
65
+ };
66
+ const RULE_REF = {
67
+ type: 'object',
68
+ description:
69
+ 'Rule handle from css_matched_rules or css_create_rule. Pass back exactly as received; hand-built refs are rejected.',
70
+ properties: {
71
+ index: { type: 'number' },
72
+ selector: { type: 'string' },
73
+ wrappers: { type: 'array', items: { type: 'string' } },
74
+ stylesheet: { type: 'string' },
75
+ occ: { type: 'number' },
76
+ own: { type: 'string' },
77
+ },
78
+ additionalProperties: false,
79
+ };
80
+
81
+ const req = (props, required = ['tabId']) => ({
82
+ type: 'object',
83
+ properties: props,
84
+ required,
85
+ additionalProperties: false,
86
+ });
87
+
88
+ const TOOLS = [
89
+ { name: 'ping', description: 'Health check, forwarded to the extension. Replies with extension + bridge versions.', inputSchema: req({}, []) },
90
+ { name: 'tabs_list', description: 'Tabs where TruePx is currently on. Returns { tabId, url, title } per tab; other tabs are invisible.', inputSchema: req({}, []) },
91
+ { name: 'tab_focus', description: 'Bring a tab to the foreground so the user can watch edits happen.', inputSchema: req({ ...TABID }) },
92
+ { name: 'page_info', description: 'Page metadata: url, title, lang, description, OG tags, h1, viewport meta, page size, scroll, devicePixelRatio.', inputSchema: req({ ...TABID }) },
93
+ { name: 'selection_get', description: 'The element the user has locked in the TruePx overlay (id, tag, selector, classes, rect, computed subset, text). Starting point for "this element".', inputSchema: req({ ...TABID }) },
94
+ { name: 'element_inspect', description: 'Uniform summary for one element: id, tag, selector, classes, rect, computed CSS subset, direct text.', inputSchema: req({ ...TABID, ...ELID, ...VIEWPORT_ID }, ['tabId', 'elementId']) },
95
+ { name: 'elements_inspect', description: 'Batch element_inspect for up to 50 ids; unresolvable ids are reported separately.', inputSchema: req({ tabId: TABID.tabId, elementIds: { type: 'array', items: { type: 'string' } }, ...VIEWPORT_ID }, ['tabId', 'elementIds']) },
96
+ { name: 'dom_outline', description: 'Depth-limited subtree walk from an element (default body). Keeps layout exploration payloads small.', inputSchema: { type: 'object', properties: { ...TABID, elementId: { type: 'string', description: "Root element id; 'body' targets document.body. Omit for current selection." }, maxDepth: { type: 'number', description: 'Max depth (default 8).' }, maxChildren: { type: 'number', description: 'Max children per node (default 30).' }, ...VIEWPORT_ID }, additionalProperties: false } },
97
+ { name: 'dom_query', description: 'CSS selector search, up to 200 matches (id, tag, classes, rect, text, attrs). Optional rootId scopes the search.', inputSchema: { type: 'object', properties: { ...TABID, selector: { type: 'string' }, limit: { type: 'number' }, rootId: { type: 'string' }, ...VIEWPORT_ID }, required: ['tabId', 'selector'], additionalProperties: false } },
98
+ { name: 'css_matched_rules', description: 'Every CSS rule matching an element, highest specificity first, plus inline style text. Each rule carries a ruleHandle for css_set_inline.', inputSchema: req({ ...TABID, ...ELID, ...VIEWPORT_ID }, ['tabId', 'elementId']) },
99
+ { name: 'edits_list', description: 'All tracked edits on the tab (CSS diffs with revert ids, class/HTML/text edits). Call first to review prior work.', inputSchema: req({ ...TABID }) },
100
+ { name: 'capture_screenshot', description: 'Viewport PNG with the TruePx highlight overlay (dock excluded), returned as an image block for vision models.', inputSchema: req({ ...TABID }) },
101
+ { name: 'css_set_inline', description: "Merge declarations into a rule (ruleHandle) or an element's inline style (elementId). Empty value deletes a property; values may carry !important.", inputSchema: { type: 'object', properties: { ...TABID, styles: { type: 'object', description: "CSS prop → value, e.g. { color: 'red' }." }, elementId: { type: 'string', description: 'Required without ruleHandle; ignored with ruleHandle.' }, ruleHandle: RULE_REF }, required: ['tabId', 'styles'], additionalProperties: false } },
102
+ { name: 'css_create_rule', description: 'Add a real cascade rule (no !important needed). Optional at-rule wrappers outermost-first, optional seed declarations. Returns a ruleHandle.', inputSchema: { type: 'object', properties: { ...TABID, selector: { type: 'string' }, wrappers: { type: 'array', items: { type: 'string' }, description: 'At-rule strings, e.g. ["@media (min-width: 768px)"].' }, styles: { type: 'object' }, elementId: { type: 'string', description: 'Inject into this element\'s shadow root instead of the document.' } }, required: ['tabId', 'selector'], additionalProperties: false } },
103
+ { name: 'css_set_selector', description: "Retarget a rule's selector. Returns a refreshed ruleHandle; nesting selectors are read-only.", inputSchema: req({ ...TABID, ruleHandle: RULE_REF, selector: { type: 'string', description: 'New selector text.' } }, ['tabId', 'ruleHandle', 'selector']) },
104
+ { name: 'edits_revert', description: 'Revert one tracked CSS change by its changeId (from an edit response or edits_list).', inputSchema: req({ ...TABID, changeId: { type: 'string' } }, ['tabId', 'changeId']) },
105
+ { name: 'edits_revert_all', description: "Revert every tracked change on the tab (rules, inline styles, classes, HTML/text). Same coverage as the panel's revert-all.", inputSchema: req({ ...TABID }) },
106
+ { name: 'dom_set_text', description: "Replace an element's text (drops HTML children). Undoable.", inputSchema: req({ ...TABID, ...ELID, text: { type: 'string', description: 'New plain text.' } }, ['tabId', 'elementId', 'text']) },
107
+ { name: 'dom_set_html', description: 'Replace inner HTML. Strips script/style/iframe/form/input/link/meta and inline handlers.', inputSchema: req({ ...TABID, ...ELID, html: { type: 'string' } }, ['tabId', 'elementId', 'html']) },
108
+ { name: 'dom_insert_html', description: "Insert sanitized HTML relative to an element: before | after | prepend | append.", inputSchema: req({ ...TABID, ...ELID, html: { type: 'string' }, position: { type: 'string', enum: ['before', 'after', 'prepend', 'append'] } }, ['tabId', 'elementId', 'html', 'position']) },
109
+ { name: 'element_set_classes', description: 'Replace the full class list (space-separated; empty clears). Undoable.', inputSchema: req({ ...TABID, ...ELID, classes: { type: 'string' } }, ['tabId', 'elementId', 'classes']) },
110
+ { name: 'selection_set', description: 'Highlight an element in the TruePx overlay, scroll it into view, update the panel.', inputSchema: req({ ...TABID, ...ELID }, ['tabId', 'elementId']) },
111
+ { name: 'dom_remove', description: 'Soft-delete (hidden, reported removed, restorable). Undoable.', inputSchema: req({ ...TABID, ...ELID }, ['tabId', 'elementId']) },
112
+ { name: 'dom_duplicate', description: 'Clone after the original with a fresh id. Undoable.', inputSchema: req({ ...TABID, ...ELID }, ['tabId', 'elementId']) },
113
+ { name: 'page_breakpoints', description: 'Declared @media breakpoints ascending; em/rem normalized against root font size.', inputSchema: req({ ...TABID }) },
114
+ { name: 'find_overflow', description: 'Elements pushing the page past the viewport (horizontal-scroll culprits), biggest overflow first with fix hints.', inputSchema: req({ ...TABID }) },
115
+ { name: 'fix_contrast', description: 'Nudge an element subtree foreground colors to pass a contrast target (default 4.5). Pass preview:true to suggest without applying.', inputSchema: { type: 'object', properties: { ...TABID, ...ELID, target: { type: 'number' }, preview: { type: 'boolean' } }, required: ['tabId', 'elementId'], additionalProperties: false } },
116
+ { name: 'simulate_vision', description: 'Simulate color-vision deficiency page-wide so designs can be judged as affected users see them. Mode off disables.', inputSchema: { type: 'object', properties: { ...TABID, mode: { type: 'string', enum: ['protanopia', 'deuteranopia', 'tritanopia', 'achromatopsia', 'off'] } }, required: ['tabId', 'mode'], additionalProperties: false } },
117
+ { name: 'mockup_overlay', description: 'Pin a design mockup over the live page to compare implementation vs design. set takes a data:image URL (+ optional file name; "@2x" in it picks @2x size); config adjusts opacity/blend/position/scale (fit = page width, 1x = actual pixels, 2x = half size for Retina exports; set picks 1x or 2x automatically); difference blend is an onion-skin diff.', inputSchema: { type: 'object', properties: { ...TABID, action: { type: 'string', enum: ['set', 'config', 'clear', 'state'] }, dataUrl: { type: 'string' }, name: { type: 'string' }, opacity: { type: 'number' }, blend: { type: 'string' }, x: { type: 'number' }, y: { type: 'number' }, visible: { type: 'boolean' }, scale: { type: 'string', enum: ['fit', '1x', '2x'] } }, required: ['tabId', 'action'], additionalProperties: false } },
118
+ { name: 'sweep_breakpoints', description: 'Load the page at probe widths in hidden frames and report where layout breaks or heals. Optional widths array (max 9, 200–3840). Slow — allow up to ~40s.', inputSchema: { type: 'object', properties: { ...TABID, widths: { type: 'array', items: { type: 'number' } } }, required: ['tabId'], additionalProperties: false } },
119
+ { name: 'find_inconsistencies', description: 'Scan the page for visual drift: button style clusters, heading variation, off-grid spacing, near-miss alignment.', inputSchema: req({ ...TABID }) },
120
+ { name: 'list_pins', description: 'Designer pins: id, selector, note, status, live rect. Work open pins top to bottom, then resolve each.', inputSchema: req({ ...TABID }) },
121
+ { name: 'add_pin', description: 'Drop a pin on an element with a note (for batch review comments).', inputSchema: { type: 'object', properties: { ...TABID, ...ELID, selector: { type: 'string' }, note: { type: 'string' } }, required: ['tabId', 'note'], additionalProperties: false } },
122
+ { name: 'resolve_pin', description: 'Close a pin by id once acted on (resolved:false reopens).', inputSchema: { type: 'object', properties: { ...TABID, pinId: { type: 'string' }, resolved: { type: 'boolean' } }, required: ['tabId', 'pinId'], additionalProperties: false } },
123
+ { name: 'font_forensics', description: 'Which font in the stack actually rendered, plus faux bold/italic detection.', inputSchema: req({ ...TABID, ...ELID }, ['tabId', 'elementId']) },
124
+ { name: 'find_heavy_images', description: 'Images costing more than they show: oversized, unsized, eager below the fold, legacy formats.', inputSchema: req({ ...TABID }) },
125
+ { name: 'capture_states', description: 'Screenshots of one element in default/hover/focus/active states, returned as image blocks. Slow — allow ~30s.', inputSchema: req({ ...TABID, ...ELID }, ['tabId', 'elementId']) },
126
+ { name: 'viewports_open', description: 'Wrap the page in resizable frames. Discards pending CSS edits — call edits_list first.', inputSchema: req({ ...TABID }) },
127
+ { name: 'viewports_close', description: 'Reload at natural viewport. Discards edits made inside responsive mode.', inputSchema: req({ ...TABID }) },
128
+ { name: 'viewport_update', description: 'Resize one viewport (200–3840 × 300–3840). Without viewportId resizes the first.', inputSchema: { type: 'object', properties: { ...TABID, width: { type: 'number' }, height: { type: 'number' }, viewportId: { type: 'number' } }, required: ['tabId', 'width', 'height'], additionalProperties: false } },
129
+ { name: 'viewport_state', description: 'Canvas state: viewports in on-screen order (id, size, name, color scheme), zoom, breakpointsMode, scroll sync. Empty outside responsive mode.', inputSchema: req({ ...TABID }) },
130
+ { name: 'viewports_set', description: 'Replace the whole viewport set left-to-right in one rebuild (prefer over repeated viewport_add). Exits breakpoints mode.', inputSchema: { type: 'object', properties: { ...TABID, viewports: { type: 'array', items: { type: 'object', properties: { width: { type: 'number' }, height: { type: 'number' }, name: { type: 'string' } }, required: ['width', 'height'], additionalProperties: false } } }, required: ['tabId', 'viewports'], additionalProperties: false } },
131
+ { name: 'viewport_add', description: 'Add one frame (inherits current edits for comparison). Omit size to copy the last viewport.', inputSchema: { type: 'object', properties: { ...TABID, width: { type: 'number' }, height: { type: 'number' }, name: { type: 'string' } }, required: ['tabId'], additionalProperties: false } },
132
+ { name: 'viewport_remove', description: 'Remove one frame by id. Removing the last leaves an empty canvas. Unavailable in breakpoints mode.', inputSchema: req({ ...TABID, viewportId: { type: 'number' } }, ['tabId', 'viewportId']) },
133
+ { name: 'viewports_zoom', description: 'Canvas scale 0.25–2 (visual only; frames still report declared size, media queries unaffected).', inputSchema: req({ ...TABID, zoom: { type: 'number' } }, ['tabId', 'zoom']) },
134
+ { name: 'breakpoints_overlay', description: 'One viewport per declared breakpoint. Fails when the page declares none.', inputSchema: req({ ...TABID, enabled: { type: 'boolean' } }, ['tabId', 'enabled']) },
135
+ { name: 'page_seo_audit', description: 'SEO audit (title, description, h1, robots, alt text, OG tags…) with severity + recommendations.', inputSchema: req({ ...TABID }) },
136
+ { name: 'page_meta', description: 'Meta values grouped SEO / Open Graph / Twitter. Read before social-card previews.', inputSchema: req({ ...TABID }) },
137
+ { name: 'page_weight', description: 'Byte breakdown: scripts, stylesheets, images, fonts, fetch, media, other + document size.', inputSchema: req({ ...TABID }) },
138
+ { name: 'css_variables', description: 'Every --* custom property in the cascade. Read before authoring CSS so agents reuse var(--foo).', inputSchema: req({ ...TABID }) },
139
+ { name: 'page_typography', description: 'Font family/stack/size/weight/spacing/transform inside a subtree, with using selectors.', inputSchema: req({ ...TABID, ...ELID }, ['tabId', 'elementId']) },
140
+ { name: 'css_specificity', description: 'Specificity of any selector as tuple + comparable score. Use before writing overrides.', inputSchema: req({ ...TABID, selector: { type: 'string' } }, ['tabId', 'selector']) },
141
+ { name: 'page_colors', description: 'Distinct text/background/border colors rendered in a subtree (incl. gradients, canvas samples).', inputSchema: req({ ...TABID, ...ELID }, ['tabId', 'elementId']) },
142
+ { name: 'page_assets', description: 'Images, SVGs, data URLs in a subtree with URL/content + rendered dimensions.', inputSchema: req({ ...TABID, ...ELID }, ['tabId', 'elementId']) },
143
+ { name: 'element_layout', description: 'Flex/grid breakdown (direction, justify, align, gap, templates). Null when neither.', inputSchema: req({ ...TABID, ...ELID, ...VIEWPORT_ID }, ['tabId', 'elementId']) },
144
+ { name: 'element_force_state', description: 'Force hover/focus/active/visited/focus-visible/focus-within to preview rendering; empty clears. Not in responsive mode.', inputSchema: req({ ...TABID, ...ELID, state: { type: 'string' } }, ['tabId', 'elementId', 'state']) },
145
+ { name: 'page_tech_stack', description: 'Frameworks/CMS/analytics with versions. Call first on a new task — the stack shapes suggestions.', inputSchema: req({ ...TABID }) },
146
+ { name: 'get_a11y_issues', description: 'WCAG text/icon contrast failures (AA+AAA), grouped by selector, worst first.', inputSchema: { type: 'object', properties: { ...TABID, limit: { type: 'number', description: 'Max issues (default 50).' } }, required: ['tabId'], additionalProperties: false } },
147
+ { name: 'element_tailwind', description: 'Computed styles → Tailwind v4 utilities. Pair with element_set_classes to apply.', inputSchema: req({ ...TABID, ...ELID }, ['tabId', 'elementId']) },
148
+ { name: 'css_fetch_stylesheet', description: "Raw CSS of an external stylesheet URL, fetched from the extension service worker (cross-origin reachable). HTTP(S) only; localhost/private IPs blocked.", inputSchema: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'], additionalProperties: false } },
149
+ ];
150
+
151
+ function errText(msg) {
152
+ return { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true };
153
+ }
154
+ const needTab = (a, tool) =>
155
+ typeof a?.tabId !== 'number' ? errText(`${tool} requires a numeric tabId from tabs_list.`) : null;
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // WebSocket bridge (leader/follower so parallel AI clients all keep working)
159
+ // ---------------------------------------------------------------------------
160
+ let activeSocket = null;
161
+ let mcpClientInfo = null;
162
+ let role = null;
163
+ let acquireStatus = `binding port ${WS_PORT}`;
164
+ const pending = new Map();
165
+ let nextId = 1;
166
+
167
+ function sendClientInfo() {
168
+ if (!activeSocket || activeSocket.readyState !== WebSocket.OPEN) return;
169
+ try {
170
+ activeSocket.send(
171
+ JSON.stringify({ id: '__agent_info__', type: 'agent_info', client: mcpClientInfo }),
172
+ );
173
+ } catch { /* closing; next hello carries it */ }
174
+ }
175
+
176
+ function originOf(req) {
177
+ const entry = Object.entries(req.headers).find(([n]) => n.toLowerCase() === 'origin');
178
+ const raw = Array.isArray(entry?.[1]) ? entry[1][0] : entry?.[1];
179
+ const v = typeof raw === 'string' ? raw.trim() : '';
180
+ return v === '' ? null : v;
181
+ }
182
+
183
+ function verifyClient(info, cb) {
184
+ // Binding localhost is not a trust boundary for WebSockets (no CORS), so
185
+ // gate on Origin, which page script cannot forge: absent (node followers)
186
+ // or chrome-extension:// (our offscreen/ext pages) pass; http(s)/file/null
187
+ // origins are web content and are rejected.
188
+ const origin = originOf(info.req);
189
+ if (origin === null) return cb(true);
190
+ if (/^chrome-extension:\/\//.test(origin)) return cb(true);
191
+ console.error(`[MCP] rejected bridge connection from origin ${origin}`);
192
+ return cb(false, 403, 'Forbidden: web origins cannot drive the bridge');
193
+ }
194
+
195
+ async function startWss() {
196
+ return new Promise((resolve, reject) => {
197
+ const wss = new WebSocketServer(
198
+ { host: BIND_HOST, port: WS_PORT, verifyClient },
199
+ () => resolve(wss),
200
+ );
201
+ wss.on('error', reject);
202
+ });
203
+ }
204
+
205
+ function becomeLeader(wss) {
206
+ role = { kind: 'leader', wss };
207
+ console.error(`[MCP] leader: listening on ${BIND_HOST}:${WS_PORT}`);
208
+ wss.on('error', (e) => console.error('[MCP] server error:', e.message));
209
+ const beat = setInterval(() => {
210
+ for (const sock of wss.clients) {
211
+ if (sock.isAlive === false) {
212
+ console.error('[MCP] dropping unresponsive socket');
213
+ sock.terminate();
214
+ continue;
215
+ }
216
+ sock.isAlive = false;
217
+ try { sock.ping(); } catch { /* closing */ }
218
+ }
219
+ }, HEARTBEAT_MS);
220
+ beat.unref?.();
221
+ wss.on('connection', (socket) => {
222
+ console.error('[MCP] client connected');
223
+ try {
224
+ socket.send(
225
+ JSON.stringify({ id: '__handshake__', type: 'handshake', version: BRIDGE_VERSION, client: mcpClientInfo }),
226
+ );
227
+ } catch { /* closing already */ }
228
+ let promoted = false;
229
+ let isRelayPeer = false;
230
+ socket.isAlive = true;
231
+ socket.on('pong', () => { socket.isAlive = true; });
232
+ socket.on('message', (raw) => {
233
+ let msg;
234
+ try { msg = JSON.parse(String(raw)); } catch {
235
+ console.error('[MCP] non-JSON frame dropped');
236
+ return;
237
+ }
238
+ if (!msg || typeof msg !== 'object') return;
239
+ // Tool replies carry no type by design (they answer by id); anything
240
+ // with neither is noise. Matching still requires the exact socket the
241
+ // request went out on, so stray peers can't forge results.
242
+ if (!msg.type && typeof msg.id !== 'string') return;
243
+ socket.isAlive = true;
244
+ if (msg.type === '__takeover__') {
245
+ if (sawStdioTraffic) {
246
+ console.error('[MCP] legacy takeover refused (live session)');
247
+ } else {
248
+ console.error('[MCP] legacy takeover honored, exiting');
249
+ process.exit(0);
250
+ }
251
+ return;
252
+ }
253
+ if (msg.type === '__follower__') { isRelayPeer = true; return; }
254
+ if (msg.type === 'relay') { isRelayPeer = true; handleRelay(socket, msg); return; }
255
+ // Promotion: the first non-control message marks the extension socket —
256
+ // including its reply to our hello (silent sockets stay unpromoted, so a
257
+ // stray page that connected but never speaks can never receive tools).
258
+ if (!promoted && !isRelayPeer && (msg.type || msg.id === '__handshake__')) {
259
+ promoted = true;
260
+ const prev = activeSocket;
261
+ if (prev && prev !== socket) {
262
+ for (const [id, p] of pending) {
263
+ if (p.socket !== prev) continue;
264
+ clearTimeout(p.timer);
265
+ p.reject(new Error('Extension reconnected before responding. Retry the call.'));
266
+ pending.delete(id);
267
+ }
268
+ try { prev.terminate(); } catch { /* noop */ }
269
+ }
270
+ activeSocket = socket;
271
+ }
272
+ if (typeof msg.id !== 'string') return;
273
+ const p = pending.get(msg.id);
274
+ if (p && p.socket === socket) {
275
+ clearTimeout(p.timer);
276
+ pending.delete(msg.id);
277
+ p.resolve(msg.payload);
278
+ }
279
+ });
280
+ socket.on('close', () => {
281
+ if (activeSocket === socket) {
282
+ activeSocket = null;
283
+ console.error('[MCP] extension disconnected');
284
+ for (const [id, p] of pending) {
285
+ if (p.socket !== socket) continue;
286
+ clearTimeout(p.timer);
287
+ p.reject(new Error('Extension disconnected. Retry the call.'));
288
+ pending.delete(id);
289
+ }
290
+ }
291
+ });
292
+ });
293
+ }
294
+
295
+ function handleRelay(sock, msg) {
296
+ const { id, tool, payload } = msg;
297
+ if (typeof id !== 'string' || typeof tool !== 'string') return;
298
+ const t = typeof msg.timeoutMs === 'number' && Number.isFinite(msg.timeoutMs)
299
+ ? Math.min(Math.max(msg.timeoutMs, 1000), RELAY_TIMEOUT_MAX_MS)
300
+ : undefined;
301
+ forwardToExtension(tool, payload, t).then(
302
+ (result) => { try { sock.send(JSON.stringify({ id, type: 'relay_result', payload: result })); } catch {} },
303
+ (e) => { try { sock.send(JSON.stringify({ id, type: 'relay_result', error: e instanceof Error ? e.message : String(e) })); } catch {} },
304
+ );
305
+ }
306
+
307
+ async function joinAsFollower() {
308
+ return new Promise((resolve) => {
309
+ const url = `ws://${BIND_HOST}:${WS_PORT}`;
310
+ let socket;
311
+ try {
312
+ socket = new WebSocket(url);
313
+ } catch { resolve('connect-failed'); return; }
314
+ const openTimer = setTimeout(() => {
315
+ try { socket.terminate(); } catch {}
316
+ resolve('open-timeout');
317
+ }, OPEN_WAIT_MS);
318
+ socket.on('open', () => {
319
+ clearTimeout(openTimer);
320
+ try { socket.send(JSON.stringify({ type: '__follower__' })); } catch {}
321
+ const helloTimer = setTimeout(() => resolve('joined-legacy'), HELLO_WAIT_MS);
322
+ socket.once('message', (raw) => {
323
+ let msg;
324
+ try { msg = JSON.parse(String(raw)); } catch { clearTimeout(helloTimer); resolve('joined-legacy'); return; }
325
+ if (msg?.type === 'handshake') {
326
+ clearTimeout(helloTimer);
327
+ role = { kind: 'follower', socket };
328
+ console.error('[MCP] follower: relaying through leader');
329
+ socket.on('message', (raw2) => {
330
+ let m;
331
+ try { m = JSON.parse(String(raw2)); } catch { return; }
332
+ if (!m || typeof m !== 'object' || typeof m.id !== 'string') return;
333
+ const p = pending.get(m.id);
334
+ if (!p || p.socket !== socket) return;
335
+ clearTimeout(p.timer);
336
+ pending.delete(m.id);
337
+ if ('error' in m) p.reject(new Error(String(m.error)));
338
+ else p.resolve(m.payload);
339
+ });
340
+ socket.on('close', () => {
341
+ console.error('[MCP] leader lost; re-acquiring');
342
+ for (const [id, p] of pending) {
343
+ clearTimeout(p.timer);
344
+ p.reject(new Error('Leader MCP instance went away. Retry the call.'));
345
+ pending.delete(id);
346
+ }
347
+ role = null;
348
+ void acquireRole().catch((e) => {
349
+ console.error('[MCP] fatal:', e instanceof Error ? e.message : String(e));
350
+ process.exit(1);
351
+ });
352
+ });
353
+ resolve('joined');
354
+ } else {
355
+ clearTimeout(helloTimer);
356
+ resolve('joined-legacy');
357
+ }
358
+ });
359
+ });
360
+ socket.on('error', () => {
361
+ clearTimeout(openTimer);
362
+ resolve(role ? 'joined' : 'connect-failed');
363
+ });
364
+ });
365
+ }
366
+
367
+ async function acquireRole() {
368
+ let delay = ACQUIRE_RETRY_MS;
369
+ for (let attempt = 1; ; attempt++) {
370
+ try {
371
+ const wss = await startWss();
372
+ wss.removeAllListeners('error');
373
+ becomeLeader(wss);
374
+ return;
375
+ } catch (e) {
376
+ if (e?.code !== 'EADDRINUSE') throw e;
377
+ }
378
+ const outcome = await joinAsFollower();
379
+ if (outcome === 'joined') return;
380
+ // Pre-relay incumbent: ask it to step aside, then retry.
381
+ if (outcome === 'joined-legacy') {
382
+ try {
383
+ const s = new WebSocket(`ws://${BIND_HOST}:${WS_PORT}`);
384
+ s.on('open', () => {
385
+ try { s.send(JSON.stringify({ type: '__takeover__' })); } catch {}
386
+ setTimeout(() => { try { s.close(); } catch {} }, 300);
387
+ });
388
+ s.on('error', () => {});
389
+ } catch {}
390
+ }
391
+ acquireStatus = `port ${WS_PORT} busy (${outcome})`;
392
+ if (attempt <= 3 || attempt % 10 === 0) {
393
+ console.error(`[MCP] acquire #${attempt}: ${acquireStatus}; retry in ${delay}ms`);
394
+ }
395
+ await new Promise((r) => setTimeout(r, delay));
396
+ delay = Math.min(delay * 2, ACQUIRE_RETRY_MAX_MS);
397
+ }
398
+ }
399
+
400
+ function forwardToExtension(type, payload, timeoutOverrideMs) {
401
+ return new Promise((resolve, reject) => {
402
+ const timeoutMs =
403
+ timeoutOverrideMs ??
404
+ (LONG_TIMEOUT_TOOLS.has(type) ? FORWARD_TIMEOUT_LONG_MS : FORWARD_TIMEOUT_MS);
405
+ if (!role) {
406
+ reject(new Error(`Bridge still connecting: ${acquireStatus}. Retry shortly.`));
407
+ return;
408
+ }
409
+ if (role.kind === 'follower') {
410
+ const socket = role.socket;
411
+ if (socket.readyState !== WebSocket.OPEN) {
412
+ reject(new Error('Not connected to the leader instance. Retry shortly.'));
413
+ return;
414
+ }
415
+ const id = String(nextId++);
416
+ const wait = timeoutMs + RELAY_SLACK_MS;
417
+ const timer = setTimeout(() => {
418
+ pending.delete(id);
419
+ reject(new Error(`Leader did not respond within ${wait}ms`));
420
+ }, wait);
421
+ pending.set(id, { resolve, reject, timer, socket });
422
+ socket.send(JSON.stringify({ id, type: 'relay', tool: type, payload, timeoutMs }));
423
+ return;
424
+ }
425
+ if (!activeSocket || activeSocket.readyState !== WebSocket.OPEN) {
426
+ reject(
427
+ new Error(
428
+ 'Extension is not connected. Load the extension and click Connect in Options → MCP Server.',
429
+ ),
430
+ );
431
+ return;
432
+ }
433
+ const socket = activeSocket;
434
+ const id = String(nextId++);
435
+ const timer = setTimeout(() => {
436
+ pending.delete(id);
437
+ reject(new Error(`Extension did not respond within ${timeoutMs}ms`));
438
+ }, timeoutMs);
439
+ pending.set(id, { resolve, reject, timer, socket });
440
+ socket.send(JSON.stringify({ id, type, payload }));
441
+ });
442
+ }
443
+
444
+ // ---------------------------------------------------------------------------
445
+ // MCP server (stdio)
446
+ // ---------------------------------------------------------------------------
447
+ const server = new Server({ name: 'truepx', version: BRIDGE_VERSION }, { capabilities: { tools: {} } });
448
+
449
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
450
+
451
+ function forwardResult(type, payload) {
452
+ return forwardToExtension(type, payload).then(
453
+ (response) => {
454
+ if (response && typeof response === 'object' && 'error' in response) {
455
+ return { content: [{ type: 'text', text: String(response.error) }], isError: true };
456
+ }
457
+ if (response && typeof response === 'object' && response.success === false) {
458
+ const m = response;
459
+ return {
460
+ content: [{ type: 'text', text: typeof m.message === 'string' && m.message ? m.message : JSON.stringify(response) }],
461
+ isError: true,
462
+ };
463
+ }
464
+ return {
465
+ content: [{ type: 'text', text: typeof response === 'string' ? response : JSON.stringify(response) }],
466
+ };
467
+ },
468
+ (e) => ({ content: [{ type: 'text', text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true }),
469
+ );
470
+ }
471
+
472
+ function forwardScreenshots(payload) {
473
+ // State matrix: {ok, states:[{state,dataUrl}]} → one image block per state.
474
+ return forwardToExtension('capture_states', payload, FORWARD_TIMEOUT_LONG_MS).then(
475
+ (response) => {
476
+ const states = response?.states;
477
+ if (!response || response.ok !== true || !Array.isArray(states)) {
478
+ return errText(String(response?.error ?? response?.message ?? 'capture_failed'));
479
+ }
480
+ const prefix = 'data:image/png;base64,';
481
+ const content = [];
482
+ for (const s of states) {
483
+ if (!s || typeof s.dataUrl !== 'string') continue;
484
+ content.push({
485
+ type: 'image',
486
+ data: s.dataUrl.startsWith(prefix) ? s.dataUrl.slice(prefix.length) : s.dataUrl,
487
+ mimeType: 'image/png',
488
+ });
489
+ content.push({ type: 'text', text: `state: ${s.state}` });
490
+ }
491
+ if (!content.length) return errText(String(response?.error ?? 'all captures failed'));
492
+ return { content };
493
+ },
494
+ (e) => errText(e instanceof Error ? e.message : String(e)),
495
+ );
496
+ }
497
+
498
+ function forwardScreenshot(payload) {
499
+ return forwardToExtension('capture_screenshot', payload).then(
500
+ (response) => {
501
+ if (!response || !response.ok || typeof response.dataUrl !== 'string') {
502
+ return errText(String(response?.reason ?? response?.error ?? 'screenshot_failed'));
503
+ }
504
+ const prefix = 'data:image/png;base64,';
505
+ const data = response.dataUrl.startsWith(prefix) ? response.dataUrl.slice(prefix.length) : response.dataUrl;
506
+ const content = [{ type: 'image', data, mimeType: 'image/png' }];
507
+ if (typeof response.note === 'string' && response.note) content.push({ type: 'text', text: response.note });
508
+ return { content };
509
+ },
510
+ (e) => errText(e instanceof Error ? e.message : String(e)),
511
+ );
512
+ }
513
+
514
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
515
+ const { name, arguments: a } = request.params;
516
+ const args = a ?? {};
517
+ switch (name) {
518
+ case 'ping': {
519
+ const r = await forwardToExtension('ping', {});
520
+ if (r && typeof r === 'object' && 'error' in r) return errText(String(r.error));
521
+ const v = r && typeof r === 'object' && typeof r.extensionVersion === 'string' ? r.extensionVersion : null;
522
+ return {
523
+ content: [{
524
+ type: 'text',
525
+ text: v ? `pong from extension v${v} (bridge v${BRIDGE_VERSION})` : `${typeof r === 'string' ? r : JSON.stringify(r)} (bridge v${BRIDGE_VERSION})`,
526
+ }],
527
+ };
528
+ }
529
+ case 'tabs_list': return forwardResult('tabs_list', {});
530
+ case 'tab_focus': { const e = needTab(args, name); if (e) return e; return forwardResult(name, { tabId: args.tabId }); }
531
+ case 'page_info': case 'page_seo_audit': case 'page_meta':
532
+ case 'page_weight': case 'css_variables': case 'page_breakpoints':
533
+ case 'find_overflow': case 'find_inconsistencies': case 'list_pins':
534
+ case 'page_tech_stack': {
535
+ const e = needTab(args, name); if (e) return e;
536
+ return forwardResult(name, { tabId: args.tabId });
537
+ }
538
+ case 'get_a11y_issues': {
539
+ const e = needTab(args, name); if (e) return e;
540
+ const p = { tabId: args.tabId };
541
+ if (typeof args.limit === 'number') p.limit = args.limit;
542
+ return forwardResult(name, p);
543
+ }
544
+ case 'selection_get': case 'edits_list': {
545
+ const e = needTab(args, name); if (e) return e;
546
+ return forwardResult(name, { tabId: args.tabId });
547
+ }
548
+ case 'element_inspect': case 'css_matched_rules': {
549
+ if (typeof args.tabId !== 'number' || typeof args.elementId !== 'string') {
550
+ return errText(`${name} requires tabId (number) and elementId (string).`);
551
+ }
552
+ return forwardResult(name, { tabId: args.tabId, elementId: args.elementId, viewportId: args.viewportId });
553
+ }
554
+ case 'elements_inspect': {
555
+ if (typeof args.tabId !== 'number' || !Array.isArray(args.elementIds)) {
556
+ return errText(`${name} requires tabId (number) and elementIds (string[]).`);
557
+ }
558
+ return forwardResult(name, { tabId: args.tabId, elementIds: args.elementIds, viewportId: args.viewportId });
559
+ }
560
+ case 'dom_outline': {
561
+ const e = needTab(args, name); if (e) return e;
562
+ const p = { tabId: args.tabId };
563
+ for (const k of ['elementId', 'maxDepth', 'maxChildren', 'viewportId']) {
564
+ if (args[k] !== undefined) p[k] = args[k];
565
+ }
566
+ return forwardResult(name, p);
567
+ }
568
+ case 'dom_query': {
569
+ if (typeof args.tabId !== 'number' || typeof args.selector !== 'string') {
570
+ return errText(`${name} requires tabId (number) and selector (string).`);
571
+ }
572
+ const p = { tabId: args.tabId, selector: args.selector };
573
+ for (const k of ['limit', 'rootId', 'viewportId']) {
574
+ if (args[k] !== undefined) p[k] = args[k];
575
+ }
576
+ return forwardResult(name, p);
577
+ }
578
+ case 'capture_screenshot': {
579
+ const e = needTab(args, name); if (e) return e;
580
+ return forwardScreenshot({ tabId: args.tabId });
581
+ }
582
+ case 'capture_states': {
583
+ if (typeof args.tabId !== 'number' || typeof args.elementId !== 'string') {
584
+ return errText(`${name} requires tabId and elementId.`);
585
+ }
586
+ return forwardScreenshots({ tabId: args.tabId, elementId: args.elementId });
587
+ }
588
+ case 'font_forensics': {
589
+ if (typeof args.tabId !== 'number' || typeof args.elementId !== 'string') {
590
+ return errText(`${name} requires tabId and elementId.`);
591
+ }
592
+ return forwardResult(name, { tabId: args.tabId, elementId: args.elementId });
593
+ }
594
+ case 'find_heavy_images': {
595
+ const e = needTab(args, name); if (e) return e;
596
+ return forwardResult(name, { tabId: args.tabId });
597
+ }
598
+ case 'css_set_inline': {
599
+ if (typeof args.tabId !== 'number' || typeof args.styles !== 'object' || !args.styles) {
600
+ return errText(`${name} requires tabId (number) and styles (object).`);
601
+ }
602
+ if (args.ruleHandle !== undefined) {
603
+ if (typeof args.ruleHandle !== 'object' || !args.ruleHandle) return errText('ruleHandle must be the object from css_matched_rules or css_create_rule.');
604
+ return forwardResult(name, { tabId: args.tabId, styles: args.styles, ruleHandle: args.ruleHandle });
605
+ }
606
+ if (typeof args.elementId !== 'string' || !args.elementId.trim()) {
607
+ return errText(`${name} requires a non-empty elementId without ruleHandle.`);
608
+ }
609
+ return forwardResult(name, { tabId: args.tabId, elementId: args.elementId, styles: args.styles });
610
+ }
611
+ case 'css_create_rule': {
612
+ if (typeof args.tabId !== 'number' || typeof args.selector !== 'string' || !args.selector.trim()) {
613
+ return errText(`${name} requires tabId (number) and a non-empty selector.`);
614
+ }
615
+ if (args.wrappers !== undefined && (!Array.isArray(args.wrappers) || args.wrappers.some((w) => typeof w !== 'string'))) {
616
+ return errText('wrappers must be at-rule strings, e.g. ["@media (min-width: 768px)"].');
617
+ }
618
+ const p = { tabId: args.tabId, selector: args.selector };
619
+ for (const k of ['wrappers', 'styles', 'elementId']) {
620
+ if (args[k] !== undefined) p[k] = args[k];
621
+ }
622
+ return forwardResult(name, p);
623
+ }
624
+ case 'css_set_selector': {
625
+ if (typeof args.tabId !== 'number' || typeof args.ruleHandle !== 'object' || !args.ruleHandle || typeof args.selector !== 'string' || !args.selector.trim()) {
626
+ return errText(`${name} requires tabId, ruleHandle, and a non-empty selector.`);
627
+ }
628
+ return forwardResult(name, { tabId: args.tabId, ruleHandle: args.ruleHandle, selector: args.selector });
629
+ }
630
+ case 'edits_revert': {
631
+ if (typeof args.tabId !== 'number' || typeof args.changeId !== 'string' || !args.changeId) {
632
+ return errText(`${name} requires tabId and changeId.`);
633
+ }
634
+ return forwardResult(name, { tabId: args.tabId, changeId: args.changeId });
635
+ }
636
+ case 'edits_revert_all': {
637
+ const e = needTab(args, name); if (e) return e;
638
+ return forwardResult(name, { tabId: args.tabId });
639
+ }
640
+ case 'dom_set_text': case 'dom_set_html': {
641
+ const field = name === 'dom_set_text' ? 'text' : 'html';
642
+ if (typeof args.tabId !== 'number' || typeof args.elementId !== 'string' || typeof args[field] !== 'string') {
643
+ return errText(`${name} requires tabId, elementId, and ${field}.`);
644
+ }
645
+ return forwardResult(name, { tabId: args.tabId, elementId: args.elementId, [field]: args[field] });
646
+ }
647
+ case 'dom_insert_html': {
648
+ if (typeof args.tabId !== 'number' || typeof args.elementId !== 'string' || typeof args.html !== 'string' || typeof args.position !== 'string') {
649
+ return errText(`${name} requires tabId, elementId, html, position (before/after/prepend/append).`);
650
+ }
651
+ return forwardResult(name, { tabId: args.tabId, elementId: args.elementId, html: args.html, position: args.position });
652
+ }
653
+ case 'element_set_classes': {
654
+ if (typeof args.tabId !== 'number' || typeof args.elementId !== 'string' || typeof args.classes !== 'string') {
655
+ return errText(`${name} requires tabId, elementId, classes.`);
656
+ }
657
+ return forwardResult(name, { tabId: args.tabId, elementId: args.elementId, classes: args.classes });
658
+ }
659
+ case 'selection_set': case 'dom_remove': case 'dom_duplicate': {
660
+ if (typeof args.tabId !== 'number' || typeof args.elementId !== 'string') {
661
+ return errText(`${name} requires tabId and elementId.`);
662
+ }
663
+ return forwardResult(name, { tabId: args.tabId, elementId: args.elementId });
664
+ }
665
+ case 'add_pin': {
666
+ if (typeof args.tabId !== 'number' || typeof args.note !== 'string' || !args.note.trim()) {
667
+ return errText(`${name} requires tabId and a non-empty note (plus elementId or selector).`);
668
+ }
669
+ if (typeof args.elementId !== 'string' && typeof args.selector !== 'string') {
670
+ return errText(`${name} requires elementId or selector.`);
671
+ }
672
+ const p = { tabId: args.tabId, note: args.note };
673
+ if (typeof args.elementId === 'string') p.elementId = args.elementId;
674
+ if (typeof args.selector === 'string') p.selector = args.selector;
675
+ return forwardResult(name, p);
676
+ }
677
+ case 'resolve_pin': {
678
+ if (typeof args.tabId !== 'number' || typeof args.pinId !== 'string') {
679
+ return errText(`${name} requires tabId and pinId from list_pins.`);
680
+ }
681
+ const p = { tabId: args.tabId, pinId: args.pinId };
682
+ if (typeof args.resolved === 'boolean') p.resolved = args.resolved;
683
+ return forwardResult(name, p);
684
+ }
685
+ case 'fix_contrast': {
686
+ if (typeof args.tabId !== 'number' || typeof args.elementId !== 'string') {
687
+ return errText(`${name} requires tabId and elementId.`);
688
+ }
689
+ const p = { tabId: args.tabId, elementId: args.elementId };
690
+ if (typeof args.target === 'number') p.target = args.target;
691
+ if (typeof args.preview === 'boolean') p.preview = args.preview;
692
+ return forwardResult(name, p);
693
+ }
694
+ case 'simulate_vision': {
695
+ if (typeof args.tabId !== 'number' || typeof args.mode !== 'string') {
696
+ return errText(`${name} requires tabId and mode (protanopia | deuteranopia | tritanopia | achromatopsia | off).`);
697
+ }
698
+ return forwardResult(name, { tabId: args.tabId, mode: args.mode });
699
+ }
700
+ case 'mockup_overlay': {
701
+ if (typeof args.tabId !== 'number' || typeof args.action !== 'string') {
702
+ return errText(`${name} requires tabId and action (set | config | clear | state).`);
703
+ }
704
+ if (args.action === 'set' && (typeof args.dataUrl !== 'string' || args.dataUrl.length > 12000000)) {
705
+ return errText('set requires a dataUrl image (limit ~12MB string).');
706
+ }
707
+ const p = { tabId: args.tabId, action: args.action };
708
+ for (const k of ['dataUrl', 'name', 'opacity', 'blend', 'x', 'y', 'visible', 'scale']) {
709
+ if (args[k] !== undefined) p[k] = args[k];
710
+ }
711
+ return forwardResult(name, p);
712
+ }
713
+ case 'viewports_open': case 'viewports_close': case 'viewport_state': {
714
+ const e = needTab(args, name); if (e) return e;
715
+ return forwardResult(name, { tabId: args.tabId });
716
+ }
717
+ case 'sweep_breakpoints': {
718
+ const e = needTab(args, name); if (e) return e;
719
+ const p = { tabId: args.tabId };
720
+ if (Array.isArray(args.widths)) p.widths = args.widths.filter((w) => typeof w === 'number').slice(0, 9);
721
+ return forwardResult(name, p);
722
+ }
723
+ case 'viewport_update': {
724
+ if (typeof args.tabId !== 'number' || typeof args.width !== 'number' || typeof args.height !== 'number') {
725
+ return errText(`${name} requires tabId, width, height (numbers).`);
726
+ }
727
+ return forwardResult(name, { tabId: args.tabId, width: args.width, height: args.height, viewportId: args.viewportId });
728
+ }
729
+ case 'viewports_set': {
730
+ if (typeof args.tabId !== 'number' || !Array.isArray(args.viewports) || !args.viewports.length) {
731
+ return errText(`${name} requires tabId and a non-empty viewports [{width,height}].`);
732
+ }
733
+ return forwardResult(name, { tabId: args.tabId, viewports: args.viewports });
734
+ }
735
+ case 'viewport_add': {
736
+ const e = needTab(args, name); if (e) return e;
737
+ const p = { tabId: args.tabId };
738
+ for (const k of ['width', 'height', 'name']) {
739
+ if (args[k] !== undefined) p[k] = args[k];
740
+ }
741
+ return forwardResult(name, p);
742
+ }
743
+ case 'viewport_remove': {
744
+ if (typeof args.tabId !== 'number' || typeof args.viewportId !== 'number') {
745
+ return errText(`${name} requires tabId and viewportId.`);
746
+ }
747
+ return forwardResult(name, { tabId: args.tabId, viewportId: args.viewportId });
748
+ }
749
+ case 'viewports_zoom': {
750
+ if (typeof args.tabId !== 'number' || typeof args.zoom !== 'number') {
751
+ return errText(`${name} requires tabId and zoom.`);
752
+ }
753
+ return forwardResult(name, { tabId: args.tabId, zoom: args.zoom });
754
+ }
755
+ case 'breakpoints_overlay': {
756
+ if (typeof args.tabId !== 'number' || typeof args.enabled !== 'boolean') {
757
+ return errText(`${name} requires tabId and enabled (boolean).`);
758
+ }
759
+ return forwardResult(name, { tabId: args.tabId, enabled: args.enabled });
760
+ }
761
+ case 'page_typography': case 'page_colors': case 'page_assets': {
762
+ if (typeof args.tabId !== 'number' || typeof args.elementId !== 'string') {
763
+ return errText(`${name} requires tabId and elementId.`);
764
+ }
765
+ return forwardResult(name, { tabId: args.tabId, elementId: args.elementId });
766
+ }
767
+ case 'css_specificity': {
768
+ if (typeof args.tabId !== 'number' || typeof args.selector !== 'string' || !args.selector.trim()) {
769
+ return errText(`${name} requires tabId and a non-empty selector.`);
770
+ }
771
+ return forwardResult(name, { tabId: args.tabId, selector: args.selector });
772
+ }
773
+ case 'element_layout': {
774
+ if (typeof args.tabId !== 'number' || typeof args.elementId !== 'string') {
775
+ return errText(`${name} requires tabId and elementId.`);
776
+ }
777
+ return forwardResult(name, { tabId: args.tabId, elementId: args.elementId, viewportId: args.viewportId });
778
+ }
779
+ case 'element_force_state': {
780
+ if (typeof args.tabId !== 'number' || typeof args.elementId !== 'string' || typeof args.state !== 'string') {
781
+ return errText(`${name} requires tabId, elementId, state (empty clears).`);
782
+ }
783
+ return forwardResult(name, { tabId: args.tabId, elementId: args.elementId, state: args.state });
784
+ }
785
+ case 'element_tailwind': {
786
+ if (typeof args.tabId !== 'number' || typeof args.elementId !== 'string') {
787
+ return errText(`${name} requires tabId and elementId.`);
788
+ }
789
+ return forwardResult(name, { tabId: args.tabId, elementId: args.elementId });
790
+ }
791
+ case 'css_fetch_stylesheet': {
792
+ if (typeof args.url !== 'string' || !args.url.trim()) {
793
+ return errText(`${name} requires a non-empty url.`);
794
+ }
795
+ return forwardResult(name, { url: args.url });
796
+ }
797
+ default:
798
+ throw new Error(`Unknown tool: ${name}`);
799
+ }
800
+ });
801
+
802
+ server.oninitialized = () => {
803
+ try {
804
+ const info = server.getClientVersion();
805
+ if (info?.name) {
806
+ mcpClientInfo = { name: info.name, version: info.version ?? '' };
807
+ sendClientInfo();
808
+ }
809
+ } catch { /* older SDK */ }
810
+ };
811
+
812
+ const transport = new StdioServerTransport();
813
+ transport.onclose = () => {
814
+ console.error('[MCP] stdio closed, exiting');
815
+ process.exit(0);
816
+ };
817
+
818
+ acquireRole()
819
+ .then(() => server.connect(transport))
820
+ .then(() => watchStdin())
821
+ .catch((e) => {
822
+ console.error('[MCP] fatal:', e instanceof Error ? e.message : String(e));
823
+ process.exit(1);
824
+ });
825
+
826
+ // Exit when the MCP client goes away, detected as stdin ending after the
827
+ // client has actually spoken. Installed only AFTER server.connect: attaching
828
+ // a 'data' listener earlier puts stdin in flowing mode and would swallow the
829
+ // initialize handshake while a follower is still joining the leader (the
830
+ // transport attaches its own reader at connect and must see every byte).
831
+ // The traffic guard keeps manual launches with stdin from /dev/null alive:
832
+ // they see instant EOF with zero traffic and keep serving the bridge.
833
+ function watchStdin() {
834
+ let sawStdioTraffic = false;
835
+ process.stdin.on('data', () => { sawStdioTraffic = true; });
836
+ const exitOnStdinGone = (event) => {
837
+ if (!sawStdioTraffic) {
838
+ console.error(`[MCP] stdin ${event} before traffic, staying up`);
839
+ return;
840
+ }
841
+ console.error(`[MCP] stdin ${event}, exiting`);
842
+ process.exit(0);
843
+ };
844
+ process.stdin.on('end', () => exitOnStdinGone('ended'));
845
+ process.stdin.on('close', () => exitOnStdinGone('closed'));
846
+ }