mcp-element-picker
v0.10.6
Published
Pick an element on any page in Chrome and hand it to your coding agent, over MCP.
Maintainers
Readme
MCP Element Picker
Point at an element on any page in Chrome, and the coding agent in your terminal gets the
selector, the source file:line, computed styles, outerHTML and a cropped screenshot of
that element.
Any MCP client can drive it. One global install and one folder loaded into Chrome; no build step, no bundler, no dependencies — plain Node ≥ 18 and an MV3 extension.
How it works
Chrome localhost:5747 agent
┌────────────┐ ┌───────────────────────┐ ┌───────────────────┐
│ content.js │ overlay │ │ MCP │ pick_element │
│ probe.js │──selection──▶│ bridge/server.js │◀─stdio──▶│ get_last_selection│
│ background │◀─long-poll───│ (the only place │ │ │
└────────────┘ │ state lives) │ └───────────────────┘
└───────────────────────┘Five parts, each doing exactly one thing:
| File | Role |
|---|---|
| extension/src/content.js | the overlay: highlight, event capture, building the element payload |
| extension/src/probe.js | main-world probe: React/Vue/Svelte/Angular internals and file:line |
| extension/src/background.js | the only piece allowed to reach 127.0.0.1 |
| bridge/server.js | hub on port 5747, holds the state, two long-poll channels |
| mcp/server.js | MCP server over stdio, JSON-RPC by hand, no SDK |
Why background.js and not a request straight from the page: a content script on an https
page cannot reach http://127.0.0.1 — that is mixed content. The extension origin can.
Why probe.js is separate: framework expandos (__reactFiber$, __vueParentComponent) live
in the main world, and a content script sits in an isolated world where they are invisible.
The two talk through a marker attribute and postMessage.
Install
Four steps, once. Nothing to clone, nothing to download by hand.
1. Install the package. It carries both the server and the extension.
npm i -g mcp-element-picker2. Register the server with your agent.
mcp-element-picker --install3. Print where the extension lives. Copy the path it prints.
mcp-element-picker --extension-path4. Load that folder into Chrome. chrome://extensions → turn on Developer mode →
Load unpacked → paste the path from step 3.
Restart your agent, and /pick (Claude Code) or $pick (Codex) works in every project.
Install globally rather than through npx: the extension folder has to keep the same path,
because Chrome remembers where an unpacked extension lives. A global install puts it at a
stable place and npm update -g mcp-element-picker refreshes it in situ, so Chrome never
notices. npx caches packages in a temporary directory that gets pruned, and Chrome would
one day report the extension as missing.
Step 2 writes three files, all under your home directory:
| File | What goes in |
|---|---|
| ~/.claude.json | mcpServers.picker, merged in — other servers and keys are left alone |
| ~/.claude/commands/pick.md | the /pick command |
| ~/.claude/commands/picked.md | the /picked command |
With --client codex it is ~/.codex/config.toml (one [mcp_servers.picker] section) and
~/.agents/skills/{pick,picked}/SKILL.md instead. With no --client at all, every supported
client found on the machine gets its own. Running it twice changes nothing.
Claude Code holds ~/.claude.json in memory while it runs and writes its own copy back on
exit, so run step 2 with it closed. The same applies to claude mcp add.
Any other MCP client. The server is a plain stdio MCP server; nothing about it is specific to the two clients above. Put this wherever that client keeps its MCP servers:
{ "mcpServers": { "picker": { "command": "npx", "args": ["-y", "mcp-element-picker"] } } }npx is used even after a global install: it finds the installed copy without going to the
network, and it resolves the Windows .cmd shim that a bare command name would not.
| Client | Config file |
|---|---|
| Claude Code, all projects | ~/.claude.json, top-level mcpServers |
| Claude Code, one project | .mcp.json in the project root |
| Codex | ~/.codex/config.toml, [mcp_servers.picker] (TOML, not JSON) |
| Claude Desktop | %APPDATA%\Claude\claude_desktop_config.json (macOS: ~/Library/Application Support/Claude/) |
The setup panel — right-click the toolbar icon — shows the right command and snippet per client, with copy buttons, and opens that section by itself when nothing is connected.
From a clone. npx is wrong there — the config has to point at your files:
node /path/to/pickerPlugin/install.js # every client found here
node /path/to/pickerPlugin/install.js --client claude-code # just one
node /path/to/pickerPlugin/install.js --project C:/app # into one project's .mcp.jsonNot on the Chrome Web Store. The extension ships inside the npm package instead, which is why step 3 exists. That also means it updates with the package rather than on Google's schedule — at the cost of Developer mode and Chrome's warning about it at startup.
How the pieces find each other
The bridge on port 5747 starts itself the first time an agent uses the picker, and needs no attention. If no agent is running yet the picker still works: the extension holds the selection and delivers it as soon as one connects.
The extension and the server never refer to each other by path — they meet on that port. So the extension can sit anywhere Chrome likes, the server can be updated underneath it, and neither breaks the other.
That also means several agents share one bridge. Claude Code and Codex running at once each
spawn their own MCP server process, and whichever gets there first brings the bridge up; the
other finds it and joins. They share one selection: if one agent reads it, it is marked read
for both. Picks started by pick_element are routed back by request id, so two agents
waiting at once each get their own click.
The bridge's lifetime is tied to whichever MCP server happened to spawn it — on Windows a child cannot outlive its parent's job object, detached or not. Restarting one agent can therefore take the bridge down for the other until the next tool call revives it. Nothing is lost but an unread selection, which lives in the bridge's memory.
Using it
Shortcuts. /pick in Claude Code, $pick in Codex: start the picker and wait for a
click. With an argument they also state the task:
/pick make this button wider (Claude Code)
$pick make this button wider (Codex)
/picked $picked take what was already picked with Alt+Shift+PWith no argument the agent just describes what was picked and changes nothing.
Both are plain markdown, installed globally so they work in every project:
| Client | Shape | Location |
|---|---|---|
| Claude Code | slash command, one .md per command | ~/.claude/commands/ |
| Codex | skill, a directory with SKILL.md | ~/.agents/skills/ |
Codex used to read ~/.codex/skills and still does, but ~/.agents/skills is the
cross-agent location it moved to; the installer writes there and clears out a copy an older
version of itself left behind, so the same skill is not listed twice. For Claude Code,
--project <dir> puts the commands in the project's .claude/commands/ instead, where they
commit with the repo.
The agent asks you to point. You write "fix this button" — the agent calls
pick_element, the overlay turns on in the active tab, you click, the agent gets the
element. The tool blocks until you click, so the conversation does not break in half.
You pick first. Click the toolbar icon — or Alt+Shift+P — then click an element and
type "make it wider". The agent takes it with get_last_selection, or you use /picked.
By default the icon picks straight away, because that is what you reach for it to do, and
the setup panel sits on the icon's right-click menu, and on chrome://extensions →
Details → Extension options, which Chrome provides itself and which therefore works
even if the menu does not. The panel has a checkbox that swaps
the two round, if you would rather click for the panel and right-click to pick. Either way
both actions stay on that menu, so nothing becomes unreachable, and Alt+Shift+P always
picks.
Shift+click cannot be the second action, which is why it is a setting rather than a
modifier: chrome.action.onClicked is handed only the tab and never the modifier keys, and
it does not fire at all while the action has a popup. The setting works by putting that
popup back — the same switch Chrome itself uses to decide which of the two happens.
In the overlay:
| Key | Action |
|---|---|
| click | pick and send |
| Shift+click | add to the selection without sending, or take it back out |
| Alt+click | pick just that one and jump to the note field |
| Ctrl+↑ / Ctrl+↓ | parent / first child |
| Ctrl+← / Ctrl+→ | previous / next sibling, wrapping around |
| Shift+Enter | send the selection with the note |
| Esc | cancel — same as the × in the panel's corner |
Picking happens on pointerup, not on click: Chrome does not dispatch click at all on
disabled form controls, so a disabled button could not be picked otherwise.
The panel can be dragged anywhere by its background — the cursor says so — and stays where you put it, on this and every later page. Its position is clamped back inside the viewport whenever it is shown or the window is resized, so a panel dragged to the edge of a wide screen is still reachable on a narrow one rather than stranded off it.
Hovering the panel itself highlights nothing — the picker ignores it. Next to the count of picked elements there is a clear button that empties the set without closing the picker.
There is always a note field at the bottom of the panel. Its text travels with the selection and reaches the agent as the first line — as an instruction, not as trailing detail. You can write it before clicking too: then a plain click sends the element along with the text.
The extension icon
The badge shows how many elements you picked that no agent has read yet. A selection made in
response to pick_element never counts: it was taken immediately. Only what you picked on
your own, by clicking the icon or pressing Alt+Shift+P, is counted.
A red ! instead of a number means there is no bridge — no agent is running. You can still
pick; the extension holds the selection until a session appears.
The setup panel shows the same number, and a Clear button if you change your mind.
MCP tools
| Tool | What it does |
|---|---|
| pick_element | turns the picker on and waits for a click (prompt, screenshot, timeoutSeconds) |
| get_last_selection | the last selection, without starting the picker |
| picker_status | whether the bridge is alive and the extension connected |
| cancel_pick | clear a wait that is stuck |
What the agent receives
The selector is built bottom-up and stops as soon as it is unique: #id first, then
[data-testid], [name], then stable classes, and only if nothing else works,
:nth-of-type(). Hashed classes (css-1a2b3c4, sc-xxxx, svelte-xxxx) are dropped from
the selector but kept in the classes field. Computed styles are stripped of default values.
{
"selector": "div[data-testid=\"checkout-card\"] > button.primary",
"source": { "framework": "react", "file": "src/BuyButton.tsx", "line": 42, "column": 8 },
"componentStack": ["BuyButton", "Card", "App"],
"text": "Buy now",
"rect": { "x": 43, "y": 434, "width": 87, "height": 37 },
"styles": { "background-color": "rgb(124, 92, 255)", "border-radius": "6px" },
"outerHTML": "<button class=\"primary\" type=\"submit\">Buy now</button>"
}The element screenshot comes back as a separate MCP image block, so the agent actually sees it rather than reading a description.
Passwords, tokens and card fields are redacted before anything leaves the page: picking a login form must not carry credentials into the agent's context.
Framework support
Verified by running test/frameworks.html, which mounts each framework and puts a real
exchange with probe.js through it:
| Stack | framework | source | componentStack |
|---|---|---|---|
| React 16–18 (classic JSX) | react | src/BuyButton.jsx:42:8 | yes |
| React 19 / Next 15 | react | coordinates inside a bundle chunk | yes |
| Vue 3 | vue | file without a line (__file) | yes |
| Vue 2 | vue2 | file without a line (__file) | yes |
| Svelte | svelte | src/Card.svelte:12:3 | no |
| Angular | angular | no | yes |
| Plain HTML | none | no | no |
About the method: React, Vue 3 and Vue 2 are the real thing, from a CDN. For React, __source
is supplied by hand — exactly what the dev JSX transform does. Svelte and Angular are checked
against the shape of their dev APIs (__svelte_meta, window.ng.getComponent), not
against a real build, which would need their compilers. So what is verified there is our
parsing, not their output.
React 19 removed _debugSource, so the source is dug out of fiber._debugStack instead.
With webpack that frame carries a real path; with Turbopack it carries coordinates inside a
chunk whose name is often a hash. Where the path looks like a chunk, the service worker
fetches the source map and translates it back — see extension/src/sourcemap.js. Where the
bundler already hands out real paths (webpack's eval devtools, Vite's raw ESM, React 16–18's
compile-time _debugSource) no map is fetched at all.
On Turbopack that translation is unreliable, and measured to be so. The stack is walked
fiber by fiber and stops at the first one carrying frames; sometimes those frames are the
app's and the result is a real .tsx path, sometimes they are all Turbopack's own dev
runtime and nothing usable comes back. Collecting frames across several fibers would fix it
and is not done. On that stack, treat componentStack as the reliable handle: a component
name greps in a second, which is most of what a file path would have bought.
Not detected at all: Preact, Solid, Qwik, Lit and other web components, Alpine, Ember. For
those framework is "none" — the selector, styles, HTML and screenshot arrive as usual,
only the component information is missing.
To run the matrix again:
npx --yes http-server -p 5801 .
# open http://127.0.0.1:5801/test/frameworks.htmlTests
npm testUnit tests for the TOML section editor and the source-map reader — VLQ, mappings, path
normalisation — and then an
end-to-end run that starts the bridge, the MCP server and a fake extension and drives the
whole path over real HTTP and JSON-RPC: selection, screenshot, the note, Esc, the unread
counter, reading a selection back.
The overlay itself is checked by hand on a fixture that loads content.js and probe.js on
an ordinary page with a chrome.runtime stub:
npx --yes http-server -p 5801 .
# open http://127.0.0.1:5801/test/fixture.html and press "Arm picker"Known limits
- One element, one top-level frame. Inside an
<iframe>theiframeitself is picked (all_framesis off on purpose: otherwise the overlay is drawn in every frame). - Elements with
pointer-events: nonecannot be picked: the browser does not treat them as hit targets, and events go to an ancestor instead. chrome://, the Web Store and extension pages are off limits to every extension, so the picker does not work there.- The screenshot is taken from the visible area, so the element has to be in the viewport at the moment you click.
- The bridge listens on
127.0.0.1only and exposes nothing outward, but it has no authentication: any local process can read the last selection.
