Command Palette

Search for a command to run...

UnylyUnyly
Весь каталог

Web Search Neo

БесплатноНе проверен

Free, API-keyless MCP for resilient web search and safe, visible Chrome automation—with dynamic companion status, forms, uploads, and WebGL testing.

GitHubEmbed

Описание

Free, API-keyless MCP for resilient web search and safe, visible Chrome automation—with dynamic companion status, forms, uploads, and WebGL testing.

README

Web Search Neo — free MCP web search and visible browser automation

Web Search Neo

Free, API-keyless web search and visible Chrome automation for AI agents through MCP.

MCP Python 3.10–3.13 No API key required Chrome automation

Web Search Neo is an MCP server that gives a model two tools — web_info to look, web_action to act — and behind them four capabilities:

Capability What it is
Search Text search with automatic fallback across independent engines. No paid API, no provider key.
Your own Chrome The already-open, signed-in browser you are looking at, driven through a local companion extension: tabs, forms, uploads, games, screenshots.
Perception An accessibility outline, readable text, semantic element lookup, the page console, and its HTTP traffic.
Isolation Separate Selenium profiles — temporary, persistent, or attached — when a clean or headless browser is preferable.

DuckDuckGo is the default search route. Brave, Mojeek, Yahoo, Bing, and Startpage are available as fallbacks.

Contents

Quick start

Requirements: Python 3.10–3.13 on PATH and Google Chrome 116+ for rendered browser tools.

git clone https://github.com/NeoXider/web-search-neo.git
cd web-search-neo
python -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
python main.py

The server uses MCP over stdio. Keep stdout reserved for MCP messages; rotating diagnostic logs are written to msp_server.log. The companion bridge runs as its own process and keeps a separate rotating log at %LOCALAPPDATA%\WebSearchNeo\bridge-daemon.log, or $XDG_DATA_HOME/WebSearchNeo/bridge-daemon.log — by default ~/.local/share/WebSearchNeo/bridge-daemon.log — because two processes rotating one file collide on Windows.

For Linux/macOS activation and detailed setup/troubleshooting, see INSTALL.md.

Connect to LM Studio

Open LM Studio's MCP configuration and add:

{
  "mcpServers": {
    "web-search-neo": {
      "command": "python",
      "args": ["main.py"],
      "cwd": "C:/path/to/web-search-neo"
    }
  }
}

The Python executable is intentionally resolved through PATH, not pinned to a machine-specific absolute interpreter path. Restart or toggle the MCP server after changing the configuration. A ready-to-edit example is included in mcp_servers.json.

Examples

Every example below is the argument object of one of the two tools: web_action takes {"actions": [...]} and performs 1–32 ordered actions; web_info takes {"topic": ..., "params": {...}} and reads state without changing anything. One session_id is one page — reuse it across calls.

1. Search and read a result

{
  "actions": [
    {"action": "search", "query": "model context protocol servers", "num": 5},
    {"action": "fetch_text", "url": "https://modelcontextprotocol.io/", "max_chars": 4000}
  ]
}

Search first, then read the page you picked. fetch_text needs no browser at all; fetch_many reads up to 16 URLs concurrently.

2. Open a page and read it the way the agent does

{"actions":[{"action":"open","url":"https://example.com","session_id":"demo"}]}

Opens a tab in your own Chrome, in the visible 🟢 AI tab group.

{"topic":"page_outline","params":{"session_id":"demo","limit":60}}

Returns an indented tree of roles, accessible names, states, ref:<epoch>:N handles, and on-screen boxes — the page as a screen reader sees it, not as HTML.

{"topic":"find","params":{"session_id":"demo","query":"more information link","role":"link"}}

Ranked matches for a plain-language query when a CSS selector would be a guess.

{"actions":[{"action":"click","selector":"a[href*='iana.org']","session_id":"demo"}]}

Clicks it. click and wait also accept a ref: handle or a piercing path in the Selenium-backed profile modes.

3. Fill in a form and submit it

{
  "actions": [
    {"action": "fill", "session_id": "intake",
     "fields": {"#full-name": "Ada Lovelace", "#topic": "demo", "#subscribe": true},
     "files": {"#attachment": "C:/docs/report.pdf"}},
    {"action": "submit", "session_id": "intake",
     "form_selector": "#intake-form", "submit_selector": "#submit-button"}
  ]
}

Text fields, <select> options, checkboxes, and file inputs in one call, then a submit that runs native browser validation. fill reports filled and a per-selector errors map, so a partial failure is visible rather than silent. → Complex forms: the long version

4. Find out why a page broke

{"topic":"console","params":{"session_id":"demo","levels":["error"],"limit":20}}
{"topic":"network","params":{"session_id":"demo","only_errors":true,"limit":20}}

The page's own console — including uncaught exceptions with stack frames — and every failed or 4xx/5xx request as method status type ms size url, back to and including the navigation that opened the session: capture is armed before the first request, so nothing has to be reloaded to be seen. Use output="json" on network to get the request id, then read one body with the network_body topic.

5. Play a game frame by frame

With the game already open in session game:

{
  "actions": [
    {"action": "render", "mode": "step", "session_id": "game"},
    {"action": "input", "session_id": "game", "target_selector": "#game",
     "key_actions": [{"key": "ARROW_RIGHT", "action": "hold"}]},
    {"action": "step", "frames": 3, "session_id": "game"},
    {"action": "input", "session_id": "game",
     "key_actions": [{"key": "SPACE", "action": "tap"}]},
    {"action": "release_inputs", "session_id": "game"},
    {"action": "render", "mode": "normal", "session_id": "game"}
  ]
}

In step mode the page is frozen: page time only advances when a frame is released, so the game measures a fixed delta instead of the model's thinking time, and a tapped key stays down for the whole frame an engine polls. → Playing games: the long version

6. Work in a tab you already have open

{"topic":"browser_tabs"}
{"actions":[{"action":"attach_tab","tab_id":123,"session_id":"existing"}]}

Lists the tabs in your Chrome with their ids and group names, then claims one without navigating or moving it. close later releases the session and leaves that tab exactly where it was.

Where to go next

{"topic":"capabilities"}

web_info() with no arguments returns the whole agent-facing contract: every action with its required parameters, the observation topics, recipes, pitfalls, limits, and worked examples. web_info(topic="action_schema", params={"action": "input"}) returns one full JSON Schema on demand.

Why Web Search Neo

Strength What it means
Free search Uses public search routes through the maintained DDGS library; no paid search plan or API key.
Resilient fallback Provider health, cooldowns, bounded retries, caching, and an overall deadline prevent one challenged engine from stalling the agent.
Your current Chrome by default New tabs open in the 🟢 AI tab group of the Chrome you already use, so existing logins remain available while automation stays in the background unless show is explicitly requested.
Reusable authorization List and claim existing tabs, use the current signed-in Chrome, a persistent MCP-owned profile, or a DevTools attach window.
Authenticated companion Server and extension prove knowledge of a machine-local secret to each other before a single command crosses the loopback bridge.
One bridge, many clients The companion port belongs to a standalone bridge process, not to whichever agent happens to be running, so Claude Code and LM Studio can drive the same Chrome at the same time and the badge stays ON between calls.
Two-tool MCP surface Models see only web_info and web_action; detailed action schemas are discovered only when needed.
Self-describing contract web_info() with no arguments returns the actions, recipes, pitfalls, limits, and examples, so an external skill file is optional.
Semantic page reading page_outline, page_text, element_text, and find return roles, accessible names, states, boxes, and ref:<epoch>:N handles across open Shadow DOM and same-origin iframes — and element_text hands over the whole content of one element, overflow-clipped tails included.
Visible failures The console and network topics surface console output, uncaught exceptions with stack frames, and every HTTP request with status, type, duration, and size — recorded from the session's first navigation, not from the first time someone asks.
Deterministic frames Gated render modes freeze performance.now()/Date.now() and queue page timers, so a released frame is a fixed delta instead of the agent's thinking time.
Concurrent work Search, HTTP fetches, and independent browser sessions run outside the MCP event loop; eight browser sessions work in parallel by default (the companion popup and WEB_SEARCH_NEO_MAX_SESSIONS both move it). Agents running side by side must each take their own session_id, because that is the only thing separating them inside one server, and may pass agent_label on open so browser_status can say who is where.

Connect your already-open Chrome

The default browser mode is current. It fails with a clear setup error when the companion isn't connected; it does not silently open a different browser.

An agent prepares the bundled companion through the compact MCP contract:

{"actions":[{"action":"setup_current_chrome"}]}

That call opens no page and touches no browsing data. It writes the shared secret into chrome-extension/bridge-token.js, checks the bundled build against whatever is connected, and returns manual_steps — the clicks that are left, with the absolute path of the folder to pick — plus token_ready and token_file. The one thing it can change is the companion itself: a connected build older than the bundled one is asked to reload itself, reported as self_update (done, unsupported, or timeout) with replaced_version.

Chrome does not let a program add an unpacked extension to a browser that is already open: the installed set is signed inside Secure Preferences, policy installs need a packed CRX behind an update URL, and a Chrome started without a DevTools port cannot be given one later. So three steps stay with the user:

  1. Open chrome://extensions.
  2. Switch on Developer mode, then choose Load unpacked.
  3. Select this repository's chrome-extension folder.

Keep Web Search Neo Companion enabled. Click its toolbar icon to open the companion panel: it shows the bridge state and controlled-tab count, and offers Reconnect, Release tabs, a GitHub release/version check, a repository link, and a persistent on/off switch. Turning the switch off closes the bridge and detaches every controlled tab. Its toolbar badge reads ON while the companion is connected and authenticated to the bridge daemon, which is between agent calls too, not only while one is running. Nobody has to create or copy a token: it is written whenever the bridge comes up and on every setup_current_chrome call.

Earlier revisions tried to perform those clicks through Windows UI Automation. That code is gone: it depended on the interface language, on which window had focus, and on a folder picker the automation backend does not enumerate. There is no automatic substitute. If you would rather not install an extension at all, profile_mode="temporary" and profile_mode="persistent" drive a Selenium browser that needs no companion.

The bundled companion is version 1.10.1. Chrome does not refresh an unpacked extension by itself, but from 1.3.1 the server does it instead: the worker understands a runtime.reload command, and setup_current_chrome sends it whenever the connected build is older than the bundled one. Upgrading onto 1.3.1 is the last one that costs a click, because the build being replaced is the build that has to understand the command; the very first install still needs the three steps above, which no program can perform.

So press Reload on the companion card at chrome://extensions once, after the pull that crosses 1.3.1. Skipping it on a 1.2.0-or-older install is the loudest case, because that build cannot even authenticate:

  • a service worker from 1.2.0 or older sends no token, so the bridge closes it with code 1008 and the reason Companion token mismatch; reload the extension on chrome://extensions;
  • the badge then stays OFF and the worker retries on a slow ladder, from about ten seconds up to two minutes;
  • the bridge daemon records the rejection in %LOCALAPPDATA%\WebSearchNeo\bridge-daemon.log, not in msp_server.log;
  • a 1.3.0 worker also prints the close code and reason in its own service-worker console; an older one does not, so on a stale install the badge and that daemon log are the signal.

The bridge accepts only the fixed bundled extension ID, binds only to loopback, and never reads Chrome profile files, cookies, or saved passwords directly. The extension uses Chrome's standard tabs, tab groups, and debugger APIs, plus storage for its own session state and alarms for the long waits in its reconnect backoff, with the permissions shown by Chrome.

