diff --git a/.ideai/tickets/295/carnet.md b/.ideai/tickets/295/carnet.md index 231dc6c7..57abf380 100644 --- a/.ideai/tickets/295/carnet.md +++ b/.ideai/tickets/295/carnet.md @@ -1,8 +1,8 @@ --- issueRef: "#295" -version: 2 +version: 4 updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} -updatedAt: 1788971880464 +updatedAt: 1788972545852 --- ## QA - 2026-09-09 - Validation post-restart chat transcript @@ -154,3 +154,40 @@ Sortie essentielle : Note : Vite signale un chunk minifié > 500 kB ; avertissement existant/non bloquant pour #295. Verdict QA : VERT. + +## Git - 2026-09-09 - Intégration locale + +Cycle Git terminé, sans push. + +- Branche de travail : `fix/295-custom-chat-transcript-after-restart`. +- Commit applicatif : `1b1476d` (`fix(chat)`), 14 fichiers applicatifs. +- Commit ticket : `8482fc5` (`chore(tickets)`), carnet #295 + capture + rattrapage store #293/#294. +- Merge local vers `develop` : `6e4d9eb` (`merge --no-ff`). + +QA reconfirmée par Git avant commit : `git diff --check`, `cargo fmt --all --check`, app-tauri chat/dto, backend, web-server `websocket_chat_attach`, frontend tests ciblés, `npm run build`. + +Réserves Git : aucun push; `develop` local reste en avance sur origin. `scripts/` non suivi reste hors périmètre. + +## AppImage - 2026-09-09 - Build local + +Workflow `build-appimage` exécuté après merge pour produire un artefact installable/testable. + +Commandes : + +```bash +npm --prefix frontend run build +cd crates/app-tauri +mkdir -p /tmp/idea-cargo-home +CARGO_HOME=/tmp/idea-cargo-home APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1 ../../frontend/node_modules/.bin/tauri build --bundles appimage +``` + +Résultat : + +```text +Finished 1 bundle at: +/home/anthony/Documents/Projects/IdeA/target/release/bundle/appimage/IdeA_0.3.0_amd64.AppImage +``` + +Artefact : `target/release/bundle/appimage/IdeA_0.3.0_amd64.AppImage`, 114M, sha256 `b7c41857977279facef5259c3c3345b8a3430ed007e54d9374f4081e41802137`. + +Remplacement de l'AppImage installée et relance IdeA non automatisés volontairement: cela tuerait la session d'orchestration active. Validation live du scénario UnitySoundTool à faire après installation manuelle de cet artefact. diff --git a/.ideai/tickets/295/issue.md b/.ideai/tickets/295/issue.md index b68da93e..7276c1ca 100644 --- a/.ideai/tickets/295/issue.md +++ b/.ideai/tickets/295/issue.md @@ -2,7 +2,7 @@ id: "bc0fe5e4-8398-4c47-aeca-2c2e93a0d1d3" number: 295 title: "Corriger l’affichage du transcript après redémarrage automatique d’un agent" -status: "qa" +status: "closed" priority: "high" sprint: null links: [] @@ -11,7 +11,7 @@ attachments: [] createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} createdAt: 1788969948217 -updatedAt: 1788971880464 -version: 2 +updatedAt: 1788972545852 +version: 4 --- Bug utilisateur observé sur UnitySoundTool: après redémarrage automatique de l'agent Main, l'agent continue réellement à travailler et les workstates changent, mais aucun message ne s'affiche après le message indiquant que l'agent a bien redémarré. Après changement de projet puis retour, l'affichage se nettoie et ne montre plus que des bandeaux/progress d'agents sollicités par Main en haut de la cellule. Contexte: le redémarrage est survenu après appel à Main UnityPlugin pour recharger un plugin modifié. Capture jointe dans la conversation utilisateur. \ No newline at end of file diff --git a/.ideai/tickets/296/carnet.md b/.ideai/tickets/296/carnet.md new file mode 100644 index 00000000..21d737a0 --- /dev/null +++ b/.ideai/tickets/296/carnet.md @@ -0,0 +1,195 @@ +--- +issueRef: "#296" +version: 2 +updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +updatedAt: 1788976061895 +--- + +## QA - 2026-09-09 - Validation unified agent capabilities + +Branche vérifiée : `feature/296-unified-agent-capabilities`. + +Périmètre relu : + +- Backend/application : catalogue MCP natif structuré, mutation atomique `SetAgentMcpToolAllowed`, validation metadata plugin `mcpServers[].tools`. +- Backend DTO/Tauri/web-server : DTO additive `catalogue.tools`, request `set_agent_mcp_tool_allowed`, wiring commande Tauri et invoke web-server. +- SDK `sdk/IdeaSDK` : type exporté `IdeAPluginMcpToolMetadata`, validation manifest, script `test:manifest`, doc `docs/manifest.md`. +- Frontend : `AgentPluginCapabilities` devient la surface unifiée Skills/Tools MCP natifs et plugins ; `AgentsPanel` retire l'ancienne assignation skills séparée ; `ProjectPluginsPanel` recentré sur enable/disable ; adapters Tauri/HTTP/mock pour la mutation native. + +Diff vérifié : les 6 fichiers web en échec dans la suite complète ne sont pas modifiés par ce ticket (`git diff --name-only | rg '^(frontend/src/features/web/|frontend/src/features/projects/ProjectsView|frontend/src/features/web/tickets/)'` ne retourne rien). + +Commandes vertes exécutées : + +```bash +git diff --check +cargo fmt --all --check +``` + +Sortie : aucune. + +```bash +cargo test -p application mcp_tool_permissions --lib +cargo test -p application plugin::tests:: --lib +``` + +Sortie essentielle : + +```text +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 170 filtered out +test result: ok. 37 passed; 0 failed; 0 ignored; 0 measured; 138 filtered out +``` + +```bash +cargo test -p backend mcp_tool_permissions --lib +cargo test -p backend backend_core_exposes_structured_native_mcp_catalogue_with_descriptions --lib +cargo test -p backend dto::tests::project_mcp_tool_permissions_dto_uses_stable_camel_case_contract --lib +cargo check -p backend +cargo check -p application +cargo check -p domain +``` + +Sortie essentielle : + +```text +test dto::tests::project_mcp_tool_permissions_dto_uses_stable_camel_case_contract ... ok +test backend_core_build_tests::backend_core_exposes_structured_native_mcp_catalogue_with_descriptions ... ok +Finished `dev` profile [unoptimized + debuginfo] +``` + +```bash +cargo check -p app-tauri +cargo test -p app-tauri --test dto --test dto_plugins --test dto_agents +``` + +Sortie essentielle : + +```text +Finished `dev` profile [unoptimized + debuginfo] +test result: ok. 33 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out +test result: ok. 30 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out +``` + +```bash +cargo test -p web-server set_agent_mcp_tool_allowed --lib +cargo check -p web-server +``` + +Sortie essentielle : + +```text +running 0 tests +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 112 filtered out +Finished `dev` profile [unoptimized + debuginfo] +``` + +Note : aucun test web-server ne cible directement le nom `set_agent_mcp_tool_allowed`; la commande n'est donc pas probante en test, mais le routage apparaît dans la suite `web_invoke_routes_permission_commands` de la lib complète, qui passe en exécution série avant les échecs PTY hors périmètre listés plus bas. + +```bash +npm --prefix sdk/IdeaSDK run check +``` + +Sortie essentielle : + +```text +> @idea/plugin-sdk@0.1.0 check +> npm run test:manifest && npm run typecheck:examples && npm run package:hello-plugin + +> @idea/plugin-sdk@0.1.0 test:manifest +> npm run build && node scripts/check-manifest-tools.mjs + +created /home/anthony/Documents/Projects/IdeA/sdk/IdeaSDK/examples/hello-plugin/build/hello-plugin-0.1.0.zip +``` + +```bash +npm test -- ProjectPluginSettings.test.tsx skills.test.tsx permission.test.ts httpInvoker.test.ts +npm test -- agents.test.tsx plugins.test.tsx plugin.test.ts McpToolPermissionsPanel.test.tsx +``` + +Sortie essentielle : + +```text +Test Files 4 passed (4) +Tests 35 passed (35) + +Test Files 4 passed (4) +Tests 65 passed (65) +``` + +```bash +npm run build +npm run build:web +``` + +Sortie essentielle : + +```text +✓ 515 modules transformed. +✓ built in 2.75s + +✓ 515 modules transformed. +✓ built in 2.56s +``` + +Notes : warnings Vite de configuration `esbuild`/`oxc`, warning `HTMLCanvasElement.getContext()` jsdom dans des tests, et warning chunk > 500 kB au build ; non bloquants pour #296. + +Suites rouges exécutées et classification : + +```bash +cargo test -p web-server --lib +``` + +Sortie essentielle parallèle : + +```text +test result: FAILED. 72 passed; 40 failed; 0 ignored; 0 measured; 0 filtered out +premier échec: Failed building the Runtime: Os { code: 24, message: "Too many open files" } +``` + +Classification : échec environnemental par limite de fichiers ouverts sous forte parallélisation, non bloquant pour #296. Relance série : + +```bash +cargo test -p web-server --lib -- --test-threads=1 +``` + +Sortie essentielle : + +```text +test result: FAILED. 105 passed; 7 failed; 0 ignored; 0 measured; 0 filtered out +``` + +Les 7 échecs série sont tous des scénarios terminal/agent launch attendant `terminal.attached` et recevant `error` : + +- `websocket_attach_terminal_replays_scrollback_in_attached_ack` +- `websocket_close_terminal_emits_exited_status_and_releases_session` +- `websocket_launch_agent_different_cell_is_refused_by_singleton_guard` +- `websocket_launch_agent_emits_attached_with_assigned_conversation_id` +- `websocket_launch_agent_reattach_replays_scrollback_without_respawn` +- `websocket_launch_agent_same_cell_is_idempotent_singleton` +- `websocket_open_terminal_emits_attached_with_empty_scrollback` + +Ces 7 tests passent individuellement avec `cargo test -p web-server --lib`. Classification : instabilité/interférence de suite web-server PTY existante, hors périmètre #296. Non bloquant pour #296. + +```bash +npm test +``` + +Sortie essentielle : + +```text +Test Files 6 failed | 120 passed (126) +Tests 32 failed | 1223 passed (1255) +``` + +Classification précise des 32 échecs frontend complets : hors périmètre #296, car ils sont dans des fichiers web non modifiés par ce ticket et ne concernent pas la surface `Capacités de l'agent`, les adapters de capabilities, ni le contrat backend/SDK MCP. + +- `frontend/src/features/projects/ProjectsView.ls7.test.tsx` : 3 échecs LS7 conversation viewer, bouton `open conversation conversation-open-me` introuvable. +- `frontend/src/features/web/WebAgentCell.test.tsx` : 1 échec, le message mobile custom CLI contient `invalid node id` alors que le test attend son masquage. +- `frontend/src/features/web/WebApp.test.tsx` : 3 échecs pairing routing, requête `getByText("Projets")` ambiguë car plusieurs éléments correspondent. +- `frontend/src/features/web/WebDesktopWorkspace.test.tsx` : 1 échec, bouton `Project context` introuvable. +- `frontend/src/features/web/WebMenus.test.tsx` : 8 échecs, attentes d'ancienne navigation/testids (`web-desktop-shell`, `Panneaux : ...`, `Surface ...`) non alignées avec l'UI rendue. +- `frontend/src/features/web/tickets/WebTicketsSprints.test.tsx` : 16 échecs, attentes `Panneaux : ...`/navigation tickets-sprints web non alignées avec l'UI rendue. + +Relance des 6 fichiers web seuls reproduite avec les mêmes 32 échecs (`32 failed | 24 passed`), et relance isolée de `WebAgentCell.test.tsx`, `WebMenus.test.tsx`, `ProjectsView.ls7.test.tsx` confirme que ces familles échouent indépendamment de #296. Non bloquant pour #296. + +Verdict QA : VERT pour le périmètre #296, avec dette de tests web hors périmètre confirmée. diff --git a/.ideai/tickets/296/issue.md b/.ideai/tickets/296/issue.md new file mode 100644 index 00000000..844e9995 --- /dev/null +++ b/.ideai/tickets/296/issue.md @@ -0,0 +1,17 @@ +--- +id: "f6760649-4f0f-41bf-956a-ebc6444c5597" +number: 296 +title: "Unifier l’attribution des skills et tools MCP natifs/plugins aux agents" +status: "qa" +priority: "high" +sprint: null +links: [] +agentRefs: [] +attachments: [] +createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +createdAt: 1788973181342 +updatedAt: 1788976061895 +version: 2 +--- +Demande utilisateur: pouvoir attribuer un tool MCP de plugin directement depuis l'interface, comme pour les skills, et regrouper l'attribution des skills/tools MCP provenant des plugins au même endroit que les skills et tools MCP natifs d'IdeA. Objectif UX: réduire les surfaces dispersées et rendre l'attribution des capacités agent plus instinctive. \ No newline at end of file diff --git a/.ideai/tickets/counter.json b/.ideai/tickets/counter.json index 7dd3cefb..21ea1074 100644 --- a/.ideai/tickets/counter.json +++ b/.ideai/tickets/counter.json @@ -1,3 +1,3 @@ { - "nextNumber": 296 + "nextNumber": 297 } \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index efe3ab9c..64de70f2 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -4042,6 +4042,20 @@ "issueRef": "#295", "path": "295", "title": "Corriger l’affichage du transcript après redémarrage automatique d’un agent", + "status": "closed", + "priority": "high", + "sprint": null, + "assignedAgentIds": [], + "createdBy": { + "kind": "agent", + "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" + }, + "updatedAt": 1788972545852 + }, + { + "issueRef": "#296", + "path": "296", + "title": "Unifier l’attribution des skills et tools MCP natifs/plugins aux agents", "status": "qa", "priority": "high", "sprint": null, @@ -4050,7 +4064,7 @@ "kind": "agent", "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" }, - "updatedAt": 1788971880464 + "updatedAt": 1788976061895 } ] } \ No newline at end of file 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. */ diff --git a/sdk/IdeaSDK b/sdk/IdeaSDK index fdb3a907..436ed59b 160000 --- a/sdk/IdeaSDK +++ b/sdk/IdeaSDK @@ -1 +1 @@ -Subproject commit fdb3a907af11589b64c980ffa631dfed1b899189 +Subproject commit 436ed59b4130ef0d6221f1742db389dbc88c0985