|
| 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 | +} |
0 commit comments