A badge reading OFF means this companion is not connected and authenticated, which is usually — but not only — because nothing is listening on the bridge port: no daemon has been started since the machine booted, the last one exited after its idle window, or it was stopped with --bridge --stop. That is the normal state of a machine on which no MCP server has run yet, not a fault — but it is no longer the state between two agent calls, which is what it was when the port lived inside the MCP server. The badge also reads OFF in two cases where the port is very much held: when the daemon refused this companion's token, and when a companion in a second Chrome profile authenticated later and displaced it. So OFF is not by itself a reason to start a daemon — read web_info(topic="browser_status") and bridge-daemon.log before concluding the port is free. While nothing listens, the worker retries with an exponential backoff — about 1.5 seconds after the first failure, doubling to a ceiling of one minute, and dropping back to the floor the moment a handshake verifies. Chrome suspends an idle service worker after roughly thirty seconds, which is why the longer waits are chrome.alarms rather than timers: a timer that long would die with the worker. Chrome also logs every refused attempt as an extension error of ours and an extension cannot suppress that, so a red Errors button on the card of an idle machine is expected; before 1.3.1 the worker retried every two seconds and could fill that page with hundreds of identical ERR_CONNECTION_REFUSED lines. Starting the browser or reloading the extension resets the schedule, and the popup's Reconnect button retries immediately.

Keep the companion enabled in one Chrome profile at a time. The bridge holds exactly one companion connection and the most recent authenticated one wins, so a second profile with the companion enabled quietly takes the agent's tabs with it.

Dynamic companion widget

The toolbar popup (also on Alt+Shift+N, its suggested shortcut) is where the companion is switched on, watched, and configured. It is a compact, icon-first widget: one live status line with a pulse marker — Connected, Connecting, Waiting plus the retry countdown, Disabled, or an error state — then three figures: controlled tabs, the parallel-session limit against its ceiling with a small capacity meter, and the bridge endpoint. Icon buttons carry accessible names (aria-label/title): release check, repository link, apply/reset port, apply/reset session limit. A version chip and a GitHub release check round off the top; Settings holds the port and session limit. Everything renders from the status the service worker reports each second — connected, connecting, waiting, disabled, or error — and nothing else: no fabricated connection state, no secrets in the UI. Opening the panel is read-only by construction: it never navigates, submits, closes, or releases anything on its own.

Companion widget connected

While the bridge is merely unreachable, the widget says so and shows when the next attempt lands, which is what tells a stalled bridge apart from a deliberate backoff:

Companion widget waiting for the bridge

Opening the panel — by click or by shortcut — performs no navigation and controls nothing; it only reads status.

Under Settings:

Setting What it does
Bridge port The loopback port the companion dials, 1024–65535. It must match WEB_SEARCH_NEO_BRIDGE_PORT on the server. Applying it stores the value, drops the old socket, and reconnects at once; the port survives a browser restart. A privileged port is refused with the range rather than failing inside a connect nobody is watching.
Protocol access States what the companion will forward. It is a list, not a passthrough: only the DevTools methods this MCP issues reach the tab.

That allowlist is the answer to the obvious question about a companion holding debugger on your signed-in tabs. Authentication proves a peer knows a machine-local secret; it does not make that peer trusted with all of Chrome's protocol. Any method outside the list is refused inside the extension, and a test compares the list against the server's own call sites, so the two cannot drift apart without the suite failing.

The bridge daemon

The listener the companion dials is not inside the MCP server any more. It is a standalone process — bridge_daemon.py — that owns 127.0.0.1:8765, holds the single connection to the extension, and relays commands for any number of local MCP clients. ChromeBridge is now a client of it. Its public surface did not change and neither did the frames the extension exchanges, so nothing an agent calls looks any different.

Three things follow, and they are why the listener moved out:

  • The badge stays ON between calls. The port used to exist only while an agent was running, so the companion spent most of the day disconnected and reconnected on its own backoff schedule whenever a server appeared.
  • Two MCP clients can share one Chrome. Claude Code and LM Studio can now run at the same time; the second server to start used to fail to bind the port with OSError: [Errno 10048] and silently lose profile_mode: "current".
  • The first call after a quiet stretch is not spent waiting. A freshly started server no longer has to sit out whatever reconnect delay the extension had climbed to.

An MCP server starts the daemon as it comes up, if nothing is listening yet, so the companion is connected before the first action rather than during it. It is started detached — with no console window on Windows, in its own session elsewhere, and never inheriting the stdio that carries MCP — and it outlives the server that started it. Nothing is registered for autostart: no service, no scheduled task, no login item. It exists once an MCP server has run, or once you have run one yourself, and it goes away again on its own.

On Windows the launcher deliberately bypasses a uv virtual-environment python.exe/pythonw.exe redirector and starts the base pythonw.exe with CREATE_NO_WINDOW, while preserving the venv through __PYVENV_LAUNCHER__. This matters because a redirector can start the real console interpreter after the original creation flags are gone; combining DETACHED_PROCESS with CREATE_NO_WINDOW does not fix it, because Windows ignores the latter flag in that combination.

Two entry points are yours:

python main.py --bridge         # run the daemon in the foreground
python main.py --bridge --stop  # ask a running daemon to exit

For a companion configured to a non-default port, set the same port in the daemon's environment before starting it (or in the MCP server's env block):

$env:WEB_SEARCH_NEO_BRIDGE_PORT = "18765"; .\.venv\Scripts\python.exe -m web_search_neo.main --bridge

For a hands-off setup, scripts/bridge_autostart.bat [port] launches the daemon through pythonw (no console window), and running scripts/install_bridge_autostart.bat [port] once drops that launcher into the Windows Startup folder so the bridge is up on every logon. Delete the generated startup file to undo. The launcher sets WEB_SEARCH_NEO_BRIDGE_IDLE_SECONDS=0, so a logon-started bridge does not idle-exit before the next browser session.

--bridge exits quietly if another daemon already owns the port, because that one serves just as well. It prints nothing while it runs either: the daemon logs to %LOCALAPPDATA%\WebSearchNeo\bridge-daemon.log, foreground or not, and never to msp_server.log, because two processes rotating one file collide on Windows. --bridge --stop is the one that talks back — it says whether it stopped a daemon or found none — and it never starts one.

Left with neither a companion nor a client attached, the daemon exits after fifteen minutes. A connected companion on its own keeps it alive indefinitely — that is the case where the badge stays on all day. Set WEB_SEARCH_NEO_BRIDGE_IDLE_SECONDS to change the window, or to 0 to disable it; a daemon inherits the environment of the server that spawned it, so setting it in the MCP client configuration is enough.

The daemon reports its version during the client handshake, and versions are ranked by component rather than compared as text — 1.3.10 is newer than 1.3.9, which the string order gets backwards. Only a strictly older daemon is asked to exit and replaced with one of this server's own, so a daemon started before a git pull cannot keep serving the old code.

A daemon that is newer than the server talking to it is used, not replaced. That is the ordinary state of an updated machine, not a fault: an MCP server keeps running the revision it imported at startup, so after a git pull every daemon started from main.py is ahead of the server that started it. Refusing that was unfixable from the user's side — the server evicted the newer daemon, started a replacement from the same updated file, was handed the same newer version again, and after two rounds gave up with an error blaming a second checkout that did not exist. What matters for sharing a companion is the bridge protocol, not the release number, and that is now the only skew that stops the server: the message names the protocol each side speaks instead of guessing at a cause.

Replacing an older daemon still gives up after two attempts, and the error names both versions — and, when this process is running code the checkout no longer has, says so and tells you to restart the MCP server rather than hunt for another clone. The error is latched so that later calls do not restart the tug-of-war, but the latch expires after ten seconds (WEB_SEARCH_NEO_BRIDGE_RECHECK_SECONDS) and the look that follows is passive — it starts no daemon and evicts none — so the state clears itself once the other side is stopped, instead of surviving until someone restarts the server by hand.

web_info(topic="browser_status") reports the link under current_chrome.daemon: linked says whether this server currently holds one, version and pid come from the last handshake this server completed on that port — whether or not it went on to link — and server_version is this server's own, so a skew is visible as two numbers side by side. version and pid are cleared when the link ends; they used to survive it, so a startup_error about the daemon now on the port could sit next to the version of a daemon that had already exited. current_chrome.connected still means what it always did — the companion itself is attached — and a daemon.linked: true with connected: false is exactly the case of a running bridge with Chrome closed.

Bridge authentication

Loopback is not a trust boundary: every process running as the same user can open 127.0.0.1:8765, an Origin header stops web pages but not local programs, and the extension ID is derived from the public key in the manifest, so it is not a secret either. Before this release, a local process that reached the port first received the full companion protocol, including cdp.send on any signed-in tab. Both sides now prove knowledge of a machine-local secret before a single command is executed:

  • a 32-byte random token — 64 hex characters — is minted on first use, by the daemon as it starts or by a server as it connects, and kept in %LOCALAPPDATA%\WebSearchNeo\bridge-token on Windows, or in $XDG_DATA_HOME/WebSearchNeo/bridge-token — by default ~/.local/share/WebSearchNeo/bridge-token — created 0600 on POSIX;
  • the same secret is copied into chrome-extension/bridge-token.js before Chrome is asked to load the folder, so setup stays hands-free. That file is listed in .gitignore and is never committed or shared between machines;
  • the companion sends the token and a fresh 16-byte nonce in its hello. The daemon compares the token in constant time and answers hello_ack with HMAC-SHA256(token, nonce);
  • the companion verifies that proof with WebCrypto and runs nothing until it matches. A peer that sends anything other than a valid ack first — a command above all — is closed immediately;
  • an MCP server authenticates to the daemon with the same hello, marked role: "client", and checks the daemon's proof before relaying anything. Being a relay is not a way around the token: a local process without it is closed on both roles alike.

A newly authenticated connection replaces the previous one, so a companion whose service worker Chrome had suspended reclaims the bridge on reconnect instead of finding it held by a stale socket.

The honest limit: the secret is a file owned by the user account, so any process running as that same user can read it and impersonate either side. This closes the "whoever binds the port first owns the browser" hole; it is not protection against malware already running as you. The real fix is Chrome Native Messaging, where Chrome launches the server itself and no port is listened on at all — it is tracked in TODO.md. An authenticated peer is also unrestricted: cdp.send forwards any DevTools method to a tab, with no method allowlist.

The daemon did not move that boundary, but it did change how long the door stands open. The listener used to exist only while an agent was running — minutes a day on a normal machine, which is the assumption the paragraph above was written under. Now it is held by a process that outlives every agent call and stays up for as long as Chrome keeps the companion attached, which in practice means the whole working day. Nothing about the authentication moved: the bind is still loopback-only, both roles still have to present the token, and a process running as you could always read the token file — same-user processes were never excluded by any of it. What is larger is simply the share of the day during which such a process finds something listening. Close it deliberately with python main.py --bridge --stop, or quit Chrome and let it close itself: a daemon with neither a companion nor a client attached exits after fifteen minutes (WEB_SEARCH_NEO_BRIDGE_IDLE_SECONDS, 0 disables the timer).

Search behavior

The normal agent call is:

