| 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::{AsyncRead, AsyncWrite, 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 | /// Withhold tools that write files or run commands. This stdio server has |
| 25 | /// no channel for an out-of-band approval prompt, so "approval required" |
| 26 | /// means those tools are refused. Defaults to `true`; only the operator's |
| 27 | /// own config file can turn it off. |
| 28 | require_approval: Option<bool>, |
| 29 | } |
| 30 | |
| 31 | #[derive(Debug, Clone)] |
| 32 | struct McpServerSettings { |
| 33 | expose_tools: Vec<String>, |
| 34 | require_approval: bool, |
| 35 | } |
| 36 | |
| 37 | impl McpServerSettings { |
| 38 | fn load() -> Result<Self> { |
| 39 | let path = default_config_path(); |
| 40 | if let Some(path) = path.filter(|p| p.exists()) { |
| 41 | let contents = std::fs::read_to_string(&path) |
| 42 | .with_context(|| format!("Failed to read MCP server config: {}", path.display()))?; |
| 43 | Self::from_toml(&contents) |
| 44 | .with_context(|| format!("Failed to parse MCP server config: {}", path.display())) |
| 45 | } else { |
| 46 | Ok(Self { |
| 47 | expose_tools: default_expose_tools(), |
| 48 | require_approval: true, |
| 49 | }) |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | fn from_toml(contents: &str) -> Result<Self> { |
| 54 | let config: McpServerConfigFile = toml::from_str(contents)?; |
| 55 | Ok(Self { |
| 56 | expose_tools: config |
| 57 | .server |
| 58 | .expose_tools |
| 59 | .unwrap_or_else(default_expose_tools), |
| 60 | require_approval: config.server.require_approval.unwrap_or(true), |
| 61 | }) |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | #[derive(Debug, Clone)] |
| 66 | struct ExposedTool { |
| 67 | public: String, |
| 68 | internal: String, |
| 69 | } |
| 70 | |
| 71 | pub async fn run_mcp_server(workspace: PathBuf) -> Result<()> { |
| 72 | // Settings load is a synchronous config read; keep it off the async |
| 73 | // worker per the blocking-call convention. |
| 74 | let settings = tokio::task::spawn_blocking(McpServerSettings::load) |
| 75 | .await |
| 76 | .context("MCP server settings task failed")??; |
| 77 | let mut server = McpServer::new(workspace, settings)?; |
| 78 | // stdout carries the protocol; the notice goes to stderr so an operator |
| 79 | // whose config lists write tools sees why they are missing. |
| 80 | for name in server.withheld_tools() { |
| 81 | eprintln!( |
| 82 | "codewhale mcp server: not exposing '{name}': it writes files or runs \ |
| 83 | commands and require_approval is on. Set require_approval = false under \ |
| 84 | [server] in the MCP server config to allow it." |
| 85 | ); |
| 86 | } |
| 87 | server.run().await |
| 88 | } |
| 89 | |
| 90 | struct McpServer { |
| 91 | workspace: PathBuf, |
| 92 | registry: crate::tools::ToolRegistry, |
| 93 | exposed_tools: Vec<ExposedTool>, |
| 94 | require_approval: bool, |
| 95 | phase: SessionPhase, |
| 96 | } |
| 97 | |
| 98 | #[derive(Clone, Copy, PartialEq, Eq)] |
| 99 | enum SessionPhase { |
| 100 | Uninitialized, |
| 101 | InitializeResponded, |
| 102 | Ready, |
| 103 | } |
| 104 | |
| 105 | impl McpServer { |
| 106 | fn new(workspace: PathBuf, settings: McpServerSettings) -> Result<Self> { |
| 107 | let exposed_tools = build_exposed_tools(&settings.expose_tools); |
| 108 | let mut internal_names: HashSet<String> = HashSet::new(); |
| 109 | for tool in &exposed_tools { |
| 110 | internal_names.insert(tool.internal.clone()); |
| 111 | } |
| 112 | |
| 113 | let mut builder = ToolRegistryBuilder::new() |
| 114 | .with_file_tools() |
| 115 | .with_search_tools(); |
| 116 | |
| 117 | if internal_names.contains("apply_patch") { |
| 118 | builder = builder.with_patch_tools(); |
| 119 | } |
| 120 | if internal_names.contains("exec_shell") { |
| 121 | builder = builder.with_shell_tools(); |
| 122 | } |
| 123 | |
| 124 | let context = ToolContext::new(workspace.clone()); |
| 125 | let registry = builder.build(context); |
| 126 | |
| 127 | Ok(Self { |
| 128 | workspace, |
| 129 | registry, |
| 130 | exposed_tools, |
| 131 | require_approval: settings.require_approval, |
| 132 | phase: SessionPhase::Uninitialized, |
| 133 | }) |
| 134 | } |
| 135 | |
| 136 | /// The serialized stdio loop runs on the caller's runtime: a JSON-RPC |
| 137 | /// stdio server answers one request at a time by definition, so it |
| 138 | /// needs no private `Runtime` and no `block_on` (#6140). |
| 139 | async fn run(&mut self) -> Result<()> { |
| 140 | self.run_io(tokio::io::stdin(), tokio::io::stdout()).await |
| 141 | } |
| 142 | |
| 143 | async fn run_io<R: AsyncRead + Unpin, W: AsyncWrite + Unpin>( |
| 144 | &mut self, |
| 145 | input: R, |
| 146 | mut output: W, |
| 147 | ) -> Result<()> { |
| 148 | let mut reader = tokio::io::BufReader::new(input); |
| 149 | let mut frame = Vec::new(); |
| 150 | loop { |
| 151 | let response = match crate::mcp::read_line_capped( |
| 152 | &mut reader, |
| 153 | &mut frame, |
| 154 | crate::mcp::MAX_MCP_RESPONSE_BYTES, |
| 155 | ) |
| 156 | .await |
| 157 | { |
| 158 | Ok(0) => break, |
| 159 | Ok(_) if frame.iter().all(u8::is_ascii_whitespace) => { |
| 160 | frame.clear(); |
| 161 | continue; |
| 162 | } |
| 163 | Ok(_) => match serde_json::from_slice(&frame) { |
| 164 | Ok(message) => self.handle_message(message).await, |
| 165 | Err(_) => respond_error(Some(&Value::Null), -32700, "Invalid JSON".into()), |
| 166 | }, |
| 167 | Err(err) => { |
| 168 | let response = respond_error( |
| 169 | Some(&Value::Null), |
| 170 | -32700, |
| 171 | "Invalid or oversized JSON-RPC frame".into(), |
| 172 | ); |
| 173 | if let Some(response) = response { |
| 174 | output.write_all(response.to_string().as_bytes()).await?; |
| 175 | output.write_all(b"\n").await?; |
| 176 | output.flush().await?; |
| 177 | } |
| 178 | return Err(err).context("Failed to read bounded MCP input"); |
| 179 | } |
| 180 | }; |
| 181 | frame.clear(); |
| 182 | if let Some(response) = response { |
| 183 | let payload = serde_json::to_vec(&response)?; |
| 184 | output.write_all(&payload).await?; |
| 185 | output.write_all(b"\n").await?; |
| 186 | output.flush().await?; |
| 187 | } |
| 188 | } |
| 189 | Ok(()) |
| 190 | } |
| 191 | |
| 192 | async fn handle_message(&mut self, message: Value) -> Option<Value> { |
| 193 | let id = message.get("id"); |
| 194 | let method = message.get("method").and_then(Value::as_str); |
| 195 | if !message.is_object() |
| 196 | || message.get("jsonrpc").and_then(Value::as_str) != Some("2.0") |
| 197 | || method.is_none_or(str::is_empty) |
| 198 | || id.is_some_and(|id| !(id.is_null() || id.is_string() || id.is_i64() || id.is_u64())) |
| 199 | || message |
| 200 | .get("params") |
| 201 | .is_some_and(|params| !params.is_object()) |
| 202 | { |
| 203 | return respond_error( |
| 204 | Some(&Value::Null), |
| 205 | -32600, |
| 206 | "Invalid JSON-RPC request".into(), |
| 207 | ); |
| 208 | } |
| 209 | let method = method.unwrap_or_default(); |
| 210 | if matches!(method, "tools/list" | "tools/call" | "resources/list") |
| 211 | && self.phase != SessionPhase::Ready |
| 212 | { |
| 213 | return respond_error( |
| 214 | id, |
| 215 | -32600, |
| 216 | "A completed initialize / notifications/initialized handshake is required".into(), |
| 217 | ); |
| 218 | } |
| 219 | |
| 220 | match method { |
| 221 | "initialize" => { |
| 222 | // A notification has no response carrying the negotiated version |
| 223 | // and cannot advance the handshake. |
| 224 | id?; |
| 225 | if self.phase != SessionPhase::Uninitialized { |
| 226 | return respond_error(id, -32600, "Initialize may only be sent once".into()); |
| 227 | } |
| 228 | let requested = message |
| 229 | .pointer("/params/protocolVersion") |
| 230 | .and_then(Value::as_str); |
| 231 | if requested.is_none_or(|version| version.trim().is_empty()) |
| 232 | || ["name", "version"].iter().any(|field| { |
| 233 | message["params"]["clientInfo"][*field] |
| 234 | .as_str() |
| 235 | .is_none_or(|value| value.trim().is_empty()) |
| 236 | }) |
| 237 | || !message["params"]["capabilities"].is_object() |
| 238 | { |
| 239 | return respond_error(id, -32602, "Invalid MCP initialize parameters".into()); |
| 240 | } |
| 241 | self.phase = SessionPhase::InitializeResponded; |
| 242 | respond(id, initialize_response(requested)) |
| 243 | } |
| 244 | "notifications/initialized" => { |
| 245 | if id.is_none() && self.phase == SessionPhase::InitializeResponded { |
| 246 | self.phase = SessionPhase::Ready; |
| 247 | None |
| 248 | } else { |
| 249 | respond_error( |
| 250 | id, |
| 251 | -32600, |
| 252 | "Expected initialized notification after initialize".into(), |
| 253 | ) |
| 254 | } |
| 255 | } |
| 256 | "tools/list" => respond(id, self.list_tools_response()), |
| 257 | "tools/call" => { |
| 258 | // Calls without an identity are notifications and must not run |
| 259 | // a tool whose result the client cannot acknowledge. |
| 260 | id?; |
| 261 | let params = message.get("params").cloned().unwrap_or_else(|| json!({})); |
| 262 | match self.call_tool(params).await { |
| 263 | Ok(result) => respond(id, result), |
| 264 | Err(err) => respond_error(id, err.code, err.message), |
| 265 | } |
| 266 | } |
| 267 | "resources/list" => respond(id, self.list_resources_response().await), |
| 268 | "ping" => respond(id, json!({})), |
| 269 | _ => respond_error(id, -32601, format!("Method not found: {method}")), |
| 270 | } |
| 271 | } |
| 272 | |
| 273 | fn list_tools_response(&self) -> Value { |
| 274 | let mut tools = Vec::new(); |
| 275 | let mut seen = HashSet::new(); |
| 276 | for entry in &self.exposed_tools { |
| 277 | if !seen.insert(entry.public.clone()) { |
| 278 | continue; |
| 279 | } |
| 280 | if let Some(tool) = self.registry.get(&entry.internal) { |
| 281 | // A tool this server would refuse is not advertised. |
| 282 | if self.require_approval && !tool.is_read_only() { |
| 283 | continue; |
| 284 | } |
| 285 | tools.push(json!({ |
| 286 | "name": entry.public, |
| 287 | "description": tool.description(), |
| 288 | "inputSchema": tool.input_schema(), |
| 289 | })); |
| 290 | } |
| 291 | } |
| 292 | // MCP spec: `nextCursor` must be omitted (or be a string) when there |
| 293 | // are no more results. Emitting `null` violates the spec and breaks |
| 294 | // strict clients (e.g. Claude Code) that validate the response shape. |
| 295 | json!({ "tools": tools }) |
| 296 | } |
| 297 | |
| 298 | /// Configured tools that `require_approval` keeps out of `tools/list`. |
| 299 | fn withheld_tools(&self) -> Vec<String> { |
| 300 | if !self.require_approval { |
| 301 | return Vec::new(); |
| 302 | } |
| 303 | let mut seen = HashSet::new(); |
| 304 | self.exposed_tools |
| 305 | .iter() |
| 306 | .filter(|entry| seen.insert(entry.public.clone())) |
| 307 | .filter(|entry| { |
| 308 | self.registry |
| 309 | .get(&entry.internal) |
| 310 | .is_some_and(|tool| !tool.is_read_only()) |
| 311 | }) |
| 312 | .map(|entry| entry.public.clone()) |
| 313 | .collect() |
| 314 | } |
| 315 | |
| 316 | async fn list_resources_response(&self) -> Value { |
| 317 | let mut resources = Vec::new(); |
| 318 | resources.push(json!({ |
| 319 | "uri": format!("file://{}", self.workspace.display()), |
| 320 | "name": "workspace", |
| 321 | "description": "Workspace root", |
| 322 | "mimeType": "inode/directory", |
| 323 | })); |
| 324 | |
| 325 | // `SessionManager` does synchronous filesystem work; the listing is |
| 326 | // a borrow-free unit so it can run on the blocking pool. |
| 327 | let sessions = tokio::task::spawn_blocking(|| { |
| 328 | SessionManager::default_location().and_then(|manager| manager.list_sessions()) |
| 329 | }) |
| 330 | .await |
| 331 | .ok() |
| 332 | .and_then(Result::ok) |
| 333 | .unwrap_or_default(); |
| 334 | for session in sessions { |
| 335 | resources.push(json!({ |
| 336 | "uri": format!("codewhale://session/{}", session.id), |
| 337 | "name": session.title, |
| 338 | "description": format!("{} messages", session.message_count), |
| 339 | "mimeType": "application/json", |
| 340 | })); |
| 341 | } |
| 342 | |
| 343 | // Same spec point as `list_tools_response`: omit `nextCursor` when |
| 344 | // there are no further pages rather than emitting `null`. |
| 345 | json!({ "resources": resources }) |
| 346 | } |
| 347 | |
| 348 | async fn call_tool(&mut self, params: Value) -> Result<Value, RpcError> { |
| 349 | let params = params.as_object().ok_or_else(|| RpcError { |
| 350 | code: -32602, |
| 351 | message: "Invalid params for tools/call".to_string(), |
| 352 | })?; |
| 353 | let name = params |
| 354 | .get("name") |
| 355 | .and_then(Value::as_str) |
| 356 | .ok_or_else(|| RpcError { |
| 357 | code: -32602, |
| 358 | message: "Missing tool name".to_string(), |
| 359 | })?; |
| 360 | |
| 361 | let internal = self |
| 362 | .exposed_tools |
| 363 | .iter() |
| 364 | .find(|tool| tool.public == name) |
| 365 | .map(|tool| tool.internal.clone()) |
| 366 | .ok_or_else(|| RpcError { |
| 367 | code: -32602, |
| 368 | message: format!("Tool not exposed: {name}"), |
| 369 | })?; |
| 370 | |
| 371 | let arguments = params |
| 372 | .get("arguments") |
| 373 | .cloned() |
| 374 | .unwrap_or_else(|| json!({})); |
| 375 | if !arguments.is_object() { |
| 376 | return Err(RpcError { |
| 377 | code: -32602, |
| 378 | message: "Tool arguments must be an object".into(), |
| 379 | }); |
| 380 | } |
| 381 | // Approval comes from the operator's config, never from the caller: |
| 382 | // a request cannot vouch for itself. |
| 383 | if self.require_approval |
| 384 | && self |
| 385 | .registry |
| 386 | .get(&internal) |
| 387 | .is_some_and(|tool| !(tool.is_read_only() && tool.is_read_only_for(&arguments))) |
| 388 | { |
| 389 | return Err(RpcError { |
| 390 | code: -32001, |
| 391 | message: format!( |
| 392 | "Tool '{name}' writes files or runs commands and needs approval, which \ |
| 393 | this server cannot request. Set require_approval = false in the MCP \ |
| 394 | server config to allow it." |
| 395 | ), |
| 396 | }); |
| 397 | } |
| 398 | let result = self.registry.execute_full(&internal, arguments).await; |
| 399 | Ok(tool_result_to_mcp(result)) |
| 400 | } |
| 401 | } |
| 402 | |
| 403 | fn default_config_path() -> Option<PathBuf> { |
| 404 | crate::config::effective_home_dir().map(|home| home.join(".deepseek").join("mcp_server.toml")) |
| 405 | } |
| 406 | |
| 407 | /// Read-only by default: writing or executing tools must be named in the |
| 408 | /// operator's config and allowed with `require_approval = false`. |
| 409 | fn default_expose_tools() -> Vec<String> { |
| 410 | vec!["file_read".to_string(), "search".to_string()] |
| 411 | } |
| 412 | |
| 413 | fn build_exposed_tools(names: &[String]) -> Vec<ExposedTool> { |
| 414 | let mut tools = Vec::new(); |
| 415 | for name in names { |
| 416 | let trimmed = name.trim(); |
| 417 | if trimmed.is_empty() { |
| 418 | continue; |
| 419 | } |
| 420 | let public = trimmed.to_string(); |
| 421 | let internal = match trimmed { |
| 422 | "file_read" => "read_file", |
| 423 | "file_write" => "write_file", |
| 424 | "file_edit" => "edit_file", |
| 425 | "shell" => "exec_shell", |
| 426 | "search" => "grep_files", |
| 427 | "file_search" => "file_search", |
| 428 | other => other, |
| 429 | } |
| 430 | .to_string(); |
| 431 | tools.push(ExposedTool { public, internal }); |
| 432 | } |
| 433 | tools |
| 434 | } |
| 435 | |
| 436 | fn tool_result_to_mcp(result: Result<ToolResult, ToolError>) -> Value { |
| 437 | match result { |
| 438 | Ok(tool_result) => { |
| 439 | let mut response = json!({ |
| 440 | "content": [{ "type": "text", "text": tool_result.content }], |
| 441 | "isError": !tool_result.success, |
| 442 | }); |
| 443 | if let Some(metadata) = tool_result.metadata { |
| 444 | response["structuredContent"] = metadata; |
| 445 | } |
| 446 | response |
| 447 | } |
| 448 | Err(err) => json!({ |
| 449 | "content": [{ "type": "text", "text": err.to_string() }], |
| 450 | "isError": true, |
| 451 | }), |
| 452 | } |
| 453 | } |
| 454 | |
| 455 | fn initialize_response(requested: Option<&str>) -> Value { |
| 456 | // Per spec, echo the requested revision when we support it; otherwise |
| 457 | // answer with the newest revision we do support and let the client decide. |
| 458 | let negotiated = match requested { |
| 459 | Some(version) if crate::mcp::MCP_SUPPORTED_PROTOCOL_VERSIONS.contains(&version) => version, |
| 460 | _ => crate::mcp::MCP_PROTOCOL_VERSION, |
| 461 | }; |
| 462 | json!({ |
| 463 | "protocolVersion": negotiated, |
| 464 | "serverInfo": { |
| 465 | "name": "codewhale-mcp-server", |
| 466 | "version": env!("CARGO_PKG_VERSION"), |
| 467 | }, |
| 468 | "capabilities": { |
| 469 | "tools": {}, |
| 470 | "resources": {}, |
| 471 | } |
| 472 | }) |
| 473 | } |
| 474 | |
| 475 | fn respond(id: Option<&Value>, result: Value) -> Option<Value> { |
| 476 | id.map(|id| json!({ "jsonrpc": "2.0", "id": id, "result": result })) |
| 477 | } |
| 478 | |
| 479 | fn respond_error(id: Option<&Value>, code: i64, message: String) -> Option<Value> { |
| 480 | id.map(|id| { |
| 481 | json!({ |
| 482 | "jsonrpc": "2.0", |
| 483 | "id": id, |
| 484 | "error": { "code": code, "message": message } |
| 485 | }) |
| 486 | }) |
| 487 | } |
| 488 | |
| 489 | #[derive(Debug)] |
| 490 | struct RpcError { |
| 491 | code: i64, |
| 492 | message: String, |
| 493 | } |
| 494 | |
| 495 | #[cfg(test)] |
| 496 | mod tests { |
| 497 | use super::*; |
| 498 | use std::collections::HashMap; |
| 499 | |
| 500 | fn initialize_request() -> Value { |
| 501 | json!({ |
| 502 | "jsonrpc": "2.0", "id": 0, "method": "initialize", |
| 503 | "params": { |
| 504 | "protocolVersion": "2024-11-05", |
| 505 | "clientInfo": {"name": "native-server-test", "version": "1"}, |
| 506 | "capabilities": {} |
| 507 | } |
| 508 | }) |
| 509 | } |
| 510 | |
| 511 | async fn complete_handshake(server: &mut McpServer) { |
| 512 | let response = server.handle_message(initialize_request()).await.unwrap(); |
| 513 | assert_eq!(response["result"]["protocolVersion"], "2024-11-05"); |
| 514 | assert!( |
| 515 | server |
| 516 | .handle_message(json!({ |
| 517 | "jsonrpc": "2.0", "method": "notifications/initialized" |
| 518 | })) |
| 519 | .await |
| 520 | .is_none() |
| 521 | ); |
| 522 | } |
| 523 | |
| 524 | #[tokio::test] |
| 525 | async fn native_server_validates_identity_without_echoing_request_data() { |
| 526 | let mut server = McpServer::new( |
| 527 | PathBuf::from("."), |
| 528 | McpServerSettings { |
| 529 | expose_tools: default_expose_tools(), |
| 530 | require_approval: true, |
| 531 | }, |
| 532 | ) |
| 533 | .unwrap(); |
| 534 | for request in [ |
| 535 | json!([]), |
| 536 | Value::Null, |
| 537 | json!({"method": "ping"}), |
| 538 | json!({"jsonrpc": "2", "id": 1, "method": "ping"}), |
| 539 | json!({"jsonrpc": "2.0", "id": true, "method": "ping"}), |
| 540 | json!({"jsonrpc": "2.0", "id": 1.5, "method": "ping"}), |
| 541 | json!({"jsonrpc": "2.0", "id": 1, "method": 7}), |
| 542 | json!({"jsonrpc": "2.0", "id": 1, "method": "ping", "params": "PRIVATE_TOKEN=sentinel"}), |
| 543 | ] { |
| 544 | let response = server.handle_message(request).await.unwrap(); |
| 545 | assert!(response["id"].is_null(), "{response}"); |
| 546 | assert_eq!(response["error"]["code"], -32600, "{response}"); |
| 547 | assert!(!response.to_string().contains("PRIVATE_TOKEN")); |
| 548 | assert!(!response.to_string().contains("sentinel")); |
| 549 | } |
| 550 | let response = server |
| 551 | .handle_message(json!({"jsonrpc": "2.0", "id": null, "method": "ping"})) |
| 552 | .await |
| 553 | .unwrap(); |
| 554 | assert!(response["id"].is_null()); |
| 555 | assert_eq!(response["result"], json!({})); |
| 556 | assert!( |
| 557 | server |
| 558 | .handle_message(json!({"jsonrpc": "2.0", "method": "ping"})) |
| 559 | .await |
| 560 | .is_none() |
| 561 | ); |
| 562 | } |
| 563 | |
| 564 | #[tokio::test] |
| 565 | async fn native_server_requires_completed_handshake_before_any_tool_effect() { |
| 566 | let workspace = tempfile::tempdir().unwrap(); |
| 567 | let mut server = McpServer::new( |
| 568 | workspace.path().to_path_buf(), |
| 569 | McpServerSettings { |
| 570 | expose_tools: vec!["file_write".into()], |
| 571 | require_approval: false, |
| 572 | }, |
| 573 | ) |
| 574 | .unwrap(); |
| 575 | let write = json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { |
| 576 | "name": "file_write", "arguments": {"path": "canary.txt", "content": "written"} |
| 577 | }}); |
| 578 | let response = server.handle_message(write.clone()).await.unwrap(); |
| 579 | assert_eq!(response["error"]["code"], -32600); |
| 580 | assert!(!workspace.path().join("canary.txt").exists()); |
| 581 | assert!( |
| 582 | server |
| 583 | .handle_message(json!({"jsonrpc": "2.0", "method": "notifications/initialized"})) |
| 584 | .await |
| 585 | .is_none() |
| 586 | ); |
| 587 | let mut notification = initialize_request(); |
| 588 | notification.as_object_mut().unwrap().remove("id"); |
| 589 | assert!(server.handle_message(notification).await.is_none()); |
| 590 | assert_eq!( |
| 591 | server.handle_message(write.clone()).await.unwrap()["error"]["code"], |
| 592 | -32600 |
| 593 | ); |
| 594 | let mut malformed = initialize_request(); |
| 595 | malformed["params"]["clientInfo"]["name"] = json!(""); |
| 596 | assert_eq!( |
| 597 | server.handle_message(malformed).await.unwrap()["error"]["code"], |
| 598 | -32602 |
| 599 | ); |
| 600 | let mut initialize = initialize_request(); |
| 601 | initialize["id"] = Value::Null; |
| 602 | let response = server.handle_message(initialize).await.unwrap(); |
| 603 | assert!(response["id"].is_null()); |
| 604 | assert_eq!(response["result"]["protocolVersion"], "2024-11-05"); |
| 605 | assert_eq!( |
| 606 | server.handle_message(initialize_request()).await.unwrap()["error"]["code"], |
| 607 | -32600 |
| 608 | ); |
| 609 | assert_eq!( |
| 610 | server.handle_message(write.clone()).await.unwrap()["error"]["code"], |
| 611 | -32600 |
| 612 | ); |
| 613 | assert_eq!( |
| 614 | server |
| 615 | .handle_message( |
| 616 | json!({"jsonrpc": "2.0", "id": 3, "method": "notifications/initialized"}) |
| 617 | ) |
| 618 | .await |
| 619 | .unwrap()["error"]["code"], |
| 620 | -32600 |
| 621 | ); |
| 622 | assert_eq!( |
| 623 | server.handle_message(write.clone()).await.unwrap()["error"]["code"], |
| 624 | -32600 |
| 625 | ); |
| 626 | assert!( |
| 627 | server |
| 628 | .handle_message(json!({"jsonrpc": "2.0", "method": "notifications/initialized"})) |
| 629 | .await |
| 630 | .is_none() |
| 631 | ); |
| 632 | let mut unacknowledged = write.clone(); |
| 633 | unacknowledged.as_object_mut().unwrap().remove("id"); |
| 634 | assert!(server.handle_message(unacknowledged).await.is_none()); |
| 635 | assert!(!workspace.path().join("canary.txt").exists()); |
| 636 | let response = server.handle_message(write).await.unwrap(); |
| 637 | assert!(response.get("error").is_none(), "{response}"); |
| 638 | assert_eq!( |
| 639 | std::fs::read_to_string(workspace.path().join("canary.txt")).unwrap(), |
| 640 | "written" |
| 641 | ); |
| 642 | } |
| 643 | |
| 644 | async fn run_native_pipe(input: &[u8]) -> (Result<()>, Vec<u8>) { |
| 645 | use tokio::io::AsyncReadExt; |
| 646 | let mut server = McpServer::new( |
| 647 | PathBuf::from("."), |
| 648 | McpServerSettings { |
| 649 | expose_tools: default_expose_tools(), |
| 650 | require_approval: true, |
| 651 | }, |
| 652 | ) |
| 653 | .unwrap(); |
| 654 | let (client, server_io) = tokio::io::duplex(8192); |
| 655 | let (mut reader, mut writer) = tokio::io::split(client); |
| 656 | let (server_reader, server_writer) = tokio::io::split(server_io); |
| 657 | tokio::time::timeout(std::time::Duration::from_secs(5), async { |
| 658 | tokio::join!(server.run_io(server_reader, server_writer), async { |
| 659 | // Oversized input deliberately closes the server before the |
| 660 | // writer finishes. Both halves still settle without a task. |
| 661 | let _ = writer.write_all(input).await; |
| 662 | let _ = writer.shutdown().await; |
| 663 | drop(writer); |
| 664 | let mut output = Vec::new(); |
| 665 | reader.read_to_end(&mut output).await.unwrap(); |
| 666 | output |
| 667 | }) |
| 668 | }) |
| 669 | .await |
| 670 | .expect("the bounded server pipe must settle") |
| 671 | } |
| 672 | |
| 673 | #[tokio::test] |
| 674 | async fn native_pipe_reports_parse_error_and_preserves_the_following_frame() { |
| 675 | let (result, output) = run_native_pipe(b"{\"PRIVATE_TOKEN\":\"sentinel\",\n{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"ping\"}\n").await; |
| 676 | result.unwrap(); |
| 677 | let text = String::from_utf8(output).unwrap(); |
| 678 | assert!(!text.contains("PRIVATE_TOKEN")); |
| 679 | assert!(!text.contains("sentinel")); |
| 680 | let responses: Vec<Value> = text |
| 681 | .lines() |
| 682 | .map(|line| serde_json::from_str(line).unwrap()) |
| 683 | .collect(); |
| 684 | assert_eq!(responses.len(), 2); |
| 685 | assert_eq!(responses[0]["error"]["code"], -32700); |
| 686 | assert!(responses[0]["id"].is_null()); |
| 687 | assert_eq!(responses[1]["id"], 7); |
| 688 | assert_eq!(responses[1]["result"], json!({})); |
| 689 | } |
| 690 | |
| 691 | #[tokio::test] |
| 692 | async fn native_pipe_refuses_oversized_unterminated_input() { |
| 693 | let input = vec![b'x'; crate::mcp::MAX_MCP_RESPONSE_BYTES + 8192]; |
| 694 | let (result, output) = run_native_pipe(&input).await; |
| 695 | assert!(result.is_err()); |
| 696 | let response: Value = serde_json::from_slice(&output).unwrap(); |
| 697 | assert_eq!(response["error"]["code"], -32700); |
| 698 | assert!(response["id"].is_null()); |
| 699 | } |
| 700 | |
| 701 | #[test] |
| 702 | fn exposed_tools_map_aliases() { |
| 703 | let names = vec![ |
| 704 | "file_read".to_string(), |
| 705 | "file_write".to_string(), |
| 706 | "search".to_string(), |
| 707 | "apply_patch".to_string(), |
| 708 | "shell".to_string(), |
| 709 | ]; |
| 710 | let tools = build_exposed_tools(&names); |
| 711 | let mut map = HashMap::new(); |
| 712 | for tool in tools { |
| 713 | map.insert(tool.public, tool.internal); |
| 714 | } |
| 715 | assert_eq!(map.get("file_read").map(String::as_str), Some("read_file")); |
| 716 | assert_eq!( |
| 717 | map.get("file_write").map(String::as_str), |
| 718 | Some("write_file") |
| 719 | ); |
| 720 | assert_eq!(map.get("search").map(String::as_str), Some("grep_files")); |
| 721 | assert_eq!( |
| 722 | map.get("apply_patch").map(String::as_str), |
| 723 | Some("apply_patch") |
| 724 | ); |
| 725 | assert_eq!(map.get("shell").map(String::as_str), Some("exec_shell")); |
| 726 | } |
| 727 | |
| 728 | #[tokio::test] |
| 729 | async fn list_responses_omit_null_next_cursor() { |
| 730 | // MCP spec: `nextCursor` must be omitted (or be a string) when there |
| 731 | // are no further pages. Emitting `null` breaks strict clients such as |
| 732 | // Claude Code, which validate the response shape. |
| 733 | let settings = McpServerSettings { |
| 734 | expose_tools: vec!["file_read".to_string(), "apply_patch".to_string()], |
| 735 | require_approval: false, |
| 736 | }; |
| 737 | let server = McpServer::new(PathBuf::from("."), settings).expect("build server"); |
| 738 | |
| 739 | let tools_value = server.list_tools_response(); |
| 740 | let tools = tools_value |
| 741 | .as_object() |
| 742 | .expect("tools/list response is an object"); |
| 743 | assert!(tools.contains_key("tools")); |
| 744 | assert!( |
| 745 | tools.get("nextCursor").is_none(), |
| 746 | "tools/list must omit nextCursor when there are no more pages" |
| 747 | ); |
| 748 | |
| 749 | let resources_value = server.list_resources_response().await; |
| 750 | let resources = resources_value |
| 751 | .as_object() |
| 752 | .expect("resources/list response is an object"); |
| 753 | assert!(resources.contains_key("resources")); |
| 754 | assert!( |
| 755 | resources.get("nextCursor").is_none(), |
| 756 | "resources/list must omit nextCursor when there are no more pages" |
| 757 | ); |
| 758 | } |
| 759 | |
| 760 | #[tokio::test] |
| 761 | async fn retired_deepseek_tools_are_not_exposed() { |
| 762 | // #6140: the `deepseek`/`deepseek-reply` tools called a provider |
| 763 | // client directly — a second model authority beside the engine. |
| 764 | // Configs still naming them degrade to "tool not exposed" rather |
| 765 | // than silently running. |
| 766 | let settings = McpServerSettings { |
| 767 | expose_tools: vec!["deepseek".to_string(), "deepseek-reply".to_string()], |
| 768 | require_approval: false, |
| 769 | }; |
| 770 | let mut server = McpServer::new(PathBuf::from("."), settings).expect("build server"); |
| 771 | complete_handshake(&mut server).await; |
| 772 | |
| 773 | let tools = server.list_tools_response(); |
| 774 | assert_eq!( |
| 775 | tools["tools"].as_array().map(Vec::len), |
| 776 | Some(0), |
| 777 | "retired tools must not be advertised: {tools}" |
| 778 | ); |
| 779 | |
| 780 | let response = server |
| 781 | .handle_message(json!({ |
| 782 | "jsonrpc": "2.0", |
| 783 | "id": 1, |
| 784 | "method": "tools/call", |
| 785 | "params": {"name": "deepseek", "arguments": {"prompt": "hi"}} |
| 786 | })) |
| 787 | .await; |
| 788 | // The name resolves through `exposed_tools` but no registry tool |
| 789 | // backs it, so the call answers with an isError result. |
| 790 | let response = response.expect("tools/call responds"); |
| 791 | assert_eq!(response["result"]["isError"], json!(true), "{response}"); |
| 792 | } |
| 793 | |
| 794 | #[test] |
| 795 | fn default_settings_expose_only_read_only_tools() { |
| 796 | assert_eq!(default_expose_tools(), vec!["file_read", "search"]); |
| 797 | let settings = McpServerSettings { |
| 798 | expose_tools: default_expose_tools(), |
| 799 | require_approval: true, |
| 800 | }; |
| 801 | let server = McpServer::new(PathBuf::from("."), settings).expect("build server"); |
| 802 | let tools = server.list_tools_response(); |
| 803 | let names: Vec<&str> = tools["tools"] |
| 804 | .as_array() |
| 805 | .expect("tools array") |
| 806 | .iter() |
| 807 | .filter_map(|tool| tool["name"].as_str()) |
| 808 | .collect(); |
| 809 | assert_eq!(names, vec!["file_read", "search"], "{tools}"); |
| 810 | } |
| 811 | |
| 812 | #[test] |
| 813 | fn existing_config_without_require_approval_withholds_and_names_write_tools() { |
| 814 | let settings = McpServerSettings::from_toml( |
| 815 | "[server]\nexpose_tools = [\"file_read\", \"file_write\", \"apply_patch\"]\n", |
| 816 | ) |
| 817 | .expect("parse config"); |
| 818 | assert!(settings.require_approval); |
| 819 | let server = McpServer::new(PathBuf::from("."), settings).expect("build server"); |
| 820 | assert_eq!(server.withheld_tools(), vec!["file_write", "apply_patch"]); |
| 821 | |
| 822 | let allowed = McpServerSettings::from_toml( |
| 823 | "[server]\nexpose_tools = [\"file_write\"]\nrequire_approval = false\n", |
| 824 | ) |
| 825 | .expect("parse config"); |
| 826 | let server = McpServer::new(PathBuf::from("."), allowed).expect("build server"); |
| 827 | assert!(server.withheld_tools().is_empty()); |
| 828 | } |
| 829 | |
| 830 | #[tokio::test] |
| 831 | async fn caller_cannot_self_approve_write_tools() { |
| 832 | let workspace = tempfile::tempdir().expect("tempdir"); |
| 833 | let canary = workspace.path().join("canary.txt"); |
| 834 | let settings = McpServerSettings { |
| 835 | expose_tools: vec![ |
| 836 | "file_read".to_string(), |
| 837 | "file_write".to_string(), |
| 838 | "apply_patch".to_string(), |
| 839 | ], |
| 840 | require_approval: true, |
| 841 | }; |
| 842 | let mut server = |
| 843 | McpServer::new(workspace.path().to_path_buf(), settings).expect("build server"); |
| 844 | complete_handshake(&mut server).await; |
| 845 | |
| 846 | let tools = server.list_tools_response(); |
| 847 | let names: Vec<&str> = tools["tools"] |
| 848 | .as_array() |
| 849 | .expect("tools array") |
| 850 | .iter() |
| 851 | .filter_map(|tool| tool["name"].as_str()) |
| 852 | .collect(); |
| 853 | assert_eq!(names, vec!["file_read"], "{tools}"); |
| 854 | |
| 855 | for (name, arguments) in [ |
| 856 | ("file_write", json!({"path": "canary.txt", "content": "x"})), |
| 857 | ( |
| 858 | "apply_patch", |
| 859 | json!({"patch": "--- /dev/null\n+++ b/canary.txt\n@@ -0,0 +1 @@\n+x\n"}), |
| 860 | ), |
| 861 | ] { |
| 862 | let response = server |
| 863 | .handle_message(json!({ |
| 864 | "jsonrpc": "2.0", |
| 865 | "id": 1, |
| 866 | "method": "tools/call", |
| 867 | "params": {"name": name, "approved": true, "arguments": arguments} |
| 868 | })) |
| 869 | .await |
| 870 | .expect("tools/call responds"); |
| 871 | assert_eq!( |
| 872 | response["error"]["code"], |
| 873 | json!(-32001), |
| 874 | "{name}: {response}" |
| 875 | ); |
| 876 | assert!(!canary.exists(), "{name} must not run"); |
| 877 | } |
| 878 | } |
| 879 | |
| 880 | #[tokio::test] |
| 881 | async fn operator_config_can_allow_write_tools() { |
| 882 | let workspace = tempfile::tempdir().expect("tempdir"); |
| 883 | let settings = McpServerSettings { |
| 884 | expose_tools: vec!["file_write".to_string()], |
| 885 | require_approval: false, |
| 886 | }; |
| 887 | let mut server = |
| 888 | McpServer::new(workspace.path().to_path_buf(), settings).expect("build server"); |
| 889 | complete_handshake(&mut server).await; |
| 890 | let response = server |
| 891 | .handle_message(json!({ |
| 892 | "jsonrpc": "2.0", |
| 893 | "id": 1, |
| 894 | "method": "tools/call", |
| 895 | "params": { |
| 896 | "name": "file_write", |
| 897 | "arguments": {"path": "allowed.txt", "content": "x"} |
| 898 | } |
| 899 | })) |
| 900 | .await |
| 901 | .expect("tools/call responds"); |
| 902 | assert!(response.get("error").is_none(), "{response}"); |
| 903 | assert!(workspace.path().join("allowed.txt").exists(), "{response}"); |
| 904 | } |
| 905 | |
| 906 | #[test] |
| 907 | fn initialize_uses_standard_mcp_shape_and_codewhale_identity() { |
| 908 | let response = initialize_response(Some(crate::mcp::MCP_PROTOCOL_VERSION)); |
| 909 | assert_eq!( |
| 910 | response["protocolVersion"], |
| 911 | crate::mcp::MCP_PROTOCOL_VERSION |
| 912 | ); |
| 913 | assert_eq!(response["serverInfo"]["name"], "codewhale-mcp-server"); |
| 914 | assert_eq!(response["serverInfo"]["version"], env!("CARGO_PKG_VERSION")); |
| 915 | assert!(response["capabilities"]["tools"].is_object()); |
| 916 | } |
| 917 | |
| 918 | #[test] |
| 919 | fn initialize_negotiates_supported_revisions() { |
| 920 | // A client asking for an older dated revision gets it echoed back; |
| 921 | // an unknown or missing revision answers with the newest supported. |
| 922 | for requested in ["2025-03-26", "2024-11-05"] { |
| 923 | let response = initialize_response(Some(requested)); |
| 924 | assert_eq!(response["protocolVersion"], requested); |
| 925 | } |
| 926 | for requested in [Some("2099-01-01"), None] { |
| 927 | let response = initialize_response(requested); |
| 928 | assert_eq!( |
| 929 | response["protocolVersion"], |
| 930 | crate::mcp::MCP_PROTOCOL_VERSION |
| 931 | ); |
| 932 | } |
| 933 | } |
| 934 | } |
| 935 |