Skip to content

Commit bdff5bd

Browse files
ericdalloeca
andcommitted
Await pending MCP tool list refresh before reading tools
After a tool execution triggers a tools/list_changed notification (e.g. load-module), the async refresh could still be in-flight when we read all-tools for the next LLM turn. This caused dynamically added tools to be missing. Add a promise-based coordination mechanism: the list_changed handler stores a promise in a per-server atom, and await-pending-tools-refresh blocks (with a shared deadline) until all pending refreshes complete before reading tools. 🤖 Generated with [eca](https://eca.dev) Co-Authored-By: eca <git@eca.dev>
1 parent 3521b5a commit bdff5bd

File tree

3 files changed

+45
-8
lines changed

3 files changed

+45
-8
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- Wait for pending MCP tool list refresh before reading tools after tool execution, fixing race where dynamically loaded tools were not immediately available.
6+
57
## 0.123.1
68

79
- Fix OAuth HTTPS server crash in native image by building SSLContext in-memory instead of relying on ring-jetty's keystore path reflection.

src/eca/features/chat/tool_calls.clj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
[eca.features.chat.lifecycle :as lifecycle]
55
[eca.features.hooks :as f.hooks]
66
[eca.features.tools :as f.tools]
7+
[eca.features.tools.mcp :as f.tools.mcp]
78
[eca.llm-util :as llm-util]
89
[eca.logger :as logger]
910
[eca.shared :refer [assoc-some]]))
@@ -798,6 +799,7 @@
798799
:ex-data (ex-data t)
799800
:message (.getMessage ^Throwable t)
800801
:cause (.getCause ^Throwable t)})))))))
802+
(f.tools.mcp/await-pending-tools-refresh @db* 5000)
801803
(let [all-tools (f.tools/all-tools chat-id agent @db* config)]
802804
(if-let [rejection-info @rejected-tool-call-info*]
803805
(let [reason-code

src/eca/features/tools/mcp.clj

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@
129129
(future (f jsonrpc-notification)))))
130130

131131
(defn ^:private ->client [name transport init-timeout workspaces
132-
{:keys [on-tools-change]}]
132+
{:keys [on-tools-change pending-tools-refresh*]}]
133133
(let [tools-consumer (fn [tools]
134134
(logger/info logger-tag
135135
(format "[%s] Tools list changed, received %d tools"
@@ -142,11 +142,26 @@
142142
{:name (:name %)})
143143
workspaces)}
144144
:notification-handlers
145-
{psd/method-notifications-tools-list_changed
146-
(non-blocking-handler
147-
(fn [notification]
148-
(pcs/fetch-tools notification
149-
{:on-tools tools-consumer})))
145+
{;; Uses custom wrapping instead of non-blocking-handler to coordinate
146+
;; a promise that await-pending-tools-refresh can block on.
147+
psd/method-notifications-tools-list_changed
148+
(pcs/wrap-initialized-check
149+
(fn [jsonrpc-notification]
150+
(let [p (promise)]
151+
(when pending-tools-refresh*
152+
(reset! pending-tools-refresh* p))
153+
(future
154+
(try
155+
(pcs/fetch-tools jsonrpc-notification
156+
{:on-tools tools-consumer})
157+
(catch Exception e
158+
(logger/error logger-tag
159+
(format "[%s] Failed to refresh tools after list_changed: %s"
160+
name (.getMessage ^Throwable e))))
161+
(finally
162+
(when pending-tools-refresh*
163+
(compare-and-set! pending-tools-refresh* p nil))
164+
(deliver p true)))))))
150165

151166
psd/method-notifications-resources-list_changed
152167
(non-blocking-handler
@@ -353,6 +368,7 @@
353368
server-config
354369
{:on-server-updated on-server-updated})
355370
(let [init-timeout (:mcpTimeoutSeconds config)
371+
pending-tools-refresh* (atom nil)
356372
on-tools-change (fn [tools]
357373
(let [tools (mapv tool->internal tools)]
358374
(swap! db* assoc-in [:mcp-clients name :tools] tools)
@@ -361,12 +377,14 @@
361377
(let [{:keys [transport http-client needs-reinit?*]} (->transport name server-config workspaces db*)
362378
result (try
363379
(let [client (->client name transport init-timeout workspaces
364-
{:on-tools-change on-tools-change})
380+
{:on-tools-change on-tools-change
381+
:pending-tools-refresh* pending-tools-refresh*})
365382
init-result (pmc/get-initialize-result client)
366383
version (get-in init-result [:serverInfo :version])]
367384
(swap! db* assoc-in [:mcp-clients name] (cond-> {:client client
368385
:status :starting
369-
:needs-reinit?* needs-reinit?*}
386+
:needs-reinit?* needs-reinit?*
387+
:pending-tools-refresh* pending-tools-refresh*}
370388
http-client (assoc :http-client http-client)))
371389
(swap! db* assoc-in [:mcp-clients name :version] version)
372390
(swap! db* assoc-in [:mcp-clients name :instructions] (:instructions init-result))
@@ -628,6 +646,21 @@
628646
:version version}) tools)))
629647
(:mcp-clients db)))
630648

649+
(defn await-pending-tools-refresh
650+
"Waits for any pending tool list refreshes to complete, with a timeout.
651+
Call before reading tools from db* to ensure dynamically loaded tools
652+
are available after a tools/list_changed notification.
653+
The pending-tools-refresh* atoms are set by the tools/list_changed
654+
notification handler and cleared when the refresh completes.
655+
Uses a shared deadline so multiple pending servers don't multiply the wait."
656+
[db timeout-ms]
657+
(let [deadline (+ (System/currentTimeMillis) timeout-ms)]
658+
(doseq [[_ {:keys [pending-tools-refresh*]}] (:mcp-clients db)]
659+
(when-let [p (and pending-tools-refresh* @pending-tools-refresh*)]
660+
(let [remaining (- deadline (System/currentTimeMillis))]
661+
(when (pos? remaining)
662+
(deref p remaining nil)))))))
663+
631664
(defn ^:private reinitialize-server!
632665
"Re-initialize an MCP server after a transport error (HTTP 401/403/5xx).
633666
Stops the old transport without attempting disconnect (the session is already

0 commit comments

Comments
 (0)