Desktop: MCP elicitation is declared but never presented — `onElicitation` is never wired, and `local-agent-mode-*` declares no `elicitation` (2.2553.1)
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report
- [x] I am using the latest version of Claude Code
What's Wrong?
Summary
In Claude Desktop's Code tab, a local stdio MCP server cannot ask the human anything through MCP elicitation. The MCP client Desktop presents to local servers identifies as local-agent-mode-<serverName> and declares no elicitation capability at initialize. A spec-compliant server therefore never sends elicitation/create at all — it reads the capability, sees nothing, and silently falls back to prose.
The same server, same config entry, driven by the standalone CLI (claude 2.1.251) sees elicitation: {} and gets a working inline form.
This is the capability-declaration side of the problem, which I believe is not yet filed. The hook side is #89858; see "Not a duplicate of" below.
Environment
- Claude Desktop 2.2553.1, macOS (Darwin 25.5.0);
app.asar45,624,218 bytes - Bundled Agent SDK:
CLAUDE_AGENT_SDK_VERSION="0.3.275" - Standalone CLI used for comparison: 2.1.251
- Server: any stdio server that records its own
initializeparams (repro below)
What I observed
Byte offsets are into the unpacked asar's .vite/build/*.js.
1. [observed] The Code-tab MCP client declares roots + the MCP Apps UI extension, and nothing else.index.chunk-Cchj35Qj.js @10558, in LocalMcpServerManager.createConnection:
x = { ...y && { roots: { listChanged: !0 } },
extensions: { "io.modelcontextprotocol/ui": { mimeTypes: ["text/html;profile=mcp-app"] }, ... } }
f = new Client({ name: `local-agent-mode-${e}`, version: "1.0.0" }, { capabilities: x, ... })
The string elicitation occurs 0 times in that chunk, and the only request handler set on this client is roots/list.
2. [observed] What the server actually sees. From a server's own record of initialize:
- via the Code tab:
clientInfo {"name":"local-agent-mode-<server>","version":"1.0.0"},capabilitieswith noelicitationkey - via the standalone CLI:
capabilities.elicitation = {}(CLI binary:function vCn(){return{roots:{listChanged:!0},elicitation:{},…)
3. [observed] The Code tab's query() call sets canUseTool and onUserDialog, never onElicitation.index.chunk-l0PS_wmg.js @809198:
canUseTool: this.permissionBroker.createCanUseTool(i, d),
onUserDialog: this.userDialogBroker.createOnUserDialog(i),
supportedDialogKinds: this.userDialogBroker.supportedDialogKinds({ ... })
onElicitation occurs 0 times in that chunk. Across the entire bundle it occurs in exactly one chunk — the SDK's.
4. [observed] The bundled SDK declines when that hook is unset.index.chunk-Bepat7CB.js @917508:
if (e.request.subtype === "elicitation") {
let n = e.request
if (this.onElicitation) { let r = await this.onElicitation({ ... }); return r === null ? DK : r }
return { action: "decline" }
}
[inferred] 3 + 4 together mean that even if a local server's request reached the CLI's elicitation path from the Code tab, it would be declined. Today it never gets that far, because of 1.
Expected
Either the Code-tab client declares elicitation (at least form mode) and renders elicitation/create inline — the standalone CLI already does this for the same config entry, and onUserDialog shows the dialog plumbing exists — or, if human input is intentionally unsupported on this surface, document that so servers can fall back deliberately.
Declaring nothing is spec-correct, but it silently removes a capability the same server has one surface away, and a well-behaved server has no way to tell "not supported here" from "nobody asked".
Repro (~5 minutes)
probe.mjs — zero-dependency stdio server with one tool that returns whatever the client declared at initialize:
import { createInterface } from 'node:readline'
let seen = null
const send = (m) => process.stdout.write(JSON.stringify(m) + '\n')
createInterface({ input: process.stdin }).on('line', (line) => {
if (!line.trim()) return
const msg = JSON.parse(line)
if (msg.id === undefined || !msg.method) return
const reply = (result) => send({ jsonrpc: '2.0', id: msg.id, result })
if (msg.method === 'initialize') {
seen = { clientInfo: msg.params?.clientInfo, capabilities: msg.params?.capabilities }
return reply({ protocolVersion: msg.params?.protocolVersion ?? '2025-06-18',
capabilities: { tools: {} }, serverInfo: { name: 'cap-probe', version: '0.0.1' } })
}
if (msg.method === 'tools/list') return reply({ tools: [{ name: 'probe_capabilities',
description: 'Returns the MCP client capabilities recorded at initialize.',
inputSchema: { type: 'object', properties: {} } }] })
if (msg.method === 'tools/call') return reply({ content: [{ type: 'text', text: JSON.stringify(seen, null, 2) }] })
send({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: msg.method } })
})
claude mcp add --scope user cap-probe -- node /absolute/path/probe.mjs- In a Desktop Code tab session: "Call cap-probe's
probe_capabilitiestool and show the raw result."
→ clientInfo.name is local-agent-mode-cap-probe; capabilities has no elicitation.
- In the standalone CLI, same prompt →
capabilities.elicitationis{}.
Not a duplicate of
- #89858 — SDK
onElicitationunset → auto-decline (items 3–4 here). That is the hook side; this issue is that local servers in the Code tab are never offered elicitation, so there is nothing to decline. Fixing #89858 alone would not change item 1. - #88901 / #94450 / #94806 — Cowork and remote-connector legs, where the client does declare elicitation and then never renders / auto-cancels / hangs to timeout. Here the capability is absent, so a compliant server falls back silently and immediately rather than hanging.
- #88075 / #48164 — URL-mode elicitation not advertised by the CLI. Different mode, different surface.
Related (same build, context only — no change requested here)
The Desktop chat client (claude-desktop-3p, index.chunk-ChZ67Jhw.js) declares capabilities: { elicitation: { url: {} } } only, logs declining form-mode elicitation for form requests, and refuses non-https: URLs. So for a local server today: no elicitation in the Code tab, and URL-only (https) elicitation in chat — which a http://127.0.0.1 local server cannot use either.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
Correction from the reporter — the title and framing were wrong for one of the two paths
I have new live captures that contradict part of what I filed. Correcting it rather than leaving it to mislead.
What I got wrong. I wrote that in the Code tab a local stdio server sees
local-agent-mode-<serverName>with noelicitation. That is not true for a server registered withclaude mcp add(i.e. in~/.claude.json). There are two different local MCP client paths on the same machine, and I conflated them:| Registration | Client the server sees |
elicitationatinitialize||---|---|---|
|
claude_desktop_config.json→ Desktop'sLocalMcpServerManager|local-agent-mode-<serverName>v1.0.0 | absent ||
~/.claude.json→ the CLI embedded in the Code tab |claude-code2.1.251 / 2.1.275 |elicitation: {}present |Three live captures today from a zero-dep stdio server that logs its own
initialize, registered viaclaude mcp add --scope userand driven from a Desktop Code tab session:So "local stdio MCP servers never receive elicitation in the Code tab" is too strong, and I've retitled accordingly.
What still stands, unchanged. Both original findings are reproducible:
LocalMcpServerManagerreally does build its client withroots+io.modelcontextprotocol/uiand noelicitation(index.chunk-Cchj35Qj.js). That client is what a server configured inclaude_desktop_config.jsonsees. A server in both configs sees both clients.query()call setscanUseToolandonUserDialogbut neveronElicitation(index.chunk-l0PS_wmg.js@809198), and the bundled Agent SDK 0.3.275 auto-declineselicitation/createwhenonElicitationis unset (index.chunk-Bepat7CB.js@917508).onElicitationappears in exactly one chunk bundle-wide.Why the corrected version is arguably the worse bug. On the
~/.claude.jsonpath the capability is declared, so a spec-compliant server does the right thing and opens a form — and then, per (2), the SDK answersdeclineon the client's behalf. The server receives{"action":"decline"}, which in MCP means the human declined. It cannot distinguish that from a real refusal, so it reports a human decision that never happened.For an approval gate that is a correctness problem, not a UX one: our server was telling users "you declined this change" for a prompt they were never shown.
Scope of what I verified. (1) and (2) are read directly from the shipped bundle at the offsets given, and the capability captures are live. The end-to-end auto-decline on the Code tab path is inferred from (2) plus the SDK code — my probe could not complete the round trip, so I have not observed the
declineframe itself. Flagging that rather than implying I measured it.Suggested fix, unchanged in substance: wire
onElicitationin the Code tab orchestrator, and addelicitationtoLocalMcpServerManager's declared capabilities. Until then, a client that declareselicitationwithout a handler is worse for servers than one that declares nothing, because it converts "cannot ask" into "the human said no."The correction makes this a two-contract bug, and it also makes the acceptance boundary clearer. I would test the two registration paths separately:
claude_desktop_config/LocalMcpServerManager, either advertise only capabilities that have a bound handler, or advertise elicitation only when the UI callback is actually available.~/.claude.jsonCode-tab path, if form elicitation is advertised, require a real UI callback; never synthesize a humandeclinewhen the callback is missing.That distinction is the same approval/evidence boundary I care about while maintaining Hronaut, a local visible Browser/MCP workspace. Hronaut is disclosed for context only; this comment is AI-assisted and does not claim Claude Code compatibility.
Follow-up: my registration-path table was wrong; both bugs are still in 2.2553.13
Retraction. My correction above says the registration file decides which client a server sees in the Code tab (
claude_desktop_config.json→local-agent-mode-*,~/.claude.json→claude-code). A controlled test disproves that as a rule:local-agent-mode-<serverName>v1.0.0, which declares noelicitation.claude_desktop_config.jsonand relaunched. Code-tab tool calls still arrived vialocal-agent-mode-<serverName>.claude mcp add, did getclaude-code(my earlier captures).So something other than that file decides which client serves a Code-tab call, and I have not found what. Moving a server to
claude mcp addis not a reliable workaround. A test plan that splits by registration file should split by the client identity the server actually sees atinitializeinstead.Still present in Claude Desktop 2.2553.13 with the bundled Agent SDK 0.3.280 (read from the shipped bundle):
LocalMcpServerManagerdeclaresroots+io.modelcontextprotocol/uiand noelicitation(index.chunk-BlVDR-X3.js@10558).query()call passescanUseTool,onUserDialogandsupportedDialogKinds, neveronElicitation(index.chunk-J2SXJQ_3.js@810521).onElicitationunset, the SDK answerselicitation/createwith{"action":"decline"}(index.chunk-CvQb3ejj.js@921326). WithonUserDialogunset, it synthesizes no answer. The SDK already treats "no handler" as "no answer" in one place, and as "the human said no" in the other.Something to reuse. Desktop already ships a URL-elicitation dialog, but only the managed / custom-3P remote path reaches it (
index.chunk-Cj_08-IJ.js@2172345, its client at @2237941;directMcpHost.js@591806 declareselicitation: {url: {}}). Routing local servers'elicitation/createto that dialog would cover URL mode.Measured vs inferred. The client identities and the config experiment are live captures. Items 1–3 and the dialog are read from the bundle. The end-to-end auto-decline on the
claude-codepath is still inferred: I have not captured thedeclineframe itself.