{
  "actions": [{
    "action": "search",
    "query": "best local-first MCP tools",
    "num": 5,
    "engine": "duckduckgo",
    "fallback": true,
    "challenge_mode": "fallback"
  }]
}

Send that object to web_action. Search state is readable separately:

{"topic":"search_status","params":{"check_live":true}}

It reports configured engines, current live availability, latency, cooldown state, and detected challenges. A live probe is a diagnostic: a provider that fails during the probe is not pushed into the cooldown used by real searches, so checking status can no longer degrade the next search. Status checks are cached for five minutes; search results are cached for two minutes.

CAPTCHA and challenge modes

  • challenge_mode="fallback" is the default. A challenged provider is skipped immediately and the search continues through another route.
  • challenge_mode="manual" opens visible Chrome and waits up to manual_timeout_seconds=180. If you complete the challenge, the agent receives the open browser session; otherwise the window closes and fallback continues.

Detection answers "is a challenge in the way", not "does this page contain a captcha". It looks for a rendered provider widget — reCAPTCHA, hCaptcha, Cloudflare Turnstile, Yandex SmartCaptcha, PerimeterX, DataDome or AWS WAF — at least 20x20 pixels, walking open shadow roots and same-origin frames rather than only the top document, which is where the last three and anything one frame down were being missed. A bare data-sitekey counts only when the element or a nested frame says captcha, so a chat widget carrying one is not a challenge. Wording counts only in a page heading or in a short interstitial. Every page summary carries challenge_detected, challenge_type, challenge_evidence and captcha_widgets — the last lists every captcha seen, blocking or not, so an article with an hCaptcha in its comment form is reported without being called challenged.

A challenge with no box is the other half of this. An invisible Turnstile renders no picture and no checkbox — the whole widget is a container in the DOM and a hidden field its token would go into — so a visibility test walked straight past it while the site's own submit handler waited for a token that was never coming: the button sits on "Submitting…", no request leaves the page, and the console stays empty. Every page summary therefore also carries invisible_challenge_pending, and with it invisible_challenge naming the vendor, the state (token_empty when the hidden cf-turnstile-response, g-recaptcha-response, h-captcha-response or smart-token field is empty; widget_hidden when the widget rendered before its field existed), the evidence and what to do about it. It gates the form rather than the page, so challenge_detected stays false — but captcha with op=detect reports it, mode='wait' no longer calls it resolved, and a click that made no network request at all while it is pending comes back with submit_blocked_by_challenge and a reason.

Automatic CAPTCHA bypass is intentionally not implemented. The roadmap tracks a future provider-supported, legal integration in TODO.md.

Current Chrome automation

Yes — the agent can work in your normal already-open Chrome while you watch it. New pages go into a visible tab group named 🟢 AI by default — the mascot is there so agent tabs are obvious in a crowded window, and the companion colours the group purple when it is the one creating it:

{
  "actions": [{
    "action": "open",
    "url": "https://example.com",
    "session_id": "demo"
  }]
}

From there, inspect with page_outline, page_text, find, or page_elements, then send ordered fill, upload, click, scroll, and submit actions through web_action using the same session_id. scroll defaults to the viewport centre, uses positive delta_y for down and negative for up, and returns before/after page scroll metrics. Screenshots are returned by the screenshot info topic: its default is the actual current viewport, mode="full_page" captures the document, and mode="region" captures an exact x/y/width/height CSS-pixel rectangle without resizing Chrome.

A session tracks whether it owns its tab, and never navigates one it borrowed. An open on a session that claimed a tab through attach_tab opens the agent's own tab in the 🟢 AI group and hands the borrowed one back — the debugger detaches, and the page the user was reading is neither closed nor navigated. The freed tab id comes back as left_claimed_tab. close removes a tab that the agent opened itself, and leaves a claimed tab open and detached, so the group no longer accumulates abandoned pages; pass close_tab: true to close a claimed one anyway.

Tabs the agent never opened are closed with close_tabs, naming the ids from web_info(topic="browser_tabs"). There is no close-everything switch: closing a tab cannot be undone, so each one is named. Two kinds are skipped rather than closed — pinned tabs, which are the set a person keeps on purpose, and tabs another agent is driving, where closing one would pull the page out from under a running session. include_pinned and include_claimed override each, and every skip says which rule it hit. Whether a tab actually went is decided against a fresh tab list rather than against Chrome's acknowledgement, because chrome.tabs.remove reports failure for a tab the user had already closed by hand; a tab that was already gone is reported as already_gone, not as an error. A session sitting on a closed tab is forgotten and its claim released, so a later action cannot dispatch at a tab id Chrome has since reused. Both close and close_all report what they could not release — a tab that would not close, a debugger that would not detach — instead of answering closed: true over the top of it; closing a session that was never open stays a no-op and says so in note. A tab the user closed by hand frees its session slot the next time the session cap is reached, so the server does not refuse to open a page because of tabs that no longer exist. close_all — and the shutdown hook that runs it — applies the same rule instead of leaving every self-opened tab behind. close_all closes only the sessions carrying your own agent_label unless you pass scope="all", and always reports kept_sessions so a skipped session is never silent. The MCP doesn't read cookies or passwords; the page simply continues using the authorization already present in Chrome.

Teardown is not unconditional, and the exception is the point of it. Tab ids restart with Chrome, so a session that outlived a restart holds a number that now names somebody else's tab. Every teardown path — close, close_all, the shutdown hook, the cap sweep — asks first whether the session's browser run is still the one it was opened in, and if it is not, sends nothing at all and forgets the session. close reports that as browser_gone: true with a note; close_all lists the affected ids under browser_gone and still answers closed_all: true, because nothing of ours was left to leak. Both keys are absent on the ordinary path. The claim is not released either: the daemon dropped its whole registry when the run changed, so a release aimed at the old id could only hit a claim made since. Before this, closing such a session sent tabs.remove for the stale id to the new browser, closed a tab of the user's, and reported success.

browser_status answers the same way rather than describing a stranger's tab: a session whose Chrome is gone is dropped and reported as session_open: false, browser_gone: true, with a next saying to open the page again. It used to read a page summary off whatever tab had inherited the id and answer session_open: true — the same identity bug, in the topic an agent uses to check for it.

If the companion isn't ready, read web_info(topic="browser_status") and run the setup_current_chrome action for the exact steps. profile_mode="auto" falls back to a separate headless Selenium session, so it does not raise another window.

Chrome profile modes

Mode Authorization and lifetime Best for
current (default) Companion extension controls the user's open Chrome. New tabs enter the 🟢 AI group in the background; claimed tabs stay where they are. Authorized sites, work alongside the user, existing tabs.
auto Prefer current; fall back to a headless temporary Selenium profile if the companion is unavailable. Portable background clients.
temporary Clean disposable profile, headless by default; cookies disappear when the session closes. Search, scraping, isolated tests.
persistent MCP owns a durable profile under %LOCALAPPDATA%\WebSearchNeo\profiles\<profile_id>, headless by default. Repeated automation with a separate signed-in profile.
attach MCP connects to a Chrome process that you started with a DevTools port and does not close it on detach. Watching the agent work in an already authorized managed Chrome window.

Working in the same Chrome as the user

In current mode the agent takes no focus. Its tabs open in the background, in an existing window — the group's own window when there is one, otherwise any window that is neither focused nor minimized. It does not open a window of its own, because on Windows a new window raises itself into the taskbar; the one exception is a Chrome left running with no window at all, where a tab has nowhere else to go. Navigation and keyboard input do not activate a tab or focus its OS window either. Only web_action with the explicit show action requests the foreground; it never minimizes, maximizes, restores, or resizes a window.

Chrome starves a tab nobody is looking at, which would make that useless: a hidden tab gets no requestAnimationFrame callbacks at all, timers are clamped to a second and then to a minute, and — measured on Chrome 151 — input dispatched into a tab that has never been shown is silently dropped. So the companion turns on focus emulation for every tab it drives, which restores all three (49 fps, 4.5 ms timers, input delivered). The page believes it is focused and visible while it is not, and stops believing it the moment the debugger detaches.

Two consequences worth knowing. A targeted keyboard action can change DOM focus inside the controlled background page, but it does not take OS focus or change the active user tab. A screenshot of a tab in a window that another window covers can take tens of seconds — Chrome has no fresh pixels to hand over — so screenshot waits up to 45 s there and, if it gives up, says that the window is obscured and that reading and typing are unaffected.

Several agents can drive one Chrome at once, so tab ownership lives in the bridge daemon rather than in any one server process: every tab an agent opens or claims is registered there, an attach_tab for a tab another agent already drives is refused with who holds it and for how long, and a claim is dropped the moment its client's socket does — including an abrupt one, so a crashed agent cannot strand a tab. A client that reconnects re-asserts its claims, and gives up any it lost. When there is no daemon to ask, nothing is guarding the browser and the claim is skipped rather than failing closed.

That register is also readable, which it had to become: a refusal arrives after an agent has already chosen a tab, and the list it chose from said nothing. Every client is told who else is on the bridge — browser_status reports current_chrome.daemon.clients and a claims list naming each held tab, its holder and whether it is ours — and browser_tabs marks a tab another agent is driving with driven_by, so the choice is informed rather than corrected.

Agents inside one MCP server are not separated by any of that. Subagents of a single run share one server process, so session_id is the only thing between them, and two that both leave it at its default "default" are handed the same session and the same tab: each one's navigation and typing lands in the other's page. Nothing here can arbitrate — an MCP call carries no caller identity — so the server does the one useful thing instead of pretending otherwise and says so: sessions_in_use lists the sessions another caller is inside right now, and shared_session with a warning appears on browser_status and on open when this session is one of them. Give each agent its own session_id and they get a tab each. The session cap is per process, so parallel agents share it too; it defaults to eight, the companion popup carries a user's own number to the server, and WEB_SEARCH_NEO_MAX_SESSIONS in the server's environment overrides both. The refusal at the cap names the setting and the agents holding the slots.

Pass agent_label on open or attach_tab and the session records who opened it. Nothing is refused without one — it is a courtesy, not a credential — but two things become possible with it. browser_status lists every session in the server with its owner, tab id and group, the page it was last seen on, when it was created and last used, whether another thread is inside it, and N of M occupancy; capabilities carries the same occupancy under limits. And close_all defaults to scope="mine": it closes the sessions with your label (or, with no label, the unlabelled ones), lists what it left standing under kept_sessions, and only ends everyone's work when asked with scope="all". Process exit still closes everything, because at that point nobody is left to own a session.

In profile_mode="current" the label is also visible in the tab strip: open and attach_tab prefix the tab's title with [agent_label] (or [session_id] without a label), kept across navigations and reloads, while page scripts and every read topic keep seeing the unlabelled title. Headless, persistent, and attach sessions are never labelled. Pass label_tab=false, or set WEB_SEARCH_NEO_LABEL_TABS=0 in the server's environment, to turn it off.

Sessions are pinned to the browser run they were opened in. Tab ids restart with Chrome, so a session that outlived a restart would address whatever tab inherited its number — quite possibly one of the user's. Such a session is dropped, with an error that says to open the page again, and nothing is sent to the new browser on the way out. The companion updating itself counts as a new run, since its reload drops every debugger attachment anyway.

