返回 CodeWhale
mcp_server.rs
根目录 / crates / tui / src / mcp_server.rs
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
488 lines RUST