Skip to content

fix(mcp): keep loaded tools when their server goes away, and never stop silently - #60

Merged
IBJunior merged 1 commit into
mainfrom
fix/disclosure-keep-loaded-tools
Nov 9, 2026
Merged

IBJunior merged 1 commit into
mainfrom
fix/disclosure-keep-loaded-tools

Conversation

@IBJunior

@IBJunior IBJunior commented Nov 9, 2026 •

Copy link
Copy Markdown
Member

Summary

Deleting an MCP server mid-thread made the agent stop with no error. The cause was a chain of three gaps, and this fixes each one.

  1. Loaded tools disappeared with their server. The disclosure middleware kept only tool names in the thread (activeTools) and rebuilt each definition from the server's catalog row on every turn. Deleting a server cascade-deletes that row, so its loaded tools silently left the model's tool list. That also contradicted the package's own rule that loaded tools are never removed, which exists to protect the prompt cache.
  2. The model then called a tool from memory with no schema. Its arguments didn't parse, so LangChain recorded an invalid_tool_call, which the agent never runs. With no valid calls, the turn just ended, and the UI showed nothing.
  3. Results the middleware returns itself weren't streamed. messageStream.ts only saw results from the tool-execution stream. A result the middleware answered directly showed up only after a reload, and the call was reported as pendingToolCallIds, which is the approval signal.

Changes

Disclosure: loaded tools outlive their server (packages/mcp-disclosure)

  • When a tool is loaded, its definition is saved in a new thread-state field, toolDefinitions. The first definition saved for a name is kept, so what the model is shown never changes.
  • wrapModelCall builds loaded tools from those saved definitions. Threads saved before this change hold names only, so they fall back to the catalog, and load_tools back-fills their definitions.
  • A call to a loaded tool whose server was removed or turned off returns server_unavailable, naming the server and pointing to the Connections page. Before, it fell through to the agent's tool node, which has no MCP tools.
  • Loaded tools stay offered even when no MCP server is left at all. The old !catalogBlock early return dropped them.
  • When a server's catalog is unavailable for a request, the live server now decides whether a tool exists, instead of the call being refused as unknown_tool.

Stream: results the middleware returned itself (src/services/messageStream.ts)

  • Tool results are also read from run.values snapshots and from the final state, limited to calls made in this run.
  • Each result is sent once, whichever source delivers it first, and a call with a result is never reported as pending. Calls the approval gate really paused are still reported as pending.

Malformed tool calls are retried (src/lib/agent/invalidToolCalls.ts)

  • invalidToolCallMiddleware sits just inside disclosure, so it sees the full tool list. When a response contains a call whose arguments don't parse, it re-asks the model, up to 2 times, with a correction that is never saved to the thread:
    • invalid_arguments for a real tool
    • unknown_tool when the model calls a tool it isn't offered
    • valid sibling calls are answered "not run", because the whole step is retried
  • If the response is still malformed after the retries, it ends with a visible notice. The stream also shows that notice live for each malformed attempt.
  • Invalid calls are read both from invalid_tool_calls and from v1 invalid_tool_call content blocks. The live test showed that the v1 output format leaves the field empty.

Test plan

  • tsc --noEmit, pnpm typecheck:eval, pnpm test (488 passing, 16 new), and format check
  • Live on the dev stack, with Notion turned off after loading its search tool: the tool stayed offered, the call returned server_unavailable live with no false pending, and the agent told the user to re-enable Notion
  • Live on the original stalled thread (notes server deleted): malformed calls showed a notice instead of a silent stop, and a later run answered that notes isn't available
  • The unknown_tool correction live. The model didn't produce a malformed call on the run where it would have applied, so this path is covered by unit tests only.
  • pnpm build, skipped locally because the dev server was using the same .next folder

Notes

  • During a retry, each attempt's text streams live, so a few short messages and notices can appear before the final reply. After a reload, only the final reply remains.
  • The disclosure test harness gave every scripted model the same message ids, so a second agent on one thread replaced messages instead of appending them. Ids are now unique per model.

🤖 Generated with Claude Code

…op silently

Deleting an MCP server cascade-deleted its catalog row, which is where the
disclosure middleware rebuilt loaded tools from each turn. The tools
vanished from the model's list, the model called one from memory with no
schema, and the unparseable call ended the turn with no error shown.

- Disclosure saves each loaded tool's definition in the thread
  (`toolDefinitions`, first definition wins) and builds the loaded tools
  from it, so the tool list never shrinks. A call to a tool whose server
  was removed or turned off returns `server_unavailable` instead of
  falling through to the tool node. Threads from before keep the catalog
  fallback.
- The stream also takes tool results from the run's state, so a result a
  middleware returned itself (server_unavailable, auth_required,
  unknown_tool) arrives live and is no longer reported as pending
  approval.
- invalidToolCallMiddleware re-asks the model when a tool call's
  arguments don't parse, with a correction never saved to the thread
  (invalid_arguments, or unknown_tool for a tool not offered). After two
  retries the reply ends with a visible notice, and the stream shows the
  notice live. Invalid calls are read from both `invalid_tool_calls` and
  v1 `invalid_tool_call` content blocks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IBJunior
IBJunior merged commit 3e883ba into main Nov 9, 2026
1 check passed
@IBJunior
IBJunior deleted the fix/disclosure-keep-loaded-tools branch November 9, 2026 11:14
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.

1 participant