From 5f23cf85b10f4e3291d363aaea98cf67fbea1aa2 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Thu, 24 Sep 2026 08:22:38 +0900 Subject: [PATCH] =?UTF-8?q?test(resource/broker):=20=E8=A1=A5=E9=BD=90=20s?= =?UTF-8?q?coped=20MCP=20=E5=90=88=E6=88=90=20PR=20=E7=AB=AF=E5=88=B0?= =?UTF-8?q?=E7=AB=AF=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rules-Applied: R03,R13 --- .blueprint/manifest.yaml | 4 +- .blueprint/modules/project-resource-broker.md | 9 + src-tauri/src/mcp_server.rs | 173 ++++++++++++++++++ 3 files changed, 184 insertions(+), 2 deletions(-) diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index 6b341d6..8890c4a 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -494,8 +494,8 @@ modules: area: infra status: done progress: 100 - updated: 2026-09-23 - note: "R1–R8 按已确认设计逐卡实施;仅受保护本机凭证与有界 Gitea 代办,不接管生产部署凭证。" + updated: 2026-09-24 + note: "R1–R10 的合成路径已验证,含 scoped MCP 到模拟 Gitea 的 PR 全链路;不接管真实生产密钥或部署。" position: [3000, 950] edges: diff --git a/.blueprint/modules/project-resource-broker.md b/.blueprint/modules/project-resource-broker.md index 3b89fc5..9a20fbb 100644 --- a/.blueprint/modules/project-resource-broker.md +++ b/.blueprint/modules/project-resource-broker.md @@ -68,6 +68,14 @@ progress: 100 ## 任务拆解 +### ✅ R10 — scoped MCP 合成 PR 端到端回归 +- status: done +- complexity: M +- files: src-tauri/src/mcp_server.rs +- depends: R4, R7, R8 +- acceptance: "agent_verify: [cargo test] scoped_mcp_create_pull_request_uses_synthetic_credential → 真实 127.0.0.1 MCP initialize/tools/list/tools/call 经项目作用域到模拟 Gitea;请求使用合成受保护凭据,结果仅含 PR 标识、不含 token,跨项目参数拒绝;测试后合成凭据引用被清理。[CLI] cargo test、git diff --check 通过。human_verify: 无(仅合成测试,不接触真实仓库)。" +- retrospective: "【复盘】scoped MCP 合成 PR 端到端回归|任务类型: MCP工具|复杂度: M|实际轮数: 2轮(预估 2 轮)|是否返工: 是|返工原因: 初版模拟服务无接入超时且清理守卫建立偏晚|有效策略: 内存数据库 + 随机 Windows 凭据引用 + 127.0.0.1 mock,复用真实 MCP 路由并在 Drop 中清理|遗留风险: 该串联测试仅在 Windows 执行;真实 Gitea 试点仍需单独授权|auto_applied_rules: R13|rule_effectiveness: R13=有效" + ### ✅ R9 — PR 审查修复:缓存失效、凭证删除与 readiness - status: done - complexity: M @@ -140,6 +148,7 @@ progress: 100 - R3:Windows Credential Manager 随机测试引用跨进程读取并清理;模拟存储的写、读、覆盖、删除与失败注入通过。 - R4–R6:旧值迁移、存储/数据库失败保留旧值、SQLite 迁移前备份及恢复演练、新建/更新/删除回归通过;模拟 Gitea PR 与 webhook 注册均只访问 `127.0.0.1`。 - R7–R8:内存 Enterprise fixture 经真实 loopback MCP HTTP/SSE 完成 `initialize → tools/list → tools/call`,readiness 不含凭证,跨项目参数被拒绝。 +- R10:Windows 合成项目经真实 scoped MCP `initialize → tools/list → tools/call(create_pull_request)` 到 `127.0.0.1` 模拟 Gitea;代办内部使用受保护存储的合成 token,Agent 结果仅含 PR 信息,跨项目参数被拒绝,测试后凭据引用清理;全量 Rust 测试通过(211 passed、1 ignored)。 - 未验证:真实用户 DB/凭证迁移、真实 Gitea/CI/服务器、正式安装版 UI 人工终验和真实 PR/部署;现有备份若不含当前旧值会拒绝迁移并要求人工安排新备份,不会降级明文。 ## 阶段退出标准 diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 707062f..bfacacf 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -445,6 +445,179 @@ mod tests { server.abort(); } + #[cfg(windows)] + #[tokio::test(flavor = "multi_thread")] + async fn scoped_mcp_create_pull_request_uses_synthetic_credential() { + use std::io::{BufRead, BufReader, Read, Write}; + + struct SyntheticInstanceGuard(Option); + impl Drop for SyntheticInstanceGuard { + fn drop(&mut self) { + if let Some(instance_id) = self.0.take() { + let _ = crate::commands::gitea::gitea_delete_instance(instance_id); + } + } + } + + db::init_test_pool(); + let project_id = format!("synthetic-broker-{}", uuid::Uuid::new_v4()); + let profile_id = format!("{project_id}-profile"); + let token = "qa-only-synthetic-mcp-token"; + let remote = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let remote_address = remote.local_addr().unwrap(); + let instance = crate::commands::gitea::gitea_save_instance( + crate::commands::gitea::SaveGiteaInstanceInput { + name: "synthetic MCP PR".into(), + url: format!("http://{remote_address}"), + access_token: token.into(), + is_default: Some(false), + }, + ) + .unwrap(); + let mut instance_guard = SyntheticInstanceGuard(Some(instance.id.clone())); + let reference = { + let conn = db::pool().get().unwrap(); + conn.query_row( + "SELECT access_token_ref FROM gitea_instances WHERE id = ?1", + [&instance.id], + |row| row.get::<_, String>(0), + ) + .unwrap() + }; + { + let conn = db::pool().get().unwrap(); + conn.execute( + "INSERT INTO project_profiles (id, name) VALUES (?1, 'Synthetic MCP PR')", + [&profile_id], + ) + .unwrap(); + conn.execute( + "INSERT INTO project_workspaces (id, profile_id) VALUES (?1, ?2)", + rusqlite::params![project_id, profile_id], + ) + .unwrap(); + conn.execute( + "INSERT INTO gitea_repos (id, instance_id, project_id, gitea_owner, gitea_repo_name) VALUES (?1, ?2, ?3, 'owner', 'repo')", + rusqlite::params![uuid::Uuid::new_v4().to_string(), instance.id, project_id], + ) + .unwrap(); + } + + let listener = tokio::net::TcpListener::bind(mcp_bind_addr(0)) + .await + .unwrap(); + let port = listener.local_addr().unwrap().port(); + let sessions: Sessions = Arc::new(Mutex::new(HashMap::new())); + let server = tokio::spawn(async move { + axum::serve(listener, mcp_router(sessions)).await.unwrap(); + }); + let url = format!("http://127.0.0.1:{port}/mcp/projects/{project_id}"); + let call = |method: &str, params: Value| -> Value { + ureq::post(&url) + .send_json(json!({"jsonrpc":"2.0","id":1,"method":method,"params":params})) + .unwrap() + .into_json() + .unwrap() + }; + + let init = call("initialize", json!({})); + assert_eq!(init["result"]["lianJing"]["project_id"], project_id); + let catalog = call("tools/list", json!({})); + assert!(catalog["result"]["tools"] + .as_array() + .unwrap() + .iter() + .any(|tool| tool["name"] == "create_pull_request")); + let readiness = call( + "tools/call", + json!({"name":"get_project_resource_status","arguments":{}}), + ); + assert_eq!( + readiness["result"]["structuredContent"]["gitea_pr"]["state"], + "configured" + ); + assert!(!readiness.to_string().contains(token)); + let denied = call( + "tools/call", + json!({"name":"create_pull_request","arguments":{"project_id":"other-project","head":"feature","base":"main","title":"synthetic"}}), + ); + assert_eq!( + denied["result"]["structuredContent"]["code"], + "scope_violation" + ); + + remote.set_nonblocking(true).unwrap(); + let mock = std::thread::spawn(move || { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + let (mut stream, _) = loop { + match remote.accept() { + Ok(accepted) => break accepted, + Err(error) + if error.kind() == std::io::ErrorKind::WouldBlock + && std::time::Instant::now() < deadline => + { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + Err(error) => panic!("模拟 Gitea 未收到请求: {error}"), + } + }; + stream + .set_read_timeout(Some(std::time::Duration::from_secs(3))) + .unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert!(line.starts_with("POST /api/v1/repos/owner/repo/pulls ")); + let mut authorized = false; + let mut content_length = 0; + loop { + line.clear(); + reader.read_line(&mut line).unwrap(); + if line == "\r\n" || line.is_empty() { + break; + } + if line.eq_ignore_ascii_case("authorization: token qa-only-synthetic-mcp-token\r\n") + { + authorized = true; + } + if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") { + content_length = value.trim().parse().unwrap(); + } + } + assert!(authorized, "模拟 Gitea 未收到受保护存储中的合成 token"); + let mut body = vec![0; content_length]; + reader.read_exact(&mut body).unwrap(); + let request: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(request["head"], "feature"); + let response = + r#"{"number":42,"html_url":"https://example.invalid/pr/42","title":"synthetic"}"#; + write!(stream, "HTTP/1.1 201 Created\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", response.len(), response).unwrap(); + }); + let created = call( + "tools/call", + json!({"name":"create_pull_request","arguments":{"head":"feature","base":"main","title":"synthetic"}}), + ); + mock.join().unwrap(); + assert_ne!(created["result"]["isError"], true); + let result = created["result"]["content"][0]["text"].as_str().unwrap(); + assert_eq!( + serde_json::from_str::(result).unwrap()["pr_number"], + 42 + ); + assert!(!created.to_string().contains(token)); + assert!(!created.to_string().contains("access_token")); + server.abort(); + + crate::commands::gitea::gitea_delete_instance(instance.id).unwrap(); + instance_guard.0 = None; + assert!(crate::credential_store::CredentialStore::get( + &crate::credential_store::WindowsCredentialStore, + &reference, + ) + .unwrap() + .is_none()); + } + #[tokio::test] async fn readiness_probe_does_not_satisfy_client_reload_evidence() { let project_id = "reload-evidence-fixture";