1use std::{borrow::Cow, collections::HashMap, sync::Arc};
2
3use futures::{StreamExt, stream::BoxStream};
4use http::{HeaderName, HeaderValue};
5use reqwest::header::{ACCEPT, WWW_AUTHENTICATE};
6use rmcp::{
7 model::{ClientJsonRpcMessage, JsonRpcMessage, ServerJsonRpcMessage},
8 transport::{
9 common::http_header::{
10 EVENT_STREAM_MIME_TYPE, HEADER_LAST_EVENT_ID, HEADER_MCP_PROTOCOL_VERSION,
11 HEADER_SESSION_ID, JSON_MIME_TYPE,
12 },
13 streamable_http_client::{
14 AuthRequiredError, InsufficientScopeError, SseError, StreamableHttpClient,
15 StreamableHttpError, StreamableHttpPostResponse,
16 },
17 },
18};
19use sse_stream::{Sse, SseStream};
20
21#[derive(Clone)]
22pub struct BodyCappedHttpClient {
23 inner: reqwest::Client,
24 json_max_bytes: usize,
25 sse_event_max_bytes: usize,
26}
27
28impl BodyCappedHttpClient {
29 #[must_use]
30 pub fn new(inner: reqwest::Client, json_max_bytes: usize, sse_event_max_bytes: usize) -> Self {
31 Self {
32 inner,
33 json_max_bytes,
34 sse_event_max_bytes,
35 }
36 }
37
38 #[must_use]
39 pub fn default_with_caps(json_max_bytes: usize, sse_event_max_bytes: usize) -> Self {
40 let inner = reqwest::Client::builder()
41 .pool_max_idle_per_host(0)
42 .redirect(reqwest::redirect::Policy::none())
43 .build()
44 .expect("failed to build gateway HTTP client");
45 Self::new(inner, json_max_bytes, sse_event_max_bytes)
46 }
47}
48
49impl StreamableHttpClient for BodyCappedHttpClient {
50 type Error = reqwest::Error;
51
52 async fn post_message(
53 &self,
54 uri: Arc<str>,
55 message: ClientJsonRpcMessage,
56 session_id: Option<Arc<str>>,
57 auth_token: Option<String>,
58 custom_headers: HashMap<HeaderName, HeaderValue>,
59 ) -> Result<StreamableHttpPostResponse, StreamableHttpError<Self::Error>> {
60 let session_was_attached = session_id.is_some();
61 let mut request = self
62 .inner
63 .post(uri.as_ref())
64 .header(ACCEPT, [EVENT_STREAM_MIME_TYPE, JSON_MIME_TYPE].join(", "));
65 if let Some(token) = auth_token {
66 request = request.bearer_auth(token);
67 }
68 if let Some(session_id) = session_id {
69 request = request.header(HEADER_SESSION_ID, session_id.as_ref());
70 }
71 let response = apply_custom_headers(request, custom_headers)?
72 .json(&message)
73 .send()
74 .await
75 .map_err(StreamableHttpError::Client)?;
76 response_to_post_result(response, message, session_was_attached, self.json_max_bytes).await
77 }
78
79 async fn delete_session(
80 &self,
81 uri: Arc<str>,
82 session_id: Arc<str>,
83 auth_token: Option<String>,
84 custom_headers: HashMap<HeaderName, HeaderValue>,
85 ) -> Result<(), StreamableHttpError<Self::Error>> {
86 let mut request = self
87 .inner
88 .delete(uri.as_ref())
89 .header(HEADER_SESSION_ID, session_id.as_ref());
90 if let Some(token) = auth_token {
91 request = request.bearer_auth(token);
92 }
93 let response = apply_custom_headers(request, custom_headers)?
94 .send()
95 .await
96 .map_err(StreamableHttpError::Client)?;
97 if response.status() == reqwest::StatusCode::METHOD_NOT_ALLOWED {
98 return Err(StreamableHttpError::ServerDoesNotSupportDeleteSession);
99 }
100 response
101 .error_for_status()
102 .map(|_| ())
103 .map_err(StreamableHttpError::Client)
104 }
105
106 async fn get_stream(
107 &self,
108 uri: Arc<str>,
109 session_id: Option<Arc<str>>,
110 last_event_id: Option<String>,
111 auth_token: Option<String>,
112 custom_headers: HashMap<HeaderName, HeaderValue>,
113 ) -> Result<BoxStream<'static, Result<Sse, SseError>>, StreamableHttpError<Self::Error>> {
114 let mut request = self
115 .inner
116 .get(uri.as_ref())
117 .header(ACCEPT, [EVENT_STREAM_MIME_TYPE, JSON_MIME_TYPE].join(", "));
118 if let Some(session_id) = session_id {
119 request = request.header(HEADER_SESSION_ID, session_id.as_ref());
120 }
121 if let Some(last_event_id) = last_event_id {
122 request = request.header(HEADER_LAST_EVENT_ID, last_event_id);
123 }
124 if let Some(token) = auth_token {
125 request = request.bearer_auth(token);
126 }
127 let response = apply_custom_headers(request, custom_headers)?
128 .send()
129 .await
130 .map_err(StreamableHttpError::Client)?;
131 if response.status() == reqwest::StatusCode::METHOD_NOT_ALLOWED {
132 return Err(StreamableHttpError::ServerDoesNotSupportSse);
133 }
134 let response = response
135 .error_for_status()
136 .map_err(StreamableHttpError::Client)?;
137 ensure_stream_content_type(&response)?;
138 let capped = per_event_capped_stream(response.bytes_stream(), self.sse_event_max_bytes);
139 Ok(SseStream::from_bytes_stream(capped).boxed())
140 }
141}
142
143fn apply_custom_headers(
144 mut request: reqwest::RequestBuilder,
145 custom_headers: HashMap<HeaderName, HeaderValue>,
146) -> Result<reqwest::RequestBuilder, StreamableHttpError<reqwest::Error>> {
147 for (name, value) in custom_headers {
148 validate_custom_header(&name).map_err(StreamableHttpError::ReservedHeaderConflict)?;
149 request = request.header(name, value);
150 }
151 Ok(request)
152}
153
154fn validate_custom_header(name: &HeaderName) -> Result<(), String> {
155 let reserved = [
156 "accept",
157 HEADER_SESSION_ID,
158 HEADER_LAST_EVENT_ID,
159 HEADER_MCP_PROTOCOL_VERSION,
160 ];
161 if reserved
162 .iter()
163 .any(|reserved| name.as_str().eq_ignore_ascii_case(reserved))
164 && !name
165 .as_str()
166 .eq_ignore_ascii_case(HEADER_MCP_PROTOCOL_VERSION)
167 {
168 return Err(name.to_string());
169 }
170 Ok(())
171}
172
173async fn response_to_post_result(
174 response: reqwest::Response,
175 message: ClientJsonRpcMessage,
176 session_was_attached: bool,
177 max_bytes: usize,
178) -> Result<StreamableHttpPostResponse, StreamableHttpError<reqwest::Error>> {
179 if let Some(error) = auth_error(&response) {
180 return Err(error);
181 }
182 let status = response.status();
183 if matches!(
184 status,
185 reqwest::StatusCode::ACCEPTED | reqwest::StatusCode::NO_CONTENT
186 ) {
187 return Ok(StreamableHttpPostResponse::Accepted);
188 }
189 if status == reqwest::StatusCode::NOT_FOUND && session_was_attached {
190 return Err(StreamableHttpError::SessionExpired);
191 }
192 let content_type = content_type(&response);
193 let session_id = response
194 .headers()
195 .get(HEADER_SESSION_ID)
196 .and_then(|value| value.to_str().ok())
197 .map(ToOwned::to_owned);
198 if status.is_success() && response.content_length() == Some(0) && is_empty_ok(&message) {
199 return Ok(StreamableHttpPostResponse::Accepted);
200 }
201 if !status.is_success() {
202 return non_success_response(status, content_type, response, max_bytes).await;
203 }
204 match content_type.as_deref() {
205 Some(ct) if ct.as_bytes().starts_with(EVENT_STREAM_MIME_TYPE.as_bytes()) => {
206 let capped = per_event_capped_stream(response.bytes_stream(), max_bytes);
207 Ok(StreamableHttpPostResponse::Sse(
208 SseStream::from_bytes_stream(capped).boxed(),
209 session_id,
210 ))
211 }
212 Some(ct) if ct.as_bytes().starts_with(JSON_MIME_TYPE.as_bytes()) => {
213 let bytes = read_body_capped(response, max_bytes).await?;
214 match serde_json::from_slice::<ServerJsonRpcMessage>(&bytes) {
215 Ok(message) => Ok(StreamableHttpPostResponse::Json(message, session_id)),
216 Err(_) => Ok(StreamableHttpPostResponse::Accepted),
217 }
218 }
219 _ => Err(StreamableHttpError::UnexpectedContentType(content_type)),
220 }
221}
222
223fn auth_error(response: &reqwest::Response) -> Option<StreamableHttpError<reqwest::Error>> {
224 let header = response.headers().get(WWW_AUTHENTICATE)?.to_str().ok()?;
225 match response.status() {
226 reqwest::StatusCode::UNAUTHORIZED => Some(StreamableHttpError::AuthRequired(
227 AuthRequiredError::new(header.to_owned()),
228 )),
229 reqwest::StatusCode::FORBIDDEN => Some(StreamableHttpError::InsufficientScope(
230 InsufficientScopeError::new(header.to_owned(), extract_scope(header)),
231 )),
232 _ => None,
233 }
234}
235
236async fn non_success_response(
237 status: reqwest::StatusCode,
238 content_type: Option<String>,
239 response: reqwest::Response,
240 max_bytes: usize,
241) -> Result<StreamableHttpPostResponse, StreamableHttpError<reqwest::Error>> {
242 let bytes = read_body_capped(response, max_bytes).await?;
243 let body = String::from_utf8_lossy(&bytes);
244 if content_type
245 .as_deref()
246 .is_some_and(|ct| ct.as_bytes().starts_with(JSON_MIME_TYPE.as_bytes()))
247 && let Some(message) = parse_json_rpc_error(&body)
248 {
249 return Ok(StreamableHttpPostResponse::Json(message, None));
250 }
251 Err(StreamableHttpError::UnexpectedServerResponse(Cow::Owned(
252 format!("HTTP {status}: {body}"),
253 )))
254}
255
256async fn read_body_capped(
257 response: reqwest::Response,
258 max_bytes: usize,
259) -> Result<Vec<u8>, StreamableHttpError<reqwest::Error>> {
260 if let Some(length) = response.content_length()
261 && length > max_bytes as u64
262 {
263 return Err(too_large(format!(
264 "response_too_large: declared {length} bytes, max {max_bytes}"
265 )));
266 }
267 let mut stream = response.bytes_stream();
268 let mut bytes = Vec::new();
269 while let Some(chunk) = stream.next().await {
270 let chunk = chunk.map_err(StreamableHttpError::Client)?;
271 if bytes.len().saturating_add(chunk.len()) > max_bytes {
272 return Err(too_large(format!(
273 "response_too_large: streamed {} bytes, max {max_bytes}",
274 bytes.len() + chunk.len()
275 )));
276 }
277 bytes.extend_from_slice(&chunk);
278 }
279 Ok(bytes)
280}
281
282fn per_event_capped_stream(
283 inner: impl futures::Stream<Item = reqwest::Result<bytes::Bytes>> + Send + 'static,
284 max_bytes: usize,
285) -> BoxStream<'static, Result<bytes::Bytes, CappedStreamError>> {
286 inner
287 .scan((0usize, false), move |state, item| {
288 let result = match item {
289 Ok(chunk) => account_event_bytes(&chunk, state, max_bytes).map(|_| chunk),
290 Err(error) => Err(CappedStreamError::Reqwest(error)),
291 };
292 futures::future::ready(Some(result))
293 })
294 .boxed()
295}
296
297fn account_event_bytes(
298 chunk: &[u8],
299 state: &mut (usize, bool),
300 max_bytes: usize,
301) -> Result<(), CappedStreamError> {
302 for byte in chunk {
303 if state.1 && *byte == b'\n' {
304 state.0 = 0;
305 state.1 = false;
306 continue;
307 }
308 state.0 = state.0.saturating_add(1);
309 if state.0 > max_bytes {
310 return Err(CappedStreamError::TooLarge {
311 event_bytes: state.0,
312 max_bytes,
313 });
314 }
315 state.1 = *byte == b'\n';
316 }
317 Ok(())
318}
319
320#[derive(Debug)]
321enum CappedStreamError {
322 Reqwest(reqwest::Error),
323 TooLarge {
324 event_bytes: usize,
325 max_bytes: usize,
326 },
327}
328
329impl std::fmt::Display for CappedStreamError {
330 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
331 match self {
332 Self::Reqwest(error) => write!(f, "upstream stream error: {error}"),
333 Self::TooLarge {
334 event_bytes,
335 max_bytes,
336 } => write!(
337 f,
338 "response_too_large: single SSE event reached {event_bytes} bytes, max {max_bytes}"
339 ),
340 }
341 }
342}
343
344impl std::error::Error for CappedStreamError {}
345
346fn ensure_stream_content_type(
347 response: &reqwest::Response,
348) -> Result<(), StreamableHttpError<reqwest::Error>> {
349 match response.headers().get(reqwest::header::CONTENT_TYPE) {
350 Some(value) => {
351 let raw = value.as_bytes();
352 if raw.starts_with(EVENT_STREAM_MIME_TYPE.as_bytes())
353 || raw.starts_with(JSON_MIME_TYPE.as_bytes())
354 {
355 Ok(())
356 } else {
357 Err(StreamableHttpError::UnexpectedContentType(Some(
358 String::from_utf8_lossy(raw).to_string(),
359 )))
360 }
361 }
362 None => Err(StreamableHttpError::UnexpectedContentType(None)),
363 }
364}
365
366fn content_type(response: &reqwest::Response) -> Option<String> {
367 response
368 .headers()
369 .get(reqwest::header::CONTENT_TYPE)
370 .map(|value| String::from_utf8_lossy(value.as_bytes()).to_string())
371}
372
373fn is_empty_ok(message: &ClientJsonRpcMessage) -> bool {
374 matches!(
375 message,
376 ClientJsonRpcMessage::Notification(_)
377 | ClientJsonRpcMessage::Response(_)
378 | ClientJsonRpcMessage::Error(_)
379 )
380}
381
382fn parse_json_rpc_error(body: &str) -> Option<ServerJsonRpcMessage> {
383 match serde_json::from_str::<ServerJsonRpcMessage>(body) {
384 Ok(message @ JsonRpcMessage::Error(_)) => Some(message),
385 _ => None,
386 }
387}
388
389fn extract_scope(header: &str) -> Option<String> {
390 let lower = header.to_ascii_lowercase();
391 let start = lower.find("scope=")? + "scope=".len();
392 let value = &header[start..];
393 if let Some(quoted) = value.strip_prefix('"') {
394 return quoted.split('"').next().map(ToOwned::to_owned);
395 }
396 let end = value
397 .find(|ch: char| ch == ',' || ch == ';' || ch.is_whitespace())
398 .unwrap_or(value.len());
399 (end > 0).then(|| value[..end].to_owned())
400}
401
402fn too_large(message: String) -> StreamableHttpError<reqwest::Error> {
403 StreamableHttpError::UnexpectedServerResponse(Cow::Owned(message))
404}
405
406#[cfg(test)]
407#[path = "http_body_cap_tests.rs"]
408mod tests;