Skip to main content

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;