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

@ -1,10 +1,12 @@
//! In-memory MCP tool policy registry.
//! MCP tool policy helpers and in-memory live policy registry.
use std::collections::HashMap;
use std::sync::RwLock;
use domain::AgentToolPolicy;
use domain::AgentToolPolicyStore;
use domain::ports::McpToolPermissionStore;
use domain::{AgentId, AgentToolPolicy, AgentToolPolicyStore, McpToolPolicy, Project, StoreError};
use super::tools::{self, ToolDef};
/// Stores per-requester MCP tool policies for live assistant sessions.
#[derive(Default)]
@ -53,6 +55,106 @@ impl AgentToolPolicyStore for ToolPolicyRegistry {
}
}
/// Effective MCP tool surface resolved for one requester.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EffectiveToolSurface {
/// Durable MCP policy when one applies.
pub durable_policy: Option<McpToolPolicy>,
/// Whether the read-only fallback was used because the requester could not be
/// resolved to an agent policy.
pub used_read_only_fallback: bool,
}
impl EffectiveToolSurface {
/// Returns whether `tool` is allowed by the durable policy, when present.
#[must_use]
pub fn permits_durable(&self, tool: &str) -> bool {
self.durable_policy
.as_ref()
.map_or(true, |policy| policy.permits(tool))
}
}
/// Resolves the durable MCP tool policy for the given requester.
///
/// This is the shared source of truth for the surfaces that expose IdeA tools to
/// agents. Agent UUID requesters use `.ideai/mcp-tool-permissions.json`;
/// anonymous/legacy requesters and non-agent requesters without an ephemeral
/// policy fall back to the canonical read-only policy. Non-agent requesters with
/// an ephemeral policy (ticket assistants) are intentionally governed only by
/// that narrower session policy.
///
/// # Errors
/// [`StoreError`] on store load failure or invalid persisted policy.
pub async fn resolve_effective_tool_surface(
store: Option<&dyn McpToolPermissionStore>,
project: &Project,
requester: &str,
ephemeral_policy: Option<&AgentToolPolicy>,
) -> Result<EffectiveToolSurface, StoreError> {
let Some(store) = store else {
return Ok(EffectiveToolSurface {
durable_policy: None,
used_read_only_fallback: false,
});
};
let known_tools = tools::classified_tool_names();
if let Some(agent_id) = requester_agent_id(requester) {
let doc = store.load_mcp_tool_permissions(project).await?;
let policy = doc
.effective_policy(agent_id, tools::READ_ONLY_TOOLS, &known_tools)
.map_err(|err| StoreError::Invalid(err.to_string()))?;
return Ok(EffectiveToolSurface {
durable_policy: Some(policy),
used_read_only_fallback: false,
});
}
if requester.is_empty() || requester == "mcp" || ephemeral_policy.is_none() {
application::diag!(
"[mcp] unresolved requester `{}` uses read-only tool fallback",
if requester.is_empty() {
"mcp"
} else {
requester
},
);
let policy = McpToolPolicy::read_only(tools::READ_ONLY_TOOLS, &known_tools)
.map_err(|err| StoreError::Invalid(err.to_string()))?;
return Ok(EffectiveToolSurface {
durable_policy: Some(policy),
used_read_only_fallback: true,
});
}
Ok(EffectiveToolSurface {
durable_policy: None,
used_read_only_fallback: false,
})
}
/// Filters the canonical catalogue by the effective ephemeral + durable policies.
#[must_use]
pub fn effective_tool_catalogue(
ephemeral_policy: Option<&AgentToolPolicy>,
surface: &EffectiveToolSurface,
) -> Vec<ToolDef> {
tools::catalogue()
.into_iter()
.filter(|tool| {
ephemeral_policy.map_or(true, |policy| policy.permits(tool.name))
&& surface.permits_durable(tool.name)
})
.collect()
}
fn requester_agent_id(requester: &str) -> Option<AgentId> {
uuid::Uuid::parse_str(requester)
.ok()
.map(AgentId::from_uuid)
}
#[cfg(test)]
mod tests {
use domain::IssueRef;

View File

@ -347,25 +347,20 @@ impl McpServer {
/// The `tools/list` result: the catalogue as MCP tool descriptors.
async fn tools_list_result(&self) -> Result<Value, JsonRpcError> {
let ephemeral_policy = self.ephemeral_tool_policy();
let durable_policy = self.durable_tool_policy().await?;
let tools: Vec<Value> = tools::catalogue()
.into_iter()
.filter(|t| {
ephemeral_policy
.as_ref()
.map_or(true, |policy| policy.permits(t.name))
&& durable_policy
.as_ref()
.map_or(true, |policy| policy.permits(t.name))
})
.map(|t| {
json!({
"name": t.name,
"description": t.description,
"inputSchema": t.input_schema,
let surface = self
.effective_tool_surface(ephemeral_policy.as_ref())
.await?;
let tools: Vec<Value> =
super::policy::effective_tool_catalogue(ephemeral_policy.as_ref(), &surface)
.into_iter()
.map(|t| {
json!({
"name": t.name,
"description": t.description,
"inputSchema": t.input_schema,
})
})
})
.collect();
.collect();
Ok(json!({ "tools": tools }))
}
@ -376,53 +371,30 @@ impl McpServer {
}
async fn durable_tool_policy(&self) -> Result<Option<McpToolPolicy>, JsonRpcError> {
let Some(store) = &self.mcp_tool_permissions else {
return Ok(None);
};
let known_tools = tools::classified_tool_names();
let policy = if let Some(agent_id) = self.requester_agent_id() {
let doc = store
.load_mcp_tool_permissions(&self.project)
.await
.map_err(|e| {
JsonRpcError::new(
error_codes::INTERNAL_ERROR,
format!("failed to load MCP tool permissions: {e}"),
)
})?;
doc.effective_policy(agent_id, tools::READ_ONLY_TOOLS, &known_tools)
.map_err(|e| {
JsonRpcError::new(
error_codes::INTERNAL_ERROR,
format!("invalid MCP tool permissions: {e}"),
)
})?
} else if self.requester.is_empty()
|| self.requester == "mcp"
|| self.ephemeral_tool_policy().is_none()
{
// Anonymous/legacy peers cannot be mapped to an agent override. Fail
// closed to the canonical read-only policy. Non-agent requesters with an
// ephemeral policy (ticket assistants) are governed by that narrower,
// session-scoped policy instead of the durable per-agent store.
McpToolPolicy::read_only(tools::READ_ONLY_TOOLS, &known_tools).map_err(|e| {
JsonRpcError::new(
error_codes::INTERNAL_ERROR,
format!("invalid read-only MCP tool fallback: {e}"),
)
})?
} else {
return Ok(None);
};
Ok(Some(policy))
let ephemeral_policy = self.ephemeral_tool_policy();
Ok(self
.effective_tool_surface(ephemeral_policy.as_ref())
.await?
.durable_policy)
}
fn requester_agent_id(&self) -> Option<AgentId> {
uuid::Uuid::parse_str(&self.requester)
.ok()
.map(AgentId::from_uuid)
async fn effective_tool_surface(
&self,
ephemeral_policy: Option<&AgentToolPolicy>,
) -> Result<super::policy::EffectiveToolSurface, JsonRpcError> {
super::policy::resolve_effective_tool_surface(
self.mcp_tool_permissions.as_deref(),
&self.project,
&self.requester,
ephemeral_policy,
)
.await
.map_err(|e| {
JsonRpcError::new(
error_codes::INTERNAL_ERROR,
format!("failed to resolve MCP tool permissions: {e}"),
)
})
}
fn enforce_durable_tool_policy(

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");

View File

@ -281,7 +281,7 @@ impl OpenAiCompatibleSession {
send_tap(&tap, &ReplyEvent::Heartbeat);
for iteration in 0..=self.config.effective_max_tool_iterations() {
let transcript = self.transcript.lock().expect("mutex sain").clone();
let tools = self.effective_tools();
let tools = self.effective_tools().await?;
let response = self.post_chat(&transcript, &tools, true).await;
let response = match response {
Ok(response) => response,
@ -330,13 +330,17 @@ impl OpenAiCompatibleSession {
unreachable!("loop returns at max_tool_iterations");
}
fn effective_tools(&self) -> Vec<ToolSpec> {
async fn effective_tools(&self) -> Result<Vec<ToolSpec>, AgentSessionError> {
if *self.tools_disabled.lock().expect("mutex sain") {
return Vec::new();
return Ok(Vec::new());
}
match &self.tool_invoker {
Some(invoker) => invoker
.tools_for_bound_context()
.await
.map_err(|e| AgentSessionError::Start(format!("résolution tools: {e}"))),
None => Ok(Vec::new()),
}
self.tool_invoker
.as_ref()
.map_or_else(Vec::new, |invoker| invoker.tools())
}
async fn post_chat(