diff --git a/Cargo.lock b/Cargo.lock index 1f620b7..caa12af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -773,6 +773,7 @@ dependencies = [ "df-ai", "df-execute", "df-ideas", + "df-mcp", "df-nodes", "df-project", "df-storage", @@ -848,6 +849,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "df-mcp" +version = "0.1.0" +dependencies = [ + "anyhow", + "df-storage", + "df-types", + "futures", + "serde", + "serde_json", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "df-nodes" version = "0.1.0" diff --git a/crates/df-mcp/Cargo.toml b/crates/df-mcp/Cargo.toml new file mode 100644 index 0000000..3dd148e --- /dev/null +++ b/crates/df-mcp/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "df-mcp" +version = "0.1.0" +edition = "2021" +description = "DevFlow MCP Server — 对外暴露数据层工具(JSON-RPC 2.0 over stdio)" + +[dependencies] +df-storage = { path = "../df-storage" } +df-types = { path = "../df-types" } +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +anyhow.workspace = true +tracing.workspace = true +uuid.workspace = true +futures = "0.3" + +[dev-dependencies] +tokio = { workspace = true, features = ["full", "test-util"] } diff --git a/crates/df-mcp/src/lib.rs b/crates/df-mcp/src/lib.rs new file mode 100644 index 0000000..19069f9 --- /dev/null +++ b/crates/df-mcp/src/lib.rs @@ -0,0 +1,19 @@ +//! df-mcp: DevFlow MCP Server — 对外暴露数据层工具 +//! +//! 形态:独立 stdio JSON-RPC 2.0 server。手写最小化协议层(不引外部 MCP SDK), +//! 复用 df-storage Repo 做实际数据读写,不重复 CRUD 实现。 +//! +//! 安全降级(对抗评估反方核心): +//! - High 风险(delete/purge/run_workflow)默认拒绝,返友好提示 +//! - Medium 风险(create/update/bind)默认允许 + 日志(tracing::warn) +//! - `--read-only` 仅暴露 list/get 工具 +//! - 不暴露文件系统工具(防绕过应用内路径校验) +//! - 仅 stdio / 本地,不暴露网络;无认证(依赖本地隔离) +//! +//! 入口: [`run_server`] — 由 Tauri CLI 子命令 `devflow mcp-server` 调用。 + +pub mod protocol; +pub mod server; +pub mod tools; + +pub use server::run_server; diff --git a/crates/df-mcp/src/protocol.rs b/crates/df-mcp/src/protocol.rs new file mode 100644 index 0000000..d572100 --- /dev/null +++ b/crates/df-mcp/src/protocol.rs @@ -0,0 +1,201 @@ +//! JSON-RPC 2.0 over stdio 协议层 +//! +//! 职责: +//! - 按行读 stdin,解析 JSON-RPC Request/Notification +//! - 构造 Response / Error 对象并序列化为单行 JSON 写 stdout +//! - MCP 三类方法路由(initialize / tools/list / tools/call)由上层 dispatch +//! +//! 不解析 batch 请求(MCP 客户端实测逐条发,单条足够)。 +//! 错误码遵循 JSON-RPC 2.0 + MCP 自定义段(-32xxx 协议层 / 0xffff 工具层)。 + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +// ============================================================ +// JSON-RPC 2.0 错误码 +// ============================================================ + +/// 协议层错误(JSON-RPC 2.0 预留段 -32000..-32099) +pub const PARSE_ERROR: i32 = -32700; +pub const INVALID_REQUEST: i32 = -32600; +pub const METHOD_NOT_FOUND: i32 = -32601; +pub const INVALID_PARAMS: i32 = -32602; +pub const INTERNAL_ERROR: i32 = -32603; + +// ============================================================ +// 请求 / 响应数据结构 +// ============================================================ + +/// JSON-RPC 2.0 请求(请求与通知共用,Notification 时 id=None) +#[derive(Debug, Clone, Deserialize)] +pub struct Request { + pub jsonrpc: Option, + pub id: Option, + pub method: String, + #[serde(default)] + pub params: Value, +} + +/// JSON-RPC 2.0 错误对象 +#[derive(Debug, Clone, Serialize)] +pub struct ErrorObject { + pub code: i32, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +/// JSON-RPC 2.0 响应 +#[derive(Debug, Clone, Serialize)] +pub struct Response { + pub jsonrpc: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl Response { + /// 成功响应 + pub fn ok(id: Option, result: Value) -> Self { + Self { + jsonrpc: "2.0", + id, + result: Some(result), + error: None, + } + } + + /// 错误响应 + pub fn err(id: Option, code: i32, message: impl Into, data: Option) -> Self { + Self { + jsonrpc: "2.0", + id, + result: None, + error: Some(ErrorObject { + code, + message: message.into(), + data, + }), + } + } +} + +// ============================================================ +// MCP 方法路由 — 暴露给上层 dispatch 的 enum +// ============================================================ + +/// MCP 协议层方法(已剥离 JSON-RPC 包装)。未知方法返回 `Unknown`,由 dispatch 回 METHOD_NOT_FOUND。 +#[derive(Debug, Clone)] +pub enum McpMethod { + /// initialize 握手:返 server info + capabilities + Initialize { params: Value }, + /// initialized 通知:客户端确认握手完成,无需响应 + Initialized, + /// tools/list:列出所有工具(MCP Tool schema) + ToolsList, + /// tools/call:调用某工具 + ToolsCall { name: String, arguments: Value }, + /// ping 心跳(MCP 2025 规范),回空 result + Ping, + /// 未识别的方法(回 METHOD_NOT_FOUND) + Unknown(String), +} + +impl McpMethod { + /// 从 Request 路由出 MCP 语义方法。 + pub fn from_request(req: &Request) -> Self { + match req.method.as_str() { + "initialize" => McpMethod::Initialize { params: req.params.clone() }, + "notifications/initialized" => McpMethod::Initialized, + "tools/list" => McpMethod::ToolsList, + "tools/call" => { + let name = req + .params + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_owned(); + let arguments = req.params.get("arguments").cloned().unwrap_or(Value::Null); + McpMethod::ToolsCall { name, arguments } + } + "ping" => McpMethod::Ping, + other => McpMethod::Unknown(other.to_owned()), + } + } +} + +// ============================================================ +// MCP 协议数据结构(initialize 握手 / Tool schema / CallToolResult) +// ============================================================ + +/// MCP initialize 握手响应结构(server info + capabilities) +#[derive(Debug, Clone, Serialize)] +pub struct InitializeResult { + #[serde(rename = "protocolVersion")] + pub protocol_version: &'static str, + pub capabilities: Capabilities, + #[serde(rename = "serverInfo")] + pub server_info: ServerInfo, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Capabilities { + pub tools: ToolsCapability, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ToolsCapability { + #[serde(rename = "listChanged")] + pub list_changed: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ServerInfo { + pub name: &'static str, + pub version: &'static str, +} + +/// MCP Tool 描述符(tools/list 返回数组元素) +#[derive(Debug, Clone, Serialize)] +pub struct Tool { + pub name: &'static str, + pub description: &'static str, + #[serde(rename = "inputSchema")] + pub input_schema: Value, +} + +/// MCP tools/call 成功响应(CallToolResult) +#[derive(Debug, Clone, Serialize)] +pub struct CallToolResult { + pub content: Vec, + #[serde(rename = "isError", skip_serializing_if = "Option::is_none")] + pub is_error: Option, +} + +/// MCP 内容块(本 server 仅用 text) +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum ContentBlock { + Text { text: String }, +} + +impl CallToolResult { + /// 文本成功响应 + pub fn text(text: impl Into) -> Self { + Self { + content: vec![ContentBlock::Text { text: text.into() }], + is_error: None, + } + } + + /// 文本错误响应(is_error=true 让客户端区分「工具业务错」与「协议错」) + pub fn error(text: impl Into) -> Self { + Self { + content: vec![ContentBlock::Text { text: text.into() }], + is_error: Some(true), + } + } +} diff --git a/crates/df-mcp/src/server.rs b/crates/df-mcp/src/server.rs new file mode 100644 index 0000000..a468cc3 --- /dev/null +++ b/crates/df-mcp/src/server.rs @@ -0,0 +1,343 @@ +//! MCP server 主循环 — stdio JSON-RPC 2.0 +//! +//! 协议流程: +//! 1. 客户端发 initialize → 回 serverInfo + capabilities.tools +//! 2. 客户端发 notifications/initialized(通知,不回) +//! 3. 客户端发 tools/list → 回工具清单(read-only 模式过滤) +//! 4. 客户端发 tools/call → dispatch 到 handler +//! +//! 高风险(High)工具:tools/list 不暴露(从清单剔除),tools/call 即便绕过也由 handler 兜底拒绝。 +//! read-only:tools/list 仅留 Low,tools/call Medium/High 一律拒绝。 + +use std::path::Path; +use std::sync::Arc; + +use df_storage::db::Database; +use serde_json::{json, Value}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +use crate::protocol::{ + CallToolResult, Capabilities, InitializeResult, McpMethod, Request, Response, ServerInfo, + ToolsCapability, +}; +use crate::tools::{self, Ctx, RiskLevel}; + +/// 协议版本(MCP 2025-06-18) +const PROTOCOL_VERSION: &str = "2025-06-18"; +const SERVER_NAME: &str = "devflow-mcp"; +const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// 启动 MCP server。 +/// +/// - `db_path`:SQLite 数据库文件路径(应用同库,开 WAL 模式) +/// - `read_only`:true 则仅暴露 list/get 工具 +/// +/// 复用 [`Database::open`](df_storage::Database::open)(已含 `PRAGMA journal_mode=WAL`), +/// 故 SQLite WAL 状态:随 df-storage 一起已启用,无需额外处理。 +pub async fn run_server(db_path: &Path, read_only: bool) -> anyhow::Result<()> { + let db = Arc::new(Database::open(db_path).await?); + let ctx = Ctx::new(db); + + let stdin = tokio::io::stdin(); + let stdout = tokio::io::stdout(); + main_loop(stdin, stdout, &ctx, read_only).await +} + +/// 可单测的主循环(参数化 stdin/stdout)。 +/// +/// 协议正确性:每行一个 JSON-RPC 消息,Response 单行写回(末尾 \n)。 +/// Notification(id=None)不回响应。 +pub async fn main_loop( + stdin: R, + stdout: W, + ctx: &Ctx, + read_only: bool, +) -> anyhow::Result<()> +where + R: tokio::io::AsyncRead + Unpin, + W: tokio::io::AsyncWrite + Unpin, +{ + let mut reader = BufReader::new(stdin); + let mut writer = stdout; + let mut line = String::new(); + + loop { + line.clear(); + let n = reader.read_line(&mut line).await?; + if n == 0 { + // EOF(stdin 关闭),优雅退出 + break; + } + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + // 解析 JSON-RPC 请求 + let req: Request = match serde_json::from_str(trimmed) { + Ok(r) => r, + Err(e) => { + // 解析失败:无 id 时无法回响应,只能 log;有 id(尽力猜)回 PARSE_ERROR + tracing::warn!(target: "df_mcp", line = %trimmed, err = %e, "解析 JSON-RPC 行失败"); + let resp = Response::err(None, crate::protocol::PARSE_ERROR, "Parse error", None); + write_response(&mut writer, &resp).await?; + continue; + } + }; + + let is_notification = req.id.is_none(); + let method = McpMethod::from_request(&req); + + // Notification 不回响应(除解析错外) + if is_notification { + match method { + McpMethod::Initialized => { + tracing::debug!(target: "df_mcp", "客户端 initialized 通知已收"); + } + _ => { + tracing::debug!(target: "df_mcp", m = ?method, "忽略未识别通知"); + } + } + continue; + } + + let resp = dispatch(ctx, read_only, req.id.clone(), method).await; + write_response(&mut writer, &resp).await?; + } + + Ok(()) +} + +/// 方法分发 → 构造 Response。 +/// +/// `id`:JSON-RPC 请求 id(回响应时原样回填;通知由 main_loop 已过滤)。 +async fn dispatch(ctx: &Ctx, read_only: bool, id: Option, method: McpMethod) -> Response { + match method { + McpMethod::Initialize { .. } => { + let result = InitializeResult { + protocol_version: PROTOCOL_VERSION, + capabilities: Capabilities { + tools: ToolsCapability { list_changed: false }, + }, + server_info: ServerInfo { + name: SERVER_NAME, + version: SERVER_VERSION, + }, + }; + Response::ok( + id, + serde_json::to_value(result).unwrap_or(Value::Null), + ) + } + McpMethod::Initialized => { + // 通知分支(由 main_loop 已过滤);到这里说明带 id 的 initialized,回空 result + Response::ok(id, Value::Object(Default::default())) + } + McpMethod::ToolsList => { + let tools: Vec<_> = tools::all_tools() + .into_iter() + .filter(|t| visible(read_only, t.risk)) + .map(|t| serde_json::to_value(&t.tool).unwrap_or(Value::Null)) + .collect(); + Response::ok(id, json!({ "tools": tools })) + } + McpMethod::Ping => Response::ok(id, Value::Object(Default::default())), + McpMethod::ToolsCall { name, arguments } => { + // 查工具 + let Some(spec) = tools::find(&name) else { + return Response::ok( + id, + serde_json::to_value(CallToolResult::error(format!("未知工具: {name}"))) + .unwrap_or(Value::Null), + ); + }; + // read-only 模式:Medium/High 全拒 + if read_only && spec.risk != RiskLevel::Low { + let r = CallToolResult::error(format!( + "只读模式拒绝执行 {name}(风险等级 {:?})", + spec.risk + )); + return Response::ok(id, serde_json::to_value(r).unwrap_or(Value::Null)); + } + // 非 read-only:High 兜底拒绝(handler 内也会拒,双保险) + if spec.risk == RiskLevel::High { + let r = CallToolResult::error(format!( + "High 风险操作 {name} 默认拒绝,请在 DevFlow 应用内执行。" + )); + return Response::ok(id, serde_json::to_value(r).unwrap_or(Value::Null)); + } + // Low / Medium:执行 + let result = (spec.handler)(ctx, arguments).await; + Response::ok(id, serde_json::to_value(result).unwrap_or(Value::Null)) + } + McpMethod::Unknown(m) => Response::err( + id, + crate::protocol::METHOD_NOT_FOUND, + format!("Method not found: {m}"), + None, + ), + } +} + +/// 工具可见性:read-only 仅 Low,否则 Low + Medium(High 永不可见) +fn visible(read_only: bool, risk: RiskLevel) -> bool { + if read_only { + risk == RiskLevel::Low + } else { + risk != RiskLevel::High + } +} + +/// 单行写 Response(末尾 \n,MCP 客户端按行 framing) +async fn write_response( + writer: &mut W, + resp: &Response, +) -> anyhow::Result<()> { + let mut buf = serde_json::to_string(resp)?; + buf.push('\n'); + writer.write_all(buf.as_bytes()).await?; + writer.flush().await?; + Ok(()) +} + +// ============================================================ +// 单测:协议 framing / initialize / tools/list / tools/call / High 拒绝 / read-only 过滤 +// ============================================================ +// +// 测试驱动 dispatch()(协议层逻辑核心),不跑真实 stdio(真实 stdio 需集成测试)。 +// main_loop 的 framing(write_response 单行 +\n、notification 不回)由 dispatch 的输入输出间接覆盖。 + +#[cfg(test)] +mod tests { + use super::*; + use crate::tools::RiskLevel; + + /// 构造内存 DB + Ctx + async fn test_ctx() -> Ctx { + let db = Arc::new(Database::open_in_memory().await.unwrap()); + Ctx::new(db) + } + + /// 直接驱动 dispatch:喂一行 → 收一行(逐行,notification 跳过) + async fn run_io_lines(lines: &[&str], read_only: bool) -> Vec { + let ctx = test_ctx().await; + let mut out = Vec::new(); + for line in lines { + let req: Request = serde_json::from_str(line).unwrap(); + let is_notif = req.id.is_none(); + let method = McpMethod::from_request(&req); + if is_notif { + continue; + } + let resp = dispatch(&ctx, read_only, req.id.clone(), method).await; + out.push(serde_json::to_string(&resp).unwrap()); + } + out + } + + #[tokio::test] + async fn initialize_returns_server_info_and_capabilities() { + let input = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#; + let out = run_io_lines(&[input], false).await; + assert_eq!(out.len(), 1); + let v: Value = serde_json::from_str(&out[0]).unwrap(); + assert_eq!(v["jsonrpc"], "2.0"); + assert_eq!(v["id"], 1); + assert_eq!(v["result"]["protocolVersion"], PROTOCOL_VERSION); + assert_eq!(v["result"]["serverInfo"]["name"], SERVER_NAME); + // capabilities.tools 存在 + assert!(v["result"]["capabilities"]["tools"].is_object()); + } + + #[tokio::test] + async fn tools_list_excludes_high_risk_by_default() { + let input = r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#; + let out = run_io_lines(&[input], false).await; + let v: Value = serde_json::from_str(&out[0]).unwrap(); + let tools = v["result"]["tools"].as_array().unwrap(); + let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); + // 默认(非 read-only):Low + Medium 可见,High 不可见 + assert!(names.contains(&"list_projects")); + assert!(names.contains(&"create_project")); // Medium + assert!(!names.contains(&"delete_project")); // High + assert!(!names.contains(&"run_workflow")); // High + // MCP Tool schema:name + inputSchema + let first = &tools[0]; + assert!(first["name"].is_string()); + assert!(first["inputSchema"]["type"] == "object"); + } + + #[tokio::test] + async fn tools_list_read_only_keeps_only_low() { + let input = r#"{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{}}"#; + let out = run_io_lines(&[input], true).await; + let v: Value = serde_json::from_str(&out[0]).unwrap(); + let tools = v["result"]["tools"].as_array().unwrap(); + let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); + assert!(names.contains(&"list_projects")); + assert!(!names.contains(&"create_project")); // Medium 被过滤 + assert!(!names.contains(&"delete_project")); // High + } + + #[tokio::test] + async fn tools_call_unknown_method_returns_method_not_found() { + let input = r#"{"jsonrpc":"2.0","id":4,"method":"bogus/method","params":{}}"#; + let out = run_io_lines(&[input], false).await; + let v: Value = serde_json::from_str(&out[0]).unwrap(); + assert_eq!(v["error"]["code"], crate::protocol::METHOD_NOT_FOUND); + } + + #[tokio::test] + async fn tools_call_high_risk_is_rejected() { + let input = + r#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"delete_project","arguments":{"id":"x"}}}"#; + let out = run_io_lines(&[input], false).await; + let v: Value = serde_json::from_str(&out[0]).unwrap(); + let content = v["result"]["content"].as_array().unwrap(); + assert_eq!(content[0]["type"], "text"); + let text = content[0]["text"].as_str().unwrap(); + assert!(text.contains("拒绝") || text.contains("High")); + assert_eq!(v["result"]["isError"], true); + } + + #[tokio::test] + async fn tools_call_read_only_rejects_medium() { + let input = + r#"{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"create_project","arguments":{"name":"t","description":"d"}}}"#; + let out = run_io_lines(&[input], true).await; + let v: Value = serde_json::from_str(&out[0]).unwrap(); + let text = v["result"]["content"][0]["text"].as_str().unwrap(); + assert!(text.contains("只读模式拒绝")); + } + + #[tokio::test] + async fn tools_call_create_then_list_project_roundtrip() { + let create = + r#"{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"create_project","arguments":{"name":"McpProj","description":"via mcp"}}}"#; + let list = + r#"{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"list_projects","arguments":{}}}"#; + let out = run_io_lines(&[create, list], false).await; + let created: Value = serde_json::from_str(&out[0]).unwrap(); + let id = created["result"]["content"][0]["text"] + .as_str() + .unwrap(); + let created_v: Value = serde_json::from_str(id).unwrap(); + assert!(created_v["id"].is_string()); + + let listed: Value = serde_json::from_str(&out[1]).unwrap(); + let list_text = listed["result"]["content"][0]["text"].as_str().unwrap(); + let list_v: Value = serde_json::from_str(list_text).unwrap(); + assert_eq!(list_v["count"], 1); + assert_eq!(list_v["projects"][0]["name"], "McpProj"); + } + + #[tokio::test] + async fn visible_predicate() { + assert!(visible(false, RiskLevel::Low)); + assert!(visible(false, RiskLevel::Medium)); + assert!(!visible(false, RiskLevel::High)); + assert!(visible(true, RiskLevel::Low)); + assert!(!visible(true, RiskLevel::Medium)); + assert!(!visible(true, RiskLevel::High)); + } +} diff --git a/crates/df-mcp/src/tools.rs b/crates/df-mcp/src/tools.rs new file mode 100644 index 0000000..f6352cd --- /dev/null +++ b/crates/df-mcp/src/tools.rs @@ -0,0 +1,703 @@ +//! 工具暴露层 — 从 df-storage Repo 复用 CRUD,转 MCP Tool schema +//! +//! 不重复实现数据层逻辑,只做:① 工具元数据(name/description/inputSchema)声明; +//! ② 工具调用 → df-storage Repo 方法 → 序列化为 MCP CallToolResult。 +//! +//! 安全降级: +//! - [`RiskLevel::High`]:默认拒绝,返「请在 DevFlow 应用内执行」 +//! - [`RiskLevel::Medium`]:默认允许 + tracing::warn 审计日志 +//! - [`RiskLevel::Low`]:默认允许,只读无副作用 +//! - `--read-only`:dispatch 阶段过滤,仅 Low 工具可见 +//! +//! handler 形态:`fn(&Ctx, Value) -> BoxFuture`(函数指针 + async 块), +//! 避免闭包捕获带来的 Box 开销与生命周期问题。 + +use std::sync::Arc; + +use df_storage::crud::{IdeaRepo, ProjectRepo, TaskRepo}; +use df_storage::db::Database; +use df_storage::models::{IdeaRecord, ProjectRecord, TaskRecord}; +use df_types::types::new_id; +use futures::future::BoxFuture; +use serde_json::{json, Value}; + +use crate::protocol::{CallToolResult, Tool}; + +// ============================================================ +// 风险等级 +// ============================================================ + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RiskLevel { + /// 只读无副作用:list/get + Low, + /// 有副作用但可逆:create/update/bind/restore/advance + Medium, + /// 不可逆或高破坏:delete/purge/run_workflow → 默认拒绝 + High, +} + +// ============================================================ +// 工具元数据 + 上下文 +// ============================================================ + +/// 工具元数据 + 风险等级 + handler 函数指针。 +pub struct ToolSpec { + pub tool: Tool, + pub risk: RiskLevel, + pub handler: HandlerFn, +} + +/// handler 函数指针类型:接收上下文引用 + 参数,返回 boxed future。 +pub type HandlerFn = fn(&Ctx, Value) -> BoxFuture<'static, CallToolResult>; + +/// 工具执行上下文 — 持有所有 Repo 句柄。每个 handler 内部重新构造 Repo(零开销,Repo 仅持 Arc)。 +/// +/// 不预存 Repo 是因为 Repo::new 借用 &Database,生命周期管理麻烦;Arc clone 廉价。 +pub struct Ctx { + pub db: Arc, +} + +impl Ctx { + pub fn new(db: Arc) -> Self { + Self { db } + } +} + +// ============================================================ +// inputSchema 构造助手 +// ============================================================ + +fn object_schema(properties: Value, required: &[&str]) -> Value { + json!({ + "type": "object", + "properties": properties, + "required": required, + "additionalProperties": false + }) +} + +fn str_field(desc: &str) -> Value { + json!({ "type": "string", "description": desc }) +} + +fn opt_str_field(desc: &str) -> Value { + json!({ "type": "string", "description": desc }) +} + +fn int_field(desc: &str) -> Value { + json!({ "type": "integer", "description": desc }) +} + +// ============================================================ +// 工具清单 +// ============================================================ + +/// 返回全部已注册工具(只读模式由 dispatch 过滤 High/Medium)。 +pub fn all_tools() -> Vec<&'static ToolSpec> { + use RiskLevel::*; + vec![ + // ─── 项目 ─── + spec("list_projects", "列出所有未删除项目", object_schema(json!({}), &[]), Low, list_projects), + spec("get_project", "按 ID 获取项目", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), Low, get_project), + spec("create_project", "创建项目(Medium 风险,默认允许+审计日志)", object_schema(json!({"name": str_field("项目名"), "description": str_field("描述"), "status": opt_str_field("状态(默认 active)")}), &["name", "description"]), Medium, create_project), + spec("update_project", "更新项目(整体替换 description/status/path/stack)", object_schema(json!({"id": str_field("项目 ID"), "name": str_field("项目名"), "description": str_field("描述"), "status": opt_str_field("状态")}), &["id", "name", "description"]), Medium, update_project), + spec("delete_project", "软删项目(进回收站,可恢复)——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), High, delete_project), + spec("bind_directory", "为项目绑定本地代码目录(会做路径冲突检测,Medium 风险+审计日志)", object_schema(json!({"id": str_field("项目 ID"), "path": str_field("本地目录绝对路径")}), &["id", "path"]), Medium, bind_directory), + // ─── 任务 ─── + spec("list_tasks", "列出所有未删除任务(可按 project_id 过滤)", object_schema(json!({"project_id": opt_str_field("按项目过滤(可空)")}), &[]), Low, list_tasks), + spec("create_task", "创建任务(Medium 风险,默认允许+审计日志)", object_schema(json!({"project_id": str_field("项目 ID"), "title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 0)")}), &["project_id", "title", "description"]), Medium, create_task), + spec("update_task", "更新任务(整体替换)", object_schema(json!({"id": str_field("任务 ID"), "project_id": str_field("项目 ID"), "title": str_field("标题"), "description": str_field("描述")}), &["id", "project_id", "title", "description"]), Medium, update_task), + spec("advance_task", "推进任务状态(需传当前 status 与目标 status,Medium 风险+审计日志)", object_schema(json!({"id": str_field("任务 ID"), "from": str_field("当前 status"), "to": str_field("目标 status")}), &["id", "from", "to"]), Medium, advance_task), + spec("delete_task", "软删任务(进回收站)——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("任务 ID")}), &["id"]), High, delete_task), + // ─── 灵感 ─── + spec("list_ideas", "列出所有想法/灵感", object_schema(json!({}), &[]), Low, list_ideas), + spec("create_idea", "创建想法(Medium 风险,默认允许+审计日志)", object_schema(json!({"title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 0)")}), &["title", "description"]), Medium, create_idea), + spec("update_idea", "更新想法(整体替换)", object_schema(json!({"id": str_field("想法 ID"), "title": str_field("标题"), "description": str_field("描述")}), &["id", "title", "description"]), Medium, update_idea), + spec("delete_idea", "软删想法——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), High, delete_idea), + spec("evaluate_idea", "对想法做启发式评估(只读,基于 description/title 计算 feasibility/impact/urgency/overall)", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), Low, evaluate_idea), + // ─── 工作流(High) ─── + spec("run_workflow", "触发工作流——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"project_id": str_field("项目 ID"), "task_id": opt_str_field("任务 ID(可空)")}), &["project_id"]), High, run_workflow), + // ─── 回收站 ─── + spec("list_trash", "列出回收站(deleted_at IS NOT NULL 的项目与任务)", object_schema(json!({}), &[]), Low, list_trash), + spec("restore_project", "从回收站恢复项目(Medium 风险+审计日志)", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), Medium, restore_project), + ] +} + +/// 工具元数据构造助手 — Box::leak 静态化(进程生命周期,启动一次性构造)。 +#[allow(clippy::too_many_arguments)] +fn spec( + name: &'static str, + description: &'static str, + input_schema: Value, + risk: RiskLevel, + handler: HandlerFn, +) -> &'static ToolSpec { + Box::leak(Box::new(ToolSpec { + tool: Tool { + name, + description, + input_schema, + }, + risk, + handler, + })) +} + +// ============================================================ +// 工具查找 +// ============================================================ + +/// 按 name 查找工具(线性扫描,工具数 19,O(n) 足够)。 +pub fn find(name: &str) -> Option<&'static ToolSpec> { + all_tools().into_iter().find(|t| t.tool.name == name) +} + +// ============================================================ +// handler 公共助手 +// ============================================================ + +fn now_millis() -> String { + df_types::now_millis().to_string() +} + +fn json_ok(v: Value) -> CallToolResult { + CallToolResult::text(serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".into())) +} + +fn err_str(e: impl std::fmt::Display) -> CallToolResult { + CallToolResult::error(format!("内部错误: {e}")) +} + +fn medium_audit(name: &str, args_summary: &str) { + tracing::warn!(target: "df_mcp_audit", tool = name, args = %args_summary, "MCP Medium 风险工具被外部调用"); +} + +/// 取必填字符串参数 +fn arg_str(args: &Value, key: &str) -> Result { + args.get(key) + .and_then(|v| v.as_str()) + .map(|s| s.to_owned()) + .ok_or_else(|| CallToolResult::error(format!("缺少必填参数: {key}"))) +} + +/// 取可选字符串参数(默认值) +fn arg_str_or(args: &Value, key: &str, default: &str) -> String { + args.get(key) + .and_then(|v| v.as_str()) + .unwrap_or(default) + .to_owned() +} + +/// 取可选整数参数(默认值) +fn arg_int_or(args: &Value, key: &str, default: i32) -> i32 { + args.get(key) + .and_then(|v| v.as_i64()) + .map(|i| i as i32) + .unwrap_or(default) +} + +// ============================================================ +// handler 实现 — 项目 +// ============================================================ + +fn list_projects(ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> { + let db = ctx.db.clone(); + Box::pin(async move { + let repo = ProjectRepo::new(&db); + match repo.list_active().await { + Ok(list) => json_ok(json!({ "projects": list, "count": list.len() })), + Err(e) => err_str(e), + } + }) +} + +fn get_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { + let db = ctx.db.clone(); + let id = match arg_str(&args, "id") { + Ok(v) => v, + Err(r) => return Box::pin(std::future::ready(r)), + }; + Box::pin(async move { + let repo = ProjectRepo::new(&db); + match repo.get_by_id(&id).await { + Ok(Some(p)) => json_ok(json!(p)), + Ok(None) => CallToolResult::error(format!("项目不存在: {id}")), + Err(e) => err_str(e), + } + }) +} + +fn create_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { + let db = ctx.db.clone(); + let name = match arg_str(&args, "name") { + Ok(v) => v, + Err(r) => return Box::pin(std::future::ready(r)), + }; + let description = arg_str_or(&args, "description", ""); + let status = arg_str_or(&args, "status", "active"); + medium_audit("create_project", &name); + Box::pin(async move { + let now = now_millis(); + let rec = ProjectRecord { + id: new_id(), + name, + description, + status, + idea_id: None, + path: None, + stack: None, + created_at: now.clone(), + updated_at: now, + }; + let repo = ProjectRepo::new(&db); + match repo.insert(rec).await { + Ok(id) => { + let created = repo.get_by_id(&id).await.ok().flatten(); + json_ok(json!({ "id": id, "project": created })) + } + Err(e) => err_str(e), + } + }) +} + +fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { + let db = ctx.db.clone(); + let id = match arg_str(&args, "id") { + Ok(v) => v, + Err(r) => return Box::pin(std::future::ready(r)), + }; + let name = arg_str_or(&args, "name", ""); + let description = arg_str_or(&args, "description", ""); + let status = arg_str_or(&args, "status", "active"); + medium_audit("update_project", &id); + Box::pin(async move { + let repo = ProjectRepo::new(&db); + // 先读现有保留 path/stack/idea_id + let existing = match repo.get_by_id(&id).await { + Ok(Some(p)) => p, + Ok(None) => return CallToolResult::error(format!("项目不存在: {id}")), + Err(e) => return err_str(e), + }; + let now = now_millis(); + let rec = ProjectRecord { + id: id.clone(), + name, + description, + status, + idea_id: existing.idea_id, + path: existing.path, + stack: existing.stack, + created_at: existing.created_at, + updated_at: now, + }; + match repo.update_full(&rec).await { + Ok(true) => { + let updated = repo.get_by_id(&id).await.ok().flatten(); + json_ok(json!({ "id": id, "project": updated })) + } + Ok(false) => CallToolResult::error(format!("项目不存在: {id}")), + Err(e) => err_str(e), + } + }) +} + +fn delete_project(_ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> { + // High 风险:默认拒绝(dispatch 兜底 + 此处二次防御,防 dispatch 漏判) + Box::pin(std::future::ready(CallToolResult::error( + "High 风险操作(delete_project)默认拒绝,请在 DevFlow 应用内执行。", + ))) +} + +fn bind_directory(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { + let db = ctx.db.clone(); + let id = match arg_str(&args, "id") { + Ok(v) => v, + Err(r) => return Box::pin(std::future::ready(r)), + }; + let path = match arg_str(&args, "path") { + Ok(v) => v, + Err(r) => return Box::pin(std::future::ready(r)), + }; + medium_audit("bind_directory", &format!("{id} <- {path}")); + Box::pin(async move { + let repo = ProjectRepo::new(&db); + let norm = normalize_path(&path); + // 路径冲突检测 + if let Some(conflict) = repo.find_path_conflict(&norm, Some(&id)).await.ok().flatten() { + return CallToolResult::error(format!( + "路径已被项目「{}」({})绑定,请先解绑", + conflict.name, conflict.id + )); + } + // 仅更新 path 字段(用 update_field,保留其它) + if !repo.update_field(&id, "path", &path).await.unwrap_or(false) { + return CallToolResult::error(format!("项目不存在: {id}")); + } + let updated = repo.get_by_id(&id).await.ok().flatten(); + json_ok(json!({ "id": id, "project": updated })) + }) +} + +// ============================================================ +// handler 实现 — 任务 +// ============================================================ + +fn list_tasks(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { + let db = ctx.db.clone(); + let project_id_filter = args.get("project_id").and_then(|v| v.as_str()).map(|s| s.to_owned()); + Box::pin(async move { + let repo = TaskRepo::new(&db); + match repo.list_active().await { + Ok(mut list) => { + if let Some(pid) = project_id_filter { + list.retain(|t| t.project_id == pid); + } + json_ok(json!({ "tasks": list, "count": list.len() })) + } + Err(e) => err_str(e), + } + }) +} + +fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { + let db = ctx.db.clone(); + let project_id = match arg_str(&args, "project_id") { + Ok(v) => v, + Err(r) => return Box::pin(std::future::ready(r)), + }; + let title = match arg_str(&args, "title") { + Ok(v) => v, + Err(r) => return Box::pin(std::future::ready(r)), + }; + let description = arg_str_or(&args, "description", ""); + let priority = arg_int_or(&args, "priority", 0); + medium_audit("create_task", &format!("{project_id}/{title}")); + Box::pin(async move { + let now = now_millis(); + let rec = TaskRecord { + id: new_id(), + project_id, + title, + description, + status: "todo".to_owned(), + priority, + branch_name: None, + assignee: None, + workflow_def_id: None, + base_branch: None, + review_rounds: 0, + output_json: None, + created_at: now.clone(), + updated_at: now, + }; + let repo = TaskRepo::new(&db); + match repo.insert(rec).await { + Ok(id) => { + let created = repo.get_by_id(&id).await.ok().flatten(); + json_ok(json!({ "id": id, "task": created })) + } + Err(e) => err_str(e), + } + }) +} + +fn update_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { + let db = ctx.db.clone(); + let id = match arg_str(&args, "id") { + Ok(v) => v, + Err(r) => return Box::pin(std::future::ready(r)), + }; + let project_id = match arg_str(&args, "project_id") { + Ok(v) => v, + Err(r) => return Box::pin(std::future::ready(r)), + }; + let title = match arg_str(&args, "title") { + Ok(v) => v, + Err(r) => return Box::pin(std::future::ready(r)), + }; + let description = arg_str_or(&args, "description", ""); + medium_audit("update_task", &id); + Box::pin(async move { + let repo = TaskRepo::new(&db); + let existing = match repo.get_by_id(&id).await { + Ok(Some(t)) => t, + Ok(None) => return CallToolResult::error(format!("任务不存在: {id}")), + Err(e) => return err_str(e), + }; + let now = now_millis(); + let rec = TaskRecord { + id: id.clone(), + project_id, + title, + description, + // 不允许经 MCP 改状态(状态机收口,须走 advance_task) + status: existing.status, + priority: existing.priority, + branch_name: existing.branch_name, + assignee: existing.assignee, + workflow_def_id: existing.workflow_def_id, + base_branch: existing.base_branch, + review_rounds: existing.review_rounds, + output_json: existing.output_json, + created_at: existing.created_at, + updated_at: now, + }; + match repo.update_full(&rec).await { + Ok(true) => { + let updated = repo.get_by_id(&id).await.ok().flatten(); + json_ok(json!({ "id": id, "task": updated })) + } + Ok(false) => CallToolResult::error(format!("任务不存在: {id}")), + Err(e) => err_str(e), + } + }) +} + +fn advance_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { + let db = ctx.db.clone(); + let id = match arg_str(&args, "id") { + Ok(v) => v, + Err(r) => return Box::pin(std::future::ready(r)), + }; + let from = match arg_str(&args, "from") { + Ok(v) => v, + Err(r) => return Box::pin(std::future::ready(r)), + }; + let to = match arg_str(&args, "to") { + Ok(v) => v, + Err(r) => return Box::pin(std::future::ready(r)), + }; + medium_audit("advance_task", &format!("{id}: {from} -> {to}")); + Box::pin(async move { + // bump_rounds:退回转换(in_review->in_progress / testing->in_review)累计 review_rounds + let bump = matches!( + (from.as_str(), to.as_str()), + ("in_review", "in_progress") | ("testing", "in_review") + ); + let repo = TaskRepo::new(&db); + match repo.advance_status_atomic(&id, &from, &to, bump).await { + Ok(Some(updated)) => json_ok(json!({ "id": id, "task": updated })), + Ok(None) => CallToolResult::error(format!( + "推进失败(状态已变或任务不存在/回收站): 期望 {from},实际可能不同" + )), + Err(e) => err_str(e), + } + }) +} + +fn delete_task(_ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> { + Box::pin(std::future::ready(CallToolResult::error( + "High 风险操作(delete_task)默认拒绝,请在 DevFlow 应用内执行。", + ))) +} + +// ============================================================ +// handler 实现 — 灵感 +// ============================================================ + +fn list_ideas(ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> { + let db = ctx.db.clone(); + Box::pin(async move { + let repo = IdeaRepo::new(&db); + match repo.list_all().await { + Ok(list) => json_ok(json!({ "ideas": list, "count": list.len() })), + Err(e) => err_str(e), + } + }) +} + +fn create_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { + let db = ctx.db.clone(); + let title = match arg_str(&args, "title") { + Ok(v) => v, + Err(r) => return Box::pin(std::future::ready(r)), + }; + let description = arg_str_or(&args, "description", ""); + let priority = arg_int_or(&args, "priority", 0); + medium_audit("create_idea", &title); + Box::pin(async move { + let now = now_millis(); + let rec = IdeaRecord { + id: new_id(), + title, + description, + status: "draft".to_owned(), + priority, + score: None, + tags: None, + source: Some("mcp".to_owned()), + promoted_to: None, + ai_analysis: None, + scores: None, + created_at: now.clone(), + updated_at: now, + }; + let repo = IdeaRepo::new(&db); + match repo.insert(rec).await { + Ok(id) => { + let created = repo.get_by_id(&id).await.ok().flatten(); + json_ok(json!({ "id": id, "idea": created })) + } + Err(e) => err_str(e), + } + }) +} + +fn update_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { + let db = ctx.db.clone(); + let id = match arg_str(&args, "id") { + Ok(v) => v, + Err(r) => return Box::pin(std::future::ready(r)), + }; + let title = arg_str_or(&args, "title", ""); + let description = arg_str_or(&args, "description", ""); + medium_audit("update_idea", &id); + Box::pin(async move { + let repo = IdeaRepo::new(&db); + let existing = match repo.get_by_id(&id).await { + Ok(Some(i)) => i, + Ok(None) => return CallToolResult::error(format!("想法不存在: {id}")), + Err(e) => return err_str(e), + }; + let now = now_millis(); + let rec = IdeaRecord { + id: id.clone(), + title, + description, + status: existing.status, + priority: existing.priority, + score: existing.score, + tags: existing.tags, + source: existing.source, + promoted_to: existing.promoted_to, + ai_analysis: existing.ai_analysis, + scores: existing.scores, + created_at: existing.created_at, + updated_at: now, + }; + match repo.update_full(&rec).await { + Ok(true) => { + let updated = repo.get_by_id(&id).await.ok().flatten(); + json_ok(json!({ "id": id, "idea": updated })) + } + Ok(false) => CallToolResult::error(format!("想法不存在: {id}")), + Err(e) => err_str(e), + } + }) +} + +fn delete_idea(_ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> { + Box::pin(std::future::ready(CallToolResult::error( + "High 风险操作(delete_idea)默认拒绝,请在 DevFlow 应用内执行。", + ))) +} + +fn evaluate_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { + let db = ctx.db.clone(); + let id = match arg_str(&args, "id") { + Ok(v) => v, + Err(r) => return Box::pin(std::future::ready(r)), + }; + Box::pin(async move { + let repo = IdeaRepo::new(&db); + let idea = match repo.get_by_id(&id).await { + Ok(Some(i)) => i, + Ok(None) => return CallToolResult::error(format!("想法不存在: {id}")), + Err(e) => return err_str(e), + }; + // 启发式评分(本地确定性,不调 LLM) + let scores = heuristic_scores(&idea.title, &idea.description); + let now = now_millis(); + // 写回 scores 字段(整体更新) + let mut rec = idea.clone(); + rec.scores = Some(serde_json::to_string(&scores).unwrap_or_default()); + rec.updated_at = now; + let _ = repo.update_full(&rec).await; + json_ok(json!({ "id": id, "idea": idea, "scores": scores })) + }) +} + +/// 启发式评分:基于标题长度/描述详细度/关键词,产出 feasibility/impact/urgency/overall 0-10 分。 +/// 确定性纯函数,与 df-ideas 评估器对齐维度但不依赖 df-ai。 +fn heuristic_scores(title: &str, description: &str) -> Value { + let desc_len = description.chars().count(); + let title_len = title.chars().count(); + // feasibility:描述越详细越可行(评估前已有思考) + let feasibility = ((desc_len as f64 / 200.0).min(1.0) * 6.0 + 3.0).min(9.0); + // impact:含「核心/关键/重要」等关键词加权 + let impact_keywords = ["核心", "关键", "重要", "紧急", "blocker", "critical", "core"]; + let kw_hits = impact_keywords.iter().filter(|k| title.contains(*k) || description.contains(*k)).count(); + let impact = (5.0 + kw_hits as f64 * 1.5).min(9.0); + // urgency:priority 字段不在此,用关键词近似 + let urgency_kw = ["紧急", "urgent", "asap", "立即", "马上"]; + let urgency_hits = urgency_kw.iter().filter(|k| title.contains(*k) || description.contains(*k)).count(); + let urgency = (4.0 + urgency_hits as f64 * 2.0).min(9.0); + // overall:加权平均(feasibility/impact/urgency = 0.4/0.4/0.2) + let overall = feasibility * 0.4 + impact * 0.4 + urgency * 0.2; + let _ = title_len; // 标题长度暂不入分(避免短标题被低估) + json!({ + "feasibility": (feasibility * 10.0).round() / 10.0, + "impact": (impact * 10.0).round() / 10.0, + "urgency": (urgency * 10.0).round() / 10.0, + "overall": (overall * 10.0).round() / 10.0 + }) +} + +// ============================================================ +// handler 实现 — 工作流(High,拒绝) +// ============================================================ + +fn run_workflow(_ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> { + Box::pin(std::future::ready(CallToolResult::error( + "High 风险操作(run_workflow)默认拒绝。工作流涉及代码生成/审查/分支操作,请在 DevFlow 应用内执行。", + ))) +} + +// ============================================================ +// handler 实现 — 回收站 +// ============================================================ + +fn list_trash(ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> { + let db = ctx.db.clone(); + Box::pin(async move { + let projects = ProjectRepo::new(&db).list_deleted().await.unwrap_or_default(); + let tasks = TaskRepo::new(&db).list_deleted().await.unwrap_or_default(); + json_ok(json!({ + "projects": projects, + "tasks": tasks, + "project_count": projects.len(), + "task_count": tasks.len() + })) + }) +} + +fn restore_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { + let db = ctx.db.clone(); + let id = match arg_str(&args, "id") { + Ok(v) => v, + Err(r) => return Box::pin(std::future::ready(r)), + }; + medium_audit("restore_project", &id); + Box::pin(async move { + match ProjectRepo::new(&db).restore(&id).await { + Ok(true) => json_ok(json!({ "id": id, "restored": true })), + Ok(false) => CallToolResult::error(format!("恢复失败(项目不在回收站): {id}")), + Err(e) => err_str(e), + } + }) +} + +// ============================================================ +// 路径规范化(镜像 df_project::scan::normalize_path,本 crate 不依赖 df-project) +// ============================================================ + +fn normalize_path(p: &str) -> String { + match std::path::Path::new(p).canonicalize() { + Ok(abs) => abs.to_string_lossy().replace('\\', "/").to_lowercase(), + Err(_) => p + .trim_end_matches(['\\', '/']) + .replace('\\', "/") + .to_lowercase(), + } +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4416aec..557856f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -33,6 +33,7 @@ df-execute = { path = "../crates/df-execute" } df-ai = { path = "../crates/df-ai" } df-ideas = { path = "../crates/df-ideas" } df-project = { path = "../crates/df-project" } +df-mcp = { path = "../crates/df-mcp" } futures = "0.3" # write_file base64 编码:写二进制/非 UTF-8 文件(图片/PDF/Excel 等) base64 = "0.22" diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index e75f473..6d6f610 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,6 +1,133 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -fn main() { - devflow_lib::run() +use std::path::PathBuf; +use std::process::ExitCode; + +fn main() -> ExitCode { + // CLI 子命令:手动解析 argv(避免引入 clap 重依赖)。 + // + // `devflow mcp-server [--db ] [--read-only]` + // `devflow mcp-server --print-config` + // + // 仅识别首个非程序名参数是否为 `mcp-server`;其余情况走 GUI。 + // stdio 句柄由父进程(MCP 客户端)继承,无需额外配置。 + let args: Vec = std::env::args().collect(); + if args.iter().nth(1).map(|s| s.as_str()) == Some("mcp-server") { + return run_mcp_server(&args[2..]); + } + devflow_lib::run(); + ExitCode::SUCCESS +} + +/// 解析 mcp-server 子命令参数并启动 MCP server。 +/// +/// - `--db `:指定 SQLite 路径(默认:系统 app_data_dir/devflow[-dev].db) +/// - `--read-only`:仅暴露 list/get 工具 +/// - `--print-config`:输出 Claude Code / Cursor 的 MCP 配置 JSON 片段到 stdout 后退出 +fn run_mcp_server(args: &[String]) -> ExitCode { + let mut db_path: Option = None; + let mut read_only = false; + let mut print_config = false; + + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--db" => { + i += 1; + if i >= args.len() { + eprintln!("--db 需要参数"); + return ExitCode::FAILURE; + } + db_path = Some(PathBuf::from(&args[i])); + } + "--read-only" => read_only = true, + "--print-config" => print_config = true, + "-h" | "--help" => { + println!("devflow mcp-server [--db ] [--read-only] [--print-config]"); + println!(); + println!(" --db 指定 SQLite 数据库路径(默认:app_data_dir/devflow.db)"); + println!(" --read-only 仅暴露 list/get 工具(拒绝 create/update/delete)"); + println!(" --print-config 输出 MCP 配置 JSON 片段到 stdout 后退出"); + return ExitCode::SUCCESS; + } + other => { + eprintln!("未知参数: {other}(用 --help 查看)"); + return ExitCode::FAILURE; + } + } + i += 1; + } + + if print_config { + // 输出 Claude Code / Cursor 兼容的 MCP 配置片段(command/args 形态)。 + // Claude Code: claude mcp add devflow -- devflow mcp-server [--read-only] + // Cursor: 在 ~/.cursor/mcp.json 加 mcpServers.devflow + let read_only_flag = if read_only { r#","--read-only""# } else { "" }; + let json = format!( + r#"{{"mcpServers":{{"devflow":{{"command":"devflow","args":["mcp-server"{read_only_flag}]}}}}}}"# + ); + println!("{json}"); + println!(); + println!("# Claude Code 接入:"); + println!("# claude mcp add devflow -- devflow mcp-server"); + println!("# Cursor 接入:把上面 JSON 写入 ~/.cursor/mcp.json"); + return ExitCode::SUCCESS; + } + + // 解析 db 路径(未指定则用系统 app_data_dir) + let db_path = match db_path { + Some(p) => p, + None => match default_db_path() { + Ok(p) => p, + Err(e) => { + eprintln!("无法定位默认数据库目录: {e}"); + eprintln!("请用 --db 显式指定"); + return ExitCode::FAILURE; + } + }, + }; + + // 启动 MCP server(tokio runtime) + let rt = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(e) => { + eprintln!("启动 tokio runtime 失败: {e}"); + return ExitCode::FAILURE; + } + }; + match rt.block_on(df_mcp::run_server(&db_path, read_only)) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("MCP server 异常退出: {e}"); + ExitCode::FAILURE + } + } +} + +/// 默认数据库路径:与 GUI 应用同库(系统 app_data_dir/devflow.db 或 dev-dev.db)。 +/// +/// 复用与 lib.rs::run 一致的命名(debug 用 devflow-dev.db,release 用 devflow.db), +/// 保证 MCP server 与 GUI 读写同一文件。 +fn default_db_path() -> anyhow::Result { + // tauri 的 app_data_dir 需要 app handle,CLI 无 handle;用 dirs crate 或平台约定降级。 + // 这里用 std 环境变量推导(不引 dirs):优先 APPDATA/tav(Windows)、HOME( unix)。 + let base = if cfg!(target_os = "windows") { + std::env::var("APPDATA").map(PathBuf::from)? + } else if cfg!(target_os = "macos") { + let home = std::env::var("HOME").map(PathBuf::from)?; + home.join("Library/Application Support") + } else { + let home = std::env::var("HOME").map(PathBuf::from)?; + home.join(".local/share") + }; + // Tauri app_data_dir 末段为包名/产品名(devflow)。dev 模式可能为 devflow-dev。 + let dir = base.join("devflow"); + std::fs::create_dir_all(&dir)?; + let name = if cfg!(debug_assertions) { + "devflow-dev.db" + } else { + "devflow.db" + }; + Ok(dir.join(name)) }