Skip to content

Commit b8cc335

Browse files
authored
fix(mcp-nats-server): time out unanswered proxy requests (#393)
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
1 parent c0e682a commit b8cc335

2 files changed

Lines changed: 112 additions & 16 deletions

File tree

rsworkspace/crates/mcp-nats-server/src/runtime.rs

Lines changed: 67 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
use std::collections::HashMap;
2+
use std::future;
23
use std::io;
4+
use std::time::Duration;
35

46
use mcp_nats::{
57
ClientJsonRpcMessage, Config, ErrorData, FlushClient, McpPeerId, NatsTransport, PublishClient, RequestClient,
@@ -11,6 +13,7 @@ use rmcp::transport::Transport;
1113
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
1214
use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService};
1315
use tokio::sync::{mpsc, oneshot};
16+
use tokio::time::Instant;
1417
use tracing::warn;
1518
use uuid::Uuid;
1619

@@ -19,6 +22,11 @@ use crate::allowed_host::AllowedHost;
1922
type ProxyResponse = oneshot::Sender<Result<ServerResult, ErrorData>>;
2023
type ProxyAck = oneshot::Sender<Result<(), ErrorData>>;
2124

25+
struct PendingEntry {
26+
response_tx: ProxyResponse,
27+
deadline: Instant,
28+
}
29+
2230
pub fn streamable_http_config(allowed_hosts: Vec<AllowedHost>) -> StreamableHttpServerConfig {
2331
let config = StreamableHttpServerConfig::default();
2432
if allowed_hosts.is_empty() {
@@ -84,6 +92,7 @@ where
8492
N: SubscribeClient + RequestClient + PublishClient + FlushClient,
8593
{
8694
command_tx: mpsc::Sender<ProxyCommand>,
95+
operation_timeout: Duration,
8796
server_info: ServerInfo,
8897
_nats: std::marker::PhantomData<N>,
8998
}
@@ -98,9 +107,11 @@ where
98107
{
99108
pub fn new(nats: N, config: Config, client_id: McpPeerId, server_id: McpPeerId) -> Self {
100109
let (command_tx, command_rx) = mpsc::channel(64);
110+
let operation_timeout = config.operation_timeout();
101111
tokio::spawn(run_proxy_worker(nats, config, client_id, server_id, command_rx));
102112
Self {
103113
command_tx,
114+
operation_timeout,
104115
server_info: ServerInfo::default(),
105116
_nats: std::marker::PhantomData,
106117
}
@@ -126,8 +137,9 @@ where
126137
})
127138
.await
128139
.map_err(|_| ErrorData::internal_error("MCP NATS proxy is unavailable", None))?;
129-
response_rx
140+
tokio::time::timeout(self.operation_timeout, response_rx)
130141
.await
142+
.map_err(|_| ErrorData::internal_error("MCP NATS proxy timed out waiting for a response", None))?
131143
.map_err(|_| ErrorData::internal_error("MCP NATS proxy dropped the request", None))?
132144
}
133145

@@ -145,8 +157,9 @@ where
145157
})
146158
.await
147159
.map_err(|_| ErrorData::internal_error("MCP NATS proxy is unavailable", None))?;
148-
response_rx
160+
tokio::time::timeout(self.operation_timeout, response_rx)
149161
.await
162+
.map_err(|_| ErrorData::internal_error("MCP NATS proxy timed out waiting for the notification", None))?
150163
.map_err(|_| ErrorData::internal_error("MCP NATS proxy dropped the notification", None))?
151164
}
152165

@@ -189,16 +202,18 @@ async fn run_proxy_worker<N>(
189202
return;
190203
}
191204
};
205+
let operation_timeout = config.operation_timeout();
192206
let mut peer = None;
193-
let mut pending = HashMap::new();
207+
let mut pending: HashMap<RequestId, PendingEntry> = HashMap::new();
194208

