dev-manager-tauri/src-tauri/src/mcp_tools.rs

1232 lines
60 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use serde_json::{json, Value};
use crate::db;
// ── 工具 Schema 定义 ──────────────────────────────────────────────────────────
pub fn tools_list_result() -> Value {
json!({
"tools": [
{
"name": "get_diagnostics",
"description": "查询炼境运行时诊断信息MCP Server 启动状态、各项目 MCP 配置注入结果、最近错误日志。排查功能是否正常时首先调用此工具。",
"inputSchema": { "type": "object", "properties": {}, "required": [] }
},
{
"name": "list_group_projects",
"description": "列出产品组内所有成员项目包含名称、路径、平台、技术栈、角色等信息group_id 可从 list_all_projects 获取)",
"inputSchema": {
"type": "object",
"properties": {
"group_id": { "type": "string", "description": "产品组 ID" }
},
"required": ["group_id"]
}
},
{
"name": "get_project_blueprint",
"description": "读取产品组内某个项目的蓝图文件(.blueprint/manifest.yaml了解该项目的模块与任务状态",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID来自 list_group_projects 的返回结果)" }
},
"required": ["project_id"]
}
},
{
"name": "get_project_file",
"description": "读取产品组内某个项目的任意文件内容,如 openapi.yaml、README.md、src/types.ts 等",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID" },
"relative_path": { "type": "string", "description": "相对于项目根目录的文件路径,如 openapi.yaml 或 src/api/routes.ts" }
},
"required": ["project_id", "relative_path"]
}
},
{
"name": "get_project_mentor_context",
"description": "获取产品组内某个项目的完整导师上下文——包含健康状态、蓝图进度摘要、近期错误日志,以及格式化好的 Markdown 报告(可直接用于质量分析和成长建议)",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID来自 list_group_projects 的返回结果)" }
},
"required": ["project_id"]
}
},
{
"name": "list_envoy_returns",
"description": "列出所有子项目通过「炼境使者」发回炼境的待处理洞察,按项目分组展示。炼境会话启动时调用,用于检查是否有需要处置的治理反馈(模板 bug、验证有效的实践、体系改进假设。无需任何参数。",
"inputSchema": {
"type": "object",
"properties": {}
}
},
{
"name": "append_project_note",
"description": "将 Claude 的分析结论或复盘记录写入项目笔记,供未来会话和用户在炼境中查阅。完成 M/L 复杂度任务后必须调用,写入结构化复盘笔记。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID来自 list_group_projects 的返回结果)" },
"content": {
"type": "string",
"description": "笔记内容。完成 M/L 任务后使用以下结构化格式:\n【复盘】<任务标题>\n任务类型: 后端命令|前端组件|数据层|MCP工具|架构调整\n复杂度: S|M|L\n实际轮数: N轮预估M轮\n是否返工: 是/否\n返工原因: <若返工填写,否则省略>\n有效策略: <本次奏效的做法>\n遗留风险: <若有填写,否则省略>\n\n其他场景(分析结论、建议、洞察)可自由格式。"
}
},
"required": ["project_id", "content"]
}
},
{
"name": "get_blueprint_status",
"description": "检测项目蓝图治理状态CONVENTIONS 规则版本是否最新、蓝图内容是否可能落后于代码synced/rules_outdated/content_stale/none。等价于炼境 UI 项目卡片上的蓝图状态徽章。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID来自 list_group_projects 的返回结果)" }
},
"required": ["project_id"]
}
},
{
"name": "generate_blueprint_prompt",
"description": "生成蓝图初始化/同步提示词(含 CONVENTIONS 规范与 manifest 严格格式约束)。等价于炼境 UI 治理面板的「初始化蓝图/更新蓝图描绘」按钮。拿到提示词后在该项目目录下执行它(或交给该项目的 AI 会话),完成后可用 get_blueprint_status / get_project_blueprint 验证结果。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID来自 list_group_projects 的返回结果)" },
"mode": { "type": "string", "enum": ["init", "sync"], "description": "init=项目尚无蓝图时从零初始化sync=已有蓝图,对照代码现状同步更新" }
},
"required": ["project_id", "mode"]
}
},
{
"name": "sync_blueprint_rules",
"description": "将最新 CONVENTIONS 规则同步到项目(写入 .blueprint/CONVENTIONS.md + 更新 AGENTS.md/CLAUDE.md 的蓝图管理区块)。等价于炼境 UI 治理面板的「同步规则」按钮。仅当 get_blueprint_status 返回 rules_outdated 时需要调用。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID来自 list_group_projects 的返回结果)" }
},
"required": ["project_id"]
}
},
{
"name": "create_pull_request",
"description": "通过炼境存储的 Gitea 凭证为项目创建合并请求PR。炼境内部调用 Gitea API无需 Claude 自行提取 token。/ship 交付流程在推送分支后应调用此工具完成 PR 创建。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID来自 list_group_projects 的返回结果)" },
"head": { "type": "string", "description": "源分支名,如 dev" },
"base": { "type": "string", "description": "目标分支名,如 main" },
"title": { "type": "string", "description": "PR 标题" },
"body": { "type": "string", "description": "PR 描述Markdown可为空字符串" }
},
"required": ["project_id", "head", "base", "title"]
}
},
{
"name": "get_project_snapshots",
"description": "获取项目的历史健康快照(每日一条),包含 git 提交间隔、蓝图完成率、阻塞任务数、错误情况。用于分析趋势、识别高价值改进变量。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID来自 list_group_projects 的返回结果)" },
"days": { "type": "integer", "description": "查询最近 N 天的快照,默认 30" }
},
"required": ["project_id"]
}
},
{
"name": "poll_now",
"description": "立即触发一次 Gitea 轮询:拉取所有绑定仓库的最新 commit 和 PR 状态写入飞轮。等价于炼境 UI「立即拉取」按钮。适用于公网 Gitea + 内网炼境webhook 不可达)场景。",
"inputSchema": { "type": "object", "properties": {}, "required": [] }
},
{
"name": "setup_webhook",
"description": "为项目重新注册 Gitea webhook订阅 push + pull_request 事件)。等价于炼境 UI「重设」按钮。导入新项目后、或需要更新事件订阅时调用。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID来自 list_group_projects 的返回结果)" }
},
"required": ["project_id"]
}
},
{
"name": "inject_mcp",
"description": "向项目的 .claude/settings.json 注入炼境 MCP 配置,同时写入 bypassPermissions 和 liangjing.json。等价于炼境 UI「注入 MCP」按钮。导入新项目后或炼境重启后调用。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID来自 list_group_projects 的返回结果)" }
},
"required": ["project_id"]
}
},
{
"name": "get_ci_status",
"description": "查询指定 commit 的 CI 检查结果pass/fail/pending。/ship 流程推送分支并开 PR 后,用此工具确认 CI 是否通过再决定是否合并。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID来自 list_group_projects 的返回结果)" },
"commit_sha": { "type": "string", "description": "要查询的 commit SHA完整 40 位或前 8 位均可)" }
},
"required": ["project_id", "commit_sha"]
}
},
{
"name": "merge_pull_request",
"description": "合并项目的指定 PR。建议在 get_ci_status 确认通过后再调用。等价于 Gitea UI「合并 PR」按钮。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID来自 list_group_projects 的返回结果)" },
"pr_number": { "type": "integer", "description": "PR 编号(来自 create_pull_request 的返回结果或 Gitea UI" }
},
"required": ["project_id", "pr_number"]
}
},
{
"name": "get_pr_events",
"description": "查询项目的 PR 生命周期历史opened/merged/closed。数据来自炼境飞轮本地 git 命令无法获取。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID来自 list_group_projects 的返回结果)" },
"limit": { "type": "integer", "description": "返回最近 N 条记录,默认 20" }
},
"required": ["project_id"]
}
},
{
"name": "get_commit_metrics",
"description": "查询项目的提交飞轮数据提交类型分布feat/fix/chore...)、是否返工、近期频率。数据来自炼境 DB本地 git log 无法直接得到解析后的分类。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID来自 list_group_projects 的返回结果)" },
"days": { "type": "integer", "description": "查询最近 N 天,默认 30" }
},
"required": ["project_id"]
}
},
{
"name": "distribute_cicd",
"description": "向项目写入 CI 模板文件(.gitea/workflows/)。当项目缺少 CI 配置时调用,自动解析项目路径写入。支持 pr_ciPR 质量检查)和 deepseek_reviewAI 代码审查)两种类型。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID来自 list_group_projects 的返回结果)" },
"workflow_type": { "type": "string", "enum": ["pr_ci", "deepseek_review"], "description": "pr_ci=PR 合并前质量检查deepseek_review=AI 代码审查并评论到 PR" }
},
"required": ["project_id", "workflow_type"]
}
},
{
"name": "list_all_projects",
"description": "列出炼境中所有已登记项目(不限产品组),包含 project_id、名称、路径、所属产品组、MCP 注入状态。用于发现尚未注入的项目,再调 inject_mcp 完成接入。",
"inputSchema": { "type": "object", "properties": {}, "required": [] }
},
{
"name": "apply_onboarding_pack",
"description": "向项目分发标准接入包:写入 AGENTS.md / lefthook / git-workflow 模板(已存在则原位升级 ONBOARDING_MANAGED 块,绝不覆盖块外用户内容),并挂载 buff 自动监听 git 提交。等价于炼境 UI「接入包」区域的「应用接入包」按钮。模板版本升级后对存量项目重新调用即可推送新版本。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID来自 list_group_projects 的返回结果)" }
},
"required": ["project_id"]
}
},
{
"name": "verify_onboarding",
"description": "接入生效验收:检查接入包在项目里是否真正生效(模板管理块版本到位 / lefthook hook 真挂在 .git/hooks / origin 指向已登记 Gitea / CI workflow 存在 / 近 30 天飞轮 commit 数据流入)。分发成功 ≠ 功能生效apply_onboarding_pack 之后应调用本工具做消费者角色验收。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID来自 list_group_projects 的返回结果)" }
},
"required": ["project_id"]
}
},
{
"name": "scan_unregistered_projects",
"description": "扫描已登记项目的父目录,找出存在于文件系统但尚未在炼境注册的项目目录。返回每个未注册目录的路径、是否有 .git、技术栈特征文件package.json / Cargo.toml / go.mod、是否有 AGENTS.md。用于日常发现漏网项目并决定是否接入炼境。",
"inputSchema": { "type": "object", "properties": {}, "required": [] }
},
{
"name": "unregister_project",
"description": "从炼境删除一条项目登记(等价 UI 删除项目按钮的 agent 投影):清理产品组 MCP 配置、关联表、workspace 与孤儿 profile。绝不删除本地文件该能力不对 agent 开放。适用scan_unregistered_projects 的反向操作——清理目录已消失或已无 .git 的死登记。删除不可逆,调用前先向用户列出目标清单确认。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "要删除登记的项目 workspace ID" }
},
"required": ["project_id"]
}
},
{
"name": "ingest_git_history",
"description": "全量导入 git 历史到飞轮(等价 UI「全量导入」按钮的 agent 投影):解析最近 500 条 commit 的 conventional 指标、Rules-Applied trailer 与触碰文件UPSERT 幂等(存量行自动回填 trailer。不传 project_id 时对所有已登记 Windows 路径项目执行。适用trailer 存量回填、新项目接入后初始化飞轮数据。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "可选。项目 workspace ID省略则遍历所有已登记项目" }
},
"required": []
}
},
{
"name": "predict_task_risk",
"description": "任务风险预测(飞轮阶段三):按 scope 和/或文件清单查询该项目历史返工率,返回 risk_levellow/medium/high、样本量与置信度、热点文件排行。agent 开工前调用——任务卡涉及高返工 scope/文件时提高警惕(多写测试、小步提交)。样本 <5 或 commit 历史不规范时 confidence=low结论仅供参考。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID来自 list_group_projects 的返回结果)" },
"scope": { "type": "string", "description": "conventional commit scope如 onboarding、auth/login前缀匹配子 scope" },
"files": { "type": "array", "items": { "type": "string" }, "description": "任务卡涉及的文件相对路径清单(与 git log --name-only 路径格式一致)" }
},
"required": ["project_id"]
}
},
{
"name": "setup_gitea_repo",
"description": "为项目在默认 Gitea 实例上创建远程仓库,并将本地 git remote origin 指向新仓库幂等仓库已存在则直接绑定remote 已有则 set-url 更新)。创建后炼境自动记录绑定关系,后续可调用 setup_webhook 注册 webhook。需先在炼境 UI 配置一个默认 Gitea 实例。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID来自 list_group_projects 的返回结果)" },
"repo_name": { "type": "string", "description": "Gitea 仓库名;省略则用项目名自动 slug 化(非 ASCII 字符过滤掉)" },
"private": { "type": "boolean", "description": "是否创建私有仓库,默认 false公开" }
},
"required": ["project_id"]
}
}
]
})
}
// ── 工具分发 ──────────────────────────────────────────────────────────────────
pub async fn tools_call(params: Option<&Value>) -> Value {
let tool_name = params
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
let args = params
.and_then(|p| p.get("arguments"))
.cloned()
.unwrap_or(json!({}));
let outcome = tokio::task::spawn_blocking(move || match tool_name.as_str() {
"get_diagnostics" => get_diagnostics(),
"list_group_projects" => {
let group_id = args
.get("group_id")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty());
match group_id {
Some(g) => list_group_projects(g),
None => Err("需显式传 group_id 参数(组 ID 可从 list_all_projects 获取)".to_string()),
}
}
"get_project_blueprint" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
get_project_blueprint(pid)
}
"get_blueprint_status" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
resolve_project_root(pid).and_then(|root| {
crate::commands::blueprint::get_blueprint_status(root.to_string_lossy().to_string())
.and_then(|s| serde_json::to_string_pretty(&s).map_err(|e| e.to_string()))
})
}
"generate_blueprint_prompt" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let mode = args.get("mode").and_then(|v| v.as_str()).unwrap_or("sync");
if mode != "init" && mode != "sync" {
Err(format!("mode 只允许 init 或 sync收到: {mode}"))
} else {
resolve_project_root(pid).and_then(|root| {
crate::commands::blueprint::generate_blueprint_prompt(
root.to_string_lossy().to_string(),
mode.to_string(),
)
})
}
}
"sync_blueprint_rules" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
resolve_project_root(pid).and_then(|root| {
crate::commands::blueprint::sync_blueprint_rules(root.to_string_lossy().to_string())
.and_then(|r| serde_json::to_string_pretty(&r).map_err(|e| e.to_string()))
})
}
"get_project_file" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let rel = args.get("relative_path").and_then(|v| v.as_str()).unwrap_or("");
get_project_file(pid, rel)
}
"get_project_mentor_context" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
get_project_mentor_context(pid)
}
"list_envoy_returns" => {
list_envoy_returns()
}
"append_project_note" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let content = args.get("content").and_then(|v| v.as_str()).unwrap_or("");
append_project_note(pid, content)
}
"get_project_snapshots" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let days = args.get("days").and_then(|v| v.as_u64()).unwrap_or(30) as u32;
get_project_snapshots(pid, days)
}
"create_pull_request" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let head = args.get("head").and_then(|v| v.as_str()).unwrap_or("");
let base = args.get("base").and_then(|v| v.as_str()).unwrap_or("main");
let title = args.get("title").and_then(|v| v.as_str()).unwrap_or("");
let body = args.get("body").and_then(|v| v.as_str()).unwrap_or("");
crate::commands::gitea::gitea_api_create_pull_request(pid, head, base, title, body)
}
"poll_now" => crate::commands::gitea::poll_all_repos()
.map(|(polled, ingested)| format!("轮询完成:{polled} 个仓库,新 commit {ingested}PR 事件已同步")),
"setup_webhook" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let port = crate::mcp_server::read_port();
let callback_base = format!("http://127.0.0.1:{}", port);
crate::commands::gitea::gitea_setup_webhook(pid.to_string(), callback_base).map(|repo| {
format!(
"webhook 注册成功:{}/{} hook_id={}",
repo.gitea_owner,
repo.gitea_repo_name,
repo.webhook_id.map(|id| id.to_string()).unwrap_or_default()
)
})
}
"inject_mcp" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
crate::mcp_inject::inject_project_mcp(pid)
.map(|_| format!("MCP 注入成功project_id={}(单一 lian-jing 服务器,无需 group_id", pid))
}
"get_ci_status" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let sha = args.get("commit_sha").and_then(|v| v.as_str()).unwrap_or("");
if sha.is_empty() {
return Err("commit_sha 不能为空".to_string());
}
crate::commands::gitea::gitea_api_get_ci_status(pid, sha)
}
"merge_pull_request" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let pr_num = args.get("pr_number").and_then(|v| v.as_i64()).unwrap_or(0);
if pr_num <= 0 {
return Err("pr_number 必须为正整数".to_string());
}
crate::commands::gitea::gitea_api_merge_pull_request(pid, pr_num)
}
"get_pr_events" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
let conn = db::pool().get().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare(
"SELECT pr_number, title, action, head_branch, base_branch, author, event_at
FROM pr_events WHERE project_id = ?1 ORDER BY event_at DESC LIMIT ?2",
)
.map_err(|e| e.to_string())?;
let rows: Vec<String> = stmt
.query_map(rusqlite::params![pid, limit as i64], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
r.get::<_, String>(3)?,
r.get::<_, String>(4)?,
r.get::<_, String>(5)?,
r.get::<_, String>(6)?,
))
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.map(|(num, title, action, head, base, author, at)| {
let icon = match action.as_str() {
"merged" => "",
"closed" => "🚫",
_ => "🔵",
};
format!("{icon} PR #{num} [{action}] {title}\n {head}{base} by {author} @ {at}")
})
.collect();
if rows.is_empty() {
Ok(format!("项目 {} 暂无 PR 事件记录", pid))
} else {
Ok(format!("PR 事件(最近 {} 条):\n\n{}", rows.len(), rows.join("\n\n")))
}
}
"get_commit_metrics" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let days = args.get("days").and_then(|v| v.as_u64()).unwrap_or(30);
let conn = db::pool().get().map_err(|e| e.to_string())?;
let offset = format!("-{} days", days);
let total: i64 = conn
.query_row(
"SELECT COUNT(*) FROM commit_metrics WHERE project_id = ?1 AND committed_at >= datetime('now', ?2)",
rusqlite::params![pid, offset],
|r| r.get(0),
)
.unwrap_or(0);
let mut type_stmt = conn
.prepare(
"SELECT COALESCE(commit_type,'unknown') as t, COUNT(*) as n, SUM(is_rework) as rework
FROM commit_metrics WHERE project_id = ?1 AND committed_at >= datetime('now', ?2)
GROUP BY t ORDER BY n DESC",
)
.map_err(|e| e.to_string())?;
let type_rows: Vec<String> = type_stmt
.query_map(rusqlite::params![pid, offset], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?))
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.map(|(t, n, rework)| format!(" - {t}: {n} 条(返工 {rework} 条)"))
.collect();
let rework_total: i64 = conn
.query_row(
"SELECT SUM(is_rework) FROM commit_metrics WHERE project_id = ?1 AND committed_at >= datetime('now', ?2)",
rusqlite::params![pid, offset],
|r| r.get(0),
)
.unwrap_or(0);
let mut out = format!("## 提交飞轮数据(最近 {} 天)\n\n", days);
out.push_str(&format!("- **总提交数**{}\n", total));
out.push_str(&format!(
"- **返工提交**{} 条({:.0}%\n",
rework_total,
if total > 0 { rework_total as f64 / total as f64 * 100.0 } else { 0.0 }
));
out.push_str("\n**按类型分布**\n");
out.push_str(&type_rows.join("\n"));
Ok(out)
}
"distribute_cicd" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let wt = args.get("workflow_type").and_then(|v| v.as_str()).unwrap_or("");
crate::commands::cicd::distribute_workflow_for_project(pid, wt)
}
"list_all_projects" => list_all_projects(),
"scan_unregistered_projects" => scan_unregistered_projects(),
"unregister_project" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
if pid.is_empty() {
Err("project_id 不能为空".to_string())
} else {
// delete_local 硬编码 falseMCP 侧永不删除本地文件
crate::commands::projects::delete_project(pid.to_string(), Some(false))
.map(|_| format!("已删除登记:{}(本地文件未动)", pid))
}
}
"ingest_git_history" => {
let pid = args.get("project_id").and_then(|v| v.as_str());
let conn = db::pool().get().map_err(|e| e.to_string())?;
let targets: Vec<(String, String)> = {
let mut stmt = conn
.prepare(
"SELECT id, win_path FROM project_workspaces
WHERE win_path IS NOT NULL AND (?1 IS NULL OR id = ?1)",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map(rusqlite::params![pid], |r| Ok((r.get(0)?, r.get(1)?)))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
rows
};
drop(conn);
if targets.is_empty() {
Err(format!("未找到可导入的项目project_id={:?}", pid))
} else {
let mut lines = Vec::new();
let (mut ok, mut failed) = (0u32, 0u32);
for (id, path) in &targets {
match crate::commands::commit_metrics::ingest_full_git_history(
id.clone(),
path.clone(),
) {
Ok(r) => {
ok += 1;
lines.push(format!("- {}: 写入 {} 条,跳过 {}", id, r.imported, r.skipped));
}
Err(e) => {
failed += 1;
lines.push(format!("- {}: ❌ {}", id, e));
}
}
}
Ok(format!(
"全量导入完成:{} 个项目成功,{} 个失败\n\n{}",
ok,
failed,
lines.join("\n")
))
}
}
"predict_task_risk" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let scope = args.get("scope").and_then(|v| v.as_str());
let files: Vec<String> = args
.get("files")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|f| f.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let conn = db::pool().get().map_err(|e| e.to_string())?;
crate::commands::risk::predict_task_risk(&conn, pid, scope, &files)
.and_then(|r| serde_json::to_string_pretty(&r).map_err(|e| e.to_string()))
}
"setup_gitea_repo" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let repo_name = args.get("repo_name").and_then(|v| v.as_str());
let private = args.get("private").and_then(|v| v.as_bool()).unwrap_or(false);
crate::commands::gitea::gitea_api_setup_repo(pid, repo_name, private)
}
"apply_onboarding_pack" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
resolve_project_root(pid).and_then(|root| {
let path = root.to_string_lossy().to_string();
crate::commands::onboarding::apply_onboarding_pack(path, pid.to_string()).map(
|report| {
let template_lines: Vec<String> = report
.templates
.iter()
.map(|t| {
let icon = match t.action.as_str() {
"written" => "✅ written",
"updated" => "🔄 updated",
"appended" => " appended",
"skipped" => "⏭ skipped",
_ => "❌ failed",
};
format!(" {icon}: {}", t.name)
})
.collect();
let hook_icon = match report.hook.status.as_str() {
"activated" => "",
"missing_binary" => "⚠️",
_ => "",
};
format!(
"接入包分发完成project_id={}\n\n模板:\n{}\n\nlefthook hook{} {}{}\nbuff{}\n\n建议调用 verify_onboarding 做生效验收。",
pid,
template_lines.join("\n"),
hook_icon,
report.hook.status,
report.hook.detail,
report.buff.status
)
},
)
})
}
"verify_onboarding" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let root = resolve_project_root(pid)?;
let conn = db::pool().get().map_err(|e| e.to_string())?;
let checks = crate::commands::onboarding::verify_onboarding(&conn, pid, &root);
let passed = checks.iter().filter(|c| c.pass).count();
serde_json::to_string_pretty(&json!({
"project_id": pid,
"pass": passed == checks.len(),
"summary": format!("{passed}/{} 项通过", checks.len()),
"checks": checks,
}))
.map_err(|e| e.to_string())
}
_ => Err(format!("未知工具: {}", tool_name)),
})
.await;
match outcome {
Ok(Ok(text)) => json!({ "content": [{ "type": "text", "text": text }] }),
Ok(Err(e)) => json!({ "content": [{ "type": "text", "text": e }], "isError": true }),
Err(e) => json!({ "content": [{ "type": "text", "text": e.to_string() }], "isError": true }),
}
}
// ── 工具实现 ──────────────────────────────────────────────────────────────────
fn get_diagnostics() -> Result<String, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
let port = crate::mcp_server::read_port();
let start_time: Option<String> = conn
.query_row(
"SELECT ts FROM runtime_logs WHERE category = 'mcp_server' AND level = 'info' ORDER BY id ASC LIMIT 1",
[],
|r| r.get(0),
)
.ok();
let mut out = String::new();
out.push_str("## 炼境 MCP 诊断报告\n\n");
out.push_str(&format!("- **服务端口**: {}\n", port));
out.push_str(&format!(
"- **启动时间**: {}\n\n",
start_time.as_deref().unwrap_or("未知(可能在本次日志之前启动)")
));
out.push_str("### MCP 注入状态(每个项目最后一次结果)\n\n");
let mut stmt = conn
.prepare(
"SELECT context, level, message, ts
FROM runtime_logs
WHERE category = 'mcp_inject'
AND id IN (
SELECT MAX(id) FROM runtime_logs
WHERE category = 'mcp_inject'
GROUP BY json_extract(context, '$.project_id')
)
ORDER BY ts DESC
LIMIT 50",
)
.map_err(|e| e.to_string())?;
let inject_rows: Vec<(Option<String>, String, String, String)> = stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
if inject_rows.is_empty() {
out.push_str("_暂无注入记录成员加入产品组时会产生_\n\n");
} else {
for (ctx, level, msg, ts) in &inject_rows {
let project_id = ctx
.as_deref()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.and_then(|v| v.get("project_id").and_then(|x| x.as_str()).map(|s| s.to_string()))
.unwrap_or_else(|| "unknown".to_string());
let icon = if level == "info" { "" } else { "⚠️" };
out.push_str(&format!("- {} `{}` — {} _({})\n", icon, project_id, msg, ts));
}
out.push('\n');
}
out.push_str("### 最近错误日志(最多 20 条)\n\n");
let mut stmt2 = conn
.prepare(
"SELECT ts, category, level, message FROM runtime_logs
WHERE level IN ('error', 'warn')
ORDER BY id DESC LIMIT 20",
)
.map_err(|e| e.to_string())?;
let error_rows: Vec<(String, String, String, String)> = stmt2
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
if error_rows.is_empty() {
out.push_str("_无错误记录_ ✅\n");
} else {
for (ts, cat, level, msg) in &error_rows {
out.push_str(&format!("- `[{}]` **{}** `{}` — {}\n", ts, level, cat, msg));
}
}
out.push_str("\n### 系统健康\n\n");
let report = crate::commands::settings::collect_health_report();
out.push_str(&format!("检查时间:{}\n\n", report.checked_at));
out.push_str("| 检查项 | 状态 | 详情 |\n|--------|------|------|\n");
for item in &report.items {
let icon = match item.status.as_str() {
"ok" => "",
"warn" => "⚠️",
"error" => "",
_ => "",
};
out.push_str(&format!(
"| {} | {} {} | {} |\n",
item.name,
icon,
item.status,
item.detail.as_deref().unwrap_or("-")
));
}
out.push_str("\n### 质量指标\n\n");
let q = &report.quality;
out.push_str(&format!("- **休眠项目**>30天未 push{}\n", q.dormant_projects.len()));
for p in &q.dormant_projects {
out.push_str(&format!(" - {} {} 天)\n", p.name, p.days_since_push));
}
out.push_str(&format!("- **孤立项目**(无分组 & 无 repo{}\n", q.orphaned_projects.len()));
for p in &q.orphaned_projects {
out.push_str(&format!(" - {}\n", p.name));
}
out.push_str(&format!(
"- **MCP 注入异常产品组**(近 7 天):{}\n",
q.groups_with_inject_warn.len()
));
for gid in &q.groups_with_inject_warn {
out.push_str(&format!(" - `{}`\n", gid));
}
out.push_str(&format!("- **无本地路径项目**{}\n", q.projects_without_path));
Ok(out)
}
fn list_group_projects(group_id: &str) -> Result<String, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare(
"SELECT pw.id, pp.name, pw.platform, pw.win_path, pw.wsl_path, pp.tech_stack, pgm.role
FROM project_group_map pgm
JOIN project_workspaces pw ON pw.id = pgm.project_id
JOIN project_profiles pp ON pp.id = pw.profile_id
WHERE pgm.group_id = ?1",
)
.map_err(|e| e.to_string())?;
let rows: Vec<String> = stmt
.query_map([group_id], |row| {
let id: String = row.get(0)?;
let name: String = row.get(1)?;
let platform: Option<String> = row.get(2)?;
let win_path: Option<String> = row.get(3)?;
let wsl_path: Option<String> = row.get(4)?;
let tech_stack: Option<String> = row.get(5)?;
let role: Option<String> = row.get(6)?;
Ok(format!(
"- **{}** (id: `{}`)\n 平台: {} | 技术栈: {}{}\n Windows 路径: {}\n WSL 路径: {}",
name,
id,
platform.as_deref().unwrap_or("unknown"),
tech_stack.as_deref().unwrap_or("未配置"),
role.map(|r| format!(" | 角色: {}", r)).unwrap_or_default(),
win_path.as_deref().unwrap_or("未配置"),
wsl_path.as_deref().unwrap_or("未配置"),
))
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
if rows.is_empty() {
return Ok("该产品组暂无成员项目。".to_string());
}
Ok(format!("产品组成员(共 {} 个项目):\n\n{}", rows.len(), rows.join("\n\n")))
}
fn get_project_blueprint(project_id: &str) -> Result<String, String> {
let root = resolve_project_root(project_id)?;
let path = root.join(".blueprint").join("manifest.yaml");
std::fs::read_to_string(&path)
.map_err(|e| format!("读取蓝图失败({}: {}", path.display(), e))
}
fn get_project_file(project_id: &str, relative_path: &str) -> Result<String, String> {
if relative_path.contains("..") {
return Err("路径不允许包含 \"..\"".to_string());
}
let root = resolve_project_root(project_id)?;
let path = root.join(relative_path);
std::fs::read_to_string(&path)
.map_err(|e| format!("读取文件失败({}: {}", path.display(), e))
}
/// 按 project_id 直查项目根目录(单一服务器寻址,不做组归属过滤)
pub fn resolve_project_root(project_id: &str) -> Result<std::path::PathBuf, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
let row: rusqlite::Result<(Option<String>, Option<String>, Option<String>)> = conn.query_row(
"SELECT win_path, wsl_path, platform FROM project_workspaces WHERE id = ?1",
[project_id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
);
let (win_path, wsl_path, platform) = row
.map_err(|_| format!("项目 {} 不存在", project_id))?;
if platform.as_deref() == Some("wsl") {
let wsl = wsl_path.ok_or_else(|| "WSL 项目未配置 wsl_path".to_string())?;
let distro: String = conn
.query_row("SELECT value FROM settings WHERE key = 'wsl_distro'", [], |r| r.get(0))
.unwrap_or_else(|_| "Ubuntu".to_string());
let inner = wsl.trim_start_matches('/').replace('/', "\\");
Ok(std::path::PathBuf::from(format!("\\\\wsl.localhost\\{}\\{}", distro, inner)))
} else {
let win = win_path.ok_or_else(|| "Windows 项目未配置 win_path".to_string())?;
Ok(std::path::PathBuf::from(win))
}
}
/// 项目存在性检查(替代旧的组归属校验——单一服务器寻址后组不再是访问边界)
fn ensure_project_exists(conn: &rusqlite::Connection, project_id: &str) -> Result<(), String> {
let exists: bool = conn
.query_row(
"SELECT COUNT(*) FROM project_workspaces WHERE id = ?1",
[project_id],
|r| r.get::<_, i64>(0),
)
.map(|n| n > 0)
.unwrap_or(false);
if !exists {
return Err(format!("项目 {} 不存在", project_id));
}
Ok(())
}
fn get_project_mentor_context(project_id: &str) -> Result<String, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
ensure_project_exists(&conn, project_id)?;
let ctx = crate::commands::mentor::get_mentor_context(project_id.to_string())
.map_err(|e| format!("获取导师上下文失败: {e}"))?;
Ok(ctx.mentor_markdown)
}
fn list_envoy_returns() -> Result<String, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare(
"SELECT pn.id, pp.name, pn.content, pn.created_at
FROM project_notes pn
JOIN project_workspaces pw ON pn.project_id = pw.id
JOIN project_profiles pp ON pw.profile_id = pp.id
WHERE pn.content LIKE '【炼境使者回传】%'
ORDER BY pn.created_at DESC
LIMIT 50",
)
.map_err(|e| e.to_string())?;
let rows: Vec<(i64, String, String, String)> = stmt
.query_map([], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
if rows.is_empty() {
return Ok("📭 暂无待处理的炼境使者回传".to_string());
}
let mut out = format!("📬 炼境使者回传(共 {} 条)\n\n", rows.len());
for (_id, project_name, content, created_at) in &rows {
let date = &created_at[..created_at.len().min(10)];
out.push_str(&format!("---\n**来源**: {} | **时间**: {}\n{}\n\n", project_name, date, content));
}
out.push_str("---\n💡 处置参考fix_template → 改接入包模板文件update_convention → 改 CONVENTIONS.mdlog_for_review → 记录待观察,暂不动模板");
Ok(out)
}
fn append_project_note(project_id: &str, content: &str) -> Result<String, String> {
if content.trim().is_empty() {
return Err("笔记内容不能为空".to_string());
}
let conn = db::pool().get().map_err(|e| e.to_string())?;
ensure_project_exists(&conn, project_id)?;
conn.execute(
"INSERT INTO project_notes (project_id, source, content) VALUES (?1, 'mcp', ?2)",
rusqlite::params![project_id, content],
)
.map_err(|e| e.to_string())?;
Ok("笔记已保存 ✅".to_string())
}
fn get_project_snapshots(project_id: &str, days: u32) -> Result<String, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
ensure_project_exists(&conn, project_id)?;
let mut stmt = conn
.prepare(
"SELECT snapshot_date, days_since_commit, blueprint_done_rate,
blueprint_in_progress, blueprint_blocked, has_errors
FROM project_snapshots
WHERE project_id = ?1
AND snapshot_date >= date('now', 'localtime', ?2)
ORDER BY snapshot_date ASC",
)
.map_err(|e| e.to_string())?;
let offset = format!("-{} days", days);
let rows: Vec<(String, Option<i64>, Option<f64>, i64, i64, i64)> = stmt
.query_map(rusqlite::params![project_id, offset], |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?))
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
if rows.is_empty() {
return Ok(format!(
"项目 `{}` 暂无历史快照(快照在每次应用启动时写入,最多保留 {} 天)。",
project_id, days
));
}
let mut md = format!(
"# 项目历史快照:`{}`\n\n最近 {} 天,共 {} 条记录\n\n",
project_id,
days,
rows.len()
);
md.push_str("| 日期 | 距上次提交(天) | 蓝图完成率 | 进行中模块 | 阻塞任务 | 有错误 |\n");
md.push_str("|------|--------------|-----------|-----------|---------|------|\n");
for (date, days_commit, done_rate, in_prog, blocked, has_err) in &rows {
let commit_col = days_commit.map(|d| d.to_string()).unwrap_or_else(|| "".to_string());
let rate_col = done_rate
.map(|r| format!("{:.0}%", r * 100.0))
.unwrap_or_else(|| "".to_string());
let err_col = if *has_err == 1 { "⚠️" } else { "" };
md.push_str(&format!(
"| {} | {} | {} | {} | {} | {} |\n",
date, commit_col, rate_col, in_prog, blocked, err_col
));
}
md.push_str("\n---\n\n请根据以上趋势数据,分析哪些变量与项目停滞或健康度下降最相关,并给出改进建议。\n");
Ok(md)
}
pub fn list_all_projects() -> Result<String, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare(
"SELECT pw.id, pp.name, pw.platform, pw.win_path, pw.wsl_path, pgm.group_id
FROM project_workspaces pw
JOIN project_profiles pp ON pp.id = pw.profile_id
LEFT JOIN project_group_map pgm ON pgm.project_id = pw.id
ORDER BY pp.name",
)
.map_err(|e| e.to_string())?;
let rows: Vec<(String, String, Option<String>, Option<String>, Option<String>, Option<String>)> =
stmt.query_map([], |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?))
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
if rows.is_empty() {
return Ok("炼境暂无登记项目。".to_string());
}
let mut out = format!("## 炼境全量项目(共 {} 个)\n\n", rows.len());
for (id, name, platform, win_path, wsl_path, group_id) in &rows {
let project_path = if platform.as_deref() == Some("wsl") {
wsl_path.as_ref().map(|p| {
let conn2 = db::pool().get().ok();
let distro = conn2
.and_then(|c| {
c.query_row(
"SELECT value FROM settings WHERE key = 'wsl_distro'",
[],
|r| r.get::<_, String>(0),
)
.ok()
})
.unwrap_or_else(|| "Ubuntu".to_string());
let inner = p.trim_start_matches('/').replace('/', "\\");
format!("\\\\wsl.localhost\\{}\\{}", distro, inner)
})
} else {
win_path.clone()
};
let injected = project_path
.as_ref()
.map(|p| std::path::Path::new(p).join(".claude").join("liangjing.json").exists())
.unwrap_or(false);
let inject_icon = if injected { "✅ 已注入" } else { "⚠️ 未注入" };
let group_str = group_id.as_deref().unwrap_or("无产品组");
let path_str = project_path.as_deref().unwrap_or("未配置路径");
out.push_str(&format!(
"- **{}** `{}`\n {} | 组: `{}` | {}\n\n",
name, id, inject_icon, group_str, path_str
));
}
out.push_str("---\n对未注入项目调用 `inject_mcp(project_id)` 完成接入。");
Ok(out)
}
pub fn scan_unregistered_projects() -> Result<String, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
// 取所有已登记的 win_path
let mut stmt = conn
.prepare("SELECT win_path FROM project_workspaces WHERE win_path IS NOT NULL")
.map_err(|e| e.to_string())?;
let registered_paths: std::collections::HashSet<String> = stmt
.query_map([], |r| r.get::<_, String>(0))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.map(|p| p.trim_end_matches(['/', '\\']).to_string())
.collect();
// 推导父目录(去重)
let mut parent_dirs: std::collections::HashSet<std::path::PathBuf> =
std::collections::HashSet::new();
for p in &registered_paths {
if let Some(parent) = std::path::Path::new(p).parent() {
parent_dirs.insert(parent.to_path_buf());
}
}
if parent_dirs.is_empty() {
return Ok("炼境暂无登记项目,无法推导扫描目录。".to_string());
}
let mut unregistered: Vec<(String, bool, Vec<String>, bool)> = Vec::new();
for parent in &parent_dirs {
let entries = std::fs::read_dir(parent).map_err(|e| e.to_string())?;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let path_str = path.to_string_lossy().trim_end_matches(['/', '\\']).to_string();
if registered_paths.contains(&path_str) {
continue;
}
// 隐藏目录跳过
if path
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with('.'))
.unwrap_or(false)
{
continue;
}
let has_git = path.join(".git").exists();
let mut stack: Vec<String> = Vec::new();
if path.join("package.json").exists() {
stack.push("JS/TS".into());
}
if path.join("Cargo.toml").exists() {
stack.push("Rust".into());
}
if path.join("go.mod").exists() {
stack.push("Go".into());
}
if path.join("pom.xml").exists() {
stack.push("Java".into());
}
let has_agents = path.join("AGENTS.md").exists();
unregistered.push((path_str, has_git, stack, has_agents));
}
}
if unregistered.is_empty() {
return Ok(format!(
"扫描了 {} 个父目录,所有子目录均已在炼境注册。",
parent_dirs.len()
));
}
unregistered.sort_by(|a, b| a.0.cmp(&b.0));
let mut out = format!(
"## 未注册目录(共 {} 个)\n\n扫描范围:{}\n\n",
unregistered.len(),
parent_dirs
.iter()
.map(|p| p.to_string_lossy().to_string())
.collect::<Vec<_>>()
.join("")
);
for (path, has_git, stack, has_agents) in &unregistered {
let git_icon = if *has_git { "🗂 git" } else { "❌ 无git" };
let agents_icon = if *has_agents { "📄 AGENTS" } else { "" };
let stack_str = if stack.is_empty() {
"未知".to_string()
} else {
stack.join("/")
};
let name = std::path::Path::new(path)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| path.clone());
out.push_str(&format!(
"- **{}** | {} | {} | {}\n `{}`\n\n",
name, git_icon, stack_str, agents_icon, path
));
}
out.push_str("---\n在炼境 UI 添加项目后,调用 `apply_onboarding_pack(project_id)` 分发接入包。");
Ok(out)
}
// ── 测试 ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tools_list_contains_expected_tools() {
let result = tools_list_result();
let tools = result["tools"].as_array().expect("tools 应为数组");
let names: Vec<&str> = tools
.iter()
.filter_map(|t| t["name"].as_str())
.collect();
// 核心工具必须存在
assert!(names.contains(&"apply_onboarding_pack"), "缺少 apply_onboarding_pack");
assert!(names.contains(&"verify_onboarding"), "缺少 verify_onboarding");
assert!(names.contains(&"list_all_projects"), "缺少 list_all_projects");
assert!(
names.contains(&"scan_unregistered_projects"),
"缺少 scan_unregistered_projects"
);
assert!(names.contains(&"inject_mcp"), "缺少 inject_mcp");
assert!(names.contains(&"create_pull_request"), "缺少 create_pull_request");
assert!(names.contains(&"poll_now"), "缺少 poll_now");
assert!(names.contains(&"predict_task_risk"), "缺少 predict_task_risk");
assert!(names.contains(&"ingest_git_history"), "缺少 ingest_git_history");
assert!(names.contains(&"unregister_project"), "缺少 unregister_project");
assert!(names.contains(&"setup_gitea_repo"), "缺少 setup_gitea_repo");
// 每个工具都有 description 和 inputSchema
for tool in tools {
let name = tool["name"].as_str().unwrap_or("?");
assert!(tool["description"].is_string(), "工具 {name} 缺少 description");
assert!(tool["inputSchema"].is_object(), "工具 {name} 缺少 inputSchema");
}
}
#[test]
fn apply_onboarding_pack_schema_requires_project_id() {
let result = tools_list_result();
let tools = result["tools"].as_array().unwrap();
for name in ["apply_onboarding_pack", "verify_onboarding"] {
let tool = tools.iter().find(|t| t["name"] == name).unwrap();
let required = tool["inputSchema"]["required"].as_array().unwrap();
let fields: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
assert!(fields.contains(&"project_id"), "{name} 应必填 project_id");
}
}
}