Skip to main content

mcp_streamable_proxy/
detector.rs

1use reqwest::header::{ACCEPT, CONTENT_TYPE, HeaderMap, HeaderValue};
2use std::collections::HashMap;
3use std::time::Duration;
4
5/// Detect if a URL supports the Streamable HTTP protocol (backward compatible, no custom headers)
6///
7/// This is a convenience wrapper around [`is_streamable_http_with_headers`] that passes no
8/// custom headers. See that function for full documentation.
9///
10/// # Example
11///
12/// ```rust,ignore
13/// use mcp_streamable_proxy::is_streamable_http;
14///
15/// if is_streamable_http("http://localhost:8080/mcp").await {
16///     println!("Server supports Streamable HTTP");
17/// }
18/// ```
19pub async fn is_streamable_http(url: &str) -> bool {
20    is_streamable_http_with_headers(url, None).await
21}
22
23/// Detect if a URL supports the Streamable HTTP protocol, with optional custom headers
24///
25/// This detection works by sending an MCP Initialize request
26/// and checking the response characteristics.
27///
28/// Custom headers (e.g., `Authorization`) are merged into the detection request,
29/// which is essential for MCP services that require authentication.
30///
31/// # Detection characteristics
32///
33/// - Presence of `mcp-session-id` response header (Streamable HTTP specific)
34/// - Valid JSON-RPC 2.0 response format
35/// - POST request returning `text/event-stream` (Streamable HTTP feature)
36///
37/// # Arguments
38///
39/// * `url` - The URL to test
40/// * `custom_headers` - Optional custom headers to include in the detection request
41///
42/// # Returns
43///
44/// Returns `true` if the URL supports Streamable HTTP protocol, `false` otherwise.
45pub async fn is_streamable_http_with_headers(
46    url: &str,
47    custom_headers: Option<&HashMap<String, String>>,
48) -> bool {
49    // Build HTTP client with timeout
50    let client = match reqwest::Client::builder()
51        .timeout(Duration::from_secs(5))
52        .build()
53    {
54        Ok(c) => c,
55        Err(_) => return false,
56    };
57
58    // Construct headers for Streamable HTTP detection
59    let mut headers = HeaderMap::new();
60    headers.insert(
61        ACCEPT,
62        HeaderValue::from_static("application/json, text/event-stream"),
63    );
64    headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
65
66    // Merge custom headers (e.g., Authorization)
67    if let Some(custom) = custom_headers {
68        for (key, value) in custom {
69            if let (Ok(name), Ok(val)) = (
70                reqwest::header::HeaderName::try_from(key.as_str()),
71                HeaderValue::from_str(value),
72            ) {
73                headers.insert(name, val);
74            }
75        }
76    }
77
78    // Construct an MCP Initialize request using rmcp 1.1.0 types
79    use rmcp::model::{
80        ClientCapabilities, ClientRequest, Implementation, InitializeRequestParams,
81        ProtocolVersion, Request, RequestId,
82    };
83
84    let init_request = ClientRequest::InitializeRequest(Request::new(
85        InitializeRequestParams::new(
86            ClientCapabilities::default(),
87            Implementation::new("mcp-proxy-detector", "0.1.0"),
88        )
89        .with_protocol_version(ProtocolVersion::V_2024_11_05),
90    ));
91
92    // Serialize to JSON-RPC message
93    let body = rmcp::model::ClientJsonRpcMessage::request(init_request, RequestId::Number(1));
94
95    // Send POST request and analyze response
96    let response = match client.post(url).headers(headers).json(&body).send().await {
97        Ok(r) => r,
98        Err(_) => return false,
99    };
100
101    let status = response.status();
102    let resp_headers = response.headers().clone();
103
104    // Check 1: Presence of mcp-session-id header (Streamable HTTP specific)
105    if resp_headers.contains_key("mcp-session-id") {
106        return true;
107    }
108    // Check 2: POST request returning text/event-stream (Streamable HTTP feature)
109    if let Some(content_type) = resp_headers.get(CONTENT_TYPE)
110        && let Ok(ct) = content_type.to_str()
111        && ct.contains("text/event-stream")
112        && status.is_success()
113    {
114        return true;
115    }
116    // Check 3: Valid JSON-RPC 2.0 response (even if status is not 2xx)
117    if let Ok(json) = response.json::<serde_json::Value>().await {
118        // JSON-RPC 2.0 response must have jsonrpc: "2.0" field
119        let is_jsonrpc = json
120            .get("jsonrpc")
121            .and_then(|v| v.as_str())
122            .map(|v| v == "2.0")
123            .unwrap_or(false);
124        if is_jsonrpc {
125            return true;
126        }
127    }
128
129    // Check 4: 406 Not Acceptable might indicate Streamable HTTP expecting specific headers
130    if status == reqwest::StatusCode::NOT_ACCEPTABLE {
131        return true;
132    }
133
134    false
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[tokio::test]
142    async fn test_is_streamable_http_with_headers_backward_compatible() {
143        // With None headers should behave identically to is_streamable_http
144        let result = is_streamable_http_with_headers("http://localhost:99999/mcp", None).await;
145        assert!(!result);
146    }
147
148    #[tokio::test]
149    async fn test_is_streamable_http_with_headers_no_panic() {
150        // Non-existent server, but validates headers don't cause panics
151        let mut headers = HashMap::new();
152        headers.insert("Authorization".to_string(), "Bearer test-token".to_string());
153        let result =
154            is_streamable_http_with_headers("http://localhost:99999/mcp", Some(&headers)).await;
155        assert!(!result);
156    }
157}