7b91169017
- Move src/* into crates/tui/src/ to create a proper workspace structure - Add .claude/ and .trimtab/ directories for Trimtab closed-loop workflow - Add DEPENDENCY_GRAPH.md and update documentation - Update Cargo.toml files to reflect new crate dependencies - Update CI workflows and npm package scripts - All tests pass, release build works
365 lines
11 KiB
Rust
365 lines
11 KiB
Rust
//! MCP server implementation for exposing DeepSeek tools over stdio.
|
|
|
|
use std::collections::HashSet;
|
|
use std::io::{self, BufRead, Write};
|
|
use std::path::PathBuf;
|
|
|
|
use anyhow::{Context, Result};
|
|
use serde::Deserialize;
|
|
use serde_json::{Value, json};
|
|
use tokio::runtime::Runtime;
|
|
|
|
use crate::session_manager::SessionManager;
|
|
use crate::tools::spec::{ToolError, ToolResult};
|
|
use crate::tools::{ToolContext, ToolRegistryBuilder};
|
|
|
|
#[derive(Debug, Default, Deserialize)]
|
|
struct McpServerConfigFile {
|
|
#[serde(default)]
|
|
server: McpServerSection,
|
|
}
|
|
|
|
#[derive(Debug, Default, Deserialize)]
|
|
struct McpServerSection {
|
|
expose_tools: Option<Vec<String>>,
|
|
require_approval: Option<bool>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct McpServerSettings {
|
|
expose_tools: Vec<String>,
|
|
require_approval: bool,
|
|
}
|
|
|
|
impl McpServerSettings {
|
|
fn load() -> Result<Self> {
|
|
let path = default_config_path();
|
|
if let Some(path) = path.filter(|p| p.exists()) {
|
|
let contents = std::fs::read_to_string(&path)
|
|
.with_context(|| format!("Failed to read MCP server config: {}", path.display()))?;
|
|
let config: McpServerConfigFile = toml::from_str(&contents).with_context(|| {
|
|
format!("Failed to parse MCP server config: {}", path.display())
|
|
})?;
|
|
let expose_tools = config
|
|
.server
|
|
.expose_tools
|
|
.unwrap_or_else(default_expose_tools);
|
|
let require_approval = config.server.require_approval.unwrap_or(false);
|
|
Ok(Self {
|
|
expose_tools,
|
|
require_approval,
|
|
})
|
|
} else {
|
|
Ok(Self {
|
|
expose_tools: default_expose_tools(),
|
|
require_approval: false,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct ExposedTool {
|
|
public: String,
|
|
internal: String,
|
|
}
|
|
|
|
pub fn run_mcp_server(workspace: PathBuf) -> Result<()> {
|
|
let settings = McpServerSettings::load()?;
|
|
let mut server = McpServer::new(workspace, settings)?;
|
|
server.run()
|
|
}
|
|
|
|
struct McpServer {
|
|
workspace: PathBuf,
|
|
registry: crate::tools::ToolRegistry,
|
|
exposed_tools: Vec<ExposedTool>,
|
|
require_approval: bool,
|
|
}
|
|
|
|
impl McpServer {
|
|
fn new(workspace: PathBuf, settings: McpServerSettings) -> Result<Self> {
|
|
let exposed_tools = build_exposed_tools(&settings.expose_tools);
|
|
let mut internal_names: HashSet<String> = HashSet::new();
|
|
for tool in &exposed_tools {
|
|
internal_names.insert(tool.internal.clone());
|
|
}
|
|
|
|
let mut builder = ToolRegistryBuilder::new()
|
|
.with_file_tools()
|
|
.with_search_tools();
|
|
|
|
if internal_names.contains("apply_patch") {
|
|
builder = builder.with_patch_tools();
|
|
}
|
|
if internal_names.contains("exec_shell") {
|
|
builder = builder.with_shell_tools();
|
|
}
|
|
|
|
let context = ToolContext::new(workspace.clone());
|
|
let registry = builder.build(context);
|
|
|
|
Ok(Self {
|
|
workspace,
|
|
registry,
|
|
exposed_tools,
|
|
require_approval: settings.require_approval,
|
|
})
|
|
}
|
|
|
|
fn run(&mut self) -> Result<()> {
|
|
let runtime = Runtime::new().context("Failed to start MCP runtime")?;
|
|
let stdin = io::stdin();
|
|
let mut stdout = io::stdout();
|
|
|
|
for line in stdin.lock().lines() {
|
|
let line = line?;
|
|
let trimmed = line.trim();
|
|
if trimmed.is_empty() {
|
|
continue;
|
|
}
|
|
let Ok(message) = serde_json::from_str::<Value>(trimmed) else {
|
|
continue;
|
|
};
|
|
|
|
if let Some(response) = self.handle_message(&runtime, message) {
|
|
let payload = serde_json::to_string(&response)?;
|
|
writeln!(stdout, "{payload}")?;
|
|
stdout.flush()?;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn handle_message(&mut self, runtime: &Runtime, message: Value) -> Option<Value> {
|
|
let method = message.get("method").and_then(Value::as_str)?;
|
|
let id = message.get("id").cloned();
|
|
|
|
match method {
|
|
"initialize" => respond(id.as_ref(), initialize_response()),
|
|
"tools/list" => respond(id.as_ref(), self.list_tools_response()),
|
|
"tools/call" => {
|
|
let params = message.get("params").cloned().unwrap_or_else(|| json!({}));
|
|
match self.call_tool(runtime, params) {
|
|
Ok(result) => respond(id.as_ref(), result),
|
|
Err(err) => respond_error(id.as_ref(), err.code, err.message),
|
|
}
|
|
}
|
|
"resources/list" => respond(id.as_ref(), self.list_resources_response()),
|
|
"ping" => respond(id.as_ref(), json!({})),
|
|
"notifications/initialized" => None,
|
|
_ => respond_error(id.as_ref(), -32601, format!("Method not found: {method}")),
|
|
}
|
|
}
|
|
|
|
fn list_tools_response(&self) -> Value {
|
|
let mut tools = Vec::new();
|
|
let mut seen = HashSet::new();
|
|
for entry in &self.exposed_tools {
|
|
if !seen.insert(entry.public.clone()) {
|
|
continue;
|
|
}
|
|
if let Some(tool) = self.registry.get(&entry.internal) {
|
|
tools.push(json!({
|
|
"name": entry.public,
|
|
"description": tool.description(),
|
|
"inputSchema": tool.input_schema(),
|
|
}));
|
|
}
|
|
}
|
|
json!({ "tools": tools, "nextCursor": Value::Null })
|
|
}
|
|
|
|
fn list_resources_response(&self) -> Value {
|
|
let mut resources = Vec::new();
|
|
resources.push(json!({
|
|
"uri": format!("file://{}", self.workspace.display()),
|
|
"name": "workspace",
|
|
"description": "Workspace root",
|
|
"mimeType": "inode/directory",
|
|
}));
|
|
|
|
if let Ok(manager) = SessionManager::default_location()
|
|
&& let Ok(sessions) = manager.list_sessions()
|
|
{
|
|
for session in sessions {
|
|
resources.push(json!({
|
|
"uri": format!("deepseek://session/{}", session.id),
|
|
"name": session.title,
|
|
"description": format!("{} messages", session.message_count),
|
|
"mimeType": "application/json",
|
|
}));
|
|
}
|
|
}
|
|
|
|
json!({ "resources": resources, "nextCursor": Value::Null })
|
|
}
|
|
|
|
fn call_tool(&self, runtime: &Runtime, params: Value) -> Result<Value, RpcError> {
|
|
let params = params.as_object().ok_or_else(|| RpcError {
|
|
code: -32602,
|
|
message: "Invalid params for tools/call".to_string(),
|
|
})?;
|
|
let name = params
|
|
.get("name")
|
|
.and_then(Value::as_str)
|
|
.ok_or_else(|| RpcError {
|
|
code: -32602,
|
|
message: "Missing tool name".to_string(),
|
|
})?;
|
|
|
|
if self.require_approval
|
|
&& !params
|
|
.get("approved")
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(false)
|
|
{
|
|
return Err(RpcError {
|
|
code: -32001,
|
|
message: "Approval required. Resend with approved=true.".to_string(),
|
|
});
|
|
}
|
|
|
|
let internal = self
|
|
.exposed_tools
|
|
.iter()
|
|
.find(|tool| tool.public == name)
|
|
.map(|tool| tool.internal.clone())
|
|
.ok_or_else(|| RpcError {
|
|
code: -32602,
|
|
message: format!("Tool not exposed: {name}"),
|
|
})?;
|
|
|
|
let arguments = params
|
|
.get("arguments")
|
|
.cloned()
|
|
.unwrap_or_else(|| json!({}));
|
|
let result = runtime.block_on(self.registry.execute_full(&internal, arguments));
|
|
Ok(tool_result_to_mcp(result))
|
|
}
|
|
}
|
|
|
|
fn default_config_path() -> Option<PathBuf> {
|
|
dirs::home_dir().map(|home| home.join(".deepseek").join("mcp_server.toml"))
|
|
}
|
|
|
|
fn default_expose_tools() -> Vec<String> {
|
|
vec![
|
|
"file_read".to_string(),
|
|
"file_write".to_string(),
|
|
"search".to_string(),
|
|
"apply_patch".to_string(),
|
|
"shell".to_string(),
|
|
]
|
|
}
|
|
|
|
fn build_exposed_tools(names: &[String]) -> Vec<ExposedTool> {
|
|
let mut tools = Vec::new();
|
|
for name in names {
|
|
let trimmed = name.trim();
|
|
if trimmed.is_empty() {
|
|
continue;
|
|
}
|
|
let public = trimmed.to_string();
|
|
let internal = match trimmed {
|
|
"file_read" => "read_file",
|
|
"file_write" => "write_file",
|
|
"file_edit" => "edit_file",
|
|
"shell" => "exec_shell",
|
|
"search" => "grep_files",
|
|
"file_search" => "file_search",
|
|
other => other,
|
|
}
|
|
.to_string();
|
|
tools.push(ExposedTool { public, internal });
|
|
}
|
|
tools
|
|
}
|
|
|
|
fn tool_result_to_mcp(result: Result<ToolResult, ToolError>) -> Value {
|
|
match result {
|
|
Ok(tool_result) => {
|
|
let mut response = json!({
|
|
"content": [{ "type": "text", "text": tool_result.content }],
|
|
"isError": !tool_result.success,
|
|
});
|
|
if let Some(metadata) = tool_result.metadata {
|
|
response["structuredContent"] = metadata;
|
|
}
|
|
response
|
|
}
|
|
Err(err) => json!({
|
|
"content": [{ "type": "text", "text": err.to_string() }],
|
|
"isError": true,
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn initialize_response() -> Value {
|
|
json!({
|
|
"protocolVersion": "2024-11-05",
|
|
"serverInfo": {
|
|
"name": "deepseek-mcp-server",
|
|
"version": env!("CARGO_PKG_VERSION"),
|
|
},
|
|
"capabilities": {
|
|
"tools": {},
|
|
"resources": {},
|
|
}
|
|
})
|
|
}
|
|
|
|
fn respond(id: Option<&Value>, result: Value) -> Option<Value> {
|
|
id.map(|id| json!({ "jsonrpc": "2.0", "id": id, "result": result }))
|
|
}
|
|
|
|
fn respond_error(id: Option<&Value>, code: i64, message: String) -> Option<Value> {
|
|
id.map(|id| {
|
|
json!({
|
|
"jsonrpc": "2.0",
|
|
"id": id,
|
|
"error": { "code": code, "message": message }
|
|
})
|
|
})
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct RpcError {
|
|
code: i64,
|
|
message: String,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::collections::HashMap;
|
|
|
|
#[test]
|
|
fn exposed_tools_map_aliases() {
|
|
let names = vec![
|
|
"file_read".to_string(),
|
|
"file_write".to_string(),
|
|
"search".to_string(),
|
|
"apply_patch".to_string(),
|
|
"shell".to_string(),
|
|
];
|
|
let tools = build_exposed_tools(&names);
|
|
let mut map = HashMap::new();
|
|
for tool in tools {
|
|
map.insert(tool.public, tool.internal);
|
|
}
|
|
assert_eq!(map.get("file_read").map(String::as_str), Some("read_file"));
|
|
assert_eq!(
|
|
map.get("file_write").map(String::as_str),
|
|
Some("write_file")
|
|
);
|
|
assert_eq!(map.get("search").map(String::as_str), Some("grep_files"));
|
|
assert_eq!(
|
|
map.get("apply_patch").map(String::as_str),
|
|
Some("apply_patch")
|
|
);
|
|
assert_eq!(map.get("shell").map(String::as_str), Some("exec_shell"));
|
|
}
|
|
}
|