feat(dev-variables/mcp): 提供项目级测试账户登记与认证读取

scoped MCP 只登记非秘密元数据;读取共用 bearer 门槛,并以真实本机 HTTP 合成链路覆盖授权、复用和撤销。

Rules-Applied: R07,R10

Feature-Confirmed: true
This commit is contained in:
lanrtop 2026-09-28 01:45:23 +09:00
parent d559b7917b
commit 1daef98af3
2 changed files with 198 additions and 9 deletions

View File

@ -31,7 +31,8 @@ type Sessions = Arc<Mutex<HashMap<String, SessionEntry>>>;
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<RwLock<HashSet<String>>> = OnceLock::new();
fn initialized_project_clients() -> &'static RwLock<HashSet<String>> {
@ -203,13 +204,16 @@ fn authorize_sensitive_request(
headers: &HeaderMap,
store: &impl crate::credential_store::CredentialStore,
) -> Result<(), &'static str> {
if req.method != "tools/call"
|| req
let requested_tool = req
.params
.as_ref()
.and_then(|params| params.get("name"))
.and_then(Value::as_str)
!= Some(DEV_VARIABLE_READ_TOOL)
.and_then(Value::as_str);
if req.method != "tools/call"
|| !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::<Value>()
.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::<Value>()
.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::<Value>()
.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::<Value>()
.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::<Value>()
.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::<Value>()
.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::<Value>()
.unwrap();
assert_eq!(account_after_revoke["result"]["isError"], true);
assert!(!account_after_revoke
.to_string()
.contains("qa-only-account-password"));
server.abort();
}

View File

@ -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<Str
&& matches!(
tool_name.as_str(),
"register_project_dev_variable"
| "register_project_test_account"
| "list_project_dev_variables"
| "read_project_dev_variable"
| "read_project_test_account"
)
{
return scope_violation("开发变量仅允许项目级 MCP 连接");
@ -654,6 +677,18 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option<Str
crate::commands::dev_variables::register(&conn, project_id, name, purpose)?;
Ok("已登记开发变量需求;等待用户在炼境项目 UI 填写".to_string())
}
"register_project_test_account" => {
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<Str
name,
)
}
"read_project_test_account" => {
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(),