From e283f4a9e45e7c2d3e22bd7e208b6655c14ab5a7 Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 9 Sep 2026 19:52:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(agents,plugins):=20surface=20unifi=C3=A9e?= =?UTF-8?q?=20Capacit=C3=A9s=20de=20l'agent=20=E2=80=94=20skills=20et=20to?= =?UTF-8?q?ols=20MCP=20natifs=20+=20plugins=20#296=20(domain:=20PluginMcpT?= =?UTF-8?q?oolMetadata=20dans=20PluginMcpServerContribution,=20validation?= =?UTF-8?q?=20h=C3=B4te=20align=C3=A9e=20SDK=20=E2=80=94=20name=20requis?= =?UTF-8?q?=20non=20vide,=20description=20optionnelle=20non=20vide,=20doub?= =?UTF-8?q?lons=20rejet=C3=A9s;=20application:=20McpToolPermissionCatalogu?= =?UTF-8?q?e.vue=20structur=C3=A9e=20additive=20tools=20{name,=20descripti?= =?UTF-8?q?on,=20access=20read|writeAction}=20enrichie=20des=20description?= =?UTF-8?q?s=20canoniques=20infrastructure=20orchestrator=20mcp=20tools,?= =?UTF-8?q?=20use=20case=20SetAgentMcpToolAllowed=20=E2=80=94=20toggle=20a?= =?UTF-8?q?tomique=20idempotente=20d'un=20tool=20natif=20dans=20l'allowlis?= =?UTF-8?q?t=20explicite=20d'un=20agent,=20refus=20unknown=20tool=20sans?= =?UTF-8?q?=20mutation,=20overrides=20vides=20retir=C3=A9s;=20backend=20DT?= =?UTF-8?q?O:=20catalogue.tools=20additive=20+=20SetAgentMcpToolAllowedReq?= =?UTF-8?q?uestDto,=20wire=20camelCase;=20app-tauri=20+=20web-server:=20co?= =?UTF-8?q?mmande=20set=5Fagent=5Fmcp=5Ftool=5Fallowed;=20frontend:=20Agen?= =?UTF-8?q?tPluginCapabilities=20devient=20la=20surface=20unifi=C3=A9e=20?= =?UTF-8?q?=E2=80=94=20skills=20natifs=20assign/unassign,=20tools=20natifs?= =?UTF-8?q?=20via=20setAgentMcpToolAllowed=20(catalogue=20structur=C3=A9?= =?UTF-8?q?=20avec=20fallback=20legacy),=20skills/tools=20plugins=20avec?= =?UTF-8?q?=20metadata=20statiques=20mcpServers[].tools=20et=20saisie=20ma?= =?UTF-8?q?nuelle=20fallback=20pour=20serveurs=20sans=20catalogue,=20tombs?= =?UTF-8?q?tones=20indisponibles,=20banni=C3=A8re=20red=C3=A9marrage=20req?= =?UTF-8?q?uis=20si=20agent=20actif;=20AgentsPanel=20retire=20l'ancienne?= =?UTF-8?q?=20section=20Skills=20s=C3=A9par=C3=A9e=20au=20profit=20de=20la?= =?UTF-8?q?=20surface=20unifi=C3=A9e;=20ProjectPluginsPanel=20recentr?= =?UTF-8?q?=C3=A9=20enable/disable=20avec=20renvoi=20Capacit=C3=A9s=20des?= =?UTF-8?q?=20agents;=20adapters=20Tauri/HTTP/mock=20+=20permission.test.t?= =?UTF-8?q?s;=20QA=20verte=20reconfirm=C3=A9e=20par=20Git:=20application?= =?UTF-8?q?=20mcp=5Ftool=5Fpermissions=205/5=20+=20plugin::tests=2037/37,?= =?UTF-8?q?=20backend=20cibl=C3=A9s=203/3=20+=20checks=20domain/applicatio?= =?UTF-8?q?n/backend,=20app-tauri=20dto=2033/33=20+=20dto=5Fagents=2030/30?= =?UTF-8?q?=20+=20dto=5Fplugins=202/2,=20check=20web-server,=20npm=20test?= =?UTF-8?q?=2035/35=20+=2065/65,=20npm=20run=20build;=20suites=20compl?= =?UTF-8?q?=C3=A8tes=20rouges=20class=C3=A9es=20non=20bloquantes=20par=20Q?= =?UTF-8?q?A:=20web-server=20parall=C3=A8le=20too=20many=20open=20files=20?= =?UTF-8?q?+=207=20PTY=20s=C3=A9rie=20passant=20individuellement,=20fronte?= =?UTF-8?q?nd=2032=20=C3=A9checs=20sur=206=20fichiers=20web=20non=20modifi?= =?UTF-8?q?=C3=A9s=20reproduits=20isol=C3=A9ment)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/app-tauri/src/commands.rs | 37 +- crates/app-tauri/src/lib.rs | 1 + crates/application/src/lib.rs | 5 +- .../application/src/mcp_tool_permissions.rs | 201 ++++++++ crates/application/src/plugin/mod.rs | 108 +++- crates/backend/src/dto.rs | 62 ++- crates/backend/src/lib.rs | 61 ++- crates/domain/src/lib.rs | 11 +- crates/domain/src/plugin.rs | 15 + crates/web-server/src/lib.rs | 39 +- .../src/adapters/http/httpInvoker.test.ts | 37 ++ .../adapters/http/requestResponseGateways.ts | 10 + frontend/src/adapters/mock/index.ts | 25 + frontend/src/adapters/permission.test.ts | 30 ++ frontend/src/adapters/permission.ts | 11 + frontend/src/domain/index.ts | 10 + frontend/src/features/agents/AgentsPanel.tsx | 124 +---- .../plugins/AgentPluginCapabilities.tsx | 467 ++++++++++++++---- .../plugins/ProjectPluginSettings.test.tsx | 146 +++++- .../features/plugins/ProjectPluginsPanel.tsx | 8 +- frontend/src/features/skills/skills.test.tsx | 38 +- frontend/src/ports/index.ts | 7 + 22 files changed, 1152 insertions(+), 301 deletions(-) create mode 100644 frontend/src/adapters/permission.test.ts diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index e88b9fc5..16454173 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -27,10 +27,11 @@ use application::{ ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput, - RotateConversationLogInput, SaveChatDraftInput, SetActiveLayoutInput, SlashCommandEffect, - SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput, - UnassignSkillFromAgentInput, UpdateAgentContactableInput, UpdateAgentContextInput, - UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput, + RotateConversationLogInput, SaveChatDraftInput, SetActiveLayoutInput, + SetAgentMcpToolAllowedInput, SlashCommandEffect, SnapshotRunningAgentsInput, + StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, + UpdateAgentContactableInput, UpdateAgentContextInput, UpdateAgentEffortInput, + UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectOrchestratorInput, UpdateProjectPermissionsInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput, @@ -74,10 +75,10 @@ use crate::dto::{ ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto, ResumableAgentListDto, SaveChatDraftRequestDto, SaveEmbedderProfileRequestDto, SaveModelServerRequestDto, SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, - SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto, - SlashCommandEffectDto, SlashCommandListDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto, - SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto, - TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto, + SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SetAgentMcpToolAllowedRequestDto, + SkillDto, SkillListDto, SlashCommandEffectDto, SlashCommandListDto, StopLiveAgentRequestDto, + StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, + TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContactableRequestDto, UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto, UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto, UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto, @@ -984,6 +985,26 @@ pub async fn update_agent_mcp_tool_permissions( .map_err(ErrorDto::from) } +/// Atomically adds or removes one native MCP tool from an agent override. +#[tauri::command] +pub async fn set_agent_mcp_tool_allowed( + request: SetAgentMcpToolAllowedRequestDto, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&request.project_id, &state).await?; + state + .set_agent_mcp_tool_allowed + .execute(SetAgentMcpToolAllowedInput { + project, + agent_id: parse_agent_id(&request.agent_id)?, + tool_name: request.tool_name, + allowed: request.allowed, + }) + .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 b6ed40b6..461ab6ee 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -271,6 +271,7 @@ pub fn run() { commands::get_mcp_tool_permissions, commands::update_project_mcp_tool_permissions, commands::update_agent_mcp_tool_permissions, + commands::set_agent_mcp_tool_allowed, commands::open_terminal, commands::write_terminal, commands::resize_terminal, diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 8d9a9986..a0b8d457 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -142,8 +142,9 @@ pub use managed_temp::{ ResolveManagedTempOutput, MANAGED_TEMP_DEFAULT_TTL_MS, MANAGED_TEMP_PURPOSE_MAX_CHARS, }; pub use mcp_tool_permissions::{ - McpToolPermissionCatalogue, ReadMcpToolPermissions, ReadMcpToolPermissionsInput, - ReadMcpToolPermissionsOutput, UpdateAgentMcpToolPermissions, + McpToolCatalogueAccess, McpToolCatalogueEntry, McpToolPermissionCatalogue, + ReadMcpToolPermissions, ReadMcpToolPermissionsInput, ReadMcpToolPermissionsOutput, + SetAgentMcpToolAllowed, SetAgentMcpToolAllowedInput, UpdateAgentMcpToolPermissions, UpdateAgentMcpToolPermissionsInput, UpdateProjectMcpToolPermissions, UpdateProjectMcpToolPermissionsInput, }; diff --git a/crates/application/src/mcp_tool_permissions.rs b/crates/application/src/mcp_tool_permissions.rs index 2e5a3986..40bf796f 100644 --- a/crates/application/src/mcp_tool_permissions.rs +++ b/crates/application/src/mcp_tool_permissions.rs @@ -22,6 +22,28 @@ pub struct McpToolPermissionCatalogue { pub read_only_tools: Vec, /// Tools denied by default because they write, act, or execute. pub write_action_tools: Vec, + /// Structured additive view used by capability-management surfaces. + pub tools: Vec, +} + +/// Access class exposed alongside one native MCP tool's display metadata. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpToolCatalogueAccess { + /// Read-only capability allowed by the default fallback. + Read, + /// Mutating, executing, or otherwise action-producing capability. + WriteAction, +} + +/// Structured metadata for one native IdeA MCP tool. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpToolCatalogueEntry { + /// Stable MCP tool name. + pub name: String, + /// Human-readable description advertised through MCP `tools/list`. + pub description: String, + /// Coarse permission class. + pub access: McpToolCatalogueAccess, } impl McpToolPermissionCatalogue { @@ -33,14 +55,43 @@ impl McpToolPermissionCatalogue { read_only_tools: Vec, write_action_tools: Vec, ) -> Result { + let tools = read_only_tools + .iter() + .map(|name| McpToolCatalogueEntry { + name: name.clone(), + description: String::new(), + access: McpToolCatalogueAccess::Read, + }) + .chain(write_action_tools.iter().map(|name| McpToolCatalogueEntry { + name: name.clone(), + description: String::new(), + access: McpToolCatalogueAccess::WriteAction, + })) + .collect(); let catalogue = Self { read_only_tools, write_action_tools, + tools, }; catalogue.validate()?; Ok(catalogue) } + /// Enriches the structured view with canonical MCP descriptions while + /// retaining the legacy classified name arrays unchanged. + #[must_use] + pub fn with_descriptions<'a>( + mut self, + descriptions: impl IntoIterator, + ) -> Self { + for (name, description) in descriptions { + if let Some(tool) = self.tools.iter_mut().find(|tool| tool.name == name) { + tool.description = description.to_owned(); + } + } + self + } + /// Returns all known tool names as borrowed strings. #[must_use] pub fn known_tool_refs(&self) -> Vec<&str> { @@ -218,6 +269,89 @@ pub struct UpdateAgentMcpToolPermissionsInput { pub policy: Option, } +/// Atomically toggles one native MCP tool in an agent's explicit allowlist. +pub struct SetAgentMcpToolAllowed { + store: Arc, + catalogue: McpToolPermissionCatalogue, +} + +impl SetAgentMcpToolAllowed { + /// Builds the granular mutation use case. + #[must_use] + pub fn new( + store: Arc, + catalogue: McpToolPermissionCatalogue, + ) -> Self { + Self { store, catalogue } + } + + /// Adds or removes exactly one tool while preserving every other explicit + /// agent allow. Empty overrides are removed to keep the document sparse. + pub async fn execute( + &self, + input: SetAgentMcpToolAllowedInput, + ) -> Result { + if !self + .catalogue + .known_tool_refs() + .contains(&input.tool_name.as_str()) + { + return Err(AppError::Invalid(format!( + "unknown MCP tool: {}", + input.tool_name + ))); + } + let mut doc = self.store.load_mcp_tool_permissions(&input.project).await?; + let existing = doc + .agents + .iter() + .position(|override_| override_.agent_id == input.agent_id); + match (existing, input.allowed) { + (Some(index), true) => { + if !doc.agents[index].policy.permits(&input.tool_name) { + doc.agents[index].policy.allowed_tools.push(input.tool_name); + } + } + (Some(index), false) => { + doc.agents[index] + .policy + .allowed_tools + .retain(|tool| tool != &input.tool_name); + if doc.agents[index].policy.allowed_tools.is_empty() { + doc.agents.remove(index); + } + } + (None, true) => doc.agents.push(AgentMcpToolPolicyOverride::new( + input.agent_id, + McpToolPolicy { + allowed_tools: vec![input.tool_name], + }, + )), + (None, false) => {} + } + 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 [`SetAgentMcpToolAllowed`]. +pub struct SetAgentMcpToolAllowedInput { + /// Target project. + pub project: Project, + /// Target agent. + pub agent_id: AgentId, + /// Exact native MCP tool name. + pub tool_name: String, + /// Desired explicit allowlist membership. + pub allowed: bool, +} + fn validate_policy( policy: Option<&McpToolPolicy>, catalogue: &McpToolPermissionCatalogue, @@ -399,4 +533,71 @@ mod tests { vec!["idea_memory_write"] ); } + + #[tokio::test] + async fn granular_agent_tool_toggle_is_idempotent_and_preserves_other_tools() { + let shared = store(ProjectMcpToolPermissions::default()); + let toggle = SetAgentMcpToolAllowed::new(Arc::clone(&shared), catalogue()); + let agent_id = agent(11); + + for tool_name in ["idea_memory_write", "idea_ticket_list", "idea_memory_write"] { + toggle + .execute(SetAgentMcpToolAllowedInput { + project: project(), + agent_id, + tool_name: tool_name.to_owned(), + allowed: true, + }) + .await + .unwrap(); + } + let output = toggle + .execute(SetAgentMcpToolAllowedInput { + project: project(), + agent_id, + tool_name: "idea_memory_write".to_owned(), + allowed: false, + }) + .await + .unwrap(); + assert_eq!(output.permissions.agents.len(), 1); + assert_eq!( + output.permissions.agents[0].policy.allowed_tools, + vec!["idea_ticket_list"] + ); + + let output = toggle + .execute(SetAgentMcpToolAllowedInput { + project: project(), + agent_id, + tool_name: "idea_memory_write".to_owned(), + allowed: false, + }) + .await + .unwrap(); + assert_eq!( + output.permissions.agents[0].policy.allowed_tools, + vec!["idea_ticket_list"] + ); + } + + #[tokio::test] + async fn granular_agent_tool_toggle_rejects_unknown_tool_without_mutating() { + let shared = store(ProjectMcpToolPermissions::default()); + let toggle = SetAgentMcpToolAllowed::new(Arc::clone(&shared), catalogue()); + + let error = toggle + .execute(SetAgentMcpToolAllowedInput { + project: project(), + agent_id: agent(12), + tool_name: "idea_unknown".to_owned(), + allowed: true, + }) + .await + .unwrap_err(); + + assert!(matches!(error, AppError::Invalid(message) if message.contains("idea_unknown"))); + let loaded = shared.load_mcp_tool_permissions(&project()).await.unwrap(); + assert!(loaded.agents.is_empty()); + } } diff --git a/crates/application/src/plugin/mod.rs b/crates/application/src/plugin/mod.rs index e59e52c6..11725ac9 100644 --- a/crates/application/src/plugin/mod.rs +++ b/crates/application/src/plugin/mod.rs @@ -3416,6 +3416,16 @@ struct RawMcpServer { auto_start: bool, #[serde(default)] allow_absolute_command: bool, + #[serde(default)] + tools: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawMcpToolMetadata { + name: String, + #[serde(default)] + description: Option, } impl PluginManifestValidator for JsonPluginManifestValidator { @@ -3652,6 +3662,38 @@ fn validate_contributes(raw: RawContributes) -> Result, _>>()?; Ok(domain::PluginMcpServerContribution { id: domain::PluginMcpServerId::new(s.id) .map_err(|e| PluginManifestError::Invalid(e.to_string()))?, @@ -3663,6 +3705,7 @@ fn validate_contributes(raw: RawContributes) -> Result, _>>()?; @@ -3744,7 +3787,7 @@ mod tests { "menuItems": [{"id":"dev.acme.open","targetMenuId":"panels","label":"Open","command":"dev.acme.open"}], "slashCommands": [{"name":"/graph","shortDescription":"Open graph tools","command":"dev.acme.open","requiresConfirmation":true}], "layouts": [{"type":"dev.acme.layout","label":"Graph","component":"Graph"}], - "mcpServers": [{"id":"dev.acme.mcp","displayName":"Tools","command":"servers/tool","transport":"stdio","autoStart":true}] + "mcpServers": [{"id":"dev.acme.mcp","displayName":"Tools","command":"servers/tool","transport":"stdio","autoStart":true,"tools":[{"name":"unity_get_scene","description":"Read the active Unity scene."},{"name":"unity_run_tests"}]}] } }"#.to_vec() } @@ -4080,11 +4123,74 @@ mod tests { "skills/build-debug/SKILL.md" ); assert_eq!(m.contributes.mcp_servers.len(), 1); + assert_eq!(m.contributes.mcp_servers[0].tools.len(), 2); + assert_eq!( + m.contributes.mcp_servers[0].tools[0].name, + "unity_get_scene" + ); + assert_eq!( + m.contributes.mcp_servers[0].tools[0].description.as_deref(), + Some("Read the active Unity scene.") + ); + let wire = serde_json::to_value(&m).unwrap(); + assert_eq!( + wire["contributes"]["mcpServers"][0]["tools"], + serde_json::json!([ + {"name": "unity_get_scene", "description": "Read the active Unity scene."}, + {"name": "unity_run_tests"} + ]) + ); assert_eq!(m.contributes.slash_commands.len(), 1); assert_eq!(m.contributes.slash_commands[0].name, "/graph"); assert!(m.contributes.slash_commands[0].requires_confirmation); } + #[test] + fn validates_mcp_server_without_static_tool_metadata_for_compatibility() { + let mut value: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap(); + value["contributes"]["mcpServers"][0] + .as_object_mut() + .unwrap() + .remove("tools"); + + let manifest = validator() + .validate( + &serde_json::to_vec(&value).unwrap(), + &domain::PluginPackageRef { + plugin_id: None, + root: "x".into(), + }, + ) + .unwrap(); + + assert!(manifest.contributes.mcp_servers[0].tools.is_empty()); + let serialized = serde_json::to_value(&manifest).unwrap(); + assert!(serialized["contributes"]["mcpServers"][0] + .get("tools") + .is_none()); + } + + #[test] + fn rejects_duplicate_static_mcp_tool_metadata() { + let mut value: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap(); + value["contributes"]["mcpServers"][0]["tools"] = serde_json::json!([ + {"name": "unity_get_scene"}, + {"name": "unity_get_scene", "description": "Duplicate"} + ]); + + let error = validator() + .validate( + &serde_json::to_vec(&value).unwrap(), + &domain::PluginPackageRef { + plugin_id: None, + root: "x".into(), + }, + ) + .unwrap_err(); + + assert!(error.to_string().contains("duplicate MCP tool metadata")); + } + #[test] fn rejects_plugin_skill_outside_markdown_contract() { let mut value: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap(); diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 9476a164..8b83c0b1 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -2952,6 +2952,20 @@ pub struct McpToolCatalogueDto { pub read_only_tools: Vec, /// Tools treated as writing/action/execution tools. pub write_action_tools: Vec, + /// Structured additive catalogue for unified capability surfaces. + pub tools: Vec, +} + +/// Structured metadata for one native IdeA MCP tool. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpToolCatalogueEntryDto { + /// Stable tool name. + pub name: String, + /// Human-readable description from the canonical MCP definition. + pub description: String, + /// Coarse access class: `read` or `writeAction`. + pub access: String, } /// Full MCP tool permission state crossing the wire. @@ -2975,6 +2989,20 @@ impl From for ProjectMcpToolPermissionsDto { catalogue: McpToolCatalogueDto { read_only_tools: out.catalogue.read_only_tools, write_action_tools: out.catalogue.write_action_tools, + tools: out + .catalogue + .tools + .into_iter() + .map(|tool| McpToolCatalogueEntryDto { + name: tool.name, + description: tool.description, + access: match tool.access { + application::McpToolCatalogueAccess::Read => "read", + application::McpToolCatalogueAccess::WriteAction => "writeAction", + } + .to_owned(), + }) + .collect(), }, project_default: out.permissions.project_default, agents: out.permissions.agents, @@ -3068,6 +3096,20 @@ pub struct UpdateAgentMcpToolPermissionsRequestDto { pub policy: Option, } +/// Request for atomically toggling one native MCP tool for an agent. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetAgentMcpToolAllowedRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Target agent id. + pub agent_id: String, + /// Exact native MCP tool name. + pub tool_name: String, + /// Desired explicit allowlist membership. + pub allowed: bool, +} + /// Request DTO for `update_project_context`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -5782,7 +5824,11 @@ mod tests { vec!["idea_ticket_read".to_owned()], vec!["idea_ticket_update".to_owned()], ) - .unwrap(), + .unwrap() + .with_descriptions([ + ("idea_ticket_read", "Read one ticket."), + ("idea_ticket_update", "Update one ticket."), + ]), permissions: ProjectMcpToolPermissions { version: 1, project_default: Some( @@ -5804,7 +5850,19 @@ mod tests { "version": 1, "catalogue": { "readOnlyTools": ["idea_ticket_read"], - "writeActionTools": ["idea_ticket_update"] + "writeActionTools": ["idea_ticket_update"], + "tools": [ + { + "name": "idea_ticket_read", + "description": "Read one ticket.", + "access": "read" + }, + { + "name": "idea_ticket_update", + "description": "Update one ticket.", + "access": "writeAction" + } + ] }, "projectDefault": { "allowedTools": ["idea_ticket_read"] diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 17137b2d..4b9fae5c 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -52,16 +52,17 @@ use application::{ ResolveManagedTemp, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveChatDraft, SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveProfile, - SessionLimitService, SetActiveLayout, SetPluginEnabled, SetProjectPluginEnabled, - SlashCommandRegistry, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, - StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession, - SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent, - UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContactable, - UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions, UpdateAgentPermissions, - UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, - UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectOrchestrator, - UpdateProjectPermissions, UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate, - WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, + SessionLimitService, SetActiveLayout, SetAgentMcpToolAllowed, SetPluginEnabled, + SetProjectPluginEnabled, SlashCommandRegistry, SnapshotOpenWindows, SnapshotRunningAgents, + SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions, + SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice, + UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, + UpdateAgentContactable, UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions, + UpdateAgentPermissions, UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, + UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectMcpToolPermissions, + UpdateProjectOrchestrator, UpdateProjectPermissions, UpdateProjectSystemPermissions, + UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, + AGENT_MEMORY_RECALL_BUDGET, }; use async_trait::async_trait; use domain::events::AgentRestartPhase; @@ -291,6 +292,32 @@ mod backend_core_build_tests { let _ = std::fs::remove_dir_all(dir); } + #[tokio::test] + async fn backend_core_exposes_structured_native_mcp_catalogue_with_descriptions() { + let dir = std::env::temp_dir().join(format!("idea-mcp-catalogue-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let core = BackendCore::build(dir.clone()); + let output = core + .read_mcp_tool_permissions + .execute(application::ReadMcpToolPermissionsInput { + project: project(&dir), + }) + .await + .unwrap(); + + assert_eq!( + output.catalogue.tools.len(), + output.catalogue.read_only_tools.len() + output.catalogue.write_action_tools.len() + ); + assert!(output + .catalogue + .tools + .iter() + .all(|tool| !tool.name.is_empty() && !tool.description.is_empty())); + + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn plugin_directory_resolution_accepts_relative_directory_under_project_root() { let root = std::env::temp_dir().join(format!("idea-plugin-root-{}", Uuid::new_v4())); @@ -1939,6 +1966,8 @@ pub struct BackendCore { pub update_project_mcp_tool_permissions: Arc, /// Replace or clear one agent MCP tool policy override. pub update_agent_mcp_tool_permissions: Arc, + /// Atomically toggle one tool in an agent's explicit native MCP allowlist. + pub set_agent_mcp_tool_allowed: 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. @@ -2858,7 +2887,12 @@ impl BackendCore { .map(|tool| (*tool).to_owned()) .collect(), ) - .expect("MCP tool catalogue classification is valid"); + .expect("MCP tool catalogue classification is valid") + .with_descriptions( + infrastructure::orchestrator::mcp::tools::catalogue() + .into_iter() + .map(|tool| (tool.name, tool.description)), + ); let read_mcp_tool_permissions = Arc::new(ReadMcpToolPermissions::new( Arc::clone(&mcp_tool_permission_store_port), mcp_tool_catalogue.clone(), @@ -2868,6 +2902,10 @@ impl BackendCore { mcp_tool_catalogue.clone(), )); let update_agent_mcp_tool_permissions = Arc::new(UpdateAgentMcpToolPermissions::new( + Arc::clone(&mcp_tool_permission_store_port), + mcp_tool_catalogue.clone(), + )); + let set_agent_mcp_tool_allowed = Arc::new(SetAgentMcpToolAllowed::new( Arc::clone(&mcp_tool_permission_store_port), mcp_tool_catalogue, )); @@ -4061,6 +4099,7 @@ impl BackendCore { read_mcp_tool_permissions, update_project_mcp_tool_permissions, update_agent_mcp_tool_permissions, + set_agent_mcp_tool_allowed, structured_sessions, create_agent, list_agents, diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 3627d681..e93881df 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -240,11 +240,12 @@ pub use plugin::{ PluginBundleUrl, PluginCapability, PluginCommandId, PluginContributionSet, PluginDescriptor, PluginError, PluginId, PluginInstallSource, PluginLayoutContribution, PluginLayoutType, PluginLifecycleState, PluginManifest, PluginMcpServerContribution, PluginMcpServerId, - PluginMcpServerSpec, PluginMcpStatus, PluginMcpStatusSet, PluginMenuItemContribution, - PluginPackageRef, PluginRegistry, PluginRegistryEntry, PluginSkillContribution, PluginSkillId, - PluginSkillRef, PluginSlashCommandContribution, PluginToolRef, PluginTopLevelMenuContribution, - PluginTrustLevel, PluginVersion, ProjectPluginActivation, ProjectPluginSettings, RelativePath, - RemovalOutcome, StagedPluginPackage, + PluginMcpServerSpec, PluginMcpStatus, PluginMcpStatusSet, PluginMcpToolMetadata, + PluginMenuItemContribution, PluginPackageRef, PluginRegistry, PluginRegistryEntry, + PluginSkillContribution, PluginSkillId, PluginSkillRef, PluginSlashCommandContribution, + PluginToolRef, PluginTopLevelMenuContribution, PluginTrustLevel, PluginVersion, + ProjectPluginActivation, ProjectPluginSettings, RelativePath, RemovalOutcome, + StagedPluginPackage, }; pub use sandbox::{ diff --git a/crates/domain/src/plugin.rs b/crates/domain/src/plugin.rs index 68e711a3..de881d0d 100644 --- a/crates/domain/src/plugin.rs +++ b/crates/domain/src/plugin.rs @@ -513,6 +513,21 @@ pub struct PluginMcpServerContribution { /// Development-only escape hatch for absolute commands. #[serde(default)] pub allow_absolute_command: bool, + /// Optional static tool catalogue for capability-management UI. This does + /// not replace MCP `tools/list` runtime introspection. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tools: Vec, +} + +/// Optional static display metadata for a tool exposed by a plugin MCP server. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginMcpToolMetadata { + /// Exact MCP tool name advertised by the server. + pub name: String, + /// Optional human-readable summary for capability-management UI. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, } /// Validated contribution set. diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index 1619be79..16d0bf68 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -56,8 +56,8 @@ use application::{ ReconcileLayoutsInput, ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, RenameSprintInput, ReorderSprintsInput, ResizeTerminalInput, ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput, - RotateConversationLogInput, SetActiveLayoutInput, StopLiveAgentInput, - SyncAgentWithTemplateInput, TouchDeviceInput, UnassignSkillFromAgentInput, + RotateConversationLogInput, SetActiveLayoutInput, SetAgentMcpToolAllowedInput, + StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput, UnassignSkillFromAgentInput, UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateAgentContactableInput, UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput, @@ -99,11 +99,12 @@ use backend::dto::{ ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto, ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto, - SaveProfileRequestDto, SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, - SkillListDto, SprintCreateRequestDto, SprintDeleteRequestDto, SprintDto, SprintListDto, - SprintListRequestDto, SprintRenameRequestDto, SprintReorderRequestDto, StopLiveAgentRequestDto, - StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, - TemplateListDto, TerminalSessionDto, TicketAssignRequestDto, TicketAttachmentAddRequestDto, + SaveProfileRequestDto, SetActiveLayoutRequestDto, SetActiveLayoutResultDto, + SetAgentMcpToolAllowedRequestDto, SkillDto, SkillListDto, SprintCreateRequestDto, + SprintDeleteRequestDto, SprintDto, SprintListDto, SprintListRequestDto, SprintRenameRequestDto, + SprintReorderRequestDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto, + SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto, + TerminalSessionDto, TicketAssignRequestDto, TicketAttachmentAddRequestDto, TicketAttachmentContentDto, TicketAttachmentMarkSummarizedRequestDto, TicketAttachmentReadRequestDto, TicketCarnetDto, TicketCreateRequestDto, TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, TicketListPageInput, @@ -3437,6 +3438,9 @@ async fn invoke( "update_agent_mcp_tool_permissions" => { invoke_update_agent_mcp_tool_permissions(&request.args, &state.app).await } + "set_agent_mcp_tool_allowed" => { + invoke_set_agent_mcp_tool_allowed(&request.args, &state.app).await + } "list_memories" => invoke_list_memories(&request.args, &state.app).await, "get_memory" => invoke_get_memory(&request.args, &state.app).await, "create_memory" => invoke_create_memory(&request.args, &state.app).await, @@ -4948,6 +4952,27 @@ async fn invoke_update_agent_mcp_tool_permissions( serde_json::to_value(output).map_err(serialization_error) } +async fn invoke_set_agent_mcp_tool_allowed( + args: &Value, + state: &BackendCore, +) -> Result { + let request = + required_request::("set_agent_mcp_tool_allowed", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .set_agent_mcp_tool_allowed + .execute(SetAgentMcpToolAllowedInput { + project, + agent_id: parse_agent_id(&request.agent_id)?, + tool_name: request.tool_name, + allowed: request.allowed, + }) + .await + .map(ProjectMcpToolPermissionsDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + async fn invoke_list_memories(args: &Value, state: &BackendCore) -> Result { let project = resolve_project_readonly(string_arg(args, "projectId", "list_memories")?, state).await?; diff --git a/frontend/src/adapters/http/httpInvoker.test.ts b/frontend/src/adapters/http/httpInvoker.test.ts index f976a2aa..d6c8d197 100644 --- a/frontend/src/adapters/http/httpInvoker.test.ts +++ b/frontend/src/adapters/http/httpInvoker.test.ts @@ -11,6 +11,7 @@ import { HttpInvoker, defaultFetch } from "./httpInvoker"; import { HttpProjectGateway, HttpGitGateway, + HttpPermissionGateway, HttpTemplateGateway, } from "./requestResponseGateways"; @@ -191,6 +192,42 @@ describe("defaultFetch", () => { }); describe("request/response gateways preserve the Tauri command contract", () => { + it("HttpPermissionGateway sends the granular MCP tool toggle DTO", async () => { + const http = new HttpInvoker({ baseUrl: "https://h" }); + const response = { + version: 1, + catalogue: { + readOnlyTools: [], + writeActionTools: ["idea_ticket_update"], + tools: [{ + name: "idea_ticket_update", + description: "Update a ticket.", + access: "writeAction", + }], + }, + projectDefault: null, + agents: [], + }; + const spy = vi.spyOn(http, "invoke").mockResolvedValue(response as never); + + await expect( + new HttpPermissionGateway(http).setAgentMcpToolAllowed( + "project-1", + "agent-1", + "idea_ticket_update", + true, + ), + ).resolves.toEqual(response); + expect(spy).toHaveBeenCalledWith("set_agent_mcp_tool_allowed", { + request: { + projectId: "project-1", + agentId: "agent-1", + toolName: "idea_ticket_update", + allowed: true, + }, + }); + }); + it("HttpProjectGateway.createProject maps to create_project with a request envelope", async () => { const http = new HttpInvoker({ baseUrl: "https://h" }); const spy = vi.spyOn(http, "invoke").mockResolvedValue({ id: "p1" } as never); diff --git a/frontend/src/adapters/http/requestResponseGateways.ts b/frontend/src/adapters/http/requestResponseGateways.ts index aed8f42c..4c2dcf78 100644 --- a/frontend/src/adapters/http/requestResponseGateways.ts +++ b/frontend/src/adapters/http/requestResponseGateways.ts @@ -505,6 +505,16 @@ export class HttpPermissionGateway implements PermissionGateway { request: { projectId, agentId, policy }, }); } + setAgentMcpToolAllowed( + projectId: string, + agentId: string, + toolName: string, + allowed: boolean, + ): Promise { + return this.http.invoke("set_agent_mcp_tool_allowed", { + request: { projectId, agentId, toolName, allowed }, + }); + } } export class HttpWorkStateGateway implements WorkStateGateway { diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 30fa6713..4df0da70 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -3080,6 +3080,31 @@ export class MockPermissionGateway implements PermissionGateway { if (policy) doc.agents.push({ agentId, policy: structuredClone(policy) }); return structuredClone(doc); } + + async setAgentMcpToolAllowed( + projectId: string, + agentId: string, + toolName: string, + allowed: boolean, + ): Promise { + const doc = this.mcpDoc(projectId); + const known = new Set([ + ...doc.catalogue.readOnlyTools, + ...doc.catalogue.writeActionTools, + ]); + if (!known.has(toolName)) { + throw { code: "INVALID", message: `unknown MCP tool: ${toolName}` }; + } + const existing = doc.agents.find((entry) => entry.agentId === agentId); + const allowedTools = new Set(existing?.policy.allowedTools ?? []); + if (allowed) allowedTools.add(toolName); + else allowedTools.delete(toolName); + doc.agents = doc.agents.filter((entry) => entry.agentId !== agentId); + if (allowedTools.size > 0) { + doc.agents.push({ agentId, policy: { allowedTools: [...allowedTools] } }); + } + return structuredClone(doc); + } } export class MockWorkStateGateway implements WorkStateGateway { diff --git a/frontend/src/adapters/permission.test.ts b/frontend/src/adapters/permission.test.ts new file mode 100644 index 00000000..70d94b57 --- /dev/null +++ b/frontend/src/adapters/permission.test.ts @@ -0,0 +1,30 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const invoke = vi.fn(); +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (...args: unknown[]) => invoke(...args), +})); + +import { TauriPermissionGateway } from "./permission"; + +describe("TauriPermissionGateway invoke payloads", () => { + beforeEach(() => invoke.mockReset().mockResolvedValue({})); + + it("wraps the granular MCP tool toggle in the request DTO", async () => { + await new TauriPermissionGateway().setAgentMcpToolAllowed( + "project-1", + "agent-1", + "idea_ticket_update", + false, + ); + + expect(invoke).toHaveBeenCalledWith("set_agent_mcp_tool_allowed", { + request: { + projectId: "project-1", + agentId: "agent-1", + toolName: "idea_ticket_update", + allowed: false, + }, + }); + }); +}); diff --git a/frontend/src/adapters/permission.ts b/frontend/src/adapters/permission.ts index 8d9d9c21..a2992dce 100644 --- a/frontend/src/adapters/permission.ts +++ b/frontend/src/adapters/permission.ts @@ -107,4 +107,15 @@ export class TauriPermissionGateway implements PermissionGateway { { request: { projectId, agentId, policy } }, ); } + + setAgentMcpToolAllowed( + projectId: string, + agentId: string, + toolName: string, + allowed: boolean, + ): Promise { + return invoke("set_agent_mcp_tool_allowed", { + request: { projectId, agentId, toolName, allowed }, + }); + } } diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 64ab0f3d..85a08dda 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -918,6 +918,14 @@ export interface AgentMcpToolPolicyOverride { export interface McpToolCatalogue { readOnlyTools: string[]; writeActionTools: string[]; + /** Structured catalogue added for unified capability-management surfaces. */ + tools?: McpToolCatalogueEntry[]; +} + +export interface McpToolCatalogueEntry { + name: string; + description: string; + access: "read" | "writeAction"; } /** Full per-project MCP tool permission document, mirroring the backend DTO. */ @@ -2365,4 +2373,6 @@ export interface PluginMcpServerSummary { transport?: "stdio"; autoStart?: boolean; allowAbsoluteCommand?: boolean; + /** Optional static tool catalogue; absence means runtime-only discovery. */ + tools?: Array<{ name: string; description?: string }>; } diff --git a/frontend/src/features/agents/AgentsPanel.tsx b/frontend/src/features/agents/AgentsPanel.tsx index c0738fa5..dcee13ed 100644 --- a/frontend/src/features/agents/AgentsPanel.tsx +++ b/frontend/src/features/agents/AgentsPanel.tsx @@ -68,50 +68,6 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { void templateGw.listTemplates().then(setTemplates).catch(() => {}); } - // Skills available for assignment (global + project), loaded lazily once. - const skillGw = gateways.skill ?? null; - const [skills, setSkills] = useState([]); - const [skillsLoaded, setSkillsLoaded] = useState(false); - const [skillToAssign, setSkillToAssign] = useState(""); - - async function refreshSkills() { - if (!skillGw) return; - try { - const [globals, projects] = await Promise.all([ - skillGw.listSkills(projectId, "global"), - skillGw.listSkills(projectId, "project"), - ]); - setSkills([...projects, ...globals]); - } catch { - // Skills are optional — leave the list empty on failure. - } - } - - if (!skillsLoaded && skillGw) { - setSkillsLoaded(true); - void refreshSkills(); - } - - async function handleAssignSkill() { - if (!skillGw || !vm.selectedAgentId || !skillToAssign) return; - const target = skills.find((s) => s.id === skillToAssign); - if (!target) return; - await skillGw.assignSkill( - projectId, - vm.selectedAgentId, - target.id, - target.scope, - ); - setSkillToAssign(""); - await vm.refresh(); - } - - async function handleUnassignSkill(skillId: string) { - if (!skillGw || !vm.selectedAgentId) return; - await skillGw.unassignSkill(projectId, vm.selectedAgentId, skillId); - await vm.refresh(); - } - // Context editor state — local copy before Save const [editedContext, setEditedContext] = useState(vm.context); @@ -668,82 +624,16 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { )} - {/* ── Skill assignment ── */} - {selectedAgent && skillGw && ( -
-

- Skills — {selectedAgent.name} -

- - {/* Assigned skills */} - {selectedAgent.skills.length === 0 ? ( -

No skills assigned.

- ) : ( -
    - {selectedAgent.skills.map((ref) => { - const name = - skills.find((s) => s.id === ref.skillId)?.name ?? ref.skillId; - return ( -
  • - {name} - -
  • - ); - })} -
- )} - - {/* Assign selector */} -
- - !selectedAgent.skills.some((r) => r.skillId === s.id), - ) - .map((s) => ({ - value: s.id, - label: `${s.name} (${s.scope})`, - })), - ]} - /> - -
-
- )} - - {selectedAgent && ( + {selectedAgent && gateways.plugin && (
live.agentId === selectedAgent.id) + } + onAgentChanged={vm.refresh} />
)} diff --git a/frontend/src/features/plugins/AgentPluginCapabilities.tsx b/frontend/src/features/plugins/AgentPluginCapabilities.tsx index 78b26816..b3a5305f 100644 --- a/frontend/src/features/plugins/AgentPluginCapabilities.tsx +++ b/frontend/src/features/plugins/AgentPluginCapabilities.tsx @@ -1,158 +1,411 @@ -import { useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { ReactNode } from "react"; -import { Button, Input, Panel, SmallDropdown, Spinner } from "@/shared"; -import type { PluginSkillRef, PluginToolRef } from "@/domain"; +import { useGateways } from "@/app/di"; +import type { + Agent, + McpToolCatalogueEntry, + PluginSkillRef, + PluginToolRef, + ProjectMcpToolPermissions, + Skill, + SkillRef, +} from "@/domain"; +import { Button, Input, SmallDropdown, Spinner } from "@/shared"; import { useProjectPlugins } from "./useProjectPlugins"; interface AgentPluginCapabilitiesProps { projectId: string; - agentId: string; - agentName: string; + agent: Agent; + active: boolean; + onAgentChanged?: () => Promise | void; } -function skillKey(ref: PluginSkillRef): string { - return `${ref.pluginId}:${ref.skillId}`; +const pluginSkillKey = (ref: PluginSkillRef) => `${ref.pluginId}:${ref.skillId}`; +const pluginToolKey = (ref: PluginToolRef) => + `${ref.pluginId}:${ref.serverId}:${ref.toolName}`; +const nativeSkillKey = (ref: SkillRef) => `${ref.scope}:${ref.skillId}`; + +function nativeCatalogue(doc: ProjectMcpToolPermissions | null): McpToolCatalogueEntry[] { + if (!doc) return []; + if (doc.catalogue.tools?.length) return doc.catalogue.tools; + return [ + ...doc.catalogue.readOnlyTools.map((name) => ({ + name, + description: "", + access: "read" as const, + })), + ...doc.catalogue.writeActionTools.map((name) => ({ + name, + description: "", + access: "writeAction" as const, + })), + ]; } -function toolKey(ref: PluginToolRef): string { - return `${ref.pluginId}:${ref.serverId}:${ref.toolName}`; +function Origin({ children }: { children: ReactNode }) { + return {children}; +} + +function Tombstone({ + label, + origin, + onRemove, + disabled, +}: { + label: string; + origin: string; + onRemove: () => void; + disabled: boolean; +}) { + return ( +
+ + {label}{" "} + — Indisponible{" "} + {origin} + + +
+ ); } export function AgentPluginCapabilities({ projectId, - agentId, - agentName, + agent, + active, + onAgentChanged, }: AgentPluginCapabilitiesProps) { - const vm = useProjectPlugins(projectId); + const { skill: skillGateway, permission: permissionGateway } = useGateways(); + const plugins = useProjectPlugins(projectId); + const [nativeSkills, setNativeSkills] = useState([]); + const [nativeAssignments, setNativeAssignments] = useState(agent.skills); + const [permissions, setPermissions] = useState(null); + const [loadingNative, setLoadingNative] = useState(true); + const [nativeBusy, setNativeBusy] = useState(false); + const [nativeError, setNativeError] = useState(null); const [serverChoice, setServerChoice] = useState(""); const [toolName, setToolName] = useState(""); - const assignments = vm.settings?.agents.find((entry) => entry.agentId === agentId); - const assignedSkills = new Set((assignments?.skills ?? []).map(skillKey)); - const assignedTools = assignments?.tools ?? []; - const enabledIds = new Set( - (vm.settings?.plugins ?? []).filter((entry) => entry.enabled).map((entry) => entry.pluginId), + const [restartRequired, setRestartRequired] = useState(false); + + useEffect(() => setNativeAssignments(agent.skills), [agent.skills]); + + const loadNative = useCallback(async () => { + setLoadingNative(true); + setNativeError(null); + try { + const [globals, projects, permissionDoc] = await Promise.all([ + skillGateway?.listSkills(projectId, "global") ?? Promise.resolve([]), + skillGateway?.listSkills(projectId, "project") ?? Promise.resolve([]), + permissionGateway?.getMcpToolPermissions(projectId) ?? Promise.resolve(null), + ]); + setNativeSkills([...projects, ...globals]); + setPermissions(permissionDoc); + } catch (cause) { + setNativeError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setLoadingNative(false); + } + }, [permissionGateway, projectId, skillGateway]); + + useEffect(() => { + void loadNative(); + }, [loadNative]); + + function markRestartRequired() { + if (active) setRestartRequired(true); + } + + async function setNativeSkill(skill: Skill, assigned: boolean) { + if (!skillGateway) return; + setNativeBusy(true); + setNativeError(null); + try { + if (assigned) { + await skillGateway.assignSkill(projectId, agent.id, skill.id, skill.scope); + setNativeAssignments((current) => [ + ...current.filter((ref) => ref.skillId !== skill.id), + { skillId: skill.id, scope: skill.scope }, + ]); + } else { + await skillGateway.unassignSkill(projectId, agent.id, skill.id); + setNativeAssignments((current) => + current.filter((ref) => ref.skillId !== skill.id), + ); + } + await onAgentChanged?.(); + markRestartRequired(); + } catch (cause) { + setNativeError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setNativeBusy(false); + } + } + + async function removeMissingNativeSkill(ref: SkillRef) { + if (!skillGateway) return; + setNativeBusy(true); + try { + await skillGateway.unassignSkill(projectId, agent.id, ref.skillId); + setNativeAssignments((current) => + current.filter((candidate) => nativeSkillKey(candidate) !== nativeSkillKey(ref)), + ); + await onAgentChanged?.(); + markRestartRequired(); + } finally { + setNativeBusy(false); + } + } + + async function setNativeTool(name: string, allowed: boolean) { + if (!permissionGateway) return; + setNativeBusy(true); + setNativeError(null); + try { + setPermissions( + await permissionGateway.setAgentMcpToolAllowed( + projectId, + agent.id, + name, + allowed, + ), + ); + markRestartRequired(); + } catch (cause) { + setNativeError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setNativeBusy(false); + } + } + + const assignments = plugins.settings?.agents.find((entry) => entry.agentId === agent.id); + const assignedPluginSkills = new Set((assignments?.skills ?? []).map(pluginSkillKey)); + const assignedPluginTools = assignments?.tools ?? []; + const enabledPluginIds = new Set( + (plugins.settings?.plugins ?? []) + .filter((entry) => entry.enabled) + .map((entry) => entry.pluginId), ); - const enabledPlugins = vm.installed.filter((plugin) => enabledIds.has(plugin.id)); - const serverOptions = useMemo( - () => enabledPlugins.flatMap((plugin) => - (plugin.contributes.mcpServers ?? []).map((server) => ({ - value: `${plugin.id}\u0000${server.id}`, - label: `${plugin.displayName} — ${server.displayName}`, - })), + const knownPluginSkillKeys = new Set( + plugins.installed.flatMap((plugin) => + (plugin.contributes.skills ?? []).map((skill) => `${plugin.id}:${skill.id}`), ), - [enabledPlugins], + ); + const knownServerKeys = new Set( + plugins.installed.flatMap((plugin) => + (plugin.contributes.mcpServers ?? []).map((server) => `${plugin.id}:${server.id}`), + ), + ); + const staticPluginToolKeys = new Set( + plugins.installed.flatMap((plugin) => + (plugin.contributes.mcpServers ?? []).flatMap((server) => + (server.tools ?? []).map((tool) => `${plugin.id}:${server.id}:${tool.name}`), + ), + ), + ); + const noCatalogueServerKeys = new Set( + plugins.installed.flatMap((plugin) => + (plugin.contributes.mcpServers ?? []) + .filter((server) => (server.tools ?? []).length === 0) + .map((server) => `${plugin.id}:${server.id}`), + ), + ); + const stalePluginSkills = (assignments?.skills ?? []).filter( + (ref) => !knownPluginSkillKeys.has(pluginSkillKey(ref)), + ); + const stalePluginTools = assignedPluginTools.filter( + (ref) => { + const serverKey = `${ref.pluginId}:${ref.serverId}`; + return ( + !knownServerKeys.has(serverKey) || + (!noCatalogueServerKeys.has(serverKey) && + !staticPluginToolKeys.has(pluginToolKey(ref))) + ); + }, + ); + const advancedAssignedTools = assignedPluginTools.filter( + (ref) => + noCatalogueServerKeys.has(`${ref.pluginId}:${ref.serverId}`), + ); + const advancedServers = useMemo( + () => + plugins.installed.flatMap((plugin) => + (plugin.contributes.mcpServers ?? []) + .filter((server) => (server.tools ?? []).length === 0) + .map((server) => ({ + value: `${plugin.id}\u0000${server.id}`, + label: `${plugin.displayName} — ${server.displayName}`, + enabled: enabledPluginIds.has(plugin.id), + })), + ), + [plugins.installed, plugins.settings], ); - const knownSkillKeys = new Set(vm.installed.flatMap((plugin) => - (plugin.contributes.skills ?? []).map((skill) => `${plugin.id}:${skill.id}`), - )); - const knownServerKeys = new Set(vm.installed.flatMap((plugin) => - (plugin.contributes.mcpServers ?? []).map((server) => `${plugin.id}:${server.id}`), - )); - const staleSkills = (assignments?.skills ?? []).filter((ref) => !knownSkillKeys.has(skillKey(ref))); - const staleTools = assignedTools.filter( - (ref) => !knownServerKeys.has(`${ref.pluginId}:${ref.serverId}`), - ); + async function setPluginSkill(pluginId: string, skillId: string, assigned: boolean) { + await plugins.setSkillAssigned(agent.id, pluginId, skillId, assigned); + markRestartRequired(); + } - async function assignTool() { + async function setPluginTool(ref: PluginToolRef, assigned: boolean) { + await plugins.setToolAssigned({ agentId: agent.id, ...ref }, assigned); + markRestartRequired(); + } + + async function assignAdvancedTool() { const [pluginId, serverId] = serverChoice.split("\u0000"); const name = toolName.trim(); if (!pluginId || !serverId || !name) return; - await vm.setToolAssigned({ agentId, pluginId, serverId, toolName: name }, true); + await setPluginTool({ pluginId, serverId, toolName: name }, true); setToolName(""); } - if (vm.loading && !vm.settings) { - return
Chargement des capabilities de plugins…
; + const nativeAssignedKeys = new Set(nativeAssignments.map(nativeSkillKey)); + const knownNativeSkillKeys = new Set( + nativeSkills.map((skill) => `${skill.scope}:${skill.id}`), + ); + const staleNativeSkills = nativeAssignments.filter( + (ref) => !knownNativeSkillKeys.has(nativeSkillKey(ref)), + ); + const nativeTools = nativeCatalogue(permissions); + const explicitNativeTools = new Set( + permissions?.agents.find((entry) => entry.agentId === agent.id)?.policy.allowedTools ?? [], + ); + const knownNativeTools = new Set(nativeTools.map((tool) => tool.name)); + const staleNativeTools = [...explicitNativeTools].filter( + (name) => !knownNativeTools.has(name), + ); + const busy = nativeBusy || plugins.busy; + + if ((loadingNative || plugins.loading) && !permissions && !plugins.settings) { + return ( +
+ Chargement des capacités de l’agent… +
+ ); } return ( -
+

- Capabilities de plugins — {agentName} + Capacités de l'agent — {agent.name}

- Attribuez à cet agent les skills et tools MCP fournis par les plugins activés - dans les paramètres du projet. + Attribuez les skills et tools MCP disponibles pour cet agent.

- {vm.error &&

{vm.error}

} + {restartRequired && ( +

+ Relance requise pour appliquer ces changements à l’agent actif. +

+ )} + {(nativeError || plugins.error) && ( +

{nativeError ?? plugins.error}

+ )} -
-
Skills de plugins
- {enabledPlugins.every((plugin) => (plugin.contributes.skills ?? []).length === 0) ? ( -

Aucun skill fourni par les plugins activés pour ce projet.

- ) : ( - enabledPlugins.map((plugin) => { - const skills = plugin.contributes.skills ?? []; - if (skills.length === 0) return null; +
+
Skills
+ {nativeSkills.length === 0 && + plugins.installed.every( + (plugin) => (plugin.contributes.skills ?? []).length === 0, + ) && + staleNativeSkills.length === 0 && + stalePluginSkills.length === 0 && ( +

Aucun skill disponible.

+ )} + {nativeSkills.map((skill) => { + const assigned = nativeAssignedKeys.has(`${skill.scope}:${skill.id}`); + return ( + + ); + })} + {staleNativeSkills.map((ref) => ( + void removeMissingNativeSkill(ref)} /> + ))} + {plugins.installed.flatMap((plugin) => + (plugin.contributes.skills ?? []).map((skill) => { + const assigned = assignedPluginSkills.has(`${plugin.id}:${skill.id}`); + const enabled = enabledPluginIds.has(plugin.id); return ( - -
{plugin.displayName}
- {skills.map((skill) => { - const assigned = assignedSkills.has(`${plugin.id}:${skill.id}`); - return ( - - ); - })} -
+ ); - }) + }), )} - {staleSkills.map((ref) => ( -
- {ref.pluginId} · {ref.skillId} — Obsolète ou manquant - -
+ {stalePluginSkills.map((ref) => ( + void setPluginSkill(ref.pluginId, ref.skillId, false)} /> ))}
-
-
Tools MCP de plugins
- {assignedTools.length === 0 ? ( -

Aucun tool MCP de plugin attribué à l’agent.

- ) : ( -
    - {assignedTools.map((ref) => { - const stale = staleTools.some((candidate) => toolKey(candidate) === toolKey(ref)); +
    +
    Tools MCP
    + {nativeTools.length === 0 && + staticPluginToolKeys.size === 0 && + advancedAssignedTools.length === 0 && + stalePluginTools.length === 0 && + advancedServers.length === 0 && ( +

    Aucun tool MCP disponible.

    + )} + {nativeTools.map((tool) => ( + + ))} + {staleNativeTools.map((name) => ( + void setNativeTool(name, false)} /> + ))} + {plugins.installed.flatMap((plugin) => + (plugin.contributes.mcpServers ?? []).flatMap((server) => + (server.tools ?? []).map((tool) => { + const ref = { pluginId: plugin.id, serverId: server.id, toolName: tool.name }; + const assigned = assignedPluginTools.some((candidate) => pluginToolKey(candidate) === pluginToolKey(ref)); + const enabled = enabledPluginIds.has(plugin.id); return ( -
  • - {ref.pluginId} · {ref.serverId} · {ref.toolName}{stale && — Obsolète ou manquant} - -
  • + ); - })} -
+ }), + ), )} - - {serverOptions.length > 0 && ( -
- - setToolName(event.target.value)} placeholder="Nom du tool" disabled={vm.busy} /> - -
+ {advancedAssignedTools.map((ref) => { + const plugin = plugins.installed.find((candidate) => candidate.id === ref.pluginId); + const enabled = enabledPluginIds.has(ref.pluginId); + return ( +
+ {ref.toolName} · {ref.serverId}{!enabled && — Plugin désactivé} Plugin: {plugin?.displayName ?? ref.pluginId} + +
+ ); + })} + {stalePluginTools.map((ref) => ( + void setPluginTool(ref, false)} /> + ))} + {advancedServers.length > 0 && ( +
+ Ajout avancé d’un tool MCP +
+ ({ value: server.value, label: `${server.label}${server.enabled ? "" : " — désactivé"}`, disabled: !server.enabled }))]} /> + setToolName(event.target.value)} placeholder="Nom exact du tool" disabled={busy} /> + +
+
)}
-
+ ); } diff --git a/frontend/src/features/plugins/ProjectPluginSettings.test.tsx b/frontend/src/features/plugins/ProjectPluginSettings.test.tsx index 05efdc12..25816950 100644 --- a/frontend/src/features/plugins/ProjectPluginSettings.test.tsx +++ b/frontend/src/features/plugins/ProjectPluginSettings.test.tsx @@ -2,7 +2,11 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { DIProvider } from "@/app/di"; -import { createMockGateways, MockPluginGateway } from "@/adapters/mock"; +import { + createMockGateways, + MockPermissionGateway, + MockPluginGateway, +} from "@/adapters/mock"; import type { ProjectPluginManifest } from "@/domain"; import { AgentPluginCapabilities } from "./AgentPluginCapabilities"; import { ProjectPluginsPanel } from "./ProjectPluginsPanel"; @@ -29,7 +33,15 @@ function manifest(): ProjectPluginManifest { menus: [], menuItems: [], layouts: [], - mcpServers: [{ id: "unity-tools", displayName: "Unity Tools", transport: "stdio" }], + mcpServers: [ + { + id: "unity-tools", + displayName: "Unity Tools", + transport: "stdio", + tools: [{ name: "unity_build", description: "Build the Unity project." }], + }, + { id: "legacy-tools", displayName: "Legacy Tools", transport: "stdio" }, + ], }, }; } @@ -75,48 +87,152 @@ describe("project plugin settings #282", () => { , ); - fireEvent.click(await screen.findByRole("button", { name: "Attribuer aux agents" })); + fireEvent.click(await screen.findByRole("button", { name: "Capacités des agents" })); expect(onAssignAgents).toHaveBeenCalledOnce(); }); it("assigns plugin skills and MCP tools to an agent and keeps stale references removable", async () => { const gateways = gatewaysWithPlugin(); + const agent = await gateways.agent.createAgent("project-1", { + name: "Dev", + profileId: "profile-1", + }); gateways.plugin._seedProjectPluginSettings("project-1", { version: 1, plugins: [{ pluginId: "dev.acme.unity", enabled: true }], agents: [{ - agentId: "agent-1", + agentId: agent.id, skills: [{ pluginId: "missing.plugin", skillId: "removed-skill" }], tools: [], }], }); render( - + , ); - expect(await screen.findByText("Capabilities de plugins — Dev")).toBeTruthy(); + expect(await screen.findByText("Capacités de l'agent — Dev")).toBeTruthy(); const skill = await screen.findByRole("checkbox", { name: /unity-debug/ }); fireEvent.click(skill); await waitFor(() => expect((skill as HTMLInputElement).checked).toBe(true)); - expect(screen.getByText(/removed-skill/).textContent).toContain("Obsolète ou manquant"); + expect(screen.getByText(/removed-skill/).closest("div")?.textContent).toContain( + "Indisponible", + ); - fireEvent.click(screen.getByLabelText("serveur MCP de plugin")); - fireEvent.click(screen.getByRole("option", { name: "Unity Plugin — Unity Tools" })); - fireEvent.change(screen.getByLabelText("nom du tool MCP"), { - target: { value: "unity_build" }, + const staticTool = screen.getByRole("checkbox", { name: /unity_build/ }); + fireEvent.click(staticTool); + await waitFor(() => expect((staticTool as HTMLInputElement).checked).toBe(true)); + + fireEvent.click(screen.getByText("Ajout avancé d’un tool MCP")); + fireEvent.click(screen.getByLabelText("serveur MCP sans catalogue statique")); + fireEvent.click(screen.getByRole("option", { name: "Unity Plugin — Legacy Tools" })); + fireEvent.change(screen.getByLabelText("nom avancé du tool MCP"), { + target: { value: "legacy_ping" }, }); fireEvent.click(screen.getByRole("button", { name: "Attribuer" })); - expect(await screen.findByText("unity_build")).toBeTruthy(); + expect(await screen.findByText(/legacy_ping/)).toBeTruthy(); + expect(screen.getByRole("status").textContent).toContain("Relance requise"); const settings = (await gateways.plugin.listProjectSettings("project-1")).settings; - const agent = settings.agents.find((entry) => entry.agentId === "agent-1"); - expect(agent?.skills).toContainEqual({ pluginId: "dev.acme.unity", skillId: "unity.debug" }); - expect(agent?.tools).toContainEqual({ + const assigned = settings.agents.find((entry) => entry.agentId === agent.id); + expect(assigned?.skills).toContainEqual({ pluginId: "dev.acme.unity", skillId: "unity.debug" }); + expect(assigned?.tools).toContainEqual({ pluginId: "dev.acme.unity", serverId: "unity-tools", toolName: "unity_build", }); + expect(assigned?.tools).toContainEqual({ + pluginId: "dev.acme.unity", + serverId: "legacy-tools", + toolName: "legacy_ping", + }); + }); + + it("aggregates native capabilities and uses the granular MCP tool toggle", async () => { + const gateways = gatewaysWithPlugin(); + const agent = await gateways.agent.createAgent("project-1", { + name: "Main", + profileId: "profile-1", + }); + await gateways.skill.createSkill({ + projectId: "project-1", + scope: "project", + name: "release-check", + description: "Vérifier une release.", + content: "# Release", + }); + (gateways.permission as MockPermissionGateway).setMcpToolCatalogue({ + readOnlyTools: ["idea_ticket_read"], + writeActionTools: ["idea_ticket_update"], + tools: [ + { name: "idea_ticket_read", description: "Lire un ticket.", access: "read" }, + { name: "idea_ticket_update", description: "Modifier un ticket.", access: "writeAction" }, + ], + }); + const toggleSpy = vi.spyOn(gateways.permission, "setAgentMcpToolAllowed"); + + render( + + + , + ); + + const nativeSkill = await screen.findByRole("checkbox", { name: /release-check/ }); + const nativeTool = screen.getByRole("checkbox", { name: /idea_ticket_update/ }); + expect(screen.getAllByText("IdeA").length).toBeGreaterThanOrEqual(2); + fireEvent.click(nativeSkill); + fireEvent.click(nativeTool); + + await waitFor(() => + expect(toggleSpy).toHaveBeenCalledWith( + "project-1", + agent.id, + "idea_ticket_update", + true, + ), + ); + expect(screen.getByRole("status").textContent).toContain("Relance requise"); + }); + + it("keeps disabled and missing plugin assignments removable", async () => { + const gateways = gatewaysWithPlugin(); + const agent = await gateways.agent.createAgent("project-1", { + name: "QA", + profileId: "profile-1", + }); + gateways.plugin._seedProjectPluginSettings("project-1", { + version: 1, + plugins: [{ pluginId: "dev.acme.unity", enabled: false }], + agents: [{ + agentId: agent.id, + skills: [{ pluginId: "missing.plugin", skillId: "gone-skill" }], + tools: [ + { pluginId: "dev.acme.unity", serverId: "unity-tools", toolName: "unity_build" }, + { pluginId: "missing.plugin", serverId: "gone-server", toolName: "gone_tool" }, + ], + }], + }); + + render( + + + , + ); + + const disabledAssigned = await screen.findByRole("checkbox", { name: /unity_build/ }); + expect((disabledAssigned as HTMLInputElement).checked).toBe(true); + expect((disabledAssigned as HTMLInputElement).disabled).toBe(false); + fireEvent.click(disabledAssigned); + await waitFor(() => expect((disabledAssigned as HTMLInputElement).checked).toBe(false)); + + const stale = screen.getByText(/gone_tool/).closest("div")!; + fireEvent.click(stale.querySelector("button")!); + await waitFor(async () => { + const settings = (await gateways.plugin.listProjectSettings("project-1")).settings; + expect(settings.agents[0]?.tools).not.toContainEqual( + expect.objectContaining({ toolName: "gone_tool" }), + ); + }); }); }); diff --git a/frontend/src/features/plugins/ProjectPluginsPanel.tsx b/frontend/src/features/plugins/ProjectPluginsPanel.tsx index 08232719..38c13864 100644 --- a/frontend/src/features/plugins/ProjectPluginsPanel.tsx +++ b/frontend/src/features/plugins/ProjectPluginsPanel.tsx @@ -36,13 +36,13 @@ export function ProjectPluginsPanel({

Plugins du projet

- Activez les plugins utilisés par ce projet, puis attribuez leurs skills et - tools MCP à chaque agent. + Activez les plugins utilisés par ce projet. Les attributions se gèrent + depuis la fiche de chaque agent.

{onAssignAgents && ( - )} diff --git a/frontend/src/features/skills/skills.test.tsx b/frontend/src/features/skills/skills.test.tsx index 4b62f97f..5dae0072 100644 --- a/frontend/src/features/skills/skills.test.tsx +++ b/frontend/src/features/skills/skills.test.tsx @@ -17,6 +17,8 @@ import { render, screen, waitFor, fireEvent } from "@testing-library/react"; import { MockAgentGateway, + MockPermissionGateway, + MockPluginGateway, MockProfileGateway, MockSkillGateway, } from "@/adapters/mock"; @@ -44,7 +46,13 @@ function renderSkillsPanel(agent?: MockAgentGateway, skill?: MockSkillGateway) { function renderAgentsPanel(agent: MockAgentGateway, skill: MockSkillGateway) { const profile = new MockProfileGateway(); - const gateways = { agent, profile, skill } as unknown as Gateways; + const gateways = { + agent, + profile, + skill, + permission: new MockPermissionGateway(), + plugin: new MockPluginGateway(), + } as unknown as Gateways; return render( @@ -58,11 +66,6 @@ async function waitForSkillsIdle() { }); } -function chooseDropdownOption(label: string | RegExp, optionName: string | RegExp) { - fireEvent.click(screen.getByLabelText(label)); - fireEvent.click(screen.getByRole("option", { name: optionName })); -} - /** Opens the SkillEditor, fills it, and saves. */ async function createSkill( name: string, @@ -172,7 +175,7 @@ describe("SkillsPanel (with MockSkillGateway)", () => { // --------------------------------------------------------------------------- describe("Skill assignment (AgentsPanel + MockSkillGateway)", () => { - it("assigns a skill to the selected agent and shows it as a chip", async () => { + it("assigns a skill to the selected agent from the unified capabilities", async () => { const agent = new MockAgentGateway(); const skill = new MockSkillGateway(agent); const created = await agent.createAgent(PROJECT_ID, { @@ -191,14 +194,9 @@ describe("Skill assignment (AgentsPanel + MockSkillGateway)", () => { // Select the agent fireEvent.click(screen.getByText("Worker")); - await waitFor(() => - expect(screen.getByLabelText("skill to assign")).toBeTruthy(), - ); - - // Choose the skill and assign + const toggle = await screen.findByRole("checkbox", { name: /Deploy/ }); const sk = (await skill.listSkills(PROJECT_ID, "project"))[0]; - chooseDropdownOption("skill to assign", "Deploy (project)"); - fireEvent.click(screen.getByRole("button", { name: "assign skill" })); + fireEvent.click(toggle); // The agent record now carries the skill ref await waitFor(async () => { @@ -206,8 +204,7 @@ describe("Skill assignment (AgentsPanel + MockSkillGateway)", () => { const a = agents.find((x) => x.id === created.id)!; expect(a.skills.map((s) => s.skillId)).toContain(sk.id); }); - // Chip with an unassign control appears - expect(screen.getByRole("button", { name: "unassign Deploy" })).toBeTruthy(); + expect((toggle as HTMLInputElement).checked).toBe(true); }); it("unassigns a skill from the agent", async () => { @@ -229,12 +226,9 @@ describe("Skill assignment (AgentsPanel + MockSkillGateway)", () => { await waitFor(() => expect(screen.getByLabelText("agent name")).toBeTruthy()); fireEvent.click(screen.getByText("Worker2")); - await waitFor(() => - expect( - screen.getByRole("button", { name: "unassign Cleanup" }), - ).toBeTruthy(), - ); - fireEvent.click(screen.getByRole("button", { name: "unassign Cleanup" })); + const toggle = await screen.findByRole("checkbox", { name: /Cleanup/ }); + expect((toggle as HTMLInputElement).checked).toBe(true); + fireEvent.click(toggle); await waitFor(async () => { const agents = await agent.listAgents(PROJECT_ID); diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 492d333a..b4db761d 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -1133,6 +1133,13 @@ export interface PermissionGateway { agentId: string, policy: McpToolPolicy | null, ): Promise; + /** Atomically toggles one native IdeA MCP tool for an agent. */ + setAgentMcpToolAllowed( + projectId: string, + agentId: string, + toolName: string, + allowed: boolean, + ): Promise; } /** Read-only live work-state read-model for conversations/delegations. */