feat(mcp): synchronise les permissions MCP avec les tools réellement exposés

Les permissions déclarées ne reflétaient pas toujours les tools
effectivement exposés aux agents (contexte requester/projet lié après
coup). Ajoute ToolInvoker::tools_for_bound_context/tools_for_context
pour exposer la liste effective au moment de l'injection dans une
requête OpenAI-compatible, et propage le calcul dans la policy MCP,
le serveur, la factory de session et l'adapter openai_compat.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 15:57:45 +02:00
parent 5955ea37a2
commit 21a84ab8f2
8 changed files with 462 additions and 223 deletions

View File

@ -4967,59 +4967,14 @@ mod mcp_serve_peer_tests {
assert_eq!(resp["id"], json!(1));
let tools = resp["result"]["tools"].as_array().expect("tools array");
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
for expected in [
"idea_list_agents",
"idea_ask_agent",
"idea_ask_agents",
"idea_run_in_background",
"idea_launch_agent",
"idea_stop_agent",
"idea_update_context",
"idea_create_skill",
// FileGuard-mediated context/memory tools (cadrage C7).
"idea_context_read",
"idea_context_propose",
"idea_memory_read",
"idea_memory_write",
// Skill-awareness : lecture à la demande du corps d'un skill.
"idea_skill_read",
// Conversation inter-agent headless : réponse inline capturée depuis le Final.
"idea_ask_agent",
// Live-state (programme live-state, lot LS4).
"idea_workstate_read",
"idea_workstate_set",
// Public ticket tools (Issue domain).
"idea_ticket_create",
"idea_ticket_read",
"idea_ticket_list",
"idea_ticket_update",
"idea_ticket_update_status",
"idea_ticket_update_priority",
"idea_ticket_bulk_update_status",
"idea_ticket_bulk_update_priority",
"idea_ticket_bulk_delete",
"idea_ticket_read_carnet",
"idea_ticket_update_carnet",
"idea_ticket_link",
"idea_ticket_unlink",
"idea_sprint_list",
// Public template tools.
"idea_template_list",
"idea_template_read",
"idea_template_create",
"idea_template_update",
"idea_template_delete",
] {
assert!(
names.contains(&expected),
"missing tool {expected}; got {names:?}"
);
}
let expected_names: Vec<&str> = infrastructure::orchestrator::mcp::catalogue()
.into_iter()
.map(|tool| tool.name)
.collect();
assert!(!names.contains(&"idea_reply"));
assert_eq!(
tools.len(),
34,
"exactly the thirty-four exposed idea_* tools; got {names:?}"
names, expected_names,
"tools/list must expose exactly the canonical MCP catalogue"
);
drop(client); // EOF ⇒ serve loop ends

View File

@ -13,7 +13,7 @@ use domain::ports::{
AgentToolPolicyStore, McpToolPermissionStore, ProjectStore, ToolInvocationError, ToolInvoker,
ToolSpec,
};
use domain::{AgentId, AgentToolPolicy, IssueRef, McpToolPolicy, Project};
use domain::{AgentToolPolicy, IssueRef, McpToolPolicy, Project};
use infrastructure::{TemplateToolProvider, TicketToolProvider};
use serde_json::Value;
@ -62,6 +62,22 @@ impl ToolInvoker for LateBoundOpenAiToolInvoker {
.map_or_else(Vec::new, |inner| inner.tools())
}
async fn tools_for_context(
&self,
project_root: &str,
requester: &str,
) -> Result<Vec<ToolSpec>, ToolInvocationError> {
let inner = self
.inner
.lock()
.expect("mutex sain")
.clone()
.ok_or_else(|| {
ToolInvocationError::Execution("ToolInvoker OpenAI non initialisé".to_owned())
})?;
inner.tools_for_context(project_root, requester).await
}
async fn call(&self, name: &str, args_json: &str) -> Result<String, ToolInvocationError> {
let inner = self
.inner
@ -102,14 +118,38 @@ impl ToolInvoker for AppOpenAiToolInvoker {
fn tools(&self) -> Vec<ToolSpec> {
infrastructure::orchestrator::mcp::catalogue()
.into_iter()
.map(|tool| ToolSpec {
name: tool.name.to_owned(),
description: tool.description.to_owned(),
input_schema: tool.input_schema,
})
.map(tool_def_to_spec)
.collect()
}
async fn tools_for_context(
&self,
project_root: &str,
requester: &str,
) -> Result<Vec<ToolSpec>, ToolInvocationError> {
let ephemeral_policy = self.policies.get_policy(requester);
let project = self.find_project(project_root).await?;
let surface = infrastructure::orchestrator::mcp::policy::resolve_effective_tool_surface(
Some(self.mcp_tool_permissions.as_ref()),
&project,
requester,
ephemeral_policy.as_ref(),
)
.await
.map_err(|e| {
ToolInvocationError::Execution(format!("failed to resolve MCP tool permissions: {e}"))
})?;
Ok(
infrastructure::orchestrator::mcp::policy::effective_tool_catalogue(
ephemeral_policy.as_ref(),
&surface,
)
.into_iter()
.map(tool_def_to_spec)
.collect(),
)
}
async fn call(&self, name: &str, args_json: &str) -> Result<String, ToolInvocationError> {
let value: Value = serde_json::from_str(args_json)
.map_err(|e| ToolInvocationError::InvalidArguments(format!("JSON invalide: {e}")))?;
@ -140,18 +180,7 @@ impl ToolInvoker for AppOpenAiToolInvoker {
if let Some(policy) = &ephemeral_policy {
enforce_policy(policy, &requester, name, &value)?;
}
let project = self
.projects
.list_projects()
.await
.map_err(|e| ToolInvocationError::Execution(e.to_string()))?
.into_iter()
.find(|project| project.root.as_str() == project_root)
.ok_or_else(|| {
ToolInvocationError::Execution(format!(
"projet introuvable pour root `{project_root}`"
))
})?;
let project = self.find_project(&project_root).await?;
enforce_durable_tool_policy(
self.mcp_tool_permissions.as_ref(),
&project,
@ -209,6 +238,30 @@ impl ToolInvoker for AppOpenAiToolInvoker {
}
}
impl AppOpenAiToolInvoker {
async fn find_project(&self, project_root: &str) -> Result<Project, ToolInvocationError> {
self.projects
.list_projects()
.await
.map_err(|e| ToolInvocationError::Execution(e.to_string()))?
.into_iter()
.find(|project| project.root.as_str() == project_root)
.ok_or_else(|| {
ToolInvocationError::Execution(format!(
"projet introuvable pour root `{project_root}`"
))
})
}
}
fn tool_def_to_spec(tool: infrastructure::orchestrator::mcp::ToolDef) -> ToolSpec {
ToolSpec {
name: tool.name.to_owned(),
description: tool.description.to_owned(),
input_schema: tool.input_schema,
}
}
async fn enforce_durable_tool_policy(
store: &dyn McpToolPermissionStore,
project: &Project,
@ -239,38 +292,17 @@ async fn durable_tool_policy(
ephemeral_policy: Option<&AgentToolPolicy>,
requester: &str,
) -> Result<Option<McpToolPolicy>, ToolInvocationError> {
let known_tools = infrastructure::orchestrator::mcp::tools::classified_tool_names();
let policy = if let Some(agent_id) = requester_agent_id(requester) {
let doc = store
.load_mcp_tool_permissions(project)
.await
.map_err(|e| {
ToolInvocationError::Execution(format!("failed to load MCP tool permissions: {e}"))
})?;
doc.effective_policy(
agent_id,
infrastructure::orchestrator::mcp::tools::READ_ONLY_TOOLS,
&known_tools,
)
.map_err(|e| ToolInvocationError::Execution(format!("invalid MCP tool permissions: {e}")))?
} else if requester.is_empty() || requester == "mcp" || ephemeral_policy.is_none() {
McpToolPolicy::read_only(
infrastructure::orchestrator::mcp::tools::READ_ONLY_TOOLS,
&known_tools,
)
.map_err(|e| {
ToolInvocationError::Execution(format!("invalid read-only MCP tool fallback: {e}"))
})?
} else {
return Ok(None);
};
Ok(Some(policy))
}
fn requester_agent_id(requester: &str) -> Option<AgentId> {
uuid::Uuid::parse_str(requester)
.ok()
.map(AgentId::from_uuid)
infrastructure::orchestrator::mcp::policy::resolve_effective_tool_surface(
Some(store),
project,
requester,
ephemeral_policy,
)
.await
.map(|surface| surface.durable_policy)
.map_err(|e| {
ToolInvocationError::Execution(format!("failed to resolve MCP tool permissions: {e}"))
})
}
#[cfg(test)]
@ -796,6 +828,42 @@ mod tests {
let _ = std::fs::remove_dir_all(temp);
}
#[tokio::test]
async fn openai_tools_list_matches_durable_agent_policy() {
let temp =
std::env::temp_dir().join(format!("idea-openai-mcp-tools-list-{}", Uuid::new_v4()));
let core = crate::BackendCore::build(temp.clone());
let agent = AgentId::from_uuid(Uuid::from_u128(182));
let invoker = AppOpenAiToolInvoker::new(
Arc::clone(&core.orchestrator_service),
Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>,
Arc::new(FakePolicies::default()),
mcp_permissions(allow_doc(
agent,
&["idea_ticket_list", "idea_ticket_update_carnet"],
)),
Arc::new(FakeTicketTools::default()),
Arc::new(FakeTemplateTools::default()),
);
let tools = invoker
.tools_for_context("/tmp/project", &agent.to_string())
.await
.expect("tools list should resolve durable policy");
let names = tools
.iter()
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>();
assert!(names.contains(&"idea_ticket_list"));
assert!(names.contains(&"idea_ticket_update_carnet"));
assert!(!names.contains(&"idea_ask_agent"));
assert!(!names.contains(&"idea_ticket_update"));
assert!(!names.contains(&"idea_run_in_background"));
let _ = std::fs::remove_dir_all(temp);
}
#[tokio::test]
async fn openai_durable_agent_override_allows_explicit_write_tool() {
let temp =