Skip to content

Commit 4882eec

Browse files
committed
feat(sacp)!: add scoped lifetime support for MCP servers
MCP servers can now capture references to stack-local data, enabling tools that interact with data scoped to a session or proxy lifetime. Key changes: - McpServer<'scope, Role>: MCP servers now have a lifetime parameter allowing tool closures to capture non-'static references - SessionBuilder<'scope, Role>: Sessions can now use scoped MCP servers via the new run_session() method which races the session operation against the MCP server's tool loop future - SessionBuilder::with_mcp_server now takes ownership of McpServer instead of a reference, accumulating tool futures - New run_session() API provides scoped session execution where MCP server futures are automatically cancelled when the session ends - send_request() still available for 'static MCP servers, spawning the session in a background task - Added ActiveSession::read_to_string() convenience method - Added run_until() and both() future combinators for cleaner async code - Added test demonstrating scoped MCP servers capturing stack-local Mutex<Vec<String>> BREAKING CHANGE: SessionBuilder::with_mcp_server signature changed from taking &McpServer<Role> to taking McpServer<'scope, Role> by value. McpServer now requires a lifetime parameter.
1 parent d6bd612 commit 4882eec

14 files changed

Lines changed: 441 additions & 92 deletions

File tree

src/elizacp/src/lib.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,15 +117,15 @@ impl ElizaAgent {
117117
) -> Result<(), sacp::Error> {
118118
let session_id = request.session_id.clone();
119119

120+
// Extract text from the prompt
121+
let input_text = extract_text_from_prompt(&request.prompt);
122+
120123
tracing::debug!(
121-
"Processing prompt in session {}: {} content blocks",
124+
"Processing prompt in session {}: {input_text:?} over {} content blocks",
122125
session_id,
123126
request.prompt.len()
124127
);
125128

126-
// Extract text from the prompt
127-
let input_text = extract_text_from_prompt(&request.prompt);
128-
129129
// Check for MCP commands first before invoking Eliza
130130
let final_response = if let Some(server_name) = parse_list_tools_command(&input_text) {
131131
// List tools from a specific server
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
//! Test that MCP servers can reference stack-local data.
2+
//!
3+
//! This test demonstrates the new scoped lifetime feature where an MCP tool
4+
//! can capture references to stack-local data (like a Vec) and push to it
5+
//! when the tool is invoked.
6+
7+
use elizacp::ElizaAgent;
8+
use sacp::mcp_server::McpServer;
9+
use sacp::{Agent, ClientToAgent, Component, DynComponent, HasEndpoint, JrRole, ProxyToConductor};
10+
use sacp_conductor::{Conductor, McpBridgeMode};
11+
use schemars::JsonSchema;
12+
use serde::{Deserialize, Serialize};
13+
use std::sync::Mutex;
14+
15+
/// Test that an MCP tool can push to a stack-local vector.
16+
///
17+
/// This validates the scoped lifetime feature - the tool closure captures
18+
/// a reference to `collected_values` which lives on the stack.
19+
#[tokio::test]
20+
async fn test_scoped_mcp_server_through_proxy() -> Result<(), sacp::Error> {
21+
let conductor = Conductor::new(
22+
"conductor".to_string(),
23+
vec![
24+
DynComponent::new(ScopedProxy),
25+
DynComponent::new(ElizaAgent::new()),
26+
],
27+
Default::default(),
28+
);
29+
30+
let result = yopo::prompt(
31+
conductor,
32+
r#"Use tool test::push with {"elements": ["Hello", "world"]}"#,
33+
)
34+
.await?;
35+
36+
expect_test::expect![[r#"
37+
"OK: CallToolResult { content: [Annotated { raw: Text(RawTextContent { text: \"2\", meta: None }), annotations: None }], structured_content: Some(Number(2)), is_error: Some(false), meta: None }"
38+
"#]].assert_debug_eq(&result);
39+
40+
Ok(())
41+
}
42+
43+
/// Test that an MCP tool can push to a stack-local vector through a session.
44+
///
45+
/// This validates the scoped lifetime feature with session-scoped MCP servers.
46+
/// The MCP server captures a reference to stack-local data that lives for
47+
/// the duration of the session.
48+
#[tokio::test]
49+
async fn test_scoped_mcp_server_through_session() -> Result<(), sacp::Error> {
50+
ClientToAgent::builder()
51+
.connect_to(Conductor::new("conductor".to_string(), vec![ElizaAgent::new()], McpBridgeMode::default()))?
52+
.with_client(async |cx| {
53+
// Initialize first
54+
cx.send_request(sacp::schema::InitializeRequest {
55+
protocol_version: Default::default(),
56+
client_capabilities: Default::default(),
57+
client_info: None,
58+
meta: None,
59+
})
60+
.block_task()
61+
.await?;
62+
63+
let collected_values = Mutex::new(Vec::new());
64+
let result = cx
65+
.build_session(".")
66+
.with_mcp_server(make_mcp_server(&collected_values))?
67+
.run_session(async |mut active_session| {
68+
active_session
69+
.send_prompt(r#"Use tool test::push with {"elements": ["Hello", "world"]}"#)?;
70+
active_session.read_to_string().await
71+
})
72+
.await?;
73+
74+
expect_test::expect![[r#"
75+
"OK: CallToolResult { content: [Annotated { raw: Text(RawTextContent { text: \"2\", meta: None }), annotations: None }], structured_content: Some(Number(2)), is_error: Some(false), meta: None }"
76+
"#]].assert_debug_eq(&result);
77+
78+
Ok(())
79+
}).await?;
80+
81+
Ok(())
82+
}
83+
84+
struct ScopedProxy;
85+
86+
fn make_mcp_server<Role: JrRole>(values: &Mutex<Vec<String>>) -> McpServer<'_, Role>
87+
where
88+
Role: HasEndpoint<Agent>,
89+
{
90+
#[derive(Serialize, Deserialize, JsonSchema)]
91+
struct PushInput {
92+
elements: Vec<String>,
93+
}
94+
95+
McpServer::builder("test".to_string())
96+
.instructions("A test MCP server with scoped tool")
97+
.tool_fn(
98+
"push",
99+
"Push a value to the collected values",
100+
async |input: PushInput, _cx| {
101+
let mut values = values.lock().expect("not poisoned");
102+
values.extend(input.elements);
103+
Ok(values.len())
104+
},
105+
sacp::tool_fn!(),
106+
)
107+
.tool_fn(
108+
"get",
109+
"Get the collected values",
110+
async |(): (), _cx| {
111+
let values = values.lock().expect("not poisoned");
112+
Ok(values.clone())
113+
},
114+
sacp::tool_fn!(),
115+
)
116+
.build()
117+
}
118+
119+
impl Component for ScopedProxy {
120+
async fn serve(self, client: impl Component) -> Result<(), sacp::Error> {
121+
// Stack-local data that the MCP tool will push to
122+
let values: Mutex<Vec<String>> = Mutex::new(Vec::new());
123+
124+
// Build the MCP server that captures a reference to collected_values
125+
let mcp_server = make_mcp_server(&values);
126+
127+
ProxyToConductor::builder()
128+
.name("scoped-mcp-server")
129+
.with_mcp_server(mcp_server)
130+
.connect_to(client)?
131+
.serve()
132+
.await
133+
}
134+
}

src/sacp-conductor/tests/test_session_id_in_mcp_tools.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,14 @@ fn create_echo_proxy() -> Result<sacp::DynComponent, sacp::Error> {
4949
}
5050

5151
struct ProxyWithEchoServer {
52-
mcp_server: McpServer<ProxyToConductor>,
52+
mcp_server: McpServer<'static, ProxyToConductor>,
5353
}
5454

5555
impl Component for ProxyWithEchoServer {
5656
async fn serve(self, client: impl Component) -> Result<(), sacp::Error> {
5757
ProxyToConductor::builder()
5858
.name("echo-proxy")
59-
.with_handler(self.mcp_server)
59+
.with_mcp_server(self.mcp_server)
6060
.serve(client)
6161
.await
6262
}

src/sacp-rmcp/examples/with_mcp_server.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
9292
ProxyToConductor::builder()
9393
.name("mcp-server-proxy")
9494
// Register the MCP server as a handler
95-
.with_handler(mcp_server)
95+
.with_mcp_server(mcp_server)
9696
// Start serving
9797
.connect_to(sacp::ByteStreams::new(
9898
tokio::io::stdout().compat_write(),

src/sacp-rmcp/src/lib.rs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ where
3737
fn from_rmcp<S>(
3838
name: impl ToString,
3939
new_fn: impl Fn() -> S + Send + Sync + 'static,
40-
) -> McpServer<Role>
40+
) -> McpServer<'static, Role>
4141
where
4242
S: rmcp::Service<rmcp::RoleServer>,
4343
{
@@ -62,14 +62,17 @@ where
6262
}
6363
}
6464

65-
McpServer::new(RmcpServer {
66-
name: name.to_string(),
67-
new_fn,
68-
})
65+
McpServer::new(
66+
RmcpServer {
67+
name: name.to_string(),
68+
new_fn,
69+
},
70+
Box::pin(futures::future::ready(Ok(()))),
71+
)
6972
}
7073
}
7174

72-
impl<Role: JrRole> McpServerExt<Role> for McpServer<Role> where Role: HasEndpoint<Agent> {}
75+
impl<Role: JrRole> McpServerExt<Role> for McpServer<'_, Role> where Role: HasEndpoint<Agent> {}
7376

7477
/// Component wrapper for rmcp services.
7578
struct RmcpServerComponent<S> {

src/sacp/src/jsonrpc.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ pub use crate::jsonrpc::handlers::NullHandler;
3030
use crate::jsonrpc::handlers::{MessageHandler, NotificationHandler, RequestHandler};
3131
use crate::jsonrpc::outgoing_actor::{OutgoingMessageTx, send_raw_message};
3232
use crate::jsonrpc::task_actor::{PendingTask, Task, TaskTx};
33-
use crate::mcp_server::McpServer;
33+
use crate::mcp_server::{McpMessageHandler, McpServer};
3434
use crate::role::{HasDefaultEndpoint, HasEndpoint, JrEndpoint, JrRole};
3535
use crate::{Agent, Client, Component};
3636

@@ -917,13 +917,15 @@ impl<'scope, H: JrMessageHandler> JrConnectionBuilder<'scope, H> {
917917
/// ```
918918
pub fn with_mcp_server<Role: JrRole>(
919919
self,
920-
registry: McpServer<Role>,
921-
) -> JrConnectionBuilder<'scope, ChainedHandler<H, McpServer<Role>>>
920+
server: McpServer<'scope, Role>,
921+
) -> JrConnectionBuilder<'scope, ChainedHandler<H, McpMessageHandler<Role>>>
922922
where
923923
H: JrMessageHandler<Role = Role>,
924924
Role: HasEndpoint<Client> + HasEndpoint<Agent>,
925925
{
926-
self.with_handler(registry)
926+
let (message_handler, future) = server.into_handler_and_future();
927+
self.with_handler(message_handler)
928+
.with_spawned(move |_cx| future)
927929
}
928930

929931
/// Connect these handlers to a transport layer.

src/sacp/src/mcp.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ impl HasEndpoint<McpServerEnd> for McpClientToServer {
3434

3535
impl McpClientToServer {
3636
/// Create a connection builder for an MCP client talking to an MCP server.
37-
pub fn builder() -> JrConnectionBuilder<'static, NullHandler<McpClientToServer>> {
37+
pub fn builder<'any>() -> JrConnectionBuilder<'any, NullHandler<McpClientToServer>> {
3838
JrConnectionBuilder::new(McpClientToServer)
3939
}
4040
}
@@ -58,7 +58,7 @@ impl HasEndpoint<McpClient> for McpServerToClient {
5858

5959
impl McpServerToClient {
6060
/// Create a connection builder for an MCP server talking to an MCP client.
61-
pub fn builder() -> JrConnectionBuilder<'static, NullHandler<McpServerToClient>> {
61+
pub fn builder<'any>() -> JrConnectionBuilder<'any, NullHandler<McpServerToClient>> {
6262
JrConnectionBuilder::new(McpServerToClient)
6363
}
6464
}

0 commit comments

Comments
 (0)