use core::time::Duration;
use thiserror::Error;
use crate::adapters::secondary::transport::http::{HttpClient, HttpClientConfig, HttpClientError};
use crate::adapters::secondary::transport::legacy_sse::{
LegacySseClient, LegacySseConfig, LegacySseError,
};
use crate::domain::TransportKind;
const ACCEPT: &str = "application/json, text/event-stream";
const JSON: &str = "application/json";
#[derive(Debug, Error)]
pub enum DetectError {
#[error("the probe could not be sent: {error}")]
Io {
#[source]
error: reqwest::Error,
},
#[error(
"the server answered the probe with {status}, which is not a version signal and not a success"
)]
Unusable {
status: reqwest::StatusCode,
preview: String,
},
#[error("the server refused the probe with {status} and then would not open an event stream")]
Neither {
status: reqwest::StatusCode,
#[source]
source: LegacySseError,
},
}
#[derive(Debug)]
pub enum Detected {
Streamable {
client: HttpClient,
result: Vec<u8>,
},
Legacy(LegacySseClient),
}
impl Detected {
#[must_use]
pub const fn transport(&self) -> TransportKind {
match self {
Self::Streamable { .. } => TransportKind::StreamableHttp,
Self::Legacy(_) => TransportKind::LegacySse,
}
}
pub fn into_legacy(self) -> Result<LegacySseClient, NotLegacy> {
match self {
Self::Legacy(client) => Ok(client),
Self::Streamable { .. } => Err(NotLegacy::NotLegacy),
}
}
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum NotLegacy {
#[error("the server speaks Streamable HTTP, not the 2024-11-05 transport")]
NotLegacy,
}
#[derive(Debug, Clone)]
pub struct DetectConfig {
config: HttpClientConfig,
timeout: Option<Duration>,
}
impl DetectConfig {
pub fn new(url: &str) -> Result<Self, HttpClientError> {
Ok(Self {
config: HttpClientConfig::new(url)?,
timeout: None,
})
}
#[must_use]
pub const fn with_probe_timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
}
pub async fn detect(config: DetectConfig, probe: &[u8]) -> Result<Detected, DetectError> {
let mut builder = reqwest::Client::builder()
.user_agent(concat!("protocol-droid/", env!("CARGO_PKG_VERSION")));
if let Some(limit) = config.timeout {
builder = builder.timeout(limit);
}
let inner = builder.build().map_err(|error| DetectError::Io { error })?;
let response = inner
.post(config.config.endpoint())
.header(reqwest::header::ACCEPT, ACCEPT)
.header(reqwest::header::CONTENT_TYPE, JSON)
.body(probe.to_vec())
.send()
.await
.map_err(|error| DetectError::Io { error })?;
let status = response.status();
if status.is_client_error() {
drop(response);
return fall_back(inner, config, status).await;
}
if !status.is_success() {
return Err(DetectError::Unusable {
status,
preview: preview(response).await,
});
}
let session = response
.headers()
.get(SESSION_HEADER)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse().ok());
let result = response
.bytes()
.await
.map_err(|error| DetectError::Io { error })?
.to_vec();
let mut client = HttpClient::from_client(inner, config.config.clone());
if let Some(session) = session {
client = client.resuming(session);
}
Ok(Detected::Streamable { client, result })
}
async fn fall_back(
inner: reqwest::Client,
config: DetectConfig,
status: reqwest::StatusCode,
) -> Result<Detected, DetectError> {
let mut legacy =
LegacySseConfig::new(config.config.endpoint()).map_err(|source| DetectError::Neither {
status,
source: unclassifiable(source),
})?;
if let Some(limit) = config.timeout {
legacy = legacy.with_connect_timeout(limit);
}
LegacySseClient::connect_with(inner, legacy)
.await
.map(Detected::Legacy)
.map_err(|source| DetectError::Neither { status, source })
}
fn unclassifiable(source: LegacySseError) -> LegacySseError {
match source {
LegacySseError::Url { got, reason } => LegacySseError::Unreadable { got, reason },
other => other,
}
}
const SESSION_HEADER: &str = "mcp-session-id";
async fn preview(response: reqwest::Response) -> String {
response
.bytes()
.await
.map(|body| String::from_utf8_lossy(&body).chars().take(200).collect())
.unwrap_or_default()
}
#[cfg(test)]
mod tests;