Claude sessions could not work inside the user's own profile: the desktop app's browser lost synthetic pastes and typing in Medium's editor, and a browser-wide --remote-debugging-port would hand every session to any local process. The connector is a built-in extension, off until turned on in Settings > Extensions, that serves MCP on 127.0.0.1 behind a per-install token and drives one tab at a time through that tab's webContents.debugger. - Host: a "tab-control" capability (built-in add-ons only) for listing, opening, navigating, closing and focusing tabs, handing out web tabs' webContents (never Settings, extension or home tabs), and a toolbar chip plus tab outline while a client is connected; the chip's X stops it. Built-in add-ons can ship "defaultEnabled": false. - Tools: list_tabs, open_tab, navigate, activate_tab, close_tab, screenshot, read_page_text, snapshot (accessibility tree with refs), click, type (insertText + real Enter), press_key, paste_html / paste_text (system clipboard + webContents.paste, clipboard restored), upload_file (DOM.setFileInputFiles), wait_for, and evaluate behind its own switch. - Safety: first use of each site asks the user; password and one-time-code fields are refused; uploads only from folders the user added; requests from web pages or foreign Host headers are refused; every action is logged with a timestamp in the panel and actions.log. - Claude Desktop runs stdio-shim.cjs through the Theseus executable in Node mode. - Tests: unit (protocol, auth, policy, shim) and an end-to-end run on a throwaway profile that types, pastes HTML with a heading and a link, and uploads a PNG on a local page.
12 KiB
Claude connector — design and plan
An opt-in MCP server built into Theseus, so a Claude client on the same computer (Claude Code, Claude Desktop) can work inside the user's real, signed-in profile: open a tab, read it, click, type, paste rich text into an editor, upload a file.
Why
On 2026-10-22 a Claude Code session had to paste a prepared story into a Medium draft as the user's author account. The Claude desktop app's built-in browser could not do it: its synthetic paste arrived empty and its typed text never reached Medium's editor. The two other routes are worse:
--remote-debugging-porton the main profile opens a browser-wide DevTools port. Any local process can attach and read every tab, cookie and session.- Theseus is a desktop app, and these sessions have no computer-use access to drive it from outside.
So the browser itself has to offer a narrow, user-controlled door.
Shape
A built-in add-on, bundled-addons/claude-connector/, following the Pithos
pattern: activate(api) starts a small HTTP server on 127.0.0.1 from the
main process and registers a sidebar panel. What it needs from the browser
that no add-on API offered yet comes through one new host capability,
tab-control, which only built-in add-ons can hold.
Claude Code ──HTTP (Bearer token)──► 127.0.0.1:<port>/mcp ─┐
Claude Desktop ─stdio─► stdio-shim.cjs ──HTTP──────────────┤
▼
claude-connector add-on (main process)
policy: allowlist · password guard · upload folders · log
│ api.tabControl (tab-control capability)
▼
main.js: tabs, open/close/navigate, indicator
│ webContents.debugger (CDP, per tab)
▼
one tab's page
Off by default
- The add-on ships with
"defaultEnabled": false. main.js puts any built-in add-on with that flag intosettings.disabledAddonsthe first time it sees it (remembered insettings.addonsDefaultOffSeen, so the user's later choice is never overridden). The user turns it on in Settings › Extensions › Claude connector. - While it is off, nothing listens. When it is on, the panel has a second switch, Accept connections, on by default once the extension is turned on, so the stop button can close the port without uninstalling anything.
Transport and authentication
- MCP Streamable HTTP at
http://127.0.0.1:<port>/mcp. POST carries JSON-RPC; GET opens an SSE stream (kept alive with comments, used as the "connected" signal); DELETE ends a session. - Binds
127.0.0.1only. The port is picked once per install (first free in 47800–47899, else any) and kept, so the config the user pasted stays valid. - A random 256-bit token per install, base64url. Every request needs
Authorization: Bearer <token>, compared in constant time. - DNS-rebinding and browser guard: the
Hostheader must be127.0.0.1:<port>orlocalhost:<port>, and any request carrying anOriginheader is refused. A web page cannot reach the server even if it guessed the token. - Revoke in the panel issues a new token, which drops every client at once. Stop (toolbar chip or panel) closes the port.
- Claude Desktop speaks stdio, so the add-on ships
stdio-shim.cjs, run by the Theseus executable itself in Node mode (ELECTRON_RUN_AS_NODE=1, the same mode the extension sandbox already uses). It reads the port fromextensions-data/claude-connector/endpoint.jsonand the token fromTHESEUS_MCP_TOKEN, and relays line-delimited JSON-RPC to the HTTP endpoint.
Per-tab control, no browser-wide port
- Each controlled tab gets
webContents.debugger(CDP 1.3) attached on its own webContents. No--remote-debugging-port, no DevTools target list. - main.js already attaches that debugger to freeze background tabs
(
setTabFrozen). The connector shares the attachment: it attaches only if nothing is attached, and detaches only what it attached itself. - Only ordinary web tabs (
http:,https:) are controllable. Settings, add-on pages, the home page,file:and internal schemes are refused: a Settings tab would let a client switch the connector's own safety settings. - A dormant restored tab is loaded and a frozen one thawed before it is used.
While a tab is controlled it gets focus emulation (
Emulation. setFocusEmulationEnabled), so editors that checkdocument.hasFocus()accept input while the Theseus window is in the background.
Host capability tab-control (addons-host.js + main.js)
api.tabControl, built-in add-ons only, manifest must declare tab-control:
| call | does |
|---|---|
list() |
every tab: id, title, url, kind (web/settings/addon/home), active, pending |
webContents(id) |
the tab's webContents, after loading a dormant tab and thawing a frozen one; throws for non-web tabs |
open(url, {background}) |
new tab through main's own createTab (so BCNR names resolve as typed) |
navigate(id, url) |
main's navigateTab |
close(id), activate(id) |
as the tab strip does |
setStatus({connected, clients, busy, tabIds}) |
drives the toolbar indicator and the per-tab marker |
onStop(cb) |
the indicator's stop button |
Indicator
- A chip in the toolbar, next to Downloads: Claude with a dot that pulses while a tool call runs. Visible whenever a client is connected (an SSE stream is open or a request arrived in the last 2 minutes). Clicking it opens the connector panel; its ✕ stops the connector at once (closes the port, ends sessions, detaches every debugger).
- Each tab the client is controlling gets an outline marker in the tab strip.
Tools
| tool | how |
|---|---|
list_tabs |
tabControl.list(); tabs on sites not yet approved show only their host |
open_tab(url, background?) |
approval for the target origin first |
navigate(tab_id, url | action) |
url, or back/forward/reload |
close_tab(tab_id), activate_tab(tab_id) |
|
screenshot(tab_id, full_page?) |
Page.captureScreenshot, scaled to CSS pixels so coordinates match click(x, y); a hidden tab is brought to the front first (hidden views render no frames) |
read_page_text(tab_id, max_chars?) |
innerText read in an isolated world |
snapshot(tab_id, interactive_only?) |
Accessibility.getFullAXTree, compacted to role "name" [ref=eN] lines; refs map to backendDOMNodeId |
click(tab_id, ref | x,y) |
DOM.scrollIntoViewIfNeeded + DOM.getContentQuads, then Input.dispatchMouseEvent press/release |
type(tab_id, text, ref?) |
DOM.focus, then Input.insertText per line and a real Enter (Input.dispatchKeyEvent) between lines; mode: "keys" sends every character as keyDown/keyUp |
press_key(tab_id, key, modifiers?) |
Enter, Tab, Backspace, arrows, ctrl+a … |
paste_html(tab_id, html, ref?), paste_text(tab_id, text, ref?) |
write the real clipboard (clipboard.write({html, text})), webContents.paste(), then put the user's clipboard back |
upload_file(tab_id, ref, path) |
DOM.setFileInputFiles on an <input type=file> |
wait_for(tab_id, selector | text, timeout_ms?) |
polls in an isolated world |
evaluate(tab_id, expression) |
Runtime.evaluate in the page; listed only while Allow evaluate is on (off by default) |
Page reads run in an isolated world (Page.createIsolatedWorld), so page
scripts can neither see them nor tamper with the helpers.
Safety
- Per-site allowlist. The first time a client acts on an origin (opens it, navigates to it, reads or drives a tab on it), Theseus asks: Allow Claude to control medium.com? — Allow (remembered), This session only, or Don't allow. A refusal is remembered for 10 minutes, so a client cannot spam the prompt. The panel lists approved sites with a remove button.
- Never types into password fields. Before
type,press_key,paste_*andevaluate-free input, the focused element (followed into shadow roots and same-origin frames) is checked in an isolated world:input[type=password],autocomplete=current-password|new-password| one-time-code, or a cross-origin frame that cannot be inspected → refused.clickon a ref that is a password field is refused too, so a client cannot focus one by ref and then type. - Upload folders.
upload_fileresolves the real path (symlinks and junctions followed) and refuses anything outside the folders the user added in the panel (none by default), anything that is not a regular file, and files over 100 MB. The ref must be an<input type=file>. - evaluate is a separate switch, off by default, with a warning in the panel: it runs arbitrary script as the page.
- Action log. Every call is logged with an ISO timestamp, the tool, the
tab's origin, a short summary of the arguments (typed and pasted text is
logged by length and first 60 characters) and the outcome. The panel shows
the newest entries live; the log is kept in
extensions-data/claude-connector/actions.log(JSON lines, rotated at 1 MB).
What it does not defend against
- A program already running as the user. The token sits in the add-on store
(
extensions-data/claude-connector.json) next to the profile's cookies, which such a program can read anyway. The connector keeps the door shut to web pages and to other machines, and keeps the user in charge of which sites a connected client may touch. - A site that changes a field's type after the focus check. The check runs right before each input; what the page does in between is up to the page.
Files
bundled-addons/claude-connector/—addon.json,index.js(activate, settings, panel messages),lib/mcp-server.cjs(HTTP + JSON-RPC + sessions),lib/tab-driver.cjs(CDP per tab),lib/tools.cjs(tool definitions and handlers),lib/policy.cjs(allowlist, upload paths, password guard script),lib/action-log.cjs,panel.html,stdio-shim.cjs,README.md.addons-host.js—tab-controlcapability andapi.tabControl.main.js— thetabControlhook, indicator state,defaultEnabled:falsehandling,claude-connectorkept as a reserved first-party id.preload.js,chrome.html— the indicator chip and the tab marker.tests/claude-connector.test.mjs— launches Theseus on a scratch profile (THESEUS_USER_DATA,THESEUS_NO_UPDATE_CHECK), with the connector pre-enabled in that profile, and drives a local test page over MCP: type into a rich editor, paste HTML with headings and links, upload a PNG, refuse a password field and an upload outside the folder.tests/claude-connector-unit.test.mjs— protocol, auth and policy checks that run under plain Node.- README section with the
claude mcp addcommand.
Steps
- Host:
tab-controlcapability in addons-host.js; hook, indicator state, default-off handling in main.js; chip in chrome.html/preload.js. - Add-on: server, driver, tools, policy, log, panel, shim.
- Tests: unit (plain Node), then the Electron end-to-end test.
- Docs: README section, add-on README.
Not in this round
- Cross-origin iframes (OOPIFs) are not driven; typing into one is refused because its focused element cannot be checked.
- No per-tool approval prompts beyond the per-site one.
- No remote (non-loopback) clients, by design.