返回 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::{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
935 lines RUST