新增: 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:
@@ -14,6 +14,7 @@
|
||||
|
||||
pub mod protocol;
|
||||
pub mod server;
|
||||
pub mod server_http;
|
||||
pub mod tools;
|
||||
|
||||
pub use server::run_server;
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
@@ -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 纯函数:两工具共用,确定性 ──────────────────
|
||||
|
||||
Reference in New Issue
Block a user