From c84a66f4e803b4e231bbed207852276a036d3499 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 18 Jul 2026 23:15:05 +0200 Subject: [PATCH] feat(backend): API Tauri pour la gestion des permissions tools MCP (#82 lot B4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose au niveau application/DTO/commandes Tauri le catalogue et les permissions des tools MCP (application/mcp_tool_permissions.rs, dto.rs, commands.rs) pour une future UI de gestion. Lot B4 du ticket #82, dernier lot backend : ferme la boucle sur B1 (domaine/store) + B2 (enforcement MCP stdio) + B3 (parité OpenAI-compatible). QA vert. Co-Authored-By: Claude Sonnet 5 --- crates/app-tauri/src/commands.rs | 100 ++++- crates/app-tauri/src/lib.rs | 3 + crates/application/src/lib.rs | 7 + .../application/src/mcp_tool_permissions.rs | 402 ++++++++++++++++++ crates/backend/src/dto.rs | 123 +++++- crates/backend/src/lib.rs | 59 ++- 6 files changed, 662 insertions(+), 32 deletions(-) create mode 100644 crates/application/src/mcp_tool_permissions.rs diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 9628a12..d8ed44e 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -19,12 +19,14 @@ use application::{ LaunchAgentInput, ListAgentsInput, ListDevicesInput, ListLayoutsInput, ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput, McpRuntime, MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadConversationPageInput, - ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, - ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput, - ResolveMemoryLinksInput, RevokeDeviceInput, RotateConversationLogInput, SetActiveLayoutInput, - SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput, - UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentPermissionsInput, - UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectPermissionsInput, UpdateSkillInput, + ReadMcpToolPermissionsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, + ReconcileLayoutsInput, ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, + ResolveAgentPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput, + RotateConversationLogInput, SetActiveLayoutInput, SnapshotRunningAgentsInput, + StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, + UpdateAgentContextInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput, + UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, + UpdateProjectPermissionsInput, UpdateSkillInput, }; use domain::ports::ModelServerRuntime; use domain::ports::PtyHandle; @@ -49,17 +51,19 @@ use crate::dto::{ LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto, ModelServerConfigListDto, OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, - ProjectPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto, - ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto, - RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto, - ResolveAgentPermissionsRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto, - SaveModelServerRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, - SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto, - StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, - TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto, - UpdateAgentContextRequestDto, UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto, - UpdateProjectContextRequestDto, UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto, - UpdateTemplateRequestDto, WriteTerminalRequestDto, + ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectWorkStateDto, + ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto, + ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, + ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResumableAgentListDto, + SaveEmbedderProfileRequestDto, SaveModelServerRequestDto, SaveProfileRequestDto, + SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto, + StopLiveAgentRequestDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, + SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto, + TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto, + UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto, + UpdateMemoryRequestDto, UpdateProjectContextRequestDto, + UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto, + UpdateSkillRequestDto, UpdateTemplateRequestDto, WriteTerminalRequestDto, }; use crate::embedded_server::{ EmbeddedServerStatusDto, ServerExposurePreviewDto, ServerExposureSettingsDto, @@ -544,6 +548,68 @@ pub async fn resolve_agent_permissions( .map_err(ErrorDto::from) } +/// `get_mcp_tool_permissions` — read `.ideai/mcp-tool-permissions.json` plus catalogue. +/// +/// # Errors +/// Returns an [`ErrorDto`] on invalid project id, invalid stored policy, or store failure. +#[tauri::command] +pub async fn get_mcp_tool_permissions( + project_id: String, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&project_id, &state).await?; + state + .read_mcp_tool_permissions + .execute(ReadMcpToolPermissionsInput { project }) + .await + .map(ProjectMcpToolPermissionsDto::from) + .map_err(ErrorDto::from) +} + +/// `update_project_mcp_tool_permissions` — replace or remove project MCP tool defaults. +/// +/// # Errors +/// Returns an [`ErrorDto`] on invalid project id, invalid tool policy, or store failure. +#[tauri::command] +pub async fn update_project_mcp_tool_permissions( + request: UpdateProjectMcpToolPermissionsRequestDto, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&request.project_id, &state).await?; + state + .update_project_mcp_tool_permissions + .execute(UpdateProjectMcpToolPermissionsInput { + project, + policy: request.policy, + }) + .await + .map(ProjectMcpToolPermissionsDto::from) + .map_err(ErrorDto::from) +} + +/// `update_agent_mcp_tool_permissions` — replace or remove one agent MCP tool override. +/// +/// # Errors +/// Returns an [`ErrorDto`] on invalid ids, invalid tool policy, or store failure. +#[tauri::command] +pub async fn update_agent_mcp_tool_permissions( + request: UpdateAgentMcpToolPermissionsRequestDto, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&request.project_id, &state).await?; + let agent_id = parse_agent_id(&request.agent_id)?; + state + .update_agent_mcp_tool_permissions + .execute(UpdateAgentMcpToolPermissionsInput { + project, + agent_id, + policy: request.policy, + }) + .await + .map(ProjectMcpToolPermissionsDto::from) + .map_err(ErrorDto::from) +} + // --------------------------------------------------------------------------- // Terminals (L3) // --------------------------------------------------------------------------- diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index ba7e0f4..35b1611 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -176,6 +176,9 @@ pub fn run() { commands::update_project_permissions, commands::update_agent_permissions, commands::resolve_agent_permissions, + commands::get_mcp_tool_permissions, + commands::update_project_mcp_tool_permissions, + commands::update_agent_mcp_tool_permissions, commands::open_terminal, commands::write_terminal, commands::resize_terminal, diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 0105aed..46ce815 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -22,6 +22,7 @@ pub mod git; pub mod health; pub mod issues; pub mod layout; +pub mod mcp_tool_permissions; pub mod memory; pub mod model_server; pub mod orchestrator; @@ -105,6 +106,12 @@ pub use layout::{ SetActiveLayout, SetActiveLayoutInput, SetActiveLayoutOutput, SnapshotRunningAgents, SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput, LAYOUTS_FILE, }; +pub use mcp_tool_permissions::{ + McpToolPermissionCatalogue, ReadMcpToolPermissions, ReadMcpToolPermissionsInput, + ReadMcpToolPermissionsOutput, UpdateAgentMcpToolPermissions, + UpdateAgentMcpToolPermissionsInput, UpdateProjectMcpToolPermissions, + UpdateProjectMcpToolPermissionsInput, +}; pub use memory::{ CreateMemory, CreateMemoryInput, CreateMemoryOutput, DeleteMemory, DeleteMemoryInput, GetMemory, GetMemoryInput, GetMemoryOutput, HarvestMemoryFromTurn, ListMemories, diff --git a/crates/application/src/mcp_tool_permissions.rs b/crates/application/src/mcp_tool_permissions.rs new file mode 100644 index 0000000..2e5a398 --- /dev/null +++ b/crates/application/src/mcp_tool_permissions.rs @@ -0,0 +1,402 @@ +//! MCP tool permission use cases. +//! +//! These use cases expose the durable per-project MCP tool policy document while +//! keeping the application layer independent from the concrete MCP catalogue +//! adapter. The catalogue classification is injected by the composition root. + +use std::collections::HashSet; +use std::sync::Arc; + +use domain::ports::McpToolPermissionStore; +use domain::{ + AgentId, AgentMcpToolPolicyOverride, McpToolPermissionError, McpToolPolicy, Project, + ProjectMcpToolPermissions, +}; + +use crate::error::AppError; + +/// Read/write MCP tool catalogue classification used to validate policies. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpToolPermissionCatalogue { + /// Tools allowed by the default fallback policy. + pub read_only_tools: Vec, + /// Tools denied by default because they write, act, or execute. + pub write_action_tools: Vec, +} + +impl McpToolPermissionCatalogue { + /// Builds a catalogue from read-only and write/action tool names. + /// + /// # Errors + /// [`AppError::Invalid`] if a tool name is empty or appears twice. + pub fn new( + read_only_tools: Vec, + write_action_tools: Vec, + ) -> Result { + let catalogue = Self { + read_only_tools, + write_action_tools, + }; + catalogue.validate()?; + Ok(catalogue) + } + + /// Returns all known tool names as borrowed strings. + #[must_use] + pub fn known_tool_refs(&self) -> Vec<&str> { + self.read_only_tools + .iter() + .chain(self.write_action_tools.iter()) + .map(String::as_str) + .collect() + } + + fn validate(&self) -> Result<(), AppError> { + let mut seen = HashSet::new(); + for tool in self + .read_only_tools + .iter() + .chain(self.write_action_tools.iter()) + { + if tool.is_empty() { + return Err(AppError::Invalid( + "MCP tool name cannot be empty".to_owned(), + )); + } + if !seen.insert(tool.as_str()) { + return Err(AppError::Invalid(format!("duplicate MCP tool: {tool}"))); + } + } + Ok(()) + } +} + +/// Reads durable MCP tool permissions for a project. +pub struct ReadMcpToolPermissions { + store: Arc, + catalogue: McpToolPermissionCatalogue, +} + +impl ReadMcpToolPermissions { + /// Builds the use case. + #[must_use] + pub fn new( + store: Arc, + catalogue: McpToolPermissionCatalogue, + ) -> Self { + Self { store, catalogue } + } + + /// Executes the read. + /// + /// # Errors + /// [`AppError`] on store or validation failure. + pub async fn execute( + &self, + input: ReadMcpToolPermissionsInput, + ) -> Result { + let permissions = self.store.load_mcp_tool_permissions(&input.project).await?; + validate_doc(&permissions, &self.catalogue)?; + Ok(ReadMcpToolPermissionsOutput { + catalogue: self.catalogue.clone(), + permissions, + }) + } +} + +/// Input for [`ReadMcpToolPermissions`]. +pub struct ReadMcpToolPermissionsInput { + /// Target project. + pub project: Project, +} + +/// Output for MCP tool permission reads and writes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReadMcpToolPermissionsOutput { + /// Canonical catalogue classification. + pub catalogue: McpToolPermissionCatalogue, + /// Persisted permission document. + pub permissions: ProjectMcpToolPermissions, +} + +/// Replaces the project-wide default MCP tool policy. +pub struct UpdateProjectMcpToolPermissions { + store: Arc, + catalogue: McpToolPermissionCatalogue, +} + +impl UpdateProjectMcpToolPermissions { + /// Builds the use case. + #[must_use] + pub fn new( + store: Arc, + catalogue: McpToolPermissionCatalogue, + ) -> Self { + Self { store, catalogue } + } + + /// Executes the mutation. + /// + /// # Errors + /// [`AppError`] on store or validation failure. + pub async fn execute( + &self, + input: UpdateProjectMcpToolPermissionsInput, + ) -> Result { + validate_policy(input.policy.as_ref(), &self.catalogue)?; + let mut doc = self.store.load_mcp_tool_permissions(&input.project).await?; + doc.project_default = input.policy; + validate_doc(&doc, &self.catalogue)?; + self.store + .save_mcp_tool_permissions(&input.project, &doc) + .await?; + Ok(ReadMcpToolPermissionsOutput { + catalogue: self.catalogue.clone(), + permissions: doc, + }) + } +} + +/// Input for [`UpdateProjectMcpToolPermissions`]. +pub struct UpdateProjectMcpToolPermissionsInput { + /// Target project. + pub project: Project, + /// New project default policy. `None` removes the project default. + pub policy: Option, +} + +/// Replaces or removes one agent MCP tool policy override. +pub struct UpdateAgentMcpToolPermissions { + store: Arc, + catalogue: McpToolPermissionCatalogue, +} + +impl UpdateAgentMcpToolPermissions { + /// Builds the use case. + #[must_use] + pub fn new( + store: Arc, + catalogue: McpToolPermissionCatalogue, + ) -> Self { + Self { store, catalogue } + } + + /// Executes the mutation. + /// + /// # Errors + /// [`AppError`] on store or validation failure. + pub async fn execute( + &self, + input: UpdateAgentMcpToolPermissionsInput, + ) -> Result { + validate_policy(input.policy.as_ref(), &self.catalogue)?; + let mut doc = self.store.load_mcp_tool_permissions(&input.project).await?; + doc.agents + .retain(|override_| override_.agent_id != input.agent_id); + if let Some(policy) = input.policy { + doc.agents + .push(AgentMcpToolPolicyOverride::new(input.agent_id, policy)); + } + validate_doc(&doc, &self.catalogue)?; + self.store + .save_mcp_tool_permissions(&input.project, &doc) + .await?; + Ok(ReadMcpToolPermissionsOutput { + catalogue: self.catalogue.clone(), + permissions: doc, + }) + } +} + +/// Input for [`UpdateAgentMcpToolPermissions`]. +pub struct UpdateAgentMcpToolPermissionsInput { + /// Target project. + pub project: Project, + /// Target agent. + pub agent_id: AgentId, + /// New override policy. `None` removes the override. + pub policy: Option, +} + +fn validate_policy( + policy: Option<&McpToolPolicy>, + catalogue: &McpToolPermissionCatalogue, +) -> Result<(), AppError> { + if let Some(policy) = policy { + policy + .validate(&catalogue.known_tool_refs()) + .map_err(permission_error)?; + } + Ok(()) +} + +fn validate_doc( + doc: &ProjectMcpToolPermissions, + catalogue: &McpToolPermissionCatalogue, +) -> Result<(), AppError> { + doc.validate(&catalogue.known_tool_refs()) + .map_err(permission_error) +} + +fn permission_error(error: McpToolPermissionError) -> AppError { + AppError::Invalid(error.to_string()) +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use async_trait::async_trait; + use domain::ids::ProjectId; + use domain::ports::StoreError; + use domain::project::ProjectPath; + use domain::remote::RemoteRef; + use uuid::Uuid; + + use super::*; + + struct FakeStore { + doc: Mutex, + } + + impl FakeStore { + fn new(doc: ProjectMcpToolPermissions) -> Self { + Self { + doc: Mutex::new(doc), + } + } + } + + #[async_trait] + impl McpToolPermissionStore for FakeStore { + async fn load_mcp_tool_permissions( + &self, + _project: &Project, + ) -> Result { + Ok(self.doc.lock().unwrap().clone()) + } + + async fn save_mcp_tool_permissions( + &self, + _project: &Project, + permissions: &ProjectMcpToolPermissions, + ) -> Result<(), StoreError> { + *self.doc.lock().unwrap() = permissions.clone(); + Ok(()) + } + } + + fn catalogue() -> McpToolPermissionCatalogue { + McpToolPermissionCatalogue::new( + vec!["idea_memory_read".to_owned(), "idea_ticket_list".to_owned()], + vec!["idea_memory_write".to_owned(), "idea_ask_agent".to_owned()], + ) + .unwrap() + } + + fn project() -> Project { + Project::new( + ProjectId::from_uuid(Uuid::from_u128(1)), + "demo", + ProjectPath::new("/tmp/project").unwrap(), + RemoteRef::local(), + 1_000, + ) + .unwrap() + } + + fn agent(n: u128) -> AgentId { + AgentId::from_uuid(Uuid::from_u128(n)) + } + + fn store(doc: ProjectMcpToolPermissions) -> Arc { + Arc::new(FakeStore::new(doc)) + } + + #[tokio::test] + async fn read_returns_classified_catalogue_and_current_document() { + let policy = McpToolPolicy::new( + vec!["idea_memory_write".to_owned()], + &catalogue().known_tool_refs(), + ) + .unwrap(); + let doc = ProjectMcpToolPermissions::new( + Some(policy), + Vec::new(), + &catalogue().known_tool_refs(), + ) + .unwrap(); + let use_case = ReadMcpToolPermissions::new(store(doc.clone()), catalogue()); + + let output = use_case + .execute(ReadMcpToolPermissionsInput { project: project() }) + .await + .unwrap(); + + assert_eq!(output.permissions, doc); + assert_eq!( + output.catalogue.read_only_tools, + vec!["idea_memory_read", "idea_ticket_list"] + ); + assert_eq!( + output.catalogue.write_action_tools, + vec!["idea_memory_write", "idea_ask_agent"] + ); + } + + #[tokio::test] + async fn update_rejects_unknown_tool() { + let use_case = UpdateAgentMcpToolPermissions::new( + store(ProjectMcpToolPermissions::default()), + catalogue(), + ); + + let err = use_case + .execute(UpdateAgentMcpToolPermissionsInput { + project: project(), + agent_id: agent(7), + policy: Some(McpToolPolicy { + allowed_tools: vec!["idea_unknown".to_owned()], + }), + }) + .await + .unwrap_err(); + + assert!(matches!(err, AppError::Invalid(message) if message.contains("idea_unknown"))); + } + + #[tokio::test] + async fn update_agent_override_roundtrips_through_read() { + let shared = store(ProjectMcpToolPermissions::default()); + let update = UpdateAgentMcpToolPermissions::new(Arc::clone(&shared), catalogue()); + let read = ReadMcpToolPermissions::new(Arc::clone(&shared), catalogue()); + let agent_id = agent(9); + + update + .execute(UpdateAgentMcpToolPermissionsInput { + project: project(), + agent_id, + policy: Some( + McpToolPolicy::new( + vec!["idea_memory_write".to_owned()], + &catalogue().known_tool_refs(), + ) + .unwrap(), + ), + }) + .await + .unwrap(); + + let output = read + .execute(ReadMcpToolPermissionsInput { project: project() }) + .await + .unwrap(); + + assert_eq!(output.permissions.agents.len(), 1); + assert_eq!(output.permissions.agents[0].agent_id, agent_id); + assert_eq!( + output.permissions.agents[0].policy.allowed_tools, + vec!["idea_memory_write"] + ); + } +} diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 87a09bf..067005b 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -1479,9 +1479,12 @@ pub fn parse_profile_id(raw: &str) -> Result { use application::{ ChangeAgentProfileOutput, CreateAgentOutput, InspectConversationOutput, LaunchAgentOutput, - ListAgentsOutput, ReadAgentContextOutput, + ListAgentsOutput, ReadAgentContextOutput, ReadMcpToolPermissionsOutput, +}; +use domain::{ + Agent, AgentMcpToolPolicyOverride, EffectivePermissions, McpToolPolicy, PermissionSet, + ProjectPermissions, TerminalSession, }; -use domain::{Agent, EffectivePermissions, PermissionSet, ProjectPermissions, TerminalSession}; /// An agent crossing the wire. [`Agent`] already serialises camelCase /// (`id`, `name`, `contextPath`, `profileId`, `origin` tagged, `synchronized`), @@ -1564,6 +1567,44 @@ pub struct ProjectPermissionsDto(pub ProjectPermissions); #[serde(transparent)] pub struct EffectivePermissionsDto(pub EffectivePermissions); +/// Canonical MCP tool catalogue classification crossing the wire. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpToolCatalogueDto { + /// Tools allowed by the default read-only fallback. + pub read_only_tools: Vec, + /// Tools treated as writing/action/execution tools. + pub write_action_tools: Vec, +} + +/// Full MCP tool permission state crossing the wire. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectMcpToolPermissionsDto { + /// Document format version. + pub version: u32, + /// Canonical catalogue classification used for validation and display. + pub catalogue: McpToolCatalogueDto, + /// Optional project-wide default MCP tool policy. + pub project_default: Option, + /// Per-agent overrides. + pub agents: Vec, +} + +impl From for ProjectMcpToolPermissionsDto { + fn from(out: ReadMcpToolPermissionsOutput) -> Self { + Self { + version: out.permissions.version, + catalogue: McpToolCatalogueDto { + read_only_tools: out.catalogue.read_only_tools, + write_action_tools: out.catalogue.write_action_tools, + }, + project_default: out.permissions.project_default, + agents: out.permissions.agents, + } + } +} + /// Request DTO for updating project default permissions. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -1596,6 +1637,28 @@ pub struct ResolveAgentPermissionsRequestDto { pub agent_id: String, } +/// Request DTO for updating project default MCP tool permissions. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateProjectMcpToolPermissionsRequestDto { + /// Id of the owning project. + pub project_id: String, + /// New project MCP tool policy. `null` removes the default. + pub policy: Option, +} + +/// Request DTO for updating one agent MCP tool permission override. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateAgentMcpToolPermissionsRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Target agent id. + pub agent_id: String, + /// New agent MCP tool policy. `null` removes the override. + pub policy: Option, +} + /// Request DTO for `update_project_context`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -3493,3 +3556,59 @@ pub struct SpawnBackgroundCommandRequestDto { #[serde(default, skip_serializing_if = "Option::is_none")] pub deadline_ms: Option, } + +#[cfg(test)] +mod tests { + use application::McpToolPermissionCatalogue; + use domain::{AgentId, ProjectMcpToolPermissions}; + use serde_json::json; + use uuid::Uuid; + + use super::*; + + #[test] + fn project_mcp_tool_permissions_dto_uses_stable_camel_case_contract() { + let known_tools = ["idea_ticket_read", "idea_ticket_update"]; + let agent_id = AgentId::from_uuid(Uuid::from_u128(42)); + let output = ReadMcpToolPermissionsOutput { + catalogue: McpToolPermissionCatalogue::new( + vec!["idea_ticket_read".to_owned()], + vec!["idea_ticket_update".to_owned()], + ) + .unwrap(), + permissions: ProjectMcpToolPermissions { + version: 1, + project_default: Some( + McpToolPolicy::new(vec!["idea_ticket_read".to_owned()], &known_tools).unwrap(), + ), + agents: vec![AgentMcpToolPolicyOverride::new( + agent_id, + McpToolPolicy::new(vec!["idea_ticket_update".to_owned()], &known_tools) + .unwrap(), + )], + }, + }; + + let value = serde_json::to_value(ProjectMcpToolPermissionsDto::from(output)).unwrap(); + + assert_eq!( + value, + json!({ + "version": 1, + "catalogue": { + "readOnlyTools": ["idea_ticket_read"], + "writeActionTools": ["idea_ticket_update"] + }, + "projectDefault": { + "allowedTools": ["idea_ticket_read"] + }, + "agents": [{ + "agentId": agent_id, + "policy": { + "allowedTools": ["idea_ticket_update"] + } + }] + }) + ); + } +} diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 6b8c30a..2359943 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -28,20 +28,21 @@ use application::{ ListIssues, ListLayouts, ListMemories, ListModelServers, ListProfiles, ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, - MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, - OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice, + McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, + OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext, - ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory, ReadMemoryIndex, - ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, ReconcileLiveState, - ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameDevice, - RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions, - ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, RevokeAllDevices, RevokeDevice, - RotateConversationLog, SaveEmbedderProfile, SaveModelServer, SaveProfile, SessionLimitService, - SetActiveLayout, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, - StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession, - SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent, - UnassignTicketFromSprint, UnlinkIssues, UpdateAgentContext, UpdateAgentPermissions, - UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext, + ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, + ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, + ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, + RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, + ResolveAgentPermissions, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, + RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveEmbedderProfile, SaveModelServer, + SaveProfile, SessionLimitService, SetActiveLayout, SnapshotOpenWindows, SnapshotRunningAgents, + SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions, + SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice, + UnassignSkillFromAgent, UnassignTicketFromSprint, UnlinkIssues, UpdateAgentContext, + UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, + UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, }; @@ -944,6 +945,12 @@ pub struct BackendCore { pub tool_policy_registry: Arc, /// Durable per-agent MCP tool permission store. pub mcp_tool_permission_store: Arc, + /// Read durable MCP tool permissions plus canonical catalogue classification. + pub read_mcp_tool_permissions: Arc, + /// Replace or clear the project-wide MCP tool policy. + pub update_project_mcp_tool_permissions: Arc, + /// Replace or clear one agent MCP tool policy override. + pub update_agent_mcp_tool_permissions: Arc, /// Registre des sessions structurées (IA / cellules chat, §17.5). Partagé avec /// `LaunchAgent`/`ChangeAgentProfile` ; consommé par les commandes de chat (D4) /// pour résoudre la session vivante d'un `sessionId` et l'arrêter à la fermeture. @@ -1511,6 +1518,29 @@ impl BackendCore { Arc::new(FsMcpToolPermissionStore::new(Arc::clone(&fs_port))); let mcp_tool_permission_store_port = Arc::clone(&mcp_tool_permission_store) as Arc; + let mcp_tool_catalogue = McpToolPermissionCatalogue::new( + infrastructure::orchestrator::mcp::tools::READ_ONLY_TOOLS + .iter() + .map(|tool| (*tool).to_owned()) + .collect(), + infrastructure::orchestrator::mcp::tools::WRITE_ACTION_TOOLS + .iter() + .map(|tool| (*tool).to_owned()) + .collect(), + ) + .expect("MCP tool catalogue classification is valid"); + let read_mcp_tool_permissions = Arc::new(ReadMcpToolPermissions::new( + Arc::clone(&mcp_tool_permission_store_port), + mcp_tool_catalogue.clone(), + )); + let update_project_mcp_tool_permissions = Arc::new(UpdateProjectMcpToolPermissions::new( + Arc::clone(&mcp_tool_permission_store_port), + mcp_tool_catalogue.clone(), + )); + let update_agent_mcp_tool_permissions = Arc::new(UpdateAgentMcpToolPermissions::new( + Arc::clone(&mcp_tool_permission_store_port), + mcp_tool_catalogue, + )); // --- Skill store (L12) --- // Global skills live in the machine-local app-data dir; project skills are @@ -2446,6 +2476,9 @@ impl BackendCore { close_ticket_assistant, tool_policy_registry, mcp_tool_permission_store: mcp_tool_permission_store_port, + read_mcp_tool_permissions, + update_project_mcp_tool_permissions, + update_agent_mcp_tool_permissions, structured_sessions, create_agent, list_agents,