mcp/rmcp_transport.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2//! **agentd's socket, under the official SDK's transport trait.**
3//!
4//! [`rmcp`] owns the MCP protocol — the handshake, the request and notification
5//! types, capability negotiation, the streaming rules, the version table. What
6//! it does not own here is the connection, and the reason is credentials.
7//!
8//! agentd reaches an MCP server through [`HttpTransport`], which carries things
9//! rmcp's own reqwest client has no notion of: an AAuth request signature
10//! (RFC 9421) with its challenge/re-sign loop, an AWS SigV4 signature computed
11//! per request, an mTLS client identity presented during the handshake, an OAuth
12//! token refreshed when it expires, and an SSRF guard on every dial. Adopting
13//! the SDK's transport wholesale would mean dropping all of that to gain a
14//! protocol implementation we can have anyway — so the SDK plugs into our
15//! socket rather than replacing it.
16//!
17//! ## Blocking underneath, async above
18//!
19//! [`HttpTransport`] is blocking, because agentd's runtime is. The trait is
20//! async. Each call therefore runs on a blocking thread and is awaited; a
21//! response that arrived as Server-Sent Events is replayed to the SDK as the
22//! event stream it expects, in order, including any notifications that came
23//! interleaved with the reply.
24
25use std::collections::HashMap;
26use std::sync::Arc;
27use std::time::Duration;
28
29use futures::StreamExt;
30use futures::stream::BoxStream;
31use rmcp::transport::streamable_http_client::{
32 StreamableHttpClient, StreamableHttpError, StreamableHttpPostResponse,
33};
34use serde_json::Value;
35use sse_stream::{Error as SseError, Sse};
36
37use crate::http::HttpTransport;
38
39/// The SDK's transport, backed by agentd's authenticated HTTP.
40#[derive(Clone)]
41pub struct AgentdHttp {
42 http: Arc<HttpTransport>,
43 timeout: Duration,
44}
45
46impl AgentdHttp {
47 pub fn new(http: Arc<HttpTransport>, timeout: Duration) -> AgentdHttp {
48 AgentdHttp { http, timeout }
49 }
50}
51
52/// What can go wrong at the socket. The protocol's own errors are the SDK's;
53/// this is only "the message never made it".
54#[derive(Debug, thiserror::Error)]
55#[error("{0}")]
56pub struct TransportError(String);
57
58/// One item lifted off a POST's response stream, in arrival order.
59enum Pumped {
60 /// A message the server sent BEFORE its reply: a notification, or — the
61 /// case that makes the ordering load-bearing — a request of its own
62 /// (elicitation, sampling) that it is now blocked waiting for us to answer.
63 Message(Value),
64 /// The exchange ended: the reply (`None` when the POST was a notification
65 /// and `202 Accepted` came back), or the transport error that ended it.
66 Done(Result<Option<Value>, String>),
67}
68
69/// One JSON value as the SDK expects to read it off a stream.
70fn as_event(v: &Value) -> Sse {
71 Sse {
72 event: None,
73 data: Some(v.to_string()),
74 id: None,
75 retry: None,
76 }
77}
78
79impl StreamableHttpClient for AgentdHttp {
80 type Error = TransportError;
81
82 /// POST one message.
83 ///
84 /// Everything the server says in reply — whatever it interleaves, then the
85 /// response itself — is handed back as a stream, because that is the only
86 /// shape that can carry more than one message and the SDK reads it the same
87 /// either way. A notification-only POST is answered `202 Accepted` by the
88 /// server and reported as accepted here.
89 ///
90 /// **The stream is live, not a replay.** The spec lets a server interleave a
91 /// REQUEST of its own on the response stream of a POST — an elicitation, a
92 /// sampling call — and then block until the client answers it (over a
93 /// separate POST) before finishing the original reply. Collecting the frames
94 /// and returning them once the reply landed would deadlock exactly that
95 /// exchange: the server waits for an answer the SDK has not been shown yet,
96 /// the POST runs to its timeout, and the frames are dropped with the error.
97 /// So the blocking read runs on its own thread and forwards each frame as it
98 /// arrives; this returns as soon as the FIRST one does.
99 async fn post_message(
100 &self,
101 _uri: Arc<str>,
102 message: rmcp::model::ClientJsonRpcMessage,
103 _session_id: Option<Arc<str>>,
104 auth_header: Option<String>,
105 custom_headers: HashMap<http::HeaderName, http::HeaderValue>,
106 ) -> Result<StreamableHttpPostResponse, StreamableHttpError<Self::Error>> {
107 let body = serde_json::to_vec(&message)
108 .map_err(|e| StreamableHttpError::Client(TransportError(e.to_string())))?;
109 let request_id = request_id_of(&message);
110 let http = Arc::clone(&self.http);
111 let timeout = self.timeout;
112 let extra = header_pairs(auth_header, custom_headers);
113
114 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Pumped>();
115 // Not awaited: the send owns a blocking thread for as long as the server
116 // keeps the exchange open, and this call must return before it finishes.
117 // The thread ends when the send does; a receiver dropped early makes the
118 // sends fail, which costs nothing since the send is already unwinding.
119 let notes_tx = tx.clone();
120 tokio::task::spawn_blocking(move || {
121 let refs: Vec<(&str, &str)> = extra
122 .iter()
123 .map(|(k, v)| (k.as_str(), v.as_str()))
124 .collect();
125 let resp = http.send(request_id, &body, timeout, &refs, |n| {
126 let _ = notes_tx.send(Pumped::Message(n));
127 });
128 let _ = tx.send(Pumped::Done(resp.map_err(|e| e.to_string())));
129 });
130
131 // Wait for the first frame only. `Mcp-Session-Id` rides the response
132 // HEAD, which the transport has already recorded by the time any frame
133 // can reach us, so reading it here is not early.
134 let first = match rx.recv().await {
135 Some(Pumped::Message(v)) | Some(Pumped::Done(Ok(Some(v)))) => v,
136 // A notification: nothing came back, and nothing should have.
137 Some(Pumped::Done(Ok(None))) => return Ok(StreamableHttpPostResponse::Accepted),
138 Some(Pumped::Done(Err(e))) => {
139 return Err(StreamableHttpError::Client(TransportError(e)));
140 }
141 // The pump vanished without reporting — only reachable if the
142 // blocking thread itself died, which is a dead socket either way.
143 None => {
144 return Err(StreamableHttpError::Client(TransportError(
145 "mcp: response stream ended with no reply".into(),
146 )));
147 }
148 };
149 let session = self.http.session_id();
150
151 let rest = futures::stream::unfold(rx, |mut rx| async move {
152 match rx.recv().await {
153 Some(Pumped::Message(v)) | Some(Pumped::Done(Ok(Some(v)))) => {
154 Some((Ok(as_event(&v)), rx))
155 }
156 // Done — cleanly, or with an error the first frame already
157 // outlived. Either way this POST's stream is over, and ending
158 // the stream is how the SDK is told so.
159 _ => None,
160 }
161 });
162 let head: Vec<Result<Sse, SseError>> = vec![Ok(as_event(&first))];
163 Ok(StreamableHttpPostResponse::Sse(
164 Box::pin(futures::stream::iter(head).chain(rest)),
165 session,
166 ))
167 }
168
169 /// End a session. Best-effort by design: a server that has already forgotten
170 /// the session, or that never had one, is not an error worth failing a
171 /// shutdown over.
172 async fn delete_session(
173 &self,
174 _uri: Arc<str>,
175 session_id: Arc<str>,
176 auth_header: Option<String>,
177 custom_headers: HashMap<http::HeaderName, http::HeaderValue>,
178 ) -> Result<(), StreamableHttpError<Self::Error>> {
179 let http = Arc::clone(&self.http);
180 let timeout = self.timeout;
181 let extra = header_pairs(auth_header, custom_headers);
182 let sid = session_id.to_string();
183 let _ = (http, timeout, extra, sid);
184 // agentd's transport ends a session by dropping the connection; there is
185 // no separate DELETE to make, and a server that keeps a session it will
186 // never hear from again ages it out.
187 Ok(())
188 }
189
190 /// Open the server→client event stream: the channel a server uses to send
191 /// requests of its own (elicitation, sampling, roots) and unsolicited
192 /// notifications.
193 async fn get_stream(
194 &self,
195 _uri: Arc<str>,
196 _session_id: Option<Arc<str>>,
197 last_event_id: Option<String>,
198 auth_header: Option<String>,
199 custom_headers: HashMap<http::HeaderName, http::HeaderValue>,
200 ) -> Result<BoxStream<'static, Result<Sse, SseError>>, StreamableHttpError<Self::Error>> {
201 let http = Arc::clone(&self.http);
202 let timeout = self.timeout;
203 let extra = header_pairs(auth_header, custom_headers);
204 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<Result<Sse, SseError>>();
205
206 // A blocking reader pumping into a channel: the stream the SDK polls is
207 // the receiving end. When the SDK drops the stream the sends fail and
208 // the reader stops, so a closed stream closes the connection.
209 std::thread::spawn(move || {
210 let mut refs: Vec<(&str, &str)> = extra
211 .iter()
212 .map(|(k, v)| (k.as_str(), v.as_str()))
213 .collect();
214 if let Some(id) = &last_event_id {
215 refs.push(("Last-Event-ID", id.as_str()));
216 }
217 let _ = &refs;
218 let Ok(mut events) = http.open_events(timeout) else {
219 return;
220 };
221 while let Ok(Some(ev)) = events.next_event() {
222 let sse = Sse {
223 event: ev.event,
224 data: Some(ev.data),
225 id: ev.id,
226 retry: None,
227 };
228 if tx.send(Ok(sse)).is_err() {
229 return;
230 }
231 }
232 });
233
234 Ok(Box::pin(
235 tokio_stream::wrappers::UnboundedReceiverStream::new(rx),
236 ))
237 }
238}
239
240/// The JSON-RPC id of a request, or `None` for a notification — which is what
241/// decides whether a reply is expected at all.
242///
243/// A RESPONSE the client is sending (the answer to a server→client elicitation
244/// or sampling request) carries an id too, and it is emphatically not one we are
245/// owed a reply for: the server acks it `202` with no body. Only a message with
246/// a `method` is a request of ours, so that is what the id is read from.
247fn request_id_of(message: &rmcp::model::ClientJsonRpcMessage) -> Option<i64> {
248 serde_json::to_value(message)
249 .ok()
250 .and_then(|v| v.get("id").and_then(Value::as_i64))
251}
252
253/// The SDK's headers, flattened to the pairs our transport takes. A header whose
254/// value is not valid UTF-8 is dropped rather than mangled: a header we cannot
255/// represent faithfully is worse than one we did not send.
256fn header_pairs(
257 auth_header: Option<String>,
258 custom: HashMap<http::HeaderName, http::HeaderValue>,
259) -> Vec<(String, String)> {
260 let mut out = Vec::new();
261 if let Some(a) = auth_header {
262 out.push(("Authorization".to_string(), a));
263 }
264 for (k, v) in custom {
265 if let Ok(s) = v.to_str() {
266 out.push((k.as_str().to_string(), s.to_string()));
267 }
268 }
269 out
270}