qiksy-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 +164 -0
  2. package/package.json +18 -0
  3. package/server.mjs +307 -0
package/README.md ADDED
@@ -0,0 +1,164 @@
1
+ # qiksy-mcp
2
+
3
+ An **MCP (Model Context Protocol) bridge** for the [Qiksy](../) browser extension. It lets any MCP-capable coding agent — **Claude Code, Cursor, Cline, Windsurf, Zed, VS Code** — read the live QA state (findings with CSS selectors, detected forms, failed requests with server error bodies, recorded repro steps) straight from the browser, so you can say *"fix what Qiksy found on this page"* and the agent has the full context.
4
+
5
+ The agent can also **drive Qiksy's own surfaces**: open the panel, run the audit, pull the exploratory-tour checklist, generate the session report, and spotlight an element. What it **never** does is act on the **app under test** — no filling, clicking, submitting, or navigating the page you're testing. Qiksy senses and reports; a human (or a purpose-built driver like [Playwright MCP](#driving-the-browser-pair-with-playwright-mcp)) does the driving. This boundary is deliberate — it's what keeps a QA tool that watches *other people's* apps trustworthy.
6
+
7
+ ## How it works
8
+
9
+ ```
10
+ agent ⟷ (MCP over stdio, JSON-RPC) ⟷ qiksy-mcp ⟷ (WebSocket @ 127.0.0.1) ⟷ extension service worker
11
+ ```
12
+
13
+ The extension is the WebSocket **client** (a service worker can't listen on a socket); this process is the WebSocket **server**, bound to loopback only. When the agent calls a tool, this process asks the extension for the current tab's `qa-export/v1` bundle and returns it.
14
+
15
+ ## Tools
16
+
17
+ **Read** — sense the page (all accept an optional `tabId` from `qa_tabs`; default = active tab):
18
+
19
+ | Tool | What it returns |
20
+ |---------------|-----------------|
21
+ | `qa_tabs` | Every browser tab Qiksy can read, incl. which are **isolated multi-login sessions** (`isolatedSession` = the login name). Use a tab's `tabId` with the other tools to read that specific login. |
22
+ | `qa_status` | URL + error/warning/form counts + isolated-login name. |
23
+ | `qa_findings` | Findings (with selectors + detail); `severity` = `all` \| `error` \| `warning`. |
24
+ | `qa_export` | The full `qa-export/v1` bundle: findings, failed requests + bodies, form structure, repro steps, env. |
25
+
26
+ **Command** — drive Qiksy's own UI / analysis (never the app under test; all accept `tabId`):
27
+
28
+ | Tool | What it does |
29
+ |-----------------|--------------|
30
+ | `qa_open_panel` | Open/close the Qiksy side panel and optionally jump to a tab (`forms` \| `findings` \| `tour` \| `tools` \| `history` \| `roles`). |
31
+ | `qa_run_audit` | Run the a11y/markup audit on the current DOM and return the findings (also refreshes the panel). Static, read-only DOM analysis. |
32
+ | `qa_tour` | Return the generated exploratory-testing checklist (from forms + interactive surfaces), auto-checked from the recorded session — raw material for test cases. Markdown + structured items. |
33
+ | `qa_report` | Generate Qiksy's self-contained HTML session report and return the HTML (needs History recording to have captured steps). |
34
+ | `qa_spotlight` | Dim + outline an element by CSS selector so the human can see what the agent means. A non-destructive overlay — no click/focus/mutation. |
35
+
36
+ Because tools accept a `tabId`, an agent can read **several logins at once**: call `qa_tabs`, then `qa_export` per isolated tab to compare what admin vs. member vs. anon each see.
37
+
38
+ ## Driving the browser: pair with Playwright MCP
39
+
40
+ Qiksy is the **QA brain** (what happened, what's wrong, coverage, reports). It does **not** click through the app for you. To let an agent also *drive* the browser, run a purpose-built driver alongside it — e.g. [Playwright MCP](https://github.com/microsoft/playwright-mcp) — and register **both** servers in your agent:
41
+
42
+ ```
43
+ your agent
44
+ ├─ playwright-mcp → the HANDS: click, fill, navigate the site
45
+ └─ qiksy-mcp → the BRAIN: findings, form structure, network, coverage, reports
46
+ ```
47
+
48
+ **Example workflow (Jira story → verified test cases):**
49
+
50
+ 1. Drop a Jira story/acceptance criteria into your agent.
51
+ 2. The agent uses **Playwright MCP** to walk the flow in the browser.
52
+ 3. After each step it calls **qiksy-mcp** (`qa_export`, `qa_run_audit`, `qa_findings`) to see what *actually* happened — console/JS errors, failed requests **with server bodies**, a11y/markup issues, the detected form structure.
53
+ 4. The agent reconciles the story against reality, then writes test cases (using `qa_tour` for coverage) and, if History was recording, pulls a `qa_report` as the artifact.
54
+
55
+ Why not build driving into Qiksy itself? Playwright is a mature, dedicated driver, and keeping Qiksy read-and-report keeps its trust/permissions story clean. (Embedding Playwright *inside* the extension isn't possible anyway — it's an external Node automation framework, not something an MV3 extension can host.)
56
+
57
+ ### Wiring both servers (Playwright MCP needs a CDP browser)
58
+
59
+ Playwright MCP drives a browser over the **Chrome DevTools Protocol** — a *normal* Chrome window can't be driven, you must start Chrome with a debugging port so Playwright can attach. Qiksy must be **installed in that same Chrome** (from the Web Store, or loaded unpacked) so its content scripts + bridge run there.
60
+
61
+ 1. **Launch Chrome with the debug port** (quit other Chrome windows first, or use a separate `--user-data-dir`):
62
+ ```bash
63
+ # macOS
64
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --remote-debugging-port=9222
65
+ # Windows: "C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222
66
+ # Linux: google-chrome --remote-debugging-port=9222
67
+ ```
68
+ 2. **In the Qiksy popup** → *MCP bridge*: **On**, **Generate** a token, **Save**.
69
+ 3. **Register both MCP servers** in your agent — one config, paste and go. Claude Code `.mcp.json` (project root):
70
+ ```json
71
+ {
72
+ "mcpServers": {
73
+ "playwright": {
74
+ "command": "npx",
75
+ "args": ["@playwright/mcp@latest", "--cdp-endpoint", "http://127.0.0.1:9222"]
76
+ },
77
+ "qiksy": {
78
+ "command": "npx",
79
+ "args": ["qiksy-mcp", "--port", "7333"],
80
+ "env": { "QIKSY_MCP_TOKEN": "YOUR_TOKEN" }
81
+ }
82
+ }
83
+ }
84
+ ```
85
+ Now the agent has **hands** (Playwright, attached to your real Chrome) and a **QA brain** (Qiksy).
86
+
87
+ ### Multi-login (RBAC) with the agent
88
+
89
+ Qiksy runs several logins on one domain at once — one per tab, each with its own cookie jar + virtualized storage. The reliable division of labour:
90
+
91
+ - **You spin up the logins** (a few clicks, one-time): Qiksy panel → **Roles** → **+ Isolated tab** for a fresh empty session, or **⧉** on a saved role to open it isolated. (There's also an **auto-isolate** toggle per site: new tabs *you* open — Ctrl/Cmd+T — get their own session automatically. Note: tabs opened programmatically by Playwright carry an opener and are deliberately **not** auto-isolated, so they don't hijack OAuth-popup / `target=_blank` flows — create isolated logins from the Roles panel instead.)
92
+ - **The agent reads every login at once**: `qa_tabs` lists them with `isolatedSession` = the login name and a `tabId`; then `qa_findings`/`qa_export` with that `tabId` reads each. So the agent can diff what **admin vs. member vs. anon** each see — permissions leaks, missing guards, role-specific errors — in one pass.
93
+ - **The agent drives within a login** with Playwright MCP (click through as that user); Qiksy senses the result per tab.
94
+
95
+ ## Setup
96
+
97
+ 1. **In the extension popup** → *MCP bridge*: toggle **On**, click **Generate** for a token, pick a port (default `7333`), **Save bridge**.
98
+ 2. **Register the server in your agent** (below), using the **same token and port**. The token is passed via the `QIKSY_MCP_TOKEN` env var.
99
+
100
+ The token is the only thing guarding the bridge — localhost is reachable by any local process/page, so keep it secret and unique.
101
+
102
+ ### Claude Code
103
+
104
+ ```bash
105
+ claude mcp add --transport stdio qiksy --env QIKSY_MCP_TOKEN=YOUR_TOKEN -- npx qiksy-mcp --port 7333
106
+ ```
107
+
108
+ or `.mcp.json` at the project root:
109
+
110
+ ```json
111
+ { "mcpServers": { "qiksy": {
112
+ "type": "stdio",
113
+ "command": "npx", "args": ["qiksy-mcp", "--port", "7333"],
114
+ "env": { "QIKSY_MCP_TOKEN": "YOUR_TOKEN" }
115
+ } } }
116
+ ```
117
+
118
+ ### Cursor (`~/.cursor/mcp.json`), Windsurf (`~/.codeium/windsurf/mcp_config.json`), Cline (`cline_mcp_settings.json`)
119
+
120
+ Same canonical shape:
121
+
122
+ ```json
123
+ { "mcpServers": { "qiksy": {
124
+ "command": "npx", "args": ["qiksy-mcp", "--port", "7333"],
125
+ "env": { "QIKSY_MCP_TOKEN": "YOUR_TOKEN" }
126
+ } } }
127
+ ```
128
+
129
+ ### Zed (`settings.json`) — key is `context_servers`, `source` is required
130
+
131
+ ```json
132
+ { "context_servers": { "qiksy": {
133
+ "source": "custom",
134
+ "command": "npx", "args": ["qiksy-mcp", "--port", "7333"],
135
+ "env": { "QIKSY_MCP_TOKEN": "YOUR_TOKEN" }
136
+ } } }
137
+ ```
138
+
139
+ ### VS Code native (`.vscode/mcp.json`) — top-level `servers`, `type` required
140
+
141
+ ```json
142
+ {
143
+ "inputs": [{ "type": "promptString", "id": "qac-token", "description": "Qiksy MCP token", "password": true }],
144
+ "servers": { "qiksy": {
145
+ "type": "stdio",
146
+ "command": "npx", "args": ["qiksy-mcp", "--port", "7333"],
147
+ "env": { "QIKSY_MCP_TOKEN": "${input:qac-token}" }
148
+ } }
149
+ }
150
+ ```
151
+
152
+ ## Local run (dev)
153
+
154
+ ```bash
155
+ QIKSY_MCP_TOKEN=YOUR_TOKEN node server.mjs --port 7333
156
+ ```
157
+
158
+ Logs go to **stderr** (stdout is the JSON-RPC channel). You should see `bridge listening…` and, once Chrome has the extension with the bridge enabled, `extension connected`.
159
+
160
+ ## Notes / limits
161
+
162
+ - **One tab at a time**: tools read the *active* tab in the last-focused Chrome window. Focus the app under test.
163
+ - **Service-worker sleep**: if Chrome idles the extension's worker, the socket drops and reconnects automatically (≤~30 s). If a tool times out, retry.
164
+ - **Security**: bound to `127.0.0.1` only; connections without the matching token (or from a non-extension origin) are closed before any data is sent.
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "qiksy-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP bridge for the Qiksy browser extension — expose live QA findings, forms, network and session to any MCP-capable coding agent.",
5
+ "type": "module",
6
+ "bin": {
7
+ "qiksy-mcp": "server.mjs"
8
+ },
9
+ "files": ["server.mjs", "README.md"],
10
+ "engines": {
11
+ "node": ">=18"
12
+ },
13
+ "dependencies": {
14
+ "@modelcontextprotocol/sdk": "^1.19.0",
15
+ "ws": "^8.18.0",
16
+ "zod": "^3.24.0"
17
+ }
18
+ }
package/server.mjs ADDED
@@ -0,0 +1,307 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * qiksy-mcp — a vendor-neutral MCP (Model Context Protocol) server that
4
+ * bridges any MCP-capable coding agent (Claude Code, Cursor, Cline, Windsurf,
5
+ * Zed, VS Code) to the Qiksy browser extension.
6
+ *
7
+ * Topology:
8
+ * agent ⟷ (MCP over stdio, JSON-RPC) ⟷ THIS process
9
+ * THIS process ⟷ (WebSocket server @ 127.0.0.1) ⟷ extension service worker
10
+ *
11
+ * The agent calls a tool → we send a `req` frame to the connected extension →
12
+ * the extension answers with the read-only QA bundle → we return it. The
13
+ * extension is the WS *client* (a service worker can't listen on a socket);
14
+ * this process is the WS *server*, bound to loopback only.
15
+ *
16
+ * Security: localhost is reachable by any local process AND by web pages
17
+ * (loopback is "potentially trustworthy", WS has no CORS preflight), so the
18
+ * only real gate is a shared token — set the same value here (QIKSY_MCP_TOKEN)
19
+ * and in the extension popup. Bad/missing token → the socket is closed before
20
+ * any data is sent. We also reject non-extension Origins.
21
+ *
22
+ * stdout is the JSON-RPC channel — all logging goes to stderr.
23
+ */
24
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
25
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
26
+ import { WebSocketServer, WebSocket } from 'ws';
27
+ import { z } from 'zod';
28
+ import { randomUUID, timingSafeEqual } from 'node:crypto';
29
+
30
+ const log = (...a) => console.error('[qiksy-mcp]', ...a);
31
+
32
+ const TOKEN = process.env.QIKSY_MCP_TOKEN || '';
33
+ const portArg = process.argv.indexOf('--port');
34
+ const PORT = portArg !== -1 ? Number(process.argv[portArg + 1]) : Number(process.env.QIKSY_MCP_PORT) || 7333;
35
+ const HOST = '127.0.0.1';
36
+ const PROTOCOL_VERSION = 1;
37
+
38
+ if (!TOKEN) {
39
+ log('WARNING: QIKSY_MCP_TOKEN is not set. Set the same token here and in the Qiksy popup, or connections are refused.');
40
+ }
41
+
42
+ function tokenOk(got) {
43
+ if (!TOKEN || typeof got !== 'string' || got.length !== TOKEN.length) return false;
44
+ try {
45
+ return timingSafeEqual(Buffer.from(got), Buffer.from(TOKEN));
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ // ── WebSocket bridge to the extension ──────────────────────────────────────
52
+ let client = null; // the one authenticated extension socket
53
+ const pending = new Map(); // id → { resolve, reject, timer }
54
+
55
+ const wss = new WebSocketServer({ host: HOST, port: PORT });
56
+ wss.on('listening', () => log(`bridge listening on ws://${HOST}:${PORT} — token ${TOKEN ? 'set' : 'MISSING'}`));
57
+ wss.on('error', (e) => log('bridge error:', e?.message || e));
58
+
59
+ wss.on('connection', (ws, req) => {
60
+ const origin = req.headers.origin || '';
61
+ // Only the extension should connect; a real web page has an http(s)/null origin.
62
+ if (origin && !origin.startsWith('chrome-extension://')) {
63
+ ws.close(4003, 'forbidden origin');
64
+ return;
65
+ }
66
+ let authed = false;
67
+ const helloTimer = setTimeout(() => {
68
+ if (!authed) ws.close(4001, 'no hello');
69
+ }, 2000);
70
+
71
+ ws.on('message', (raw) => {
72
+ let msg;
73
+ try {
74
+ msg = JSON.parse(raw.toString());
75
+ } catch {
76
+ return;
77
+ }
78
+ if (!authed) {
79
+ if (msg.t !== 'hello' || !tokenOk(msg.token)) {
80
+ clearTimeout(helloTimer);
81
+ ws.close(4001, 'auth failed');
82
+ return;
83
+ }
84
+ authed = true;
85
+ clearTimeout(helloTimer);
86
+ client = ws;
87
+ ws.send(JSON.stringify({ t: 'welcome', v: PROTOCOL_VERSION }));
88
+ log('extension connected');
89
+ return;
90
+ }
91
+ if (msg.t === 'ping') {
92
+ ws.send(JSON.stringify({ t: 'pong', ts: msg.ts }));
93
+ return;
94
+ }
95
+ if (msg.t === 'res' && msg.id != null) {
96
+ const p = pending.get(msg.id);
97
+ if (!p) return;
98
+ clearTimeout(p.timer);
99
+ pending.delete(msg.id);
100
+ if (msg.ok) p.resolve(msg.result);
101
+ else p.reject(new Error(String(msg.error || 'extension error')));
102
+ }
103
+ });
104
+
105
+ ws.on('close', () => {
106
+ if (client === ws) {
107
+ client = null;
108
+ log('extension disconnected');
109
+ }
110
+ });
111
+ });
112
+
113
+ function callExtension(tool, args, timeoutMs = 15_000) {
114
+ return new Promise((resolve, reject) => {
115
+ if (!client || client.readyState !== WebSocket.OPEN) {
116
+ reject(new Error('Qiksy extension is not connected. Open Chrome with the extension and enable the MCP bridge in its popup (matching token & port).'));
117
+ return;
118
+ }
119
+ const id = randomUUID();
120
+ const timer = setTimeout(() => {
121
+ pending.delete(id);
122
+ reject(new Error(`Timed out after ${timeoutMs}ms waiting for "${tool}". Is a normal web page focused in Chrome?`));
123
+ }, timeoutMs);
124
+ pending.set(id, { resolve, reject, timer });
125
+ client.send(JSON.stringify({ t: 'req', id, tool, args: args || {} }));
126
+ });
127
+ }
128
+
129
+ // ── MCP server + tools (read-only) ─────────────────────────────────────────
130
+ const asText = (v) => ({ content: [{ type: 'text', text: typeof v === 'string' ? v : JSON.stringify(v, null, 2) }] });
131
+ const asError = (e) => ({ isError: true, content: [{ type: 'text', text: e instanceof Error ? e.message : String(e) }] });
132
+
133
+ const server = new McpServer({ name: 'qiksy', version: '0.1.0' });
134
+
135
+ server.registerTool(
136
+ 'qa_tabs',
137
+ {
138
+ title: 'QA tabs',
139
+ description:
140
+ 'List the browser tabs Qiksy can read, including which are ISOLATED multi-login sessions (isolatedSession = the login name). Use a tab\'s tabId with the other tools to read that specific login.',
141
+ inputSchema: {},
142
+ },
143
+ async () => {
144
+ try {
145
+ return asText(await callExtension('qa_tabs', {}));
146
+ } catch (e) {
147
+ return asError(e);
148
+ }
149
+ },
150
+ );
151
+
152
+ server.registerTool(
153
+ 'qa_status',
154
+ {
155
+ title: 'QA status',
156
+ description: 'Health snapshot of a page Qiksy is watching: URL, error/warning/form counts, and its isolated-login name if any. Defaults to the active tab; pass tabId (from qa_tabs) to target a specific tab.',
157
+ inputSchema: { tabId: z.number().int().optional().describe('Target tab (from qa_tabs); omit for the active tab') },
158
+ },
159
+ async ({ tabId }) => {
160
+ try {
161
+ return asText(await callExtension('qa_status', { tabId }));
162
+ } catch (e) {
163
+ return asError(e);
164
+ }
165
+ },
166
+ );
167
+
168
+ server.registerTool(
169
+ 'qa_findings',
170
+ {
171
+ title: 'QA findings',
172
+ description: 'Findings on a page (console/JS/network/a11y/markup/submit) — each with a CSS selector and detail. Filter by severity; pass tabId (from qa_tabs) to read a specific tab / isolated login.',
173
+ inputSchema: {
174
+ severity: z.enum(['all', 'error', 'warning']).default('all').describe('Severity filter'),
175
+ tabId: z.number().int().optional().describe('Target tab (from qa_tabs); omit for the active tab'),
176
+ },
177
+ },
178
+ async ({ severity, tabId }) => {
179
+ try {
180
+ return asText(await callExtension('qa_findings', { severity, tabId }));
181
+ } catch (e) {
182
+ return asError(e);
183
+ }
184
+ },
185
+ );
186
+
187
+ server.registerTool(
188
+ 'qa_export',
189
+ {
190
+ title: 'QA export',
191
+ description:
192
+ 'Full qa-export/v1 bundle for a page: findings (with selectors), failed requests (with server error bodies), detected form structure, recorded repro steps, env, and isolated-login name. Read this, then fix the underlying issues. Pass tabId (from qa_tabs) to target a specific tab / isolated login.',
193
+ inputSchema: { tabId: z.number().int().optional().describe('Target tab (from qa_tabs); omit for the active tab') },
194
+ },
195
+ async ({ tabId }) => {
196
+ try {
197
+ return asText(await callExtension('qa_export', { tabId }, 30_000));
198
+ } catch (e) {
199
+ return asError(e);
200
+ }
201
+ },
202
+ );
203
+
204
+ // ── Command tools (drive Qiksy's OWN UI / analysis; never the app under test) ─
205
+ const tabIdArg = z.number().int().optional().describe('Target tab (from qa_tabs); omit for the active tab');
206
+
207
+ server.registerTool(
208
+ 'qa_open_panel',
209
+ {
210
+ title: 'Open Qiksy panel',
211
+ description:
212
+ "Open (or close) the Qiksy side panel on a tab and optionally jump to a tab: 'forms' | 'findings' | 'tour' | 'tools' | 'history' | 'roles'. Controls Qiksy's OWN UI only — it does not touch the page under test. Set open:false to close.",
213
+ inputSchema: {
214
+ tabId: tabIdArg,
215
+ open: z.boolean().default(true).describe('true to open, false to close'),
216
+ tab: z.enum(['forms', 'findings', 'tour', 'tools', 'history', 'roles']).optional().describe('Which panel tab to show'),
217
+ },
218
+ },
219
+ async ({ tabId, open, tab }) => {
220
+ try {
221
+ return asText(await callExtension('qa_open_panel', { tabId, open, tab }));
222
+ } catch (e) {
223
+ return asError(e);
224
+ }
225
+ },
226
+ );
227
+
228
+ server.registerTool(
229
+ 'qa_run_audit',
230
+ {
231
+ title: 'Run accessibility & markup audit',
232
+ description:
233
+ 'Run Qiksy\'s a11y/markup audit on the current DOM (unlabeled controls, missing names, WCAG AA contrast, broken images, duplicate ids, positive tabindex, …) and return the findings. Also refreshes the panel. Static, non-destructive — reads the DOM only.',
234
+ inputSchema: { tabId: tabIdArg },
235
+ },
236
+ async ({ tabId }) => {
237
+ try {
238
+ return asText(await callExtension('qa_run_audit', { tabId }, 30_000));
239
+ } catch (e) {
240
+ return asError(e);
241
+ }
242
+ },
243
+ );
244
+
245
+ server.registerTool(
246
+ 'qa_tour',
247
+ {
248
+ title: 'Exploratory tour / coverage checklist',
249
+ description:
250
+ 'Return a generated per-page exploratory-testing checklist (from detected forms + interactive surfaces), with items auto-checked from the recorded session. Great raw material for writing test cases. Returns markdown + structured items.',
251
+ inputSchema: { tabId: tabIdArg },
252
+ },
253
+ async ({ tabId }) => {
254
+ try {
255
+ return asText(await callExtension('qa_tour', { tabId }, 20_000));
256
+ } catch (e) {
257
+ return asError(e);
258
+ }
259
+ },
260
+ );
261
+
262
+ server.registerTool(
263
+ 'qa_report',
264
+ {
265
+ title: 'Generate session report',
266
+ description:
267
+ 'Generate Qiksy\'s self-contained HTML session report (coverage stats, per-page table, steps with screenshots, findings) from the recorded session and return the HTML. Requires History recording to have captured steps; otherwise returns a note.',
268
+ inputSchema: { tabId: tabIdArg },
269
+ },
270
+ async ({ tabId }) => {
271
+ try {
272
+ return asText(await callExtension('qa_report', { tabId }, 45_000));
273
+ } catch (e) {
274
+ return asError(e);
275
+ }
276
+ },
277
+ );
278
+
279
+ server.registerTool(
280
+ 'qa_spotlight',
281
+ {
282
+ title: 'Spotlight an element',
283
+ description:
284
+ 'Visually highlight (dim + outline) an element on the page by CSS selector, so the human tester can see what the agent is referring to. A non-destructive overlay — it does not click, focus, or modify the element.',
285
+ inputSchema: {
286
+ tabId: tabIdArg,
287
+ selector: z.string().describe('CSS selector of the element to spotlight'),
288
+ label: z.string().optional().describe('Optional caption shown on the highlight'),
289
+ },
290
+ },
291
+ async ({ tabId, selector, label }) => {
292
+ try {
293
+ return asText(await callExtension('qa_spotlight', { tabId, selector, label }));
294
+ } catch (e) {
295
+ return asError(e);
296
+ }
297
+ },
298
+ );
299
+
300
+ async function main() {
301
+ await server.connect(new StdioServerTransport());
302
+ log('MCP stdio server ready');
303
+ }
304
+ main().catch((e) => {
305
+ log('fatal', e);
306
+ process.exit(1);
307
+ });