From 1daef98af30031238e2ec3e121af222f479e0fbe Mon Sep 17 00:00:00 2001 From: lanrtop Date: Mon, 28 Sep 2026 01:45:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(dev-variables/mcp):=20=E6=8F=90=E4=BE=9B?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E7=BA=A7=E6=B5=8B=E8=AF=95=E8=B4=A6=E6=88=B7?= =?UTF-8?q?=E7=99=BB=E8=AE=B0=E4=B8=8E=E8=AE=A4=E8=AF=81=E8=AF=BB=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scoped MCP 只登记非秘密元数据;读取共用 bearer 门槛,并以真实本机 HTTP 合成链路覆盖授权、复用和撤销。 Rules-Applied: R07,R10 Feature-Confirmed: true --- src-tauri/src/mcp_server.rs | 156 ++++++++++++++++++++++++++++++++++-- src-tauri/src/mcp_tools.rs | 51 +++++++++++- 2 files changed, 198 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 5c888c8..61f885c 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -31,7 +31,8 @@ type Sessions = Arc>>; const MCP_LOOPBACK_HOST: &str = "127.0.0.1"; const DEV_VARIABLE_READ_TOOL: &str = "read_project_dev_variable"; -const MCP_SERVER_INSTRUCTIONS: &str = "先读取 get_diagnostics、verify_onboarding 或项目状态,再执行后续操作。项目级工具必须使用当前项目的 canonical project_id,不得跨项目猜测或改写身份。写操作仅在用户明确确认目标与影响后调用;状态 unavailable/unknown 不得当作成功。"; +const TEST_ACCOUNT_READ_TOOL: &str = "read_project_test_account"; +const MCP_SERVER_INSTRUCTIONS: &str = "先读取 get_diagnostics、verify_onboarding 或项目状态,再执行后续操作。项目级工具必须使用当前项目的 canonical project_id,不得跨项目猜测或改写身份。测试需要登录时先查询 list_project_dev_variables,已有需求就复用;未登记时可登记不含秘密的开发需求元数据,账号与密码只由用户在项目 UI 填写。其他写操作仅在用户明确确认目标与影响后调用;状态 unavailable/unknown 不得当作成功。"; static INITIALIZED_PROJECT_CLIENTS: OnceLock>> = OnceLock::new(); fn initialized_project_clients() -> &'static RwLock> { @@ -203,13 +204,16 @@ fn authorize_sensitive_request( headers: &HeaderMap, store: &impl crate::credential_store::CredentialStore, ) -> Result<(), &'static str> { + let requested_tool = req + .params + .as_ref() + .and_then(|params| params.get("name")) + .and_then(Value::as_str); if req.method != "tools/call" - || req - .params - .as_ref() - .and_then(|params| params.get("name")) - .and_then(Value::as_str) - != Some(DEV_VARIABLE_READ_TOOL) + || !matches!( + requested_tool, + Some(DEV_VARIABLE_READ_TOOL | TEST_ACCOUNT_READ_TOOL) + ) { return Ok(()); } @@ -428,6 +432,21 @@ mod tests { assert!(authorize_sensitive_request(&req, Some("p2"), &headers, &store).is_err()); assert!(authorize_sensitive_request(&req, None, &headers, &store).is_err()); + let test_account = RpcRequest { + params: Some(json!({ + "name": TEST_ACCOUNT_READ_TOOL, + "arguments": {"project_id": "p1", "name": "E2E_LOGIN"} + })), + ..rpc("tools/call") + }; + assert!( + authorize_sensitive_request(&test_account, Some("p1"), &HeaderMap::new(), &store) + .is_err() + ); + assert!(authorize_sensitive_request(&test_account, Some("p1"), &headers, &store).is_ok()); + assert!(authorize_sensitive_request(&test_account, Some("p2"), &headers, &store).is_err()); + assert!(authorize_sensitive_request(&test_account, None, &headers, &store).is_err()); + let mut implicit_project = req; implicit_project.params.as_mut().unwrap()["arguments"] = json!({"name": "E2E_PASSWORD"}); assert!( @@ -584,6 +603,12 @@ mod tests { project_id, "E2E_PASSWORD", ); + let _ = crate::commands::dev_variables::revoke( + &conn, + &store, + project_id, + "E2E_LOGIN", + ); let _ = conn .execute("DELETE FROM project_workspaces WHERE id=?1", [project_id]); let _ = @@ -655,6 +680,8 @@ mod tests { .filter_map(|tool| tool["name"].as_str()) .collect(); assert!(tool_names.contains(&"read_project_dev_variable")); + assert!(tool_names.contains(&"register_project_test_account")); + assert!(tool_names.contains(&"read_project_test_account")); assert!(!tool_names .iter() .any(|name| name.contains("get_secret") || name.contains("export_env"))); @@ -666,6 +693,9 @@ mod tests { assert!(!global_catalog .to_string() .contains("read_project_dev_variable")); + assert!(!global_catalog + .to_string() + .contains("read_project_test_account")); let register = ureq::post(&p1_url) .send_json(call_body( "register_project_dev_variable", @@ -747,6 +777,118 @@ mod tests { .unwrap(); assert_eq!(after_revoke["result"]["isError"], true); assert!(!after_revoke.to_string().contains("qa-only-http-value")); + + let account_register = ureq::post(&p1_url) + .send_json(call_body( + "register_project_test_account", + json!({"name":"E2E_LOGIN","service":"enterprise-system","environment":"test","purpose":"合成登录验收"}), + )) + .unwrap() + .into_json::() + .unwrap(); + assert!(account_register["result"]["isError"].is_null()); + let account_pending = ureq::post(&p1_url) + .send_json(call_body("list_project_dev_variables", json!({}))) + .unwrap() + .into_json::() + .unwrap(); + let pending_text = account_pending["result"]["content"][0]["text"] + .as_str() + .unwrap(); + assert!(pending_text.contains("\"kind\":\"test_account\"")); + assert!(pending_text.contains("\"configured\":false")); + assert!(!pending_text.contains("qa-only-account-user")); + { + let conn = db::pool().get().unwrap(); + crate::commands::dev_variables::save_test_account( + &conn, + &store, + &p1, + "E2E_LOGIN", + "qa-only-account-user", + "qa-only-account-password", + ) + .unwrap(); + } + let account_registered_again = ureq::post(&p1_url) + .send_json(call_body( + "register_project_test_account", + json!({"name":"E2E_LOGIN","service":"enterprise-system","environment":"test","purpose":"合成登录验收"}), + )) + .unwrap() + .into_json::() + .unwrap(); + assert!(account_registered_again["result"]["isError"].is_null()); + let account_ready = ureq::post(&p1_url) + .send_json(call_body("list_project_dev_variables", json!({}))) + .unwrap() + .into_json::() + .unwrap(); + let ready_text = account_ready["result"]["content"][0]["text"] + .as_str() + .unwrap(); + assert_eq!(ready_text.matches("\"name\":\"E2E_LOGIN\"").count(), 1); + assert!(ready_text.contains("\"configured\":true")); + assert!(!ready_text.contains("qa-only-account-user")); + assert!(!ready_text.contains("qa-only-account-password")); + let account_read = call_body("read_project_test_account", json!({"name":"E2E_LOGIN"})); + assert!(matches!( + ureq::post(&p1_url).send_json(account_read.clone()), + Err(ureq::Error::Status(403, _)) + )); + assert!(matches!( + ureq::post(&p1_url) + .set("Authorization", "Bearer wrong") + .send_json(account_read.clone()), + Err(ureq::Error::Status(403, _)) + )); + assert!(matches!( + ureq::post(&p2_url) + .set("Authorization", &format!("Bearer {token}")) + .send_json(account_read.clone()), + Err(ureq::Error::Status(403, _)) + )); + assert!(matches!( + ureq::post(&global_url) + .set("Authorization", &format!("Bearer {token}")) + .send_json(account_read.clone()), + Err(ureq::Error::Status(403, _)) + )); + let account = ureq::post(&p1_url) + .set("Authorization", &format!("Bearer {token}")) + .send_json(account_read.clone()) + .unwrap() + .into_json::() + .unwrap(); + let value: Value = + serde_json::from_str(account["result"]["content"][0]["text"].as_str().unwrap()) + .unwrap(); + assert_eq!(value["username"], "qa-only-account-user"); + assert_eq!(value["password"], "qa-only-account-password"); + let scalar_read = ureq::post(&p1_url) + .set("Authorization", &format!("Bearer {token}")) + .send_json(call_body( + "read_project_dev_variable", + json!({"name":"E2E_LOGIN"}), + )) + .unwrap() + .into_json::() + .unwrap(); + assert_eq!(scalar_read["result"]["isError"], true); + { + let conn = db::pool().get().unwrap(); + crate::commands::dev_variables::revoke(&conn, &store, &p1, "E2E_LOGIN").unwrap(); + } + let account_after_revoke = ureq::post(&p1_url) + .set("Authorization", &format!("Bearer {token}")) + .send_json(account_read) + .unwrap() + .into_json::() + .unwrap(); + assert_eq!(account_after_revoke["result"]["isError"], true); + assert!(!account_after_revoke + .to_string() + .contains("qa-only-account-password")); server.abort(); } diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index fe54e1e..90f9ccf 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -423,21 +423,42 @@ pub fn tools_list_result_for_project(project_id: &str) -> Value { },"required":["name","purpose"]}, "annotations":{"readOnlyHint":false,"destructiveHint":false} }), + json!({ + "name": "register_project_test_account", + "description": "仅登记本项目非生产测试登录账户的需求卡,不提交账号或密码。重复相同名称和语义返回同一张卡;用户随后在炼境项目 UI 一次填写。", + "inputSchema": {"type":"object","properties":{ + "project_id":{"type":"string"}, + "name":{"type":"string","description":"稳定需求名,例如 E2E_LOGIN"}, + "service":{"type":"string","description":"目标系统名称,不含账号或密码"}, + "environment":{"type":"string","description":"非生产测试环境名称"}, + "purpose":{"type":"string","description":"本账户用于什么测试,不含秘密"} + },"required":["name","service","environment","purpose"]}, + "annotations":{"readOnlyHint":false,"destructiveHint":false} + }), json!({ "name": "list_project_dev_variables", - "description": "查询本项目开发变量的待填写/已配置状态,不返回值或凭证引用。", + "description": "测试登录前先查询本项目开发需求状态。返回名称、类型、目标系统、环境、用途和待填写/已配置状态,不返回值或凭证引用;已有需求请复用,勿重复登记。", "inputSchema": {"type":"object","properties":{"project_id":{"type":"string"}},"required":[]}, "annotations":{"readOnlyHint":true} }), json!({ "name": "read_project_dev_variable", - "description": "经本机认证后单项读取本项目已由用户填写的开发变量。返回值会进入 Agent 会话;仅用于非生产、低权限、可重置的开发数据。", + "description": "经本机认证后单项读取本项目已由用户填写的标量开发变量;测试账户请使用 read_project_test_account。返回值会进入 Agent 会话;仅用于非生产、低权限、可重置的开发数据。", "inputSchema": {"type":"object","properties":{ "project_id":{"type":"string"}, "name":{"type":"string"} },"required":["name"]}, "annotations":{"readOnlyHint":true} }), + json!({ + "name": "read_project_test_account", + "description": "经本机认证后读取本项目已由用户填写的单张非生产测试账户卡,返回 username/password 给 Agent 用于当前测试。不要写入仓库、日志或普通聊天摘要。", + "inputSchema": {"type":"object","properties":{ + "project_id":{"type":"string"}, + "name":{"type":"string","description":"已登记的测试账户需求名"} + },"required":["name"]}, + "annotations":{"readOnlyHint":true} + }), ]); for tool in tools { let Some(schema) = tool.get_mut("inputSchema").and_then(Value::as_object_mut) else { @@ -631,8 +652,10 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option, project_scope: Option { + let project_id = args.get("project_id").and_then(Value::as_str).unwrap_or(""); + let name = args.get("name").and_then(Value::as_str).unwrap_or(""); + let service = args.get("service").and_then(Value::as_str).unwrap_or(""); + let environment = args.get("environment").and_then(Value::as_str).unwrap_or(""); + let purpose = args.get("purpose").and_then(Value::as_str).unwrap_or(""); + let conn = db::pool().get().map_err(|_| "数据库不可用".to_string())?; + crate::commands::dev_variables::register_test_account( + &conn, project_id, name, service, environment, purpose, + )?; + Ok("已登记测试账户需求;等待用户在炼境项目 UI 一次填写账号与密码".to_string()) + } "list_project_dev_variables" => { let project_id = args.get("project_id").and_then(Value::as_str).unwrap_or(""); let conn = db::pool().get().map_err(|_| "数据库不可用".to_string())?; @@ -675,6 +710,18 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option { + let project_id = args.get("project_id").and_then(Value::as_str).unwrap_or(""); + let name = args.get("name").and_then(Value::as_str).unwrap_or(""); + let conn = db::pool().get().map_err(|_| "数据库不可用".to_string())?; + let account = crate::commands::dev_variables::read_test_account( + &conn, + &crate::credential_store::WindowsCredentialStore, + project_id, + name, + )?; + serde_json::to_string(&account).map_err(|_| "序列化测试账户失败".to_string()) + } "get_diagnostics" => match scope_for_call.as_deref() { Some(project_id) => get_scoped_diagnostics(project_id), None => get_diagnostics(),