195209
loop {
210+
let next_deadline = pending.values().map(|entry| entry.deadline).min();
196211
tokio::select! {
197212
command = command_rx.recv() => {
198213
let Some(command) = command else {
199214
break;
200215
};
201-
handle_proxy_command(command, &mut transport, &mut peer, &mut pending).await;
216+
handle_proxy_command(command, &mut transport, &mut peer, &mut pending, operation_timeout).await;
202217
}
203218
message = transport.receive() => {
204219
let Some(message) = message else {
@@ -207,6 +222,9 @@ async fn run_proxy_worker<N>(
207222
};
208223
handle_remote_message(message, &mut transport, peer.as_ref(), &mut pending).await;
209224
}
225+
() = wait_for_deadline(next_deadline) => {
226+
evict_expired_pending(&mut pending);
227+
}
210228
}
211229
}
212230

@@ -219,7 +237,8 @@ async fn handle_proxy_command<N>(
219237
command: ProxyCommand,
220238
transport: &mut NatsTransport<RoleClient, N>,
221239
peer: &mut Option<Peer<RoleServer>>,
222-
pending: &mut HashMap<RequestId, ProxyResponse>,
240+
pending: &mut HashMap<RequestId, PendingEntry>,
241+
operation_timeout: Duration,
223242
) where
224243
N: SubscribeClient + RequestClient + PublishClient + FlushClient,
225244
N::RequestError: 'static,
@@ -235,11 +254,19 @@ async fn handle_proxy_command<N>(
235254
} => {
236255
*peer = Some(request_peer);
237256
let message = ClientJsonRpcMessage::request(*request, request_id.clone());
238-
pending.insert(request_id.clone(), response_tx);
257+
pending.insert(
258+
request_id.clone(),
259+
PendingEntry {
260+
response_tx,
261+
deadline: Instant::now() + operation_timeout,
262+
},
263+
);
239264
if let Err(error) = transport.send(message).await
240-
&& let Some(response_tx) = pending.remove(&request_id)
265+
&& let Some(entry) = pending.remove(&request_id)
241266
{
242-
let _ = response_tx.send(Err(ErrorData::internal_error(error.to_string(), None)));
267+
let _ = entry
268+
.response_tx
269+
.send(Err(ErrorData::internal_error(error.to_string(), None)));
243270
}
244271
}
245272
ProxyCommand::Notification {
@@ -261,7 +288,7 @@ async fn handle_remote_message<N>(
261288
message: ServerJsonRpcMessage,
262289
transport: &mut NatsTransport<RoleClient, N>,
263290
peer: Option<&Peer<RoleServer>>,
264-
pending: &mut HashMap<RequestId, ProxyResponse>,
291+
pending: &mut HashMap<RequestId, PendingEntry>,
265292
) where
266293
N: SubscribeClient + RequestClient + PublishClient + FlushClient,
267294
N::RequestError: 'static,
@@ -270,13 +297,13 @@ async fn handle_remote_message<N>(
270297
{
271298
match message {
272299
ServerJsonRpcMessage::Response(response) => {
273-
if let Some(response_tx) = pending.remove(&response.id) {
274-
let _ = response_tx.send(Ok(response.result));
300+
if let Some(entry) = pending.remove(&response.id) {
301+
let _ = entry.response_tx.send(Ok(response.result));
275302
}
276303
}
277304
ServerJsonRpcMessage::Error(error) => {
278-
if let Some(response_tx) = pending.remove(&error.id) {
279-
let _ = response_tx.send(Err(error.error));
305+
if let Some(entry) = pending.remove(&error.id) {
306+
let _ = entry.response_tx.send(Err(error.error));
280307
}
281308
}
282309
ServerJsonRpcMessage::Notification(notification) => {
@@ -323,9 +350,33 @@ fn service_error_to_error_data(error: ServiceError) -> ErrorData {
323350
ErrorData::internal_error(error.to_string(), None)
324351
}
325352

326-
fn fail_pending(pending: HashMap<RequestId, ProxyResponse>, error: ErrorData) {
327-
for response_tx in pending.into_values() {
328-
let _ = response_tx.send(Err(error.clone()));
353+
fn fail_pending(pending: HashMap<RequestId, PendingEntry>, error: ErrorData) {
354+
for entry in pending.into_values() {
355+
let _ = entry.response_tx.send(Err(error.clone()));
356+
}
357+
}
358+
359+
async fn wait_for_deadline(deadline: Option<Instant>) {
360+
match deadline {
361+
Some(deadline) => tokio::time::sleep_until(deadline).await,
362+
None => future::pending().await,
363+
}
364+
}
365+
366+
fn evict_expired_pending(pending: &mut HashMap<RequestId, PendingEntry>) {
367+
let now = Instant::now();
368+
let expired = pending
369+
.iter()
370+
.filter(|(_, entry)| entry.deadline <= now)
371+
.map(|(request_id, _)| request_id.clone())
372+
.collect::<Vec<_>>();
373+
for request_id in expired {
374+
if let Some(entry) = pending.remove(&request_id) {
375+
let _ = entry.response_tx.send(Err(ErrorData::internal_error(
376+
"MCP NATS proxy timed out waiting for a response",
377+
None,
378+
)));
379+
}
329380
}
330381
}
331382

rsworkspace/crates/mcp-nats-server/src/runtime/tests.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,51 @@ async fn streamable_http_service_routes_initialize_to_nats_server() {
8181
assert!(body.contains("remote-server"));
8282
}
8383

84+
#[tokio::test]
85+
async fn request_fails_when_nats_response_id_never_matches() {
86+
let nats = trogon_nats::AdvancedMockNatsClient::new();
87+
let _inbound = nats.inject_messages();
88+
nats.set_response(
89+
"mcp.server.default.initialize",
90+
serde_json::to_vec(&ServerJsonRpcMessage::response(
91+
ServerResult::InitializeResult(
92+
InitializeResult::new(ServerCapabilities::default())
93+
.with_server_info(Implementation::new("remote-server", "1.0.0")),
94+
),
95+
NumberOrString::Number(99),
96+
))
97+
.unwrap()
98+
.into(),
99+
);
100+
let service = streamable_http_service(
101+
nats.clone(),
102+
mcp_config().with_operation_timeout(std::time::Duration::from_secs(1)),
103+
ClientIdFactory::new(McpPeerId::new("http").unwrap()),
104+
McpPeerId::new("default").unwrap(),
105+
StreamableHttpServerConfig::default(),
106+
);
107+
let app = Router::new().route_service("/mcp", service);
108+
let body = serde_json::to_vec(&initialize_request()).unwrap();
109+
110+
let response = app
111+
.oneshot(
112+
Request::builder()
113+
.method("POST")
114+
.uri("/mcp")
115+
.header(header::HOST, "localhost")
116+
.header(header::ACCEPT, "application/json, text/event-stream")
117+
.header(header::CONTENT_TYPE, "application/json")
118+
.body(Body::from(body))
119+
.unwrap(),
120+
)
121+
.await
122+
.unwrap();
123+
124+
let response_body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
125+
let body = String::from_utf8(response_body.to_vec()).unwrap();
126+
assert!(body.contains("timed out"));
127+
}
128+
84129
#[test]
85130
fn client_id_factory_generates_valid_unique_peer_ids() {
86131
let factory = ClientIdFactory::new(McpPeerId::new("http").unwrap());

0 commit comments

Comments
 (0)