| 1 | //! MCP server implementation for exposing DeepSeek tools over stdio. |
| 2 | |
| 3 | use std::collections::{HashMap, HashSet}; |
| 4 | use std::io::{self, BufRead, Write}; |
| 5 | use std::path::PathBuf; |
| 6 | use std::sync::{Arc, Mutex}; |
| 7 | |
| 8 | use anyhow::{Context, Result}; |
| 9 | use serde::Deserialize; |
| 10 | use serde_json::{Value, json}; |
| 11 | use tokio::runtime::Runtime; |
| 12 | use uuid::Uuid; |
| 13 | |
| 14 | use crate::client::DeepSeekClient; |
| 15 | use crate::config::Config; |
| 16 | use crate::llm_client::LlmClient; |
| 17 | use crate::models::{ContentBlock, Message, MessageRequest}; |
| 18 | use crate::session_manager::SessionManager; |
| 19 | use crate::tools::spec::{ToolError, ToolResult}; |
| 20 | use crate::tools::{ToolContext, ToolRegistryBuilder}; |
| 21 | |
| 22 | #[derive(Debug, Default, Deserialize)] |
| 23 | struct McpServerConfigFile { |
| 24 | #[serde(default)] |
| 25 | server: McpServerSection, |
| 26 | } |
| 27 | |
| 28 | #[derive(Debug, Default, Deserialize)] |
| 29 | struct McpServerSection { |
| 30 | expose_tools: Option<Vec<String>>, |
| 31 | require_approval: Option<bool>, |
| 32 | } |
| 33 | |
| 34 | #[derive(Debug, Clone)] |
| 35 | struct McpServerSettings { |
| 36 | expose_tools: Vec<String>, |
| 37 | require_approval: bool, |
| 38 | } |
| 39 | |
| 40 | impl McpServerSettings { |
| 41 | fn load() -> Result<Self> { |
| 42 | let path = default_config_path(); |
| 43 | if let Some(path) = path.filter(|p| p.exists()) { |
| 44 | let contents = std::fs::read_to_string(&path) |
| 45 | .with_context(|| format!("Failed to read MCP server config: {}", path.display()))?; |
| 46 | let config: McpServerConfigFile = toml::from_str(&contents).with_context(|| { |
| 47 | format!("Failed to parse MCP server config: {}", path.display()) |
| 48 | })?; |
| 49 | let expose_tools = config |
| 50 | .server |
| 51 | .expose_tools |
| 52 | .unwrap_or_else(default_expose_tools); |
| 53 | let require_approval = config.server.require_approval.unwrap_or(false); |
| 54 | Ok(Self { |
| 55 | expose_tools, |
| 56 | require_approval, |
| 57 | }) |
| 58 | } else { |
| 59 | Ok(Self { |
| 60 | expose_tools: default_expose_tools(), |
| 61 | require_approval: false, |
| 62 | }) |
| 63 | } |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | #[derive(Debug, Clone)] |
| 68 | struct ExposedTool { |
| 69 | public: String, |
| 70 | internal: String, |
| 71 | } |
| 72 | |
| 73 | pub fn run_mcp_server(workspace: PathBuf) -> Result<()> { |
| 74 | let settings = McpServerSettings::load()?; |
| 75 | let mut server = McpServer::new(workspace, settings)?; |
| 76 | server.run() |
| 77 | } |
| 78 | |
| 79 | struct McpServer { |
| 80 | workspace: PathBuf, |
| 81 | registry: crate::tools::ToolRegistry, |
| 82 | exposed_tools: Vec<ExposedTool>, |
| 83 | require_approval: bool, |
| 84 | /// Thread-based conversation state for deepseek/deepseek-reply tools. |
| 85 | /// Maps thread_id -> ordered list of messages in the conversation. |
| 86 | threads: Arc<Mutex<HashMap<String, Vec<Message>>>>, |
| 87 | /// Monotonic request counter for notification correlation. |
| 88 | next_notification_id: u64, |
| 89 | } |
| 90 | |
| 91 | impl McpServer { |
| 92 | fn new(workspace: PathBuf, settings: McpServerSettings) -> Result<Self> { |
| 93 | let exposed_tools = build_exposed_tools(&settings.expose_tools); |
| 94 | let mut internal_names: HashSet<String> = HashSet::new(); |
| 95 | for tool in &exposed_tools { |
| 96 | internal_names.insert(tool.internal.clone()); |
| 97 | } |
| 98 | |
| 99 | let mut builder = ToolRegistryBuilder::new() |
| 100 | .with_file_tools() |
| 101 | .with_search_tools(); |
| 102 | |
| 103 | if internal_names.contains("apply_patch") { |
| 104 | builder = builder.with_patch_tools(); |
| 105 | } |
| 106 | if internal_names.contains("exec_shell") { |
| 107 | builder = builder.with_shell_tools(); |
| 108 | } |
| 109 | |
| 110 | let context = ToolContext::new(workspace.clone()); |
| 111 | let registry = builder.build(context); |
| 112 | |
| 113 | Ok(Self { |
| 114 | workspace, |
| 115 | registry, |
| 116 | exposed_tools, |
| 117 | require_approval: settings.require_approval, |
| 118 | threads: Arc::new(Mutex::new(HashMap::new())), |
| 119 | next_notification_id: 0, |
| 120 | }) |
| 121 | } |
| 122 | |
| 123 | fn run(&mut self) -> Result<()> { |
| 124 | let runtime = Runtime::new().context("Failed to start MCP runtime")?; |
| 125 | let stdin = io::stdin(); |
| 126 | let mut stdout = io::stdout(); |
| 127 | |
| 128 | for line in stdin.lock().lines() { |
| 129 | let line = line?; |
| 130 | let trimmed = line.trim(); |
| 131 | if trimmed.is_empty() { |
| 132 | continue; |
| 133 | } |
| 134 | let Ok(message) = serde_json::from_str::<Value>(trimmed) else { |
| 135 | continue; |
| 136 | }; |
| 137 | |
| 138 | if let Some(response) = self.handle_message(&runtime, message) { |
| 139 | let payload = serde_json::to_string(&response)?; |
| 140 | writeln!(stdout, "{payload}")?; |
| 141 | stdout.flush()?; |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | Ok(()) |
| 146 | } |
| 147 | |
| 148 | fn handle_message(&mut self, runtime: &Runtime, message: Value) -> Option<Value> { |
| 149 | let method = message.get("method").and_then(Value::as_str)?; |
| 150 | let id = message.get("id").cloned(); |
| 151 | |
| 152 | match method { |
| 153 | "initialize" => respond(id.as_ref(), initialize_response()), |
| 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(runtime, params, id.clone()) { |
| 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()), |
| 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 | match entry.internal.as_str() { |
| 177 | "deepseek" => { |
| 178 | tools.push(json!({ |
| 179 | "name": "deepseek", |
| 180 | "description": "Send a prompt to Codewhale and get a response. Creates a new conversation thread.", |
| 181 | "inputSchema": { |
| 182 | "type": "object", |
| 183 | "properties": { |
| 184 | "prompt": { |
| 185 | "type": "string", |
| 186 | "description": "The user prompt to send to Codewhale" |
| 187 | }, |
| 188 | "model": { |
| 189 | "type": "string", |
| 190 | "description": "Optional model identifier (default: deepseek-v4-pro)" |
| 191 | }, |
| 192 | "cwd": { |
| 193 | "type": "string", |
| 194 | "description": "Optional working directory context" |
| 195 | } |
| 196 | }, |
| 197 | "required": ["prompt"] |
| 198 | } |
| 199 | })); |
| 200 | } |
| 201 | "deepseek-reply" => { |
| 202 | tools.push(json!({ |
| 203 | "name": "deepseek-reply", |
| 204 | "description": "Continue an existing conversation thread with Codewhale. Requires a thread_id from a previous deepseek call.", |
| 205 | "inputSchema": { |
| 206 | "type": "object", |
| 207 | "properties": { |
| 208 | "thread_id": { |
| 209 | "type": "string", |
| 210 | "description": "Thread ID from a previous deepseek call" |
| 211 | }, |
| 212 | "prompt": { |
| 213 | "type": "string", |
| 214 | "description": "The follow-up prompt" |
| 215 | }, |
| 216 | "model": { |
| 217 | "type": "string", |
| 218 | "description": "Optional model override" |
| 219 | } |
| 220 | }, |
| 221 | "required": ["thread_id", "prompt"] |
| 222 | } |
| 223 | })); |
| 224 | } |
| 225 | _ => { |
| 226 | if let Some(tool) = self.registry.get(&entry.internal) { |
| 227 | tools.push(json!({ |
| 228 | "name": entry.public, |
| 229 | "description": tool.description(), |
| 230 | "inputSchema": tool.input_schema(), |
| 231 | })); |
| 232 | } |
| 233 | } |
| 234 | } |
| 235 | } |
| 236 | json!({ "tools": tools, "nextCursor": Value::Null }) |
| 237 | } |
| 238 | |
| 239 | fn list_resources_response(&self) -> Value { |
| 240 | let mut resources = Vec::new(); |
| 241 | resources.push(json!({ |
| 242 | "uri": format!("file://{}", self.workspace.display()), |
| 243 | "name": "workspace", |
| 244 | "description": "Workspace root", |
| 245 | "mimeType": "inode/directory", |
| 246 | })); |
| 247 | |
| 248 | if let Ok(manager) = SessionManager::default_location() |
| 249 | && let Ok(sessions) = manager.list_sessions() |
| 250 | { |
| 251 | for session in sessions { |
| 252 | resources.push(json!({ |
| 253 | "uri": format!("deepseek://session/{}", session.id), |
| 254 | "name": session.title, |
| 255 | "description": format!("{} messages", session.message_count), |
| 256 | "mimeType": "application/json", |
| 257 | })); |
| 258 | } |
| 259 | } |
| 260 | |
| 261 | json!({ "resources": resources, "nextCursor": Value::Null }) |
| 262 | } |
| 263 | |
| 264 | fn call_tool( |
| 265 | &mut self, |
| 266 | runtime: &Runtime, |
| 267 | params: Value, |
| 268 | request_id: Option<Value>, |
| 269 | ) -> Result<Value, RpcError> { |
| 270 | let params = params.as_object().ok_or_else(|| RpcError { |
| 271 | code: -32602, |
| 272 | message: "Invalid params for tools/call".to_string(), |
| 273 | })?; |
| 274 | let name = params |
| 275 | .get("name") |
| 276 | .and_then(Value::as_str) |
| 277 | .ok_or_else(|| RpcError { |
| 278 | code: -32602, |
| 279 | message: "Missing tool name".to_string(), |
| 280 | })?; |
| 281 | |
| 282 | if self.require_approval |
| 283 | && !params |
| 284 | .get("approved") |
| 285 | .and_then(Value::as_bool) |
| 286 | .unwrap_or(false) |
| 287 | { |
| 288 | return Err(RpcError { |
| 289 | code: -32001, |
| 290 | message: "Approval required. Resend with approved=true.".to_string(), |
| 291 | }); |
| 292 | } |
| 293 | |
| 294 | let internal = self |
| 295 | .exposed_tools |
| 296 | .iter() |
| 297 | .find(|tool| tool.public == name) |
| 298 | .map(|tool| tool.internal.clone()) |
| 299 | .ok_or_else(|| RpcError { |
| 300 | code: -32602, |
| 301 | message: format!("Tool not exposed: {name}"), |
| 302 | })?; |
| 303 | |
| 304 | // Handle deepseek and deepseek-reply natively |
| 305 | if internal == "deepseek" || internal == "deepseek-reply" { |
| 306 | let arguments = params |
| 307 | .get("arguments") |
| 308 | .cloned() |
| 309 | .unwrap_or_else(|| json!({})); |
| 310 | return self.handle_deepseek_call(runtime, &internal, &arguments, request_id); |
| 311 | } |
| 312 | |
| 313 | let arguments = params |
| 314 | .get("arguments") |
| 315 | .cloned() |
| 316 | .unwrap_or_else(|| json!({})); |
| 317 | let result = runtime.block_on(self.registry.execute_full(&internal, arguments)); |
| 318 | Ok(tool_result_to_mcp(result)) |
| 319 | } |
| 320 | |
| 321 | /// Handle a `deepseek` or `deepseek-reply` tool call. |
| 322 | /// |
| 323 | /// Uses `DeepSeekClient` directly (not the full engine) to send a prompt |
| 324 | /// and return the response. For `deepseek` a new thread is created; for |
| 325 | /// `deepseek-reply` the caller supplies a `thread_id` to continue an |
| 326 | /// existing conversation. |
| 327 | fn handle_deepseek_call( |
| 328 | &mut self, |
| 329 | runtime: &Runtime, |
| 330 | internal_name: &str, |
| 331 | arguments: &Value, |
| 332 | request_id: Option<Value>, |
| 333 | ) -> Result<Value, RpcError> { |
| 334 | let prompt = arguments |
| 335 | .get("prompt") |
| 336 | .and_then(Value::as_str) |
| 337 | .ok_or_else(|| RpcError { |
| 338 | code: -32602, |
| 339 | message: "Missing required argument: prompt".to_string(), |
| 340 | })?; |
| 341 | |
| 342 | let model = arguments |
| 343 | .get("model") |
| 344 | .and_then(Value::as_str) |
| 345 | .unwrap_or("deepseek-v4-pro"); |
| 346 | |
| 347 | // Resolve thread_id |
| 348 | let thread_id = if internal_name == "deepseek" { |
| 349 | // New thread |
| 350 | Uuid::new_v4().to_string() |
| 351 | } else { |
| 352 | arguments |
| 353 | .get("thread_id") |
| 354 | .and_then(Value::as_str) |
| 355 | .ok_or_else(|| RpcError { |
| 356 | code: -32602, |
| 357 | message: "Missing required argument: thread_id for deepseek-reply".to_string(), |
| 358 | })? |
| 359 | .to_string() |
| 360 | }; |
| 361 | |
| 362 | // Load config and create client |
| 363 | let config = Config::load(None, None).map_err(|e| RpcError { |
| 364 | code: -32000, |
| 365 | message: format!("Failed to load config: {e}"), |
| 366 | })?; |
| 367 | let client = DeepSeekClient::new(&config).map_err(|e| RpcError { |
| 368 | code: -32000, |
| 369 | message: format!("Failed to create DeepSeek client: {e}"), |
| 370 | })?; |
| 371 | |
| 372 | // Build message list |
| 373 | let user_message = Message { |
| 374 | role: "user".to_string(), |
| 375 | content: vec![ContentBlock::Text { |
| 376 | text: prompt.to_string(), |
| 377 | cache_control: None, |
| 378 | }], |
| 379 | }; |
| 380 | |
| 381 | let messages = if internal_name == "deepseek" { |
| 382 | vec![user_message] |
| 383 | } else { |
| 384 | let thread = self.threads.lock().unwrap_or_else(|e| e.into_inner()); |
| 385 | let mut existing = thread.get(&thread_id).cloned().ok_or_else(|| RpcError { |
| 386 | code: -32602, |
| 387 | message: format!("Thread not found: {thread_id}"), |
| 388 | })?; |
| 389 | existing.push(user_message); |
| 390 | existing |
| 391 | }; |
| 392 | |
| 393 | // Send the API request (non-streaming for the basic version) |
| 394 | let request = MessageRequest { |
| 395 | model: model.to_string(), |
| 396 | messages: messages.clone(), |
| 397 | max_tokens: 16384, |
| 398 | system: None, |
| 399 | tools: None, |
| 400 | tool_choice: None, |
| 401 | metadata: None, |
| 402 | thinking: None, |
| 403 | reasoning_effort: None, |
| 404 | stream: None, |
| 405 | temperature: None, |
| 406 | top_p: None, |
| 407 | }; |
| 408 | |
| 409 | let response = runtime |
| 410 | .block_on(client.create_message(request)) |
| 411 | .map_err(|e| RpcError { |
| 412 | code: -32000, |
| 413 | message: format!("DeepSeek API call failed: {e}"), |
| 414 | })?; |
| 415 | |
| 416 | // Extract response text from content blocks |
| 417 | let response_text = response |
| 418 | .content |
| 419 | .iter() |
| 420 | .filter_map(|block| { |
| 421 | if let ContentBlock::Text { text, .. } = block { |
| 422 | Some(text.as_str()) |
| 423 | } else { |
| 424 | None |
| 425 | } |
| 426 | }) |
| 427 | .collect::<Vec<_>>() |
| 428 | .join(""); |
| 429 | |
| 430 | let usage = &response.usage; |
| 431 | |
| 432 | // Store the assistant response in the thread |
| 433 | { |
| 434 | let mut thread = self.threads.lock().unwrap_or_else(|e| e.into_inner()); |
| 435 | let convo = thread.entry(thread_id.clone()).or_default(); |
| 436 | // If deepseek, we already have just the user message; if deepseek-reply, |
| 437 | // the user message was appended to the cloned messages above but we need |
| 438 | // to also append it to the stored thread and then the assistant response. |
| 439 | if internal_name == "deepseek" { |
| 440 | convo.push(Message { |
| 441 | role: "user".to_string(), |
| 442 | content: vec![ContentBlock::Text { |
| 443 | text: prompt.to_string(), |
| 444 | cache_control: None, |
| 445 | }], |
| 446 | }); |
| 447 | } |
| 448 | convo.push(Message { |
| 449 | role: "assistant".to_string(), |
| 450 | content: vec![ContentBlock::Text { |
| 451 | text: response_text.clone(), |
| 452 | cache_control: None, |
| 453 | }], |
| 454 | }); |
| 455 | } |
| 456 | |
| 457 | // Emit a notification/message so the client can correlate the response |
| 458 | let notification_id = { |
| 459 | let nid = self.next_notification_id; |
| 460 | self.next_notification_id += 1; |
| 461 | nid |
| 462 | }; |
| 463 | |
| 464 | // Write notification to stdout |
| 465 | let notification = json!({ |
| 466 | "jsonrpc": "2.0", |
| 467 | "method": "notifications/message", |
| 468 | "params": { |
| 469 | "notificationId": notification_id, |
| 470 | "requestId": request_id, |
| 471 | "threadId": thread_id, |
| 472 | "content": response_text, |
| 473 | "usage": { |
| 474 | "inputTokens": usage.input_tokens, |
| 475 | "outputTokens": usage.output_tokens, |
| 476 | } |
| 477 | } |
| 478 | }); |
| 479 | if let Ok(payload) = serde_json::to_string(¬ification) { |
| 480 | let mut stdout = io::stdout(); |
| 481 | let _ = writeln!(stdout, "{payload}"); |
| 482 | let _ = stdout.flush(); |
| 483 | } |
| 484 | |
| 485 | Ok(json!({ |
| 486 | "content": [{ "type": "text", "text": &response_text }], |
| 487 | "isError": false, |
| 488 | "structuredContent": { |
| 489 | "threadId": thread_id, |
| 490 | "content": response_text, |
| 491 | "usage": { |
| 492 | "inputTokens": usage.input_tokens, |
| 493 | "outputTokens": usage.output_tokens, |
| 494 | } |
| 495 | } |
| 496 | })) |
| 497 | } |
| 498 | } |
| 499 | |
| 500 | fn default_config_path() -> Option<PathBuf> { |
| 501 | crate::config::effective_home_dir().map(|home| home.join(".deepseek").join("mcp_server.toml")) |
| 502 | } |
| 503 | |
| 504 | fn default_expose_tools() -> Vec<String> { |
| 505 | vec![ |
| 506 | "file_read".to_string(), |
| 507 | "file_write".to_string(), |
| 508 | "search".to_string(), |
| 509 | "apply_patch".to_string(), |
| 510 | "shell".to_string(), |
| 511 | "deepseek".to_string(), |
| 512 | "deepseek-reply".to_string(), |
| 513 | ] |
| 514 | } |
| 515 | |
| 516 | fn build_exposed_tools(names: &[String]) -> Vec<ExposedTool> { |
| 517 | let mut tools = Vec::new(); |
| 518 | for name in names { |
| 519 | let trimmed = name.trim(); |
| 520 | if trimmed.is_empty() { |
| 521 | continue; |
| 522 | } |
| 523 | let public = trimmed.to_string(); |
| 524 | let internal = match trimmed { |
| 525 | "file_read" => "read_file", |
| 526 | "file_write" => "write_file", |
| 527 | "file_edit" => "edit_file", |
| 528 | "shell" => "exec_shell", |
| 529 | "search" => "grep_files", |
| 530 | "file_search" => "file_search", |
| 531 | // deepseek and deepseek-reply are handled natively in call_tool |
| 532 | "deepseek" | "deepseek-reply" => trimmed, |
| 533 | other => other, |
| 534 | } |
| 535 | .to_string(); |
| 536 | tools.push(ExposedTool { public, internal }); |
| 537 | } |
| 538 | tools |
| 539 | } |
| 540 | |
| 541 | fn tool_result_to_mcp(result: Result<ToolResult, ToolError>) -> Value { |
| 542 | match result { |
| 543 | Ok(tool_result) => { |
| 544 | let mut response = json!({ |
| 545 | "content": [{ "type": "text", "text": tool_result.content }], |
| 546 | "isError": !tool_result.success, |
| 547 | }); |
| 548 | if let Some(metadata) = tool_result.metadata { |
| 549 | response["structuredContent"] = metadata; |
| 550 | } |
| 551 | response |
| 552 | } |
| 553 | Err(err) => json!({ |
| 554 | "content": [{ "type": "text", "text": err.to_string() }], |
| 555 | "isError": true, |
| 556 | }), |
| 557 | } |
| 558 | } |
| 559 | |
| 560 | fn initialize_response() -> Value { |
| 561 | json!({ |
| 562 | "protocolVersion": "2024-11-05", |
| 563 | "serverInfo": { |
| 564 | "name": "deepseek-mcp-server", |
| 565 | "version": env!("CARGO_PKG_VERSION"), |
| 566 | }, |
| 567 | "capabilities": { |
| 568 | "tools": {}, |
| 569 | "resources": {}, |
| 570 | } |
| 571 | }) |
| 572 | } |
| 573 | |
| 574 | fn respond(id: Option<&Value>, result: Value) -> Option<Value> { |
| 575 | id.map(|id| json!({ "jsonrpc": "2.0", "id": id, "result": result })) |
| 576 | } |
| 577 | |
| 578 | fn respond_error(id: Option<&Value>, code: i64, message: String) -> Option<Value> { |
| 579 | id.map(|id| { |
| 580 | json!({ |
| 581 | "jsonrpc": "2.0", |
| 582 | "id": id, |
| 583 | "error": { "code": code, "message": message } |
| 584 | }) |
| 585 | }) |
| 586 | } |
| 587 | |
| 588 | #[derive(Debug)] |
| 589 | struct RpcError { |
| 590 | code: i64, |
| 591 | message: String, |
| 592 | } |
| 593 | |
| 594 | #[cfg(test)] |
| 595 | mod tests { |
| 596 | use super::*; |
| 597 | use std::collections::HashMap; |
| 598 | |
| 599 | #[test] |
| 600 | fn exposed_tools_map_aliases() { |
| 601 | let names = vec![ |
| 602 | "file_read".to_string(), |
| 603 | "file_write".to_string(), |
| 604 | "search".to_string(), |
| 605 | "apply_patch".to_string(), |
| 606 | "shell".to_string(), |
| 607 | ]; |
| 608 | let tools = build_exposed_tools(&names); |
| 609 | let mut map = HashMap::new(); |
| 610 | for tool in tools { |
| 611 | map.insert(tool.public, tool.internal); |
| 612 | } |
| 613 | assert_eq!(map.get("file_read").map(String::as_str), Some("read_file")); |
| 614 | assert_eq!( |
| 615 | map.get("file_write").map(String::as_str), |
| 616 | Some("write_file") |
| 617 | ); |
| 618 | assert_eq!(map.get("search").map(String::as_str), Some("grep_files")); |
| 619 | assert_eq!( |
| 620 | map.get("apply_patch").map(String::as_str), |
| 621 | Some("apply_patch") |
| 622 | ); |
| 623 | assert_eq!(map.get("shell").map(String::as_str), Some("exec_shell")); |
| 624 | } |
| 625 | } |
| 626 |