新增: MCP Server(df-mcp crate + stdio 协议 + CLI 子命令)

依据 F-260619-02 设计,对外暴露数据层接入 Claude Code/Cursor:
- 新建 df-mcp crate:JSON-RPC 2.0 over stdio(initialize/tools/list/tools/call)
- 暴露 19 数据层工具(项目/任务/灵感/工作流/回收站),复用 df-storage Repo 零重复 CRUD
- 三层安全降级:High 风险(delete/purge/run_workflow)tools/list 不暴露+dispatch 拒+handler 二次拒;Medium 允许+审计日志;--read-only 仅 Low
- 不暴露文件系统工具(防绕过应用内路径校验)
- Tauri CLI 子命令 devflow mcp-server [--db/--read-only/--print-config]
- SQLite WAL 已启用(复用 Database::open),应用与 MCP server 并发写安全

自验: df-mcp 8 passed + workspace EXIT 0
This commit is contained in:
2026-06-19 19:24:02 +08:00
parent e981c1492a
commit f217e39bfe
8 changed files with 1431 additions and 2 deletions

19
crates/df-mcp/Cargo.toml Normal file
View File

@@ -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"] }

19
crates/df-mcp/src/lib.rs Normal file
View File

@@ -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;

View File

@@ -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<String>,
pub id: Option<Value>,
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<Value>,
}
/// 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<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<ErrorObject>,
}
impl Response {
/// 成功响应
pub fn ok(id: Option<Value>, result: Value) -> Self {
Self {
jsonrpc: "2.0",
id,
result: Some(result),
error: None,
}
}
/// 错误响应
pub fn err(id: Option<Value>, code: i32, message: impl Into<String>, data: Option<Value>) -> 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<ContentBlock>,
#[serde(rename = "isError", skip_serializing_if = "Option::is_none")]
pub is_error: Option<bool>,
}
/// 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<String>) -> Self {
Self {
content: vec![ContentBlock::Text { text: text.into() }],
is_error: None,
}
}
/// 文本错误响应(is_error=true 让客户端区分「工具业务错」与「协议错」)
pub fn error(text: impl Into<String>) -> Self {
Self {
content: vec![ContentBlock::Text { text: text.into() }],
is_error: Some(true),
}
}
}

343
crates/df-mcp/src/server.rs Normal file
View File

@@ -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<R, W>(
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<Value>, 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<W: tokio::io::AsyncWrite + Unpin>(
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<String> {
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));
}
}

703
crates/df-mcp/src/tools.rs Normal file
View File

@@ -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<CallToolResult>`(函数指针 + async 块),
//! 避免闭包捕获带来的 Box<dyn> 开销与生命周期问题。
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<Database> clone 廉价。
pub struct Ctx {
pub db: Arc<Database>,
}
impl Ctx {
pub fn new(db: Arc<Database>) -> 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<String, CallToolResult> {
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(),
}
}