提交qiming-mcp-proxy

This commit is contained in:
Codex
2026-06-01 13:03:20 +08:00
parent 9e9486b7c2
commit afb3d9f4e6
394 changed files with 124494 additions and 0 deletions

View 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)));
}
}

View 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
}

View 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);
}
}

View 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,
};

File diff suppressed because it is too large Load Diff

View 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 ObjectWindows
// 并且在 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(())
}

View 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 ObjectWindows
// 并且在 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"),
}
}
}

View 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
}
}