@@ -17,6 +17,46 @@ import (
1717 "github.com/docker/docker-agent/pkg/tools"
1818)
1919
20+ // startMCPServer creates a minimal MCP server on addr with the given tools
21+ // and returns a function to shut it down.
22+ func startMCPServer (t * testing.T , addr string , mcpTools ... * gomcp.Tool ) (shutdown func ()) {
23+ t .Helper ()
24+
25+ s := gomcp .NewServer (& gomcp.Implementation {Name : "test-server" , Version : "1.0.0" }, nil )
26+ for _ , tool := range mcpTools {
27+ s .AddTool (tool , func (_ context.Context , _ * gomcp.CallToolRequest ) (* gomcp.CallToolResult , error ) {
28+ return & gomcp.CallToolResult {
29+ Content : []gomcp.Content {& gomcp.TextContent {Text : "ok-" + tool .Name }},
30+ }, nil
31+ })
32+ }
33+
34+ // Retry Listen until the port is available (e.g. after a server shutdown).
35+ var srvLn net.Listener
36+ require .Eventually (t , func () bool {
37+ var listenErr error
38+ srvLn , listenErr = net .Listen ("tcp" , addr )
39+ return listenErr == nil
40+ }, 2 * time .Second , 50 * time .Millisecond , "port %s not available in time" , addr )
41+
42+ srv := & http.Server {
43+ Handler : gomcp .NewStreamableHTTPHandler (func (* http.Request ) * gomcp.Server { return s }, nil ),
44+ }
45+ go func () { _ = srv .Serve (srvLn ) }()
46+
47+ return func () { _ = srv .Close () }
48+ }
49+
50+ // allocateAddr returns a free TCP address on localhost.
51+ func allocateAddr (t * testing.T ) string {
52+ t .Helper ()
53+ ln , err := net .Listen ("tcp" , "127.0.0.1:0" )
54+ require .NoError (t , err )
55+ addr := ln .Addr ().String ()
56+ ln .Close ()
57+ return addr
58+ }
59+
2060// TestRemoteReconnectAfterServerRestart verifies that a Toolset backed by a
2161// real remote (streamable-HTTP) MCP server transparently recovers when the
2262// server is restarted.
@@ -33,11 +73,7 @@ import (
3373func TestRemoteReconnectAfterServerRestart (t * testing.T ) {
3474 t .Parallel ()
3575
36- // Use a fixed listener address so we can restart on the same port.
37- ln , err := net .Listen ("tcp" , "127.0.0.1:0" )
38- require .NoError (t , err )
39- addr := ln .Addr ().String ()
40- ln .Close () // We only needed the address; close so startServer can bind it.
76+ addr := allocateAddr (t )
4177
4278 var callCount atomic.Int32
4379
@@ -123,3 +159,82 @@ func TestRemoteReconnectAfterServerRestart(t *testing.T) {
123159 t .Fatal ("reconnect did not complete: restarted channel was not closed" )
124160 }
125161}
162+
163+ // TestRemoteReconnectRefreshesTools verifies that after a remote MCP server
164+ // restarts with a different set of tools, the Toolset picks up the new tools
165+ // and notifies the runtime via the toolsChangedHandler.
166+ //
167+ // This is the scenario from https://github.com/docker/docker-agent/issues/2244:
168+ // - Server v1 exposes tools [alpha, shared].
169+ // - Client connects and caches [alpha, shared].
170+ // - Server v1 shuts down; server v2 starts with tools [beta, shared].
171+ // - A tool call to "shared" triggers reconnection.
172+ // - After reconnection, Tools() must return [beta, shared], not the stale [alpha, shared].
173+ // - The toolsChangedHandler must be called so the runtime refreshes its own state.
174+ func TestRemoteReconnectRefreshesTools (t * testing.T ) {
175+ t .Parallel ()
176+
177+ addr := allocateAddr (t )
178+
179+ // "shared" exists on both servers so we can call it to trigger reconnect.
180+ sharedTool := & gomcp.Tool {Name : "shared" , InputSchema : & jsonschema.Schema {Type : "object" }}
181+ alphaTool := & gomcp.Tool {Name : "alpha" , InputSchema : & jsonschema.Schema {Type : "object" }}
182+ betaTool := & gomcp.Tool {Name : "beta" , InputSchema : & jsonschema.Schema {Type : "object" }}
183+
184+ // --- Start server v1 with tools "alpha" + "shared" ---
185+ shutdown1 := startMCPServer (t , addr , alphaTool , sharedTool )
186+
187+ ts := NewRemoteToolset ("ns" , fmt .Sprintf ("http://%s/mcp" , addr ), "streamable-http" , nil )
188+
189+ // Track toolsChangedHandler invocations.
190+ toolsChangedCh := make (chan struct {}, 1 )
191+ ts .SetToolsChangedHandler (func () {
192+ select {
193+ case toolsChangedCh <- struct {}{}:
194+ default :
195+ }
196+ })
197+
198+ require .NoError (t , ts .Start (t .Context ()))
199+
200+ // Verify initial tools.
201+ toolList , err := ts .Tools (t .Context ())
202+ require .NoError (t , err )
203+ require .Len (t , toolList , 2 )
204+ toolNames := []string {toolList [0 ].Name , toolList [1 ].Name }
205+ assert .Contains (t , toolNames , "ns_alpha" )
206+ assert .Contains (t , toolNames , "ns_shared" )
207+
208+ // --- Shut down server v1, start server v2 with tools "beta" + "shared" ---
209+ shutdown1 ()
210+
211+ shutdown2 := startMCPServer (t , addr , betaTool , sharedTool )
212+ t .Cleanup (func () {
213+ _ = ts .Stop (t .Context ())
214+ shutdown2 ()
215+ })
216+
217+ // Call "shared" to trigger ErrSessionMissing → reconnect.
218+ result , callErr := ts .callTool (t .Context (), tools.ToolCall {
219+ Function : tools.FunctionCall {Name : "shared" , Arguments : "{}" },
220+ })
221+ require .NoError (t , callErr )
222+ assert .Equal (t , "ok-shared" , result .Output )
223+
224+ // Wait for the toolsChangedHandler to be called (signals reconnect + refresh).
225+ select {
226+ case <- toolsChangedCh :
227+ // Good — the handler was called.
228+ case <- time .After (30 * time .Second ):
229+ t .Fatal ("timed out waiting for toolsChangedHandler after reconnect" )
230+ }
231+
232+ // Verify the toolset now reports the new server's tools.
233+ toolList , err = ts .Tools (t .Context ())
234+ require .NoError (t , err )
235+ require .Len (t , toolList , 2 , "expected exactly two tools from the new server" )
236+ toolNames = []string {toolList [0 ].Name , toolList [1 ].Name }
237+ assert .Contains (t , toolNames , "ns_beta" , "expected the new server's tool, got stale tool" )
238+ assert .Contains (t , toolNames , "ns_shared" )
239+ assert .NotContains (t , toolNames , "ns_alpha" , "stale tool from old server should not be present" )
240+ }
0 commit comments