| 1 | //! MCP server implementation for exposing Codewhale tools over stdio. |
| 2 | |
| 3 | use std::collections::HashSet; |
| 4 | use std::path::PathBuf; |
| 5 | |
| 6 | use anyhow::{Context, Result}; |
| 7 | use serde::Deserialize; |
| 8 | use serde_json::{Value, json}; |
| 9 | use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; |
| 10 | |
| 11 | use crate::session_manager::SessionManager; |
| 12 | use crate::tools::spec::{ToolError, ToolResult}; |
| 13 | use crate::tools::{ToolContext, ToolRegistryBuilder}; |
| 14 | |
| 15 | #[derive(Debug, Default, Deserialize)] |
| 16 | struct McpServerConfigFile { |
| 17 | #[serde(default)] |
| 18 | server: McpServerSection, |
| 19 | } |
| 20 | |
| 21 | #[derive(Debug, Default, Deserialize)] |
| 22 | struct McpServerSection { |
| 23 | expose_tools: Option<Vec<String>>, |
| 24 | require_approval: Option<bool>, |
| 25 | } |
| 26 | |
| 27 | #[derive(Debug, Clone)] |
| 28 | struct McpServerSettings { |
| 29 | expose_tools: Vec<String>, |
| 30 | require_approval: bool, |
| 31 | } |
| 32 | |
| 33 | impl McpServerSettings { |
| 34 | fn load() -> Result<Self> { |
| 35 | let path = default_config_path(); |
| 36 | if let Some(path) = path.filter(|p| p.exists()) { |
| 37 | let contents = std::fs::read_to_string(&path) |
| 38 | .with_context(|| format!("Failed to read MCP server config: {}", path.display()))?; |
| 39 | let config: McpServerConfigFile = toml::from_str(&contents).with_context(|| { |
| 40 | format!("Failed to parse MCP server config: {}", path.display()) |
| 41 | })?; |
| 42 | let expose_tools = config |
| 43 | .server |
| 44 | .expose_tools |
| 45 | .unwrap_or_else(default_expose_tools); |
| 46 | let require_approval = config.server.require_approval.unwrap_or(false); |
| 47 | Ok(Self { |
| 48 | expose_tools, |
| 49 | require_approval, |
| 50 | }) |
| 51 | } else { |
| 52 | Ok(Self { |
| 53 | expose_tools: default_expose_tools(), |
| 54 | require_approval: false, |
| 55 | }) |
| 56 | } |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | #[derive(Debug, Clone)] |
| 61 | struct ExposedTool { |
| 62 | public: String, |
| 63 | internal: String, |
| 64 | } |
| 65 | |
| 66 | pub async fn run_mcp_server(workspace: PathBuf) -> Result<()> { |
| 67 | // Settings load is a synchronous config read; keep it off the async |
| 68 | // worker per the blocking-call convention. |
| 69 | let settings = tokio::task::spawn_blocking(McpServerSettings::load) |
| 70 | .await |
| 71 | .context("MCP server settings task failed")??; |
| 72 | let mut server = McpServer::new(workspace, settings)?; |
| 73 | server.run().await |
| 74 | } |
| 75 | |
| 76 | struct McpServer { |
| 77 | workspace: PathBuf, |
| 78 | registry: crate::tools::ToolRegistry, |
| 79 | exposed_tools: Vec<ExposedTool>, |
| 80 | require_approval: bool, |
| 81 | } |
| 82 | |
| 83 | impl McpServer { |
| 84 | fn new(workspace: PathBuf, settings: McpServerSettings) -> Result<Self> { |
| 85 | let exposed_tools = build_exposed_tools(&settings.expose_tools); |
| 86 | let mut internal_names: HashSet<String> = HashSet::new(); |
| 87 | for tool in &exposed_tools { |
| 88 | internal_names.insert(tool.internal.clone()); |
| 89 | } |
| 90 | |
| 91 | let mut builder = ToolRegistryBuilder::new() |
| 92 | .with_file_tools() |
| 93 | .with_search_tools(); |
| 94 | |
| 95 | if internal_names.contains("apply_patch") { |
| 96 | builder = builder.with_patch_tools(); |
| 97 | } |
| 98 | if internal_names.contains("exec_shell") { |
| 99 | builder = builder.with_shell_tools(); |
| 100 | } |
| 101 | |
| 102 | let context = ToolContext::new(workspace.clone()); |
| 103 | let registry = builder.build(context); |
| 104 | |
| 105 | Ok(Self { |
| 106 | workspace, |
| 107 | registry, |
| 108 | exposed_tools, |
| 109 | require_approval: settings.require_approval, |
| 110 | }) |
| 111 | } |
| 112 | |
| 113 | /// The serialized stdio loop runs on the caller's runtime: a JSON-RPC |
| 114 | /// stdio server answers one request at a time by definition, so it |
| 115 | /// needs no private `Runtime` and no `block_on` (#6140). |
| 116 | async fn run(&mut self) -> Result<()> { |
| 117 | let stdin = tokio::io::BufReader::new(tokio::io::stdin()); |
| 118 | let mut stdout = tokio::io::stdout(); |
| 119 | let mut lines = stdin.lines(); |
| 120 | |
| 121 | while let Some(line) = lines.next_line().await? { |
| 122 | let trimmed = line.trim(); |
| 123 | if trimmed.is_empty() { |
| 124 | continue; |
| 125 | } |
| 126 | let Ok(message) = serde_json::from_str::<Value>(trimmed) else { |
| 127 | continue; |
| 128 | }; |
| 129 | |
| 130 | if let Some(response) = self.handle_message(message).await { |
| 131 | let payload = serde_json::to_string(&response)?; |
| 132 | stdout.write_all(payload.as_bytes()).await?; |
| 133 | stdout.write_all(b"\n").await?; |
| 134 | stdout.flush().await?; |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | Ok(()) |
| 139 | } |
| 140 | |
| 141 | async fn handle_message(&mut self, message: Value) -> Option<Value> { |
| 142 | let method = message.get("method").and_then(Value::as_str)?; |
| 143 | let id = message.get("id").cloned(); |
| 144 | |
| 145 | match method { |
| 146 | "initialize" => respond( |
| 147 | id.as_ref(), |
| 148 | initialize_response( |
| 149 | message |
| 150 | .pointer("/params/protocolVersion") |
| 151 | .and_then(Value::as_str), |
| 152 | ), |
| 153 | ), |
| 154 | "tools/list" => respond(id.as_ref(), self.list_tools_response()), |
| 155 | "tools/call" => { |
| 156 | let params = message.get("params").cloned().unwrap_or_else(|| json!({})); |
| 157 | match self.call_tool(params).await { |
| 158 | Ok(result) => respond(id.as_ref(), result), |
| 159 | Err(err) => respond_error(id.as_ref(), err.code, err.message), |
| 160 | } |
| 161 | } |
| 162 | "resources/list" => respond(id.as_ref(), self.list_resources_response().await), |
| 163 | "ping" => respond(id.as_ref(), json!({})), |
| 164 | "notifications/initialized" => None, |
| 165 | _ => respond_error(id.as_ref(), -32601, format!("Method not found: {method}")), |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | fn list_tools_response(&self) -> Value { |
| 170 | let mut tools = Vec::new(); |
| 171 | let mut seen = HashSet::new(); |
| 172 | for entry in &self.exposed_tools { |
| 173 | if !seen.insert(entry.public.clone()) { |
| 174 | continue; |
| 175 | } |
| 176 | if let Some(tool) = self.registry.get(&entry.internal) { |
| 177 | tools.push(json!({ |
| 178 | "name": entry.public, |
| 179 | "description": tool.description(), |
| 180 | "inputSchema": tool.input_schema(), |
| 181 | })); |
| 182 | } |
| 183 | } |
| 184 | // MCP spec: `nextCursor` must be omitted (or be a string) when there |
| 185 | // are no more results. Emitting `null` violates the spec and breaks |
| 186 | // strict clients (e.g. Claude Code) that validate the response shape. |
| 187 | json!({ "tools": tools }) |
| 188 | } |
| 189 | |
| 190 | async fn list_resources_response(&self) -> Value { |
| 191 | let mut resources = Vec::new(); |
| 192 | resources.push(json!({ |
| 193 | "uri": format!("file://{}", self.workspace.display()), |
| 194 | "name": "workspace", |
| 195 | "description": "Workspace root", |
| 196 | "mimeType": "inode/directory", |
| 197 | })); |
| 198 | |
| 199 | // `SessionManager` does synchronous filesystem work; the listing is |
| 200 | // a borrow-free unit so it can run on the blocking pool. |
| 201 | let sessions = tokio::task::spawn_blocking(|| { |
| 202 | SessionManager::default_location().and_then(|manager| manager.list_sessions()) |
| 203 | }) |
| 204 | .await |
| 205 | .ok() |
| 206 | .and_then(Result::ok) |
| 207 | .unwrap_or_default(); |
| 208 | for session in sessions { |
| 209 | resources.push(json!({ |
| 210 | "uri": format!("codewhale://session/{}", session.id), |
| 211 | "name": session.title, |
| 212 | "description": format!("{} messages", session.message_count), |
| 213 | "mimeType": "application/json", |
| 214 | })); |
| 215 | } |
| 216 | |
| 217 | // Same spec point as `list_tools_response`: omit `nextCursor` when |
| 218 | // there are no further pages rather than emitting `null`. |
| 219 | json!({ "resources": resources }) |
| 220 | } |
| 221 | |
| 222 | async fn call_tool(&mut self, params: Value) -> Result<Value, RpcError> { |
| 223 | let params = params.as_object().ok_or_else(|| RpcError { |
| 224 | code: -32602, |
| 225 | message: "Invalid params for tools/call".to_string(), |
| 226 | })?; |
| 227 | let name = params |
| 228 | .get("name") |
| 229 | .and_then(Value::as_str) |
| 230 | .ok_or_else(|| RpcError { |
| 231 | code: -32602, |
| 232 | message: "Missing tool name".to_string(), |
| 233 | })?; |
| 234 | |
| 235 | if self.require_approval |
| 236 | && !params |
| 237 | .get("approved") |
| 238 | .and_then(Value::as_bool) |
| 239 | .unwrap_or(false) |
| 240 | { |
| 241 | return Err(RpcError { |
| 242 | code: -32001, |
| 243 | message: "Approval required. Resend with approved=true.".to_string(), |
| 244 | }); |
| 245 | } |
| 246 | |
| 247 | let internal = self |
| 248 | .exposed_tools |
| 249 | .iter() |
| 250 | .find(|tool| tool.public == name) |
| 251 | .map(|tool| tool.internal.clone()) |
| 252 | .ok_or_else(|| RpcError { |
| 253 | code: -32602, |
| 254 | message: format!("Tool not exposed: {name}"), |
| 255 | })?; |
| 256 | |
| 257 | let arguments = params |
| 258 | .get("arguments") |
| 259 | .cloned() |
| 260 | .unwrap_or_else(|| json!({})); |
| 261 | let result = self.registry.execute_full(&internal, arguments).await; |
| 262 | Ok(tool_result_to_mcp(result)) |
| 263 | } |
| 264 | } |
| 265 | |
| 266 | fn default_config_path() -> Option<PathBuf> { |
| 267 | crate::config::effective_home_dir().map(|home| home.join(".deepseek").join("mcp_server.toml")) |
| 268 | } |
| 269 | |
| 270 | fn default_expose_tools() -> Vec<String> { |
| 271 | vec![ |
| 272 | "file_read".to_string(), |
| 273 | "file_write".to_string(), |
| 274 | "search".to_string(), |
| 275 | "apply_patch".to_string(), |
| 276 | "shell".to_string(), |
| 277 | ] |
| 278 | } |
| 279 | |
| 280 | fn build_exposed_tools(names: &[String]) -> Vec<ExposedTool> { |
| 281 | let mut tools = Vec::new(); |
| 282 | for name in names { |
| 283 | let trimmed = name.trim(); |
| 284 | if trimmed.is_empty() { |
| 285 | continue; |
| 286 | } |
| 287 | let public = trimmed.to_string(); |
| 288 | let internal = match trimmed { |
| 289 | "file_read" => "read_file", |
| 290 | "file_write" => "write_file", |
| 291 | "file_edit" => "edit_file", |
| 292 | "shell" => "exec_shell", |
| 293 | "search" => "grep_files", |
| 294 | "file_search" => "file_search", |
| 295 | other => other, |
| 296 | } |
| 297 | .to_string(); |
| 298 | tools.push(ExposedTool { public, internal }); |
| 299 | } |
| 300 | tools |
| 301 | } |
| 302 | |
| 303 | fn tool_result_to_mcp(result: Result<ToolResult, ToolError>) -> Value { |
| 304 | match result { |
| 305 | Ok(tool_result) => { |
| 306 | let mut response = json!({ |
| 307 | "content": [{ "type": "text", "text": tool_result.content }], |
| 308 | "isError": !tool_result.success, |
| 309 | }); |
| 310 | if let Some(metadata) = tool_result.metadata { |
| 311 | response["structuredContent"] = metadata; |
| 312 | } |
| 313 | response |
| 314 | } |
| 315 | Err(err) => json!({ |
| 316 | "content": [{ "type": "text", "text": err.to_string() }], |
| 317 | "isError": true, |
| 318 | }), |
| 319 | } |
| 320 | } |
| 321 | |
| 322 | fn initialize_response(requested: Option<&str>) -> Value { |
| 323 | // Per spec, echo the requested revision when we support it; otherwise |
| 324 | // answer with the newest revision we do support and let the client decide. |
| 325 | let negotiated = match requested { |
| 326 | Some(version) if crate::mcp::MCP_SUPPORTED_PROTOCOL_VERSIONS.contains(&version) => version, |
| 327 | _ => crate::mcp::MCP_PROTOCOL_VERSION, |
| 328 | }; |
| 329 | json!({ |
| 330 | "protocolVersion": negotiated, |
| 331 | "serverInfo": { |
| 332 | "name": "codewhale-mcp-server", |
| 333 | "version": env!("CARGO_PKG_VERSION"), |
| 334 | }, |
| 335 | "capabilities": { |
| 336 | "tools": {}, |
| 337 | "resources": {}, |
| 338 | } |
| 339 | }) |
| 340 | } |
| 341 | |
| 342 | fn respond(id: Option<&Value>, result: Value) -> Option<Value> { |
| 343 | id.map(|id| json!({ "jsonrpc": "2.0", "id": id, "result": result })) |
| 344 | } |
| 345 | |
| 346 | fn respond_error(id: Option<&Value>, code: i64, message: String) -> Option<Value> { |
| 347 | id.map(|id| { |
| 348 | json!({ |
| 349 | "jsonrpc": "2.0", |
| 350 | "id": id, |
| 351 | "error": { "code": code, "message": message } |
| 352 | }) |
| 353 | }) |
| 354 | } |
| 355 | |
| 356 | #[derive(Debug)] |
| 357 | struct RpcError { |
| 358 | code: i64, |
| 359 | message: String, |
| 360 | } |
| 361 | |
| 362 | #[cfg(test)] |
| 363 | mod tests { |
| 364 | use super::*; |
| 365 | use std::collections::HashMap; |
| 366 | |
| 367 | #[test] |
| 368 | fn exposed_tools_map_aliases() { |
| 369 | let names = vec![ |
| 370 | "file_read".to_string(), |
| 371 | "file_write".to_string(), |
| 372 | "search".to_string(), |
| 373 | "apply_patch".to_string(), |
| 374 | "shell".to_string(), |
| 375 | ]; |
| 376 | let tools = build_exposed_tools(&names); |
| 377 | let mut map = HashMap::new(); |
| 378 | for tool in tools { |
| 379 | map.insert(tool.public, tool.internal); |
| 380 | } |
| 381 | assert_eq!(map.get("file_read").map(String::as_str), Some("read_file")); |
| 382 | assert_eq!( |
| 383 | map.get("file_write").map(String::as_str), |
| 384 | Some("write_file") |
| 385 | ); |
| 386 | assert_eq!(map.get("search").map(String::as_str), Some("grep_files")); |
| 387 | assert_eq!( |
| 388 | map.get("apply_patch").map(String::as_str), |
| 389 | Some("apply_patch") |
| 390 | ); |
| 391 | assert_eq!(map.get("shell").map(String::as_str), Some("exec_shell")); |
| 392 | } |
| 393 | |
| 394 | #[tokio::test] |
| 395 | async fn list_responses_omit_null_next_cursor() { |
| 396 | // MCP spec: `nextCursor` must be omitted (or be a string) when there |
| 397 | // are no further pages. Emitting `null` breaks strict clients such as |
| 398 | // Claude Code, which validate the response shape. |
| 399 | let settings = McpServerSettings { |
| 400 | expose_tools: vec!["file_read".to_string(), "apply_patch".to_string()], |
| 401 | require_approval: false, |
| 402 | }; |
| 403 | let server = McpServer::new(PathBuf::from("."), settings).expect("build server"); |
| 404 | |
| 405 | let tools_value = server.list_tools_response(); |
| 406 | let tools = tools_value |
| 407 | .as_object() |
| 408 | .expect("tools/list response is an object"); |
| 409 | assert!(tools.contains_key("tools")); |
| 410 | assert!( |
| 411 | tools.get("nextCursor").is_none(), |
| 412 | "tools/list must omit nextCursor when there are no more pages" |
| 413 | ); |
| 414 | |
| 415 | let resources_value = server.list_resources_response().await; |
| 416 | let resources = resources_value |
| 417 | .as_object() |
| 418 | .expect("resources/list response is an object"); |
| 419 | assert!(resources.contains_key("resources")); |
| 420 | assert!( |
| 421 | resources.get("nextCursor").is_none(), |
| 422 | "resources/list must omit nextCursor when there are no more pages" |
| 423 | ); |
| 424 | } |
| 425 | |
| 426 | #[tokio::test] |
| 427 | async fn retired_deepseek_tools_are_not_exposed() { |
| 428 | // #6140: the `deepseek`/`deepseek-reply` tools called a provider |
| 429 | // client directly — a second model authority beside the engine. |
| 430 | // Configs still naming them degrade to "tool not exposed" rather |
| 431 | // than silently running. |
| 432 | let settings = McpServerSettings { |
| 433 | expose_tools: vec!["deepseek".to_string(), "deepseek-reply".to_string()], |
| 434 | require_approval: false, |
| 435 | }; |
| 436 | let mut server = McpServer::new(PathBuf::from("."), settings).expect("build server"); |
| 437 | |
| 438 | let tools = server.list_tools_response(); |
| 439 | assert_eq!( |
| 440 | tools["tools"].as_array().map(Vec::len), |
| 441 | Some(0), |
| 442 | "retired tools must not be advertised: {tools}" |
| 443 | ); |
| 444 | |
| 445 | let response = server |
| 446 | .handle_message(json!({ |
| 447 | "jsonrpc": "2.0", |
| 448 | "id": 1, |
| 449 | "method": "tools/call", |
| 450 | "params": {"name": "deepseek", "arguments": {"prompt": "hi"}} |
| 451 | })) |
| 452 | .await; |
| 453 | // The name resolves through `exposed_tools` but no registry tool |
| 454 | // backs it, so the call answers with an isError result. |
| 455 | let response = response.expect("tools/call responds"); |
| 456 | assert_eq!(response["result"]["isError"], json!(true), "{response}"); |
| 457 | } |
| 458 | |
| 459 | #[test] |
| 460 | fn initialize_uses_standard_mcp_shape_and_codewhale_identity() { |
| 461 | let response = initialize_response(Some(crate::mcp::MCP_PROTOCOL_VERSION)); |
| 462 | assert_eq!( |
| 463 | response["protocolVersion"], |
| 464 | crate::mcp::MCP_PROTOCOL_VERSION |
| 465 | ); |
| 466 | assert_eq!(response["serverInfo"]["name"], "codewhale-mcp-server"); |
| 467 | assert_eq!(response["serverInfo"]["version"], env!("CARGO_PKG_VERSION")); |
| 468 | assert!(response["capabilities"]["tools"].is_object()); |
| 469 | } |
| 470 | |
| 471 | #[test] |
| 472 | fn initialize_negotiates_supported_revisions() { |
| 473 | // A client asking for an older dated revision gets it echoed back; |
| 474 | // an unknown or missing revision answers with the newest supported. |
| 475 | for requested in ["2025-03-26", "2024-11-05"] { |
| 476 | let response = initialize_response(Some(requested)); |
| 477 | assert_eq!(response["protocolVersion"], requested); |
| 478 | } |
| 479 | for requested in [Some("2099-01-01"), None] { |
| 480 | let response = initialize_response(requested); |
| 481 | assert_eq!( |
| 482 | response["protocolVersion"], |
| 483 | crate::mcp::MCP_PROTOCOL_VERSION |
| 484 | ); |
| 485 | } |
| 486 | } |
| 487 | } |
| 488 |