Repository navigation
fix(core): fix MCP elicitation forms never appearing - #58184
Conversation
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>
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
## 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.
## 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.
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 ondev.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.publishtakes the event's location from the ambientLocation.Service, soform.createdwent 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 outOnce 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/cancelledfor request id0: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
@modelcontextprotocol/client@2.0.0patch so_oncancelchecksrequestId === 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
"global"sentinel session, so they show in every session view for that Location instead of only the calling session.questionandwebsearch.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 runattaches 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. Withclient.tsreverted, both URL tests fail withReceived: undefined(no Location).bun test test/mcp.test.ts -t "gives up". With the SDK patch reverted, the form stayspendinguntil 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 checkfrom 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:{"action":"accept","content":{"approve":true}}{"action":"accept","content":{"approve":false}}{"action":"cancel"}input_required), form{"action":"accept","content":{"approve":true}}{"action":"accept"}{"action":"accept"}execute→tools.cm.deploy)GET /api/session/global/form→[])Added by the opencode bot (AI-generated).
Related: #53608