[BUG] Claude in Chrome (VS Code 2.1.160): `mcp_set_servers` publishes only SDK tools to the model and gates even that on `sdkServersChanged` — so a dynamically-added stdio server connects (status/pill shows its tools) but the model never receives them

Resolved 💬 3 comments Opened Jun 2, 2026 by CiberNin Closed Jun 22, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

Using Claude in Chrome from the VS Code extension panel, the browser shows as fully
connected — the "connected" pill appears, @browser is accepted, and the browser system
prompt is injected — but Claude never actually receives the mcp__claude-in-chrome__* tools.
They're absent from the model's tool set: not directly callable and not found via tool search.
Browser automation is therefore unusable from the panel even though every status indicator
says it's connected.

This is NOT the -32601 issue (#23939) and NOT the --no-chrome flag (that flag is intentional).
Root cause, traced through the shipped binary: when the Chrome stdio MCP server is added
mid-conversation via setMcpServers, the only code path that publishes tools to the MODEL
merges SDK-instance tools only (never dynamic/stdio-server tools) AND is gated on
sdkServersChanged, which is false for a stdio-only add. The status reporter reads
different state that DOES include the dynamic tools — which is exactly why the pill says
connected while the model gets nothing.

What Should Happen?

After the Chrome MCP server is added via setMcpServers and connects, the
mcp__claude-in-chrome__* tools should be surfaced to the model (callable, or present in
the tool-search index) on that turn or the next — i.e. browser automation should work from
the panel, matching the claude --chrome CLI behavior. The tool set reported by
mcpServerStatus should equal the tool set the model actually receives.

Error Messages/Logs

No error is raised — the failure is silent (the tools simply never appear). Corroboration:

Processes (the stdio server genuinely connects, so the tools exist to be published):
  claude.exe --chrome-native-host                 (native host)
  claude.exe … --no-chrome --replay-user-messages (panel session — intentional)
  claude.exe … --claude-in-chrome-mcp             (Chrome MCP server — connected)

Divergence observed in the same instant: mcpServerStatus lists the mcp__claude-in-chrome__*
tools as connected, while the model has zero such tools (cannot see or call them).

Steps to Reproduce

  1. Install/pair the Claude in Chrome extension; confirm the native messaging host is registered.
  2. Open the Claude Code VS Code extension panel.
  3. Send a message containing an @browser mention.

(This send is what triggers the connection — the "connected" pill and the
<browser_instruction> prompt appear as a RESULT of this step, not before it.)

  1. Observe: the pill flips to connected and the --claude-in-chrome-mcp subprocess spawns,

yet Claude reports it has no browser tools; mcp__claude-in-chrome__* tools are not
callable and tool search returns nothing for them.

  1. Send further messages (with or without @browser): the tools never appear for the rest

of the conversation.

Claude Model

Not sure / Multiple models

Is this a regression?

Yes, this worked in a previous version

Last Working Version

_No response_

Claude Code Version

2.1.160

Platform

Anthropic API

Operating System

Windows

Terminal/Shell

VS Code integrated terminal

Additional Information

Root cause

From extension.js (2.1.160), the relevant flow:

  • The panel session starts with mcpServers: {} and chromeMcpState: { status: "disconnected" }.
  • ensure_chrome_mcp_enabled is triggered by the @browser mention itself, in the webview's outgoing-message preprocessing — not by a prior connection event. The mention parser w70 runs on send; if the message contains @browser, it awaits the ensureChromeMcpEnabled() callback and only then injects the browser prompt into that same message:

``js
async function w70(text, ensureChromeMcpEnabled /*=Z*/, ...) {
let X = [...text.matchAll(/@browser.../g)];
if (X.length === 0) return [];
if (await ensureChromeMcpEnabled()) // ← fires the enable, in-band with the send
push(
<browser_instruction>...); // ← injected into the SAME message, afterward
...
}
`
So the connected pill and
<browser_instruction> are a **consequence of sending an @browser message**, never a precondition. The enable then runs ensureChromeMcpEnabled() on the CLI side:
`js
const x = this.getChromeMcpServerConfig(); // { command: claude, args: ["--claude-in-chrome-mcp"] }
const N = { ...V.mcpServers, "claude-in-chrome": x };
const B = await V.query.setMcpServers(N); // dynamic, mid-session
// on success: V.chromeMcpState = { status: "connected" }; pushChannelStateUpdate(...)
``

  • requestToolPermission even special-cases mcp__claude-in-chrome__* to auto-allow while connected.

Confirmed from the shipped binary. The CLI/SDK implementation is bundled (as text) inside resources/native-binary/claude.exe. Tracing the full data flow shows two compounding defects: the only code path that publishes MCP tools to the model-facing tool list (a) merges only SDK-instance tools and never dynamic-server tools, and (b) is additionally gated on sdkServersChanged which excludes dynamic servers entirely. Meanwhile the status reporter does include dynamic tools — which is exactly why the connected pill and @browser prompt appear while the model gets nothing.

setMcpServers (SDK client) sends an mcp_set_servers control request. Inside the CLI, server reconciliation (xE4) splits the desired servers into SDK-instance servers (M) and everything else — stdio/sse/http (j) — handing the latter to uE4, which connects each dynamic server and fetches its tools:

// xE4: SDK servers tracked in newSdkState; dynamic servers delegated to uE4
let L = await uE4(j, $, K, _, f);                 // stdio (claude-in-chrome) connected here
return {
    response: { added: [...P, ...L.response.added], removed: [...W, ...L.response.removed], ... },
    newSdkState: { configs: X, clients: J, tools: Z },
    newDynamicState: L.newState,                  // <-- holds the chrome stdio tools
    sdkServersChanged: P.length > 0 || W.length > 0   // P/W = SDK-instance servers ONLY
};
// uE4: for each added dynamic server -> Q = await AR(name, cfg); if connected: tools = await Ah(Q)

The iH wrapper then commits state and (conditionally) publishes tools to the live session:

// state committed UNCONDITIONALLY:
Object.assign(A, h8.newSdkState.configs);   // A  = sdk configs
AH = h8.newSdkState.clients;                 // AH = sdk clients
KH = h8.newSdkState.tools;                    // KH = sdk tools
MH = h8.newDynamicState;                       // MH = dynamic state — HOLDS the chrome tools

if (h8.sdkServersChanged) {                    // (b) GATE: never true for a stdio-only add
    Y(state => ({ ...state, mcp: { ...state.mcp, tools: [
        ...state.mcp.tools.filter(/* drop replaced */),
        ...KH                                  // (a) merges ONLY KH (sdk tools); MH.tools is never merged
    ]}}));
}
return { response: h8.response, sdkServersChanged: h8.sdkServersChanged };

Contrast the status reporter BH() (backs mcpServerStatus / the connected-pill UI), which does surface dynamic state:

let pH = bP([...mH.mcp.tools, ...MH.tools], "name");           // <-- INCLUDES MH.tools
// lists [...sdk clients, ...AH, ...MH.clients] as connected, each with its tools

The defect (and why the UI lies):

  1. The claude-in-chrome stdio server connects and its tools are fetched, then committed into MH (dynamic state) unconditionally.
  2. BH() reports status from [...mcp.tools, **...MH.tools**] and lists MH.clients as connected — so the pill shows "connected" with tools and @browser is injected.
  3. But the only writer of the model-facing state.mcp.tools is the gated block above, which (a) merges only KH (SDK tools) and never MH.tools, and (b) runs only when sdkServersChanged — which is false for a stdio-only addition. So the Chrome tools have no path to the model, on the triggering turn or any later turn.

The same sdkServersChanged gate guards wH() (the reconciliation/rebuild called right after the handler, if (_H(pH,Q8), h8) wH()) and the headless periodic-refresh path (if (S6) wH(); beside a "Headless MCP refresh" log line) — so no secondary path rescues dynamic tools either.

This is compounded structurally by the trigger: ensure_chrome_mcp_enabled is not a separate connection step — it is caused by the @browser mention in the message being sent (the w70 send-preprocessor awaits it before injecting <browser_instruction>). So on the first @browser turn the server registration is initiated as part of building that very prompt; the server connects, but per the merge/gate defect its tools never publish to the model. Later turns don't recover even though ensure_chrome_mcp_enabled re-fires (the webview keeps re-attaching browser context, visible in the logs): re-registering an already-present server produces no add/remove diff, so response.added is empty and sdkServersChanged is false — the publish path is skipped again. Between "first registration publishes only KH, never MH.tools" and "re-registration is a no-op diff," there is simply no code path that ever puts MH.tools in front of the model.

The divergence, at a glance

The Chrome server's tools are fetched and stored, then two consumers read different state — the status UI sees them, the model never does:

                 setMcpServers({ "claude-in-chrome": stdio })
                                   │
                                   ▼
                      xE4()  ── splits servers ──┐
                       │ SDK-instance (M)         │ stdio/sse/http (j)
                       ▼                          ▼
                  newSdkState                  uE4(): AR() connect + Ah() fetch tools
                  { tools: KH }                  └─> newDynamicState  { tools: MH.tools }
                       │                                      │
                       │   iH() commit (UNCONDITIONAL):       │
                       │   A,AH,KH ← sdk      MH ← dynamic ◄───┘
                       │
        ┌──────────────┴───────────────────────────────────────────────┐
        ▼                                                               ▼
  model-facing publish                                          status reporter BH()
  if (sdkServersChanged)              ✗ false for stdio-only     tools = [...mcp.tools,
      mcp.tools = [...old, ...KH]     ✗ merges KH only,                   ...MH.tools]  ✓ includes dynamic
                                        never MH.tools           clients = [...sdk, ...MH.clients] ✓
        │                                                               │
        ▼                                                               ▼
   MODEL sees: (chrome tools absent)                          UI/pill: "connected", @browser ✓
        │                                                               │
        └──────────────── same session, contradictory views ───────────┘

Legend: KH = SDK-instance tools · MH.tools = dynamic (stdio) tools incl. mcp__claude-in-chrome__*. The model-facing branch is the only writer of state.mcp.tools, and it never carries MH.tools.

Sequence (message flow)

user types "@browser …"  webview (w70 on send)   extension (panel)   CLI / SDK (--no-chrome)        model
  │  send msg w/ @browser   │                        │                      │                          │
  │────────────────────────▶│ parse @browser →       │                      │                          │
  │                         │ await ensureChromeMcp ─▶│ ensure_chrome_mcp_enabled                       │
  │                         │                        │─────────────────────▶│ mcp_set_servers           │
  │                         │ inject <browser_instr> │   user prompt ──────▶│ (stdio connect OK,        │
  │                         │  + prompt (SAME batch) │                      │  tools → MH; pill ✓)      │
  │                         │                        │                      │  sdkServersChanged=false  │
  │                         │                        │                      │  → publish SKIPPED ──────▶│ turn runs
  │                         │                        │                      │                          │ NO chrome
  │                         │                        │                      │                          │ tools; TS→0
  │   later plain turn ─────────────────────────────▶│   user prompt ──────▶│ (no re-publish path)─────▶│ STILL 0

View original on GitHub ↗

This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