feat(mcp): dynamic tool discovery on tools/list_changed notifications#1153
Merged
Conversation
Servers with dynamic tool lists start with a minimal catalog and emit the list_changed notification when it grows — HTTP Toolkit exposes only its start tool and registers the real ones after it runs. The notification landed in the channel inbox but the registry never refreshed, so the new tools stayed invisible to the agent. The channel manager now fires a hook on that notification while still recording it in the inbox for auditability. The MCP manager reacts with an async, per-server debounced re-run of tools list that reconciles the registry: new tools added, changed schemas replaced, vanished tools dropped without touching same-named tools owned by other servers. The refresh is asynchronous by contract — the hook runs on the transport reader goroutine, where a synchronous round-trip would deadlock the response pump. Reconciled deltas queue for the agent loop, which drains them at the same safe turn boundary used by mid-loop skill injection and tells the model which tools appeared or vanished, how to invoke them and to fetch schemas via the tools catalog before first use. Tool dispatch, the tools catalog and the completer already read the live registry, so they pick the changes up immediately. Gated by CHATCLI_MCP_DYNAMIC_TOOLS (default on), surfaced in /config integrations.
Contributor
Quality GateResult: ✅ all floors passed
Config: .github/quality-gate.yml. Workflow: |
added 5 commits
July 9, 2026 21:12
The channel inbox accumulated without a real drain path: protocol events the client now handles itself still bumped the unread counter, the ring had no purge command, and the model had no way to read or acknowledge pushed events at all. The new channels builtin gives the agent a read-mostly window into the inbox: list recent messages with optional channel filter, read what arrived since the last acknowledgment, and ack once processed — via the same path as the user command so both surfaces stay consistent. Pending confirm actions are deliberately never exposed to the model; they gate real side effects and remain a human decision. Draining fixes: protocol notifications consumed by the dynamic tool refresh are recorded as handled audit entries that no longer inflate unread (when the refresh is disabled nothing handled them, so they count as before); the new clear subcommand empties the live ring and acknowledges pending state while preserving the on-disk audit trail; and the tools-changed hook now reports ownership so only genuinely consumed events go quiet. The refresh goroutine captures its debounce window at schedule time, fixing a read that raced with concurrent mutation.
Plain clear drains only the live ring — the JSONL audit file replays its tail on the next start, which surprises anyone expecting the purge to stick. The --all variant truncates the active audit file through the open append handle and removes the rotated backup, so nothing returns after a restart. Disk cleanup errors are surfaced as a partial warning while the in-memory drain always succeeds, and the audit trail keeps receiving writes afterwards.
…subcommands Completion and the palette (which shares the completer as its single source) only offered the flat /channel subcommand list — the deeper slots were dead ends. Now /channel completes every level: clear --all, rules reload, confirm <id> from the live pending set plus the yes/no decision, and run <seq> from the recent ring with a per-message description. @-tool arguments gained subcommand completion too: @channels list/unread/ack and @tools list/describe, alongside the existing @file/@command paths. All labels are i18n ×3; the palette inherits everything automatically since it runs the same completer.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Two related gaps around MCP channels and dynamic servers:
start) and emitnotifications/tools/list_changedafter it runs, expecting the client to re-runtools/list. ChatCLI routed the notification into the channel inbox but never refreshed the registry — the real tools stayed hidden for the whole session.Dynamic tool discovery
ProcessSSENotification, shared by stdio/HTTP/SSE):tools/list_changedfires the refresh hook and stays in the ring as an audit entry.tools/listcall (tested).RefreshServerTools): adds/updates/removes only entries owned by that server;ToolCountupdated for/mcp status.mcp_<tool>form,@tools describeguidance) and a 🔌 user summary line. Dispatch,@toolsand completer read the live registry.CHATCLI_MCP_DYNAMIC_TOOLS(default on) in/configintegrations.Agent-facing channels + drainable inbox
@channelsbuiltin:list(optional channel filter, capped output),unread(since last ack),ack— same path as/channel ackso both surfaces stay consistent. Read-only caps for list/unread; pending confirm actions are never exposed to the model (they gate real side effects — human-only via/channel confirm)./channel clear: empties the live ring + acks pending state, preserving the on-disk audit trail. In the completer with i18n ×3.Validation
Clearsemantics;@channelsenvelope/flattened/bare-word parsing, aliases, caps and adapter forwarding.go test -race -count=3 ./cli/mcp/clean (including a fixed debounce-capture race), fullcli/plugins/i18nsuites green,golangci-lintzero.