新增: 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:
16
Cargo.lock
generated
16
Cargo.lock
generated
@@ -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"
|
||||
|
||||
19
crates/df-mcp/Cargo.toml
Normal file
19
crates/df-mcp/Cargo.toml
Normal 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
19
crates/df-mcp/src/lib.rs
Normal 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;
|
||||
201
crates/df-mcp/src/protocol.rs
Normal file
201
crates/df-mcp/src/protocol.rs
Normal 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
343
crates/df-mcp/src/server.rs
Normal 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
703
crates/df-mcp/src/tools.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 <path>] [--read-only]`
|
||||
// `devflow mcp-server --print-config`
|
||||
//
|
||||
// 仅识别首个非程序名参数是否为 `mcp-server`;其余情况走 GUI。
|
||||
// stdio 句柄由父进程(MCP 客户端)继承,无需额外配置。
|
||||
let args: Vec<String> = 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 <path>`:指定 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<PathBuf> = 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 <path>] [--read-only] [--print-config]");
|
||||
println!();
|
||||
println!(" --db <path> 指定 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 <path> 显式指定");
|
||||
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<PathBuf> {
|
||||
// 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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user