For isolated work, opt into Selenium explicitly. profile_mode="temporary" and profile_mode="persistent" are headless when headless is omitted; pass headless=false only when a visible MCP-owned Chrome is intentional. Persistent mode keeps a durable MCP-owned profile. For attach, the launcher — not the headless argument — determines whether the already-running Chrome is visible or headless.

headless=true cannot be combined with current: that mode drives a Chrome the user is looking at, so the request is refused outright rather than quietly answered by some other browser. auto with headless=true resolves straight to temporary without even probing for the companion — worth knowing, because it is the one way auto reaches a Selenium profile on a machine where current works perfectly.

Start a durable visible Chrome for attach mode (visible is the launcher default):

powershell -ExecutionPolicy Bypass -File scripts\start_managed_chrome.ps1 -ProfileId authorized -Port 9222 -WindowMode visible

Sign in to the sites you need in that window, keep it open, then let the agent attach:

{
  "actions": [{
    "action": "open",
    "url": "https://example.com/",
    "session_id": "authorized",
    "headless": false,
    "profile_mode": "attach",
    "debugger_address": "127.0.0.1:9222"
  }]
}

Chrome 136+ does not allow remote debugging against its normal default data directory. The included launcher therefore uses a separate durable profile. It feels like a normal visible Chrome window, keeps its logins, and remains open after MCP disconnects. See the Chrome remote debugging security change.

An ordinary Chrome window cannot accept a DevTools-port attach retroactively, which is why Web Search Neo includes its own companion extension. Codex's private extension/runtime isn't copied or required.

Attach mode can also connect to a managed Chrome running without a window:

powershell -ExecutionPolicy Bypass -File scripts\start_managed_chrome.ps1 -ProfileId automation -Port 9223 -WindowMode headless

The headless argument cannot hide or reveal a Chrome process that is already running; for attach, -WindowMode on the launcher controls the actual window.

Reading a page

Four observation topics describe an open session, from semantic structure down to raw selectors:

