Skip to content

fix(core): fix MCP elicitation forms never appearing - #58184

Merged
rekram1-node merged 2 commits into
devfrom
fix-mcp-elicitation
Oct 22, 2026
Merged

rekram1-node merged 2 commits into
devfrom
fix-mcp-elicitation

Conversation

@rekram1-node

@rekram1-node rekram1-node commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #51856

Problem

When an MCP server asks for input during a tool call (elicitation/create), opencode creates a form, but the form never appears. The tool call then waits until it times out: about a minute for TS SDK servers, 10 minutes through Code Mode, or longer in the TUI. This was reported on 2.0.18 through 2.0.25 over both stdio and HTTP, and still happens on dev.

The SDK calls our elicitation handlers outside any Effect fiber. They ran through a bare Effect.runPromise, so they had no Location in context. Bus.publish takes the event's location from the ambient Location.Service, so form.created went out without one. The client data layer drops events without a location, so the form was never stored or rendered.

sequenceDiagram
    participant Server as MCP server
    participant SDK as MCP SDK
    participant Handler as elicitation handler
    participant Bus
    participant Client as client data layer
    Server->>SDK: elicitation/create
    SDK->>Handler: callback (outside any fiber)
    Handler->>Bus: publish form.created
    Note over Bus: ambient Location.Service missing<br/>→ event has no location
    Bus->>Client: form.created (no location)
    Note over Client: unlocated event dropped<br/>→ no form, call times out
Loading

Once forms rendered, live testing exposed a second bug. When a server gives up on an elicitation (its request times out or it cancels), the form stayed pending forever and took focus in the TUI. The MCP client SDK ignores notifications/cancelled for request id 0:

