Skip to content

fix+refactor(mcp): lifecycle tests, cancelPending fix, Effect migration#19042

Merged
kitlangton merged 20 commits intodevfrom
kit/mcp-tests-and-fixes
Mar 26, 2026
Merged

fix+refactor(mcp): lifecycle tests, cancelPending fix, Effect migration#19042
kitlangton merged 20 commits intodevfrom
kit/mcp-tests-and-fixes

Conversation

@kitlangton
Copy link
Copy Markdown
Contributor

@kitlangton kitlangton commented Mar 25, 2026

Summary

Bug fixes:

  • Fix MCP servers permanently disappearing after transient errors: When listTools() failed (network blip, server restart), the client was permanently deleted with no recovery. Now forks a background reconnection fiber — tools come back on the next LLM turn.
  • Fix cancelPending key mismatch: McpOAuthCallback.cancelPending(mcpName) was a no-op because pendingAuths is keyed by oauthState, not mcpName. Added a reverse index so removeAuth() can actually cancel in-flight OAuth flows.
  • Fix readResource log messages: Copy-paste bug — said "prompt" instead of "resource".
  • Fix finishAuth not updating state: After OAuth reconnect, the new client wasn't being stored in InstanceState.
  • Fix mcpNameToState memory leak: Reverse index entries weren't cleaned up on successful/error OAuth callback.

Effect migration:

  • Migrate MCP from Instance.state() to Effect Service/Layer/InstanceState with makeRunPromise and async facades
  • Effectify McpAuth with AppFileSystem (no error channel — orDie inside the service)
  • Wire McpAuth.Service as a layer dependency (direct yield* auth.* instead of Effect.promise)
  • OAuth methods moved into the Effect layer (eliminates plain async startAuthImpl/authenticateImpl/finishAuthImpl)
  • Use ChildProcessSpawner for descendants() (replaces Process.lines)
  • Use Effect.callback for browser subprocess detection
  • Use Effect.forEach with {concurrency: "unbounded"} for init, finalizer, and tool listing

Cleanup:

  • Extract closeClient, createAndStore, getMcpConfig, withClient, collectFromConnected, fetchFromClient helpers
  • convertMcpTool was async for no reason — now sync
  • Deduplicate fetchPromptsForClient/fetchResourcesForClient
  • Remove dead code, misleading aliases, redundant state operations

Tests:

  • 25 MCP lifecycle tests (connect/disconnect, add/replace, prompts, resources, disabled servers, error paths, tool name sanitization, transient failure recovery, cancelPending fix verification)

Test plan

  • All 25 MCP tests pass (25 pass, 0 fail)
  • Zero typecheck errors
  • CI fully green (all 8 checks)
  • Verify MCP servers connect and provide tools in a live session
  • Verify OAuth flow still works for remote MCP servers

McpOAuthCallback.cancelPending(mcpName) was a no-op because
pendingAuths is keyed by oauthState (a hex string), not mcpName.
Add a reverse index so removeAuth() can actually cancel in-flight
OAuth flows.

Add comprehensive MCP lifecycle tests covering connect/disconnect,
add/replace, prompts, resources, disabled servers, error paths, and
tool name sanitization. Two tests for tools() transient-failure
recovery are marked .todo — the current behavior permanently deletes
clients on listTools() failure with no reconnect path; to be fixed
during Effect migration.
Migrate MCP from Instance.state() to Effect Service/Layer/InstanceState
pattern with makeRunPromise and async facades.

- InstanceState for per-directory lifecycle with ScopedCache
- Effect.fn tracing on all service methods
- Extract closeClient helper (was duplicated 3x)
- Extract collectFromConnected helper (prompts/resources were identical)
- Fix readResource log messages (said "prompt" instead of "resource")
- Fix finishAuthImpl not updating state after OAuth reconnect
- OAuth impl stays as plain async (inherently imperative)
- Replace descendants() with ChildProcessSpawner + Effect.scoped
- Move descendants inside layer to close over spawner
- Replace Promise.all with Effect.forEach({concurrency: "unbounded"})
  in state init, finalizer, and tools()
