Desktop: MCP elicitation is declared but never presented — `onElicitation` is never wired, and `local-agent-mode-*` declares no `elicitation` (2.2553.1)

Status Open
Reported on v2.1.251
Maintainer reply None cached
Activity 5 comments · opened Sep 22, 2026

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.asar 45,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 initialize params (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"}, capabilities with no elicitation key
  • 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 } })
})
  1. claude mcp add --scope user cap-probe -- node /absolute/path/probe.mjs
  2. In a Desktop Code tab session: "Call cap-probe's probe_capabilities tool and show the raw result."

→ clientInfo.name is local-agent-mode-cap-probe; capabilities has no elicitation.

  1. In the standalone CLI, same prompt → capabilities.elicitation is {}.

Not a duplicate of

  • #89858 — SDK onElicitation unset → 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.

View original on GitHub ↗

3 Comments

ferterahadi-oddle · 22 days ago

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 no elicitation. That is not true for a server registered with claude 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 | elicitation at initialize |
|---|---|---|
| claude_desktop_config.json → Desktop's LocalMcpServerManager | local-agent-mode-<serverName> v1.0.0 | absent |
| ~/.claude.json → the CLI embedded in the Code tab | claude-code 2.1.251 / 2.1.275 | elicitation: {} present |

Three live captures today from a zero-dep stdio server that logs its own initialize, registered via claude mcp add --scope user and driven from a Desktop Code tab session:

{"clientInfo":{"name":"claude-code","title":"Claude Code","version":"2.1.275",
  "description":"Anthropic's agentic coding tool","websiteUrl":"https://claude.com/claude-code"},
 "capabilities":{"roots":{"listChanged":true},"elicitation":{}},
 "protocolVersion":"2025-11-25"}

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:

  1. Desktop's own LocalMcpServerManager really does build its client with roots + io.modelcontextprotocol/ui and no elicitation (index.chunk-Cchj35Qj.js). That client is what a server configured in claude_desktop_config.json sees. A server in both configs sees both clients.
  2. The Code tab's query() call sets canUseTool and onUserDialog but never onElicitation (index.chunk-l0PS_wmg.js @809198), and the bundled Agent SDK 0.3.275 auto-declines elicitation/create when onElicitation is unset (index.chunk-Bepat7CB.js @917508). onElicitation appears in exactly one chunk bundle-wide.

Why the corrected version is arguably the worse bug. On the ~/.claude.json path the capability is declared, so a spec-compliant server does the right thing and opens a form — and then, per (2), the SDK answers decline on 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 decline frame itself. Flagging that rather than implying I measured it.

Suggested fix, unchanged in substance: wire onElicitation in the Code tab orchestrator, and add elicitation to LocalMcpServerManager's declared capabilities. Until then, a client that declares elicitation without a handler is worse for servers than one that declares nothing, because it converts "cannot ask" into "the human said no."

Hronom · 22 days ago

The correction makes this a two-contract bug, and it also makes the acceptance boundary clearer. I would test the two registration paths separately:

  • For claude_desktop_config / LocalMcpServerManager, either advertise only capabilities that have a bound handler, or advertise elicitation only when the UI callback is actually available.
  • For the ~/.claude.json Code-tab path, if form elicitation is advertised, require a real UI callback; never synthesize a human decline when the callback is missing.
  • Include values-free provenance in diagnostics: registration source, client identity, protocol version, advertised capabilities, handler-bound state and client/session generation.
  • Cover form accept, explicit decline, cancel, missing handler, bridge disconnect, restart, duplicate request ID, server timeout and concurrent surfaces. The server should be able to distinguish user decline/cancel from unsupported or unknown UI delivery.

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.

ferterahadi-oddle · 22 days ago

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:

  • Server registered in both files: Code-tab tool calls arrive via local-agent-mode-<serverName> v1.0.0, which declares no elicitation.
  • With Desktop fully quit, I removed the server from claude_desktop_config.json and relaunched. Code-tab tool calls still arrived via local-agent-mode-<serverName>.
  • A throwaway server Desktop had never seen, registered only with claude mcp add, did get claude-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 add is not a reliable workaround. A test plan that splits by registration file should split by the client identity the server actually sees at initialize instead.

Still present in Claude Desktop 2.2553.13 with the bundled Agent SDK 0.3.280 (read from the shipped bundle):

  1. LocalMcpServerManager declares roots + io.modelcontextprotocol/ui and no elicitation (index.chunk-BlVDR-X3.js @10558).
  2. The Code-tab query() call passes canUseTool, onUserDialog and supportedDialogKinds, never onElicitation (index.chunk-J2SXJQ_3.js @810521).
  3. With onElicitation unset, the SDK answers elicitation/create with {"action":"decline"} (index.chunk-CvQb3ejj.js @921326). With onUserDialog unset, 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 declares elicitation: {url: {}}). Routing local servers' elicitation/create to 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-code path is still inferred: I have not captured the decline frame itself.

Showing cached comments. Read the full discussion on GitHub ↗