use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use outrig::error::OutrigError;
use outrig::mcp_proxy::{BackingClient, ProxyServer};
use outrig::{McpTool, McpToolResult};
use rmcp::model::{CallToolRequestParams, RawContent};
use serde_json::{Value, json};
type CallResponse = Result<McpToolResult, OutrigError>;
#[derive(Default)]
struct FakeClient {
name: String,
tools: Vec<McpTool>,
received: Mutex<Vec<(String, Value)>>,
responses: HashMap<String, CallResponse>,
}
impl FakeClient {
fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
..Default::default()
}
}
fn with_tool(mut self, tool_name: &str) -> Self {
self.tools.push(McpTool {
name: tool_name.to_string(),
description: Some(format!("desc for {tool_name}")),
input_schema: json!({"type": "object"}),
});
self
}
fn respond_ok(mut self, tool_name: &str, body: &str, is_error: bool) -> Self {
self.responses.insert(
tool_name.to_string(),
Ok(McpToolResult {
content_text: body.to_string(),
is_error,
}),
);
self
}
fn respond_err(mut self, tool_name: &str, msg: &str) -> Self {
self.responses.insert(
tool_name.to_string(),
Err(OutrigError::Configuration(msg.to_string())),
);
self
}
}
impl BackingClient for FakeClient {
fn name(&self) -> &str {
&self.name
}
async fn list_tools(&self) -> outrig::error::Result<Vec<McpTool>> {
Ok(self.tools.clone())
}
async fn call_tool(&self, name: &str, args: Value) -> outrig::error::Result<McpToolResult> {
self.received.lock().unwrap().push((name.to_string(), args));
match self.responses.get(name) {
Some(Ok(r)) => Ok(r.clone()),
Some(Err(e)) => Err(OutrigError::Configuration(e.to_string())),
None => Err(OutrigError::Configuration(format!(
"FakeClient({}) has no response for {name:?}",
self.name
))),
}
}
}
fn text_body(result: &rmcp::model::CallToolResult) -> String {
let mut out = String::new();
for content in &result.content {
if let RawContent::Text(t) = &content.raw {
if !out.is_empty() {
out.push('\n');
}
out.push_str(&t.text);
}
}
out
}
fn call(name: &str, args: Value) -> CallToolRequestParams {
let arguments = match args {
Value::Object(m) => Some(m),
Value::Null => None,
other => panic!("test args must be object or null, got {other:?}"),
};
let mut request = CallToolRequestParams::new(name.to_string());
if let Some(arguments) = arguments {
request = request.with_arguments(arguments);
}
request
}
#[tokio::test]
async fn list_tools_unions_namespaces_and_preserves_order() {
let fs = Arc::new(
FakeClient::new("fs")
.with_tool("read_file")
.with_tool("write_file"),
);
let git = Arc::new(FakeClient::new("git").with_tool("commit"));
let proxy = ProxyServer::build(vec![fs, git]).await.unwrap();
let listing = proxy.list_tools_inner();
let names: Vec<&str> = listing.tools.iter().map(|t| t.name.as_ref()).collect();
assert_eq!(
names,
vec!["fs__read_file", "fs__write_file", "git__commit"]
);
let read_file = &listing.tools[0];
assert_eq!(read_file.description.as_deref(), Some("desc for read_file"));
assert_eq!(
Value::Object(read_file.input_schema.as_ref().clone()),
json!({"type": "object"})
);
}
#[tokio::test]
async fn call_tool_routes_to_correct_backend() {
let fs = Arc::new(FakeClient::new("fs").with_tool("read_file").respond_ok(
"read_file",
"fs payload",
false,
));
let git = Arc::new(FakeClient::new("git").with_tool("commit").respond_ok(
"commit",
"git payload",
false,
));
let fs_for_assert = fs.clone();
let git_for_assert = git.clone();
let proxy = ProxyServer::build(vec![fs, git]).await.unwrap();
let result = proxy
.dispatch_call(call("git__commit", json!({"msg": "hi"})))
.await;
assert_eq!(result.is_error, Some(false));
assert_eq!(text_body(&result), "git payload");
let git_calls = git_for_assert.received.lock().unwrap();
assert_eq!(git_calls.len(), 1);
assert_eq!(git_calls[0].0, "commit");
assert_eq!(git_calls[0].1, json!({"msg": "hi"}));
assert!(fs_for_assert.received.lock().unwrap().is_empty());
}
#[tokio::test]
async fn null_arguments_become_value_null() {
let fs = Arc::new(
FakeClient::new("fs")
.with_tool("ping")
.respond_ok("ping", "pong", false),
);
let fs_for_assert = fs.clone();
let proxy = ProxyServer::build(vec![fs]).await.unwrap();
let req = CallToolRequestParams::new("fs__ping".to_string());
let result = proxy.dispatch_call(req).await;
assert_eq!(result.is_error, Some(false));
let calls = fs_for_assert.received.lock().unwrap();
assert_eq!(calls[0].1, Value::Null);
}
#[tokio::test]
async fn backend_error_surfaces_as_call_tool_result_is_error() {
let fs = Arc::new(
FakeClient::new("fs")
.with_tool("read_file")
.respond_err("read_file", "permission denied"),
);
let proxy = ProxyServer::build(vec![fs]).await.unwrap();
let result = proxy.dispatch_call(call("fs__read_file", json!({}))).await;
assert_eq!(result.is_error, Some(true));
let body = text_body(&result);
assert!(
body.contains("outrig: backing server `fs`"),
"body should identify the backing server, was {body:?}"
);
assert!(
body.contains("permission denied"),
"body should carry the backend error, was {body:?}"
);
}
#[tokio::test]
async fn backend_semantic_error_propagates_unchanged() {
let fs = Arc::new(FakeClient::new("fs").with_tool("read_file").respond_ok(
"read_file",
"ENOENT: missing",
true,
));
let proxy = ProxyServer::build(vec![fs]).await.unwrap();
let result = proxy.dispatch_call(call("fs__read_file", json!({}))).await;
assert_eq!(result.is_error, Some(true));
assert_eq!(text_body(&result), "ENOENT: missing");
}
#[tokio::test]
async fn unknown_tool_returns_is_error_not_protocol_error() {
let fs = Arc::new(FakeClient::new("fs").with_tool("read_file"));
let proxy = ProxyServer::build(vec![fs]).await.unwrap();
let result = proxy.dispatch_call(call("fs__nope", json!({}))).await;
assert_eq!(result.is_error, Some(true));
assert!(text_body(&result).contains("unknown tool"));
}
fn build_err(result: Result<ProxyServer<Arc<FakeClient>>, OutrigError>) -> OutrigError {
match result {
Ok(_) => panic!("expected ProxyServer::build to fail"),
Err(e) => e,
}
}
#[tokio::test]
async fn duplicate_client_name_is_rejected() {
let a = Arc::new(FakeClient::new("fs").with_tool("read_file"));
let b = Arc::new(FakeClient::new("fs").with_tool("write_file"));
let err = build_err(ProxyServer::build(vec![a, b]).await);
let msg = err.to_string();
assert!(
msg.contains("duplicate backing-client name") && msg.contains("\"fs\""),
"error was {msg:?}"
);
}
#[tokio::test]
async fn public_name_collision_is_rejected() {
let a = Arc::new(FakeClient::new("fs/").with_tool("bar"));
let b = Arc::new(FakeClient::new("fs").with_tool("_bar"));
let err = build_err(ProxyServer::build(vec![a, b]).await);
let msg = err.to_string();
assert!(
msg.contains("public name") && msg.contains("fs___bar"),
"error was {msg:?}"
);
}
#[tokio::test]
async fn iter_public_names_matches_list_tools() {
let fs = Arc::new(
FakeClient::new("fs")
.with_tool("read_file")
.with_tool("write_file"),
);
let proxy = ProxyServer::build(vec![fs]).await.unwrap();
let from_iter: Vec<&str> = proxy.iter_public_names().collect();
let from_list: Vec<String> = proxy
.list_tools_inner()
.tools
.into_iter()
.map(|t| t.name.into_owned())
.collect();
assert_eq!(from_iter, vec!["fs__read_file", "fs__write_file"]);
assert_eq!(from_iter, from_list);
}
#[tokio::test]
async fn per_server_counts_preserves_registration_order() {
let fs = Arc::new(
FakeClient::new("fs")
.with_tool("read_file")
.with_tool("write_file"),
);
let git = Arc::new(FakeClient::new("git").with_tool("commit"));
let empty = Arc::new(FakeClient::new("empty"));
let proxy = ProxyServer::build(vec![fs, git, empty]).await.unwrap();
assert_eq!(
proxy.per_server_counts(),
vec![("fs", 2), ("git", 1), ("empty", 0)]
);
}