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

@ -41,6 +41,12 @@ impl ToolInvoker for ProjectScopedToolInvoker {
self.inner.tools()
}
async fn tools_for_bound_context(&self) -> Result<Vec<ToolSpec>, ToolInvocationError> {
self.inner
.tools_for_context(&self.project_root, &self.requester)
.await
}
async fn call(&self, name: &str, args_json: &str) -> Result<String, ToolInvocationError> {
let mut value: Value = serde_json::from_str(args_json)
.map_err(|e| ToolInvocationError::InvalidArguments(format!("JSON invalide: {e}")))?;
@ -257,6 +263,7 @@ mod tests {
#[derive(Default)]
struct RecordingToolInvoker {
call: Mutex<Option<(String, String)>>,
tools_context: Mutex<Option<(String, String)>>,
}
#[async_trait]
@ -265,6 +272,20 @@ mod tests {
Vec::new()
}
async fn tools_for_context(
&self,
project_root: &str,
requester: &str,
) -> Result<Vec<ToolSpec>, ToolInvocationError> {
*self.tools_context.lock().unwrap() =
Some((project_root.to_owned(), requester.to_owned()));
Ok(vec![ToolSpec {
name: "idea_memory_read".to_owned(),
description: "Read memory".to_owned(),
input_schema: json!({"type":"object"}),
}])
}
async fn call(&self, name: &str, args_json: &str) -> Result<String, ToolInvocationError> {
*self.call.lock().unwrap() = Some((name.to_owned(), args_json.to_owned()));
Ok("ok".to_owned())
@ -299,6 +320,28 @@ mod tests {
);
}
#[tokio::test]
async fn project_scoped_tool_invoker_filters_tools_with_bound_context() {
let recorder = Arc::new(RecordingToolInvoker::default());
let invoker = ProjectScopedToolInvoker {
inner: recorder.clone(),
project_root: "/project/root".to_owned(),
requester: "agent-1".to_owned(),
};
let tools = invoker
.tools_for_bound_context()
.await
.expect("tools resolve");
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].name, "idea_memory_read");
assert_eq!(
recorder.tools_context.lock().unwrap().clone(),
Some(("/project/root".to_owned(), "agent-1".to_owned()))
);
}
#[test]
fn fallback_requester_uses_cwd_file_name_only_when_explicit_identity_absent() {
assert_eq!(fallback_requester_from_cwd("/tmp/run/7"), "7");