Skip to content

Commit 5445a07

Browse files
committed
feat(sacp)!: use AsyncFnMut for tool closures with macro workaround
Replace FnMut(...) -> impl Future with AsyncFnMut for MCP tool closures. This allows tool closures to properly capture references from their environment without lifetime threading through the entire type system. Renamed tool_fn to tool_fn_mut to document that tool invocations are serialized - only one can run at a time because we hold &mut self across the await. This is fine for typical agent usage (one tool call at a time) but could serialize concurrent invocations. Due to the lack of return-type notation (rust-lang/rust#109417) and a compiler bug (rust-lang/rust#110338), callers must pass a `tool_fn_mut!()` macro as the final argument which boxes the future. Other changes: - Remove unnecessary JrRole bounds from struct definitions - send_request() now takes run_responder fn to avoid 'static requirement - ConductorHandlerState renamed to ConductorResponder, implements JrResponder - sacp-derive updated to edition 2024 BREAKING CHANGE: tool_fn() renamed to tool_fn_mut() and now requires sacp::tool_fn_mut!() as final argument. send_request() signature changed to take a run_responder function.
1 parent 641dbaf commit 5445a07

16 files changed

Lines changed: 168 additions & 109 deletions

File tree

src/sacp-conductor/src/conductor.rs

Lines changed: 68 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ use futures::{
116116
SinkExt, StreamExt,
117117
channel::mpsc::{self},
118118
};
119-
use sacp::role::{ConductorToAgent, ConductorToClient, ConductorToProxy};
119+
use sacp::{ChainResponder, JrResponder, NullResponder, role::{ConductorToAgent, ConductorToClient, ConductorToProxy}};
120120
use sacp::schema::{
121121
McpConnectRequest, McpConnectResponse, McpDisconnectNotification, McpOverAcpMessage,
122122
SuccessorMessage,
@@ -193,11 +193,13 @@ impl Conductor {
193193

194194
pub fn into_connection_builder(
195195
self,
196-
) -> JrConnectionBuilder<ConductorMessageHandler, impl sacp::JrResponder<ConductorToClient>>
196+
) -> JrConnectionBuilder<ConductorMessageHandler, ChainResponder<NullResponder, ConductorResponder>>
197197
{
198-
let (mut conductor_tx, mut conductor_rx) = mpsc::channel(128 /* chosen arbitrarily */);
198+
let (conductor_tx, conductor_rx) = mpsc::channel(128 /* chosen arbitrarily */);
199199

200-
let mut state = ConductorHandlerState {
200+
let responder = ConductorResponder {
201+
conductor_rx,
202+
conductor_tx: conductor_tx.clone(),
201203
component_list: Some(self.component_list),
202204
bridge_listeners: Default::default(),
203205
bridge_connections: Default::default(),
@@ -209,22 +211,10 @@ impl Conductor {
209211
};
210212

211213
JrConnectionBuilder::new_with(ConductorMessageHandler {
212-
conductor_tx: conductor_tx.clone(),
214+
conductor_tx,
213215
})
214216
.name(self.name)
215-
.with_spawned(async move |cx| {
216-
// Components are now spawned lazily in forward_initialize_request
217-
// when the first Initialize request is received.
218-
219-
// This is the "central actor" of the conductor. Most other things forward messages
220-
// via `conductor_tx` into this loop. This lets us serialize the conductor's activity.
221-
while let Some(message) = conductor_rx.next().await {
222-
state
223-
.handle_conductor_message(&cx, message, &mut conductor_tx)
224-
.await?;
225-
}
226-
Ok(())
227-
})
217+
.with_responder(responder)
228218
}
229219

230220
/// Convenience method to run the conductor with a transport.
@@ -254,42 +244,6 @@ pub struct ConductorMessageHandler {
254244
conductor_tx: mpsc::Sender<ConductorMessage>,
255245
}
256246

257-
/// The conductor manages the proxy chain lifecycle and message routing.
258-
///
259-
/// It maintains connections to all components in the chain and routes messages
260-
/// bidirectionally between the editor, components, and agent.
261-
///
262-
struct ConductorHandlerState {
263-
/// Manages the TCP listeners for MCP connections that will be proxied over ACP.
264-
bridge_listeners: McpBridgeListeners,
265-
266-
/// Manages active connections to MCP clients.
267-
bridge_connections: HashMap<String, McpBridgeConnection>,
268-
269-
/// The component list for lazy initialization.
270-
/// Set to None after components are instantiated.
271-
component_list: Option<Box<dyn ComponentList>>,
272-
273-
/// The chain of proxies before the agent (if any).
274-
///
275-
/// Populated lazily when the first Initialize request is received.
276-
proxies: Vec<JrConnectionCx<ConductorToProxy>>,
277-
278-
/// If the conductor is operating in agent mode, this will be the agent.
279-
/// If the conductor is operating in proxy mode, this will be None.
280-
///
281-
/// Populated lazily when the first Initialize request is received.
282-
agent: Option<JrConnectionCx<ConductorToAgent>>,
283-
284-
/// Mode for the MCP bridge (determines how to spawn bridge processes).
285-
mcp_bridge_mode: crate::McpBridgeMode,
286-
287-
/// Optional trace writer for sequence diagram visualization.
288-
trace_writer: Option<crate::trace::TraceWriter>,
289-
290-
/// Tracks pending requests for response tracing: id -> (from, to)
291-
pending_requests: HashMap<String, (String, String)>,
292-
}
293247

294248
impl JrMessageHandler for ConductorMessageHandler {
295249
type Role = ConductorToClient;
@@ -336,7 +290,66 @@ impl JrMessageHandler for ConductorMessageHandler {
336290
}
337291
}
338292

339-
impl ConductorHandlerState {
293+
/// The conductor manages the proxy chain lifecycle and message routing.
294+
///
295+
/// It maintains connections to all components in the chain and routes messages
296+
/// bidirectionally between the editor, components, and agent.
297+
///
298+
pub struct ConductorResponder {
299+
conductor_rx: mpsc::Receiver<ConductorMessage>,
300+
301+
conductor_tx: mpsc::Sender<ConductorMessage>,
302+
303+
/// Manages the TCP listeners for MCP connections that will be proxied over ACP.
304+
bridge_listeners: McpBridgeListeners,
305+
306+
/// Manages active connections to MCP clients.
307+
bridge_connections: HashMap<String, McpBridgeConnection>,
308+
309+
/// The component list for lazy initialization.
310+
/// Set to None after components are instantiated.
311+
component_list: Option<Box<dyn ComponentList>>,
312+
313+
/// The chain of proxies before the agent (if any).
314+
///
315+
/// Populated lazily when the first Initialize request is received.
316+
proxies: Vec<JrConnectionCx<ConductorToProxy>>,
317+
318+
/// If the conductor is operating in agent mode, this will be the agent.
319+
/// If the conductor is operating in proxy mode, this will be None.
320+
///
321+
/// Populated lazily when the first Initialize request is received.
322+
agent: Option<JrConnectionCx<ConductorToAgent>>,
323+
324+
/// Mode for the MCP bridge (determines how to spawn bridge processes).
325+
mcp_bridge_mode: crate::McpBridgeMode,
326+
327+
/// Optional trace writer for sequence diagram visualization.
328+
trace_writer: Option<crate::trace::TraceWriter>,
329+
330+
/// Tracks pending requests for response tracing: id -> (from, to)
331+
pending_requests: HashMap<String, (String, String)>,
332+
}
333+
334+
impl JrResponder<ConductorToClient> for ConductorResponder {
335+
async fn run(mut self, cx: JrConnectionCx<ConductorToClient>) -> Result<(), sacp::Error> {
336+
// Components are now spawned lazily in forward_initialize_request
337+
// when the first Initialize request is received.
338+
339+
let mut conductor_tx = self.conductor_tx.clone();
340+
341+
// This is the "central actor" of the conductor. Most other things forward messages
342+
// via `conductor_tx` into this loop. This lets us serialize the conductor's activity.
343+
while let Some(message) = self.conductor_rx.next().await {
344+
self
345+
.handle_conductor_message(&cx, message, &mut conductor_tx)
346+
.await?;
347+
}
348+
Ok(())
349+
}
350+
}
351+
352+
impl ConductorResponder {
340353
/// Convert a component index to a trace-friendly name.
341354
fn component_name(&self, index: usize) -> String {
342355
if self.is_agent_component(index) {

src/sacp-conductor/tests/mcp_integration/proxy.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,15 @@ impl Component for ProxyComponent {
2626
async fn serve(self, client: impl Component) -> Result<(), sacp::Error> {
2727
let test_server = McpServer::builder("test")
2828
.instructions("A simple test MCP server with an echo tool")
29-
.tool_fn(
29+
.tool_fn_mut(
3030
"echo",
3131
"Echoes back the input message",
3232
async |params: EchoParams, _context| {
3333
Ok(EchoOutput {
3434
result: format!("Echo: {}", params.message),
3535
})
3636
},
37+
sacp::tool_fn_mut!(),
3738
)
3839
.build();
3940

src/sacp-conductor/tests/mcp_server_handler_chain.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,14 +74,15 @@ impl Component for ProxyWithMcpAndHandler {
7474
// Create an MCP server with a simple tool
7575
let mcp_server = McpServer::builder("test-server".to_string())
7676
.instructions("A test MCP server")
77-
.tool_fn(
77+
.tool_fn_mut(
7878
"echo",
7979
"Echoes back the input",
8080
async |params: EchoParams, _cx| {
8181
Ok(EchoOutput {
8282
result: format!("Echo: {}", params.message),
8383
})
8484
},
85+
sacp::tool_fn_mut!(),
8586
)
8687
.build();
8788

src/sacp-conductor/tests/scoped_mcp_server.rs

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
77
use elizacp::ElizaAgent;
88
use sacp::mcp_server::McpServer;
9-
use sacp::{Agent, ClientToAgent, Component, DynComponent, HasEndpoint, JrRole, ProxyToConductor};
9+
use sacp::{
10+
Agent, ClientToAgent, Component, DynComponent, HasEndpoint, JrResponder, JrRole,
11+
ProxyToConductor,
12+
};
1013
use sacp_conductor::{Conductor, McpBridgeMode};
1114
use schemars::JsonSchema;
1215
use serde::{Deserialize, Serialize};
@@ -83,9 +86,9 @@ async fn test_scoped_mcp_server_through_session() -> Result<(), sacp::Error> {
8386

8487
struct ScopedProxy;
8588

86-
fn make_mcp_server<Role: JrRole>(
87-
values: &Mutex<Vec<String>>,
88-
) -> McpServer<Role, impl sacp::JrResponder<Role> + use<'_, Role>>
89+
fn make_mcp_server<'a, Role: JrRole>(
90+
values: &'a Mutex<Vec<String>>,
91+
) -> McpServer<Role, impl JrResponder<Role>>
8992
where
9093
Role: HasEndpoint<Agent>,
9194
{
@@ -96,19 +99,25 @@ where
9699

97100
McpServer::builder("test".to_string())
98101
.instructions("A test MCP server with scoped tool")
99-
.tool_fn(
102+
.tool_fn_mut(
100103
"push",
101104
"Push a value to the collected values",
102105
async |input: PushInput, _cx| {
103106
let mut values = values.lock().expect("not poisoned");
104107
values.extend(input.elements);
105108
Ok(values.len())
106109
},
110+
sacp::tool_fn_mut!(),
111+
)
112+
.tool_fn_mut(
113+
"get",
114+
"Get the collected values",
115+
async |(): (), _cx| {
116+
let values = values.lock().expect("not poisoned");
117+
Ok(values.clone())
118+
},
119+
sacp::tool_fn_mut!(),
107120
)
108-
.tool_fn("get", "Get the collected values", async |(): (), _cx| {
109-
let values = values.lock().expect("not poisoned");
110-
Ok(values.clone())
111-
})
112121
.build()
113122
}
114123

src/sacp-conductor/tests/test_session_id_in_mcp_tools.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,15 @@ fn create_echo_proxy() -> Result<sacp::DynComponent, sacp::Error> {
3232
// Create MCP server with an echo tool that returns the session_id
3333
let mcp_server = McpServer::builder("echo_server".to_string())
3434
.instructions("Test MCP server with session_id echo tool")
35-
.tool_fn(
35+
.tool_fn_mut(
3636
"echo",
3737
"Returns the current session_id",
3838
async |_input: EchoInput, context| {
3939
Ok(EchoOutput {
4040
acp_url: context.acp_url(),
4141
})
4242
},
43+
sacp::tool_fn_mut!(),
4344
)
4445
.build();
4546

@@ -51,7 +52,7 @@ struct ProxyWithEchoServer<R: sacp::JrResponder<ProxyToConductor>> {
5152
mcp_server: McpServer<ProxyToConductor, R>,
5253
}
5354

54-
impl<R: sacp::JrResponder<ProxyToConductor> + 'static> Component for ProxyWithEchoServer<R> {
55+
impl<R: sacp::JrResponder<ProxyToConductor> + 'static + Send> Component for ProxyWithEchoServer<R> {
5556
async fn serve(self, client: impl Component) -> Result<(), sacp::Error> {
5657
ProxyToConductor::builder()
5758
.name("echo-proxy")

src/sacp-derive/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[package]
22
name = "sacp-derive"
33
version = "8.0.0"
4-
edition = "2021"
4+
edition = "2024"
55
description = "Derive macros for SACP JSON-RPC traits"
66
license = "MIT OR Apache-2.0"
77
repository = "https://github.com/anthropics/acp"

src/sacp/src/jsonrpc.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1390,7 +1390,7 @@ impl<T> IntoHandled<T> for Handled<T> {
13901390
/// See the [Event Loop and Concurrency](JrConnection#event-loop-and-concurrency) section
13911391
/// for more details.
13921392
#[derive(Clone, Debug)]
1393-
pub struct JrConnectionCx<Role: JrRole> {
1393+
pub struct JrConnectionCx<Role> {
13941394
#[expect(dead_code)]
13951395
role: Role,
13961396
message_tx: OutgoingMessageTx,

src/sacp/src/jsonrpc/dynamic_handler.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use crate::role::JrRole;
55
use crate::{Handled, JrConnectionCx, MessageCx, jsonrpc::JrMessageHandlerSend};
66

77
/// Internal dyn-safe wrapper around `JrMessageHandlerSend`
8-
pub(crate) trait DynamicHandler<Role: JrRole>: Send {
8+
pub(crate) trait DynamicHandler<Role>: Send {
99
fn dyn_handle_message(
1010
&mut self,
1111
message: MessageCx,
@@ -30,7 +30,7 @@ impl<H: JrMessageHandlerSend> DynamicHandler<H::Role> for H {
3030
}
3131

3232
/// Messages used to add/remove dynamic handlers
33-
pub(crate) enum DynamicHandlerMessage<Role: JrRole> {
33+
pub(crate) enum DynamicHandlerMessage<Role> {
3434
AddDynamicHandler(Uuid, Box<dyn DynamicHandler<Role>>),
3535
RemoveDynamicHandler(Uuid),
3636
}

src/sacp/src/jsonrpc/responder.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,7 @@ use crate::{JrConnectionCx, role::JrRole};
1414
/// when the connection is active.
1515
pub trait JrResponder<Role: JrRole>: Send {
1616
/// Run this responder to completion.
17-
fn run(self, cx: JrConnectionCx<Role>)
18-
-> impl Future<Output = Result<(), crate::Error>> + Send;
17+
fn run(self, cx: JrConnectionCx<Role>) -> impl Future<Output = Result<(), crate::Error>> + Send;
1918
}
2019

2120
/// A no-op responder that completes immediately.

src/sacp/src/lib.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,3 +229,18 @@ pub use sacp_derive::{JrNotification, JrRequest, JrResponsePayload};
229229

230230
mod session;
231231
pub use session::*;
232+
233+
/// This is a hack that must be given as the final argument of
234+
/// [`McpServerBuilder::tool_fn`] when defining tools.
235+
/// Look away, lest ye be blinded by its vileness!
236+
///
237+
/// Fine, if you MUST know, it's a horrific workaround for not having
238+
/// [return-type notation](https://github.com/rust-lang/rust/issues/109417)
239+
/// and for [this !@$#!%! bug](https://github.com/rust-lang/rust/issues/110338).
240+
/// Trust me, the need for it hurts me more than it hurts you. --nikomatsakis
241+
#[macro_export]
242+
macro_rules! tool_fn_mut {
243+
() => {
244+
|func, params, context| Box::pin(func(params, context))
245+
};
246+
}

0 commit comments

Comments
 (0)