async _oncancel(notification) {
  if (!notification.params.requestId) return;   // 0 is falsy

Server-initiated request ids start at 0, so the first elicitation on every connection could never be cancelled. Before this PR that form leaked invisibly; with forms rendering, it blocks the prompt.

Change

  1. Run the elicitation request and completion handlers with the context captured when the connection is set up. The transport error handler in the same function already does this.
 initialize(transport)
-  runFork = Effect.runForkWith(context)
+  context = Effect.context()
+  runFork = Effect.runForkWith(context)
+  runPromise = Effect.runPromiseWith(context)
   ...
-  elicitation/create             -> Effect.runPromise(elicitation.create(...))
-  notifications/elicitation/complete -> Effect.runPromise(elicitation.complete(...))
+  elicitation/create             -> runPromise(elicitation.create(...))
+  notifications/elicitation/complete -> runPromise(elicitation.complete(...))
  1. Extend our existing @modelcontextprotocol/client@2.0.0 patch so _oncancel checks requestId === undefined. This is the same fix upstream shipped in 2.3.1.

The existing elicitation tests passed because they mock the Bus and never check where the location comes from. Both URL-elicitation tests (legacy and 2026-07-28) now assert that the form is published inside the server's Location. A new test has a legacy server time out its elicitation and asserts the form is cancelled.

@dimitriskourg found the missing-context root cause in the issue thread, and @chrischall traced the request-to-form path. Both are credited as co-authors.

Out of scope

  • Forms are still owned by the "global" sentinel session, so they show in every session view for that Location instead of only the calling session.
  • The desktop and web app only render forms of kind question and websearch.provider (packages/app/src/session/requests/session-request-tree.ts), so MCP elicitation forms still don't appear there. App support was added in feat(core): MCP elicitation support  #35064, lost in the fix(core): revert form service and mcp elicitation #35080 revert, and not reapplied when feat(core): rework form when conditions #35115 restored the core side. A follow-up will restore it.
  • opencode run attaches to the service, and attached runs intentionally leave global forms for another client to answer. A headless run therefore still waits for the server's timeout; this PR doesn't change that.

Verification

Unit tests in packages/core:

  • bun test test/mcp.test.ts -t elicitation. With client.ts reverted, both URL tests fail with Received: undefined (no Location).
  • bun test test/mcp.test.ts -t "gives up". With the SDK patch reverted, the form stays pending until the 2s wait times out.
  • bun test test/mcp.test.ts test/mcp-oauth.test.ts test/mcp-instructions.test.ts test/mcp-import-boundary.test.ts test/form.test.ts: 108 pass, 0 fail.
  • bun run check from the repo root: 34/34 tasks pass.

Live in the dev TUI against local MCP servers (stdio and Streamable HTTP, built on @modelcontextprotocol/server), with the model calling the tool:

Case Without fix With fix
stdio, legacy, form, approve no form, timeout form shown → {"action":"accept","content":{"approve":true}}
stdio, legacy, form, decline — {"action":"accept","content":{"approve":false}}
stdio, legacy, form, Esc — {"action":"cancel"}
HTTP, legacy, form — accept delivered, call completes
HTTP, 2026-07-28 (input_required), form — retry carries {"action":"accept","content":{"approve":true}}
HTTP, legacy, URL mode — {"action":"accept"}
HTTP, 2026-07-28, URL mode — {"action":"accept"}
Code Mode (execute → tools.cm.deploy) — accept delivered through Code Mode
Server times out its elicitation form stuck pending, blocks prompt form removed (GET /api/session/global/form → [])

Added by the opencode bot (AI-generated).

Related: #53608

MCP elicitation handlers ran through bare Effect.runPromise, so the
form.created event they published carried no Location. Clients drop
unlocated form events, so the form never rendered and the tool call
waited until it timed out. Run the handlers with the connection's
captured context, as the transport error handler already does.

Fixes #51856

Co-authored-by: Dimitris Kourgiantakis <64262257+dimitriskourg@users.noreply.github.com>
Co-authored-by: chrischall <10564245+chrischall@users.noreply.github.com>

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The change looks correct. The SDK calls the elicitation handlers outside any fiber, so running them with the context captured in initialize, as client.onerror already does, gives form.created the server's Location. No other MCP SDK callback that publishes events still uses a bare Effect.runPromise.

I checked it by running the updated elicitation tests against the base: both URL-elicitation tests fail there with Received: undefined for the directory, and all three pass with this change. The wider MCP and form suites pass apart from one stdio descendant-cleanup test, which also fails on the base, and typecheck passes. I couldn't confirm it in the TUI: my scripted run with a local elicitation server never got as far as the tool call, so a quick manual check against a real elicitation server would still help.

Public API is unchanged.

No concerns.

Related: #53608

The MCP client SDK ignores notifications/cancelled for request id 0
(`if (!requestId) return`), and server-initiated request ids start at
0. A server that times out or cancels its first elicitation therefore
never aborts our handler, and the form stays pending until the
connection closes. Now that these forms render, the stale form also
takes focus in the TUI. Patch the check to `requestId === undefined`,
matching the upstream fix in 2.3.1.

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new commits work. I checked the _oncancel patch by running the new test against the base install with the unpatched SDK: it fails there because the form stays pending, and with the patch it passes reliably. The remaining MCP and form tests pass apart from the stdio descendant-cleanup test, which also fails on the base, and typecheck passes. I didn't re-check this in the TUI.

Public API is unchanged.

No concerns.

@rekram1-node rekram1-node changed the title fix(core): keep Location on MCP elicitation forms fix(core): fix MCP elicitation forms never appearing Oct 22, 2026
@rekram1-node
rekram1-node merged commit 889abb4 into dev Oct 22, 2026
24 of 25 checks passed
@rekram1-node
rekram1-node deleted the fix-mcp-elicitation branch October 22, 2026 19:42
pascalandr added a commit to NeuralNomadsAI/CodeNomad that referenced this pull request Oct 24, 2026
## Problem

OpenCode 2.1.4
([#58184](anomalyco/opencode#58184)) delivers
MCP elicitation requests live, as global-session Forms (`sessionID:
"global"`) whose `form.created` event carries the server's location.

CodeNomad's live event path (`stores/instances.ts`) queued
`event.data.form`, which has no `location`. `formRequestOptions()` reads
`form.location`, so the dock's reply and cancellation went out **without
`x-opencode-directory`**. For a Form created in a worktree or
subdirectory, OpenCode answered `FormNotFoundError` until a
pending-request rescan replaced the entry with its located copy.

Found by the independent review of the 2.1.4 qualification (#1126),
which reproduced the headerless `FormNotFoundError` on an isolated 2.1.4
daemon.

## Fix

Recovery already attaches the snapshot location to global Forms. The
live path now does the same with the event location before queuing.
Session-owned Forms are unchanged, since their replies need no location
header.

`dev-docs/NATIVE_INTERRUPTION_UX.md` states that live and recovered
global Forms keep their location.

## Tests

- New regression in `instances-pending-requests.test.ts`: a real SSE
dispatch of two global Forms from a worktree, then a reply and a
cancellation. Both must send `x-opencode-directory` for that worktree.
It **fails without the fix**, because the queued Form has no location.
- With the fix:
  - pending-request, compaction and Form store tests: 55/55;
  - full UI unit suite: 1700/1700;
  - interruption dock and mobile interruption browser tests: 43/43;
  - UI typecheck.
pascalandr added a commit to NeuralNomadsAI/CodeNomad that referenced this pull request Oct 24, 2026
## Summary

- Qualify OpenCode **2.1.4** (latest stable, 23 October) with the
existing locked **2.1.0 client/plugin** dependencies, and **recommend**
it.
- Keep the technical **minimum at 2.1.2**: no consumed contract changed,
so the minimum has no new reason to move.
- 2.1.2 and 2.1.3 daemons stay ready and get an optional upgrade offer.
2.1.4 is the `tested` version. Newer and custom labels keep
authenticated contract recognition.
- The reviewed-contract fast path lists the 2.1.2 minimum and the 2.1.4
target.
- The recommended version is updated in the help (all ten locales), both
READMEs, `CONTRIBUTING.md`, `dev-docs/OPENCODE_COMPATIBILITY.md` and the
architecture skill.

## Contract review (v2.1.3 → v2.1.4, 72 commits)

- **Authenticated OpenAPI:** 121 → **124 paths**, 146 → **149
operations**. No operation changed. The changes:
  - added `POST /api/experimental/client-mcp/token`;
  - added `GET /api/experimental/client-mcp` (WebSocket);
  - added `POST /api/debug/heap-dump`;
- `Config.InfoEncoded` gains the opt-in
`experimental.background_compaction`.

The session event (`V2EventEncoded`) and message schemas are unchanged.
None of the new routes is on the instance proxy allowlist, and the proxy
relays WebSockets only for SideCars and previews.
- **Published declarations:**
  - the client adds `mcp.client.token` and `debug.heapDump`;
- the schema and plugin changes are type refactors and unused-export
removals, among them `SessionID.descending`, `SessionEvent.Source` and
`PromptInput.FileAttachment.create`; `Session.Message.ID.fromEvent` now
takes any string instead of an event ID. CodeNomad uses none of these,
and every host module its native plugin bundles import
(`@opencode/plugin`, `/effect`, `/promise/adapter`, `/rpc`,
`@opencode/schema/location`, `/session`) still exists.
- **MCP elicitation forms**
([#58184](anomalyco/opencode#58184)): an MCP
server asking for input during a tool call now creates a global-session
Form whose `form.created` event carries the server's location.
CodeNomad's live path requires that location for global Forms and shows
them as a **Project request**. Review found that the live path dropped
the location from the queued Form, so its reply lacked
`x-opencode-directory` and failed for a worktree or subdirectory until a
pending-request rescan. **#1127** fixes that; recovery already kept it.

A native probe (minimal stdio MCP server, a real Code Mode `execute`
call, CodeNomad-style reply) gives:

| Runtime | Form listed for the location | `form.created` carries the
location | Reply completes the tool |
  | --- | --- | --- | --- |
  | 2.1.3 | yes | **no** | yes |
  | 2.1.4 | yes | **yes** | yes (`action: accept`, `channel: stable`) |

On 2.1.3 such a request only appeared after a pending-request rescan; on
2.1.4 it appears live. The probe sends the location header itself, so it
checks the runtime contract; #1127 covers CodeNomad's live reply path.
- **Other changes** are covered by the native suites below:
- compaction (oldest-first shortening, ignored buffers, overflow
detection);
  - the agent's model for sessions without one;
  - V2 agent files;
  - V1 global-session import attribution;
  - MCP lifecycle fixes.
- **Live check:** this machine's shared daemon runs 2.1.4, and the
CodeNomad desktop session works through it.

## Isolated Windows qualification

Base: dev `9ac792fff`; Node **24.20.0**; exact official CLI **2.1.4**;
unchanged locked client/plugin **2.1.0**.

- **Build and checks:** production server/UI/native plugin builds,
server and root typechecks, native bundle guard.
- **Native runtime (9/9):**
  - Code Mode job ordering, plugin runtime, automation;
  - complete pruning, history, ownership, worktree and event relay;
  - loaded-only pending snapshot;
- Missions contract, family control, native and Compare/Wayfinder
trajectory.
- **Sessions (15/15):** environment, fork, aside, interrupt, lifecycle,
blank cleanup, prompt skills, provider usage/accounts/selection, service
usage, web search, plugin packages, output limit, Promise-tool failure.
- **Recurring (5/5):** all **11 recurring Mission journeys**, subagent
depth, **2 MiB** compaction, permission receipts.
- **Migrations (10/10):**
- history from **2.0.3, beta-19271, 2.0.32, 2.1.0, 2.1.1, 2.1.2 and
2.1.3** to 2.1.4;
  - Git degraded/init;
  - temporary workspaces.
- **Installation (3/3), with the 2.1.4 recommendation:**
  - fresh install of the 2.1.2 minimum;
- native **2.0.15 → 2.1.4** upgrade, after which the already-running
daemon keeps its PID and 2.0.15 version;
  - explicit OpenCode **1.18.10 → 2.1.4** replacement.
- **After the recommendation change:**
- admission, transport, update, installation and instance proxy units
**116 passed, 1 POSIX-only skipped**;
  - full server **2,471 passed, 8 skipped**, no failure or cancellation;
  - UI units **1,699/1,699**;
  - focused browser **126/126**;
  - native pruning with the real rendered UI;
  - the exact CI invocation of the Code Mode regression (without tsx);
  - ten-locale help checks (**9/9**).

This is Windows automation evidence. In CI, `latest` resolves to 2.1.4:
the compatibility job runs the native suites against the minimum and
latest on Windows, and the migration and Git-degraded certification runs
them on Ubuntu, Windows and macOS. WSL, packaged desktop,
installers/updater end-to-end and real mobile devices keep their
separate release gates.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE]: MCP Client advertises elicitation.form capability but never handles elicitation/create requests, causing tool calls to hang and timeout

1 participant