use alloc::string::ToString;
use serde_json::Value;
use crate::PROTOCOL_VERSION;
use crate::context::RequestContext;
use crate::error::McpError;
use crate::handler::McpHandler;
use crate::jsonrpc::{JsonRpcIncoming, JsonRpcOutgoing};
use turbomcp_types::ServerInfo;
pub async fn dispatch_notification<H: McpHandler>(
handler: &H,
request: &JsonRpcIncoming,
ctx: &RequestContext,
) {
if request.method == "notifications/roots/list_changed" {
let _ = handler.on_roots_list_changed(ctx).await;
}
}
fn progress_token(request: &JsonRpcIncoming) -> Option<Value> {
let token = request
.params
.as_ref()?
.get("_meta")?
.get("progressToken")?;
match token {
Value::String(_) | Value::Number(_) => Some(token.clone()),
_ => None,
}
}
struct ProgressScope<'a>(&'a RequestContext);
impl Drop for ProgressScope<'_> {
fn drop(&mut self) {
self.0.end_progress();
}
}
fn check_task_support<H: McpHandler>(
handler: &H,
name: &str,
is_task: bool,
ctx: &RequestContext,
) -> Result<(), McpError> {
let declares_task_calls = handler
.server_capabilities()
.tasks
.and_then(|tasks| tasks.requests)
.and_then(|requests| requests.tools)
.and_then(|tools| tools.call)
.is_some();
if !declares_task_calls {
return Ok(());
}
let support = handler
.list_tools_for(ctx)
.into_iter()
.find(|tool| tool.name == name)
.and_then(|tool| tool.execution)
.and_then(|execution| execution.task_support);
match (support, is_task) {
(Some(turbomcp_types::TaskSupportLevel::Required), false) => Err(McpError::new(
crate::error::ErrorKind::MethodNotFound,
alloc::format!("tool '{name}' must be invoked as a task"),
)),
(None | Some(turbomcp_types::TaskSupportLevel::Forbidden), true) => Err(McpError::new(
crate::error::ErrorKind::MethodNotFound,
alloc::format!("tool '{name}' cannot be invoked as a task"),
)),
_ => Ok(()),
}
}
fn cursor_param(params: Option<&serde_json::Value>) -> Result<Option<&str>, McpError> {
match params.and_then(|params| params.get("cursor")) {
None | Some(serde_json::Value::Null) => Ok(None),
Some(serde_json::Value::String(cursor)) => Ok(Some(cursor)),
Some(_) => Err(McpError::invalid_params("cursor must be a string")),
}
}
fn paginate<T>(
items: alloc::vec::Vec<T>,
kind: &str,
cursor: Option<&str>,
page_size: Option<usize>,
) -> Result<(alloc::vec::Vec<T>, Option<alloc::string::String>), McpError> {
let offset = match cursor {
None => 0,
Some(raw) => {
let parsed = raw
.split_once(':')
.filter(|(cursor_kind, _)| *cursor_kind == kind)
.and_then(|(_, offset)| offset.parse::<usize>().ok());
match parsed {
Some(offset) if offset <= items.len() => offset,
_ => {
return Err(McpError::invalid_params(alloc::format!(
"invalid cursor for {kind}/list"
)));
}
}
}
};
let Some(page_size) = page_size.filter(|size| *size > 0) else {
return Ok((items.into_iter().skip(offset).collect(), None));
};
let mut remaining = items.into_iter().skip(offset);
let page: alloc::vec::Vec<T> = remaining.by_ref().take(page_size).collect();
let next = if remaining.next().is_some() {
Some(alloc::format!("{kind}:{}", offset + page.len()))
} else {
None
};
Ok((page, next))
}
#[derive(Debug, Clone, Default)]
pub struct RouteConfig<'a> {
pub protocol_version: Option<&'a str>,
}
pub async fn route_request<H: McpHandler>(
handler: &H,
request: JsonRpcIncoming,
ctx: &RequestContext,
config: &RouteConfig<'_>,
) -> JsonRpcOutgoing {
if request.is_notification() {
dispatch_notification(handler, &request, ctx).await;
return JsonRpcOutgoing::notification_ack();
}
let with_token;
let ctx = match progress_token(&request) {
Some(token) => {
with_token = ctx.clone().with_progress_token(token);
&with_token
}
None => ctx,
};
let _progress = ProgressScope(ctx);
let id = request.id.clone();
match request.method.as_str() {
"initialize" => {
let params = request.params.clone().unwrap_or_default();
let Some(client_info) = params.get("clientInfo") else {
return JsonRpcOutgoing::error(
id,
McpError::invalid_params("Missing required field: clientInfo"),
);
};
let client_name = client_info.get("name").and_then(|v| v.as_str());
let client_version = client_info.get("version").and_then(|v| v.as_str());
if client_name.is_none() || client_version.is_none() {
return JsonRpcOutgoing::error(
id,
McpError::invalid_params("clientInfo must contain 'name' and 'version' fields"),
);
}
let protocol_version = config.protocol_version.unwrap_or_else(|| {
params
.get("protocolVersion")
.and_then(|v| v.as_str())
.and_then(|requested| {
crate::SUPPORTED_VERSIONS
.iter()
.copied()
.find(|supported| *supported == requested)
})
.unwrap_or(PROTOCOL_VERSION)
});
let info = handler.server_info();
let result = build_initialize_result(&info, handler, protocol_version);
JsonRpcOutgoing::success(id, result)
}
"initialized" | "notifications/initialized" => {
JsonRpcOutgoing::success(id, serde_json::json!({}))
}
"tools/list" => {
let cursor = match cursor_param(request.params.as_ref()) {
Ok(cursor) => cursor,
Err(err) => return JsonRpcOutgoing::error(id, err),
};
match paginate(
handler.list_tools_for(ctx),
"tools",
cursor,
handler.page_size(),
) {
Ok((tools, next_cursor)) => {
let mut result = serde_json::json!({ "tools": tools });
if let Some(next) = next_cursor {
result["nextCursor"] = serde_json::Value::String(next);
}
JsonRpcOutgoing::success(id, result)
}
Err(err) => JsonRpcOutgoing::error(id, err),
}
}
"tools/call" => {
let params = request.params.unwrap_or_default();
let Some(name) = params.get("name").and_then(|v| v.as_str()) else {
return JsonRpcOutgoing::error(
id,
McpError::invalid_params("tools/call requires a string 'name' parameter"),
);
};
let args = params.get("arguments").cloned().unwrap_or_default();
if let Err(err) = check_task_support(handler, name, params.get("task").is_some(), ctx) {
return JsonRpcOutgoing::error(id, err);
}
match handler.call_tool(name, args, ctx).await {
Ok(result) => match serde_json::to_value(&result) {
Ok(result_value) => JsonRpcOutgoing::success(id, result_value),
Err(e) => JsonRpcOutgoing::error(
id,
McpError::internal(alloc::format!(
"Failed to serialize tool result: {}",
e
)),
),
},
Err(err) => JsonRpcOutgoing::error(id, err),
}
}
"resources/list" => {
let cursor = match cursor_param(request.params.as_ref()) {
Ok(cursor) => cursor,
Err(err) => return JsonRpcOutgoing::error(id, err),
};
match paginate(
handler.list_resources_for(ctx),
"resources",
cursor,
handler.page_size(),
) {
Ok((resources, next_cursor)) => {
let mut result = serde_json::json!({ "resources": resources });
if let Some(next) = next_cursor {
result["nextCursor"] = serde_json::Value::String(next);
}
JsonRpcOutgoing::success(id, result)
}
Err(err) => JsonRpcOutgoing::error(id, err),
}
}
"resources/templates/list" => {
let cursor = match cursor_param(request.params.as_ref()) {
Ok(cursor) => cursor,
Err(err) => return JsonRpcOutgoing::error(id, err),
};
match paginate(
handler.list_resource_templates_for(ctx),
"resourceTemplates",
cursor,
handler.page_size(),
) {
Ok((resource_templates, next_cursor)) => {
let mut result = serde_json::json!({ "resourceTemplates": resource_templates });
if let Some(next) = next_cursor {
result["nextCursor"] = serde_json::Value::String(next);
}
JsonRpcOutgoing::success(id, result)
}
Err(err) => JsonRpcOutgoing::error(id, err),
}
}
"resources/read" => {
let params = request.params.unwrap_or_default();
let Some(uri) = params.get("uri").and_then(|v| v.as_str()) else {
return JsonRpcOutgoing::error(
id,
McpError::invalid_params("resources/read requires a string 'uri' parameter"),
);
};
match handler.read_resource(uri, ctx).await {
Ok(result) => match serde_json::to_value(&result) {
Ok(result_value) => JsonRpcOutgoing::success(id, result_value),
Err(e) => JsonRpcOutgoing::error(
id,
McpError::internal(alloc::format!(
"Failed to serialize resource result: {}",
e
)),
),
},
Err(err) => JsonRpcOutgoing::error(id, err),
}
}
"prompts/list" => {
let cursor = match cursor_param(request.params.as_ref()) {
Ok(cursor) => cursor,
Err(err) => return JsonRpcOutgoing::error(id, err),
};
match paginate(
handler.list_prompts_for(ctx),
"prompts",
cursor,
handler.page_size(),
) {
Ok((prompts, next_cursor)) => {
let mut result = serde_json::json!({ "prompts": prompts });
if let Some(next) = next_cursor {
result["nextCursor"] = serde_json::Value::String(next);
}
JsonRpcOutgoing::success(id, result)
}
Err(err) => JsonRpcOutgoing::error(id, err),
}
}
"prompts/get" => {
let params = request.params.unwrap_or_default();
let Some(name) = params.get("name").and_then(|v| v.as_str()) else {
return JsonRpcOutgoing::error(
id,
McpError::invalid_params("prompts/get requires a string 'name' parameter"),
);
};
let args = params.get("arguments").cloned();
match handler.get_prompt(name, args, ctx).await {
Ok(result) => match serde_json::to_value(&result) {
Ok(result_value) => JsonRpcOutgoing::success(id, result_value),
Err(e) => JsonRpcOutgoing::error(
id,
McpError::internal(alloc::format!(
"Failed to serialize prompt result: {}",
e
)),
),
},
Err(err) => JsonRpcOutgoing::error(id, err),
}
}
"tasks/list" => {
let params = request.params.unwrap_or_default();
let cursor = match cursor_param(Some(¶ms)) {
Ok(cursor) => cursor,
Err(err) => return JsonRpcOutgoing::error(id, err),
};
let limit = params
.get("limit")
.and_then(|v| v.as_u64())
.map(|v| v as usize);
match handler.list_tasks(cursor, limit, ctx).await {
Ok(result) => match serde_json::to_value(&result) {
Ok(v) => JsonRpcOutgoing::success(id, v),
Err(e) => JsonRpcOutgoing::error(id, McpError::internal(e.to_string())),
},
Err(err) => JsonRpcOutgoing::error(id, err),
}
}
"tasks/get" => {
let params = request.params.unwrap_or_default();
let Some(task_id) = params.get("taskId").and_then(|v| v.as_str()) else {
return JsonRpcOutgoing::error(id, McpError::invalid_params("Missing taskId"));
};
match handler.get_task(task_id, ctx).await {
Ok(result) => match serde_json::to_value(&result) {
Ok(v) => JsonRpcOutgoing::success(id, v),
Err(e) => JsonRpcOutgoing::error(id, McpError::internal(e.to_string())),
},
Err(err) => JsonRpcOutgoing::error(id, err),
}
}
"tasks/cancel" => {
let params = request.params.unwrap_or_default();
let Some(task_id) = params.get("taskId").and_then(|v| v.as_str()) else {
return JsonRpcOutgoing::error(id, McpError::invalid_params("Missing taskId"));
};
match handler.cancel_task(task_id, ctx).await {
Ok(result) => match serde_json::to_value(&result) {
Ok(v) => JsonRpcOutgoing::success(id, v),
Err(e) => JsonRpcOutgoing::error(id, McpError::internal(e.to_string())),
},
Err(err) => JsonRpcOutgoing::error(id, err),
}
}
"tasks/result" => {
let params = request.params.unwrap_or_default();
let Some(task_id) = params.get("taskId").and_then(|v| v.as_str()) else {
return JsonRpcOutgoing::error(id, McpError::invalid_params("Missing taskId"));
};
match handler.get_task_result(task_id, ctx).await {
Ok(result) => JsonRpcOutgoing::success(id, result),
Err(err) => JsonRpcOutgoing::error(id, err),
}
}
"resources/subscribe" => {
let params = request.params.unwrap_or_default();
let Some(uri) = params.get("uri").and_then(|v| v.as_str()) else {
return JsonRpcOutgoing::error(id, McpError::invalid_params("Missing uri"));
};
match handler.subscribe(uri, ctx).await {
Ok(()) => JsonRpcOutgoing::success(id, serde_json::json!({})),
Err(err) => JsonRpcOutgoing::error(id, err),
}
}
"resources/unsubscribe" => {
let params = request.params.unwrap_or_default();
let Some(uri) = params.get("uri").and_then(|v| v.as_str()) else {
return JsonRpcOutgoing::error(id, McpError::invalid_params("Missing uri"));
};
match handler.unsubscribe(uri, ctx).await {
Ok(()) => JsonRpcOutgoing::success(id, serde_json::json!({})),
Err(err) => JsonRpcOutgoing::error(id, err),
}
}
"logging/setLevel" => {
if handler.server_capabilities().logging.is_none() {
return JsonRpcOutgoing::error(
id,
McpError::capability_not_supported("logging/setLevel"),
);
}
let params = request.params.unwrap_or_default();
let Some(level) = params.get("level").and_then(|v| v.as_str()) else {
return JsonRpcOutgoing::error(id, McpError::invalid_params("Missing level"));
};
if !crate::LOG_LEVELS.contains(&level) {
return JsonRpcOutgoing::error(
id,
McpError::invalid_params(alloc::format!(
"Invalid log level '{level}'; expected one of {}",
crate::LOG_LEVELS.join(", ")
)),
);
}
match handler.set_log_level(level, ctx).await {
Ok(()) => {
#[cfg(feature = "std")]
ctx.set_min_log_level(level);
JsonRpcOutgoing::success(id, serde_json::json!({}))
}
Err(err) => JsonRpcOutgoing::error(id, err),
}
}
"completion/complete" => {
let params = request.params.unwrap_or_default();
if handler.server_capabilities().completions.is_none() {
return JsonRpcOutgoing::error(
id,
McpError::capability_not_supported("completion/complete"),
);
}
let Some(reference) = params.get("ref") else {
return JsonRpcOutgoing::error(
id,
McpError::invalid_params("completion/complete requires a 'ref' parameter"),
);
};
match reference.get("type").and_then(|v| v.as_str()) {
Some("ref/prompt") => {
if reference.get("name").and_then(|v| v.as_str()).is_none() {
return JsonRpcOutgoing::error(
id,
McpError::invalid_params("ref/prompt requires a string 'name'"),
);
}
}
Some("ref/resource") => {
if reference.get("uri").and_then(|v| v.as_str()).is_none() {
return JsonRpcOutgoing::error(
id,
McpError::invalid_params("ref/resource requires a string 'uri'"),
);
}
}
other => {
return JsonRpcOutgoing::error(
id,
McpError::invalid_params(alloc::format!(
"unsupported completion ref type {:?}; expected \
'ref/prompt' or 'ref/resource'",
other.unwrap_or("<missing>")
)),
);
}
}
let argument_ok = params.get("argument").is_some_and(|argument| {
argument.get("name").and_then(|v| v.as_str()).is_some()
&& argument.get("value").and_then(|v| v.as_str()).is_some()
});
if !argument_ok {
return JsonRpcOutgoing::error(
id,
McpError::invalid_params(
"completion/complete requires 'argument' with string 'name' and 'value'",
),
);
}
match handler.complete(params, ctx).await {
Ok(mut value) => {
cap_completion_values(&mut value);
JsonRpcOutgoing::success(id, value)
}
Err(err) => JsonRpcOutgoing::error(id, err),
}
}
"ping" => JsonRpcOutgoing::success(id, serde_json::json!({})),
_ => JsonRpcOutgoing::error(id, McpError::method_not_found(&request.method)),
}
}
fn cap_completion_values(value: &mut Value) {
const MAX_COMPLETION_VALUES: usize = 100;
let Some(completion) = value.get_mut("completion") else {
return;
};
let Some(values) = completion.get_mut("values").and_then(Value::as_array_mut) else {
return;
};
if values.len() <= MAX_COMPLETION_VALUES {
return;
}
values.truncate(MAX_COMPLETION_VALUES);
if let Some(object) = completion.as_object_mut() {
object.insert("hasMore".into(), Value::Bool(true));
}
}
fn build_initialize_result<H: McpHandler>(
info: &ServerInfo,
handler: &H,
protocol_version: &str,
) -> Value {
let capabilities = match serde_json::to_value(handler.server_capabilities()) {
Ok(Value::Object(map)) => map,
Ok(_) | Err(_) => serde_json::Map::new(),
};
let server_info = match serde_json::to_value(info) {
Ok(Value::Object(map)) => map,
Ok(_) | Err(_) => {
let mut fallback = serde_json::Map::new();
fallback.insert("name".to_string(), serde_json::json!(info.name));
fallback.insert("version".to_string(), serde_json::json!(info.version));
fallback
}
};
let mut result = serde_json::Map::new();
result.insert(
"protocolVersion".to_string(),
serde_json::json!(protocol_version),
);
result.insert("capabilities".to_string(), Value::Object(capabilities));
result.insert("serverInfo".to_string(), Value::Object(server_info));
if let Some(instructions) = handler.instructions() {
result.insert("instructions".to_string(), Value::String(instructions));
}
Value::Object(result)
}
pub fn parse_request(input: &str) -> Result<JsonRpcIncoming, McpError> {
let value: serde_json::Value =
serde_json::from_str(input).map_err(|e| McpError::parse_error(e.to_string()))?;
parse_request_from_value(value)
}
pub fn parse_request_from_value(value: serde_json::Value) -> Result<JsonRpcIncoming, McpError> {
let request: JsonRpcIncoming =
serde_json::from_value(value).map_err(|e| McpError::invalid_request(e.to_string()))?;
if !request.is_valid_version() {
return Err(McpError::invalid_request(
"jsonrpc field must be exactly \"2.0\"",
));
}
Ok(request)
}
pub fn serialize_response(response: &JsonRpcOutgoing) -> Result<alloc::string::String, McpError> {
response
.to_json()
.map_err(|e| McpError::internal(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::{ErrorKind, McpResult};
use crate::marker::MaybeSend;
use core::future::Future;
use std::collections::HashMap;
use turbomcp_types::{
Prompt, PromptResult, Resource, ResourceResult, ResourceTemplate, ServerCapabilities,
ServerTasksCapabilities, ServerTasksRequestsCapabilities, TasksCancelCapabilities,
TasksListCapabilities, TasksToolsCallCapabilities, TasksToolsCapabilities, Tool,
ToolResult,
};
#[derive(Clone)]
struct TestHandler;
impl McpHandler for TestHandler {
fn server_info(&self) -> ServerInfo {
ServerInfo::new("test-router", "1.0.0")
}
fn list_tools(&self) -> Vec<Tool> {
vec![Tool::new("greet", "Say hello")]
}
fn list_resources(&self) -> Vec<Resource> {
vec![]
}
fn list_prompts(&self) -> Vec<Prompt> {
vec![]
}
fn call_tool<'a>(
&'a self,
name: &'a str,
args: Value,
_ctx: &'a RequestContext,
) -> impl Future<Output = McpResult<ToolResult>> + MaybeSend + 'a {
let name = name.to_string();
async move {
match name.as_str() {
"greet" => {
let who = args.get("name").and_then(|v| v.as_str()).unwrap_or("World");
Ok(ToolResult::text(alloc::format!("Hello, {}!", who)))
}
_ => Err(McpError::tool_not_found(&name)),
}
}
}
fn read_resource<'a>(
&'a self,
uri: &'a str,
_ctx: &'a RequestContext,
) -> impl Future<Output = McpResult<ResourceResult>> + MaybeSend + 'a {
let uri = uri.to_string();
async move { Err(McpError::resource_not_found(&uri)) }
}
fn get_prompt<'a>(
&'a self,
name: &'a str,
_args: Option<Value>,
_ctx: &'a RequestContext,
) -> impl Future<Output = McpResult<PromptResult>> + MaybeSend + 'a {
let name = name.to_string();
async move { Err(McpError::prompt_not_found(&name)) }
}
}
#[test]
fn test_parse_request() {
let input = r#"{"jsonrpc": "2.0", "id": 1, "method": "ping"}"#;
let request = parse_request(input).unwrap();
assert_eq!(request.method, "ping");
assert_eq!(request.id, Some(serde_json::json!(1)));
}
#[test]
fn test_parse_request_rejects_invalid_jsonrpc_version() {
let input = r#"{"jsonrpc": "1.0", "id": 1, "method": "ping"}"#;
let error = parse_request(input).unwrap_err();
assert_eq!(error.kind, ErrorKind::InvalidRequest);
}
#[test]
fn test_parse_request_rejects_invalid_id_as_invalid_request() {
let input = r#"{"jsonrpc": "2.0", "id": null, "method": "ping"}"#;
let error = parse_request(input).unwrap_err();
assert_eq!(error.kind, ErrorKind::InvalidRequest);
}
#[test]
fn test_serialize_response() {
let response = JsonRpcOutgoing::success(Some(serde_json::json!(1)), serde_json::json!({}));
let serialized = serialize_response(&response).unwrap();
assert!(serialized.contains("\"jsonrpc\":\"2.0\""));
assert!(serialized.contains("\"id\":1"));
}
#[tokio::test]
async fn test_route_initialize() {
let handler = TestHandler;
let ctx = RequestContext::stdio();
let config = RouteConfig::default();
let request = JsonRpcIncoming {
jsonrpc: "2.0".to_string(),
id: Some(serde_json::json!(1)),
method: "initialize".to_string(),
params: Some(serde_json::json!({
"protocolVersion": "2025-11-25",
"clientInfo": {
"name": "test-client",
"version": "1.0.0"
},
"capabilities": {}
})),
};
let response = route_request(&handler, request, &ctx, &config).await;
assert!(response.result.is_some());
assert!(response.error.is_none());
let result = response.result.unwrap();
assert_eq!(result["serverInfo"]["name"], "test-router");
assert!(result["capabilities"]["tools"].is_object());
assert_eq!(result["capabilities"]["tools"]["listChanged"], true);
}
#[tokio::test]
async fn test_route_initialize_preserves_server_info_metadata() {
#[derive(Clone)]
struct MetadataHandler;
#[allow(clippy::manual_async_fn)]
impl McpHandler for MetadataHandler {
fn server_info(&self) -> ServerInfo {
ServerInfo::new("test-router", "1.0.0")
.with_title("Test Router")
.with_description("Initialize metadata should survive serialization")
.with_website_url("https://example.com")
.with_icon(
turbomcp_types::Icon::new("https://example.com/icon.png")
.with_mime_type("image/png"),
)
}
fn list_tools(&self) -> Vec<Tool> {
vec![]
}
fn list_resources(&self) -> Vec<Resource> {
vec![]
}
fn list_prompts(&self) -> Vec<Prompt> {
vec![]
}
fn call_tool<'a>(
&'a self,
_name: &'a str,
_args: Value,
_ctx: &'a RequestContext,
) -> impl Future<Output = McpResult<ToolResult>> + MaybeSend + 'a {
async move { unreachable!("tool calls are not used in this test") }
}
fn read_resource<'a>(
&'a self,
_uri: &'a str,
_ctx: &'a RequestContext,
) -> impl Future<Output = McpResult<ResourceResult>> + MaybeSend + 'a {
async move { unreachable!("resource reads are not used in this test") }
}
fn get_prompt<'a>(
&'a self,
_name: &'a str,
_args: Option<Value>,
_ctx: &'a RequestContext,
) -> impl Future<Output = McpResult<PromptResult>> + MaybeSend + 'a {
async move { unreachable!("prompt reads are not used in this test") }
}
}
let handler = MetadataHandler;
let ctx = RequestContext::stdio();
let config = RouteConfig::default();
let request = JsonRpcIncoming {
jsonrpc: "2.0".to_string(),
id: Some(serde_json::json!(1)),
method: "initialize".to_string(),
params: Some(serde_json::json!({
"protocolVersion": "2025-11-25",
"clientInfo": {
"name": "test-client",
"version": "1.0.0"
},
"capabilities": {}
})),
};
let response = route_request(&handler, request, &ctx, &config).await;
let result = response.result.expect("initialize should succeed");
assert_eq!(result["serverInfo"]["title"], "Test Router");
assert_eq!(
result["serverInfo"]["description"],
"Initialize metadata should survive serialization"
);
assert_eq!(result["serverInfo"]["websiteUrl"], "https://example.com");
assert_eq!(
result["serverInfo"]["icons"][0]["src"],
"https://example.com/icon.png"
);
}
#[tokio::test]
async fn test_task_support_is_enforced_only_where_declared() {
#[derive(Clone)]
struct TaskServer {
declares: bool,
}
#[allow(clippy::manual_async_fn)]
impl McpHandler for TaskServer {
fn server_info(&self) -> ServerInfo {
ServerInfo::new("tasks", "1.0.0")
}
fn server_capabilities(&self) -> ServerCapabilities {
ServerCapabilities {
tasks: self.declares.then(|| ServerTasksCapabilities {
requests: Some(ServerTasksRequestsCapabilities {
tools: Some(TasksToolsCapabilities {
call: Some(TasksToolsCallCapabilities {}),
}),
}),
..Default::default()
}),
..Default::default()
}
}
fn list_tools(&self) -> Vec<Tool> {
let with = |name: &str, support| {
let mut tool = Tool::new(name, name);
tool.execution = Some(turbomcp_types::ToolExecution {
task_support: Some(support),
});
tool
};
vec![
with("slow", turbomcp_types::TaskSupportLevel::Required),
with("quick", turbomcp_types::TaskSupportLevel::Forbidden),
]
}
fn list_resources(&self) -> Vec<Resource> {
vec![]
}
fn list_prompts(&self) -> Vec<Prompt> {
vec![]
}
fn call_tool<'a>(
&'a self,
_name: &'a str,
_args: Value,
_ctx: &'a RequestContext,
) -> impl Future<Output = McpResult<ToolResult>> + MaybeSend + 'a {
async move { Ok(ToolResult::text("ran")) }
}
fn read_resource<'a>(
&'a self,
uri: &'a str,
_ctx: &'a RequestContext,
) -> impl Future<Output = McpResult<ResourceResult>> + MaybeSend + 'a {
async move { Err(McpError::resource_not_found(uri)) }
}
fn get_prompt<'a>(
&'a self,
name: &'a str,
_args: Option<Value>,
_ctx: &'a RequestContext,
) -> impl Future<Output = McpResult<PromptResult>> + MaybeSend + 'a {
async move { Err(McpError::prompt_not_found(name)) }
}
}
let call = |handler: TaskServer, tool: &'static str, task: bool| async move {
let mut params = serde_json::json!({ "name": tool, "arguments": {} });
if task {
params["task"] = serde_json::json!({ "ttl": 1000 });
}
let request = JsonRpcIncoming {
jsonrpc: "2.0".to_string(),
id: Some(serde_json::json!(1)),
method: "tools/call".to_string(),
params: Some(params),
};
route_request(
&handler,
request,
&RequestContext::stdio(),
&RouteConfig::default(),
)
.await
};
let code = |response: JsonRpcOutgoing| response.error.map(|e| e.code);
let declaring = TaskServer { declares: true };
assert_eq!(
code(call(declaring.clone(), "slow", false).await),
Some(-32601)
);
assert_eq!(
code(call(declaring.clone(), "quick", true).await),
Some(-32601)
);
assert_eq!(code(call(declaring, "quick", false).await), None);
let plain = TaskServer { declares: false };
assert_eq!(code(call(plain.clone(), "slow", false).await), None);
assert_eq!(code(call(plain, "quick", true).await), None);
}
#[tokio::test]
async fn test_route_initialize_uses_handler_capabilities() {
#[derive(Clone)]
struct CapabilityHandler;
#[allow(clippy::manual_async_fn)]
impl McpHandler for CapabilityHandler {
fn server_info(&self) -> ServerInfo {
ServerInfo::new("capability-router", "1.0.0")
}
fn server_capabilities(&self) -> ServerCapabilities {
ServerCapabilities {
tasks: Some(ServerTasksCapabilities {
list: Some(TasksListCapabilities {}),
cancel: Some(TasksCancelCapabilities {}),
requests: Some(ServerTasksRequestsCapabilities {
tools: Some(TasksToolsCapabilities {
call: Some(TasksToolsCallCapabilities {}),
}),
}),
}),
extensions: Some(HashMap::from([(
"trace".to_string(),
serde_json::json!({"version": "1"}),
)])),
..Default::default()
}
}
fn list_tools(&self) -> Vec<Tool> {
vec![]
}
fn list_resources(&self) -> Vec<Resource> {
vec![]
}
fn list_prompts(&self) -> Vec<Prompt> {
vec![]
}
fn call_tool<'a>(
&'a self,
_name: &'a str,
_args: Value,
_ctx: &'a RequestContext,
) -> impl Future<Output = McpResult<ToolResult>> + MaybeSend + 'a {
async move { unreachable!("tool calls are not used in this test") }
}
fn read_resource<'a>(
&'a self,
_uri: &'a str,
_ctx: &'a RequestContext,
) -> impl Future<Output = McpResult<ResourceResult>> + MaybeSend + 'a {
async move { unreachable!("resource reads are not used in this test") }
}
fn get_prompt<'a>(
&'a self,
_name: &'a str,
_args: Option<Value>,
_ctx: &'a RequestContext,
) -> impl Future<Output = McpResult<PromptResult>> + MaybeSend + 'a {
async move { unreachable!("prompt reads are not used in this test") }
}
}
let handler = CapabilityHandler;
let ctx = RequestContext::stdio();
let request = JsonRpcIncoming {
jsonrpc: "2.0".to_string(),
id: Some(serde_json::json!(1)),
method: "initialize".to_string(),
params: Some(serde_json::json!({
"protocolVersion": "DRAFT-2026-v1",
"clientInfo": {
"name": "test-client",
"version": "1.0.0"
},
"capabilities": {}
})),
};
let response = route_request(&handler, request, &ctx, &RouteConfig::default()).await;
let result = response.result.expect("initialize should succeed");
assert!(result["capabilities"]["tasks"]["requests"]["tools"]["call"].is_object());
assert_eq!(
result["capabilities"]["extensions"]["trace"]["version"],
"1"
);
}
#[tokio::test]
async fn test_route_initialize_missing_client_info() {
let handler = TestHandler;
let ctx = RequestContext::stdio();
let config = RouteConfig::default();
let request = JsonRpcIncoming {
jsonrpc: "2.0".to_string(),
id: Some(serde_json::json!(1)),
method: "initialize".to_string(),
params: Some(serde_json::json!({
"protocolVersion": "2025-11-25"
})),
};
let response = route_request(&handler, request, &ctx, &config).await;
assert!(response.error.is_some());
let error = response.error.unwrap();
assert_eq!(error.code, -32602); }
#[tokio::test]
async fn test_route_tools_list() {
let handler = TestHandler;
let ctx = RequestContext::stdio();
let config = RouteConfig::default();
let request = JsonRpcIncoming {
jsonrpc: "2.0".to_string(),
id: Some(serde_json::json!(1)),
method: "tools/list".to_string(),
params: None,
};
let response = route_request(&handler, request, &ctx, &config).await;
assert!(response.result.is_some());
let result = response.result.unwrap();
let tools = result["tools"].as_array().unwrap();
assert_eq!(tools.len(), 1);
assert_eq!(tools[0]["name"], "greet");
}
#[tokio::test]
async fn test_route_resource_templates_list() {
#[derive(Clone)]
struct TemplateHandler;
impl McpHandler for TemplateHandler {
fn server_info(&self) -> ServerInfo {
ServerInfo::new("template-router", "1.0.0")
}
fn list_tools(&self) -> Vec<Tool> {
vec![]
}
fn list_resources(&self) -> Vec<Resource> {
vec![]
}
fn list_resource_templates(&self) -> Vec<ResourceTemplate> {
vec![ResourceTemplate::new("file://{path}", "file")]
}
fn list_prompts(&self) -> Vec<Prompt> {
vec![]
}
async fn call_tool<'a>(
&'a self,
_name: &'a str,
_args: Value,
_ctx: &'a RequestContext,
) -> McpResult<ToolResult> {
unreachable!("tool calls are not used in this test")
}
async fn read_resource<'a>(
&'a self,
_uri: &'a str,
_ctx: &'a RequestContext,
) -> McpResult<ResourceResult> {
unreachable!("resource reads are not used in this test")
}
async fn get_prompt<'a>(
&'a self,
_name: &'a str,
_args: Option<Value>,
_ctx: &'a RequestContext,
) -> McpResult<PromptResult> {
unreachable!("prompt reads are not used in this test")
}
}
let ctx = RequestContext::stdio();
let request = JsonRpcIncoming {
jsonrpc: "2.0".to_string(),
id: Some(serde_json::json!(1)),
method: "resources/templates/list".to_string(),
params: None,
};
let response =
route_request(&TemplateHandler, request, &ctx, &RouteConfig::default()).await;
assert!(response.error.is_none());
let result = response.result.expect("resource templates result");
let templates = result["resourceTemplates"].as_array().unwrap();
assert_eq!(templates.len(), 1);
assert_eq!(templates[0]["uriTemplate"], "file://{path}");
assert_eq!(templates[0]["name"], "file");
}
#[tokio::test]
async fn test_route_tools_call() {
let handler = TestHandler;
let ctx = RequestContext::stdio();
let config = RouteConfig::default();
let request = JsonRpcIncoming {
jsonrpc: "2.0".to_string(),
id: Some(serde_json::json!(1)),
method: "tools/call".to_string(),
params: Some(serde_json::json!({
"name": "greet",
"arguments": {"name": "Alice"}
})),
};
let response = route_request(&handler, request, &ctx, &config).await;
assert!(response.result.is_some());
assert!(response.error.is_none());
}
#[tokio::test]
async fn test_route_ping() {
let handler = TestHandler;
let ctx = RequestContext::stdio();
let config = RouteConfig::default();
let request = JsonRpcIncoming {
jsonrpc: "2.0".to_string(),
id: Some(serde_json::json!(1)),
method: "ping".to_string(),
params: None,
};
let response = route_request(&handler, request, &ctx, &config).await;
assert!(response.result.is_some());
assert!(response.error.is_none());
}
#[tokio::test]
async fn test_route_notification() {
let handler = TestHandler;
let ctx = RequestContext::stdio();
let config = RouteConfig::default();
let request = JsonRpcIncoming {
jsonrpc: "2.0".to_string(),
id: None,
method: "notifications/initialized".to_string(),
params: None,
};
let response = route_request(&handler, request, &ctx, &config).await;
assert!(!response.should_send());
}
#[tokio::test]
async fn test_route_request_method_without_id_is_not_sent() {
let handler = TestHandler;
let ctx = RequestContext::stdio();
let config = RouteConfig::default();
let request = JsonRpcIncoming {
jsonrpc: "2.0".to_string(),
id: None,
method: "tools/list".to_string(),
params: None,
};
let response = route_request(&handler, request, &ctx, &config).await;
assert!(!response.should_send());
}
#[tokio::test]
async fn test_route_unknown_method() {
let handler = TestHandler;
let ctx = RequestContext::stdio();
let config = RouteConfig::default();
let request = JsonRpcIncoming {
jsonrpc: "2.0".to_string(),
id: Some(serde_json::json!(1)),
method: "unknown/method".to_string(),
params: None,
};
let response = route_request(&handler, request, &ctx, &config).await;
assert!(response.error.is_some());
let error = response.error.unwrap();
assert_eq!(error.code, -32601); }
#[tokio::test]
async fn test_route_with_custom_protocol_version() {
let handler = TestHandler;
let ctx = RequestContext::stdio();
let config = RouteConfig {
protocol_version: Some("2025-11-25"),
};
let request = JsonRpcIncoming {
jsonrpc: "2.0".to_string(),
id: Some(serde_json::json!(1)),
method: "initialize".to_string(),
params: Some(serde_json::json!({
"protocolVersion": "2025-11-25",
"clientInfo": {
"name": "test-client",
"version": "1.0.0"
}
})),
};
let response = route_request(&handler, request, &ctx, &config).await;
let result = response.result.unwrap();
assert_eq!(result["protocolVersion"], "2025-11-25");
}
}