use std::future::Future;
use std::sync::Arc;
use std::time::Duration;
use serde_json::Value;
use turbomcp_protocol::types::{
PromptsCapabilities, ResourcesCapabilities, ServerCapabilities, ToolsCapabilities,
};
use turbomcp_protocol::{Error as McpError, Result as McpResult};
use turbomcp_server::{JsonRpcIncoming, McpHandler, RequestContext};
use super::BackendConnector;
use crate::error::{ProxyError, ProxyResult};
use crate::introspection::ServerSpec;
fn proxy_error_to_mcp(err: ProxyError) -> McpError {
err.into()
}
#[derive(Clone)]
pub struct ProxyService {
backend: Arc<BackendConnector>,
spec: Arc<ServerSpec>,
request_timeout: Option<Duration>,
}
impl ProxyService {
#[must_use]
pub fn new(backend: BackendConnector, spec: ServerSpec) -> Self {
Self {
backend: Arc::new(backend),
spec: Arc::new(spec),
request_timeout: None,
}
}
#[must_use]
pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
self.request_timeout = Some(timeout);
self
}
async fn forward<T>(
&self,
operation: &str,
call: impl Future<Output = ProxyResult<T>>,
) -> McpResult<T> {
let result = match self.request_timeout {
Some(timeout) => tokio::time::timeout(timeout, call).await.map_err(|_| {
McpError::timeout(format!("{operation} exceeded {}ms", timeout.as_millis()))
})?,
None => call.await,
};
result.map_err(proxy_error_to_mcp)
}
pub(crate) async fn process_value(&self, request: Value) -> McpResult<Value> {
let request: JsonRpcIncoming =
serde_json::from_value(request).map_err(|e| McpError::parse_error(e.to_string()))?;
let response = turbomcp_server::route_request(self, request, &RequestContext::new()).await;
match (response.result, response.error) {
(_, Some(error)) => {
let mut err = McpError::from_rpc_code(error.code, error.message);
if let Some(data) = error.data {
err = err.with_data(data);
}
Err(err)
}
(Some(result), None) => Ok(result),
(None, None) => Ok(Value::Null),
}
}
}
fn relayed_capabilities(upstream: &ServerCapabilities) -> ServerCapabilities {
ServerCapabilities {
tools: upstream
.tools
.as_ref()
.map(|_| ToolsCapabilities { list_changed: None }),
resources: upstream.resources.as_ref().map(|_| ResourcesCapabilities {
subscribe: None,
list_changed: None,
}),
prompts: upstream
.prompts
.as_ref()
.map(|_| PromptsCapabilities { list_changed: None }),
..Default::default()
}
}
fn tool_arguments_from_value(
args: Value,
) -> McpResult<Option<std::collections::HashMap<String, Value>>> {
match args {
Value::Null => Ok(None),
Value::Object(map) => Ok(Some(map.into_iter().collect())),
_ => Err(McpError::invalid_params(
"tools/call arguments must be an object".to_string(),
)),
}
}
impl McpHandler for ProxyService {
fn server_info(&self) -> turbomcp_server::prelude::ServerInfo {
let upstream = &self.spec.server_info;
turbomcp_server::prelude::ServerInfo {
name: format!("{}-proxy", upstream.name),
version: upstream.version.clone(),
title: upstream
.title
.as_ref()
.map(|title| format!("{title} Proxy")),
description: upstream.description.clone(),
icons: upstream.icons.clone(),
website_url: upstream.website_url.clone(),
}
}
fn instructions(&self) -> Option<String> {
self.spec.instructions.clone()
}
fn server_capabilities(&self) -> ServerCapabilities {
relayed_capabilities(&self.spec.capabilities)
}
fn list_tools(&self) -> Vec<turbomcp_protocol::types::Tool> {
self.spec.tools.clone()
}
fn list_resources(&self) -> Vec<turbomcp_protocol::types::Resource> {
self.spec.resources.clone()
}
fn list_resource_templates(&self) -> Vec<turbomcp_protocol::types::ResourceTemplate> {
self.spec.resource_templates.clone()
}
fn list_prompts(&self) -> Vec<turbomcp_protocol::types::Prompt> {
self.spec.prompts.clone()
}
async fn call_tool(
&self,
name: &str,
args: Value,
_ctx: &RequestContext,
) -> McpResult<turbomcp_server::prelude::ToolResult> {
let arguments = tool_arguments_from_value(args)?;
let result = self
.forward("tools/call", self.backend.call_tool(name, arguments))
.await?;
serde_json::from_value(result).map_err(|e| McpError::internal(e.to_string()))
}
async fn read_resource(
&self,
uri: &str,
_ctx: &RequestContext,
) -> McpResult<turbomcp_server::prelude::ResourceResult> {
let result = self
.forward("resources/read", self.backend.read_resource(uri))
.await?;
serde_json::to_value(result)
.and_then(serde_json::from_value)
.map_err(|e| McpError::internal(e.to_string()))
}
async fn get_prompt(
&self,
name: &str,
args: Option<Value>,
_ctx: &RequestContext,
) -> McpResult<turbomcp_server::prelude::PromptResult> {
let arguments = match args {
Some(Value::Object(map)) => Some(map.into_iter().collect()),
Some(Value::Null) | None => None,
Some(_) => {
return Err(McpError::invalid_params(
"prompts/get arguments must be an object".to_string(),
));
}
};
let result = self
.forward("prompts/get", self.backend.get_prompt(name, arguments))
.await?;
serde_json::to_value(result)
.and_then(serde_json::from_value)
.map_err(|e| McpError::internal(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
use turbomcp_protocol::types::{Icon, Implementation, ResourceTemplate, Tool, ToolExecution};
async fn service_over(backend: BackendConnector) -> ProxyService {
let spec = backend.introspect().await.expect("introspection");
ProxyService::new(backend, spec)
}
#[tokio::test]
async fn test_resource_templates_list_is_forwarded() {
let template = ResourceTemplate {
uri_template: "repo://{owner}/{name}".to_string(),
name: "repo".to_string(),
title: Some("Repository".to_string()),
description: Some("Repository metadata".to_string()),
mime_type: Some("application/json".to_string()),
..Default::default()
};
let service = service_over(BackendConnector::from_static_data_for_test(
Vec::new(),
Vec::new(),
vec![template],
Vec::new(),
))
.await;
let result = service
.process_value(serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "resources/templates/list"
}))
.await
.expect("resources/templates/list result");
let templates = result["resourceTemplates"].as_array().expect("templates");
assert_eq!(templates.len(), 1);
assert_eq!(templates[0]["uriTemplate"], "repo://{owner}/{name}");
assert_eq!(templates[0]["title"], "Repository");
assert_eq!(templates[0]["mimeType"], "application/json");
}
#[tokio::test]
async fn the_upstream_catalogue_reaches_the_client_losslessly() {
let icon = Icon {
src: "https://example.com/icon.png".to_string(),
mime_type: Some("image/png".to_string()),
..Default::default()
};
let tool = Tool {
name: "search".to_string(),
title: Some("Search".to_string()),
icons: Some(vec![icon.clone()]),
execution: Some(ToolExecution::default()),
meta: Some(HashMap::from([(
"io.example/owner".to_string(),
serde_json::json!("search-team"),
)])),
..Default::default()
};
let mut backend =
BackendConnector::from_static_data_for_test(vec![tool], vec![], vec![], vec![]);
backend.set_server_info_for_test(Implementation {
name: "upstream".to_string(),
version: "2.0.0".to_string(),
description: Some("Searches things".to_string()),
icons: Some(vec![icon]),
website_url: Some("https://example.com".to_string()),
..Default::default()
});
let service = service_over(backend).await;
let listed =
serde_json::to_value(service.list_tools()).expect("tools serialize to the wire shape");
assert_eq!(listed[0]["icons"][0]["src"], "https://example.com/icon.png");
assert!(listed[0].get("execution").is_some(), "execution: {listed}");
assert_eq!(listed[0]["_meta"]["io.example/owner"], "search-team");
let info = service.server_info();
assert_eq!(info.name, "upstream-proxy");
assert_eq!(info.description.as_deref(), Some("Searches things"));
assert_eq!(info.icons.as_ref().map(Vec::len), Some(1));
assert_eq!(info.website_url.as_deref(), Some("https://example.com"));
}
#[tokio::test]
async fn only_relayed_capabilities_are_advertised() {
let upstream = ServerCapabilities {
tools: Some(ToolsCapabilities {
list_changed: Some(true),
}),
resources: Some(ResourcesCapabilities {
subscribe: Some(true),
list_changed: Some(true),
}),
prompts: Some(PromptsCapabilities {
list_changed: Some(true),
}),
logging: Some(turbomcp_protocol::types::LoggingCapabilities::default()),
completions: Some(turbomcp_protocol::types::CompletionCapabilities::default()),
experimental: Some(HashMap::from([(
"io.example/feature".to_string(),
serde_json::json!({}),
)])),
..Default::default()
};
let advertised = serde_json::to_value(relayed_capabilities(&upstream)).expect("caps");
assert_eq!(
advertised,
serde_json::json!({ "tools": {}, "resources": {}, "prompts": {} })
);
}
}