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;