protocol_droid/adapters/secondary/transport/detect.rs
1//! Which HTTP transport is this server speaking?
2//!
3//! The `2025-03-26` specification's backwards-compatibility section says:
4//!
5//! > **Clients** wanting to support older servers should:
6//! >
7//! > 1. Accept an MCP server URL from the user, which may point to either a
8//! > server using the old transport or the new transport.
9//! > 2. Attempt to POST an `InitializeRequest` to the server URL, with an
10//! > `Accept` header as defined above:
11//! > * If it succeeds, the client can assume this is a server supporting the
12//! > new Streamable HTTP transport.
13//! > * If it fails with an HTTP 4xx status code (e.g., 405 Method Not Allowed
14//! > or 404 Not Found):
15//! > * Issue a GET request to the server URL, expecting that this will open
16//! > an SSE stream and return an `endpoint` event as the first event.
17//! > * When the `endpoint` event arrives, the client can assume this is a
18//! > server running the old HTTP+SSE transport, and should use that
19//! > transport for all subsequent communication.
20//!
21//! Four things in that are load-bearing and each is a place an implementation
22//! goes wrong in a way no test against a real server will catch:
23//!
24//! - **4xx, not "not 2xx".** A `5xx` is a server that answered and failed. It
25//! speaks the new transport and something is broken; falling back would open a
26//! second transport on top of a real error and report the wrong cause.
27//! - **`GET` to the *same* URL.** The client does not know where the SSE
28//! endpoint is. The server names it in the `endpoint` event, and a client that
29//! guesses `/sse` is relying on a convention the specification never states.
30//! - **`endpoint` must be *first*.** A server that opens with a `message` frame
31//! is one that skipped the handshake; there is nowhere to post, so the client
32//! has to stop rather than guess.
33//! - **The decision is made once.** "for all subsequent communication" is a
34//! direction to a client, not a suggestion to re-probe per request.
35
36use core::time::Duration;
37
38use thiserror::Error;
39
40use crate::adapters::secondary::transport::http::{HttpClient, HttpClientConfig, HttpClientError};
41use crate::adapters::secondary::transport::legacy_sse::{
42 LegacySseClient, LegacySseConfig, LegacySseError,
43};
44use crate::domain::TransportKind;
45
46/// The `Accept` header the specification names, verbatim.
47const ACCEPT: &str = "application/json, text/event-stream";
48/// The media type the probe is sent as.
49const JSON: &str = "application/json";
50
51/// Why a server could not be classified.
52#[derive(Debug, Error)]
53pub enum DetectError {
54 /// The probe request never reached the peer.
55 #[error("the probe could not be sent: {error}")]
56 Io {
57 /// The transport's own account of the failure.
58 #[source]
59 error: reqwest::Error,
60 },
61
62 /// The probe was answered with a status the rule does not cover.
63 ///
64 /// Its own variant rather than a bare `HttpClientError`, because *which*
65 /// status it was is the entire decision and a caller that gets it as a
66 /// string has to re-derive the branch the rule already made. A `5xx` is the
67 /// case that matters: the peer answered, it speaks the new transport, and
68 /// something on it is broken.
69 #[error(
70 "the server answered the probe with {status}, which is not a version signal and not a success"
71 )]
72 Unusable {
73 /// The status the peer answered with.
74 status: reqwest::StatusCode,
75 /// The first 200 characters of the body, decoded lossily.
76 preview: String,
77 },
78
79 /// The peer answered 4xx and then would not open a stream either.
80 ///
81 /// The two halves of the rule both ran and neither produced a transport,
82 /// which means the URL is not an MCP server at all. Reporting "no
83 /// transport" without saying which half failed would send a reader looking
84 /// at the wrong transport.
85 #[error("the server refused the probe with {status} and then would not open an event stream")]
86 Neither {
87 /// The status that triggered the fallback.
88 status: reqwest::StatusCode,
89 /// What the `GET` half said.
90 #[source]
91 source: LegacySseError,
92 },
93}
94
95/// A server that answered, and the client that talks to it.
96#[derive(Debug)]
97pub enum Detected {
98 /// The probe succeeded: the server speaks Streamable HTTP.
99 Streamable {
100 /// A client carrying the session the probe's response issued.
101 client: HttpClient,
102 /// The probe's response body — the `initialize` result, which the caller
103 /// has to read and this client has already consumed the headers of.
104 result: Vec<u8>,
105 },
106 /// The probe was refused and a stream opened: the server speaks `2024-11-05`.
107 ///
108 /// A connected client rather than a verdict, because the `endpoint` event has
109 /// already arrived and been checked by the time this is constructed. A
110 /// caller reaching this arm has a working transport, not a finding to act on.
111 Legacy(LegacySseClient),
112}
113
114impl Detected {
115 /// Which transport the server speaks.
116 ///
117 /// Decided once and recorded, so a caller that wants to log it does not
118 /// re-run the branch — and so the two arms cannot disagree.
119 #[must_use]
120 pub const fn transport(&self) -> TransportKind {
121 match self {
122 Self::Streamable { .. } => TransportKind::StreamableHttp,
123 Self::Legacy(_) => TransportKind::LegacySse,
124 }
125 }
126
127 /// The `2024-11-05` client, if that is what the server speaks.
128 ///
129 /// # Errors
130 ///
131 /// [`NotLegacy`] if the server answered the probe, in which case the answer
132 /// is [`Detected::Streamable`] and this is a caller that took the wrong
133 /// branch for the transport it was handed.
134 pub fn into_legacy(self) -> Result<LegacySseClient, NotLegacy> {
135 match self {
136 Self::Legacy(client) => Ok(client),
137 Self::Streamable { .. } => Err(NotLegacy::NotLegacy),
138 }
139 }
140}
141
142/// Why [`Detected::into_legacy`] refused.
143#[derive(Debug, Error, PartialEq, Eq)]
144pub enum NotLegacy {
145 /// The server answered the probe and so speaks the new transport.
146 #[error("the server speaks Streamable HTTP, not the 2024-11-05 transport")]
147 NotLegacy,
148}
149
150/// Where to probe and how patient to be.
151#[derive(Debug, Clone)]
152pub struct DetectConfig {
153 /// Validated once, here, and never re-derived.
154 ///
155 /// Held as the typed config rather than as a `String` so that handing it to
156 /// a client at the end is a move rather than a re-parse that could fail —
157 /// and fail only on a value this function did not produce.
158 config: HttpClientConfig,
159 timeout: Option<Duration>,
160}
161
162impl DetectConfig {
163 /// A configuration pointed at `url`.
164 ///
165 /// # Errors
166 ///
167 /// [`HttpClientError::Url`] if `url` is not a URL a request can be built
168 /// against.
169 pub fn new(url: &str) -> Result<Self, HttpClientError> {
170 Ok(Self {
171 config: HttpClientConfig::new(url)?,
172 timeout: None,
173 })
174 }
175
176 /// Bounds how long the probe may take.
177 ///
178 /// Applies to the probe **only**. The `GET` half opens a stream that is
179 /// supposed to stay open, so a request timeout handed to it would break
180 /// every connection longer than it — see
181 /// [`LegacySseConfig::with_connect_timeout`], which is why this config has
182 /// a separate one.
183 #[must_use]
184 pub const fn with_probe_timeout(mut self, timeout: Duration) -> Self {
185 self.timeout = Some(timeout);
186 self
187 }
188}
189
190/// Asks a server which transport it speaks, and returns a client that can.
191///
192/// `probe` is the serialised `InitializeRequest`. It is a parameter rather than
193/// something this module builds because the protocol layer owns what an
194/// initialize request is and which version it asks for; a transport that
195/// hard-coded one would be encoding a version-negotiation decision inside a
196/// module whose job is moving bytes.
197///
198/// # Errors
199///
200/// [`DetectError::Io`] if the probe never arrived,
201/// [`DetectError::Unusable`] if the peer answered with a status that is neither
202/// a success nor a 4xx — a `5xx` included, deliberately — and
203/// [`DetectError::Neither`] if the peer answered 4xx and then would not open a
204/// stream.
205pub async fn detect(config: DetectConfig, probe: &[u8]) -> Result<Detected, DetectError> {
206 let mut builder = reqwest::Client::builder()
207 .user_agent(concat!("protocol-droid/", env!("CARGO_PKG_VERSION")));
208 if let Some(limit) = config.timeout {
209 builder = builder.timeout(limit);
210 }
211 let inner = builder.build().map_err(|error| DetectError::Io { error })?;
212
213 let response = inner
214 .post(config.config.endpoint())
215 .header(reqwest::header::ACCEPT, ACCEPT)
216 .header(reqwest::header::CONTENT_TYPE, JSON)
217 .body(probe.to_vec())
218 .send()
219 .await
220 .map_err(|error| DetectError::Io { error })?;
221
222 let status = response.status();
223 if status.is_client_error() {
224 drop(response);
225 // The rule's second half, and only for a 4xx. The `GET` goes to the
226 // same URL — this module does not know, and is not entitled to guess,
227 // where the SSE endpoint is.
228 return fall_back(inner, config, status).await;
229 }
230 if !status.is_success() {
231 return Err(DetectError::Unusable {
232 status,
233 preview: preview(response).await,
234 });
235 }
236
237 // The probe's own response is the `initialize` result, and its headers carry
238 // the session every later request needs. Both are read here because the
239 // response is consumed below and a client built without the session would
240 // re-run the handshake to get it.
241 let session = response
242 .headers()
243 .get(SESSION_HEADER)
244 .and_then(|value| value.to_str().ok())
245 .and_then(|value| value.parse().ok());
246 let result = response
247 .bytes()
248 .await
249 .map_err(|error| DetectError::Io { error })?
250 .to_vec();
251
252 // The URL was checked by `DetectConfig::new` and has not changed since, so
253 // it cannot fail here. Re-deriving it through `expect`-shaped code would add
254 // an arm that is unreachable rather than remove one.
255 let mut client = HttpClient::from_client(inner, config.config.clone());
256 if let Some(session) = session {
257 client = client.resuming(session);
258 }
259 Ok(Detected::Streamable { client, result })
260}
261
262/// The 4xx branch, which is not a second implementation but a call to the one.
263async fn fall_back(
264 inner: reqwest::Client,
265 config: DetectConfig,
266 status: reqwest::StatusCode,
267) -> Result<Detected, DetectError> {
268 let mut legacy =
269 LegacySseConfig::new(config.config.endpoint()).map_err(|source| DetectError::Neither {
270 status,
271 source: unclassifiable(source),
272 })?;
273 if let Some(limit) = config.timeout {
274 legacy = legacy.with_connect_timeout(limit);
275 }
276 LegacySseClient::connect_with(inner, legacy)
277 .await
278 .map(Detected::Legacy)
279 .map_err(|source| DetectError::Neither { status, source })
280}
281
282/// Wraps a configuration failure as something the `Neither` arm can carry.
283///
284/// The only way to get here is a URL that [`DetectConfig::new`] already
285/// accepted and this module then failed to accept a second time, so it does not
286/// carry a `Url` variant of its own to keep in step. It is kept rather than
287/// unwrapped so the `Neither` arm's type stays about the stream, which is the
288/// thing that actually failed.
289fn unclassifiable(source: LegacySseError) -> LegacySseError {
290 match source {
291 LegacySseError::Url { got, reason } => LegacySseError::Unreadable { got, reason },
292 other => other,
293 }
294}
295
296/// The header carrying the session on this transport and every later one.
297const SESSION_HEADER: &str = "mcp-session-id";
298
299/// A body, bounded, for an error that has to show what the peer said.
300async fn preview(response: reqwest::Response) -> String {
301 response
302 .bytes()
303 .await
304 .map(|body| String::from_utf8_lossy(&body).chars().take(200).collect())
305 .unwrap_or_default()
306}
307
308#[cfg(test)]
309mod tests;