#![cfg(feature = "mcp")]
use axum::Router;
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::{CallToolResult, ContentBlock, ServerCapabilities, ServerInfo};
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService};
use rmcp::{ErrorData, ServerHandler, tool, tool_handler, tool_router};
use salvor_tools::mcp::{EffectOverrides, McpServer};
use salvor_tools::{DynTool, Effect, ToolCtx, ToolOutcome};
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::json;
use tokio::task::JoinHandle;
const TOKEN: &str = "secret-token";
#[derive(Debug, Deserialize, JsonSchema)]
struct AppendArgs {
line: String,
}
#[derive(Clone)]
struct HttpFixture {
tool_router: ToolRouter<Self>,
}
#[tool_router]
impl HttpFixture {
fn new() -> Self {
Self {
tool_router: Self::tool_router(),
}
}
#[tool(
description = "Read the note. Observes state only.",
annotations(read_only_hint = true)
)]
async fn read_note(&self) -> Result<CallToolResult, ErrorData> {
Ok(CallToolResult::success(vec![ContentBlock::text(
"the note says hello",
)]))
}
#[tool(
description = "Append a line to the note. Idempotent for a given line.",
annotations(idempotent_hint = true)
)]
async fn append_note(
&self,
Parameters(AppendArgs { line }): Parameters<AppendArgs>,
) -> Result<CallToolResult, ErrorData> {
Ok(CallToolResult::success(vec![ContentBlock::text(format!(
"appended: {line}"
))]))
}
#[tool(description = "Do something with unstated effects.")]
async fn mutate(&self) -> Result<CallToolResult, ErrorData> {
Ok(CallToolResult::success(vec![ContentBlock::text("mutated")]))
}
}
#[tool_handler(router = self.tool_router)]
impl ServerHandler for HttpFixture {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_instructions("Salvor MCP HTTP integration-test fixture server.")
}
}
struct TestServer {
url: String,
handle: JoinHandle<()>,
}
impl TestServer {
fn shutdown(self) {
self.handle.abort();
}
}
fn mcp_router() -> Router {
let service = StreamableHttpService::<HttpFixture, LocalSessionManager>::new(
|| Ok(HttpFixture::new()),
Default::default(),
StreamableHttpServerConfig::default(),
);
Router::new().nest_service("/mcp", service)
}
async fn require_bearer(
headers: HeaderMap,
req: axum::extract::Request,
next: axum::middleware::Next,
) -> Response {
let presented = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok());
if presented == Some(&format!("Bearer {TOKEN}")) {
next.run(req).await
} else {
(StatusCode::FORBIDDEN, "missing or wrong bearer token").into_response()
}
}
async fn spawn_server(require_token: bool) -> TestServer {
let router = if require_token {
mcp_router().layer(axum::middleware::from_fn(require_bearer))
} else {
mcp_router()
};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind a loopback port");
let addr = listener.local_addr().expect("read the bound address");
let handle = tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
});
TestServer {
url: format!("http://{addr}/mcp"),
handle,
}
}
async fn connect(url: &str) -> McpServer {
McpServer::connect_http(url, None, &EffectOverrides::new())
.await
.expect("the HTTP fixture connects, initializes, and lists tools")
}
fn find<'a>(server: &'a McpServer, name: &str) -> &'a dyn DynTool {
server
.tools()
.iter()
.find(|t| t.name() == name)
.unwrap_or_else(|| panic!("the fixture exposes a `{name}` tool"))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn connecting_over_http_lists_tools_and_maps_annotations_to_effects() {
let server = spawn_server(false).await;
let client = connect(&server.url).await;
let names: Vec<&str> = client.tools().iter().map(|t| t.name()).collect();
assert!(names.contains(&"read_note"));
assert!(names.contains(&"append_note"));
assert!(names.contains(&"mutate"));
let append = find(&client, "append_note");
assert!(append.description().contains("Append a line"));
assert!(
append
.input_schema()
.get("properties")
.and_then(|p| p.get("line"))
.is_some(),
"the schema exposes the `line` argument"
);
assert_eq!(find(&client, "read_note").effect(), Effect::Read);
assert_eq!(find(&client, "append_note").effect(), Effect::Idempotent);
assert_eq!(find(&client, "mutate").effect(), Effect::Write);
client.close().await.expect("clean shutdown");
server.shutdown();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn an_override_beats_the_annotation_over_http() {
let server = spawn_server(false).await;
let overrides = EffectOverrides::new().with("read_note", Effect::Write);
let client = McpServer::connect_http(&server.url, None, &overrides)
.await
.expect("connect with overrides");
assert_eq!(
find(&client, "read_note").effect(),
Effect::Write,
"the connect-time override wins over the server's read-only hint"
);
client.close().await.expect("clean shutdown");
server.shutdown();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_tool_call_round_trips_over_http() {
let server = spawn_server(false).await;
let client = connect(&server.url).await;
let outcome = find(&client, "append_note")
.call_json(&ToolCtx::new(None), json!({ "line": "echo-me" }))
.await
.expect("the call succeeds");
match outcome {
ToolOutcome::Output(value) => assert!(
value.to_string().contains("echo-me"),
"the server echoed the input back, got: {value}"
),
ToolOutcome::Suspend(_) => panic!("an MCP tool never suspends"),
}
client.close().await.expect("clean shutdown");
server.shutdown();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reconnecting_over_http_works() {
let server = spawn_server(false).await;
{
let first = connect(&server.url).await;
find(&first, "read_note")
.call_json(&ToolCtx::new(None), json!({}))
.await
.expect("call on the first connection");
}
let second = connect(&server.url).await;
let outcome = find(&second, "read_note")
.call_json(&ToolCtx::new(None), json!({}))
.await
.expect("call on the reconnected client");
assert!(matches!(outcome, ToolOutcome::Output(_)));
second.close().await.expect("clean shutdown");
server.shutdown();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_bearer_token_is_sent_and_accepted() {
let server = spawn_server(true).await;
let client = McpServer::connect_http(&server.url, Some(TOKEN), &EffectOverrides::new())
.await
.expect("connecting with the bearer token succeeds");
let outcome = find(&client, "read_note")
.call_json(&ToolCtx::new(None), json!({}))
.await
.expect("an authenticated call succeeds");
assert!(matches!(outcome, ToolOutcome::Output(_)));
client.close().await.expect("clean shutdown");
server.shutdown();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn an_unauthenticated_client_is_rejected() {
let server = spawn_server(true).await;
let result = McpServer::connect_http(&server.url, None, &EffectOverrides::new()).await;
assert!(
result.is_err(),
"a gated server rejects an unauthenticated client"
);
server.shutdown();
}