@@ -4,159 +4,180 @@ use std::sync::Arc;
44
55use rmcp:: service:: { Peer , RoleClient , RunningService , serve_client} ;
66use tokio:: process:: { ChildStderr , Command } ;
7- use tokio:: sync:: RwLock ;
7+ use tokio:: sync:: { Mutex , RwLock } ;
88
99use super :: config:: McpServerConfig ;
1010
11- /// Shared, swappable peer reference for an MCP server. Cloned by
12- /// every `McpTool` from the same server so that an auto-reconnect
13- /// triggered by ANY tool call updates the peer for ALL tools.
14- /// Previously each `McpTool` stored a direct `Peer<RoleClient>`
15- /// clone, leaving them orphaned the moment the underlying transport
16- /// died (audit dirge-dvi).
17- pub type SharedPeer = Arc < RwLock < Peer < RoleClient > > > ;
18-
19- pub struct McpClientHandle {
11+ /// Co-owned (peer, running_service) pair for one MCP server.
12+ ///
13+ /// Every `McpTool` from the same server holds the same
14+ /// `Arc<SharedConnection>`. On reconnect — manager-side OR
15+ /// tool-side — `replace` atomically swaps in a fresh peer +
16+ /// running_service. The OLD `RunningService` drops at the end of
17+ /// the swap, which cancels its cancellation_token, closes the
18+ /// transport, and (for child-process transports) kills the dead
19+ /// child. This was the M-R1 review finding: the prior code did
20+ /// `mem::forget(RunningService)` which leaked the spawned process.
21+ ///
22+ /// Lock order to avoid deadlock: always take `running_service`
23+ /// before `peer`. Readers take a single lock at a time.
24+ pub struct SharedConnection {
25+ /// Kept for debugging / tracing — every error path logs the
26+ /// server name, so the structured field stays for log
27+ /// correlation even when no code reads it directly.
28+ #[ allow( dead_code) ]
2029 pub server_name : String ,
21- pub running_service : RunningService < RoleClient , ( ) > ,
22- /// The shared peer ref. Updated in place by `replace_peer `
23- /// when the manager reconnects, so already-handed-out McpTool
24- /// instances pick up the new peer transparently .
25- peer_ref : SharedPeer ,
30+ peer : RwLock < Peer < RoleClient > > ,
31+ /// `Option` so the consuming-`Drop` of `RunningService::cancel `
32+ /// can take it out cleanly during shutdown. `None` after the
33+ /// connection has been explicitly shut down .
34+ running_service : Mutex < Option < RunningService < RoleClient , ( ) > > > ,
2635}
2736
28- /// Upper bound on how long we'll wait for an MCP server to complete
29- /// initialization. Command-based servers that hang on `initialize`
30- /// (e.g. waiting for stdin that never comes) would otherwise pin
31- /// startup indefinitely. 10s is generous for legitimate inits — npm
32- /// install-on-first-run servers take a few seconds; locally-running
33- /// binaries respond in <100ms. Past the cap we abort and log.
34- const MCP_INIT_TIMEOUT : std:: time:: Duration = std:: time:: Duration :: from_secs ( 10 ) ;
35-
36- impl McpClientHandle {
37- pub async fn connect ( server_name : String , config : & McpServerConfig ) -> anyhow:: Result < Self > {
38- // Wrap the entire connect in a timeout so a wedged server
39- // doesn't block startup forever. Returns a clean
40- // "init timeout" error past the cap.
41- let inner = Self :: connect_inner ( server_name. clone ( ) , config) ;
42- match tokio:: time:: timeout ( MCP_INIT_TIMEOUT , inner) . await {
43- Ok ( result) => result,
44- Err ( _) => Err ( anyhow:: anyhow!(
45- "MCP server {server_name:?} did not initialize within {}s — skipping" ,
46- MCP_INIT_TIMEOUT . as_secs( ) ,
47- ) ) ,
37+ impl SharedConnection {
38+ /// Wrap a freshly-built (peer, running_service) pair. Pub-crate so
39+ /// the manager's reconnect path can create a SharedConnection for
40+ /// servers that failed initial connect but succeed later.
41+ pub ( crate ) fn new (
42+ server_name : String ,
43+ peer : Peer < RoleClient > ,
44+ rs : RunningService < RoleClient , ( ) > ,
45+ ) -> Self {
46+ Self {
47+ server_name,
48+ peer : RwLock :: new ( peer) ,
49+ running_service : Mutex :: new ( Some ( rs) ) ,
4850 }
4951 }
5052
51- async fn connect_inner ( server_name : String , config : & McpServerConfig ) -> anyhow:: Result < Self > {
52- match config {
53- McpServerConfig :: Command { command, args, env } => {
54- let mut cmd = Command :: new ( command) ;
55- cmd. args ( args) ;
56- for ( k, v) in env {
57- cmd. env ( k, v) ;
58- }
59- // CRITICAL: capture stderr instead of inheriting it.
60- // rmcp's default `TokioChildProcess::new` uses
61- // `Stdio::inherit()` for stderr, which gives the MCP
62- // server (and its descendants) direct access to
63- // dirge's controlling terminal. If the server (or
64- // any library it uses) emits terminal queries — OSC
65- // 11 for bg-color detection, `\x1b[c` for DA1,
66- // `\x1b[6n` for CPR — those queries reach the
67- // terminal, which replies via the TTY's INPUT side
68- // (dirge's stdin). Crossterm's event parser doesn't
69- // recognize those reply shapes, so the bytes sit in
70- // the OS stdin buffer until exit, when the shell
71- // inherits them and renders the literal escape
72- // payload as visible garbage at the prompt.
73- //
74- // Pipe stderr instead. The child's logs are still
75- // surfaced — we line-read them and forward to
76- // dirge's stderr via tracing — but the child no
77- // longer has a route to send escape queries that
78- // can elicit a reply on dirge's stdin.
79- let ( transport, stderr) =
80- rmcp:: transport:: child_process:: TokioChildProcess :: builder ( cmd)
81- . stderr ( Stdio :: piped ( ) )
82- . spawn ( ) ?;
83- if let Some ( child_stderr) = stderr {
84- spawn_stderr_forwarder ( server_name. clone ( ) , child_stderr) ;
85- }
86- let running_service = serve_client ( ( ) , transport) . await . map_err ( |e| {
87- anyhow:: anyhow!( "MCP connection failed for '{server_name}': {e}" )
88- } ) ?;
89- let peer = running_service. peer ( ) . clone ( ) ;
90- Ok ( Self {
91- server_name,
92- running_service,
93- peer_ref : Arc :: new ( RwLock :: new ( peer) ) ,
94- } )
95- }
96- McpServerConfig :: Url { url, headers } => {
97- let custom_headers = parse_headers ( headers) ?;
98- let cfg = rmcp:: transport:: streamable_http_client:: StreamableHttpClientTransportConfig :: with_uri ( url. as_str ( ) )
99- . custom_headers ( custom_headers) ;
100- type HttpClient = rmcp:: transport:: StreamableHttpClientTransport < reqwest:: Client > ;
101- let transport = HttpClient :: from_config ( cfg) ;
102- let running_service = serve_client ( ( ) , transport) . await . map_err ( |e| {
103- anyhow:: anyhow!( "MCP HTTP connection failed for '{server_name}': {e}" )
104- } ) ?;
105- let peer = running_service. peer ( ) . clone ( ) ;
106- Ok ( Self {
107- server_name,
108- running_service,
109- peer_ref : Arc :: new ( RwLock :: new ( peer) ) ,
110- } )
111- }
112- }
53+ /// Snapshot the current peer. Cheap — `Peer` is an `mpsc::Sender`
54+ /// wrapper, cloning bumps a refcount.
55+ pub async fn current_peer ( & self ) -> Peer < RoleClient > {
56+ self . peer . read ( ) . await . clone ( )
11357 }
11458
115- /// Direct peer clone (legacy path). Prefer [`shared_peer`] in
116- /// new code so auto-reconnect updates flow through.
117- #[ allow( dead_code) ]
118- pub fn peer ( & self ) -> rmcp:: service:: Peer < RoleClient > {
119- self . running_service . peer ( ) . clone ( )
59+ /// Atomically swap in a fresh (peer, running_service). The OLD
60+ /// `RunningService` drops as `_old` falls out of scope, cancelling
61+ /// its background task + closing its transport.
62+ pub async fn replace (
63+ & self ,
64+ new_peer : Peer < RoleClient > ,
65+ new_rs : RunningService < RoleClient , ( ) > ,
66+ ) {
67+ // Order: take running_service first (Option swap), then peer
68+ // (RwLock write). Both consumed before the OLD running_service
69+ // is dropped so the new one is fully wired before the cleanup
70+ // signal fires on the old transport.
71+ let _old = {
72+ let mut rs_guard = self . running_service . lock ( ) . await ;
73+ std:: mem:: replace ( & mut * rs_guard, Some ( new_rs) )
74+ } ;
75+ * self . peer . write ( ) . await = new_peer;
76+ // `_old` drops here. If it was `Some`, that `RunningService`'s
77+ // `DropGuard` cancels its cancellation_token; the background
78+ // task observes the cancel + closes the transport; the
79+ // TokioChildProcess transport's drop reaps the child.
12080 }
12181
122- /// Shared, swappable peer ref. Cloning this `Arc` is cheap; the
123- /// inner peer is mutated in place when the server is
124- /// reconnected, so every cloned holder sees the fresh peer
125- /// on its next read.
126- pub fn shared_peer ( & self ) -> SharedPeer {
127- Arc :: clone ( & self . peer_ref )
82+ /// Explicit shutdown — drops the running service synchronously
83+ /// (via `Drop`'s async-cancellation guard) and renders the
84+ /// connection dead. Called by `McpClientManager::shutdown`.
85+ pub async fn shutdown ( & self ) {
86+ let mut rs = self . running_service . lock ( ) . await ;
87+ rs . take ( ) ; // dropping the Some(...) here triggers cleanup
12888 }
89+ }
12990
130- /// Replace the inner peer with a fresh one. Called when the
131- /// manager (or a self-reconnecting McpTool) builds a new
132- /// connection — write-locks briefly, swaps, drops the lock.
133- /// Existing McpTool clones holding the same Arc see the new
134- /// peer on their next read.
135- pub async fn replace_peer ( & self , new_peer : Peer < RoleClient > ) {
136- let mut guard = self . peer_ref . write ( ) . await ;
137- * guard = new_peer;
138- }
91+ /// Upper bound on how long we'll wait for an MCP server to complete
92+ /// initialization. Command-based servers that hang on `initialize`
93+ /// (e.g. waiting for stdin that never comes) would otherwise pin
94+ /// startup indefinitely. 10s is generous for legitimate inits — npm
95+ /// install-on-first-run servers take a few seconds; locally-running
96+ /// binaries respond in <100ms. Past the cap we abort and log.
97+ const MCP_INIT_TIMEOUT : std:: time:: Duration = std:: time:: Duration :: from_secs ( 10 ) ;
13998
140- pub async fn list_tools ( & self ) -> Result < Vec < rmcp:: model:: Tool > , rmcp:: ServiceError > {
141- self . running_service . peer ( ) . list_all_tools ( ) . await
99+ /// Connect to one MCP server and wrap the connection in a
100+ /// shared, swappable container. Returns the `Arc<SharedConnection>`
101+ /// the manager + every McpTool clone holds.
102+ pub async fn connect (
103+ server_name : String ,
104+ config : & McpServerConfig ,
105+ ) -> anyhow:: Result < Arc < SharedConnection > > {
106+ let inner = connect_inner ( server_name. clone ( ) , config) ;
107+ match tokio:: time:: timeout ( MCP_INIT_TIMEOUT , inner) . await {
108+ Ok ( result) => result,
109+ Err ( _) => Err ( anyhow:: anyhow!(
110+ "MCP server {server_name:?} did not initialize within {}s — skipping" ,
111+ MCP_INIT_TIMEOUT . as_secs( ) ,
112+ ) ) ,
142113 }
143114}
144115
145- /// Build a fresh `RunningService` for an existing server using its
146- /// config. Used by the McpTool auto-reconnect path on transport
147- /// failure: returns just the new peer; the caller swaps it into a
148- /// `SharedPeer` and drops the old RunningService.
116+ async fn connect_inner (
117+ server_name : String ,
118+ config : & McpServerConfig ,
119+ ) -> anyhow:: Result < Arc < SharedConnection > > {
120+ let ( peer, rs) = raw_connect ( & server_name, config) . await ?;
121+ Ok ( Arc :: new ( SharedConnection :: new ( server_name, peer, rs) ) )
122+ }
123+
124+ /// Build a new `RunningService` + extract its peer, without wrapping
125+ /// in `SharedConnection`. Used by `SharedConnection::replace` callers
126+ /// (manager + tool-side auto-reconnect) which already own the
127+ /// container they want to swap into.
149128///
150- /// This is `pub` so the tool side can call it without owning a full
151- /// `McpClientHandle`. Wraps `connect_inner` directly to skip the
152- /// init-timeout layer (caller already times out the whole reconnect).
153- pub async fn fresh_peer_for (
129+ /// Does NOT wrap in `MCP_INIT_TIMEOUT` — the caller times out the
130+ /// whole reconnect operation.
131+ pub async fn raw_connect (
154132 server_name : & str ,
155133 config : & McpServerConfig ,
156134) -> anyhow:: Result < ( Peer < RoleClient > , RunningService < RoleClient , ( ) > ) > {
157- let handle = McpClientHandle :: connect ( server_name. to_string ( ) , config) . await ?;
158- let peer = handle. running_service . peer ( ) . clone ( ) ;
159- Ok ( ( peer, handle. running_service ) )
135+ match config {
136+ McpServerConfig :: Command { command, args, env } => {
137+ let mut cmd = Command :: new ( command) ;
138+ cmd. args ( args) ;
139+ for ( k, v) in env {
140+ cmd. env ( k, v) ;
141+ }
142+ // CRITICAL: capture stderr instead of inheriting it.
143+ // See lengthy explanation below at the original
144+ // call site — terminal-query bytes from the child must
145+ // not reach dirge's stdin via the controlling TTY.
146+ let ( transport, stderr) =
147+ rmcp:: transport:: child_process:: TokioChildProcess :: builder ( cmd)
148+ . stderr ( Stdio :: piped ( ) )
149+ . spawn ( ) ?;
150+ if let Some ( child_stderr) = stderr {
151+ spawn_stderr_forwarder ( server_name. to_string ( ) , child_stderr) ;
152+ }
153+ let rs = serve_client ( ( ) , transport)
154+ . await
155+ . map_err ( |e| anyhow:: anyhow!( "MCP connection failed for '{server_name}': {e}" ) ) ?;
156+ let peer = rs. peer ( ) . clone ( ) ;
157+ Ok ( ( peer, rs) )
158+ }
159+ McpServerConfig :: Url { url, headers } => {
160+ let custom_headers = parse_headers ( headers) ?;
161+ let cfg = rmcp:: transport:: streamable_http_client:: StreamableHttpClientTransportConfig :: with_uri ( url. as_str ( ) )
162+ . custom_headers ( custom_headers) ;
163+ type HttpClient = rmcp:: transport:: StreamableHttpClientTransport < reqwest:: Client > ;
164+ let transport = HttpClient :: from_config ( cfg) ;
165+ let rs = serve_client ( ( ) , transport) . await . map_err ( |e| {
166+ anyhow:: anyhow!( "MCP HTTP connection failed for '{server_name}': {e}" )
167+ } ) ?;
168+ let peer = rs. peer ( ) . clone ( ) ;
169+ Ok ( ( peer, rs) )
170+ }
171+ }
172+ }
173+
174+ /// List the tools the server advertises. Called once at startup
175+ /// (or after manual reconnect) to build the agent's tool registry.
176+ pub async fn list_tools (
177+ conn : & SharedConnection ,
178+ ) -> Result < Vec < rmcp:: model:: Tool > , rmcp:: ServiceError > {
179+ let peer = conn. current_peer ( ) . await ;
180+ peer. list_all_tools ( ) . await
160181}
161182
162183/// Forward an MCP child's stderr line-by-line to dirge's tracing
0 commit comments