优化: 前端体验批次(Knowledge持久化+流式缓存失效 + dfnodes节点注册 + delta 50ms合批 + MCP schema必填校验+bind_directory对齐)

This commit is contained in:
lxy
2026-08-08 20:19:19 +08:00
parent 97525a3143
commit 4092a8d5bb
9 changed files with 277 additions and 90 deletions
+85 -1
View File
@@ -215,7 +215,19 @@ pub(crate) async fn dispatch(ctx: &Ctx, read_only: bool, id: Option<Value>, meth
let r = CallToolResult::error(msg);
return Response::ok(id, serde_json::to_value(r).unwrap_or(Value::Null));
}
// Low / Medium:执行
// Low / Medium:执行前先校验 schema 必填参数(缺必填直接拒,
// 防 handler 内 arg_str_or 静默兜底把缺参当空串/默认值写入)
let missing: Vec<String> = required_names(&spec.tool.input_schema)
.into_iter()
.filter(|name| arguments.get(name).map_or(true, |v| v.is_null()))
.collect();
if !missing.is_empty() {
let r = CallToolResult::error(format!(
"缺少必填参数: {}",
missing.join(", ")
));
return Response::ok(id, serde_json::to_value(r).unwrap_or(Value::Null));
}
let result = (spec.handler)(ctx, arguments).await;
Response::ok(id, serde_json::to_value(result).unwrap_or(Value::Null))
}
@@ -235,6 +247,30 @@ fn should_execute(read_only: bool, risk: RiskLevel) -> bool {
!(read_only && risk != RiskLevel::Low) && risk != RiskLevel::High
}
/// 从工具 inputSchema 提取必填参数名列表。
///
/// 兼容 MCP schema required 的两种形态:array 显式列出必填属性;bool true 表示全部
/// properties 必填;false/缺失表示无必填。供 tools/call 执行前校验参数完整性,
/// 缺必填直接拒绝,杜绝 handler 内 arg_str_or 静默兜底把缺参当空串/默认值写入。
fn required_names(schema: &Value) -> Vec<String> {
match schema.get("required") {
Some(Value::Array(items)) => items
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_owned()))
.collect(),
Some(Value::Bool(true)) => {
let mut names: Vec<String> = schema
.get("properties")
.and_then(|p| p.as_object())
.map(|props| props.keys().cloned().collect())
.unwrap_or_default();
names.sort();
names
}
_ => Vec::new(),
}
}
/// 触发写操作回调(若有)。仅当工具为写操作(risk != Low)且未被 read-only/High 拒绝时触发,
/// 与 dispatch 的执行判定一致。回调仅作通知(如 GUI 刷新),不承载返回结果。
fn fire_write_hook(config: &ServerConfig, name: &str) {
@@ -415,6 +451,54 @@ mod tests {
assert_eq!(list_v["projects"][0]["name"], "McpProj");
}
// ── schema required 校验(dispatch 层缺必填直接拒)────────────────
#[tokio::test]
async fn tools_call_missing_required_arg_returns_error() {
// create_project schema required 含 name:缺 name 应被 dispatch 层拒绝
// (而非 handler 内 arg_str_or 静默兜底把 description 当空串)
let input =
r#"{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"create_project","arguments":{"description":"缺 name"}}}"#;
let out = run_io_lines(&[input], false).await;
let v: Value = serde_json::from_str(&out[0]).unwrap();
assert_eq!(v["result"]["isError"], true);
let text = v["result"]["content"][0]["text"].as_str().unwrap();
assert!(text.contains("缺少必填参数: name"), "实际: {text}");
}
#[test]
fn required_names_from_array() {
let schema = json!({
"type": "object",
"properties": { "id": {}, "name": {} },
"required": ["id", "name"]
});
assert_eq!(required_names(&schema), vec!["id".to_string(), "name".to_string()]);
}
#[test]
fn required_names_from_bool_true_means_all_properties() {
let schema = json!({
"type": "object",
"properties": { "id": {}, "name": {}, "desc": {} },
"required": true
});
// bool true = 全部 properties 必填,返回全部属性名(排序保证确定性)
assert_eq!(
required_names(&schema),
vec!["desc".to_string(), "id".to_string(), "name".to_string()]
);
}
#[test]
fn required_names_false_or_missing_is_empty() {
assert_eq!(required_names(&json!({ "type": "object" })), Vec::<String>::new());
assert_eq!(
required_names(&json!({ "type": "object", "required": false })),
Vec::<String>::new()
);
}
#[tokio::test]
async fn visible_predicate() {
assert!(visible(false, RiskLevel::Low));
+82 -39
View File
@@ -104,19 +104,19 @@ pub fn all_tools() -> &'static Vec<&'static ToolSpec> {
// ─── 项目 ───
spec("list_projects", "列出所有未删除项目(分页:offset/limit,默认 limit=50 上限 100)", object_schema(json!({"offset": int_field("偏移量(可空,默认 0)"), "limit": int_field("返回上限(可空,默认 50,上限 100)")}), &[]), 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("create_project", "创建项目(Medium 风险,默认允许+审计日志)", object_schema(json!({"name": str_field("项目名"), "description": str_field("描述"), "status": opt_str_field("状态(默认 planning)")}), &["name"]), 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("状态(可空=保留原值)"), "expected_updated_at": int_field("乐观锁版本(可空):上次读取到的 updated_at 毫秒时间戳,不一致则拒绝写入")}), &["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 过滤;分页 offset/limit,默认 limit=50 上限 100)", object_schema(json!({"project_id": opt_str_field("按项目过滤(可空)"), "status": opt_str_field("按状态过滤(todo/in_progress/in_review/testing/blocked/done/cancelled,可空)"), "offset": int_field("偏移量(可空,默认 0)"), "limit": int_field("返回上限(可空,默认 50,上限 100)")}), &[]), 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("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"]), 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("描述(可空=保留原值)"), "expected_updated_at": int_field("乐观锁版本(可空):上次读取到的 updated_at 毫秒时间戳,不一致则拒绝写入")}), &["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", "列出所有想法/灵感(分页:offset/limit,默认 limit=50 上限 100)", object_schema(json!({"offset": int_field("偏移量(可空,默认 0)"), "limit": int_field("返回上限(可空,默认 50,上限 100)")}), &[]), 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("create_idea", "创建想法(Medium 风险,默认允许+审计日志)", object_schema(json!({"title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 0)")}), &["title"]), Medium, create_idea),
spec("update_idea", "更新想法(部分更新:仅传需要改的字段,未传字段保留原值)", object_schema(json!({"id": str_field("想法 ID"), "title": opt_str_field("标题(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)"), "expected_updated_at": int_field("乐观锁版本(可空):上次读取到的 updated_at 毫秒时间戳,不一致则拒绝写入")}), &["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),
@@ -455,26 +455,55 @@ fn bind_directory(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
medium_audit("bind_directory", &format!("{id} <- {path}"));
Box::pin(async move {
let repo = ProjectRepo::new(&db);
// 目录必须真实存在(对齐 GUI bind_dir_to_project:先 is_dir 校验再往下走)
if !std::path::Path::new(&path).is_dir() {
return CallToolResult::error(format!("目录不存在: {path}"));
}
// 分段检测 `..`(防穿越)——纯子串 contains("..") 会误伤 my..file 这类合法名,
// 改用逐段判断对齐 tool_registry.rs:validate_path 的分段检测逻辑。
let has_traversal = path.split(|c| c == '\\' || c == '/').any(|seg| seg == "..");
if has_traversal {
return CallToolResult::error(format!("路径不得包含 '..' 段: {}", path));
}
let norm = normalize_path(&path);
// 路径冲突检测
// canonicalize 解析真实绝对路径:目录已存在,符号链接在此被解析,
// 防经 symlink 逃逸到预期外目录(对齐 GUI normalize_path 的 canonicalize 优先)。
let canon = match std::path::Path::new(&path).canonicalize() {
Ok(c) => c,
Err(e) => return CallToolResult::error(format!("路径解析失败: {path}: {e}")),
};
// 路径冲突检测(规范化比较,防路径写法差异绕过,复用 Repo 统一实现)
let norm = df_project::scan::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 字段(用 normalize 后的规范化路径,保留其它)
// 技术栈探测:detect_stack 内含多次同步 fs IO,spawn_blocking 防阻塞 tokio runtime
// (与 GUI bind_dir_to_project 同法;用 canonicalize 后的真实目录探测)
let stack_dir = canon;
let stack = match tokio::task::spawn_blocking(move || {
df_project::scan::detect_stack(&stack_dir)
})
.await
{
Ok(Ok(s)) => s,
Ok(Err(e)) => return CallToolResult::error(format!("技术栈探测失败: {e}")),
Err(e) => return CallToolResult::error(format!("技术栈探测任务失败: {e}")),
};
let stack_json = match serde_json::to_string(&stack) {
Ok(s) => s,
Err(e) => return CallToolResult::error(format!("技术栈序列化失败: {e}")),
};
// 写回 path(规范化后的真实路径)与 stack 字段,其余字段保留
match repo.update_field(&id, "path", &norm).await {
Ok(true) => {}
Ok(false) => return CallToolResult::error(format!("项目不存在: {id}")),
Err(e) => return err_str(e),
}
if let Err(e) = repo.update_field(&id, "stack", &stack_json).await {
return err_str(e);
}
let updated = repo.get_by_id(&id).await.ok().flatten();
json_ok(json!({ "id": id, "project": updated }))
})
@@ -951,39 +980,6 @@ fn restore_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
})
}
// ============================================================
// 路径规范化(镜像 df_project::scan::normalize_path,本 crate 不依赖 df-project)
// ============================================================
fn normalize_path(p: &str) -> String {
// 先尝试 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()
}
// ============================================================
// 单测:evaluate_idea(只读,不写库)/ score_idea(写库)/ 风险契约
// ============================================================
@@ -1514,4 +1510,51 @@ mod tests {
assert_eq!(v["projects"].as_array().unwrap().len(), 1);
assert_eq!(v["has_more"], false);
}
// ── bind_directory(对齐 GUI:is_dir 校验 + canonicalize + stack 探测)──
#[tokio::test]
async fn bind_directory_rejects_non_existent_dir() {
let ctx = test_ctx().await;
let pid = seed_project(&ctx, "项目").await;
let fake = std::env::temp_dir().join(format!("df_mcp_no_such_{}", uuid::Uuid::new_v4()));
let r = bind_directory(&ctx, json!({ "id": pid, "path": fake.to_string_lossy() })).await;
assert_eq!(r.is_error, Some(true));
assert!(text_of(&r).contains("目录不存在"), "实际: {}", text_of(&r));
}
#[tokio::test]
async fn bind_directory_rejects_traversal_path() {
let ctx = test_ctx().await;
let pid = seed_project(&ctx, "项目").await;
let root = std::env::temp_dir().join(format!("df_mcp_trav_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(root.join("sub")).unwrap();
// 真实存在但含 `..` 段的路径:is_dir 通过后应由遍历检测拒绝
let path = root.join("sub").join("..").join("sub");
let r = bind_directory(&ctx, json!({ "id": pid, "path": path.to_string_lossy() })).await;
std::fs::remove_dir_all(&root).ok();
assert_eq!(r.is_error, Some(true));
assert!(text_of(&r).contains("'..'"), "实际: {}", text_of(&r));
}
#[tokio::test]
async fn bind_directory_binds_real_dir_and_writes_stack() {
let ctx = test_ctx().await;
let pid = seed_project(&ctx, "项目").await;
let dir = std::env::temp_dir().join(format!("df_mcp_bind_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
// 放一个 Cargo.toml 让 detect_stack 识别为 rust,验证 stack 探测生效
std::fs::write(
dir.join("Cargo.toml"),
"[package]\nname = \"x\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let r = bind_directory(&ctx, json!({ "id": pid, "path": dir.to_string_lossy() })).await;
std::fs::remove_dir_all(&dir).ok();
assert!(r.is_error.is_none(), "绑定失败: {:?}", text_of(&r));
let v = json_of(&r);
assert_eq!(v["id"], pid);
let stack = v["project"]["stack"].as_str().unwrap_or_default();
assert!(stack.contains("rust"), "应探测到 rust 技术栈,实际 stack: {stack}");
}
}