Skip to content

Commit 87e8139

Browse files
committed
fix(sacp): ensure NewSessionRequest flows through handler chain
McpServer was incorrectly forwarding NewSessionRequest directly to the agent instead of returning Handled::No, which bypassed any downstream .on_receive_request_from() handlers registered via the proxy builder. This broke proactive embodiment in SparkleComponent, where the NewSessionRequest handler intercepts sessions to inject embodiment prompts before user prompts arrive. Added regression test to prevent this from happening again.
1 parent 62dbaf2 commit 87e8139

2 files changed

Lines changed: 220 additions & 3 deletions

File tree

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
//! Test that MCP server doesn't break the handler chain for NewSessionRequest.
2+
//!
3+
//! This is a regression test for a bug where `McpServer::handle_message` would
4+
//! forward `NewSessionRequest` directly to the agent instead of returning
5+
//! `Handled::No`, which prevented downstream `.on_receive_request_from()` handlers
6+
//! from being invoked.
7+
8+
use sacp::mcp_server::McpServer;
9+
use sacp::schema::{
10+
AgentCapabilities, InitializeRequest, InitializeResponse, NewSessionRequest,
11+
NewSessionResponse, SessionId,
12+
};
13+
use sacp::{Agent, AgentToClient, Client, Component, ProxyToConductor};
14+
use sacp_conductor::Conductor;
15+
use schemars::JsonSchema;
16+
use serde::{Deserialize, Serialize};
17+
use std::path::PathBuf;
18+
use std::sync::Arc;
19+
use std::sync::atomic::{AtomicBool, Ordering};
20+
21+
use tokio::io::duplex;
22+
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
23+
24+
/// Simple echo tool parameters
25+
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
26+
struct EchoParams {
27+
message: String,
28+
}
29+
30+
/// Simple echo tool output
31+
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
32+
struct EchoOutput {
33+
result: String,
34+
}
35+
36+
/// Test helper to receive a JSON-RPC response
37+
async fn recv<T: sacp::JrResponsePayload + Send>(
38+
response: sacp::JrResponse<T>,
39+
) -> Result<T, sacp::Error> {
40+
let (tx, rx) = tokio::sync::oneshot::channel();
41+
response.await_when_result_received(async move |result| {
42+
tx.send(result).map_err(|_| sacp::Error::internal_error())
43+
})?;
44+
rx.await.map_err(|_| sacp::Error::internal_error())?
45+
}
46+
47+
/// Tracks whether the NewSessionRequest handler was invoked
48+
struct HandlerConfig {
49+
new_session_handler_called: AtomicBool,
50+
}
51+
52+
impl HandlerConfig {
53+
fn new() -> Arc<Self> {
54+
Arc::new(Self {
55+
new_session_handler_called: AtomicBool::new(false),
56+
})
57+
}
58+
59+
fn was_handler_called(&self) -> bool {
60+
self.new_session_handler_called.load(Ordering::SeqCst)
61+
}
62+
}
63+
64+
/// A proxy component that has BOTH an MCP server AND a NewSessionRequest handler.
65+
/// The bug was that when both were present, the NewSessionRequest handler was never called.
66+
struct ProxyWithMcpAndHandler {
67+
config: Arc<HandlerConfig>,
68+
}
69+
70+
impl Component for ProxyWithMcpAndHandler {
71+
async fn serve(self, client: impl Component) -> Result<(), sacp::Error> {
72+
let config = Arc::clone(&self.config);
73+
74+
// Create an MCP server with a simple tool
75+
let mcp_server = McpServer::builder("test-server".to_string())
76+
.instructions("A test MCP server")
77+
.tool_fn(
78+
"echo",
79+
"Echoes back the input",
80+
async |params: EchoParams, _cx| {
81+
Ok(EchoOutput {
82+
result: format!("Echo: {}", params.message),
83+
})
84+
},
85+
|f, args, cx| Box::pin(f(args, cx)),
86+
)
87+
.build();
88+
89+
ProxyToConductor::builder()
90+
.name("proxy-with-mcp-and-handler")
91+
// Add the MCP server
92+
.with_mcp_server(mcp_server)
93+
// Add a NewSessionRequest handler - this should be invoked!
94+
.on_receive_request_from(
95+
Client,
96+
async move |request: NewSessionRequest, request_cx, cx| {
97+
// Mark that we were called
98+
config
99+
.new_session_handler_called
100+
.store(true, Ordering::SeqCst);
101+
102+
// Forward to agent and relay response
103+
cx.send_request_to(Agent, request)
104+
.await_when_result_received(async move |result| {
105+
let response: NewSessionResponse = result?;
106+
request_cx.respond(response)
107+
})
108+
},
109+
)
110+
.serve(client)
111+
.await
112+
}
113+
}
114+
115+
/// A simple agent that responds to initialization and session requests
116+
struct SimpleAgent;
117+
118+
impl Component for SimpleAgent {
119+
async fn serve(self, client: impl Component) -> Result<(), sacp::Error> {
120+
AgentToClient::builder()
121+
.name("simple-agent")
122+
.on_receive_request(async |request: InitializeRequest, request_cx, _cx| {
123+
request_cx.respond(InitializeResponse {
124+
protocol_version: request.protocol_version,
125+
agent_capabilities: AgentCapabilities::default(),
126+
auth_methods: vec![],
127+
meta: None,
128+
agent_info: None,
129+
})
130+
})
131+
.on_receive_request(async |_request: NewSessionRequest, request_cx, _cx| {
132+
request_cx.respond(NewSessionResponse {
133+
session_id: SessionId(Arc::from(uuid::Uuid::new_v4().to_string())),
134+
modes: None,
135+
meta: None,
136+
})
137+
})
138+
.connect_to(client)?
139+
.serve()
140+
.await
141+
}
142+
}
143+
144+
async fn run_test(
145+
components: Vec<sacp::DynComponent>,
146+
editor_task: impl AsyncFnOnce(sacp::JrConnectionCx<sacp::ClientToAgent>) -> Result<(), sacp::Error>,
147+
) -> Result<(), sacp::Error> {
148+
let (editor_out, conductor_in) = duplex(1024);
149+
let (conductor_out, editor_in) = duplex(1024);
150+
151+
let transport = sacp::ByteStreams::new(editor_out.compat_write(), editor_in.compat());
152+
153+
sacp::ClientToAgent::builder()
154+
.name("editor-to-conductor")
155+
.with_spawned(|_cx| async move {
156+
Conductor::new("conductor".to_string(), components, Default::default())
157+
.run(sacp::ByteStreams::new(
158+
conductor_out.compat_write(),
159+
conductor_in.compat(),
160+
))
161+
.await
162+
})
163+
.with_client(transport, editor_task)
164+
.await
165+
}
166+
167+
/// Regression test: NewSessionRequest handler should be invoked even when MCP server is present
168+
#[tokio::test]
169+
async fn test_new_session_handler_invoked_with_mcp_server() -> Result<(), sacp::Error> {
170+
let handler_config = HandlerConfig::new();
171+
let handler_config_clone = Arc::clone(&handler_config);
172+
173+
let proxy = sacp::DynComponent::new(ProxyWithMcpAndHandler {
174+
config: handler_config,
175+
});
176+
let agent = sacp::DynComponent::new(SimpleAgent);
177+
178+
run_test(vec![proxy, agent], async |editor_cx| {
179+
// Initialize first
180+
let _init_response = recv(editor_cx.send_request(InitializeRequest {
181+
protocol_version: Default::default(),
182+
client_capabilities: Default::default(),
183+
meta: None,
184+
client_info: None,
185+
}))
186+
.await?;
187+
188+
// Create a new session - this should trigger the handler in the proxy
189+
let session_response = recv(editor_cx.send_request(NewSessionRequest {
190+
cwd: PathBuf::from("/tmp"),
191+
mcp_servers: vec![],
192+
meta: None,
193+
}))
194+
.await?;
195+
196+
// Verify we got a valid session ID
197+
assert!(
198+
!session_response.session_id.0.is_empty(),
199+
"Should receive a valid session ID"
200+
);
201+
202+
Ok::<(), sacp::Error>(())
203+
})
204+
.await?;
205+
206+
// THE KEY ASSERTION: verify the handler was actually called
207+
assert!(
208+
handler_config_clone.was_handler_called(),
209+
"NewSessionRequest handler should be invoked even when MCP server is in the chain. \
210+
This is a regression - the MCP server was incorrectly forwarding the request directly \
211+
to the agent instead of letting it flow through the handler chain."
212+
);
213+
214+
Ok(())
215+
}

src/sacp/src/mcp_server/server.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use agent_client_protocol_schema::NewSessionRequest;
66
use uuid::Uuid;
77

88
use crate::{
9-
Agent, Client, HasEndpoint, JrConnectionCx, JrMessageHandler, JrRole,
9+
Agent, Client, Handled, HasEndpoint, JrConnectionCx, JrMessageHandler, JrRole,
1010
jsonrpc::DynamicHandlerRegistration,
1111
mcp_server::{McpServerConnect, active_session::McpActiveSession, builder::McpServerBuilder},
1212
util::MatchMessageFrom,
@@ -103,8 +103,10 @@ where
103103
async |mut request: NewSessionRequest, request_cx| {
104104
self.add_to_new_session(&mut request, &cx)?
105105
.run_indefinitely();
106-
cx.send_request_to(Agent, request)
107-
.forward_to_request_cx(request_cx)
106+
Ok(Handled::No {
107+
message: (request, request_cx),
108+
retry: false,
109+
})
108110
},
109111
)
110112
.await

0 commit comments

Comments
 (0)