feat(infrastructure,backend,app-tauri,frontend): tools MCP workspace natifs read-only idea_workspace_map/list/read/search, activés par défaut et configurables dans les permissions MCP et les templates — #341 (QA verte)

This commit is contained in:
2026-09-12 19:17:09 +02:00
parent ea42d95f2e
commit 7b712f2b62
16 changed files with 1503 additions and 80 deletions

View File

@ -64,8 +64,8 @@ use infrastructure::orchestrator::mcp::{
TicketToolProvider,
};
use infrastructure::{
InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport,
SystemMillisClock, ToolPolicyRegistry,
FsWorkspaceToolProvider, InMemoryConversationRegistry, InMemoryMailbox, McpServer,
MediatedInbox, MemoryTransport, SystemMillisClock, ToolPolicyRegistry,
};
use serde_json::{json, Value};
@ -824,6 +824,11 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
// Public plugin administration tools.
"idea_plugin_install_from_directory",
"idea_plugin_reload",
// Read-only project workspace tools (#341).
"idea_workspace_map",
"idea_workspace_list",
"idea_workspace_read",
"idea_workspace_search",
] {
assert!(
names.contains(&expected),
@ -833,8 +838,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
assert!(!names.contains(&"idea_reply"));
assert_eq!(
tools.len(),
50,
"exactly the fifty exposed idea_* tools; got {names:?}"
61,
"exactly the sixty-one exposed idea_* tools; got {names:?}"
);
// Every tool advertises an object input schema.
@ -940,6 +945,14 @@ async fn requester_with_durable_store_but_no_override_sees_read_only_tools() {
assert!(names.contains(&"idea_ticket_list"));
assert!(names.contains(&"idea_template_list"));
assert!(names.contains(&"idea_template_read"));
for tool in [
"idea_workspace_map",
"idea_workspace_list",
"idea_workspace_read",
"idea_workspace_search",
] {
assert!(names.contains(&tool), "read-only fallback omitted {tool}");
}
assert!(!names.contains(&"idea_memory_write"));
assert!(!names.contains(&"idea_ask_agent"));
assert!(!names.contains(&"idea_ticket_update_carnet"));
@ -953,6 +966,76 @@ async fn requester_with_durable_store_but_no_override_sees_read_only_tools() {
assert!(!names.contains(&"idea_project_cancel_agent"));
}
#[tokio::test]
async fn workspace_tools_call_the_dedicated_provider_and_obey_explicit_policy() {
let root = std::env::temp_dir().join(format!("idea-mcp-workspace-{}", Uuid::new_v4()));
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("note.txt"), "alpha needle\nbeta\n").unwrap();
let scoped_project = Project::new(
ProjectId::from_uuid(Uuid::from_u128(1001)),
"workspace",
ProjectPath::new(root.to_string_lossy()).unwrap(),
RemoteRef::local(),
0,
)
.unwrap();
let (service, _s) = build_service(FakeContexts::new());
let agent = AgentId::from_uuid(Uuid::from_u128(341));
let allowed = McpServer::new(Arc::clone(&service), scoped_project.clone())
.with_workspace_tools(Arc::new(FsWorkspaceToolProvider::new()))
.with_mcp_tool_permissions(Arc::new(FakeMcpToolPermissionStore::new(
ProjectMcpToolPermissions::default(),
)))
.for_requester(agent.to_string());
let response = allowed
.handle_raw(&tools_call(
341,
"idea_workspace_search",
json!({"query":"needle"}),
))
.await
.expect("workspace reply");
assert!(response.error.is_none());
let result = response.result.unwrap();
assert_eq!(result["isError"], false);
let payload: Value = serde_json::from_str(result_text(&result)).unwrap();
assert_eq!(payload["matches"][0]["path"], "note.txt");
let denied = McpServer::new(service, scoped_project)
.with_workspace_tools(Arc::new(FsWorkspaceToolProvider::new()))
.with_mcp_tool_permissions(Arc::new(FakeMcpToolPermissionStore::new(allow_doc(
agent,
&["idea_memory_read"],
))))
.for_requester(agent.to_string());
let listed = denied
.handle_raw(
&serde_json::to_vec(&json!({
"jsonrpc":"2.0", "id":342, "method":"tools/list"
}))
.unwrap(),
)
.await
.unwrap()
.result
.unwrap();
assert!(!listed["tools"]
.as_array()
.unwrap()
.iter()
.any(|tool| { tool["name"] == "idea_workspace_search" }));
let rejected = denied
.handle_raw(&tools_call(
343,
"idea_workspace_search",
json!({"query":"needle"}),
))
.await
.unwrap();
assert!(rejected.error.unwrap().message.contains("not permitted"));
let _ = std::fs::remove_dir_all(root);
}
#[tokio::test]
async fn cross_project_actions_are_advertised_only_when_allowed_and_denial_is_actionable() {
let (service, _s) = build_service(FakeContexts::new());