Topic Returns
page_outline An indented tree of roles, accessible names, states, ref:<epoch>:N handles, and boxes. limit=200 nodes, output="text" by default; output="json" returns one object per node with rect, page_rect, center, visible, in_viewport, and occluded.
page_text The rendered text, with headings, list items, and table cells preserved. mode="full" is the whole <body>, same-origin frames and open dialogs included; mode="main" narrows to the main-content sub-tree and drops navigation, header, footer, aside, and form chrome. max_chars=20000, include_links=false.
element_text The whole content of one element — not a clipped slice of the page. selector takes CSS, a fresh ref:<epoch>:N, or an a >>> b piercing path. full_text=true reads textContent, which no rendering filter may drop, so the tail of a scrolled-out code block or a collapsed panel is returned whole. `mode="text"
find Ranked matches for a plain-language query such as "submit request", each with a ref, role, name, box, a match_score (query against the element alone) and the ranking score that adds context. limit=5, capped at 25; visible_only=true by default, so a control the page has not revealed yet is not a candidate; role filters rather than nudges. low_confidence means nothing on the page answers the query — the closest few still come back, as the guesses they are — and ambiguous means the top two matched and ranked equally, so document order picked the winner. candidates/scored/matched/returned, truncated and aria_hidden_skipped account for what was examined, cut, and skipped as hidden from assistive technology.
page_elements The flat lists of links, forms, fields with <select> options, and buttons, addressed by CSS selector — or by a piercing path when they live in an open shadow root or a same-origin frame, or by an empty string when nothing addresses them uniquely, which is the honest answer and not a bug to work around. It covers the whole existing DOM, not only the viewport, so a rendered button below the fold is returned before any scroll. Each entry carries visible and, when it is not, a hidden_reason; visible entries come first. limit is capped at 1,000 per category; continue with offset and range.<category>.next_offset until it is null. found, returned, truncated, and collector_truncated account for the result and the 20,000-element safety cap. limit counts controls; max_chars (default 18,000) bounds the answer itself, because 200 controls of a large directory page came to 83,616 characters — more than a small model could receive. When the budget bites, budget_truncated is true, budget_note says how much was dropped, and range.<category>.next_offset is restated to point at the first entry that was not sent. page_outline and find take the same max_chars; page_text and element_text already had one. Lazy, infinite, and virtualized controls do not exist until the page creates them: scroll, wait, then reread from offset=0. Alone among the four it takes no frame_selector: it always walks the whole page, open shadow roots, and same-origin frames.
{"topic":"page_outline","params":{"session_id":"demo","output":"json","limit":80}}

The outline and find walk open shadow roots and same-origin iframes, including nested ones, and map every box into top-document coordinates so a reported center can be clicked or pointed at directly. A frame is mapped through the full transform of itself and its ancestors — transform, the individual rotate/scale/translate properties, and CSS zoom — because a frame scaled to fit its container is ordinary on a checkout page, and translating by its origin alone put the reported centre tens of pixels from the control. center is the point to aim at; page_rect is the smallest rectangle containing the mapped corners, which for a rotated frame is larger than the element itself and is not something to click. A chain that is not affine — a 3D perspective — can only be approximated flat, and those nodes say so with page_rect_approximate rather than presenting the guess as an ordinary number. A cross-origin frame appears as one node with same_origin: false; read it by passing its selector as frame_selector. Closed shadow roots are counted in closed_shadow_roots rather than entered. A node inside a frame reports the frame path that reaches it, verified to match exactly one element and written as a #host >>> #inner piercing path when the frame is itself nested or inside a shadow root; a frame with no such path is marked frame_addressable: false instead of being given a selector that would land in the wrong one.

page_text never answers with a blank page, and never hands a fragment over as if it were the page:

  • mode="main" on a document that is one large form — a sign-up, login, or checkout page — used to drop everything as chrome. An empty result is now retried without the noise filter and reported as fallback_used: true with mode_used: "full".
  • A landmark is only trusted when it holds at least half of what the body renders, so an app shell whose real content is mounted outside its <main> no longer reads as Loading....
  • excluded_chars counts how many of the body_chars the body renders are missing from the answer, and excluded says why: chrome dropped by mode="main", cross-origin frames, frames nested deeper than the shared frame-depth limit, a frame whose document has not parsed yet, or a clip at max_chars.
  • Same-origin frames are read in place, <dialog open> and role="dialog" overlays that sit outside the chosen root are appended and counted in dialogs_appended, and frames reports what was entered, skipped as cross-origin, or too deeply nested.
  • With include_links=true the link index is paid for out of the same budget, so the response stays inside the requested max_chars instead of overshooting it by the size of the listing.

What find returns

Each match carries two numbers, because one number cannot say both things:

Field Meaning
match_score 0–100. How well the query matched that element alone, before any ranking bonus: 100 the whole field, 62 a prefix, 45 a substring, 34 every query token present — times the field's weight. matched_field names the field it came from.
score The ranking score: match_score plus context — in the viewport +18, an action-shaped query on an interactive element +12, the requested role +8, enabled +6, a child of <main> or <form> +4, disabled −15, a generic non-interactive node on a page that has interactive ones −12, a role other than the one asked for −20, occluded −25. It orders the results and says nothing about whether the page holds an answer.

Field weights, and the values matched_field can take: name 1.0, text — the element's own visible words, scored separately when an accessible name overrode them — 0.9, placeholder 0.8, title 0.65, testid 0.6, name_id (the name attribute and the id) 0.5, role and value 0.4, href 0.3.

Two flags sit on top of them, and they mean different things:

  • low_confidence — nothing cleared match_threshold (25) on match_score, so the matches are the closest things on the page, offered as guesses. It is derived from match_score and never from score, which is the fix for a real failure: context alone is worth 36 points, so on any action-shaped query every visible enabled control cleared a bar of 25 and unrelated elements came back with low_confidence: false. A role filter is a filter too — if nothing of that role clears the bar, the result is low_confidence rather than a confidently wrong element of another role.
  • ambiguous — the top two matched the query equally well and ranked equally (within five points on both), so document order alone decided which came first. Both are good matches; the choice between them is not, which is why it is a separate flag. It is computed before limit is applied, so asking for one answer still tells you the second was just as good.

The counts account for everything the page offered: candidates were examined, scored resembled the query at all — weak role-word brushes included — matched cleared match_threshold, returned fit inside limit (capped at 25), and truncated says matched did not. aria_hidden_skipped counts elements skipped because an ancestor is aria-hidden or [hidden], by the same rule page_outline uses, and frames_too_deep counts frames no topic enters. Under low_confidence nothing cleared the bar, so matched is 0 while returned counts the guesses — the one case where returned exceeds matched.

low_confidence: true is an instruction, not a footnote: re-query with different words or a role filter. Clicking matches[0] because it is the only thing on offer is how an agent ends up pressing Cancel when it meant Submit order.

Locators

Form Example Notes
CSS selector input[name='q'] Unchanged, and still the only form accepted everywhere.
Ref handle ref:3f9a1c04b7e25d18:12 Issued by page_outline and find. The first field is the document epoch — 16 hex characters, also reported separately as dom_epoch; the second is the element number.
Piercing path #host >>> .inner The separator is a space-padded >>>. Each step enters the element's open shadow root, or its document when the element is a same-origin iframe. Any number of segments may be chained.

Ref handles carry the document they were read from — which, for a node inside an iframe, is that frame and not the top page:

  • element numbers restart at 1 in every document, so the earlier ref:N form silently resolved to an unrelated element after a navigation — a ref:1 saved from a search page addressed whatever happened to be the first node of the next page;
  • a node inside a frame is numbered in that frame's own registry, so its epoch differs from the page's dom_epoch, and resolving it enters the frame that issued it. Until 1.3.2 every ref was minted in the top document, which made the natural page_outlineclick sequence fail on a payment or consent frame — after a ten-second poll, blaming staleness for what was really the wrong browsing context;
  • a handle whose epoch no longer matches now resolves to nothing, and the action fails with an explicit "read the page again with page_outline" message. So does a handle whose element has been detached from the DOM. Both fail immediately rather than polling: a replaced document does not come back, and the two cases are reported apart, since a frame that is still open but no longer holds the element can still be reached by the center coordinates the outline reported;
  • a bare ref:N without an epoch is refused outright, with an error that says to read page_outline again: it carries no document identity, and answering it from whatever document happens to be loaded is the very mistake the epoch exists to prevent.

Ref handles and piercing paths are accepted by fill (both its field and its file keys), upload, click, wait, and the form_selector of submit. click and wait took plain CSS only until 1.3.0, which made the natural findclick sequence fail with InvalidSelectorException; they now poll the resolved handle for the requested present/visible/clickable state. The submit_selector still expects a plain CSS selector. Both non-CSS forms need a live element handle, so they resolve in the Selenium-backed modes (temporary, persistent, attach); in companion current mode they are refused with an explicit message and CSS selectors remain the way to address elements.

A CSS selector that contains >>> inside quotes or brackets — div[data-op='a >>> b'] — is no longer mistaken for a piercing path; the separator is only recognised outside quoted strings and attribute brackets.

frame_selector accepts less than the locators do

A frame_selector always names exactly one frame. A CSS selector matching more than one is refused with the count, everywhere, rather than answered with whichever came first.

What differs is which forms are accepted. render and the reading topics — page_outline, page_text, find — take all three, so a nested frame can be named by the #host >>> #frame path the outline reports for it, as can fill, click, wait, submit and upload.

The input actions take plain CSS and nothing else: press_keys, pointer, touch, the pointer entries of an input batch, pointer_lock, and game_probe. They aim by coordinate, which needs the frame's own box measured in the top-level page, and neither a ref: handle nor a piercing path yields one. Both are refused before a single event is sent — not half-way through a batch, which is what an input mixing keys and pointer entries used to do — with an error that says to pass a CSS selector matching this frame and nothing else. pointer_lock refuses them on release and status too, though only acquire dispatches a click: one call cannot read the same string two ways.

game_probe sits on that side too, though it dispatches nothing. It reports canvas rectangles that are then aimed at with the same string, so a probe that read one of four matching frames while the input went to another was the same defect wearing a different hat.

So the sequence the games section leads to has one step in it: the outline names a nested game frame #host >>> #frame, and that path cannot be handed to game_probe or input. Give the input actions a CSS selector that is unique by itself, and keep the piercing path for reading.

Console and network diagnostics

The page's own console and HTTP traffic are readable without leaving the MCP contract:

Topic Returns
console console.log/info/warn/error, uncaught exceptions and rejections with stack frames, and browser log entries. Filter with levels, kinds, contains; page through with since_seq and limit=50.
network One compact line per request in the documented method status type ms size url order. Filter with url_pattern (a case-insensitive regex), types, status_min/status_max, or only_errors=true.
network_body One response body, by request_id, capped at max_chars=20000.
{"topic":"network","params":{"session_id":"demo","only_errors":true}}

One line per request, for example a form post the server rejected:

POST 422 Document       4ms 0.5KB https://example.com/submit

only_errors=true keeps exactly these — failed requests and everything from 400 up — so a post the server accepted is filtered out and an empty list is itself an answer.

Use output="json" on the network topic when you need the per-request id that network_body expects; the default text lines omit it.

Capture is armed when the session takes its tab, not when a topic is first read. In current mode the subscription is the last step of opening the tab, while it is still about:blank, so the requests and logs of the very first navigation are in the buffer — a single open reports the document, its subresources, and a 404 favicon without anyone reloading anything. This is a fix, not a nicety: network capture used to be armed by the first network read, so an agent that opened a page, saw it fail, and then asked which requests failed was told there were none.

attach_tab is necessarily different. Capture starts at the claim, and whatever the tab did before it was claimed was recorded by nobody and cannot be recovered — reload the claimed page if you need its load traffic.

Each stream keeps up to 500 entries and reports how many older records were dropped. Two further ceilings belong to companion current mode alone, because there the buffers live inside the extension: a 512 KB budget shared between console and network, and in-flight requests capped at 1000. Those limits bite on a chatty page: one that fires 700 requests keeps the newest 500 and reports a little over 200 dropped — a few more than the arithmetic, because the shared byte budget evicts as well — and eviction is oldest-first, so the very first navigation is the first thing to go. Read network before the page has had time to bury it. An empty list next to a non-zero dropped means "evicted", not "quiet".

Both topics work in companion current mode and in the Selenium-backed modes; the Selenium path reads Chrome's browser and performance logs instead of the extension's buffer, so field coverage is close but not identical. It caps in-flight requests at 500 and keeps no byte budget. Its performance log is switched on when the session starts, and its page console hook is registered with Page.addScriptToEvaluateOnNewDocument before the session navigates anywhere, so it runs ahead of each document's own first script and the first page's boot output is covered too. That hook used to be installed the first time the console topic was read, which lost exactly those lines and made a reload the price of seeing them.

Forms and multi-step flows

The short version: open the page, read it, fill everything in one call, attach files through files or the upload action, reread every consequential live choice, submit exactly once, then prove the outcome with fresh DOM/text.

{
  "actions": [
    {"action": "fill", "session_id": "intake",
     "fields": {"#full-name": "Ada Lovelace", "#topic": "demo", "#subscribe": true}},
    {"action": "submit", "session_id": "intake", "form_selector": "#intake-form"}
  ]
}
  • fill applies what it can and returns filled, field_values, files_uploaded and a per-selector errors map; success is false whenever errors is non-empty. Every write is read back off the control, so a maxlength truncation and an oninput handler that rewrote the value are errors naming both values instead of successes. field_values answers for every selector you sent, failures included — a refused control shows what it still holds, and null means nothing could be read back, because the selector matched nothing or the control has left the page.
  • Sanitisation is not refusal. Whitespace trimmed off a type=email value, \r\n normalised to \n, a handler that lower-cases the input — the control kept what you gave it, in its own terms, and all three read back as filled. A byte-for-byte comparison used to call them failures.
  • A <select multiple> is the one control that takes a list of values and reads back as one. A scalar sent to it replaces the whole selection rather than extending it, and a list sent to anything else is refused.
  • Date, time, datetime-local, month, week, range and colour inputs are set rather than typed, since typing into them depends on the browser's locale. The value is rehearsed on a throwaway input first, so an unparseable one is refused without touching the control — no more valid-looking wrong date, no more slider dropped to its midpoint — and the error names the format the control wants.
  • A contenteditable editor — TipTap, ProseMirror, Slate, Quill, a webmail message body — is written as a real edit and keeps its paragraphs: the text goes in a line at a time with a soft break between the lines (Shift+Enter, never Enter, which in a chat composer would send what is written so far), because inserting it in one go puts \n inside a single text node where it renders as a space. The read-back is innerText, not textContent, which ran every paragraph together and reported a fill that had worked as a refusal. If an editor folds the breaks away regardless, the error says exactly that and names the way through: put the text on the clipboard with run_script (user_gesture=true, navigator.clipboard.writeText) and paste it with a real Ctrl+V through input. Some chat composers never pick up a DOM write at all — their send control stays inert — so paste there always.
  • upload no longer treats an empty file input as proof of failure. Any Dropzone-style widget takes the file off the input and uploads it itself, so reading the input back finds nothing a millisecond later while the file is already stored and its chip with the name is on the screen. upload_state says how far the evidence goes: attached (the input holds the files, which is exact), taken_by_widget (the input was emptied, and either the page now names the file or a POST/PUT/PATCH followed the attach) or unconfirmed (nothing vouches for it either way — which is not a refusal; note says to look for the name with page_text/elements and for the request in the network topic before attaching again). success is false only when the attach itself failed. fill with files reports the same thing in upload_states and upload_notes.
  • submit runs native validation first and reports validation_passed with the offending field ids, then submit_triggered from the fired submit event or from the document being replaced — including a reload of the same URL, which a check on url and title alone reported as a failed submit. submit_default_prevented marks the SPA case where a handler cancelled the navigation on purpose. submit_evidence states in a sentence what the verdict rests on, and new_tab_opened warns that a target="_blank" result landed in a tab this session does not own — so the url and title beside it are still this page's, not the answer you are looking for.
  • A checkbox takes 1/yes/y/on/check/checked or 0/no/n/off/uncheck/unchecked/"" and refuses anything else, a <select> takes an option value or its visible text, and a file input must go through files/upload.
  • Every control fill writes is blurred afterwards, because that is the only way the last field of a fill ever fires its change event. Three consequences follow and the third bites: focus ends on document.body, an autocomplete list the fill opened is dismissed, and a following press_keys(["ENTER"]) goes to the body rather than the field — pass target_selector, or focus_mode="click", when you mean to submit by keyboard. The files entries are the exception: nothing is typed into them, so nothing is blurred.
  • fill, click, submit, upload and wait take frame_selector, so a form inside an iframe is addressable by name and not only through a ref.
  • File inputs honor that frame context too: same-origin uploads are resolved inside the selected frame in the tab target, while cross-origin uploads use the child debugger target. A top-level matching input is never chosen as a decoy, and valid same-origin uploads no longer surface Chrome's opaque Uncaught evaluation error.
  • wait takes present, visible, or clickable, and honours its timeout_seconds as passed (it defaults to 10). A timeout names the seconds actually waited in its message, so you can ask for as long as the target needs and read back what really happened.
  • After any navigation or step change, read page_outline again: old ref handles are stale by definition. dom_epoch only tests one direction of that. A different epoch proves your refs are dead; the same epoch proves nothing, because the epoch belongs to the document and a wizard step that swaps its own markup in place keeps it while every ref it issued goes with the old nodes. A ref read inside a frame carries that frame's epoch rather than the page's, so a mismatch there is normal and not staleness at all.

For an application, payment, message, deletion, or other consequential terminal action, keep submit_attempted=false. From a fresh page_elements read match the exact target (href where available) and verify each critical selected value in the live control; remembered defaults are not evidence. Set the flag as the terminal button is clicked once. After any response or timeout, never retry that button before inspecting URL, text, elements, console, and network — the first click may already have succeeded. Stop immediately on terminal success text.

→ Complex forms covers the rest with worked calls: choosing between the three locator forms, finding a field by meaning when selectors are generated, partial-failure recovery, multi-step wizards and SPAs, fields inside Shadow DOM and same-origin iframes, what to do when challenge_detected turns true, and how to trace a submit that silently failed through network and network_body.

Reviewable macros

A macro is a JSON file of web_action steps that lives in the calling project. Write it with an editor, check it with op=validate, and replay it by name. Before a consequential replay, preview the exact resolved steps with the same variables that will be passed to run:

{"actions":[{"action":"macro","op":"preview","name":"document-submit","variables":{"target_url":"https://forms.example/requests/42","resource_path":"C:/docs/request-42.pdf"},"project_root":"C:/work/my-project"}]}

preview loads the saved macro, validates that every placeholder was supplied, and returns the resolved steps with executed: false. It never dispatches an action or changes browser state. Review the canonical URL, form values, upload path, and whether a terminal consequential action is present before sending the corresponding op: "run" call.

It also checks every resolved step against the schema its action publishes, and reports steps_valid with a problems list naming the offending step index and error. Because macro files are written and edited by hand, the defect in one is usually a mistyped parameter rather than a wrong click path — and without this check it surfaces mid-replay, after the steps before it have already dispatched.

Preview is a review boundary, not proof that replay will succeed. After run, check the batch result and freshly inspect the page. For applications, payments, messages, or other consequential actions, use the guarded two-phase path below so the exact terminal Submit or safe Click is attempted only once after live-state validation.

Checking a macro before it runs

op=validate reads a macro file and dispatches nothing at all. It is the check that belongs before the first live run of a hand-written or generated macro, and it reports every finding with the step index, what is wrong, and how to fix it.

{"actions":[{"action":"macro","op":"validate","name":"document-submit","project_root":"auto"}]}

Errors are things that cannot work: an action name the server does not have, a required parameter missing, a parameter that is not part of that action and would be refused at dispatch, a placeholder used but not declared in variables — and, above all, a {{placeholder}} inside a run_script script. That last one is the expensive mistake: the value is pasted into the JavaScript as raw text, so anything containing a newline, a quote or a backslash produces a syntactically broken program and the step fails with an opaque Uncaught from inside the page. Pass the value through args and read it from arguments[0] instead.

Warnings never make a macro invalid, because each of them can be deliberate: a variable declared and never used, steps that drift between two different session_id values, and a macro whose last meaningful step neither waits nor reads anything back — one that sends something and then reports whatever the dispatcher happened to return.

A replay can be pointed somewhere else: session_id on run or preview rewrites the session of every step, so a macro written for one tab runs in another without editing the file. A macro that already drives two sessions is refused rather than collapsed into one, because two tabs were the point; give those steps a {{placeholder}} session instead.

Where macros live

There are two stores, and every macro answer reports which one it used, as scope, project_root, and storage. A macro that appears to have vanished is almost always a macro saved into the other one.

project_root Store
omitted The per-user store, unless WEB_SEARCH_NEO_PROJECT_ROOT names a project.
"auto" The project found by walking up from the working directory: WEB_SEARCH_NEO_PROJECT_ROOT first, then the nearest .web-search-neo directory, then the nearest repository root.
an absolute path Exactly that project.

op=list additionally reports other_store, so the question "is it in the other one?" is answered by the call that raised it. The nearest marker wins, so a package that keeps its own .web-search-neo directory inside a larger repository is its own project. Macro names are traversal-safe, and the resolved store is additionally required to remain beneath project_root; a symlink or junction that escapes is refused.

Macro files

A project's macros are one .json file each under <project_root>/.web-search-neo/macros/, intended to be committed with the project and edited by hand. The shortest valid file is the step list itself:

[{"action": "open", "url": "{{url}}", "session_id": "docs"}]

The full form adds the description and the placeholder defaults:

{
  "name": "open-docs",
  "description": "Open one documentation page",
  "steps": [{"action": "open", "url": "{{url}}", "session_id": "docs"}],
  "variables": {"url": "https://example.com"}
}

Every step is exactly one web_action action object, so web_info(topic="action_schema", params={"action": "<name>"}) is the reference for what a step may contain. The variables map is optional: placeholders are declared from the steps themselves, so a hand-written file reports what it wants through op=list and op=show without one. Name a variable only to give it a default. Each store gets a README.md stating this next to the files, because the next reader of a committed macro directory is often a model that has never seen the schema. A file that cannot be read as a macro is reported by op=list as broken rather than skipped, so one bad hand edit never hides the macros beside it. The guarded-operation ledger lives in the same directory and is never listed as a macro.

There is no write API

A macro is a file, and that is the whole of it. Creating, editing, renaming, copying, committing and deleting macros are ordinary file operations, done with the tools that already do them well; the macro action only reads (list, show, validate), resolves (preview) and replays (run, guarded_stage, guarded_commit). Moving a set between machines is copying a directory.

This used to include a recorder, and the recorder is gone. It was never self-sufficient: its own contract told the caller to save the recording and then hand-edit the JSON to turn the changing parts into {{placeholders}}, so the path ended in an editor either way. Over a full day of live use, every macro that actually worked was written directly as JSON and the recorder was not used once, while it carried a class of defects of its own — races between concurrent batches, steps attributed to the wrong open recording, a name quietly borrowed by an explicit save. op=validate replaces it with the part that was actually missing: a check that runs before the page does.

Project-local and guarded macros

The engine is universal and domain-neutral. Domain rules belong in a saved macro or its calling project, never in Web Search Neo core. Pass project_root — an absolute path, or "auto" — to any macro operation to use that project's independent macro set and its own guarded-operation ledger.

Consequential flows use a generic two-phase path. A guarded macro must end in exactly one consequential action: explicit submit, or a safe click. A terminal click must be either a plain CSS selector (required to match exactly one live element immediately before commit) or exact rendered text plus an explicit role (the semantic dispatcher refuses zero or multiple matches). Coordinates, trusted=true, substring text, ref handles, piercing paths, and non-terminal submits fail closed. guarded_stage resolves the macro, then fails closed unless guard provides:

  • equal target_url and canonical_url, plus an optional domain-defined identity_key; query parameters are preserved because they may carry the target/requisition identity, while fragments are ignored;
  • an explicit allowed_hosts policy and optional denied_hosts policy;
  • an existing absolute resource_path that the resolved macro uploads exactly;
  • the exact 64-hex resource_sha256 for the current file bytes (case-insensitive input, normalized to lowercase after verification);
  • a stable 16-128 character idempotency_token unique to this target;
  • one or more live assertions over staged action results.

Host policy is data. Core contains no built-in site categories or denylist. A project can deny platforms, production hosts, vendors, or any other domain-specific routes in its own macro/configuration. The resolved macro must open the exact canonical target URL.

This neutral document-request example assumes result 2 is a fresh page_text checkpoint:

{
  "actions": [{
    "action": "macro",
    "op": "guarded_stage",
    "name": "document-request",
    "project_root": "C:/work/my-project",
    "variables": {
      "target_url": "https://forms.example.com/requests/42",
      "resource_path": "C:/work/my-project/artifacts/request-42.pdf"
    },
    "guard": {
      "target_url": "https://forms.example.com/requests/42",
      "canonical_url": "https://forms.example.com/requests/42",
      "identity_key": "request-42",
      "allowed_hosts": ["example.com"],
      "denied_hosts": ["staging.example.com"],
      "resource_path": "C:/work/my-project/artifacts/request-42.pdf",
      "resource_sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
      "idempotency_token": "request-42-20260820",
      "assertions": [
        {"result_index": 2, "path": "data.text", "contains": "Request 42"}
      ]
    }
  }]
}

guarded_stage dispatches every step except the terminal consequential action. Only after all staged actions succeed, the supplied SHA-256 matches the current resource bytes, and every semantic assertion passes does it reserve a checkpoint. The normalized digest is returned by staging and stored with the checkpoint. Review the result and commit once from the same project store:

{"actions":[{"action":"macro","op":"guarded_commit","checkpoint":"guard-request-42-20260820","project_root":"C:/work/my-project"}]}

The project-local ledger is marked <action>_attempted before terminal dispatch. A timeout, lost response, second call, new token for the same target identity, or resource reused for another target refuses replay. Every one of those refusals is final by design, so each names the ledger file it is refusing from — otherwise a stage whose commit never ran leaves a target that can never be staged again and nothing to look at. The guard proves one guarded attempt, not server acceptance; inspect durable confirmation separately. Concrete domain macros should live with their projects and are not bundled in this repository.

guarded_commit never retries the terminal action and does not infer remote acceptance. After the single attempt, use ordinary read-only inspection actions (for example fresh page text or a screenshot) to collect destination-specific proof.

Canvas and WebGL games

Browser automation is not limited to DOM forms. The compact contract covers common HTML5 game controls:

Call What it does
web_info(topic="game_probe") Reports canvases, 2D/WebGL context, iframe surfaces, document focus, sampled FPS under animation, loading time, the console warnings and errors new since the previous probe, and held input.
input Mixes per-key tap/hold/release with pointer click, double_click, hover, move, drag, press, release, and wheel, using absolute coordinates, deltas, or unbounded relative motion, up to 16 entries of each kind.
press_keys Keyboard-only shortcut: 1-8 keys plus key_action (tap, hold, release), repeat (1-50), hold_seconds, focus_mode (focus, click, none), and hold_frames (1-30), which keeps a tap down across N released frames in step mode.
touch, touch_emulation tap, press, move, release, swipe, or cancel with up to ten simultaneous points; the emulation makes the page report navigator.maxTouchPoints and ontouchstart so a game's mobile code path actually runs.
pointer_lock Acquires, releases, or reports pointer lock for first-person controls; while locked, coordinate_mode="relative" moves without clamping to the viewport, which is what feeds movementX/movementY.
render, step, release_inputs Control the animation gate and safely reset held input.
frame_selector Targets a cross-origin game iframe such as the one used by Yandex Games.

The verb of each action is namespaced because the dispatcher already owns action: key_action, pointer_action, touch_action, and pointer_lock's operation.

Keyboard coverage includes F1-F12, NUMPAD0-NUMPAD9 with the numeric keypad location, MULTIPLY/ADD/SUBTRACT/DECIMAL/DIVIDE, META (also WIN, CMD, COMMAND), the arrow, navigation, and editing keys, and any single printable character. A key held as W is released by w as well, and the release dispatches exactly the character that was pressed. A modifier held with hold is carried into subsequent mouse and touch events, so Shift-click and Ctrl-click behave as a user's would. Giving a canvas keyboard focus no longer costs a synthetic click, which used to reach the game as a shot or a jump.

Example for a game hosted inside an iframe:

{
  "actions": [
    {"action": "open", "url": "https://yandex.ru/games/app/geometry-dash-ufo-2d-371298", "session_id": "ufo", "headless": false},
    {"action": "render", "mode": "step", "session_id": "ufo", "frame_selector": "#game-frame"},
    {
      "action": "input",
      "session_id": "ufo",
      "frame_selector": "#game-frame",
      "key_actions": [
        {"key": "W", "action": "hold"},
        {"key": "S", "action": "release"},
        {"key": "SPACE", "action": "tap"},
        {"key": "E", "action": "tap"}
      ],
      "pointer_actions": [
        {"action": "hover", "x": 640, "y": 360},
        {"action": "wheel", "x": 640, "y": 360, "delta_y": -240},
        {"action": "move", "x": 15, "y": -5, "coordinate_mode": "delta"}
      ]
    },
    {"action": "step", "frames": 5, "session_id": "ufo"},
    {"action": "release_inputs", "session_id": "ufo"},
    {"action": "render", "mode": "normal", "session_id": "ufo"}
  ]
}

Send the batch to web_action; use web_info(topic="game_probe", params={"session_id": "ufo", "frame_selector": "#game-frame"}) or the screenshot topic to observe it.

A batch like that one ends with release_inputs and render mode=normal for a reason, and web_action stops at the first action that fails unless continue_on_error=true. So the failure of a single input in the middle is the case where the cleanup at the end never runs and the page is left frozen with keys held. Either send the cleanup as its own call, or pass continue_on_error=true on any batch whose tail is cleanup.

For a top-level canvas application such as https://redoschool.ru/demo/?auto=true, omit frame_selector. Pointer coordinates are relative to the selected top-level viewport or iframe.

→ Playing games is the full walkthrough: a complete run from open to the win condition, why a tapped key is held across the frame, the auto-repeat that keeps a held key alive after a respawn, iframe coordinate handling, pointer lock, touch games, and the cleanup that must happen at the end.

Render modes

Mode Behavior
normal Removes the render gate and returns to the page's normal requestAnimationFrame loop and real clocks.
throttled Continuously releases animation callbacks at no more than target_fps, for example 10 FPS. target_fps defaults to 10 and is clamped to 1-60.
step Holds queued animation callbacks. The step action releases 1-120 frames explicitly; every input action also releases exactly one frame.

While the gate is engaged — in throttled as well as in step — page time is frozen:

  • performance.now() and Date.now() advance by exactly one frame delta of 16.667 ms per released frame;
  • setTimeout, setInterval, and requestIdleCallback are queued against that same virtual clock and run immediately before the frame's animation callbacks. Without this a game reads the agent's thinking time as its deltaTime, and a batch of frames released back to back arrives with a delta near zero;
  • promises and queueMicrotask are not gated, and new Date() still reports wall-clock time;
  • queued timers are handed back to the real scheduler, with their remaining delay intact, when the mode returns to normal;
  • page time never runs backwards. Stepping carries the clock ahead of wall time — sixty frames released back to back advance it by a second in barely a moment — and returning to normal keeps that gap instead of dropping it. Handing the native clock back used to lose the whole gain at once, measured at −5.9 s, which a game reads as a negative frame delta. A session that has stepped keeps the clock wrapper for the rest of its life, since only the wrapper can go on applying the offset; one that never stepped gets the untouched native clock back.

render also accepts frame_delta_ms, freeze_time, and gate_timers when an engine needs something other than those defaults. One knob stays Python-only: browser_tools.set_render_control(..., key_repeat=False) disables the auto-repeat that keeps a held key alive for games which latch input on keydown.

One input action can hold one key, release another, tap two more, turn the wheel, and move the pointer by a delta before releasing exactly one frame. A tapped key is pressed together with the rest of the batch, stays down for the whole released frame, and is lifted afterwards, so an engine that polls key state once per frame — Phaser, Godot, a hand-written canvas loop — actually observes the press. In step mode the game never observes a partially applied intermediate input state. Outside step mode the actions are still serialized, but the page continues rendering normally.

The gate is installed into every new document of a session, so it survives a page reload or a game iframe that reloads itself; a step that lands on a fresh document re-applies step mode once and reports gate_reinstalled. render reports frame_delta_ms, time_frozen, timers_gated, pending_callbacks, and input_advances_frame; step reports frames, callbacks, pending_timers, and virtual_now.

The render controller gates JavaScript requestAnimationFrame, which covers typical canvas/WebGL and Unity WebGL loops. It does not change video decoding, CSS compositor animations, the monitor refresh rate, or guarantee an exact GPU hardware frame rate on every engine.

While a gate is engaged, game_probe does not try to sample FPS. Frames are released by hand, so the measurement could only expire against its own script timeout and then report a fabricated zero; the probe returns immediately with an animation object holding fps: null, animation_suspended: true, and a reason naming the active render mode and the gated frame. Every frame-rate field lives in that nested object — animation.fps, animation.frames, animation.elapsed_ms, animation.available — never at the top level of the result.

console_messages holds the warnings and errors that appeared since the previous game_probe call, which is what the probe's console_scope field says in the result. A polling loop therefore reports a problem when it happens instead of re-reading everything the session has ever logged, and its output does not grow with the length of the run. The flip side is that each entry is delivered exactly once: a probe result that is thrown away takes its console entries with it. Read console_messages on every probe you make, or use the console topic, which keeps its own place in the same buffers — polling the probe never hides entries from console, and reading console never hides them from the probe.

The probe reads the in-page hook as well as Chrome's browser log, so console.warn, console.error, and uncaught exceptions are covered on both backends. In companion current mode that browser log is not available at all, and earlier builds, which read only it, reported nothing there.

Input latency

Game control is only useful if a round trip is cheap, so each action reads the page once and never sleeps by default. Measured through web_action against the bundled platformer fixture in step mode on a headless Chrome session, 30 iterations each, median and p95:

Action Median p95 Same call at the former wait_seconds=0.2
input with two keys and a pointer entry 34 ms 47 ms 233 ms
press_keys single key tap 25 ms 26 ms 230 ms
pointer hover 27 ms 27 ms 220 ms
step of one frame 11 ms 12 ms

The published wait_seconds default of every input action dropped from 0.2 to 0.0, which is the whole of the last column: the action itself was never the cost. include_summary is now part of the MCP contract as well as the Python API, and setting it to false skips the post-action page read: input 34 ms → 29 ms, press_keys 25 ms → 21 ms, step 11 ms → 8 ms. It saves nothing measurable on pointer, whose summary is already the cheapest of the four. Absolute numbers depend on the machine; the ratios do not.

Two-tool MCP contract

Tool Responsibility
web_info Return the whole contract, the built-in automation skill, or one action schema on demand; read search, current Chrome tabs, browser, page outline/text/find, console, network, game, screenshot, or time state.
web_action Execute one or up to 32 ordered setup, search, fetch, tab attach/open, form, input, render, and close actions. Supports fail-fast or continue_on_error=true.

Start with web_info(). With no arguments it returns actions with each action's summary and its required parameter names, action_groups, info_topics, recipes, pitfalls, limits, and worked examples. Optional names, types, and defaults are deliberately left out of it. Request only the needed, generated JSON Schema with web_info(topic="action_schema", params={"action": "input"}), then invoke it through web_action. Two narrower entry points exist for a model that does not want the whole contract at once: web_info(topic="actions") is the action index alone, with params={"group": "macro"} to narrow it, and web_info(topic="skill") is the runtime playbook. The same call describes an observation topic — params={"action": "find"} returns find's parameters — which matters because a topic refuses any argument it does not list, and that list appears nowhere else. This follows the on-demand Tool Search principle used by official Unreal MCP: keep the eager tool list small, disclose schemas only when needed, and dispatch actions through a meta-tool. Web Search Neo combines Unreal's list/describe discovery tools into one web_info, so only two tools are advertised.

Measured on the current build, summing each advertised tool's name, description, and serialized inputSchema: the compact surface is 1,278 characters across two tools, against 34,720 characters across the 56 tools of legacy mode. The self-describing contract behind web_info() is 12,571 characters, and it is fetched only when an agent asks for it. Each action in it lists its required parameter names; optional names, types, and defaults stay in action_schema, where they cost nothing until needed — and where an observation topic's parameters live too, since a topic accepts exactly the list it publishes and nothing else.

Every action is declared once in a single registry that also generates its published schema, and arguments are validated against that same model before the handler runs. An unknown or malformed field returns the offending names and the list of allowed parameters instead of an internal TypeError:

ValueError: action 'input': unknown parameter(s) ['frames']. Allowed: ['key_actions', 'pointer_actions',
'session_id', 'target_selector', 'frame_selector', 'wait_seconds', 'include_summary']. Call
web_info(topic='action_schema', params={'action': '<name>'}) for the full schema.

Existing direct Python imports remain available. For temporary MCP-client migration only, set WEB_SEARCH_NEO_LEGACY_TOOLS=1 before starting the server to advertise the former narrow tool list instead of the compact default.

Browser state is keyed by session_id, and that key is also the boundary between agents working at the same time: one session_id is one tab, so parallel agents must not share one. The open_many action can create as many independent sessions concurrently as the session cap allows (eight by default), and the companion popup or WEB_SEARCH_NEO_MAX_SESSIONS moves that ceiling. A viewport screenshot with no dimensions preserves the current viewport in every mode. An explicit viewport width/height pair is exact in isolated Selenium modes and is refused in current mode, where the MCP never resizes the user's Chrome; use mode="region" there for an exact-size image. Full-page captures above 3840x10000 fail explicitly instead of returning an unlabelled partial image.

Image-guided clicking is available through pointer with pointer_action="click" and viewport CSS x/y. Take a fresh viewport screenshot; if its PNG dimensions differ from the reported viewport dimensions, scale both axes proportionally. Full-page and region pixels do not map directly to the viewport: scroll the target into view and recapture. Any scroll, zoom, resize, navigation, animation, or rerender invalidates the old image coordinates.

Optional agent skill

web_info(topic="skill") returns a compact built-in playbook intended even for small local models: inspect → act → verify, schema discovery before guessing optional parameters, element pagination and lazy-page scrolling, the three screenshot modes, safe visual-coordinate clicks, current-Chrome CSS-only action locators, exact href/value matching, and a one-shot final-submit guard. It is about 6.5 KB and can be fetched once at the start of an automation task.

It also names its detailed sections, which are the part a small model needs when a specific operation starts rather than at the beginning of the session. Each is opened on demand with web_info(topic="skill", params={"section": "<name>"}) and carries what a JSON Schema cannot: when the section applies, the calls in order, the rules that are not guessable, and the mistakes to avoid.

Section Covers
start Choosing the surface, the first three calls, session naming.
loop Inspect, act once, verify — and what counts as proof.
locators CSS, refs, piercing paths, and which action accepts which.
forms Filling, choice widgets, uploads, the one-shot terminal submit.
macros Project files, placeholders, validate, preview, replay.
guarded The two-phase path for an action that cannot be taken twice.
parallel Session discipline for subagents sharing one server.
search Search and fetch without a browser.
diagnostics Console, network, response bodies, page scripts.
games The render gate, per-frame input, probes.
troubleshooting Symptom, cause, and the call that fixes it.

The troubleshooting section maps the server's own error text — an unknown parameter, a stale ref:, a macro that "does not exist" because it was saved into the other store — onto the call that resolves it, which is what turns a repeated failure into one corrected retry.

web_info() called with no arguments still returns the entire agent-facing contract: every action with its summary and required parameter names, the observation topics, ready-made recipes, the common mistakes, the hard limits, and runnable examples. Optional parameters — for an action or for a topic — come from action_schema, one at a time. An agent that reads either contract needs no external instructions, so the bundled filesystem skill is a convenience, not a requirement.

The repository still includes a short Web Search Neo skill for clients that prefer a resident description of when to reach for the server at all.

Install it locally by copying skills/web-search-neo into your Codex skills directory, then restart Codex:

Copy-Item -Recurse -Force skills\web-search-neo "$env:USERPROFILE\.codex\skills\web-search-neo"

Invoke it explicitly as $web-search-neo, or let its task description trigger it for web search, visible Chrome automation, authorized attach sessions, form work, and browser-game testing.

Optional configuration

$env:WEB_SEARCH_NEO_REGION = "us-en"
$env:WEB_SEARCH_NEO_PROXY = "socks5://127.0.0.1:9050"
$env:WEB_SEARCH_NEO_BROWSER_USER_AGENT = "..."
$env:WEB_SEARCH_NEO_PROFILE_ROOT = "D:\BrowserProfiles"
$env:WEB_SEARCH_NEO_DEBUGGER_ADDRESS = "127.0.0.1:9222"
$env:WEB_SEARCH_NEO_ALLOW_PLAIN_HTTP = "1"
$env:WEB_SEARCH_NEO_PROJECT_ROOT = "C:\work\my-project"
python main.py

WEB_SEARCH_NEO_PROJECT_ROOT names the project whose .web-search-neo/macros/ set every macro call uses when it passes no project_root of its own. Setting it per project in the MCP client's configuration is the way to give a model a project's macros without it having to spell a path on every call. WEB_SEARCH_NEO_MACRO_ROOT still points the per-user store somewhere specific, and a named project wins over it.

The bridge daemon has five switches of its own. Set them for the MCP server process: a daemon inherits the environment of whichever server spawns it.

Variable Effect
WEB_SEARCH_NEO_BRIDGE_PORT Loopback port shared by the daemon and the servers, default 8765. Set the companion to the same number under Settings → Bridge port in its popup; it is stored and survives a browser restart.
WEB_SEARCH_NEO_BRIDGE_IDLE_SECONDS How long a daemon with neither a companion nor a client stays up, default 900. 0 disables the timer and keeps the port held.
WEB_SEARCH_NEO_BRIDGE_AUTOSPAWN 0, false, or no stops a server from starting a daemon; it then uses one only if something else already runs it.
WEB_SEARCH_NEO_BRIDGE_CONNECT_TIMEOUT How long a server keeps trying to reach a daemon, including one it started itself, default 12 seconds.
WEB_SEARCH_NEO_BRIDGE_START_TIMEOUT How long server startup waits for that first attempt before continuing in the background, default 2 seconds.

One more is about the server rather than the bridge:

Variable Effect
WEB_SEARCH_NEO_MAX_SESSIONS Browser sessions one server holds at once, default 8, capped at 64. Per process, so agents running in parallel share the allowance; the refusal at the cap names this variable. Set here it wins over the companion popup's Parallel sessions setting, which is where a user without access to the server's environment changes the same number.

Only use a proxy you are authorized to use. HTTP sessions use desktop browser headers, connection pooling, bounded response sizes, and conservative retry/backoff. Rendered pages use the installed Chrome's native matching User-Agent unless explicitly overridden.

Transport policy

Unencrypted http:// to a public host is refused, for plain fetches and for browser open alike, with an error that names the host and the override. Loopback, private, link-local, and unspecified addresses stay reachable over plain HTTP, as do localhost and any host ending in .local, .localhost, .internal, .home.arpa, .lan, .home, .intranet, .private, or .corp — a local ComfyUI, Ollama, or dev server keeps working unchanged. So does any single-label host, http://nas/ or http://raspberrypi:8080/: a name with no dot cannot be published on the public DNS, and deciding it by resolution would cost a lookup on every URL and every redirect hop. Set WEB_SEARCH_NEO_ALLOW_PLAIN_HTTP=1 to accept plain HTTP everywhere.

Redirects are followed one hop at a time, at most five, and every hop is validated again, so a public HTTPS URL cannot quietly land on plain HTTP.

Tests

Tests

python -m pip install -r requirements-dev.txt
python -m pytest
python -m pytest --cov=. --cov-report=term-missing

The deterministic suite is 576 tests, grouped by what they protect:

Area Covered
MCP contract Exactly two advertised tools, on-demand action discovery, ordered multi-action calls.
Search Routing, fallback, cooldown and cache, live status probes that leave the real cooldown untouched, accurate Bing challenge detection, manual challenges.
HTTP Fetches, the plain-HTTP policy, and per-hop redirect checks.
Perception The accessibility outline including open shadow roots and same-origin frames, refs minted in the document that owns them and resolved back into it, selectors verified unique before they are handed over, page text extraction with its non-empty main fallback, its link budget and its account of what it left out, and find separating how well a query matched from how a result ranks.
Locators The three forms and their escaping, refs that refuse to resolve in another document or after their element was removed, valid CSS that merely contains >>>.
Diagnostics The companion's console and network buffers with their ring-buffer eviction, capture armed at open so the very first navigation is reported, a claimed tab recorded only from the claim, and a subscription that failed at open repaired on the next read.
Companion bridge The handshake — a client without the token, a hello without a nonce, a newer companion evicting an older one, and WebCrypto HMAC agreeing with the Python signature — origin checks, tab grouping, and confirmation-gated Chrome setup that publishes the token before touching Chrome.
Bridge daemon Two MCP servers with commands in flight at once, two servers starting together converging on one daemon, a client never mistaken for the browser, an in-flight command failing instead of hanging when the daemon dies, a daemon of the same version left alone while an outdated one is replaced, a newer daemon used rather than evicted by the server it outranks, versions ranked component by component so 1.3.10 outranks 1.3.9, two checkouts on one port settling on the newer daemon, a second outdated replacement reported instead of looped over — with the reported daemon.version being the one the error names — a conflict that clears itself once the other side stops, a daemon of another bridge protocol refused by name, the idle exit once neither browser nor client is left, --bridge --stop against a running daemon and against none, each client seeing how many agents share the bridge and which tabs they hold, a departure taken off that list, a tab list that marks what another agent is already driving, a handshake that still gets its acknowledgement while the bridge is broadcasting, and a stop request that waits for its own answer rather than the next frame.
Forms Multipart upload, form filling, native validation, values read back off the control so a refused write is not a success, exact PNG viewport size, a guarded terminal click that counts its matches in the page and so works in the user's own Chrome as well as a Selenium one, and guarded refusals that name the ledger they refuse from.
Challenges A captcha that blocks told apart from one merely present on the page, and the providers a top-level query walked past — DataDome, AWS WAF, a challenge one frame down, and one in a shadow root.
Games and input Canvas probing, normal/throttled/step rendering, atomic mixed input, held modifiers across a batch, gate and held-input reset on navigation, coordinates that follow a frame's CSS transforms, key spellings that release each other, and a virtual clock whose intervals keep their period.
Sessions Sessions that close the tabs they own, concurrent sessions, persistent storage, a session dropped when the browser it was opened in is gone, a borrowed tab handed back instead of navigated, a real managed-Chrome attach/detach that leaves Chrome running, a second caller inside one session reported rather than swallowed (and a reentrant one not mistaken for it), and a full session cap that names the setting which lifts it.

Public search engines may rate-limit an IP or region, so live internet smoke checks are kept separate from deterministic tests. scripts/live_smoke.py runs a search, opens two pages concurrently, fills and submits a public Selenium test form, uploads a file, and verifies exact screenshot dimensions; it does not cover games. scripts/companion_live_smoke.py reaches the network too, though its subject is the extension: it starts a disposable Chromium with the companion loaded and drives whatever --url names, which defaults to https://example.com. Public game sites change without notice, so the frame gate, input atomicity, and held-input recovery are verified by the deterministic local suite instead.

End-to-end check with a local model

scripts/live_agent_game.py exercises the whole stack the way a real client does: it starts main.py as an MCP stdio subprocess, hands the two advertised tools to a model served by LM Studio on 127.0.0.1:1234, and lets that model play the bundled platformer fixture while every tool call is timed.

lms load qwen3.5-4b-mtp --context-length 16384 --parallel 1
python scripts/live_agent_game.py --model qwen3.5-4b-mtp

It reports the eager tool-schema size and the median and p95 latency of both MCP tool calls and model turns, so a regression in either is visible immediately. Thinking is disabled through reasoning_effort: "none", because a mechanical control loop pays for it without gaining anything. The script needs Chrome and a running LM Studio server and is not part of pytest. Whether the model finishes the level is a property of the model, not of the server: a 4B does it, but not on every attempt.

Safety notes

  • Visible or attached sessions may contain authenticated accounts. The MCP client can act with the permissions of those accounts.
  • The companion declares five permissions: alarms, debugger, storage, tabs, and tabGroups. It ships no content scripts and asks for no host_permissions, but debugger is the broad one: it lets the extension attach the Chrome DevTools Protocol to a tab and from there read and modify that page, its console, and its network traffic. Chrome shows a "started debugging this browser" banner whenever it is attached. alarms is the narrow one — it only wakes a suspended service worker to retry the bridge, and Chrome shows no extra warning for it. Install the companion only from this repository.
  • The loopback bridge is authenticated in both directions. The extension proves it holds the machine-local token before the bridge accepts a command, and the bridge proves the same by returning HMAC-SHA256(token, nonce) before the extension executes one; an MCP server presents the same token before it may relay anything. Until 1.3.0 the port accepted any local client that spoke the protocol, and the extension trusted whatever answered on it.
  • That secret is a file readable by the user account that owns it, so it does not defend against a malicious process already running as you: such a process can read the token and impersonate either side. It removes the race in which any local program that binds 127.0.0.1:8765 before the server inherits DevTools access to every signed-in tab. Chrome Native Messaging, which needs no listening port at all, is the actual fix and is tracked in TODO.md.
  • The port is now held by a daemon that outlives each agent call, so it is reachable for as long as Chrome keeps the companion attached rather than only while an agent runs. The authentication is unchanged and same-user processes were never excluded by it, but the window in which one could use the token is wider. Stop the daemon with python main.py --bridge --stop, or let its idle exit close the port fifteen minutes after the last companion and client are gone.
  • Authentication is not authorization. An authenticated peer may call cdp.send with any DevTools method on any tab the session drives; there is no method allowlist yet.
  • setup_current_chrome opens no page, navigates nothing, and reads no browsing data. It publishes the shared secret and returns the steps. The single exception, since 1.3.1, is that it may tell an already-installed companion older than the bundled build to reload itself; installing the companion stays a deliberate user action in Chrome's own UI.
  • Plain http:// to public hosts is refused unless WEB_SEARCH_NEO_ALLOW_PLAIN_HTTP=1 is set; loopback and private-network addresses are always reachable.
  • File upload tools can upload local paths supplied to the tool. Review agent actions and scope filesystem access appropriately.
  • Browser automation may be restricted by a site's terms of service. Use it only where you are authorized.
  • Manual challenge mode hands control to you; it does not attempt to bypass CAPTCHA protections.

Contributing

Issues and focused pull requests are welcome. A new search engine only needs a SearchProvider implementation plus register_search_provider(provider); status, cooldown, cache, and fallback routing update automatically.

See TODO.md for the current roadmap.

from github.com/NeoXider/web-search-neo

Установить Web Search Neo в Claude Desktop, Claude Code, Cursor

Рекомендуется · одна команда, все IDE
unyly install web-search-neo

Ставит в Claude Desktop, Claude Code, Cursor и VS Code — сам разбирается с npx, uvx и сборкой из исходников.

Впервые? Поставь CLI: curl -fsSL https://unyly.org/install | sh

Или настроить вручную

Выполни в терминале:

claude mcp add web-search-neo -- uvx --from git+https://github.com/NeoXider/web-search-neo web-search-neo

Пошаговые гайды: как установить Web Search Neo

FAQ

Web Search Neo MCP бесплатный?

Да, Web Search Neo MCP бесплатный — установка в пару кликов через Unyly без оплаты.

Нужен ли API-ключ для Web Search Neo?

Нет, Web Search Neo работает без API-ключей и переменных окружения.

Web Search Neo — hosted или self-hosted?

Self-hosted: сервер запускается локально на твоей машине командой из раздела установки.

Как установить Web Search Neo в Claude Desktop, Claude Code или Cursor?

Открой Web Search Neo на unyly.org, выбери вкладку своего клиента (Claude Desktop, Claude Code, Cursor) и нажми Install — конфиг сгенерируется автоматически, без правки JSON.

Похожие MCP

Playwright

Browser automation, scraping, screenshots

Microsoftавтор: Microsoft

Puppeteer

Browser automation and web scraping.

modelcontextprotocolавтор: modelcontextprotocol

Garmin Connect

An MCP server for Garmin Connect that provides access to fitness activities, health statistics, and sleep data by routing requests through a headless browser to

etweisbergавтор: etweisberg

Higgsfield Unlimited

MCP server for Higgsfield AI that enables unlimited-mode image, video, audio generation, uploads, and job management via multiple parallel accounts, using brows

nukIeerавтор: nukIeer

opentabs-dev/opentabs

Plugin-based MCP server + Chrome extension that gives AI agents access to web applications through the user's authenticated browser session. 100+ plugins with a

opentabs-devавтор: opentabs-dev

robhunter/agentdeals

1,500+ developer infrastructure deals, free tiers, and startup programs across 54 categories. Search deals, compare vendors, plan stacks, and track pricing chan

robhunterавтор: robhunter

hlydecker/ucsc-genome-mcp

MCP server to interact with the UCSC Genome Browser API, letting you find genomes, chromosomes, and more.

hlydeckerавтор: hlydecker

34892002/bilibili-mcp-js

A MCP server that supports searching for Bilibili content. Provides LangChain integration examples and test scripts.

34892002автор: 34892002

achiya-automation/safari-mcp

Native Safari browser automation for AI agents with 80+ tools. No Chrome dependency, optimized for Apple Silicon with 60% less CPU overhead.

achiya-automationавтор: achiya-automation

agent-infra/mcp-server-browser

Browser automation capabilities using Puppeteer, both support local and remote browser connection.

bytedanceавтор: bytedance

Compare Web Search Neo with

Не уверен что выбрать?

Найди свой стек за 60 секунд

Автор?

Embed-бейдж для README

Похожее

Все в категории browse