use std::{
io::{self, BufRead, Write},
sync::Arc,
};
use llm_tool::{ToolContext, ToolDefinition, ToolRegistry};
use tracing::{debug, error, info};
use crate::protocol::{
self, Capabilities, ContentItem, InitializeResult, JsonRpcRequest, JsonRpcResponse,
McpToolSchema, ServerInfo, ToolCallParams, ToolCallResult, ToolCapabilities, ToolsListResult,
};
pub struct McpServer {
name: String,
version: String,
registry: ToolRegistry,
context: ToolContext,
cached_tools_list: Arc<ToolsListResult>,
}
impl McpServer {
#[must_use]
pub fn new(
name: impl Into<String>,
version: impl Into<String>,
registry: ToolRegistry,
) -> Self {
let cached_tools_list = Arc::new(build_tools_list_response(®istry));
Self {
name: name.into(),
version: version.into(),
registry,
context: ToolContext::new(None),
cached_tools_list,
}
}
#[must_use]
pub fn with_context(mut self, context: ToolContext) -> Self {
self.context = context;
self
}
#[must_use]
pub fn registry(&self) -> &ToolRegistry {
&self.registry
}
pub fn run_stdio(&self) -> io::Result<()> {
self.run(io::stdin().lock(), io::stdout().lock())
}
pub fn run(&self, reader: impl BufRead, mut writer: impl Write) -> io::Result<()> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
self.run_with_runtime(&rt, reader, &mut writer)
}
pub fn run_with_runtime(
&self,
rt: &tokio::runtime::Runtime,
reader: impl BufRead,
writer: &mut impl Write,
) -> io::Result<()> {
for line_result in reader.lines() {
let line = line_result?;
if line.trim().is_empty() {
continue;
}
debug!(request = %line, "mcp request");
let response = rt.block_on(self.handle_request(&line));
let json = serde_json::to_string(&response).map_err(|e| {
error!(error = %e, "failed to serialize JSON-RPC response");
io::Error::other(e)
})?;
debug!(response = %json, "mcp response");
writeln!(writer, "{json}")?;
writer.flush()?;
}
info!("input stream closed — shutting down");
Ok(())
}
pub async fn handle_request(&self, line: &str) -> JsonRpcResponse {
let request: JsonRpcRequest = match serde_json::from_str(line) {
Ok(r) => r,
Err(e) => {
return JsonRpcResponse::error(
None,
protocol::PARSE_ERROR,
format!("invalid JSON: {e}"),
);
}
};
let id = request.id.clone();
match request.method.as_str() {
"initialize" => self.handle_initialize(id),
"notifications/initialized" | "initialized" => {
JsonRpcResponse::success(id, serde_json::Map::new())
}
"tools/list" => self.handle_tools_list(id),
"tools/call" => self.handle_tools_call(id, request.params).await,
other => JsonRpcResponse::error(
id,
protocol::METHOD_NOT_FOUND,
format!("unknown method: {other}"),
),
}
}
fn handle_initialize(&self, id: Option<serde_json::Value>) -> JsonRpcResponse {
info!(server = %self.name, version = %self.version, "MCP initialize");
JsonRpcResponse::success(
id,
InitializeResult {
protocol_version: "2024-11-05",
server_info: ServerInfo {
name: self.name.clone(),
version: self.version.clone(),
},
capabilities: Capabilities {
tools: ToolCapabilities {},
},
},
)
}
fn handle_tools_list(&self, id: Option<serde_json::Value>) -> JsonRpcResponse {
info!(count = self.registry.len(), "tools/list");
JsonRpcResponse::success(id, (*self.cached_tools_list).clone())
}
async fn handle_tools_call(
&self,
id: Option<serde_json::Value>,
params: Option<serde_json::Value>,
) -> JsonRpcResponse {
let Some(raw_params) = params else {
return JsonRpcResponse::error(
id,
protocol::INVALID_PARAMS,
"tools/call requires params with 'name' and 'arguments'",
);
};
let call_params: ToolCallParams = match serde_json::from_value(raw_params) {
Ok(p) => p,
Err(e) => {
return JsonRpcResponse::error(
id,
protocol::INVALID_PARAMS,
format!("invalid tools/call params: {e}"),
);
}
};
debug!(tool = %call_params.name, "tools/call");
match self
.registry
.dispatch(&call_params.name, call_params.arguments, &self.context)
.await
{
Ok(output) => JsonRpcResponse::success(
id,
ToolCallResult {
content: vec![ContentItem {
content_type: "text",
text: output.content().to_owned(),
}],
is_error: false,
},
),
Err(e) => {
JsonRpcResponse::success(
id,
ToolCallResult {
content: vec![ContentItem {
content_type: "text",
text: e.to_string(),
}],
is_error: true,
},
)
}
}
}
}
fn build_tools_list_response(registry: &ToolRegistry) -> ToolsListResult {
let tools = registry
.definitions()
.iter()
.map(definition_to_mcp_schema)
.collect();
ToolsListResult { tools }
}
fn definition_to_mcp_schema(def: &ToolDefinition) -> McpToolSchema {
McpToolSchema {
name: def.name.clone(),
description: def.description.clone(),
input_schema: def.parameter_schema.clone(),
}
}
#[cfg(test)]
mod tests {
use std::io::Cursor;
use llm_tool::{EmptyParams, RustTool, ToolError, ToolOutput};
use super::*;
#[derive(serde::Deserialize, schemars::JsonSchema)]
struct AddParams {
a: i64,
b: i64,
}
struct AddTool;
impl RustTool for AddTool {
type Params = AddParams;
const NAME: &'static str = "add";
const DESCRIPTION: &'static str = "Adds two numbers.";
async fn call(
&self,
params: Self::Params,
_ctx: &ToolContext,
) -> Result<ToolOutput, ToolError> {
Ok(ToolOutput::new(format!("{}", params.a + params.b)))
}
}
struct FailTool;
impl RustTool for FailTool {
type Params = EmptyParams;
const NAME: &'static str = "fail";
const DESCRIPTION: &'static str = "Always fails.";
async fn call(
&self,
_params: Self::Params,
_ctx: &ToolContext,
) -> Result<ToolOutput, ToolError> {
Err(ToolError::new("intentional failure"))
}
}
struct ContextTool;
impl RustTool for ContextTool {
type Params = EmptyParams;
const NAME: &'static str = "whoami";
const DESCRIPTION: &'static str = "Returns the caller identity from context.";
async fn call(
&self,
_params: Self::Params,
ctx: &ToolContext,
) -> Result<ToolOutput, ToolError> {
Ok(ToolOutput::new(
ctx.conversation_id().unwrap_or("anonymous").to_owned(),
))
}
}
fn test_server() -> McpServer {
let registry = ToolRegistry::new()
.with_tool(AddTool)
.with_tool(FailTool)
.with_tool(ContextTool);
McpServer::new("test-server", "0.0.1", registry)
}
#[tokio::test]
async fn initialize_returns_server_info() {
let server = test_server();
let resp = server
.handle_request(r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#)
.await;
assert!(resp.error.is_none());
let result = resp.result.unwrap();
assert_eq!(result["protocolVersion"], "2024-11-05");
assert_eq!(result["serverInfo"]["name"], "test-server");
assert_eq!(result["serverInfo"]["version"], "0.0.1");
assert!(result["capabilities"]["tools"].is_object());
}
#[tokio::test]
async fn tools_list_returns_all_registered_tools() {
let server = test_server();
let resp = server
.handle_request(r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#)
.await;
assert!(resp.error.is_none());
let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone();
assert_eq!(tools.len(), 3);
let mut names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
names.sort_unstable();
assert_eq!(names, vec!["add", "fail", "whoami"]);
for tool in &tools {
assert!(tool["name"].is_string());
assert!(tool["description"].is_string());
assert!(tool["inputSchema"].is_object());
}
}
#[tokio::test]
async fn tools_list_returns_cached_value() {
let server = test_server();
let resp1 = server
.handle_request(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#)
.await;
let resp2 = server
.handle_request(r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#)
.await;
assert_eq!(
resp1.result.unwrap()["tools"],
resp2.result.unwrap()["tools"]
);
}
#[tokio::test]
async fn tools_call_success() {
let server = test_server();
let resp = server
.handle_request(
r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"add","arguments":{"a":17,"b":25}}}"#,
)
.await;
assert!(resp.error.is_none());
let result = resp.result.unwrap();
let text = result["content"][0]["text"].as_str().unwrap();
assert_eq!(text, "42");
assert!(result.get("isError").is_none());
}
#[tokio::test]
async fn tools_call_tool_error_returns_is_error() {
let server = test_server();
let resp = server
.handle_request(
r#"{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"fail","arguments":{}}}"#,
)
.await;
assert!(resp.error.is_none());
let result = resp.result.unwrap();
assert_eq!(result["isError"], true);
assert!(
result["content"][0]["text"]
.as_str()
.unwrap()
.contains("intentional failure")
);
}
#[tokio::test]
async fn tools_call_unknown_tool() {
let server = test_server();
let resp = server
.handle_request(
r#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"nonexistent","arguments":{}}}"#,
)
.await;
assert!(resp.error.is_none());
let result = resp.result.unwrap();
assert_eq!(result["isError"], true);
}
#[tokio::test]
async fn tools_call_missing_name() {
let server = test_server();
let resp = server
.handle_request(
r#"{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"arguments":{}}}"#,
)
.await;
assert!(resp.result.is_none());
let err = resp.error.unwrap();
assert_eq!(err.code, protocol::INVALID_PARAMS);
}
#[tokio::test]
async fn tools_call_missing_params() {
let server = test_server();
let resp = server
.handle_request(r#"{"jsonrpc":"2.0","id":7,"method":"tools/call"}"#)
.await;
assert!(resp.result.is_none());
let err = resp.error.unwrap();
assert_eq!(err.code, protocol::INVALID_PARAMS);
}
#[tokio::test]
async fn tools_call_with_default_arguments() {
let server = test_server();
let resp = server
.handle_request(
r#"{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"fail"}}"#,
)
.await;
assert!(resp.error.is_none());
let result = resp.result.unwrap();
assert_eq!(result["isError"], true);
}
#[tokio::test]
async fn unknown_method() {
let server = test_server();
let resp = server
.handle_request(r#"{"jsonrpc":"2.0","id":9,"method":"resources/list"}"#)
.await;
let err = resp.error.unwrap();
assert_eq!(err.code, protocol::METHOD_NOT_FOUND);
assert!(err.message.contains("resources/list"));
}
#[tokio::test]
async fn invalid_json() {
let server = test_server();
let resp = server.handle_request("not json at all").await;
let err = resp.error.unwrap();
assert_eq!(err.code, protocol::PARSE_ERROR);
}
#[tokio::test]
async fn initialized_notification_is_accepted() {
let server = test_server();
let resp = server
.handle_request(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#)
.await;
assert!(resp.error.is_none());
}
#[tokio::test]
async fn context_is_passed_to_tools() {
let registry = ToolRegistry::new().with_tool(ContextTool);
let ctx = ToolContext::new(Some("agent-007".into()));
let server = McpServer::new("test", "1.0", registry).with_context(ctx);
let resp = server
.handle_request(
r#"{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"whoami","arguments":{}}}"#,
)
.await;
let text = resp.result.unwrap()["content"][0]["text"]
.as_str()
.unwrap()
.to_owned();
assert_eq!(text, "agent-007");
}
#[test]
fn run_processes_multiple_requests() {
let server = test_server();
let input = [
r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#,
r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#,
r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"add","arguments":{"a":1,"b":2}}}"#,
"",
r#"{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"fail","arguments":{}}}"#,
];
let input_str = input.join("\n") + "\n";
let reader = Cursor::new(input_str.as_bytes());
let mut output = Vec::new();
server.run(reader, &mut output).unwrap();
let responses: Vec<serde_json::Value> = String::from_utf8(output)
.unwrap()
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
assert_eq!(responses.len(), 4);
assert_eq!(responses[0]["result"]["serverInfo"]["name"], "test-server");
assert_eq!(responses[1]["result"]["tools"].as_array().unwrap().len(), 3);
assert_eq!(responses[2]["result"]["content"][0]["text"], "3");
assert!(responses[2]["result"].get("isError").is_none());
assert_eq!(responses[3]["result"]["isError"], true);
}
#[test]
fn run_with_runtime_reuses_external_runtime() {
let server = test_server();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let input = r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"add","arguments":{"a":10,"b":20}}}"#;
let reader = Cursor::new(format!("{input}\n"));
let mut output = Vec::new();
server.run_with_runtime(&rt, reader, &mut output).unwrap();
let resp: serde_json::Value =
serde_json::from_str(String::from_utf8(output).unwrap().trim()).unwrap();
assert_eq!(resp["result"]["content"][0]["text"], "30");
}
#[test]
fn registry_accessor() {
let server = test_server();
assert_eq!(server.registry().len(), 3);
}
#[test]
fn definition_to_mcp_schema_has_correct_keys() {
let def = ToolDefinition {
name: "my_tool".into(),
description: "Does stuff.".into(),
parameter_schema: serde_json::json!({"type": "object"}),
};
let schema = definition_to_mcp_schema(&def);
assert_eq!(schema.name, "my_tool");
assert_eq!(schema.description, "Does stuff.");
assert_eq!(schema.input_schema["type"], "object");
}
}