[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
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 onsdkServersChanged, 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
- Install/pair the Claude in Chrome extension; confirm the native messaging host is registered.
- Open the Claude Code VS Code extension panel.
- 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.)
- 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.
- 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: {}andchromeMcpState: { status: "disconnected" }. ensure_chrome_mcp_enabledis triggered by the@browsermention itself, in the webview's outgoing-message preprocessing — not by a prior connection event. The mention parserw70runs on send; if the message contains@browser, itawaits theensureChromeMcpEnabled()callback and only then injects the browser prompt into that same message:
``js<browser_instruction>...
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(); // ← injected into the SAME message, afterward`
...
}
<browser_instruction>
So the connected pill and 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(...)
requestToolPermissioneven special-casesmcp__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):
- The
claude-in-chromestdio server connects and its tools are fetched, then committed intoMH(dynamic state) unconditionally. BH()reports status from[...mcp.tools, **...MH.tools**]and listsMH.clientsas connected — so the pill shows "connected" with tools and@browseris injected.- But the only writer of the model-facing
state.mcp.toolsis the gated block above, which (a) merges onlyKH(SDK tools) and neverMH.tools, and (b) runs only whensdkServersChanged— which isfalsefor 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 0This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