Skip to main content

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}