Skip to main content

mcp_streamable_proxy/
detector.rs

1//! Streamable HTTP Protocol Detection
2//!
3//! This module provides a detection function to determine if a given URL
4//! supports the Streamable HTTP MCP protocol.
5
6use reqwest::header::{ACCEPT, CONTENT_TYPE, HeaderMap, HeaderValue};
7use std::time::Duration;
8
9/// Detects if a URL supports the Streamable HTTP protocol
10///
11/// This function sends an MCP Initialize request to the URL and checks the response
12/// characteristics to determine if it's a Streamable HTTP endpoint:
13///
14/// - Presence of `mcp-session-id` response header (Streamable HTTP specific)
15/// - Valid JSON-RPC 2.0 response format
16/// - POST request returning `text/event-stream` (Streamable HTTP characteristic)
17///
18/// # Arguments
19///
20/// * `url` - The URL to test
21///
22/// # Returns
23///
24/// Returns `true` if the URL supports Streamable HTTP protocol, `false` otherwise.
25///
26/// # Example
27///
28/// ```rust,ignore
29/// use mcp_streamable_proxy::is_streamable_http;
30///
31/// if is_streamable_http("http://localhost:8080/mcp").await {
32///     println!("Server supports Streamable HTTP");
33/// }
34/// ```
35pub async fn is_streamable_http(url: &str) -> bool {
36    // Build HTTP client with timeout
37    let client = match reqwest::Client::builder()
38        .timeout(Duration::from_secs(5))
39        .build()
40    {
41        Ok(c) => c,
42        Err(_) => return false,
43    };
44
45    // Construct headers for Streamable HTTP detection
46    let mut headers = HeaderMap::new();
47    headers.insert(
48        ACCEPT,
49        HeaderValue::from_static("application/json, text/event-stream"),
50    );
51    headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
52
53    // Construct an MCP Initialize request using rmcp 0.12 types
54    use rmcp::model::{
55        ClientCapabilities, ClientRequest, Implementation, InitializeRequestParam, ProtocolVersion,
56        Request, RequestId,
57    };
58
59    let init_request = ClientRequest::InitializeRequest(Request::new(InitializeRequestParam {
60        protocol_version: ProtocolVersion::V_2024_11_05,
61        capabilities: ClientCapabilities::default(),
62        client_info: Implementation {
63            name: "mcp-proxy-detector".to_string(),
64            version: "0.1.0".to_string(),
65            title: None,
66            icons: None,
67            website_url: None,
68        },
69    }));
70
71    // Serialize to JSON-RPC message
72    let body = rmcp::model::ClientJsonRpcMessage::request(init_request, RequestId::Number(1));
73
74    // Send POST request and analyze response
75    let response = match client.post(url).headers(headers).json(&body).send().await {
76        Ok(r) => r,
77        Err(_) => return false,
78    };
79
80    let status = response.status();
81    let resp_headers = response.headers().clone();
82
83    // Check 1: Presence of mcp-session-id header (Streamable HTTP specific)
84    if resp_headers.contains_key("mcp-session-id") {
85        return true;
86    }
87
88    // Check 2: POST request returning text/event-stream (Streamable HTTP feature)
89    if let Some(content_type) = resp_headers.get(CONTENT_TYPE) {
90        if let Ok(ct) = content_type.to_str() {
91            if ct.contains("text/event-stream") && status.is_success() {
92                return true;
93            }
94        }
95    }
96
97    // Check 3: Valid JSON-RPC 2.0 response (even if status is not 2xx)
98    if let Ok(json) = response.json::<serde_json::Value>().await {
99        // JSON-RPC 2.0 response must have jsonrpc: "2.0" field
100        let is_jsonrpc = json
101            .get("jsonrpc")
102            .and_then(|v| v.as_str())
103            .map(|v| v == "2.0")
104            .unwrap_or(false);
105
106        if is_jsonrpc {
107            return true;
108        }
109    }
110
111    // Check 4: 406 Not Acceptable might indicate Streamable HTTP expecting specific headers
112    if status == reqwest::StatusCode::NOT_ACCEPTABLE {
113        return true;
114    }
115
116    false
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[tokio::test]
124    async fn test_is_streamable_http_invalid_url() {
125        // Invalid URL should return false without panic
126        let result = is_streamable_http("not-a-url").await;
127        assert!(!result);
128    }
129
130    #[tokio::test]
131    async fn test_is_streamable_http_nonexistent_server() {
132        // Non-existent server should return false
133        let result = is_streamable_http("http://localhost:99999/mcp").await;
134        assert!(!result);
135    }
136
137    // Note: Real integration tests would require a running MCP server
138    // and should be added in separate integration test files
139}