Skip to main content

boost/
server.rs

1//! The MCP server. Reads newline-delimited JSON-RPC requests from stdin,
2//! dispatches to the tool registry, writes responses to stdout.
3//!
4//! Trace output goes to stderr (already the default for tracing-subscriber),
5//! so log lines don't corrupt the protocol channel.
6
7use std::collections::HashMap;
8use std::sync::Arc;
9
10use serde_json::{json, Value};
11use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
12
13use anvil_core::Application;
14
15use crate::log_capture::LogBuffer;
16use crate::protocol::{
17    CallToolParams, CallToolResult, ContentBlock, InitializeResult, JsonRpcRequest,
18    JsonRpcResponse, ListToolsResult, ServerCapabilities, ServerInfo, ToolDescriptor,
19    ToolsCapability, PROTOCOL_VERSION,
20};
21use crate::tool::{Context, Tool};
22
23pub struct Server {
24    ctx: Arc<Context>,
25    tools: HashMap<&'static str, Arc<dyn Tool>>,
26}
27
28impl Server {
29    /// Build a server using every built-in tool. Most apps just call this.
30    pub fn with_defaults(app: &Application, log_buffer: Arc<LogBuffer>) -> Self {
31        let ctx = Arc::new(Context::from_app(app, log_buffer));
32        let mut tools: HashMap<&'static str, Arc<dyn Tool>> = HashMap::new();
33        for tool in crate::tools::all() {
34            tools.insert(tool.name(), tool);
35        }
36        Self { ctx, tools }
37    }
38
39    pub fn register(&mut self, tool: Arc<dyn Tool>) {
40        self.tools.insert(tool.name(), tool);
41    }
42
43    /// Run the server loop on stdin/stdout until EOF.
44    pub async fn serve_stdio(self) -> std::io::Result<()> {
45        let stdin = tokio::io::stdin();
46        let mut stdout = tokio::io::stdout();
47        let mut reader = BufReader::new(stdin).lines();
48
49        while let Some(line) = reader.next_line().await? {
50            if line.trim().is_empty() {
51                continue;
52            }
53            let response = self.handle_line(&line).await;
54            if let Some(resp) = response {
55                let bytes = serde_json::to_vec(&resp).unwrap_or_else(|_| b"{}".to_vec());
56                stdout.write_all(&bytes).await?;
57                stdout.write_all(b"\n").await?;
58                stdout.flush().await?;
59            }
60        }
61        Ok(())
62    }
63
64    async fn handle_line(&self, line: &str) -> Option<JsonRpcResponse> {
65        let req: JsonRpcRequest = match serde_json::from_str(line) {
66            Ok(r) => r,
67            Err(e) => {
68                tracing::warn!(error = %e, line, "boost: failed to parse JSON-RPC request");
69                return None;
70            }
71        };
72
73        let id = req.id.clone().unwrap_or(Value::Null);
74        let is_notification = req.id.is_none();
75
76        let result = match req.method.as_str() {
77            "initialize" => self.handle_initialize(),
78            "notifications/initialized" | "initialized" => {
79                // No response for notifications.
80                if is_notification {
81                    return None;
82                }
83                Ok(json!({}))
84            }
85            "tools/list" => self.handle_list_tools(),
86            "tools/call" => self.handle_call_tool(&req.params).await,
87            "ping" => Ok(json!({})),
88            other => Err(format!("method not implemented: {other}")),
89        };
90
91        if is_notification {
92            return None;
93        }
94
95        Some(match result {
96            Ok(value) => JsonRpcResponse::ok(id, value),
97            Err(msg) => JsonRpcResponse::err(id, -32601, msg),
98        })
99    }
100
101    fn handle_initialize(&self) -> Result<Value, String> {
102        let result = InitializeResult {
103            protocol_version: PROTOCOL_VERSION,
104            capabilities: ServerCapabilities {
105                tools: ToolsCapability {
106                    list_changed: false,
107                },
108            },
109            server_info: ServerInfo {
110                name: "anvilforge-boost",
111                version: env!("CARGO_PKG_VERSION"),
112            },
113        };
114        serde_json::to_value(result).map_err(|e| e.to_string())
115    }
116
117    fn handle_list_tools(&self) -> Result<Value, String> {
118        let mut descriptors: Vec<ToolDescriptor> = self
119            .tools
120            .values()
121            .map(|t| ToolDescriptor {
122                name: t.name().to_string(),
123                description: t.description().to_string(),
124                input_schema: t.input_schema(),
125            })
126            .collect();
127        descriptors.sort_by(|a, b| a.name.cmp(&b.name));
128        serde_json::to_value(ListToolsResult { tools: descriptors }).map_err(|e| e.to_string())
129    }
130
131    async fn handle_call_tool(&self, params: &Value) -> Result<Value, String> {
132        let parsed: CallToolParams =
133            serde_json::from_value(params.clone()).map_err(|e| format!("bad params: {e}"))?;
134        let Some(tool) = self.tools.get(parsed.name.as_str()) else {
135            return Ok(serde_json::to_value(CallToolResult {
136                content: vec![ContentBlock::Text {
137                    text: format!("unknown tool: {}", parsed.name),
138                }],
139                is_error: true,
140            })
141            .unwrap());
142        };
143        let result = tool.call(&self.ctx, parsed.arguments).await;
144        serde_json::to_value(result).map_err(|e| e.to_string())
145    }
146}
147
148/// Convenience entry: install log capture + build server with defaults + serve.
149/// This is what user apps call from their `"mcp"` subcommand case.
150pub async fn serve(app: &Application) -> std::io::Result<()> {
151    let buffer = crate::log_capture::install();
152    Server::with_defaults(app, buffer).serve_stdio().await
153}