提交qiming-mcp-proxy
This commit is contained in:
311
qiming-mcp-proxy/mcp-streamable-proxy/src/client.rs
Normal file
311
qiming-mcp-proxy/mcp-streamable-proxy/src/client.rs
Normal file
@@ -0,0 +1,311 @@
|
||||
//! Streamable HTTP Client Connection Module
|
||||
//!
|
||||
//! Provides a high-level API for connecting to MCP servers via Streamable HTTP protocol.
|
||||
//! This module encapsulates the rmcp 0.12 transport details and exposes a simple interface.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use mcp_common::McpClientConfig;
|
||||
use rmcp::{
|
||||
RoleClient, ServiceExt,
|
||||
model::{ClientCapabilities, ClientInfo, Implementation},
|
||||
service::RunningService,
|
||||
transport::{
|
||||
common::client_side_sse::SseRetryPolicy,
|
||||
streamable_http_client::{
|
||||
StreamableHttpClientTransport, StreamableHttpClientTransportConfig,
|
||||
},
|
||||
},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::proxy_handler::ProxyHandler;
|
||||
use mcp_common::ToolFilter;
|
||||
|
||||
/// 自定义的指数退避重试策略,支持最大间隔限制
|
||||
///
|
||||
/// 重试间隔按照指数增长,但不会超过 max_interval
|
||||
/// - 第 1 次重试:base_duration × 2^0
|
||||
/// - 第 2 次重试:base_duration × 2^1
|
||||
/// - ...
|
||||
/// - 第 n 次重试:min(base_duration × 2^(n-1), max_interval)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CappedExponentialBackoff {
|
||||
/// 最大重试次数,None 表示无限制
|
||||
pub max_times: Option<usize>,
|
||||
/// 基础延迟时间(第一次重试前的等待时间)
|
||||
pub base_duration: Duration,
|
||||
/// 最大延迟间隔(重试间隔不会超过这个值)
|
||||
pub max_interval: Duration,
|
||||
}
|
||||
|
||||
impl CappedExponentialBackoff {
|
||||
/// 创建一个新的带上限的指数退避策略
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `max_times` - 最大重试次数,None 表示无限制
|
||||
/// * `base_duration` - 基础延迟时间
|
||||
/// * `max_interval` - 最大延迟间隔
|
||||
pub fn new(max_times: Option<usize>, base_duration: Duration, max_interval: Duration) -> Self {
|
||||
Self {
|
||||
max_times,
|
||||
base_duration,
|
||||
max_interval,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CappedExponentialBackoff {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_times: None,
|
||||
base_duration: Duration::from_secs(1),
|
||||
max_interval: Duration::from_secs(60),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SseRetryPolicy for CappedExponentialBackoff {
|
||||
fn retry(&self, current_times: usize) -> Option<Duration> {
|
||||
// 检查是否超过最大重试次数
|
||||
if let Some(max_times) = self.max_times
|
||||
&& current_times >= max_times
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
// 计算指数退避时间
|
||||
let exponential_delay = self.base_duration * (2u32.pow(current_times as u32));
|
||||
|
||||
// 限制最大间隔
|
||||
Some(exponential_delay.min(self.max_interval))
|
||||
}
|
||||
}
|
||||
|
||||
/// Opaque wrapper for Streamable HTTP client connection
|
||||
///
|
||||
/// This type encapsulates an active connection to an MCP server via Streamable HTTP protocol.
|
||||
/// It hides the internal `RunningService` type and provides only the methods
|
||||
/// needed by consuming code.
|
||||
///
|
||||
/// Note: This type is not Clone because the underlying RunningService
|
||||
/// is designed for single-owner use. Use `into_handler()` or `into_running_service()`
|
||||
/// to consume the connection.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use mcp_streamable_proxy::{StreamClientConnection, McpClientConfig};
|
||||
///
|
||||
/// let config = McpClientConfig::new("http://localhost:8080/mcp")
|
||||
/// .with_header("Authorization", "Bearer token");
|
||||
///
|
||||
/// let conn = StreamClientConnection::connect(config).await?;
|
||||
/// let tools = conn.list_tools().await?;
|
||||
/// println!("Available tools: {:?}", tools);
|
||||
/// ```
|
||||
pub struct StreamClientConnection {
|
||||
inner: RunningService<RoleClient, ClientInfo>,
|
||||
}
|
||||
|
||||
impl StreamClientConnection {
|
||||
/// Connect to a Streamable HTTP MCP server
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `config` - Client configuration including URL and headers
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(StreamClientConnection)` - Successfully connected client
|
||||
/// * `Err` - Connection failed
|
||||
pub async fn connect(config: McpClientConfig) -> Result<Self> {
|
||||
let http_client = build_http_client(&config)?;
|
||||
|
||||
// 配置指数退避重试策略,最大间隔 1 分钟,不限制重试次数
|
||||
let retry_policy = CappedExponentialBackoff::new(
|
||||
None, // 不限制重试次数
|
||||
Duration::from_secs(1), // 基础延迟 1 秒
|
||||
Duration::from_secs(60), // 最大间隔 60 秒
|
||||
);
|
||||
|
||||
let mut transport_config = StreamableHttpClientTransportConfig::with_uri(config.url.clone());
|
||||
transport_config.retry_config = Arc::new(retry_policy);
|
||||
|
||||
let transport = StreamableHttpClientTransport::with_client(http_client, transport_config);
|
||||
|
||||
let client_info = create_default_client_info();
|
||||
let running = client_info
|
||||
.serve(transport)
|
||||
.await
|
||||
.context("Failed to initialize MCP client")?;
|
||||
|
||||
Ok(Self { inner: running })
|
||||
}
|
||||
|
||||
/// List available tools from the MCP server
|
||||
pub async fn list_tools(&self) -> Result<Vec<ToolInfo>> {
|
||||
let result = self.inner.list_tools(None).await?;
|
||||
Ok(result
|
||||
.tools
|
||||
.into_iter()
|
||||
.map(|t| ToolInfo {
|
||||
name: t.name.to_string(),
|
||||
description: t.description.map(|d| d.to_string()),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Check if the connection is closed
|
||||
pub fn is_closed(&self) -> bool {
|
||||
use std::ops::Deref;
|
||||
self.inner.deref().is_transport_closed()
|
||||
}
|
||||
|
||||
/// Get the peer info from the server
|
||||
pub fn peer_info(&self) -> Option<&rmcp::model::ServerInfo> {
|
||||
self.inner.peer_info()
|
||||
}
|
||||
|
||||
/// Convert this connection into a ProxyHandler for serving
|
||||
///
|
||||
/// This consumes the connection and creates a ProxyHandler that can
|
||||
/// proxy requests to the backend MCP server.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `mcp_id` - Identifier for logging purposes
|
||||
/// * `tool_filter` - Tool filtering configuration
|
||||
pub fn into_handler(self, mcp_id: String, tool_filter: ToolFilter) -> ProxyHandler {
|
||||
ProxyHandler::with_tool_filter(self.inner, mcp_id, tool_filter)
|
||||
}
|
||||
|
||||
/// Extract the internal RunningService for use with swap_backend
|
||||
///
|
||||
/// This is used internally to support backend hot-swapping.
|
||||
pub fn into_running_service(self) -> RunningService<RoleClient, ClientInfo> {
|
||||
self.inner
|
||||
}
|
||||
}
|
||||
|
||||
/// Simplified tool information
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ToolInfo {
|
||||
/// Tool name
|
||||
pub name: String,
|
||||
/// Tool description (optional)
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// Build an HTTP client with the given configuration
|
||||
fn build_http_client(config: &McpClientConfig) -> Result<reqwest::Client> {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
for (key, value) in &config.headers {
|
||||
let header_name = key
|
||||
.parse::<reqwest::header::HeaderName>()
|
||||
.with_context(|| format!("Invalid header name: {}", key))?;
|
||||
let header_value = value
|
||||
.parse()
|
||||
.with_context(|| format!("Invalid header value for {}: {}", key, value))?;
|
||||
headers.insert(header_name, header_value);
|
||||
}
|
||||
|
||||
let mut builder = reqwest::Client::builder().default_headers(headers);
|
||||
|
||||
if let Some(timeout) = config.connect_timeout {
|
||||
builder = builder.connect_timeout(timeout);
|
||||
}
|
||||
|
||||
if let Some(timeout) = config.read_timeout {
|
||||
builder = builder.timeout(timeout);
|
||||
}
|
||||
|
||||
builder.build().context("Failed to build HTTP client")
|
||||
}
|
||||
|
||||
/// Create default client info for MCP handshake
|
||||
fn create_default_client_info() -> ClientInfo {
|
||||
let capabilities = ClientCapabilities::builder()
|
||||
.enable_experimental()
|
||||
.enable_roots()
|
||||
.enable_roots_list_changed()
|
||||
.enable_sampling()
|
||||
.build();
|
||||
ClientInfo::new(
|
||||
capabilities,
|
||||
Implementation::new("mcp-streamable-proxy-client", env!("CARGO_PKG_VERSION")),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_tool_info() {
|
||||
let info = ToolInfo {
|
||||
name: "test_tool".to_string(),
|
||||
description: Some("A test tool".to_string()),
|
||||
};
|
||||
assert_eq!(info.name, "test_tool");
|
||||
assert_eq!(info.description, Some("A test tool".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_capped_exponential_backoff() {
|
||||
// 测试带上限的指数退避策略
|
||||
let policy = CappedExponentialBackoff::new(
|
||||
None, // 不限制重试次数
|
||||
Duration::from_secs(1), // 基础延迟 1 秒
|
||||
Duration::from_secs(60), // 最大间隔 60 秒
|
||||
);
|
||||
|
||||
// 验证第 1 次重试:1 秒
|
||||
assert_eq!(policy.retry(0), Some(Duration::from_secs(1)));
|
||||
// 验证第 2 次重试:2 秒
|
||||
assert_eq!(policy.retry(1), Some(Duration::from_secs(2)));
|
||||
// 验证第 3 次重试:4 秒
|
||||
assert_eq!(policy.retry(2), Some(Duration::from_secs(4)));
|
||||
// 验证第 4 次重试:8 秒
|
||||
assert_eq!(policy.retry(3), Some(Duration::from_secs(8)));
|
||||
// 验证第 5 次重试:16 秒
|
||||
assert_eq!(policy.retry(4), Some(Duration::from_secs(16)));
|
||||
// 验证第 6 次重试:32 秒
|
||||
assert_eq!(policy.retry(5), Some(Duration::from_secs(32)));
|
||||
// 验证第 7 次重试:64 秒 -> 会被限制为 60 秒
|
||||
assert_eq!(policy.retry(6), Some(Duration::from_secs(60)));
|
||||
// 验证第 8 次重试:128 秒 -> 会被限制为 60 秒
|
||||
assert_eq!(policy.retry(7), Some(Duration::from_secs(60)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_capped_exponential_backoff_with_max_times() {
|
||||
// 测试带最大重试次数的限制
|
||||
let policy = CappedExponentialBackoff::new(
|
||||
Some(3), // 最多重试 3 次
|
||||
Duration::from_secs(1), // 基础延迟 1 秒
|
||||
Duration::from_secs(60), // 最大间隔 60 秒
|
||||
);
|
||||
|
||||
// 验证前 3 次重试都有延迟时间
|
||||
assert_eq!(policy.retry(0), Some(Duration::from_secs(1)));
|
||||
assert_eq!(policy.retry(1), Some(Duration::from_secs(2)));
|
||||
assert_eq!(policy.retry(2), Some(Duration::from_secs(4)));
|
||||
|
||||
// 验证第 4 次重试(重试次数已达到上限)
|
||||
assert_eq!(policy.retry(3), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_capped_exponential_backoff_default() {
|
||||
// 测试默认配置
|
||||
let policy = CappedExponentialBackoff::default();
|
||||
|
||||
// 验证默认配置
|
||||
assert_eq!(policy.max_times, None);
|
||||
assert_eq!(policy.base_duration, Duration::from_secs(1));
|
||||
assert_eq!(policy.max_interval, Duration::from_secs(60));
|
||||
|
||||
// 验证重试行为
|
||||
assert_eq!(policy.retry(0), Some(Duration::from_secs(1)));
|
||||
assert_eq!(policy.retry(5), Some(Duration::from_secs(32)));
|
||||
assert_eq!(policy.retry(10), Some(Duration::from_secs(60)));
|
||||
}
|
||||
}
|
||||
37
qiming-mcp-proxy/mcp-streamable-proxy/src/config.rs
Normal file
37
qiming-mcp-proxy/mcp-streamable-proxy/src/config.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
//! Configuration types for Streamable HTTP proxy
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Streamable HTTP server configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StreamableConfig {
|
||||
/// Bind address for the HTTP server
|
||||
#[serde(default = "default_bind_addr")]
|
||||
pub bind_addr: String,
|
||||
|
||||
/// Enable stateful mode (session management)
|
||||
#[serde(default = "default_stateful_mode")]
|
||||
pub stateful_mode: bool,
|
||||
|
||||
/// Quiet mode (suppress startup messages)
|
||||
#[serde(default)]
|
||||
pub quiet: bool,
|
||||
}
|
||||
|
||||
impl Default for StreamableConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
bind_addr: default_bind_addr(),
|
||||
stateful_mode: default_stateful_mode(),
|
||||
quiet: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_bind_addr() -> String {
|
||||
"127.0.0.1:3000".to_string()
|
||||
}
|
||||
|
||||
fn default_stateful_mode() -> bool {
|
||||
true // Enable stateful mode by default for this module
|
||||
}
|
||||
157
qiming-mcp-proxy/mcp-streamable-proxy/src/detector.rs
Normal file
157
qiming-mcp-proxy/mcp-streamable-proxy/src/detector.rs
Normal file
@@ -0,0 +1,157 @@
|
||||
use reqwest::header::{ACCEPT, CONTENT_TYPE, HeaderMap, HeaderValue};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Detect if a URL supports the Streamable HTTP protocol (backward compatible, no custom headers)
|
||||
///
|
||||
/// This is a convenience wrapper around [`is_streamable_http_with_headers`] that passes no
|
||||
/// custom headers. See that function for full documentation.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use mcp_streamable_proxy::is_streamable_http;
|
||||
///
|
||||
/// if is_streamable_http("http://localhost:8080/mcp").await {
|
||||
/// println!("Server supports Streamable HTTP");
|
||||
/// }
|
||||
/// ```
|
||||
pub async fn is_streamable_http(url: &str) -> bool {
|
||||
is_streamable_http_with_headers(url, None).await
|
||||
}
|
||||
|
||||
/// Detect if a URL supports the Streamable HTTP protocol, with optional custom headers
|
||||
///
|
||||
/// This detection works by sending an MCP Initialize request
|
||||
/// and checking the response characteristics.
|
||||
///
|
||||
/// Custom headers (e.g., `Authorization`) are merged into the detection request,
|
||||
/// which is essential for MCP services that require authentication.
|
||||
///
|
||||
/// # Detection characteristics
|
||||
///
|
||||
/// - Presence of `mcp-session-id` response header (Streamable HTTP specific)
|
||||
/// - Valid JSON-RPC 2.0 response format
|
||||
/// - POST request returning `text/event-stream` (Streamable HTTP feature)
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `url` - The URL to test
|
||||
/// * `custom_headers` - Optional custom headers to include in the detection request
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `true` if the URL supports Streamable HTTP protocol, `false` otherwise.
|
||||
pub async fn is_streamable_http_with_headers(
|
||||
url: &str,
|
||||
custom_headers: Option<&HashMap<String, String>>,
|
||||
) -> bool {
|
||||
// Build HTTP client with timeout
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
// Construct headers for Streamable HTTP detection
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
ACCEPT,
|
||||
HeaderValue::from_static("application/json, text/event-stream"),
|
||||
);
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
|
||||
// Merge custom headers (e.g., Authorization)
|
||||
if let Some(custom) = custom_headers {
|
||||
for (key, value) in custom {
|
||||
if let (Ok(name), Ok(val)) = (
|
||||
reqwest::header::HeaderName::try_from(key.as_str()),
|
||||
HeaderValue::from_str(value),
|
||||
) {
|
||||
headers.insert(name, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Construct an MCP Initialize request using rmcp 1.1.0 types
|
||||
use rmcp::model::{
|
||||
ClientCapabilities, ClientRequest, Implementation, InitializeRequestParams,
|
||||
ProtocolVersion, Request, RequestId,
|
||||
};
|
||||
|
||||
let init_request = ClientRequest::InitializeRequest(Request::new(
|
||||
InitializeRequestParams::new(
|
||||
ClientCapabilities::default(),
|
||||
Implementation::new("mcp-proxy-detector", "0.1.0"),
|
||||
)
|
||||
.with_protocol_version(ProtocolVersion::V_2024_11_05),
|
||||
));
|
||||
|
||||
// Serialize to JSON-RPC message
|
||||
let body = rmcp::model::ClientJsonRpcMessage::request(init_request, RequestId::Number(1));
|
||||
|
||||
// Send POST request and analyze response
|
||||
let response = match client.post(url).headers(headers).json(&body).send().await {
|
||||
Ok(r) => r,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
let status = response.status();
|
||||
let resp_headers = response.headers().clone();
|
||||
|
||||
// Check 1: Presence of mcp-session-id header (Streamable HTTP specific)
|
||||
if resp_headers.contains_key("mcp-session-id") {
|
||||
return true;
|
||||
}
|
||||
// Check 2: POST request returning text/event-stream (Streamable HTTP feature)
|
||||
if let Some(content_type) = resp_headers.get(CONTENT_TYPE)
|
||||
&& let Ok(ct) = content_type.to_str()
|
||||
&& ct.contains("text/event-stream")
|
||||
&& status.is_success()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// Check 3: Valid JSON-RPC 2.0 response (even if status is not 2xx)
|
||||
if let Ok(json) = response.json::<serde_json::Value>().await {
|
||||
// JSON-RPC 2.0 response must have jsonrpc: "2.0" field
|
||||
let is_jsonrpc = json
|
||||
.get("jsonrpc")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|v| v == "2.0")
|
||||
.unwrap_or(false);
|
||||
if is_jsonrpc {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check 4: 406 Not Acceptable might indicate Streamable HTTP expecting specific headers
|
||||
if status == reqwest::StatusCode::NOT_ACCEPTABLE {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_is_streamable_http_with_headers_backward_compatible() {
|
||||
// With None headers should behave identically to is_streamable_http
|
||||
let result = is_streamable_http_with_headers("http://localhost:99999/mcp", None).await;
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_is_streamable_http_with_headers_no_panic() {
|
||||
// Non-existent server, but validates headers don't cause panics
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("Authorization".to_string(), "Bearer test-token".to_string());
|
||||
let result =
|
||||
is_streamable_http_with_headers("http://localhost:99999/mcp", Some(&headers)).await;
|
||||
assert!(!result);
|
||||
}
|
||||
}
|
||||
79
qiming-mcp-proxy/mcp-streamable-proxy/src/lib.rs
Normal file
79
qiming-mcp-proxy/mcp-streamable-proxy/src/lib.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
//! MCP Streamable HTTP Proxy Module
|
||||
//!
|
||||
//! This module provides a proxy implementation for MCP (Model Context Protocol)
|
||||
//! using Streamable HTTP transport with stateful session management.
|
||||
//!
|
||||
//! # Features
|
||||
//!
|
||||
//! - **Streamable HTTP Support**: Uses rmcp 0.12 with enhanced Streamable HTTP transport
|
||||
//! - **Stateful Sessions**: Custom SessionManager with backend version tracking
|
||||
//! - **Hot Swap**: Supports backend connection replacement without downtime
|
||||
//! - **Version Control**: Automatically invalidates sessions when backend reconnects
|
||||
//! - **High-level Client API**: Simple connection interface hiding transport details
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! Client → Streamable HTTP → ProxyAwareSessionManager → ProxyHandler → Backend MCP Service
|
||||
//! ↓
|
||||
//! Version Tracking
|
||||
//! (DashMap<SessionId, BackendVersion>)
|
||||
//! ```
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! use mcp_streamable_proxy::{StreamClientConnection, McpClientConfig};
|
||||
//!
|
||||
//! // Connect to an MCP server
|
||||
//! let config = McpClientConfig::new("http://localhost:8080/mcp");
|
||||
//! let conn = StreamClientConnection::connect(config).await?;
|
||||
//!
|
||||
//! // List available tools
|
||||
//! let tools = conn.list_tools().await?;
|
||||
//! ```
|
||||
|
||||
pub mod client;
|
||||
pub mod config;
|
||||
pub mod detector;
|
||||
pub mod proxy_handler;
|
||||
pub mod server;
|
||||
pub mod server_builder;
|
||||
pub mod session_manager;
|
||||
|
||||
// Re-export main types
|
||||
pub use mcp_common::McpServiceConfig;
|
||||
pub use proxy_handler::{ProxyHandler, ToolFilter};
|
||||
pub use server::{run_stream_server, run_stream_server_from_config};
|
||||
pub use session_manager::ProxyAwareSessionManager;
|
||||
|
||||
// Re-export protocol detection function
|
||||
pub use detector::{is_streamable_http, is_streamable_http_with_headers};
|
||||
|
||||
// Re-export server builder API
|
||||
pub use server_builder::{BackendConfig, StreamServerBuilder, StreamServerConfig};
|
||||
|
||||
// Re-export client connection types
|
||||
pub use client::{StreamClientConnection, ToolInfo};
|
||||
pub use mcp_common::McpClientConfig;
|
||||
|
||||
// Re-export commonly used rmcp types
|
||||
pub use rmcp::{
|
||||
RoleClient, RoleServer, ServerHandler, ServiceExt,
|
||||
model::{ClientCapabilities, ClientInfo, Implementation, ServerInfo},
|
||||
service::{Peer, RunningService},
|
||||
};
|
||||
|
||||
// Re-export transport types for Streamable HTTP protocol (rmcp 0.12)
|
||||
pub use rmcp::transport::{
|
||||
StreamableHttpServerConfig,
|
||||
child_process::TokioChildProcess,
|
||||
stdio, // stdio transport for CLI mode
|
||||
streamable_http_client::StreamableHttpClientTransport,
|
||||
streamable_http_client::StreamableHttpClientTransportConfig,
|
||||
};
|
||||
|
||||
// Re-export server-side Streamable HTTP types
|
||||
pub use rmcp::transport::streamable_http_server::{
|
||||
StreamableHttpService, session::local::LocalSessionManager,
|
||||
};
|
||||
1136
qiming-mcp-proxy/mcp-streamable-proxy/src/proxy_handler.rs
Normal file
1136
qiming-mcp-proxy/mcp-streamable-proxy/src/proxy_handler.rs
Normal file
File diff suppressed because it is too large
Load Diff
270
qiming-mcp-proxy/mcp-streamable-proxy/src/server.rs
Normal file
270
qiming-mcp-proxy/mcp-streamable-proxy/src/server.rs
Normal file
@@ -0,0 +1,270 @@
|
||||
//! Streamable HTTP server implementation
|
||||
//!
|
||||
//! This module provides the HTTP server that uses ProxyAwareSessionManager
|
||||
//! for stateful session management with backend version control.
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use mcp_common::{McpServiceConfig, check_windows_command, wrap_process_v9};
|
||||
use rmcp::{
|
||||
ServiceExt,
|
||||
model::{ClientCapabilities, ClientInfo},
|
||||
transport::{
|
||||
TokioChildProcess,
|
||||
streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService},
|
||||
},
|
||||
};
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
// 进程组管理(跨平台子进程清理)
|
||||
// process-wrap 9.0 使用 CommandWrap 而不是 TokioCommandWrap
|
||||
use process_wrap::tokio::{CommandWrap, KillOnDrop};
|
||||
|
||||
use crate::{ProxyAwareSessionManager, ProxyHandler};
|
||||
|
||||
/// 从配置启动 Streamable HTTP 服务器
|
||||
///
|
||||
/// # Features
|
||||
///
|
||||
/// - **Stateful Mode**: `stateful_mode: true` 支持 session 管理和服务端推送
|
||||
/// - **Version Control**: 自动检测后端重连,使旧 session 失效
|
||||
/// - **Full Lifecycle**: 自动创建子进程、连接、handler、服务器
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `config` - MCP 服务配置
|
||||
/// * `std_listener` - 预先绑定的 TCP 监听器(端口在重试循环前绑定,保证端口占用)
|
||||
/// * `quiet` - 静默模式,不输出启动信息
|
||||
pub async fn run_stream_server_from_config(
|
||||
config: McpServiceConfig,
|
||||
std_listener: &std::net::TcpListener,
|
||||
quiet: bool,
|
||||
) -> Result<()> {
|
||||
// 1. 使用 process-wrap 创建子进程命令(跨平台进程清理)
|
||||
// process-wrap 会自动处理进程组(Unix)或 Job Object(Windows)
|
||||
// 并且在 Drop 时自动清理子进程树
|
||||
// 子进程默认继承父进程的所有环境变量
|
||||
|
||||
// 🔧 Windows 特殊处理:检测并转换 .cmd/.bat 文件避免弹窗
|
||||
// 如果用户配置了 npm 全局安装的 MCP 服务(如 npx some-server 或 some-server.cmd),
|
||||
// 直接运行会弹 CMD 窗口。这里尝试转换
|
||||
check_windows_command(&config.command);
|
||||
|
||||
info!(
|
||||
"[Subprocess][{}] Command: {} {:?}",
|
||||
config.name,
|
||||
config.command,
|
||||
config.args.as_ref().unwrap_or(&vec![])
|
||||
);
|
||||
|
||||
let mut wrapped_cmd = CommandWrap::with_new(&config.command, |command| {
|
||||
if let Some(ref cmd_args) = config.args {
|
||||
command.args(cmd_args);
|
||||
}
|
||||
// 子进程默认继承父进程的所有环境变量
|
||||
// 设置 MCP JSON 配置中的环境变量(会覆盖继承的同名变量)
|
||||
if let Some(ref env_vars) = config.env {
|
||||
for (k, v) in env_vars {
|
||||
command.env(k, v);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 应用平台特定的进程包装(Unix: ProcessGroup, Windows: CREATE_NO_WINDOW + JobObject)
|
||||
wrap_process_v9!(wrapped_cmd);
|
||||
|
||||
// 所有平台: Drop 时自动清理进程
|
||||
wrapped_cmd.wrap(KillOnDrop);
|
||||
|
||||
// 2. 启动子进程(rmcp 的 TokioChildProcess 已经支持 process-wrap)
|
||||
// 使用 builder 模式捕获 stderr,便于诊断子 MCP 服务初始化失败
|
||||
let (tokio_process, child_stderr) = TokioChildProcess::builder(wrapped_cmd)
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
|
||||
// 启动 stderr 日志读取任务
|
||||
if let Some(stderr_pipe) = child_stderr {
|
||||
mcp_common::spawn_stderr_reader(stderr_pipe, config.name.clone());
|
||||
}
|
||||
|
||||
// 3. 创建客户端信息
|
||||
let capabilities = ClientCapabilities::builder()
|
||||
.enable_experimental()
|
||||
.enable_roots()
|
||||
.enable_roots_list_changed()
|
||||
.enable_sampling()
|
||||
.build();
|
||||
let client_info = ClientInfo::new(
|
||||
capabilities,
|
||||
rmcp::model::Implementation::new("mcp-streamable-proxy-server", env!("CARGO_PKG_VERSION")),
|
||||
);
|
||||
|
||||
// 4. 连接到子进程
|
||||
let client = client_info.serve(tokio_process).await?;
|
||||
|
||||
// 记录子进程启动到日志文件
|
||||
info!(
|
||||
"[Subprocess startup] Streamable HTTP - Service name: {}, Command: {} {:?}",
|
||||
config.name,
|
||||
config.command,
|
||||
config.args.as_ref().unwrap_or(&vec![])
|
||||
);
|
||||
|
||||
if !quiet {
|
||||
eprintln!("✅ The child process has been started");
|
||||
|
||||
// 获取并打印工具列表
|
||||
match client.list_tools(None).await {
|
||||
Ok(tools_result) => {
|
||||
let tools = &tools_result.tools;
|
||||
if tools.is_empty() {
|
||||
warn!(
|
||||
"[Tool list] Tool list is empty - Service name: {}",
|
||||
config.name
|
||||
);
|
||||
eprintln!("⚠️Tool list is empty");
|
||||
} else {
|
||||
info!(
|
||||
"[Tool list] Service name: {}, Number of tools: {}",
|
||||
config.name,
|
||||
tools.len()
|
||||
);
|
||||
eprintln!("🔧 Available tools ({}):", tools.len());
|
||||
for tool in tools.iter().take(10) {
|
||||
let desc = tool.description.as_deref().unwrap_or("无描述");
|
||||
let desc_short = if desc.len() > 50 {
|
||||
format!("{}...", &desc[..50])
|
||||
} else {
|
||||
desc.to_string()
|
||||
};
|
||||
eprintln!(" - {} : {}", tool.name, desc_short);
|
||||
}
|
||||
if tools.len() > 10 {
|
||||
eprintln!("... and {} other tools", tools.len() - 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"[Tool List] Failed to obtain tool list - Service name: {}, Error: {}",
|
||||
config.name, e
|
||||
);
|
||||
eprintln!("⚠️ Failed to obtain tool list: {}", e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 即使静默模式也记录日志
|
||||
match client.list_tools(None).await {
|
||||
Ok(tools_result) => {
|
||||
info!(
|
||||
"[Tool list] Service name: {}, Number of tools: {}",
|
||||
config.name,
|
||||
tools_result.tools.len()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"[Tool List] Failed to obtain tool list - Service name: {}, Error: {}",
|
||||
config.name, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 创建 ProxyHandler
|
||||
let proxy_handler = if let Some(tool_filter) = config.tool_filter {
|
||||
ProxyHandler::with_tool_filter(client, config.name.clone(), tool_filter)
|
||||
} else {
|
||||
ProxyHandler::with_mcp_id(client, config.name.clone())
|
||||
};
|
||||
|
||||
// 6. 启动服务器(使用预绑定的 listener)
|
||||
let listener = tokio::net::TcpListener::from_std(std_listener.try_clone()?)?;
|
||||
run_stream_server(proxy_handler, listener, quiet).await
|
||||
}
|
||||
|
||||
/// Run Streamable HTTP server with ProxyAwareSessionManager
|
||||
///
|
||||
/// # Features
|
||||
///
|
||||
/// - **Stateful Mode**: `stateful_mode: true` 支持 session 管理和服务端推送
|
||||
/// - **Version Control**: 自动检测后端重连,使旧 session 失效
|
||||
/// - **Hot Swap**: 支持后端连接热替换
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `proxy_handler` - ProxyHandler 实例(包含后端版本控制)
|
||||
/// * `listener` - 已绑定的 tokio TcpListener
|
||||
/// * `quiet` - 静默模式,不输出启动信息
|
||||
pub async fn run_stream_server(
|
||||
proxy_handler: ProxyHandler,
|
||||
listener: tokio::net::TcpListener,
|
||||
quiet: bool,
|
||||
) -> Result<()> {
|
||||
let bind_addr = listener
|
||||
.local_addr()
|
||||
.map(|a| a.to_string())
|
||||
.unwrap_or_else(|_| "<unknown>".to_string());
|
||||
let mcp_id = proxy_handler.mcp_id().to_string();
|
||||
|
||||
// 记录服务启动到日志文件
|
||||
info!(
|
||||
"[HTTP service startup] Streamable HTTP service startup - Address: {}, MCP ID: {}",
|
||||
bind_addr, mcp_id
|
||||
);
|
||||
|
||||
if !quiet {
|
||||
eprintln!("📡 Streamable HTTP service startup: http://{}", bind_addr);
|
||||
eprintln!("💡 MCP client can be used directly: http://{}", bind_addr);
|
||||
eprintln!("✨ Feature: stateful_mode (session management + server push)");
|
||||
eprintln!("🔄 Backend version control: Enable (automatically handles reconnections)");
|
||||
eprintln!("💡 Press Ctrl+C to stop the service");
|
||||
}
|
||||
|
||||
// 包装 handler 为 Arc,供 SessionManager 和 service factory 共享
|
||||
let handler = Arc::new(proxy_handler);
|
||||
|
||||
// 创建自定义 SessionManager(带版本控制)
|
||||
let session_manager = ProxyAwareSessionManager::new(handler.clone());
|
||||
|
||||
// 创建 Streamable HTTP 服务
|
||||
// service factory 每次请求都会调用,返回 handler 的克隆
|
||||
let handler_for_service = handler.clone();
|
||||
let mut server_config = StreamableHttpServerConfig::default();
|
||||
server_config.stateful_mode = true; // 关键:启用有状态模式
|
||||
let service = StreamableHttpService::new(
|
||||
move || Ok((*handler_for_service).clone()),
|
||||
session_manager.into(), // 转换为 Arc<dyn SessionManager>
|
||||
server_config,
|
||||
);
|
||||
|
||||
// Streamable HTTP 直接在根路径提供服务
|
||||
let router = axum::Router::new().fallback_service(service);
|
||||
|
||||
// 使用传入的 listener 启动 HTTP 服务器
|
||||
|
||||
// 使用 select 处理 Ctrl+C 和服务器
|
||||
tokio::select! {
|
||||
result = axum::serve(listener, router) => {
|
||||
if let Err(e) = result {
|
||||
error!(
|
||||
"[HTTP Service Error] Streamable HTTP Server Error - MCP ID: {}, Error: {}",
|
||||
mcp_id, e
|
||||
);
|
||||
bail!("服务器错误: {}", e);
|
||||
}
|
||||
}
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
info!(
|
||||
"[HTTP service shutdown] Received exit signal, closing Streamable HTTP service - MCP ID: {}",
|
||||
mcp_id
|
||||
);
|
||||
if !quiet {
|
||||
eprintln!("\\n🛑 Received exit signal, closing...");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
388
qiming-mcp-proxy/mcp-streamable-proxy/src/server_builder.rs
Normal file
388
qiming-mcp-proxy/mcp-streamable-proxy/src/server_builder.rs
Normal file
@@ -0,0 +1,388 @@
|
||||
//! Streamable HTTP Server Builder
|
||||
//!
|
||||
//! This module provides a high-level Builder API for creating Streamable HTTP MCP servers.
|
||||
//! It encapsulates all rmcp-specific types and provides a simple interface for mcp-proxy.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use process_wrap::tokio::{CommandWrap, KillOnDrop};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::info;
|
||||
|
||||
use rmcp::{
|
||||
ServiceExt,
|
||||
model::{ClientCapabilities, ClientInfo},
|
||||
transport::{
|
||||
TokioChildProcess,
|
||||
streamable_http_client::{
|
||||
StreamableHttpClientTransport, StreamableHttpClientTransportConfig,
|
||||
},
|
||||
streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService},
|
||||
},
|
||||
};
|
||||
|
||||
// Unix 进程组支持
|
||||
#[cfg(unix)]
|
||||
use process_wrap::tokio::ProcessGroup;
|
||||
|
||||
// Windows 静默运行支持
|
||||
#[cfg(windows)]
|
||||
use process_wrap::tokio::{CreationFlags, JobObject};
|
||||
|
||||
use crate::{ProxyAwareSessionManager, ProxyHandler, ToolFilter};
|
||||
pub use mcp_common::ToolFilter as CommonToolFilter;
|
||||
|
||||
/// Backend configuration for the MCP server
|
||||
///
|
||||
/// Defines how the proxy connects to the upstream MCP service.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum BackendConfig {
|
||||
/// Connect to a local command via stdio
|
||||
Stdio {
|
||||
/// Command to execute (e.g., "npx", "python", etc.)
|
||||
command: String,
|
||||
/// Arguments for the command
|
||||
args: Option<Vec<String>>,
|
||||
/// Environment variables from MCP JSON config
|
||||
env: Option<HashMap<String, String>>,
|
||||
},
|
||||
/// Connect to a remote URL
|
||||
Url {
|
||||
/// URL of the MCP service
|
||||
url: String,
|
||||
/// Custom HTTP headers (including Authorization)
|
||||
headers: Option<HashMap<String, String>>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Configuration for the Streamable HTTP server
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct StreamServerConfig {
|
||||
/// Enable stateful mode with session management
|
||||
pub stateful_mode: bool,
|
||||
/// MCP service identifier for logging
|
||||
pub mcp_id: Option<String>,
|
||||
/// Tool filter configuration
|
||||
pub tool_filter: Option<ToolFilter>,
|
||||
}
|
||||
|
||||
/// Builder for creating Streamable HTTP MCP servers
|
||||
///
|
||||
/// Provides a fluent API for configuring and building MCP proxy servers.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use mcp_streamable_proxy::server_builder::{StreamServerBuilder, BackendConfig};
|
||||
///
|
||||
/// // Create a server with stdio backend
|
||||
/// let (router, ct) = StreamServerBuilder::new(BackendConfig::Stdio {
|
||||
/// command: "npx".into(),
|
||||
/// args: Some(vec!["-y".into(), "@modelcontextprotocol/server-filesystem".into()]),
|
||||
/// env: None,
|
||||
/// })
|
||||
/// .mcp_id("my-server")
|
||||
/// .stateful(false)
|
||||
/// .build()
|
||||
/// .await?;
|
||||
/// ```
|
||||
pub struct StreamServerBuilder {
|
||||
backend_config: BackendConfig,
|
||||
server_config: StreamServerConfig,
|
||||
}
|
||||
|
||||
impl StreamServerBuilder {
|
||||
/// Create a new builder with the given backend configuration
|
||||
pub fn new(backend: BackendConfig) -> Self {
|
||||
Self {
|
||||
backend_config: backend,
|
||||
server_config: StreamServerConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set whether to enable stateful mode
|
||||
///
|
||||
/// Stateful mode enables session management and server-side push.
|
||||
pub fn stateful(mut self, enabled: bool) -> Self {
|
||||
self.server_config.stateful_mode = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the MCP service identifier
|
||||
///
|
||||
/// Used for logging and service identification.
|
||||
pub fn mcp_id(mut self, id: impl Into<String>) -> Self {
|
||||
self.server_config.mcp_id = Some(id.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the tool filter configuration
|
||||
pub fn tool_filter(mut self, filter: ToolFilter) -> Self {
|
||||
self.server_config.tool_filter = Some(filter);
|
||||
self
|
||||
}
|
||||
|
||||
/// Build the server and return an axum Router, CancellationToken, and ProxyHandler
|
||||
///
|
||||
/// The router can be merged with other axum routers or served directly.
|
||||
/// The CancellationToken can be used to gracefully shut down the service.
|
||||
/// The ProxyHandler can be used for status checks and management.
|
||||
pub async fn build(self) -> Result<(axum::Router, CancellationToken, ProxyHandler)> {
|
||||
let mcp_id = self
|
||||
.server_config
|
||||
.mcp_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "stream-proxy".into());
|
||||
|
||||
// Create client info for connecting to backend
|
||||
let capabilities = ClientCapabilities::builder()
|
||||
.enable_experimental()
|
||||
.enable_roots()
|
||||
.enable_roots_list_changed()
|
||||
.enable_sampling()
|
||||
.build();
|
||||
let client_info = ClientInfo::new(
|
||||
capabilities,
|
||||
rmcp::model::Implementation::new("mcp-streamable-proxy", env!("CARGO_PKG_VERSION")),
|
||||
);
|
||||
|
||||
// Connect to backend based on configuration
|
||||
let client = match &self.backend_config {
|
||||
BackendConfig::Stdio { command, args, env } => {
|
||||
self.connect_stdio(command, args, env, &client_info).await?
|
||||
}
|
||||
BackendConfig::Url { url, headers } => {
|
||||
self.connect_url(url, headers, &client_info).await?
|
||||
}
|
||||
};
|
||||
|
||||
// Create proxy handler
|
||||
let proxy_handler = if let Some(ref tool_filter) = self.server_config.tool_filter {
|
||||
ProxyHandler::with_tool_filter(client, mcp_id.clone(), tool_filter.clone())
|
||||
} else {
|
||||
ProxyHandler::with_mcp_id(client, mcp_id.clone())
|
||||
};
|
||||
|
||||
// Clone handler before creating server
|
||||
let handler_for_return = proxy_handler.clone();
|
||||
|
||||
// Create server with configured stateful mode
|
||||
let (router, ct) = self.create_server(proxy_handler).await?;
|
||||
|
||||
info!(
|
||||
"[StreamServerBuilder] Server created - mcp_id: {}, stateful: {}",
|
||||
mcp_id, self.server_config.stateful_mode
|
||||
);
|
||||
|
||||
Ok((router, ct, handler_for_return))
|
||||
}
|
||||
|
||||
/// Connect to a stdio backend (child process)
|
||||
async fn connect_stdio(
|
||||
&self,
|
||||
command: &str,
|
||||
args: &Option<Vec<String>>,
|
||||
env: &Option<HashMap<String, String>>,
|
||||
client_info: &ClientInfo,
|
||||
) -> Result<rmcp::service::RunningService<rmcp::RoleClient, ClientInfo>> {
|
||||
let args = args.clone();
|
||||
|
||||
// 使用 process-wrap 创建子进程命令(跨平台进程清理)
|
||||
// process-wrap 会自动处理进程组(Unix)或 Job Object(Windows)
|
||||
// 并且在 Drop 时自动清理子进程树
|
||||
// 子进程默认继承父进程的所有环境变量
|
||||
let mut wrapped_cmd = CommandWrap::with_new(command, |cmd| {
|
||||
if let Some(cmd_args) = &args {
|
||||
cmd.args(cmd_args);
|
||||
}
|
||||
// 设置 MCP JSON 配置中的环境变量(会覆盖继承的同名变量)
|
||||
if let Some(env_vars) = env {
|
||||
for (k, v) in env_vars {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Unix: 创建进程组,支持 killpg 清理整个进程树
|
||||
#[cfg(unix)]
|
||||
wrapped_cmd.wrap(ProcessGroup::leader());
|
||||
|
||||
// Windows: 使用 CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP 隐藏控制台窗口
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use windows::Win32::System::Threading::{CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW};
|
||||
info!(
|
||||
"[StreamServerBuilder] Setting CreationFlags: CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP"
|
||||
);
|
||||
wrapped_cmd.wrap(CreationFlags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP));
|
||||
wrapped_cmd.wrap(JobObject);
|
||||
}
|
||||
|
||||
// 所有平台: Drop 时自动清理进程
|
||||
wrapped_cmd.wrap(KillOnDrop);
|
||||
|
||||
info!(
|
||||
"[StreamServerBuilder] Starting child process - command: {}, args: {:?}",
|
||||
command,
|
||||
args.as_ref().unwrap_or(&vec![])
|
||||
);
|
||||
|
||||
let mcp_id = self.server_config.mcp_id.as_deref().unwrap_or("unknown");
|
||||
|
||||
// 诊断日志:子进程关键环境变量
|
||||
mcp_common::diagnostic::log_stdio_spawn_context("StreamServerBuilder", mcp_id, env);
|
||||
|
||||
// MCP 服务通过 stdin/stdout 进行 JSON-RPC 通信,必须使用 piped(默认行为)
|
||||
// 使用 builder 模式捕获 stderr,便于诊断子 MCP 服务初始化失败
|
||||
let (tokio_process, child_stderr) = TokioChildProcess::builder(wrapped_cmd)
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"{}",
|
||||
mcp_common::diagnostic::format_spawn_error(mcp_id, command, &args, e)
|
||||
)
|
||||
})?;
|
||||
|
||||
// 启动 stderr 日志读取任务
|
||||
if let Some(stderr_pipe) = child_stderr {
|
||||
mcp_common::spawn_stderr_reader(stderr_pipe, mcp_id.to_string());
|
||||
}
|
||||
|
||||
let client = client_info.clone().serve(tokio_process).await?;
|
||||
|
||||
info!("[StreamServerBuilder] Child process connected successfully");
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// Connect to a URL backend (remote MCP service)
|
||||
async fn connect_url(
|
||||
&self,
|
||||
url: &str,
|
||||
headers: &Option<HashMap<String, String>>,
|
||||
client_info: &ClientInfo,
|
||||
) -> Result<rmcp::service::RunningService<rmcp::RoleClient, ClientInfo>> {
|
||||
info!("[StreamServerBuilder] Connecting to URL backend: {}", url);
|
||||
|
||||
// Build HTTP client with custom headers (excluding Authorization)
|
||||
let mut req_headers = reqwest::header::HeaderMap::new();
|
||||
let mut auth_header: Option<String> = None;
|
||||
|
||||
if let Some(config_headers) = headers {
|
||||
for (key, value) in config_headers {
|
||||
// Authorization header is handled separately by rmcp
|
||||
if key.eq_ignore_ascii_case("Authorization") {
|
||||
auth_header = Some(value.strip_prefix("Bearer ").unwrap_or(value).to_string());
|
||||
continue;
|
||||
}
|
||||
|
||||
req_headers.insert(
|
||||
reqwest::header::HeaderName::try_from(key)
|
||||
.map_err(|e| anyhow::anyhow!("Invalid header name '{}': {}", key, e))?,
|
||||
value.parse().map_err(|e| {
|
||||
anyhow::anyhow!("Invalid header value for '{}': {}", key, e)
|
||||
})?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let http_client = reqwest::Client::builder()
|
||||
.default_headers(req_headers)
|
||||
.build()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
// Create transport configuration
|
||||
let mut config = StreamableHttpClientTransportConfig::with_uri(url.to_string());
|
||||
config.auth_header = auth_header;
|
||||
|
||||
let transport = StreamableHttpClientTransport::with_client(http_client, config);
|
||||
let client = client_info.clone().serve(transport).await?;
|
||||
|
||||
info!("[StreamServerBuilder] URL backend connected successfully");
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// Create the Streamable HTTP server
|
||||
async fn create_server(
|
||||
&self,
|
||||
proxy_handler: ProxyHandler,
|
||||
) -> Result<(axum::Router, CancellationToken)> {
|
||||
let handler = Arc::new(proxy_handler);
|
||||
let ct = CancellationToken::new();
|
||||
|
||||
if self.server_config.stateful_mode {
|
||||
// Stateful mode with custom session manager
|
||||
let session_manager = ProxyAwareSessionManager::new(handler.clone());
|
||||
let handler_for_service = handler.clone();
|
||||
|
||||
let mut server_config = StreamableHttpServerConfig::default();
|
||||
server_config.stateful_mode = true;
|
||||
let service = StreamableHttpService::new(
|
||||
move || Ok((*handler_for_service).clone()),
|
||||
session_manager.into(),
|
||||
server_config,
|
||||
);
|
||||
|
||||
let router = axum::Router::new().fallback_service(service);
|
||||
Ok((router, ct))
|
||||
} else {
|
||||
// Stateless mode with local session manager
|
||||
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
|
||||
|
||||
let handler_for_service = handler.clone();
|
||||
|
||||
let server_config = StreamableHttpServerConfig::default(); // stateless mode
|
||||
let service = StreamableHttpService::new(
|
||||
move || Ok((*handler_for_service).clone()),
|
||||
LocalSessionManager::default().into(),
|
||||
server_config,
|
||||
);
|
||||
|
||||
let router = axum::Router::new().fallback_service(service);
|
||||
Ok((router, ct))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_builder_creation() {
|
||||
let builder = StreamServerBuilder::new(BackendConfig::Stdio {
|
||||
command: "echo".into(),
|
||||
args: Some(vec!["hello".into()]),
|
||||
env: None,
|
||||
})
|
||||
.mcp_id("test")
|
||||
.stateful(true);
|
||||
|
||||
assert!(builder.server_config.mcp_id.is_some());
|
||||
assert_eq!(builder.server_config.mcp_id.as_deref(), Some("test"));
|
||||
assert!(builder.server_config.stateful_mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_url_backend_config() {
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("Authorization".into(), "Bearer token123".into());
|
||||
headers.insert("X-Custom".into(), "value".into());
|
||||
|
||||
let builder = StreamServerBuilder::new(BackendConfig::Url {
|
||||
url: "http://localhost:8080/mcp".into(),
|
||||
headers: Some(headers),
|
||||
});
|
||||
|
||||
match &builder.backend_config {
|
||||
BackendConfig::Url { url, headers } => {
|
||||
assert_eq!(url, "http://localhost:8080/mcp");
|
||||
assert!(headers.is_some());
|
||||
}
|
||||
_ => panic!("Expected URL backend"),
|
||||
}
|
||||
}
|
||||
}
|
||||
283
qiming-mcp-proxy/mcp-streamable-proxy/src/session_manager.rs
Normal file
283
qiming-mcp-proxy/mcp-streamable-proxy/src/session_manager.rs
Normal file
@@ -0,0 +1,283 @@
|
||||
//! Session Manager with backend version tracking
|
||||
//!
|
||||
//! This module implements ProxyAwareSessionManager that integrates with
|
||||
//! ProxyHandler's version control mechanism to automatically invalidate
|
||||
//! sessions when the backend reconnects.
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! ProxyAwareSessionManager
|
||||
//! ├── LocalSessionManager (rmcp 提供的基础实现)
|
||||
//! ├── ProxyHandler (Arc, 访问 backend_version)
|
||||
//! └── DashMap<SessionId, SessionMetadata> (跟踪 session 创建时的版本)
|
||||
//!
|
||||
//! 工作流程:
|
||||
//! 1. create_session: 记录当前 backend_version
|
||||
//! 2. resume: 检查版本是否匹配
|
||||
//! - 匹配 → 正常 resume
|
||||
//! - 不匹配 → 返回 NotFound,客户端重新创建 session
|
||||
//! ```
|
||||
|
||||
use dashmap::DashMap;
|
||||
use futures::Stream;
|
||||
use rmcp::{
|
||||
model::{ClientJsonRpcMessage, ServerJsonRpcMessage},
|
||||
transport::{
|
||||
WorkerTransport,
|
||||
common::server_side_http::ServerSseMessage,
|
||||
streamable_http_server::session::{
|
||||
SessionId, SessionManager,
|
||||
local::{LocalSessionManager, LocalSessionManagerError, LocalSessionWorker},
|
||||
},
|
||||
},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use super::proxy_handler::ProxyHandler;
|
||||
|
||||
/// Session 元数据:跟踪 session 创建时的后端版本
|
||||
#[derive(Debug, Clone)]
|
||||
struct SessionMetadata {
|
||||
backend_version: u64,
|
||||
}
|
||||
|
||||
/// 感知代理状态的 SessionManager
|
||||
///
|
||||
/// 职责:
|
||||
/// 1. 委托 LocalSessionManager 处理核心 session 逻辑
|
||||
/// 2. 维护 session → backend_version 映射
|
||||
/// 3. 在 resume 时检查版本一致性
|
||||
/// 4. 版本不匹配时使 session 失效
|
||||
pub struct ProxyAwareSessionManager {
|
||||
inner: LocalSessionManager,
|
||||
handler: Arc<ProxyHandler>,
|
||||
session_versions: DashMap<String, SessionMetadata>,
|
||||
}
|
||||
|
||||
impl ProxyAwareSessionManager {
|
||||
/// 默认 session keep_alive 超时: 30 分钟
|
||||
/// rmcp 默认 5 分钟太短, 对于代理场景, agent 可能长时间不发消息但仍需要保持 session
|
||||
const DEFAULT_SESSION_KEEP_ALIVE_SECS: u64 = 30 * 60;
|
||||
|
||||
pub fn new(handler: Arc<ProxyHandler>) -> Self {
|
||||
Self::with_keep_alive(handler, Duration::from_secs(Self::DEFAULT_SESSION_KEEP_ALIVE_SECS))
|
||||
}
|
||||
|
||||
pub fn with_keep_alive(handler: Arc<ProxyHandler>, keep_alive: Duration) -> Self {
|
||||
info!(
|
||||
"[Session Manager] Create ProxyAwareSessionManager - MCP ID: {}, keep_alive: {}s",
|
||||
handler.mcp_id(),
|
||||
keep_alive.as_secs()
|
||||
);
|
||||
let mut inner = LocalSessionManager::default();
|
||||
inner.session_config.keep_alive = Some(keep_alive);
|
||||
Self {
|
||||
inner,
|
||||
handler,
|
||||
session_versions: DashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_backend_version(&self, session_id: &SessionId) -> bool {
|
||||
if let Some(meta) = self.session_versions.get(session_id.as_ref()) {
|
||||
let current_version = self.handler.get_backend_version();
|
||||
if meta.backend_version != current_version {
|
||||
warn!(
|
||||
"[Session version mismatch] session_id={}, creation version={}, current version={}, MCP ID: {}",
|
||||
session_id,
|
||||
meta.backend_version,
|
||||
current_version,
|
||||
self.handler.mcp_id()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
// Implement SessionManager trait
|
||||
impl SessionManager for ProxyAwareSessionManager {
|
||||
type Error = LocalSessionManagerError;
|
||||
type Transport = WorkerTransport<LocalSessionWorker>;
|
||||
|
||||
async fn create_session(&self) -> Result<(SessionId, Self::Transport), Self::Error> {
|
||||
let (session_id, transport) = self.inner.create_session().await?;
|
||||
|
||||
let version = self.handler.get_backend_version();
|
||||
self.session_versions.insert(
|
||||
session_id.to_string(),
|
||||
SessionMetadata {
|
||||
backend_version: version,
|
||||
},
|
||||
);
|
||||
|
||||
info!(
|
||||
"[SessionCreated] session_id={}, backend_version={}, MCP ID: {}",
|
||||
session_id,
|
||||
version,
|
||||
self.handler.mcp_id()
|
||||
);
|
||||
|
||||
Ok((session_id, transport))
|
||||
}
|
||||
|
||||
async fn initialize_session(
|
||||
&self,
|
||||
id: &SessionId,
|
||||
message: ClientJsonRpcMessage,
|
||||
) -> Result<ServerJsonRpcMessage, Self::Error> {
|
||||
if !self.handler.is_backend_available() {
|
||||
warn!(
|
||||
"[Session initialization failed] session_id={}, reason: backend is unavailable, MCP ID: {}",
|
||||
id,
|
||||
self.handler.mcp_id()
|
||||
);
|
||||
return Err(LocalSessionManagerError::SessionNotFound(id.clone()));
|
||||
}
|
||||
|
||||
if !self.check_backend_version(id) {
|
||||
warn!(
|
||||
"[Session initialization failed] session_id={}, reason: version mismatch, MCP ID: {}",
|
||||
id,
|
||||
self.handler.mcp_id()
|
||||
);
|
||||
return Err(LocalSessionManagerError::SessionNotFound(id.clone()));
|
||||
}
|
||||
|
||||
debug!(
|
||||
"[Session initialization] session_id={}, MCP ID: {}",
|
||||
id,
|
||||
self.handler.mcp_id()
|
||||
);
|
||||
self.inner.initialize_session(id, message).await
|
||||
}
|
||||
|
||||
async fn has_session(&self, id: &SessionId) -> Result<bool, Self::Error> {
|
||||
if !self.check_backend_version(id) {
|
||||
return Ok(false);
|
||||
}
|
||||
self.inner.has_session(id).await
|
||||
}
|
||||
|
||||
async fn close_session(&self, id: &SessionId) -> Result<(), Self::Error> {
|
||||
info!(
|
||||
"[SessionClosed] session_id={}, MCP ID: {}",
|
||||
id,
|
||||
self.handler.mcp_id()
|
||||
);
|
||||
self.session_versions.remove(id.as_ref());
|
||||
self.inner.close_session(id).await
|
||||
}
|
||||
|
||||
async fn create_stream(
|
||||
&self,
|
||||
id: &SessionId,
|
||||
message: ClientJsonRpcMessage,
|
||||
) -> Result<impl Stream<Item = ServerSseMessage> + Send + 'static, Self::Error> {
|
||||
if !self.handler.is_backend_available() {
|
||||
warn!(
|
||||
"[Stream creation failed] session_id={}, reason: backend is unavailable, MCP ID: {}",
|
||||
id,
|
||||
self.handler.mcp_id()
|
||||
);
|
||||
return Err(LocalSessionManagerError::SessionNotFound(id.clone()));
|
||||
}
|
||||
|
||||
if !self.check_backend_version(id) {
|
||||
warn!(
|
||||
"[Stream creation failed] session_id={}, reason: version mismatch, MCP ID: {}",
|
||||
id,
|
||||
self.handler.mcp_id()
|
||||
);
|
||||
return Err(LocalSessionManagerError::SessionNotFound(id.clone()));
|
||||
}
|
||||
|
||||
debug!(
|
||||
"[Stream creation] session_id={}, MCP ID: {}",
|
||||
id,
|
||||
self.handler.mcp_id()
|
||||
);
|
||||
self.inner.create_stream(id, message).await
|
||||
}
|
||||
|
||||
async fn accept_message(
|
||||
&self,
|
||||
id: &SessionId,
|
||||
message: ClientJsonRpcMessage,
|
||||
) -> Result<(), Self::Error> {
|
||||
if !self.handler.is_backend_available() {
|
||||
warn!(
|
||||
"[Message rejected] session_id={}, reason: backend unavailable, MCP ID: {}",
|
||||
id,
|
||||
self.handler.mcp_id()
|
||||
);
|
||||
return Err(LocalSessionManagerError::SessionNotFound(id.clone()));
|
||||
}
|
||||
|
||||
if !self.check_backend_version(id) {
|
||||
warn!(
|
||||
"[Message rejected] session_id={}, reason: version mismatch, MCP ID: {}",
|
||||
id,
|
||||
self.handler.mcp_id()
|
||||
);
|
||||
return Err(LocalSessionManagerError::SessionNotFound(id.clone()));
|
||||
}
|
||||
|
||||
self.inner.accept_message(id, message).await
|
||||
}
|
||||
|
||||
async fn create_standalone_stream(
|
||||
&self,
|
||||
id: &SessionId,
|
||||
) -> Result<impl Stream<Item = ServerSseMessage> + Send + 'static, Self::Error> {
|
||||
self.inner.create_standalone_stream(id).await
|
||||
}
|
||||
|
||||
async fn resume(
|
||||
&self,
|
||||
id: &SessionId,
|
||||
last_event_id: String,
|
||||
) -> Result<impl Stream<Item = ServerSseMessage> + Send + 'static, Self::Error> {
|
||||
// 关键:检查后端版本
|
||||
if let Some(meta) = self.session_versions.get(id.as_ref()) {
|
||||
let current_version = self.handler.get_backend_version();
|
||||
if meta.backend_version != current_version {
|
||||
warn!(
|
||||
"[Session recovery failed] session_id={}, reason: backend version change ({} -> {}), MCP ID: {}",
|
||||
id,
|
||||
meta.backend_version,
|
||||
current_version,
|
||||
self.handler.mcp_id()
|
||||
);
|
||||
|
||||
// 清理失效 session
|
||||
drop(meta); // 释放 DashMap 的读锁
|
||||
self.session_versions.remove(id.as_ref());
|
||||
let _ = self.inner.close_session(id).await;
|
||||
|
||||
return Err(LocalSessionManagerError::SessionNotFound(id.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
if !self.handler.is_backend_available() {
|
||||
warn!(
|
||||
"[Session recovery failed] session_id={}, reason: backend is unavailable, MCP ID: {}",
|
||||
id,
|
||||
self.handler.mcp_id()
|
||||
);
|
||||
return Err(LocalSessionManagerError::SessionNotFound(id.clone()));
|
||||
}
|
||||
|
||||
debug!(
|
||||
"[SessionResumed] session_id={}, last_event_id={}, MCP ID: {}",
|
||||
id,
|
||||
last_event_id,
|
||||
self.handler.mcp_id()
|
||||
);
|
||||
self.inner.resume(id, last_event_id).await
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user