feat(dev-variables/mcp): 提供项目级测试账户登记与认证读取
scoped MCP 只登记非秘密元数据;读取共用 bearer 门槛,并以真实本机 HTTP 合成链路覆盖授权、复用和撤销。 Rules-Applied: R07,R10 Feature-Confirmed: true
This commit is contained in:
parent
d559b7917b
commit
1daef98af3
@ -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> {
|
||||
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::<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();
|
||||
}
|
||||
|
||||
|
||||
@ -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(),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user