- Use Effect.tryPromise for closeClient and tools listTools
- Use Effect.try for process.kill in finalizer
- Remove Process import (replaced by ChildProcessSpawner)
- collectFromConnected now returns Effect instead of Promise
- convertMcpTool is sync — drop async, use plain for loop
- Extract withClient helper for getPrompt/readResource (tryPromise)
- Extract createAndStore for add/connect/finishAuth shared pattern
- Break removeAuth into individual Effect steps
- tools() inner loop uses plain for (convertMcpTool is sync now)
Replace Filesystem.readJson/writeJson with AppFileSystem Effect service.
All auth persistence is now proper Effect with tracing. Async facades
preserved for McpOAuthProvider and CLI backward compat.
When tools() encounters a listTools() failure, fork a background
reconnection fiber via createAndStore() instead of permanently
removing the server. The current turn gets no tools from that server,
but the background fiber re-establishes the connection so tools are
available on the next turn.

Previously, a single transient error (network blip, server restart)
would permanently delete the MCP server from the session with no
recovery path.
@kitlangton kitlangton marked this pull request as ready for review March 25, 2026 13:01
Replace startAuthImpl/authenticateImpl/finishAuthImpl plain async
functions with Effect.fn methods inside the layer. OAuth state
management now uses yield* auth.* directly instead of async McpAuth.*
facades. Eliminates ~8 Effect.promise wrappers.

Extract getMcpConfig helper for shared config lookup pattern.
finishAuth now calls createAndStore directly (no intermediate status).
- Push Effect.orDie into McpAuth.Service interface (no error channel)
  eliminates 11 .pipe(Effect.orDie) at call sites
- Return oauthState from startAuth, eliminate redundant read-back
- Use Option.isNone() instead of ._tag === "None"
- Reuse getMcpConfig in connect
- Unify fetchPromptsForClient/fetchResourcesForClient into fetchFromClient
- Use Effect.callback for browser subprocess detection (was Promise
  wrapping event listeners inside Effect.tryPromise)
- Simplify closeClient to Effect.promise with .catch
- Use plain try/catch for process.kill (was Effect.try with ignore)
- Remove redundant auth.clearOAuthState after auth.remove in removeAuth
- Remove dead status assignment in create() (assigned then immediately
  returned a new identical object)
- Fix mcpNameToState leak on successful/error OAuth callback completion
- Extract mcpConfig lookup once in tools() loop (was looked up twice)
…ation

tools() no longer calls listTools() on every server each turn.
Instead, tool definitions are fetched once at connect time and
cached in state. The ToolListChangedNotification handler refreshes
the cache when a server reports its tools have changed.

- Add defs to State, populated by create() and createAndStore()
- watch() registers per-client notification handler with staleness guards
- tools() reads from s.defs (zero network calls on hot path)
- closeClient() clears defs to keep cache and client lifecycle in sync
- Close old client on ALL failure paths, not just disabled
- Tests verify caching (listToolsCalls stays at 1) and notification refresh
opencode-agent bot added a commit that referenced this pull request Mar 25, 2026
Deduplicate the repetitive get→mutate→set pattern used by
updateTokens, updateClientInfo, updateCodeVerifier, updateOAuthState,
clearCodeVerifier, and clearOAuthState into two generic helpers.
opencode-agent bot added a commit that referenced this pull request Mar 25, 2026
opencode-agent bot added a commit that referenced this pull request Mar 25, 2026
opencode-agent bot added a commit that referenced this pull request Mar 25, 2026
@kitlangton kitlangton enabled auto-merge (squash) March 25, 2026 18:18
@kitlangton kitlangton disabled auto-merge March 25, 2026 18:22
opencode-agent bot added a commit that referenced this pull request Mar 25, 2026
opencode-agent bot added a commit that referenced this pull request Mar 25, 2026
opencode-agent bot added a commit that referenced this pull request Mar 25, 2026
opencode-agent bot added a commit that referenced this pull request Mar 25, 2026
opencode-agent bot added a commit that referenced this pull request Mar 25, 2026
@kitlangton kitlangton merged commit b90de75 into dev Mar 26, 2026
8 checks passed
@kitlangton kitlangton deleted the kit/mcp-tests-and-fixes branch March 26, 2026 00:15
cainiao1992 pushed a commit to cainiao1992/opencode that referenced this pull request Mar 26, 2026
Kizunad pushed a commit to Kizunad/opencode that referenced this pull request Mar 26, 2026
Andres77872 pushed a commit to Andres77872/opencode that referenced this pull request Mar 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant