Skip to content

feat(mcp): dynamic tool discovery on tools/list_changed notifications#1153

Merged
diillson merged 6 commits into
mainfrom
feat/mcp-dynamic-tool-discovery
Jul 10, 2026
Merged

feat(mcp): dynamic tool discovery on tools/list_changed notifications#1153
diillson merged 6 commits into
mainfrom
feat/mcp-dynamic-tool-discovery

Conversation

@diillson

@diillson diillson commented Jul 10, 2026

Copy link
Copy Markdown
Owner

Problem

Two related gaps around MCP channels and dynamic servers:

  1. Dynamic tool lists were invisible. Servers like HTTP Toolkit connect exposing only a bootstrap tool (start) and emit notifications/tools/list_changed after it runs, expecting the client to re-run tools/list. ChatCLI routed the notification into the channel inbox but never refreshed the registry — the real tools stayed hidden for the whole session.
  2. The inbox couldn't be drained. Every push bumped unread (including protocol chatter the client should handle itself), the ring had no purge command, and the model had no way to read or acknowledge pushed events.

Dynamic tool discovery

  • Hook at the single notification funnel (ProcessSSENotification, shared by stdio/HTTP/SSE): tools/list_changed fires the refresh hook and stays in the ring as an audit entry.
  • Async, per-server debounced refresh (300 ms) — the hook runs on the transport reader goroutine, where a synchronous round-trip would deadlock the response pump. Burst of 5 notifications → exactly 1 tools/list call (tested).
  • Registry reconciliation (RefreshServerTools): adds/updates/removes only entries owned by that server; ToolCount updated for /mcp status.
  • Mid-task model awareness: deltas drain at the same safe turn boundary as mid-loop skill injection, with an injected notice (names capped at 20, mcp_<tool> form, @tools describe guidance) and a 🔌 user summary line. Dispatch, @tools and completer read the live registry.
  • Kill switch CHATCLI_MCP_DYNAMIC_TOOLS (default on) in /config integrations.

Agent-facing channels + drainable inbox

  • New @channels builtin: list (optional channel filter, capped output), unread (since last ack), ack — same path as /channel ack so 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).
  • Smart unread: protocol events consumed by the dynamic refresh are recorded as handled audit entries that don't inflate unread; with the refresh disabled they count as before (the hook reports ownership).
  • /channel clear: empties the live ring + acks pending state, preserving the on-disk audit trail. In the completer with i18n ×3.

Validation

  • Reconciliation add/remove/cross-server isolation; notification→refresh E2E with coalescing; kill-switch; drain-once; handled-vs-unhandled unread accounting; Clear semantics; @channels envelope/flattened/bare-word parsing, aliases, caps and adapter forwarding.
  • go test -race -count=3 ./cli/mcp/ clean (including a fixed debounce-capture race), full cli/plugins/i18n suites green, golangci-lint zero.

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.
@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Quality Gate

Result: ✅ all floors passed

Floor Status Result Δ vs main Budget
1 · Build & Static go build / vet / fmt / lint
2 · Coverage 49.5% (bootstrap) 0 ≥ baseline
3 · Patch coverage ⚠️ 67.1% (req ≥ 60%) ≥ 60%
4 · AI smells diff scanned
5 · Scope budget ⚠️ 21 files / 1554 LOC (code 1554 + tooling 0) warn 800·25
6 · E2E go test -race ./e2e/... ≤ 15min
7 · Commit lint conventional commits
8 · Cyclo (new code) 12 file(s) under threshold ≤ 30
9 · Secrets scan gitleaks
10 · i18n parity missing 0, unknown 0
11 · CRD drift drifted: 0
12 · License headers 0 missing
13 · API breaking 0 incompatible
14 · Binary size chatcli 90.7MB · operator 52.3MB 100MB each
15 · Provider parity 14 providers · 0 violations

Config: .github/quality-gate.yml. Workflow: .github/workflows/quality-gate.yml.

diillson 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.
@diillson diillson merged commit dade143 into main Jul 10, 2026
25 checks passed
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