Skip to content

Commit 641dbaf

Browse files
authored
Merge pull request #88 from nikomatsakis/main
feat(sacp)!: merge pending_tasks into JrResponder and remove 'scope lifetime
2 parents 80eb709 + 94dfb6c commit 641dbaf

File tree

19 files changed

+672
-255
lines changed

19 files changed

+672
-255
lines changed

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

src/sacp-conductor/src/conductor.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,10 @@ impl Conductor {
191191
self
192192
}
193193

194-
pub fn into_connection_builder(self) -> JrConnectionBuilder<ConductorMessageHandler> {
194+
pub fn into_connection_builder(
195+
self,
196+
) -> JrConnectionBuilder<ConductorMessageHandler, impl sacp::JrResponder<ConductorToClient>>
197+
{
195198
let (mut conductor_tx, mut conductor_rx) = mpsc::channel(128 /* chosen arbitrarily */);
196199

197200
let mut state = ConductorHandlerState {

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ impl Component for ProxyComponent {
3434
result: format!("Echo: {}", params.message),
3535
})
3636
},
37-
sacp::tool_fn!(),
3837
)
3938
.build();
4039

src/sacp-conductor/tests/mcp_server_handler_chain.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,6 @@ impl Component for ProxyWithMcpAndHandler {
8282
result: format!("Echo: {}", params.message),
8383
})
8484
},
85-
sacp::tool_fn!(),
8685
)
8786
.build();
8887

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
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>(
87+
values: &Mutex<Vec<String>>,
88+
) -> McpServer<Role, impl sacp::JrResponder<Role> + use<'_, Role>>
89+
where
90+
Role: HasEndpoint<Agent>,
91+
{
92+
#[derive(Serialize, Deserialize, JsonSchema)]
93+
struct PushInput {
94+
elements: Vec<String>,
95+
}
96+
97+
McpServer::builder("test".to_string())
98+
.instructions("A test MCP server with scoped tool")
99+
.tool_fn(
100+
"push",
101+
"Push a value to the collected values",
102+
async |input: PushInput, _cx| {
103+
let mut values = values.lock().expect("not poisoned");
104+
values.extend(input.elements);
105+
Ok(values.len())
106+
},
107+
)
108+
.tool_fn("get", "Get the collected values", async |(): (), _cx| {
109+
let values = values.lock().expect("not poisoned");
110+
Ok(values.clone())
111+
})
112+
.build()
113+
}
114+
115+
impl Component for ScopedProxy {
116+
async fn serve(self, client: impl Component) -> Result<(), sacp::Error> {
117+
// Stack-local data that the MCP tool will push to
118+
let values: Mutex<Vec<String>> = Mutex::new(Vec::new());
119+
120+
// Build the MCP server that captures a reference to collected_values
121+
let mcp_server = make_mcp_server(&values);
122+
123+
ProxyToConductor::builder()
124+
.name("scoped-mcp-server")
125+
.with_mcp_server(mcp_server)
126+
.connect_to(client)?
127+
.serve()
128+
.await
129+
}
130+
}

src/sacp-conductor/tests/test_session_id_in_mcp_tools.rs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,23 +40,22 @@ fn create_echo_proxy() -> Result<sacp::DynComponent, sacp::Error> {
4040
acp_url: context.acp_url(),
4141
})
4242
},
43-
sacp::tool_fn!(),
4443
)
4544
.build();
4645

4746
// Create proxy component
4847
Ok(sacp::DynComponent::new(ProxyWithEchoServer { mcp_server }))
4948
}
5049

51-
struct ProxyWithEchoServer {
52-
mcp_server: McpServer<ProxyToConductor>,
50+
struct ProxyWithEchoServer<R: sacp::JrResponder<ProxyToConductor>> {
51+
mcp_server: McpServer<ProxyToConductor, R>,
5352
}
5453

55-
impl Component for ProxyWithEchoServer {
54+
impl<R: sacp::JrResponder<ProxyToConductor> + 'static> Component for ProxyWithEchoServer<R> {
5655
async fn serve(self, client: impl Component) -> Result<(), sacp::Error> {
5756
ProxyToConductor::builder()
5857
.name("echo-proxy")
59-
.with_handler(self.mcp_server)
58+
.with_mcp_server(self.mcp_server)
6059
.serve(client)
6160
.await
6261
}

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
@@ -22,7 +22,7 @@
2222
2323
use rmcp::ServiceExt;
2424
use sacp::mcp_server::{McpContext, McpServer, McpServerConnect};
25-
use sacp::{Agent, ByteStreams, Component, DynComponent, HasEndpoint, JrRole};
25+
use sacp::{Agent, ByteStreams, Component, DynComponent, HasEndpoint, JrRole, NullResponder};
2626
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
2727

2828
pub trait McpServerExt<Role: JrRole>
@@ -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<Role, NullResponder>
4141
where
4242
S: rmcp::Service<rmcp::RoleServer>,
4343
{
@@ -62,10 +62,13 @@ 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+
NullResponder,
71+
)
6972
}
7073
}
7174

0 commit comments

Comments
 (0)