返回 DeepSeek-TUI-2026
mcp_server.rs
根目录 / crates / tui / src / mcp_server.rs
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 DeepSeek 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 DeepSeek"
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 DeepSeek. 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();
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();
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(&notification) {
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 dirs::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
626 lines RUST