新增: MCP HTTP transport + 系统托盘 + 实体解析(会话前基线收尾)

- df-mcp 加 streamable HTTP transport 层(axum 0.7 + tower,server_http.rs),lib.rs pub mod 接线

- src-tauri 加 mcp.rs(spawn_mcp_http + mcp_get_status IPC + mcp-server CLI)+ tray.rs(系统托盘 show_main/setup_tray),lib.rs 集成 + main.rs mcp-server 参数路由

- AI 工具加 entity_resolve.rs(实体解析,tools/mod.rs pub mod 接线)
This commit is contained in:
lxy
2026-08-05 22:14:27 +08:00
parent c480627ba6
commit a91e950874
16 changed files with 2238 additions and 94 deletions
Generated
+19
View File
@@ -872,6 +872,7 @@ dependencies = [
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-opener",
"tauri-plugin-single-instance",
"tauri-plugin-window-state",
"tokio",
"tracing",
@@ -949,6 +950,7 @@ name = "df-mcp"
version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"df-nodes",
"df-storage",
"df-types",
@@ -956,6 +958,7 @@ dependencies = [
"serde",
"serde_json",
"tokio",
"tower",
"tracing",
"uuid",
]
@@ -4666,6 +4669,22 @@ dependencies = [
"zbus 5.16.0",
]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3214becf9ef5783c0ae99a3bb25adf5353a7a16ebf53e74b909e29205735c6c"
dependencies = [
"serde",
"serde_json",
"tauri",
"thiserror 2.0.18",
"tokio",
"tracing",
"windows-sys 0.60.2",
"zbus 5.16.0",
]
[[package]]
name = "tauri-plugin-window-state"
version = "2.4.1"
+4
View File
@@ -18,6 +18,10 @@ anyhow.workspace = true
tracing.workspace = true
uuid.workspace = true
futures = "0.3"
# HTTP (streamable HTTP) transport 层:axum Router(workspace 已锁 0.7.9,零新依赖树)
axum = { version = "0.7" }
[dev-dependencies]
tokio = { workspace = true, features = ["full", "test-util"] }
# 单测用 tower::ServiceExt::oneshot 直接打 Router
tower = { version = "0.5", features = ["util"] }
+1
View File
@@ -14,6 +14,7 @@
pub mod protocol;
pub mod server;
pub mod server_http;
pub mod tools;
pub use server::run_server;
+3
View File
@@ -118,6 +118,9 @@ impl McpMethod {
.and_then(|v| v.as_str())
.unwrap_or("")
.to_owned();
if name.is_empty() {
return McpMethod::Unknown("tools/call missing required 'name' parameter".to_owned());
}
let arguments = req.params.get("arguments").cloned().unwrap_or(Value::Null);
McpMethod::ToolsCall { name, arguments }
}
+25 -7
View File
@@ -23,9 +23,9 @@ use crate::protocol::{
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");
pub(crate) const PROTOCOL_VERSION: &str = "2025-06-18";
pub(crate) const SERVER_NAME: &str = "devflow-mcp";
pub(crate) const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
/// 启动 MCP server。
///
@@ -79,7 +79,7 @@ where
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);
let resp = Response::err(Some(Value::Null), crate::protocol::PARSE_ERROR, "Parse error", None);
write_response(&mut writer, &resp).await?;
continue;
}
@@ -111,7 +111,8 @@ where
/// 方法分发 → 构造 Response。
///
/// `id`:JSON-RPC 请求 id(回响应时原样回填;通知由 main_loop 已过滤)。
async fn dispatch(ctx: &Ctx, read_only: bool, id: Option<Value>, method: McpMethod) -> Response {
/// `pub(crate)`:stdio(main_loop)与 HTTP(server_http)transport 共用。
pub(crate) async fn dispatch(ctx: &Ctx, read_only: bool, id: Option<Value>, method: McpMethod) -> Response {
match method {
McpMethod::Initialize { .. } => {
let result = InitializeResult {
@@ -135,9 +136,10 @@ async fn dispatch(ctx: &Ctx, read_only: bool, id: Option<Value>, method: McpMeth
}
McpMethod::ToolsList => {
let tools: Vec<_> = tools::all_tools()
.into_iter()
.iter()
.filter(|t| visible(read_only, t.risk))
.map(|t| serde_json::to_value(&t.tool).unwrap_or(Value::Null))
.filter(|v| !v.is_null())
.collect();
Response::ok(id, json!({ "tools": tools }))
}
@@ -180,7 +182,7 @@ async fn dispatch(ctx: &Ctx, read_only: bool, id: Option<Value>, method: McpMeth
}
/// 工具可见性:read-only 仅 Low,否则 Low + Medium(High 永不可见)
fn visible(read_only: bool, risk: RiskLevel) -> bool {
pub(crate) fn visible(read_only: bool, risk: RiskLevel) -> bool {
if read_only {
risk == RiskLevel::Low
} else {
@@ -287,6 +289,22 @@ mod tests {
assert_eq!(v["error"]["code"], crate::protocol::METHOD_NOT_FOUND);
}
#[tokio::test]
async fn tools_call_missing_name_returns_clear_error() {
// tools/call 缺 name 参数:不应回「未知工具: 」(空名),应回明确 METHOD_NOT_FOUND
let input =
r#"{"jsonrpc":"2.0","id":41,"method":"tools/call","params":{"arguments":{}}}"#;
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);
let msg = v["error"]["message"].as_str().unwrap();
assert!(
msg.contains("name"),
"空 name 应给出明确提示,实际: {msg}"
);
assert!(!msg.contains("未知工具: "), "不应是空名「未知工具: 」: {msg}");
}
#[tokio::test]
async fn tools_call_high_risk_is_rejected() {
let input =
+339
View File
@@ -0,0 +1,339 @@
//! HTTP (streamable HTTP, 2025-06-18) transport 层。
//!
//! 桌面进程内嵌 server 的传输层:POST /mcp 单响应 JSON;GET 405;notification 202 无 body。
//! 与 stdio transport 共享同一 dispatch/handler/Ctx,纯 df-mcp 内部实现,零 tauri 依赖。
//!
//! 协议合规(streamable HTTP 2025-06-18):
//! - POST /mcp → `application/json` 单响应(dispatch 层错误随 JSON-RPC 错误体回,HTTP 仍 200)
//! - GET /mcp → 405 + `Allow: POST`
//! - notification(id 缺省)→ 202 Accepted 无 body
//! - batch 数组 body → 400 + INVALID_REQUEST(本 server 不支持 batch)
//! - 非法 JSON body → 400 + PARSE_ERROR
//! - 无状态 server:忽略 Mcp-Session-Id 头(合规)
//!
//! 桌面端注入方式:构造 [`McpHttpState`] 传入共享 db + on_tool_call 回调,
//! [`build_router`] 得 Router,再 `axum::serve(listener, router)` 常驻监听。
use std::sync::Arc;
use axum::{
body::Bytes,
extract::State,
http::{header, HeaderValue, StatusCode},
response::{IntoResponse, Response},
routing::post,
Router,
};
use df_storage::db::Database;
use serde_json::{json, Value};
use crate::protocol::{McpMethod, Request, INVALID_REQUEST, PARSE_ERROR};
use crate::server::dispatch;
use crate::tools::Ctx;
/// 默认端口(桌面内嵌固定端口;可用 env `DEVFLOW_MCP_PORT` 覆盖)。
pub const DEFAULT_MCP_PORT: u16 = 18765;
/// HTTP MCP 共享状态(axum State)。
pub struct McpHttpState {
pub ctx: Ctx,
pub read_only: bool,
/// 成功 tools/call 回调(工具名)。桌面端注入 → emit df-data-changed;None=不回调。
pub on_tool_call: Option<Arc<dyn Fn(&str) + Send + Sync>>,
}
impl McpHttpState {
pub fn new(
db: Arc<Database>,
read_only: bool,
on_tool_call: Option<Arc<dyn Fn(&str) + Send + Sync>>,
) -> Self {
Self {
ctx: Ctx::new(db),
read_only,
on_tool_call,
}
}
}
/// 构造 axum Router(/mcp 单路由:POST 处理请求,GET 回 405)。
pub fn build_router(state: McpHttpState) -> Router {
Router::new()
.route("/mcp", post(post_mcp).get(get_mcp))
.with_state(Arc::new(state))
}
/// 在已绑定 listener 上启动 HTTP server(永不返回直至 shutdown)。
///
/// 桌面端用法:bind 127.0.0.1:18765 → spawn(serve_on(listener, state)),
/// 进程内常驻,多 Claude 会话 HTTP 直连同一进程。
pub async fn serve_on(listener: tokio::net::TcpListener, state: McpHttpState) -> anyhow::Result<()> {
axum::serve(listener, build_router(state))
.await
.map_err(|e| anyhow::anyhow!("axum::serve 失败: {e}"))
}
/// POST /mcp:解析 body → dispatch → JSON 单响应 / 202 notification。
async fn post_mcp(State(state): State<Arc<McpHttpState>>, body: Bytes) -> Response {
match handle_body(&state, &body).await {
Outcome::Json(status, value) => (status, axum::Json(value)).into_response(),
Outcome::Accepted => StatusCode::ACCEPTED.into_response(),
}
}
/// GET /mcp → 405 + `Allow: POST`(streamable HTTP 规范)。
async fn get_mcp() -> Response {
let mut resp = StatusCode::METHOD_NOT_ALLOWED.into_response();
resp.headers_mut()
.insert(header::ALLOW, HeaderValue::from_static("POST"));
resp
}
/// 分发结果:单响应 JSON / notification 202。
enum Outcome {
/// 单响应(HTTP 状态码 + JSON-RPC body)
Json(StatusCode, Value),
/// notification:202 Accepted,无 body
Accepted,
}
/// 解析请求体并分发到共享 dispatch。
async fn handle_body(state: &McpHttpState, body: &[u8]) -> Outcome {
// ① UTF-8 校验
let text = match std::str::from_utf8(body) {
Ok(t) => t,
Err(_) => {
return Outcome::Json(
StatusCode::BAD_REQUEST,
rpc_error(None, PARSE_ERROR, "Parse error: 请求体不是合法 UTF-8"),
);
}
};
// ② 整体 JSON 解析:数组 = batch,本 server 不支持
let raw: Value = match serde_json::from_str(text) {
Ok(v) => v,
Err(_) => {
return Outcome::Json(
StatusCode::BAD_REQUEST,
rpc_error(None, PARSE_ERROR, "Parse error: 非法 JSON"),
);
}
};
if raw.is_array() {
return Outcome::Json(
StatusCode::BAD_REQUEST,
rpc_error(None, INVALID_REQUEST, "Invalid request: batch 请求不支持"),
);
}
// ③ 解析为 JSON-RPC Request(结构非法 → INVALID_REQUEST)
let req: Request = match serde_json::from_value(raw) {
Ok(r) => r,
Err(_) => {
return Outcome::Json(
StatusCode::BAD_REQUEST,
rpc_error(None, INVALID_REQUEST, "Invalid request"),
);
}
};
let method = McpMethod::from_request(&req);
let is_notification = req.id.is_none();
// ④ notification(id 缺省)→ 202 Accepted 无 body
if is_notification {
match method {
McpMethod::Initialized => {
tracing::debug!(target: "df_mcp", "HTTP: 客户端 initialized 通知已收");
}
_ => {
tracing::debug!(target: "df_mcp", m = ?method, "HTTP: 忽略未识别通知");
}
}
return Outcome::Accepted;
}
// ⑤ 工具名预取(tools/call 成功回调用;method 随后 move 进 dispatch)
let tool_name = match &method {
McpMethod::ToolsCall { name, .. } => Some(name.clone()),
_ => None,
};
let resp = dispatch(&state.ctx, state.read_only, req.id.clone(), method).await;
// ⑥ 成功 tools/call(resp.error.is_none())→ on_tool_call 回调(桌面端据此刷新 GUI)
if resp.error.is_none() {
if let (Some(name), Some(cb)) = (tool_name, &state.on_tool_call) {
cb(&name);
}
}
let value = serde_json::to_value(&resp)
.unwrap_or_else(|_| rpc_error(req.id, crate::protocol::INTERNAL_ERROR, "响应序列化失败"));
Outcome::Json(StatusCode::OK, value)
}
/// 构造 JSON-RPC 2.0 错误响应体。
fn rpc_error(id: Option<Value>, code: i32, message: &str) -> Value {
json!({
"jsonrpc": "2.0",
"id": id,
"error": { "code": code, "message": message }
})
}
// ============================================================
// 单测:tower oneshot 打 Router + open_in_memory DB
// 覆盖:initialize / tools/list / notification 202 / GET 405 / 非法 JSON / batch / on_tool_call
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
use axum::{
body::Body,
http::{header, Request, StatusCode},
};
use std::sync::Mutex;
use tower::ServiceExt;
/// 构造内存 DB + McpHttpState(on_tool_call 可注入)
async fn test_state(
read_only: bool,
on_tool_call: Option<Arc<dyn Fn(&str) + Send + Sync>>,
) -> McpHttpState {
let db = Arc::new(Database::open_in_memory().await.unwrap());
McpHttpState::new(db, read_only, on_tool_call)
}
/// oneshot 打 Router:method + body → (status, json body, headers)
async fn send(
router: Router,
method: &str,
body: &str,
) -> (StatusCode, Value, axum::http::HeaderMap) {
let builder = Request::builder().uri("/mcp").method(method);
let builder = if method == "GET" {
builder
} else {
builder.header(header::CONTENT_TYPE, "application/json")
};
let req = builder.body(Body::from(body.to_string())).unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
let status = resp.status();
let headers = resp.headers().clone();
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let value: Value = if bytes.is_empty() {
Value::Null
} else {
serde_json::from_slice(&bytes).unwrap_or(Value::Null)
};
(status, value, headers)
}
#[tokio::test]
async fn post_initialize_returns_server_info_and_capabilities() {
let state = test_state(false, None).await;
let router = build_router(state);
let (status, v, _) = send(
router,
"POST",
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(v["jsonrpc"], "2.0");
assert_eq!(v["id"], 1);
assert_eq!(v["result"]["protocolVersion"], crate::server::PROTOCOL_VERSION);
assert_eq!(v["result"]["serverInfo"]["name"], crate::server::SERVER_NAME);
assert!(v["result"]["capabilities"]["tools"].is_object());
}
#[tokio::test]
async fn post_tools_list_excludes_high_risk_by_default() {
let state = test_state(false, None).await;
let router = build_router(state);
let (status, v, _) = send(
router,
"POST",
r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#,
)
.await;
assert_eq!(status, StatusCode::OK);
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
}
#[tokio::test]
async fn post_notification_returns_202_no_body() {
let state = test_state(false, None).await;
let router = build_router(state);
let (status, v, _) = send(
router,
"POST",
r#"{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}"#,
)
.await;
assert_eq!(status, StatusCode::ACCEPTED);
assert_eq!(v, Value::Null);
}
#[tokio::test]
async fn get_mcp_returns_405_allow_post() {
let state = test_state(false, None).await;
let router = build_router(state);
let (status, _, headers) = send(router, "GET", "").await;
assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED);
assert_eq!(headers.get(header::ALLOW).unwrap(), "POST");
}
#[tokio::test]
async fn post_invalid_json_returns_400_parse_error() {
let state = test_state(false, None).await;
let router = build_router(state);
let (status, v, _) = send(router, "POST", "not json").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(v["error"]["code"], PARSE_ERROR);
}
#[tokio::test]
async fn post_batch_returns_400_invalid_request() {
let state = test_state(false, None).await;
let router = build_router(state);
let body = r#"[{"jsonrpc":"2.0","id":1,"method":"ping","params":{}}]"#;
let (status, v, _) = send(router, "POST", body).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(v["error"]["code"], INVALID_REQUEST);
}
#[tokio::test]
async fn tools_call_success_triggers_on_tool_call() {
let calls: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let calls_cb = calls.clone();
let on_tool_call: Option<Arc<dyn Fn(&str) + Send + Sync>> = Some(Arc::new(move |name| {
calls_cb.lock().unwrap().push(name.to_string());
}));
let state = test_state(false, on_tool_call).await;
let router = build_router(state);
let body = r#"{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"create_project","arguments":{"name":"HttpProj","description":"via http"}}}"#;
let (status, v, _) = send(router, "POST", body).await;
assert_eq!(status, StatusCode::OK);
// 成功业务响应:isError 不置位
assert!(
v["result"]["isError"].is_null() || v["result"]["isError"] == Value::Bool(false)
);
assert!(
calls.lock().unwrap().contains(&"create_project".to_string()),
"成功 tools/call 应触发 on_tool_call,实际: {:?}",
*calls.lock().unwrap()
);
}
}
+124 -65
View File
@@ -12,7 +12,8 @@
//! handler 形态:`fn(&Ctx, Value) -> BoxFuture<CallToolResult>`(函数指针 + async 块),
//! 避免闭包捕获带来的 Box<dyn> 开销与生命周期问题。
use std::sync::Arc;
// name→id 解析:src-tauri 有机制层解析(audit/mod.rs auto_resolve),MCP 面暂不同步。
use std::sync::{Arc, OnceLock};
use df_storage::crud::{IdeaRepo, ProjectRepo, TaskRepo};
use df_storage::db::Database;
@@ -94,35 +95,39 @@ fn int_field(desc: &str) -> Value {
// ============================================================
/// 返回全部已注册工具(只读模式由 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", "更新项目(部分更新:仅传需要改的字段,未传字段保留原值)", object_schema(json!({"id": str_field("项目 ID"), "name": opt_str_field("项目名(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)"), "status": opt_str_field("状态(可空=保留原值)")}), &["id"]), 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/status 过滤)", object_schema(json!({"project_id": opt_str_field("项目过滤(可空)"), "status": opt_str_field("按状态过滤(todo/in_progress/in_review/testing/blocked/done/cancelled,可空)")}), &[]), 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", "更新任务(部分更新:仅传需要改的字段,未传字段保留原值;状态须走 advance_task)", object_schema(json!({"id": str_field("任务 ID"), "project_id": opt_str_field("项目 ID(可空=保留原值)"), "title": opt_str_field("标题(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)")}), &["id"]), Medium, update_task),
spec("advance_task", "推进任务状态(传目标 status,内部读当前态+状态机校验,Medium 风险+审计日志)", object_schema(json!({"id": str_field("任务 ID"), "to": str_field("目标 status(todo/in_progress/in_review/testing/blocked/done/cancelled)")}), &["id", "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": opt_str_field("标题(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)")}), &["id"]), 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),
spec("score_idea", "评分并写库(Medium 风险+审计日志):对想法做启发式评估,把 scores 写回 DB 并返回更新后的记录", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), Medium, score_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),
]
static TOOLS: OnceLock<Vec<&'static ToolSpec>> = OnceLock::new();
pub fn all_tools() -> &'static Vec<&'static ToolSpec> {
TOOLS.get_or_init(|| {
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("状态(默认 planning)")}), &["name", "description"]), Medium, create_project),
spec("update_project", "更新项目(部分更新:仅传需要改的字段,未传字段保留原值)", object_schema(json!({"id": str_field("项目 ID"), "name": opt_str_field("项目名(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)"), "status": opt_str_field("状态(可空=保留原值)")}), &["id"]), 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/status 过滤)", object_schema(json!({"project_id": opt_str_field("按项目过滤(可空)"), "status": opt_str_field("按状态过滤(todo/in_progress/in_review/testing/blocked/done/cancelled,可空)")}), &[]), Low, list_tasks),
spec("create_task", "创建任务(Medium 风险,默认允许+审计日志;可选 parent_id 父任务 ID,限 1 级嵌套,父任务自身不能是子任务)", object_schema(json!({"project_id": str_field("项目 ID"), "title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 0)"), "parent_id": opt_str_field("任务 ID(可空)")}), &["project_id", "title", "description"]), Medium, create_task),
spec("update_task", "更新任务(部分更新:仅传需要改的字段,未传字段保留原值;状态须走 advance_task)", object_schema(json!({"id": str_field("任务 ID"), "project_id": opt_str_field("项目 ID(可空=保留原值)"), "title": opt_str_field("标题(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)")}), &["id"]), Medium, update_task),
spec("advance_task", "推进任务状态(传目标 status,内部读当前态+状态机校验,Medium 风险+审计日志)", object_schema(json!({"id": str_field("任务 ID"), "to": str_field("目标 status(todo/in_progress/in_review/testing/blocked/done/cancelled)")}), &["id", "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": opt_str_field("标题(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)")}), &["id"]), 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),
spec("score_idea", "评分并写库(Medium 风险+审计日志):对想法做启发式评估,把 scores 写回 DB 并返回更新后的记录", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), Medium, score_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 静态化(进程生命周期,启动一次性构造)。
@@ -151,7 +156,7 @@ fn spec(
/// 按 name 查找工具(线性扫描,工具数 20,O(n) 足够)。
pub fn find(name: &str) -> Option<&'static ToolSpec> {
all_tools().into_iter().find(|t| t.tool.name == name)
all_tools().iter().find(|t| t.tool.name == name).copied()
}
// ============================================================
@@ -291,7 +296,12 @@ fn create_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
medium_audit("create_project", &name);
Box::pin(async move {
let now = now_millis();
let status = ProjectStatus::from_db_str(&status).unwrap_or_default();
let status = match ProjectStatus::from_db_str(&status) {
Some(s) => s,
None => return CallToolResult::error(
format!("非法状态值: {status}, 有效值: planning/in_progress/testing/releasing/completed/paused/cancelled")
),
};
let rec = ProjectRecord {
id: new_id(),
name,
@@ -339,8 +349,13 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
let name = arg_str(&args, "name").unwrap_or_else(|_| existing.name.clone());
let description = arg_str(&args, "description").unwrap_or_else(|_| existing.description.clone());
let status = arg_str(&args, "status").unwrap_or_else(|_| existing.status.as_str().to_owned());
let status = match ProjectStatus::from_db_str(&status) {
Some(s) => s,
None => return CallToolResult::error(
format!("非法状态值: {status}, 有效值: planning/in_progress/testing/releasing/completed/paused/cancelled")
),
};
let now = now_millis();
let status = ProjectStatus::from_db_str(&status).unwrap_or_default();
let rec = ProjectRecord {
id: id.clone(),
name,
@@ -398,8 +413,10 @@ fn bind_directory(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
));
}
// 仅更新 path 字段(用 normalize 后的规范化路径,保留其它)
if !repo.update_field(&id, "path", &norm).await.unwrap_or(false) {
return CallToolResult::error(format!("项目不存在: {id}"));
match repo.update_field(&id, "path", &norm).await {
Ok(true) => {}
Ok(false) => return CallToolResult::error(format!("项目不存在: {id}")),
Err(e) => return err_str(e),
}
let updated = repo.get_by_id(&id).await.ok().flatten();
json_ok(json!({ "id": id, "project": updated }))
@@ -416,16 +433,20 @@ fn list_tasks(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
let status_filter = args.get("status").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);
}
if let Some(st) = &status_filter {
list.retain(|t| t.status.as_str() == st.as_str());
}
json_ok(json!({ "tasks": list, "count": list.len() }))
}
let query = df_storage::crud::TaskQuery {
project_id: project_id_filter,
status: status_filter,
priority: None,
assignee: None,
keyword: None,
queue: None,
parent_id: None,
order_by: None,
limit: None,
offset: None,
};
match repo.list_by_query(&query).await {
Ok(list) => json_ok(json!({ "tasks": list, "count": list.len() })),
Err(e) => err_str(e),
}
})
@@ -445,6 +466,28 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
let priority = arg_int_or(&args, "priority", 0);
medium_audit("create_task", &format!("{project_id}/{title}"));
Box::pin(async move {
// parent_id 可选(arg_str_or 给 "" 哨兵,空串视为 None)。非空时校验 1 级嵌套铁律:
// 父任务存在 + 父任务自身无 parent_id(防孙任务),违反返回明确错误(与 IPC create_task 同规则)。
let parent_id_raw = arg_str_or(&args, "parent_id", "");
let parent_id = if parent_id_raw.trim().is_empty() {
None
} else {
let pid = parent_id_raw.trim();
let repo = TaskRepo::new(&db);
match repo.get_by_id(pid).await {
Ok(Some(parent)) => {
if parent.parent_id.is_some() {
return CallToolResult::error(format!(
"父任务不能是子任务(1 级嵌套限制): {pid} 自身有 parent_id={:?}",
parent.parent_id
));
}
Some(pid.to_string())
}
Ok(None) => return CallToolResult::error(format!("父任务不存在: {pid}")),
Err(e) => return err_str(e),
}
};
let now = now_millis();
let rec = TaskRecord {
id: new_id(),
@@ -461,7 +504,7 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
output_json: None,
idea_id: None,
queue: "todo".to_string(),
parent_id: None,
parent_id,
content_json: None,
created_at: now.clone(),
updated_at: now,
@@ -549,12 +592,13 @@ fn advance_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
medium_audit("advance_task", &format!("{id} -> {to}"));
Box::pin(async move {
let repo = TaskRepo::new(&db);
// 复用推进链唯一 status 写入路径(与 IPC advance_task 同源):
// 复用推进链唯一 status 写入路径(与 IPC advance_task 同源,设计 D3 消除双轨):
// - is_valid_state + can_transition + 同态拒绝三层校验
// - CAS 防 TOCTOU
// - is_regression 自动判定 bump review_rounds(取代旧内联 bump 副本)
// - advance_task_with_parent:子任务推进后自动触发父 status 聚合(聚合失败仅 warn 不阻断)
// - 错误类型(NotFound/Validation/InvalidState)由 thiserror Display 串化
match df_nodes::task_advance_node::advance_task_atomic(&repo, &id, &to).await {
match df_nodes::task_advance_node::advance_task_with_parent(&repo, &id, &to).await {
Ok(updated) => json_ok(json!({ "id": id, "task": updated })),
Err(e) => err_str(e),
}
@@ -725,7 +769,10 @@ fn score_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
let now = now_millis();
// 写回 scores 字段(整体更新)
let mut rec = idea.clone();
rec.scores = Some(serde_json::to_string(&scores).unwrap_or_default());
rec.scores = Some(match serde_json::to_string(&scores) {
Ok(s) => s,
Err(e) => return CallToolResult::error(format!("评分序列化失败: {e}")),
});
rec.updated_at = now;
if let Err(e) = repo.update_full(&rec).await {
return err_str(e);
@@ -738,11 +785,10 @@ fn score_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
/// 确定性纯函数,与 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 impact_keywords: &[&str] = &["核心", "关键", "重要", "紧急", "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 字段不在此,用关键词近似
@@ -751,7 +797,6 @@ fn heuristic_scores(title: &str, description: &str) -> Value {
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,
@@ -815,13 +860,32 @@ fn restore_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
// ============================================================
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(),
// 先尝试 canonicalize(解析符号链接 + 绝对化 + .. 折叠,仅已存在的路径有效)
if let Ok(abs) = std::path::Path::new(p).canonicalize() {
return abs.to_string_lossy().replace('\\', "/").to_lowercase();
}
// fallback:路径尚未创建,手动做以下处理:
// ① 统一分隔符
// ② 逐段折叠 ..(防 foo/../bar → foo/bar)
// ③ 去尾斜杠
// ④ 小写化
let normalized = p.replace('\\', "/");
let mut segments: Vec<&str> = Vec::new();
for seg in normalized.split('/') {
match seg {
"." | "" => continue, // 当前目录 / 空段(连续斜杠)
".." if segments.is_empty() => segments.push(".."), // 根级 .. 保留(相对路径语义)
".." => { segments.pop(); } // 上级 → 弹出上一段
_ => segments.push(seg),
}
}
let result = if segments.is_empty() {
String::new()
} else {
segments.join("/")
};
// 去尾斜杠
result.trim_end_matches('/').to_lowercase()
}
// ============================================================
@@ -1035,14 +1099,9 @@ mod tests {
assert!(visible_for_test(false, "score_idea"));
}
// 辅助:复用 server.rs 的 visible 谓词语义(本地重写,避免跨模块私有依赖)
// 辅助:复用 server.rs 的 visible 谓词(单一事实来源,避免两份逻辑漂移)
fn visible_for_test(read_only: bool, name: &str) -> bool {
let spec = find(name).expect("工具存在");
if read_only {
spec.risk == RiskLevel::Low
} else {
spec.risk != RiskLevel::High
}
crate::server::visible(read_only, find(name).expect("工具存在").risk)
}
// ── heuristic_scores 纯函数:两工具共用,确定性 ──────────────────
+1251
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -24,6 +24,7 @@
"dompurify": "^3.4.10",
"highlight.js": "^11.11.1",
"marked": "^18.0.5",
"mermaid": "^11.16.0",
"tslib": "^2.8.1",
"vue": "^3.5.13",
"vue-i18n": "9",
+4 -1
View File
@@ -13,10 +13,13 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
# Phase3 桌面内嵌 HTTP MCP server + 托盘常驻:tray-icon feature 非默认,必须显式启用
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-dialog = "2"
tauri-plugin-opener = "2"
tauri-plugin-window-state = "2"
# Phase3 单实例:二次启动唤起已有进程主窗口,防多实例抢 18765 端口
tauri-plugin-single-instance = "2"
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
+18 -1
View File
@@ -1,3 +1,20 @@
fn main() {
tauri_build::build()
tauri_build::build();
// 从 tauri.conf.json 读取 identifier,注入编译时常量 MCP_APP_IDENTIFIER。
// CLI 模式(mcp-server)无 tauri app handle,无法通过 app.path().app_data_dir() 获取
// 真实路径;此常量让 default_db_path 自动拼出 Tauri 实际使用的 APPDATA/{identifier}/ 路径,
// 保证 MCP server 与 GUI 应用读写同一数据库,无需手动传 --db。
let conf_path = std::path::Path::new(&std::env::var("CARGO_MANIFEST_DIR").unwrap())
.join("tauri.conf.json");
if let Ok(content) = std::fs::read_to_string(&conf_path) {
if let Some(identifier) = content
.lines()
.find(|l| l.contains("\"identifier\""))
.and_then(|l| l.split(':').nth(1))
.map(|s| s.trim().trim_matches(',').trim_matches('"'))
{
println!("cargo:rustc-env=MCP_APP_IDENTIFIER={}", identifier);
}
}
}
@@ -0,0 +1,226 @@
//! AI 工具实体参数 name→id 自动解析(机制层)
//!
//! 背景:DevFlow aichat 的 AI 工具要求 project_id 传 UUID,但用户/模型常用 name
//! (如 "moyu"/"u-talk"/"DevFlow"),`repo.query("project_id", pid)` 硬匹配 UUID 全返空,
//! 模型被迫绕 list_projects 找 UUID 浪费轮次。
//!
//! 本模块在工具执行前统一做 name→id 解析:
//! - 声明式映射表 [`RESOLVE_MAP`]:新工具需按 name 解析,只需在表里登记一行即生效
//! (工具名 + 参数键名 + 实体类型),无框架/无配置。
//! - UUID 形态跳过:已是 id 的值直接透传,不查库(零额外开销)。
//! - 按 name 精确查 active 项目(list_active 排软删 + 内存过滤 `p.name == value`),
//! 命中唯一则替换为项目 id;0 命中 / 重名(≥2)返可行动错误,让 LLM 拿提示自修。
//!
//! 调用点:audit/mod.rs process_tool_calls 单点漏斗(auto + 审批 + 目录授权全部执行路径
//! 统一拿到已解析 id)。
use std::sync::Arc;
use df_storage::crud::ProjectRepo;
use df_storage::db::Database;
/// 实体类型:后续扩展 Task/Idea 时在此加枚举变体即可。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum EntityType {
Project,
}
/// 声明式映射表:工具名 → [(参数键名, 实体类型)]。
/// 需按 name 解析的工具在此登记一行即生效。
pub(crate) static RESOLVE_MAP: &[(&str, &[(&str, EntityType)])] = &[
("list_tasks", &[("project_id", EntityType::Project)]),
("create_task", &[("project_id", EntityType::Project)]),
("get_task_count", &[("project_id", EntityType::Project)]),
("get_project_timeline", &[("project_id", EntityType::Project)]),
("add_project_service", &[("project_id", EntityType::Project)]),
("list_project_services",&[("project_id", EntityType::Project)]),
("list_project_modules", &[("project_id", EntityType::Project)]),
("update_project", &[("id", EntityType::Project)]),
("bind_directory", &[("id", EntityType::Project)]),
("delete_project", &[("id", EntityType::Project)]),
("restore_project", &[("id", EntityType::Project)]),
("purge_project", &[("id", EntityType::Project)]),
];
/// 判定字符串是否为 UUID 形态(`Uuid::parse_str` 支持 simple/hyphenated/urn/braced 四格式)。
///
/// **严禁用 `contains('-')` 判定**——"u-talk"/"df-relay" 是真实项目名带连字符,会被误判为 UUID。
fn is_uuid_shape(s: &str) -> bool {
uuid::Uuid::parse_str(s).is_ok()
}
/// 解析工具参数中的实体 name→id。
///
/// 规则(与调用点协商的契约):
/// - 工具未在 [`RESOLVE_MAP`] 登记 / 参数缺失 / 值非字符串 → 原样 `Ok(args.clone())`
/// (不拦截,交给工具 handler 正常处理)。
/// - 值是 UUID 形态 → 已是 id,跳过(不查库)。
/// - 非 UUID → 按 name 精确查 active 项目(list_active 排软删 + 内存过滤 `p.name == value`)。
/// 命中唯一 → 替换该键为项目 id(保持其余字段);0 命中 / 重名(≥2)→ Err(结构化错误,
/// 由调用点包 failed envelope 回传 LLM,让模型拿可行动提示自修)。
pub(crate) async fn resolve_entity_ids(
db: &Arc<Database>,
tool_name: &str,
args: &serde_json::Value,
) -> anyhow::Result<serde_json::Value> {
// 映射表未登记该工具 → 原样透传(零侵入)
let Some((_, key_entities)) = RESOLVE_MAP.iter().find(|(name, _)| *name == tool_name) else {
return Ok(args.clone());
};
let mut resolved = args.clone();
// 遍历该工具登记的所有实体参数键(当前每个工具 1 键,通用支持多键)
for (key, entity_type) in *key_entities {
// 参数缺失 / 值非字符串 → 跳过(工具 handler 自有缺参语义,不在此拦截)
let Some(value) = resolved.get(*key).and_then(|v| v.as_str()) else {
continue;
};
// UUID 形态 → 已是 id,跳过(不查库,零额外开销)
if is_uuid_shape(value) {
continue;
}
// 按 name 精确解析:list_active 排软删 + 内存过滤 p.name == value
let id = match *entity_type {
EntityType::Project => resolve_project_name(db, tool_name, key, value).await?,
};
// 替换该键为项目 id,保持其余字段
if let Some(obj) = resolved.as_object_mut() {
obj.insert(key.to_string(), serde_json::Value::String(id));
}
}
Ok(resolved)
}
/// 按 name 精确解析 active 项目(list_active 排软删),返回唯一命中的项目 id。
/// 0 命中 / 重名(≥2)返可行动错误(含工具名 + 参数键 + 值),让 LLM 自修。
async fn resolve_project_name(
db: &Arc<Database>,
tool_name: &str,
key: &str,
value: &str,
) -> anyhow::Result<String> {
let projects = ProjectRepo::new(db).list_active().await?;
let matches: Vec<_> = projects.iter().filter(|p| p.name == value).collect();
match matches.len() {
0 => Err(anyhow::anyhow!(
"工具 {} 的参数 {}={} 解析失败:未找到名为「{}」的项目(精确匹配,含空格/大小写)。可先调用 list_projects 核对项目名",
tool_name, key, value, value
)),
1 => Ok(matches[0].id.clone()),
_ => {
// 重名:列出前 2 个(含 id+path),引导用 list_projects 区分后传具体项目 id
let a = matches[0];
let b = matches[1];
Err(anyhow::anyhow!(
"工具 {} 的参数 {}={} 命中多个同名项目:「{}」(id={}, path={})、「{}」(id={}, path={})。请用 list_projects 区分后传具体项目 id",
tool_name, key, value,
a.name, a.id, a.path.clone().unwrap_or_default(),
b.name, b.id, b.path.clone().unwrap_or_default(),
))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use df_storage::models::ProjectRecord;
use df_types::types::{ProjectStatus, new_id};
/// 内存 DB + 插一条项目,返回 (db, project_id)
async fn seed_project(db: &Arc<Database>, name: &str) -> String {
let now = df_types::now_millis().to_string();
let rec = ProjectRecord {
id: new_id(),
name: name.to_owned(),
description: String::new(),
status: ProjectStatus::Planning,
idea_id: None,
path: Some(format!("C:/projects/{}", name)),
stack: None,
created_at: now.clone(),
updated_at: now,
};
ProjectRepo::new(db).insert(rec).await.unwrap()
}
#[test]
fn is_uuid_shape_table() {
let real_uuid = df_types::types::new_id();
assert!(is_uuid_shape(&real_uuid), "真 UUID v4 应为 true");
for s in ["u-talk", "moyu", "proj-svc-1", ""] {
assert!(!is_uuid_shape(s), "「{}」不应判为 UUID", s);
}
}
#[tokio::test]
async fn resolve_unknown_tool_passthrough() {
let db = Arc::new(Database::open_in_memory().await.unwrap());
let args = serde_json::json!({ "path": "/tmp/x.rs" });
let out = resolve_entity_ids(&db, "read_file", &args).await.unwrap();
assert_eq!(out, args, "未登记工具应原样透传");
}
#[tokio::test]
async fn resolve_unique_name_to_id() {
let db = Arc::new(Database::open_in_memory().await.unwrap());
let pid = seed_project(&db, "moyu").await;
let args = serde_json::json!({ "project_id": "moyu", "status": "todo" });
let out = resolve_entity_ids(&db, "list_tasks", &args).await.unwrap();
assert_eq!(out["project_id"], serde_json::Value::String(pid));
assert_eq!(out["status"], "todo", "其余字段应保持不变");
}
#[tokio::test]
async fn resolve_uuid_passthrough() {
let db = Arc::new(Database::open_in_memory().await.unwrap());
let uuid = df_types::types::new_id();
let args = serde_json::json!({ "project_id": uuid });
let out = resolve_entity_ids(&db, "list_tasks", &args).await.unwrap();
assert_eq!(out, args, "已是 UUID 应原样透传(不查库)");
}
#[tokio::test]
async fn resolve_missing_param_passthrough() {
let db = Arc::new(Database::open_in_memory().await.unwrap());
let args = serde_json::json!({ "status": "todo" });
let out = resolve_entity_ids(&db, "list_tasks", &args).await.unwrap();
assert_eq!(out, args, "参数缺失应原样透传");
}
#[tokio::test]
async fn resolve_zero_hit_errors() {
let db = Arc::new(Database::open_in_memory().await.unwrap());
let args = serde_json::json!({ "project_id": "不存在" });
let err = resolve_entity_ids(&db, "list_tasks", &args).await.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("未找到"), "0 命中应报「未找到」,实际: {}", msg);
}
#[tokio::test]
async fn resolve_duplicate_name_errors() {
let db = Arc::new(Database::open_in_memory().await.unwrap());
let pid1 = seed_project(&db, "DevFlow").await;
let pid2 = seed_project(&db, "DevFlow").await;
let args = serde_json::json!({ "project_id": "DevFlow" });
let err = resolve_entity_ids(&db, "list_tasks", &args).await.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("命中多个同名项目"), "重名应报,实际: {}", msg);
assert!(msg.contains(&pid1), "错误应含第一个 id,实际: {}", msg);
assert!(msg.contains(&pid2), "错误应含第二个 id,实际: {}", msg);
}
#[tokio::test]
async fn resolve_soft_deleted_excluded() {
let db = Arc::new(Database::open_in_memory().await.unwrap());
let pid = seed_project(&db, "回收站项目").await;
ProjectRepo::new(&db).soft_delete(&pid).await.unwrap();
let args = serde_json::json!({ "project_id": "回收站项目" });
let err = resolve_entity_ids(&db, "list_tasks", &args).await.unwrap_err();
assert!(
err.to_string().contains("未找到"),
"软删项目不应解析到,实际: {}",
err
);
}
}
+33
View File
@@ -2,6 +2,9 @@
mod commands;
mod state;
// Phase3: 桌面内嵌 HTTP MCP server + 托盘常驻 + 单实例
mod mcp;
mod tray;
use tauri::{Emitter, Listener, Manager};
@@ -51,6 +54,10 @@ pub fn run() {
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_window_state::Builder::default().build())
// Phase3 单实例:二次启动不新开进程,唤起已有实例主窗口(防多实例抢 MCP 18765 端口)
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
tray::show_main(app);
}))
.setup(|app| {
// 数据库放在系统应用数据目录 <app_data_dir> 下(dev/release 拆分):
// Dev 模式 devflow-dev.db(可随意改动/清空),Build 模式 devflow.db(长期保留真实运行数据)
@@ -71,6 +78,23 @@ pub fn run() {
let session_for_handshake = app_state.ai_session.clone();
app.manage(app_state);
// ── Phase3: 内嵌 HTTP MCP server + 托盘常驻 + 单实例 ──
// 1) MCP 运行时状态:先 manage 初始 running=false,spawn 绑定成功后更新为 true。
// mcp_get_status IPC 经 State<Mutex<McpStatus>> 读取,spawn 内写锁同步。
let mcp_status = std::sync::Mutex::new(mcp::McpStatus {
running: false,
port: mcp::mcp_port(),
});
app.manage(mcp_status);
// 2) 启动 HTTP MCP server(复用 AppState.db,与 GUI 共享同一连接)。
// db 经 app.state::<AppState>() 取 Arc clone(manage 已注入,setup 后续可用)。
let db_for_mcp = app.state::<AppState>().db.clone();
mcp::spawn_mcp_http(app.handle(), db_for_mcp);
// 3) 创建托盘(关窗口不退出,托盘「退出」才真正结束进程)。失败非阻断。
if let Err(e) = tray::setup_tray(app.handle()) {
tracing::error!("[tray] 托盘创建失败(非阻断): {}", e);
}
// B-260616-01: L0 握手 — 监听前端就绪事件,清除 HMR/刷新导致的残留 generating 状态
// 任务5: 3 秒防抖 —— 前端 HMR/快速连击会连发 ai-client-ready(实测 <1s 内多次),
// 每次都走完整 握手(spawn + 锁 session + emit)造成事务事并行冲突 + emit 风暴。
@@ -475,7 +499,16 @@ pub fn run() {
commands::settings::delete_template,
// CI 状态
commands::ci_status::get_commit_status,
// Phase3: 内嵌 HTTP MCP server 状态查询
crate::mcp::mcp_get_status,
])
// Phase3: 关窗口不退出,隐藏到托盘常驻(托盘「退出」菜单才真正结束进程)
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close(); // 拦截真正关闭
let _ = window.hide(); // 隐藏到托盘
}
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+42 -20
View File
@@ -7,8 +7,7 @@ use std::process::ExitCode;
fn main() -> ExitCode {
// CLI 子命令:手动解析 argv(避免引入 clap 重依赖)。
//
// `devflow mcp-server [--db <path>] [--read-only]`
// `devflow mcp-server --print-config`
// `devflow mcp-server [--db <path>] [--read-only] [--stdio] [--print-config]`
//
// 仅识别首个非程序名参数是否为 `mcp-server`;其余情况走 GUI。
// stdio 句柄由父进程(MCP 客户端)继承,无需额外配置。
@@ -24,10 +23,12 @@ fn main() -> ExitCode {
///
/// - `--db <path>`:指定 SQLite 路径(默认:系统 app_data_dir/devflow[-dev].db)
/// - `--read-only`:仅暴露 list/get 工具
/// - `--print-config`:输出 Claude Code / Cursor 的 MCP 配置 JSON 片段到 stdout 后退出
/// - `--stdio`:显式声明 stdio 形态(默认即 stdio;headless/CI/GUI 未启动场景)
/// - `--print-config`:输出 MCP 配置到 stdout 后退出(默认 HTTP 形态,加 --stdio 输出 stdio command 形态)
fn run_mcp_server(args: &[String]) -> ExitCode {
let mut db_path: Option<PathBuf> = None;
let mut read_only = false;
let mut stdio_mode = false;
let mut print_config = false;
let mut i = 0;
@@ -42,13 +43,15 @@ fn run_mcp_server(args: &[String]) -> ExitCode {
db_path = Some(PathBuf::from(&args[i]));
}
"--read-only" => read_only = true,
"--stdio" => stdio_mode = true,
"--print-config" => print_config = true,
"-h" | "--help" => {
println!("devflow mcp-server [--db <path>] [--read-only] [--print-config]");
println!("devflow mcp-server [--db <path>] [--read-only] [--stdio] [--print-config]");
println!();
println!(" --db <path> 指定 SQLite 数据库路径(默认:app_data_dir/devflow.db)");
println!(" --db <path> 指定 SQLite 数据库路径(默认:app_data_dir/<identifier>/devflow[-dev].db)");
println!(" --read-only 仅暴露 list/get 工具(拒绝 create/update/delete)");
println!(" --print-config 输出 MCP 配置 JSON 片段到 stdout 后退出");
println!(" --stdio 显式声明 stdio 形态(默认即 stdio;headless/CI/GUI 未启动场景)");
println!(" --print-config 输出 MCP 配置(默认 HTTP,加 --stdio 输出 stdio command 形态)");
return ExitCode::SUCCESS;
}
other => {
@@ -60,18 +63,36 @@ fn run_mcp_server(args: &[String]) -> ExitCode {
}
if print_config {
// 输出 Claude Code / Cursor 兼容的 MCP 配置片段(command/args 形态)
// Claude Code: claude mcp add devflow -- devflow mcp-server [--read-only]
// 说明性注释(非 JSON)打 stderr,保证 stdout 是纯 JSON 可直接被客户端消费
if !stdio_mode {
// 默认输出 HTTP 形态:桌面进程内嵌 HTTP MCP server + 托盘常驻,
// Claude Code 用 --transport http 直连同一进程。端口 18765 与
// crates/df-mcp/src/server_http.rs 的 DEFAULT_MCP_PORT 对齐(本文件不 import,直接写死)。
let json = r#"{"mcpServers":{"devflow":{"transport":"http","url":"http://127.0.0.1:18765/mcp"}}}"#;
println!("{json}");
eprintln!("# Claude Code 接入(需先启动 DevFlow 桌面进程,托盘常驻):");
eprintln!("# claude mcp add --transport http devflow http://127.0.0.1:18765/mcp");
eprintln!("# stdio 备选(无 GUI 场景):");
eprintln!("# devflow mcp-server --stdio --print-config");
return ExitCode::SUCCESS;
}
// 输出 Claude Code / Cursor 兼容的 MCP 配置片段(command/args 形态,显式带 --stdio)。
// Claude Code: claude mcp add devflow -- devflow mcp-server --stdio [--read-only]
// Cursor: 在 ~/.cursor/mcp.json 加 mcpServers.devflow
let read_only_flag = if read_only { r#","--read-only""# } else { "" };
// 用当前可执行文件绝对路径(command 需要能被客户端直接执行;硬编码 devflow
// 在 Windows 上是 DevFlow.exe,且 PATH 未必含安装目录)。
let exe = std::env::current_exe()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_else(|_| "devflow".to_owned());
let json = format!(
r#"{{"mcpServers":{{"devflow":{{"command":"devflow","args":["mcp-server"{read_only_flag}]}}}}}}"#
r#"{{"mcpServers":{{"devflow":{{"command":"{exe}","args":["mcp-server","--stdio"{read_only_flag}]}}}}}}"#
);
println!("{json}");
println!();
println!("# Claude Code 接入:");
println!("# claude mcp add devflow -- devflow mcp-server");
println!("# Cursor 接入:把上面 JSON 写入 ~/.cursor/mcp.json");
eprintln!("# Claude Code 接入(stdio 形态):");
eprintln!("# claude mcp add devflow -- {exe} mcp-server --stdio");
eprintln!("# Cursor 接入:把上面 JSON 写入 ~/.cursor/mcp.json");
return ExitCode::SUCCESS;
}
@@ -105,13 +126,12 @@ fn run_mcp_server(args: &[String]) -> ExitCode {
}
}
/// 默认数据库路径:与 GUI 应用同库(系统 app_data_dir/devflow.db 或 dev-dev.db)。
/// 默认数据库路径:与 GUI 应用同库(系统 app_data_dir/{identifier}/devflow[-dev].db)。
///
/// 复用与 lib.rs::run 一致的命名(debug 用 devflow-dev.db,release 用 devflow.db),
/// 保证 MCP server 与 GUI 读写同一文件
/// GUI 模式通过 Tauri 的 `app.path().app_data_dir()` 获取路径(含 identifier),
/// CLI 模式无 app handle,改用编译时注入的 MCP_APP_IDENTIFIER 拼出相同路径
/// 保证 MCP server 与 GUI 读写同一数据库,无需手动传 --db。
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") {
@@ -121,8 +141,10 @@ fn default_db_path() -> anyhow::Result<PathBuf> {
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");
// 从编译时环境变量 MCP_APP_IDENTIFIER 读取 Tauri identifier(如 top.1216.devflow),
// 与 tauri::path::PathResolver::app_data_dir 行为一致,拼出正确路径。
let identifier = option_env!("MCP_APP_IDENTIFIER").unwrap_or("devflow");
let dir = base.join(identifier);
std::fs::create_dir_all(&dir)?;
let name = if cfg!(debug_assertions) {
"devflow-dev.db"
+99
View File
@@ -0,0 +1,99 @@
//! 桌面进程内嵌 HTTP MCP server。
//!
//! 复用 AppState.db 与 GUI 共享同一数据库连接,在 tauri 后台任务里常驻监听
//! 127.0.0.1:<port>/mcp,多 Claude 会话 HTTP 直连同一桌面进程读写 DevFlow 数据。
//!
//! 运行时状态(McpStatus)以 `tauri::State<Mutex<McpStatus>>` 管理:
//! - setup 阶段 manage 初始 `{ running: false, port }`
//! - spawn 内 bind 成功/失败/异常退出时更新 running
//! - 前端经 `mcp_get_status` IPC 查询(Mutex 保证跨线程一致)
use std::sync::{Arc, Mutex};
use serde::Serialize;
use tauri::{AppHandle, Manager, State};
use df_mcp::server_http::{self, McpHttpState};
use df_storage::db::Database;
/// 前端可查的 MCP 状态。
#[derive(Clone, Serialize)]
pub struct McpStatus {
/// server 是否正在监听(bind 成功为 true;绑定失败/异常退出为 false)
pub running: bool,
/// 实际监听端口(env `DEVFLOW_MCP_PORT` 覆盖,默认 18765)
pub port: u16,
}
/// managed state 别名:跨线程共享的 MCP 运行时状态。
/// setup 中 `app.manage(Mutex::new(McpStatus { running: false, port }))` 注入,
/// spawn 内更新、`mcp_get_status` 读取。
pub type McpStatusState = Mutex<McpStatus>;
/// 解析 MCP 监听端口:优先 env `DEVFLOW_MCP_PORT`,否则默认 18765。
/// 供 setup(spawn 前 manage 初始状态)与 mcp_get_status(兜底)共用,保证一致。
pub fn mcp_port() -> u16 {
std::env::var("DEVFLOW_MCP_PORT")
.ok()
.and_then(|p| p.parse::<u16>().ok())
.unwrap_or(server_http::DEFAULT_MCP_PORT)
}
/// 在 tauri 后台任务里启动 HTTP MCP server(复用 AppState.db,与 GUI 共享连接)。
///
/// 前置条件:setup 中已 `app.manage(Mutex::new(McpStatus{...}))`(本函数内只更新不注入)。
/// bind 成功/失败/异常退出都会同步更新 managed 状态,前端 mcp_get_status 可查实时值。
pub fn spawn_mcp_http(app: &AppHandle, db: Arc<Database>) {
// 工具调用回调:复用现有 df-data-changed 机制,让 GUI 前端在 MCP 写库后自动刷新。
// 非变更类工具(如 list/get)在 emit_data_changed 内部映射不到即 noop,零副作用。
let app_for_cb = app.clone();
let on_tool_call: Option<Arc<dyn Fn(&str) + Send + Sync>> = Some(Arc::new(move |tool_name: &str| {
crate::commands::ai::audit::emit_data_changed(&app_for_cb, tool_name);
}));
let state = McpHttpState::new(db, false, on_tool_call);
let port = mcp_port();
// 后台 task 持有 app clone,用于 bind 结果写回 managed 状态
let app_for_status = app.clone();
tauri::async_runtime::spawn(async move {
let listener = match tokio::net::TcpListener::bind(("127.0.0.1", port)).await {
Ok(l) => l,
Err(e) => {
tracing::error!(port, err = %e, "[mcp-http] 绑定端口失败, MCP server 不可用(可设 DEVFLOW_MCP_PORT 换端口)");
set_mcp_running(&app_for_status, false);
return;
}
};
// 绑定成功,更新状态为 running(供前端查询)
set_mcp_running(&app_for_status, true);
tracing::info!(port, "[mcp-http] MCP server listening on 127.0.0.1:{port}/mcp");
if let Err(e) = server_http::serve_on(listener, state).await {
tracing::error!(err = %e, "[mcp-http] server 异常退出");
set_mcp_running(&app_for_status, false);
}
});
}
/// 更新 managed McpStatus.running。state 未注入/try_state 失败/锁中毒时静默跳过
/// (状态保持原值,非致命,防 panic 拖垮后台任务)。
fn set_mcp_running(app: &AppHandle, running: bool) {
if let Some(state) = app.try_state::<McpStatusState>() {
if let Ok(mut guard) = state.lock() {
guard.running = running;
}
}
}
/// IPC command:前端查询 MCP server 状态。
#[tauri::command]
pub fn mcp_get_status(state: State<'_, McpStatusState>) -> McpStatus {
match state.lock() {
Ok(guard) => McpStatus {
running: guard.running,
port: guard.port,
},
// 锁中毒(理论上不应发生):降级返回静态配置,防 panic
Err(_) => McpStatus {
running: false,
port: mcp_port(),
},
}
}
+49
View File
@@ -0,0 +1,49 @@
//! 托盘常驻:图标 + 菜单[显示主窗口/退出]。关窗口不退出,托盘退出才真正结束进程。
//!
//! - 主窗口 CloseRequested 被 lib.rs 拦截(prevent_close + hide),进程常驻托盘
//! - 托盘菜单「显示主窗口」唤起 / 「退出」app.exit(0) 真正结束
//! - TrayHandle 经 app.manage 保活,防托盘图标被 GC 移除
use tauri::{
AppHandle, Manager,
menu::{Menu, MenuItem},
tray::TrayIconBuilder,
};
/// managed state 保活,防 GC 移除托盘图标。
/// 字段不读仅用于持有 TrayIcon(保活);零调用方≠垃圾,预留保留(对齐 state.rs releases 标法)。
#[allow(dead_code)]
pub struct TrayHandle(pub tauri::tray::TrayIcon);
/// 创建托盘:图标 + 菜单(显示主窗口/退出)。
pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
let show = MenuItem::with_id(app, "show", "显示主窗口", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show, &quit])?;
// 复用窗口图标(tauri.conf.json bundle.icon 已配置),缺则 panic 提示配置
let icon = app
.default_window_icon()
.cloned()
.expect("窗口图标缺失(tauri.conf.json icons 已配置)");
let tray = TrayIconBuilder::with_id("devflow-tray")
.icon(icon)
.tooltip("DevFlow (MCP server running)")
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id().as_ref() {
"show" => show_main(app),
"quit" => app.exit(0), // 真正退出
_ => {}
})
.build(app)?;
app.manage(TrayHandle(tray));
Ok(())
}
/// 唤起主窗口:show + unminimize + set_focus(单实例二次启动 / 托盘菜单共用)。
pub(crate) fn show_main(app: &AppHandle) {
if let Some(w) = app.get_webview_window("main") {
let _ = w.show();
let _ = w.unminimize();
let _ = w.set_focus();
}
}