feat(agents,plugins): surface unifiée Capacités de l'agent — skills et tools MCP natifs + plugins #296 (domain: PluginMcpToolMetadata dans PluginMcpServerContribution, validation hôte alignée SDK — name requis non vide, description optionnelle non vide, doublons rejetés; application: McpToolPermissionCatalogue.vue structurée additive tools {name, description, access read|writeAction} enrichie des descriptions canoniques infrastructure orchestrator mcp tools, use case SetAgentMcpToolAllowed — toggle atomique idempotente d'un tool natif dans l'allowlist explicite d'un agent, refus unknown tool sans mutation, overrides vides retirés; backend DTO: catalogue.tools additive + SetAgentMcpToolAllowedRequestDto, wire camelCase; app-tauri + web-server: commande set_agent_mcp_tool_allowed; frontend: AgentPluginCapabilities devient la surface unifiée — skills natifs assign/unassign, tools natifs via setAgentMcpToolAllowed (catalogue structuré avec fallback legacy), skills/tools plugins avec metadata statiques mcpServers[].tools et saisie manuelle fallback pour serveurs sans catalogue, tombstones indisponibles, bannière redémarrage requis si agent actif; AgentsPanel retire l'ancienne section Skills séparée au profit de la surface unifiée; ProjectPluginsPanel recentré enable/disable avec renvoi Capacités des agents; adapters Tauri/HTTP/mock + permission.test.ts; QA verte reconfirmée par Git: application mcp_tool_permissions 5/5 + plugin::tests 37/37, backend ciblés 3/3 + checks domain/application/backend, app-tauri dto 33/33 + dto_agents 30/30 + dto_plugins 2/2, check web-server, npm test 35/35 + 65/65, npm run build; suites complètes rouges classées non bloquantes par QA: web-server parallèle too many open files + 7 PTY série passant individuellement, frontend 32 échecs sur 6 fichiers web non modifiés reproduits isolément)

This commit is contained in:
2026-09-09 19:52:38 +02:00
parent 6e4d9eb47e
commit e283f4a9e4
22 changed files with 1152 additions and 301 deletions

View File

@ -27,10 +27,11 @@ use application::{
ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput,
ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput, ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput,
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput, ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
RotateConversationLogInput, SaveChatDraftInput, SetActiveLayoutInput, SlashCommandEffect, RotateConversationLogInput, SaveChatDraftInput, SetActiveLayoutInput,
SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput, SetAgentMcpToolAllowedInput, SlashCommandEffect, SnapshotRunningAgentsInput,
UnassignSkillFromAgentInput, UpdateAgentContactableInput, UpdateAgentContextInput, StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput,
UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput, UpdateAgentContactableInput, UpdateAgentContextInput, UpdateAgentEffortInput,
UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
UpdateAgentSystemPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput, UpdateAgentSystemPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput,
UpdateProjectMcpToolPermissionsInput, UpdateProjectOrchestratorInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectOrchestratorInput,
UpdateProjectPermissionsInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput, UpdateProjectPermissionsInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput,
@ -74,10 +75,10 @@ use crate::dto::{
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto, ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
ResumableAgentListDto, SaveChatDraftRequestDto, SaveEmbedderProfileRequestDto, ResumableAgentListDto, SaveChatDraftRequestDto, SaveEmbedderProfileRequestDto,
SaveModelServerRequestDto, SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, SaveModelServerRequestDto, SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto,
SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto, SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SetAgentMcpToolAllowedRequestDto,
SlashCommandEffectDto, SlashCommandListDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto, SkillDto, SkillListDto, SlashCommandEffectDto, SlashCommandListDto, StopLiveAgentRequestDto,
SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
UpdateAgentContactableRequestDto, UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto, UpdateAgentContactableRequestDto, UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto,
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto, UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto, UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
@ -984,6 +985,26 @@ pub async fn update_agent_mcp_tool_permissions(
.map_err(ErrorDto::from) .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<ProjectMcpToolPermissionsDto, ErrorDto> {
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) // Terminals (L3)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -271,6 +271,7 @@ pub fn run() {
commands::get_mcp_tool_permissions, commands::get_mcp_tool_permissions,
commands::update_project_mcp_tool_permissions, commands::update_project_mcp_tool_permissions,
commands::update_agent_mcp_tool_permissions, commands::update_agent_mcp_tool_permissions,
commands::set_agent_mcp_tool_allowed,
commands::open_terminal, commands::open_terminal,
commands::write_terminal, commands::write_terminal,
commands::resize_terminal, commands::resize_terminal,

View File

@ -142,8 +142,9 @@ pub use managed_temp::{
ResolveManagedTempOutput, MANAGED_TEMP_DEFAULT_TTL_MS, MANAGED_TEMP_PURPOSE_MAX_CHARS, ResolveManagedTempOutput, MANAGED_TEMP_DEFAULT_TTL_MS, MANAGED_TEMP_PURPOSE_MAX_CHARS,
}; };
pub use mcp_tool_permissions::{ pub use mcp_tool_permissions::{
McpToolPermissionCatalogue, ReadMcpToolPermissions, ReadMcpToolPermissionsInput, McpToolCatalogueAccess, McpToolCatalogueEntry, McpToolPermissionCatalogue,
ReadMcpToolPermissionsOutput, UpdateAgentMcpToolPermissions, ReadMcpToolPermissions, ReadMcpToolPermissionsInput, ReadMcpToolPermissionsOutput,
SetAgentMcpToolAllowed, SetAgentMcpToolAllowedInput, UpdateAgentMcpToolPermissions,
UpdateAgentMcpToolPermissionsInput, UpdateProjectMcpToolPermissions, UpdateAgentMcpToolPermissionsInput, UpdateProjectMcpToolPermissions,
UpdateProjectMcpToolPermissionsInput, UpdateProjectMcpToolPermissionsInput,
}; };

View File

@ -22,6 +22,28 @@ pub struct McpToolPermissionCatalogue {
pub read_only_tools: Vec<String>, pub read_only_tools: Vec<String>,
/// Tools denied by default because they write, act, or execute. /// Tools denied by default because they write, act, or execute.
pub write_action_tools: Vec<String>, pub write_action_tools: Vec<String>,
/// Structured additive view used by capability-management surfaces.
pub tools: Vec<McpToolCatalogueEntry>,
}
/// 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 { impl McpToolPermissionCatalogue {
@ -33,14 +55,43 @@ impl McpToolPermissionCatalogue {
read_only_tools: Vec<String>, read_only_tools: Vec<String>,
write_action_tools: Vec<String>, write_action_tools: Vec<String>,
) -> Result<Self, AppError> { ) -> Result<Self, AppError> {
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 { let catalogue = Self {
read_only_tools, read_only_tools,
write_action_tools, write_action_tools,
tools,
}; };
catalogue.validate()?; catalogue.validate()?;
Ok(catalogue) 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<Item = (&'a str, &'a str)>,
) -> 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. /// Returns all known tool names as borrowed strings.
#[must_use] #[must_use]
pub fn known_tool_refs(&self) -> Vec<&str> { pub fn known_tool_refs(&self) -> Vec<&str> {
@ -218,6 +269,89 @@ pub struct UpdateAgentMcpToolPermissionsInput {
pub policy: Option<McpToolPolicy>, pub policy: Option<McpToolPolicy>,
} }
/// Atomically toggles one native MCP tool in an agent's explicit allowlist.
pub struct SetAgentMcpToolAllowed {
store: Arc<dyn McpToolPermissionStore>,
catalogue: McpToolPermissionCatalogue,
}
impl SetAgentMcpToolAllowed {
/// Builds the granular mutation use case.
#[must_use]
pub fn new(
store: Arc<dyn McpToolPermissionStore>,
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<ReadMcpToolPermissionsOutput, AppError> {
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( fn validate_policy(
policy: Option<&McpToolPolicy>, policy: Option<&McpToolPolicy>,
catalogue: &McpToolPermissionCatalogue, catalogue: &McpToolPermissionCatalogue,
@ -399,4 +533,71 @@ mod tests {
vec!["idea_memory_write"] 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());
}
} }

View File

@ -3416,6 +3416,16 @@ struct RawMcpServer {
auto_start: bool, auto_start: bool,
#[serde(default)] #[serde(default)]
allow_absolute_command: bool, allow_absolute_command: bool,
#[serde(default)]
tools: Vec<RawMcpToolMetadata>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawMcpToolMetadata {
name: String,
#[serde(default)]
description: Option<String>,
} }
impl PluginManifestValidator for JsonPluginManifestValidator { impl PluginManifestValidator for JsonPluginManifestValidator {
@ -3652,6 +3662,38 @@ fn validate_contributes(raw: RawContributes) -> Result<PluginContributionSet, Pl
.map_err(|e| PluginManifestError::Invalid(e.to_string()))?; .map_err(|e| PluginManifestError::Invalid(e.to_string()))?;
} }
} }
let mut tool_names = HashSet::new();
let tools = s
.tools
.into_iter()
.map(|tool| {
if tool.name.trim().is_empty() {
return Err(PluginManifestError::Invalid(
"mcpServers[].tools[].name is required".to_owned(),
));
}
if !tool_names.insert(tool.name.clone()) {
return Err(PluginManifestError::Invalid(format!(
"duplicate MCP tool metadata: {}",
tool.name
)));
}
if tool
.description
.as_ref()
.is_some_and(|description| description.trim().is_empty())
{
return Err(PluginManifestError::Invalid(
"mcpServers[].tools[].description must be non-empty when provided"
.to_owned(),
));
}
Ok(domain::PluginMcpToolMetadata {
name: tool.name,
description: tool.description,
})
})
.collect::<Result<Vec<_>, _>>()?;
Ok(domain::PluginMcpServerContribution { Ok(domain::PluginMcpServerContribution {
id: domain::PluginMcpServerId::new(s.id) id: domain::PluginMcpServerId::new(s.id)
.map_err(|e| PluginManifestError::Invalid(e.to_string()))?, .map_err(|e| PluginManifestError::Invalid(e.to_string()))?,
@ -3663,6 +3705,7 @@ fn validate_contributes(raw: RawContributes) -> Result<PluginContributionSet, Pl
transport: s.transport, transport: s.transport,
auto_start: s.auto_start, auto_start: s.auto_start,
allow_absolute_command: s.allow_absolute_command, allow_absolute_command: s.allow_absolute_command,
tools,
}) })
}) })
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
@ -3744,7 +3787,7 @@ mod tests {
"menuItems": [{"id":"dev.acme.open","targetMenuId":"panels","label":"Open","command":"dev.acme.open"}], "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}], "slashCommands": [{"name":"/graph","shortDescription":"Open graph tools","command":"dev.acme.open","requiresConfirmation":true}],
"layouts": [{"type":"dev.acme.layout","label":"Graph","component":"Graph"}], "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() }"#.to_vec()
} }
@ -4080,11 +4123,74 @@ mod tests {
"skills/build-debug/SKILL.md" "skills/build-debug/SKILL.md"
); );
assert_eq!(m.contributes.mcp_servers.len(), 1); 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.len(), 1);
assert_eq!(m.contributes.slash_commands[0].name, "/graph"); assert_eq!(m.contributes.slash_commands[0].name, "/graph");
assert!(m.contributes.slash_commands[0].requires_confirmation); 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] #[test]
fn rejects_plugin_skill_outside_markdown_contract() { fn rejects_plugin_skill_outside_markdown_contract() {
let mut value: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap(); let mut value: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap();

View File

@ -2952,6 +2952,20 @@ pub struct McpToolCatalogueDto {
pub read_only_tools: Vec<String>, pub read_only_tools: Vec<String>,
/// Tools treated as writing/action/execution tools. /// Tools treated as writing/action/execution tools.
pub write_action_tools: Vec<String>, pub write_action_tools: Vec<String>,
/// Structured additive catalogue for unified capability surfaces.
pub tools: Vec<McpToolCatalogueEntryDto>,
}
/// 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. /// Full MCP tool permission state crossing the wire.
@ -2975,6 +2989,20 @@ impl From<ReadMcpToolPermissionsOutput> for ProjectMcpToolPermissionsDto {
catalogue: McpToolCatalogueDto { catalogue: McpToolCatalogueDto {
read_only_tools: out.catalogue.read_only_tools, read_only_tools: out.catalogue.read_only_tools,
write_action_tools: out.catalogue.write_action_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, project_default: out.permissions.project_default,
agents: out.permissions.agents, agents: out.permissions.agents,
@ -3068,6 +3096,20 @@ pub struct UpdateAgentMcpToolPermissionsRequestDto {
pub policy: Option<McpToolPolicy>, pub policy: Option<McpToolPolicy>,
} }
/// 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`. /// Request DTO for `update_project_context`.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@ -5782,7 +5824,11 @@ mod tests {
vec!["idea_ticket_read".to_owned()], vec!["idea_ticket_read".to_owned()],
vec!["idea_ticket_update".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 { permissions: ProjectMcpToolPermissions {
version: 1, version: 1,
project_default: Some( project_default: Some(
@ -5804,7 +5850,19 @@ mod tests {
"version": 1, "version": 1,
"catalogue": { "catalogue": {
"readOnlyTools": ["idea_ticket_read"], "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": { "projectDefault": {
"allowedTools": ["idea_ticket_read"] "allowedTools": ["idea_ticket_read"]

View File

@ -52,16 +52,17 @@ use application::{
ResolveManagedTemp, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, ResolveManagedTemp, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask,
ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveChatDraft, ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveChatDraft,
SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveProfile, SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveProfile,
SessionLimitService, SetActiveLayout, SetPluginEnabled, SetProjectPluginEnabled, SessionLimitService, SetActiveLayout, SetAgentMcpToolAllowed, SetPluginEnabled,
SlashCommandRegistry, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, SetProjectPluginEnabled, SlashCommandRegistry, SnapshotOpenWindows, SnapshotRunningAgents,
StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession, SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions,
SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContactable, UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues,
UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentContactable, UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions,
UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateAgentPermissions, UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet,
UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectOrchestrator, UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectMcpToolPermissions,
UpdateProjectPermissions, UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate, UpdateProjectOrchestrator, UpdateProjectPermissions, UpdateProjectSystemPermissions,
WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal,
AGENT_MEMORY_RECALL_BUDGET,
}; };
use async_trait::async_trait; use async_trait::async_trait;
use domain::events::AgentRestartPhase; use domain::events::AgentRestartPhase;
@ -291,6 +292,32 @@ mod backend_core_build_tests {
let _ = std::fs::remove_dir_all(dir); 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] #[test]
fn plugin_directory_resolution_accepts_relative_directory_under_project_root() { fn plugin_directory_resolution_accepts_relative_directory_under_project_root() {
let root = std::env::temp_dir().join(format!("idea-plugin-root-{}", Uuid::new_v4())); 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<UpdateProjectMcpToolPermissions>, pub update_project_mcp_tool_permissions: Arc<UpdateProjectMcpToolPermissions>,
/// Replace or clear one agent MCP tool policy override. /// Replace or clear one agent MCP tool policy override.
pub update_agent_mcp_tool_permissions: Arc<UpdateAgentMcpToolPermissions>, pub update_agent_mcp_tool_permissions: Arc<UpdateAgentMcpToolPermissions>,
/// Atomically toggle one tool in an agent's explicit native MCP allowlist.
pub set_agent_mcp_tool_allowed: Arc<SetAgentMcpToolAllowed>,
/// Registre des sessions structurées (IA / cellules chat, §17.5). Partagé avec /// Registre des sessions structurées (IA / cellules chat, §17.5). Partagé avec
/// `LaunchAgent`/`ChangeAgentProfile` ; consommé par les commandes de chat (D4) /// `LaunchAgent`/`ChangeAgentProfile` ; consommé par les commandes de chat (D4)
/// pour résoudre la session vivante d'un `sessionId` et l'arrêter à la fermeture. /// 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()) .map(|tool| (*tool).to_owned())
.collect(), .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( let read_mcp_tool_permissions = Arc::new(ReadMcpToolPermissions::new(
Arc::clone(&mcp_tool_permission_store_port), Arc::clone(&mcp_tool_permission_store_port),
mcp_tool_catalogue.clone(), mcp_tool_catalogue.clone(),
@ -2868,6 +2902,10 @@ impl BackendCore {
mcp_tool_catalogue.clone(), mcp_tool_catalogue.clone(),
)); ));
let update_agent_mcp_tool_permissions = Arc::new(UpdateAgentMcpToolPermissions::new( 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), Arc::clone(&mcp_tool_permission_store_port),
mcp_tool_catalogue, mcp_tool_catalogue,
)); ));
@ -4061,6 +4099,7 @@ impl BackendCore {
read_mcp_tool_permissions, read_mcp_tool_permissions,
update_project_mcp_tool_permissions, update_project_mcp_tool_permissions,
update_agent_mcp_tool_permissions, update_agent_mcp_tool_permissions,
set_agent_mcp_tool_allowed,
structured_sessions, structured_sessions,
create_agent, create_agent,
list_agents, list_agents,

View File

@ -240,11 +240,12 @@ pub use plugin::{
PluginBundleUrl, PluginCapability, PluginCommandId, PluginContributionSet, PluginDescriptor, PluginBundleUrl, PluginCapability, PluginCommandId, PluginContributionSet, PluginDescriptor,
PluginError, PluginId, PluginInstallSource, PluginLayoutContribution, PluginLayoutType, PluginError, PluginId, PluginInstallSource, PluginLayoutContribution, PluginLayoutType,
PluginLifecycleState, PluginManifest, PluginMcpServerContribution, PluginMcpServerId, PluginLifecycleState, PluginManifest, PluginMcpServerContribution, PluginMcpServerId,
PluginMcpServerSpec, PluginMcpStatus, PluginMcpStatusSet, PluginMenuItemContribution, PluginMcpServerSpec, PluginMcpStatus, PluginMcpStatusSet, PluginMcpToolMetadata,
PluginPackageRef, PluginRegistry, PluginRegistryEntry, PluginSkillContribution, PluginSkillId, PluginMenuItemContribution, PluginPackageRef, PluginRegistry, PluginRegistryEntry,
PluginSkillRef, PluginSlashCommandContribution, PluginToolRef, PluginTopLevelMenuContribution, PluginSkillContribution, PluginSkillId, PluginSkillRef, PluginSlashCommandContribution,
PluginTrustLevel, PluginVersion, ProjectPluginActivation, ProjectPluginSettings, RelativePath, PluginToolRef, PluginTopLevelMenuContribution, PluginTrustLevel, PluginVersion,
RemovalOutcome, StagedPluginPackage, ProjectPluginActivation, ProjectPluginSettings, RelativePath, RemovalOutcome,
StagedPluginPackage,
}; };
pub use sandbox::{ pub use sandbox::{

View File

@ -513,6 +513,21 @@ pub struct PluginMcpServerContribution {
/// Development-only escape hatch for absolute commands. /// Development-only escape hatch for absolute commands.
#[serde(default)] #[serde(default)]
pub allow_absolute_command: bool, 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<PluginMcpToolMetadata>,
}
/// 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<String>,
} }
/// Validated contribution set. /// Validated contribution set.

View File

@ -56,8 +56,8 @@ use application::{
ReconcileLayoutsInput, ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, ReconcileLayoutsInput, ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput,
RenameSprintInput, ReorderSprintsInput, ResizeTerminalInput, ResolveAgentPermissionsInput, RenameSprintInput, ReorderSprintsInput, ResizeTerminalInput, ResolveAgentPermissionsInput,
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput, ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
RotateConversationLogInput, SetActiveLayoutInput, StopLiveAgentInput, RotateConversationLogInput, SetActiveLayoutInput, SetAgentMcpToolAllowedInput,
SyncAgentWithTemplateInput, TouchDeviceInput, UnassignSkillFromAgentInput, StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput, UnassignSkillFromAgentInput,
UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateAgentContactableInput, UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateAgentContactableInput,
UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput,
UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput, UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput,
@ -99,11 +99,12 @@ use backend::dto::{
ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto, ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto,
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto, ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto,
SaveProfileRequestDto, SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, SetActiveLayoutResultDto,
SkillListDto, SprintCreateRequestDto, SprintDeleteRequestDto, SprintDto, SprintListDto, SetAgentMcpToolAllowedRequestDto, SkillDto, SkillListDto, SprintCreateRequestDto,
SprintListRequestDto, SprintRenameRequestDto, SprintReorderRequestDto, StopLiveAgentRequestDto, SprintDeleteRequestDto, SprintDto, SprintListDto, SprintListRequestDto, SprintRenameRequestDto,
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, SprintReorderRequestDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto,
TemplateListDto, TerminalSessionDto, TicketAssignRequestDto, TicketAttachmentAddRequestDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
TerminalSessionDto, TicketAssignRequestDto, TicketAttachmentAddRequestDto,
TicketAttachmentContentDto, TicketAttachmentMarkSummarizedRequestDto, TicketAttachmentContentDto, TicketAttachmentMarkSummarizedRequestDto,
TicketAttachmentReadRequestDto, TicketCarnetDto, TicketCreateRequestDto, TicketAttachmentReadRequestDto, TicketCarnetDto, TicketCreateRequestDto,
TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, TicketListPageInput, TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, TicketListPageInput,
@ -3437,6 +3438,9 @@ async fn invoke(
"update_agent_mcp_tool_permissions" => { "update_agent_mcp_tool_permissions" => {
invoke_update_agent_mcp_tool_permissions(&request.args, &state.app).await 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, "list_memories" => invoke_list_memories(&request.args, &state.app).await,
"get_memory" => invoke_get_memory(&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, "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) serde_json::to_value(output).map_err(serialization_error)
} }
async fn invoke_set_agent_mcp_tool_allowed(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request =
required_request::<SetAgentMcpToolAllowedRequestDto>("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<Value, ErrorDto> { async fn invoke_list_memories(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let project = let project =
resolve_project_readonly(string_arg(args, "projectId", "list_memories")?, state).await?; resolve_project_readonly(string_arg(args, "projectId", "list_memories")?, state).await?;

View File

@ -11,6 +11,7 @@ import { HttpInvoker, defaultFetch } from "./httpInvoker";
import { import {
HttpProjectGateway, HttpProjectGateway,
HttpGitGateway, HttpGitGateway,
HttpPermissionGateway,
HttpTemplateGateway, HttpTemplateGateway,
} from "./requestResponseGateways"; } from "./requestResponseGateways";
@ -191,6 +192,42 @@ describe("defaultFetch", () => {
}); });
describe("request/response gateways preserve the Tauri command contract", () => { 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 () => { it("HttpProjectGateway.createProject maps to create_project with a request envelope", async () => {
const http = new HttpInvoker({ baseUrl: "https://h" }); const http = new HttpInvoker({ baseUrl: "https://h" });
const spy = vi.spyOn(http, "invoke").mockResolvedValue({ id: "p1" } as never); const spy = vi.spyOn(http, "invoke").mockResolvedValue({ id: "p1" } as never);

View File

@ -505,6 +505,16 @@ export class HttpPermissionGateway implements PermissionGateway {
request: { projectId, agentId, policy }, request: { projectId, agentId, policy },
}); });
} }
setAgentMcpToolAllowed(
projectId: string,
agentId: string,
toolName: string,
allowed: boolean,
): Promise<ProjectMcpToolPermissions> {
return this.http.invoke<ProjectMcpToolPermissions>("set_agent_mcp_tool_allowed", {
request: { projectId, agentId, toolName, allowed },
});
}
} }
export class HttpWorkStateGateway implements WorkStateGateway { export class HttpWorkStateGateway implements WorkStateGateway {

View File

@ -3080,6 +3080,31 @@ export class MockPermissionGateway implements PermissionGateway {
if (policy) doc.agents.push({ agentId, policy: structuredClone(policy) }); if (policy) doc.agents.push({ agentId, policy: structuredClone(policy) });
return structuredClone(doc); return structuredClone(doc);
} }
async setAgentMcpToolAllowed(
projectId: string,
agentId: string,
toolName: string,
allowed: boolean,
): Promise<ProjectMcpToolPermissions> {
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 { export class MockWorkStateGateway implements WorkStateGateway {

View File

@ -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,
},
});
});
});

View File

@ -107,4 +107,15 @@ export class TauriPermissionGateway implements PermissionGateway {
{ request: { projectId, agentId, policy } }, { request: { projectId, agentId, policy } },
); );
} }
setAgentMcpToolAllowed(
projectId: string,
agentId: string,
toolName: string,
allowed: boolean,
): Promise<ProjectMcpToolPermissions> {
return invoke<ProjectMcpToolPermissions>("set_agent_mcp_tool_allowed", {
request: { projectId, agentId, toolName, allowed },
});
}
} }

View File

@ -918,6 +918,14 @@ export interface AgentMcpToolPolicyOverride {
export interface McpToolCatalogue { export interface McpToolCatalogue {
readOnlyTools: string[]; readOnlyTools: string[];
writeActionTools: 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. */ /** Full per-project MCP tool permission document, mirroring the backend DTO. */
@ -2365,4 +2373,6 @@ export interface PluginMcpServerSummary {
transport?: "stdio"; transport?: "stdio";
autoStart?: boolean; autoStart?: boolean;
allowAbsoluteCommand?: boolean; allowAbsoluteCommand?: boolean;
/** Optional static tool catalogue; absence means runtime-only discovery. */
tools?: Array<{ name: string; description?: string }>;
} }

View File

@ -68,50 +68,6 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
void templateGw.listTemplates().then(setTemplates).catch(() => {}); void templateGw.listTemplates().then(setTemplates).catch(() => {});
} }
// Skills available for assignment (global + project), loaded lazily once.
const skillGw = gateways.skill ?? null;
const [skills, setSkills] = useState<import("@/domain").Skill[]>([]);
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 // Context editor state — local copy before Save
const [editedContext, setEditedContext] = useState(vm.context); const [editedContext, setEditedContext] = useState(vm.context);
@ -668,82 +624,16 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
</div> </div>
)} )}
{/* ── Skill assignment ── */} {selectedAgent && gateways.plugin && (
{selectedAgent && skillGw && (
<div className="flex flex-col gap-2 border-t border-border p-4">
<h4 className="text-sm font-semibold text-content">
Skills — {selectedAgent.name}
</h4>
{/* Assigned skills */}
{selectedAgent.skills.length === 0 ? (
<p className="text-xs text-muted">No skills assigned.</p>
) : (
<ul className="flex flex-wrap gap-1.5">
{selectedAgent.skills.map((ref) => {
const name =
skills.find((s) => s.id === ref.skillId)?.name ?? ref.skillId;
return (
<li
key={ref.skillId}
className="flex items-center gap-1 rounded-full bg-raised px-2 py-0.5 text-xs text-content"
>
<span>{name}</span>
<button
type="button"
aria-label={`unassign ${name}`}
disabled={vm.busy}
onClick={() => void handleUnassignSkill(ref.skillId)}
className="text-faint hover:text-danger"
>
✕
</button>
</li>
);
})}
</ul>
)}
{/* Assign selector */}
<div className="flex items-end gap-2">
<SmallDropdown
aria-label="skill to assign"
value={skillToAssign}
onChange={setSkillToAssign}
disabled={vm.busy}
className="min-w-0 flex-1"
size="md"
options={[
{ value: "", label: "— assign a skill —" },
...skills
.filter(
(s) =>
!selectedAgent.skills.some((r) => r.skillId === s.id),
)
.map((s) => ({
value: s.id,
label: `${s.name} (${s.scope})`,
})),
]}
/>
<Button
variant="primary"
aria-label="assign skill"
disabled={vm.busy || !skillToAssign}
onClick={() => void handleAssignSkill()}
>
Assign
</Button>
</div>
</div>
)}
{selectedAgent && (
<div className="border-t border-border p-4"> <div className="border-t border-border p-4">
<AgentPluginCapabilities <AgentPluginCapabilities
projectId={projectId} projectId={projectId}
agentId={selectedAgent.id} agent={selectedAgent}
agentName={selectedAgent.name} active={
activeAgentId === selectedAgent.id ||
vm.liveAgents.some((live) => live.agentId === selectedAgent.id)
}
onAgentChanged={vm.refresh}
/> />
</div> </div>
)} )}

View File

@ -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 { useGateways } from "@/app/di";
import type { PluginSkillRef, PluginToolRef } from "@/domain"; import type {
Agent,
McpToolCatalogueEntry,
PluginSkillRef,
PluginToolRef,
ProjectMcpToolPermissions,
Skill,
SkillRef,
} from "@/domain";
import { Button, Input, SmallDropdown, Spinner } from "@/shared";
import { useProjectPlugins } from "./useProjectPlugins"; import { useProjectPlugins } from "./useProjectPlugins";
interface AgentPluginCapabilitiesProps { interface AgentPluginCapabilitiesProps {
projectId: string; projectId: string;
agentId: string; agent: Agent;
agentName: string; active: boolean;
onAgentChanged?: () => Promise<void> | void;
} }
function skillKey(ref: PluginSkillRef): string { const pluginSkillKey = (ref: PluginSkillRef) => `${ref.pluginId}:${ref.skillId}`;
return `${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 { function Origin({ children }: { children: ReactNode }) {
return `${ref.pluginId}:${ref.serverId}:${ref.toolName}`; return <span className="shrink-0 text-[0.65rem] text-faint">{children}</span>;
}
function Tombstone({
label,
origin,
onRemove,
disabled,
}: {
label: string;
origin: string;
onRemove: () => void;
disabled: boolean;
}) {
return (
<div className="flex items-center justify-between gap-2 rounded border border-warning/40 px-3 py-2 text-xs">
<span className="min-w-0">
<span className="font-medium">{label}</span>{" "}
<span className="text-warning">— Indisponible</span>{" "}
<Origin>{origin}</Origin>
</span>
<Button size="sm" variant="ghost" disabled={disabled} onClick={onRemove}>
Retirer
</Button>
</div>
);
} }
export function AgentPluginCapabilities({ export function AgentPluginCapabilities({
projectId, projectId,
agentId, agent,
agentName, active,
onAgentChanged,
}: AgentPluginCapabilitiesProps) { }: AgentPluginCapabilitiesProps) {
const vm = useProjectPlugins(projectId); const { skill: skillGateway, permission: permissionGateway } = useGateways();
const plugins = useProjectPlugins(projectId);
const [nativeSkills, setNativeSkills] = useState<Skill[]>([]);
const [nativeAssignments, setNativeAssignments] = useState<SkillRef[]>(agent.skills);
const [permissions, setPermissions] = useState<ProjectMcpToolPermissions | null>(null);
const [loadingNative, setLoadingNative] = useState(true);
const [nativeBusy, setNativeBusy] = useState(false);
const [nativeError, setNativeError] = useState<string | null>(null);
const [serverChoice, setServerChoice] = useState(""); const [serverChoice, setServerChoice] = useState("");
const [toolName, setToolName] = useState(""); const [toolName, setToolName] = useState("");
const assignments = vm.settings?.agents.find((entry) => entry.agentId === agentId); const [restartRequired, setRestartRequired] = useState(false);
const assignedSkills = new Set((assignments?.skills ?? []).map(skillKey));
const assignedTools = assignments?.tools ?? []; useEffect(() => setNativeAssignments(agent.skills), [agent.skills]);
const enabledIds = new Set(
(vm.settings?.plugins ?? []).filter((entry) => entry.enabled).map((entry) => entry.pluginId), 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),
); );
const enabledPlugins = vm.installed.filter((plugin) => enabledIds.has(plugin.id)); }
const serverOptions = useMemo( await onAgentChanged?.();
() => enabledPlugins.flatMap((plugin) => markRestartRequired();
(plugin.contributes.mcpServers ?? []).map((server) => ({ } 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 knownPluginSkillKeys = new Set(
plugins.installed.flatMap((plugin) =>
(plugin.contributes.skills ?? []).map((skill) => `${plugin.id}:${skill.id}`),
),
);
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}`, value: `${plugin.id}\u0000${server.id}`,
label: `${plugin.displayName} — ${server.displayName}`, label: `${plugin.displayName} — ${server.displayName}`,
enabled: enabledPluginIds.has(plugin.id),
})), })),
), ),
[enabledPlugins], [plugins.installed, plugins.settings],
); );
const knownSkillKeys = new Set(vm.installed.flatMap((plugin) => async function setPluginSkill(pluginId: string, skillId: string, assigned: boolean) {
(plugin.contributes.skills ?? []).map((skill) => `${plugin.id}:${skill.id}`), await plugins.setSkillAssigned(agent.id, pluginId, skillId, assigned);
)); markRestartRequired();
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 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 [pluginId, serverId] = serverChoice.split("\u0000");
const name = toolName.trim(); const name = toolName.trim();
if (!pluginId || !serverId || !name) return; if (!pluginId || !serverId || !name) return;
await vm.setToolAssigned({ agentId, pluginId, serverId, toolName: name }, true); await setPluginTool({ pluginId, serverId, toolName: name }, true);
setToolName(""); setToolName("");
} }
if (vm.loading && !vm.settings) { const nativeAssignedKeys = new Set(nativeAssignments.map(nativeSkillKey));
return <div className="flex items-center gap-2 text-xs text-muted"><Spinner size={12} /> Chargement des capabilities de plugins…</div>; 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 (
<div className="flex items-center gap-2 text-xs text-muted">
<Spinner size={12} /> Chargement des capacités de l’agent…
</div>
);
} }
return ( return (
<div className="flex flex-col gap-4"> <section aria-label={`Capacités de l'agent — ${agent.name}`} className="flex flex-col gap-4">
<div> <div>
<h4 className="text-sm font-semibold text-content"> <h4 className="text-sm font-semibold text-content">
Capabilities de plugins — {agentName} Capacités de l&apos;agent — {agent.name}
</h4> </h4>
<p className="mt-1 text-xs text-muted"> <p className="mt-1 text-xs text-muted">
Attribuez à cet agent les skills et tools MCP fournis par les plugins activés Attribuez les skills et tools MCP disponibles pour cet agent.
dans les paramètres du projet.
</p> </p>
</div> </div>
{vm.error && <p role="alert" className="text-xs text-danger">{vm.error}</p>} {restartRequired && (
<p role="status" className="rounded border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
<section aria-label={`Skills de plugins — ${agentName}`} className="flex flex-col gap-2"> Relance requise pour appliquer ces changements à l’agent actif.
<h5 className="text-sm font-semibold text-content">Skills de plugins</h5> </p>
{enabledPlugins.every((plugin) => (plugin.contributes.skills ?? []).length === 0) ? (
<p className="text-xs text-muted">Aucun skill fourni par les plugins activés pour ce projet.</p>
) : (
enabledPlugins.map((plugin) => {
const skills = plugin.contributes.skills ?? [];
if (skills.length === 0) return null;
return (
<Panel key={plugin.id} className="flex flex-col gap-2">
<h6 className="text-xs font-semibold text-content">{plugin.displayName}</h6>
{skills.map((skill) => {
const assigned = assignedSkills.has(`${plugin.id}:${skill.id}`);
return (
<label key={skill.id} className="flex items-start gap-2 text-xs text-content">
<input
type="checkbox"
checked={assigned}
disabled={vm.busy}
onChange={(event) => void vm.setSkillAssigned(
agentId, plugin.id, skill.id, event.target.checked,
)} )}
/> {(nativeError || plugins.error) && (
<span><span className="font-medium">{skill.name}</span>{skill.description ? ` — ${skill.description}` : ""}</span> <p role="alert" className="text-xs text-danger">{nativeError ?? plugins.error}</p>
)}
<section aria-label={`Skills — ${agent.name}`} className="flex flex-col gap-2">
<h5 className="text-sm font-semibold text-content">Skills</h5>
{nativeSkills.length === 0 &&
plugins.installed.every(
(plugin) => (plugin.contributes.skills ?? []).length === 0,
) &&
staleNativeSkills.length === 0 &&
stalePluginSkills.length === 0 && (
<p className="text-xs text-muted">Aucun skill disponible.</p>
)}
{nativeSkills.map((skill) => {
const assigned = nativeAssignedKeys.has(`${skill.scope}:${skill.id}`);
return (
<label key={`native:${skill.scope}:${skill.id}`} className="flex items-start gap-2 rounded bg-raised px-3 py-2 text-xs text-content">
<input type="checkbox" checked={assigned} disabled={busy || !skillGateway} onChange={(event) => void setNativeSkill(skill, event.target.checked)} />
<span className="min-w-0 flex-1"><span className="font-medium">{skill.name}</span>{skill.description ? ` — ${skill.description}` : ""}</span>
<Origin>IdeA</Origin>
</label> </label>
); );
})} })}
</Panel> {staleNativeSkills.map((ref) => (
<Tombstone key={`stale-native:${nativeSkillKey(ref)}`} label={ref.skillId} origin="IdeA" disabled={busy} onRemove={() => 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 (
<label key={`plugin:${plugin.id}:${skill.id}`} className="flex items-start gap-2 rounded bg-raised px-3 py-2 text-xs text-content">
<input type="checkbox" checked={assigned} disabled={busy || (!enabled && !assigned)} onChange={(event) => void setPluginSkill(plugin.id, skill.id, event.target.checked)} />
<span className="min-w-0 flex-1"><span className="font-medium">{skill.name}</span>{skill.description ? ` — ${skill.description}` : ""}{!enabled && <span className="text-warning"> — Plugin désactivé</span>}</span>
<Origin>Plugin: {plugin.displayName}</Origin>
</label>
); );
}) }),
)} )}
{staleSkills.map((ref) => ( {stalePluginSkills.map((ref) => (
<div key={skillKey(ref)} className="flex items-center justify-between gap-2 rounded border border-warning/40 px-3 py-2 text-xs"> <Tombstone key={`stale-plugin:${pluginSkillKey(ref)}`} label={ref.skillId} origin={`Plugin: ${ref.pluginId}`} disabled={busy} onRemove={() => void setPluginSkill(ref.pluginId, ref.skillId, false)} />
<span>{ref.pluginId} · {ref.skillId} <span className="text-warning">— Obsolète ou manquant</span></span>
<Button size="sm" variant="ghost" disabled={vm.busy} onClick={() => void vm.setSkillAssigned(agentId, ref.pluginId, ref.skillId, false)}>Retirer</Button>
</div>
))} ))}
</section> </section>
<section aria-label={`Tools MCP de plugins — ${agentName}`} className="flex flex-col gap-2"> <section aria-label={`Tools MCP — ${agent.name}`} className="flex flex-col gap-2">
<h5 className="text-sm font-semibold text-content">Tools MCP de plugins</h5> <h5 className="text-sm font-semibold text-content">Tools MCP</h5>
{assignedTools.length === 0 ? ( {nativeTools.length === 0 &&
<p className="text-xs text-muted">Aucun tool MCP de plugin attribué à l’agent.</p> staticPluginToolKeys.size === 0 &&
) : ( advancedAssignedTools.length === 0 &&
<ul className="flex flex-col gap-1"> stalePluginTools.length === 0 &&
{assignedTools.map((ref) => { advancedServers.length === 0 && (
const stale = staleTools.some((candidate) => toolKey(candidate) === toolKey(ref)); <p className="text-xs text-muted">Aucun tool MCP disponible.</p>
)}
{nativeTools.map((tool) => (
<label key={`native-tool:${tool.name}`} className="flex items-start gap-2 rounded bg-raised px-3 py-2 text-xs text-content">
<input type="checkbox" checked={explicitNativeTools.has(tool.name)} disabled={busy || !permissionGateway} onChange={(event) => void setNativeTool(tool.name, event.target.checked)} />
<span className="min-w-0 flex-1"><span className="font-medium">{tool.name}</span>{tool.description ? ` — ${tool.description}` : ""}<span className="ml-1 text-faint">({tool.access === "read" ? "lecture" : "action"})</span></span>
<Origin>IdeA</Origin>
</label>
))}
{staleNativeTools.map((name) => (
<Tombstone key={`stale-native-tool:${name}`} label={name} origin="IdeA" disabled={busy} onRemove={() => 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 ( return (
<li key={toolKey(ref)} className="flex items-center justify-between gap-2 rounded bg-raised px-3 py-2 text-xs text-content"> <label key={`plugin-tool:${pluginToolKey(ref)}`} className="flex items-start gap-2 rounded bg-raised px-3 py-2 text-xs text-content">
<span>{ref.pluginId} · {ref.serverId} · <span className="font-medium">{ref.toolName}</span>{stale && <span className="text-warning"> — Obsolète ou manquant</span>}</span> <input type="checkbox" checked={assigned} disabled={busy || (!enabled && !assigned)} onChange={(event) => void setPluginTool(ref, event.target.checked)} />
<Button size="sm" variant="ghost" disabled={vm.busy} onClick={() => void vm.setToolAssigned({ agentId, ...ref }, false)}>Retirer</Button> <span className="min-w-0 flex-1"><span className="font-medium">{tool.name}</span>{tool.description ? ` — ${tool.description}` : ""}<span className="text-faint"> · {server.displayName}</span>{!enabled && <span className="text-warning"> — Plugin désactivé</span>}</span>
</li> <Origin>Plugin: {plugin.displayName}</Origin>
</label>
);
}),
),
)}
{advancedAssignedTools.map((ref) => {
const plugin = plugins.installed.find((candidate) => candidate.id === ref.pluginId);
const enabled = enabledPluginIds.has(ref.pluginId);
return (
<div key={`advanced:${pluginToolKey(ref)}`} className="flex items-center justify-between gap-2 rounded bg-raised px-3 py-2 text-xs">
<span><span className="font-medium">{ref.toolName}</span> · {ref.serverId}{!enabled && <span className="text-warning"> — Plugin désactivé</span>} <Origin>Plugin: {plugin?.displayName ?? ref.pluginId}</Origin></span>
<Button size="sm" variant="ghost" disabled={busy} onClick={() => void setPluginTool(ref, false)}>Retirer</Button>
</div>
); );
})} })}
</ul> {stalePluginTools.map((ref) => (
)} <Tombstone key={`stale-tool:${pluginToolKey(ref)}`} label={`${ref.serverId} · ${ref.toolName}`} origin={`Plugin: ${ref.pluginId}`} disabled={busy} onRemove={() => void setPluginTool(ref, false)} />
))}
{serverOptions.length > 0 && ( {advancedServers.length > 0 && (
<div className="flex items-end gap-2"> <details>
<SmallDropdown <summary className="cursor-pointer text-xs font-medium text-primary">Ajout avancé d’un tool MCP</summary>
aria-label="serveur MCP de plugin" <div className="mt-2 flex items-end gap-2">
value={serverChoice} <SmallDropdown aria-label="serveur MCP sans catalogue statique" value={serverChoice} onChange={setServerChoice} disabled={busy} className="min-w-0 flex-1" size="md" options={[{ value: "", label: "— choisir un serveur MCP —" }, ...advancedServers.map((server) => ({ value: server.value, label: `${server.label}${server.enabled ? "" : " — désactivé"}`, disabled: !server.enabled }))]} />
onChange={setServerChoice} <Input aria-label="nom avancé du tool MCP" value={toolName} onChange={(event) => setToolName(event.target.value)} placeholder="Nom exact du tool" disabled={busy} />
disabled={vm.busy} <Button variant="primary" disabled={busy || !serverChoice || !toolName.trim()} onClick={() => void assignAdvancedTool()}>Attribuer</Button>
className="min-w-0 flex-1"
size="md"
options={[{ value: "", label: "— choisir un serveur MCP —" }, ...serverOptions]}
/>
<Input aria-label="nom du tool MCP" value={toolName} onChange={(event) => setToolName(event.target.value)} placeholder="Nom du tool" disabled={vm.busy} />
<Button variant="primary" disabled={vm.busy || !serverChoice || !toolName.trim()} onClick={() => void assignTool()}>Attribuer</Button>
</div> </div>
</details>
)} )}
</section> </section>
</div> </section>
); );
} }

View File

@ -2,7 +2,11 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { DIProvider } from "@/app/di"; import { DIProvider } from "@/app/di";
import { createMockGateways, MockPluginGateway } from "@/adapters/mock"; import {
createMockGateways,
MockPermissionGateway,
MockPluginGateway,
} from "@/adapters/mock";
import type { ProjectPluginManifest } from "@/domain"; import type { ProjectPluginManifest } from "@/domain";
import { AgentPluginCapabilities } from "./AgentPluginCapabilities"; import { AgentPluginCapabilities } from "./AgentPluginCapabilities";
import { ProjectPluginsPanel } from "./ProjectPluginsPanel"; import { ProjectPluginsPanel } from "./ProjectPluginsPanel";
@ -29,7 +33,15 @@ function manifest(): ProjectPluginManifest {
menus: [], menus: [],
menuItems: [], menuItems: [],
layouts: [], 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", () => {
</DIProvider>, </DIProvider>,
); );
fireEvent.click(await screen.findByRole("button", { name: "Attribuer aux agents" })); fireEvent.click(await screen.findByRole("button", { name: "Capacités des agents" }));
expect(onAssignAgents).toHaveBeenCalledOnce(); expect(onAssignAgents).toHaveBeenCalledOnce();
}); });
it("assigns plugin skills and MCP tools to an agent and keeps stale references removable", async () => { it("assigns plugin skills and MCP tools to an agent and keeps stale references removable", async () => {
const gateways = gatewaysWithPlugin(); const gateways = gatewaysWithPlugin();
const agent = await gateways.agent.createAgent("project-1", {
name: "Dev",
profileId: "profile-1",
});
gateways.plugin._seedProjectPluginSettings("project-1", { gateways.plugin._seedProjectPluginSettings("project-1", {
version: 1, version: 1,
plugins: [{ pluginId: "dev.acme.unity", enabled: true }], plugins: [{ pluginId: "dev.acme.unity", enabled: true }],
agents: [{ agents: [{
agentId: "agent-1", agentId: agent.id,
skills: [{ pluginId: "missing.plugin", skillId: "removed-skill" }], skills: [{ pluginId: "missing.plugin", skillId: "removed-skill" }],
tools: [], tools: [],
}], }],
}); });
render( render(
<DIProvider gateways={gateways}> <DIProvider gateways={gateways}>
<AgentPluginCapabilities projectId="project-1" agentId="agent-1" agentName="Dev" /> <AgentPluginCapabilities projectId="project-1" agent={agent} active />
</DIProvider>, </DIProvider>,
); );
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/ }); const skill = await screen.findByRole("checkbox", { name: /unity-debug/ });
fireEvent.click(skill); fireEvent.click(skill);
await waitFor(() => expect((skill as HTMLInputElement).checked).toBe(true)); 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")); const staticTool = screen.getByRole("checkbox", { name: /unity_build/ });
fireEvent.click(screen.getByRole("option", { name: "Unity Plugin — Unity Tools" })); fireEvent.click(staticTool);
fireEvent.change(screen.getByLabelText("nom du tool MCP"), { await waitFor(() => expect((staticTool as HTMLInputElement).checked).toBe(true));
target: { value: "unity_build" },
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" })); 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 settings = (await gateways.plugin.listProjectSettings("project-1")).settings;
const agent = settings.agents.find((entry) => entry.agentId === "agent-1"); const assigned = settings.agents.find((entry) => entry.agentId === agent.id);
expect(agent?.skills).toContainEqual({ pluginId: "dev.acme.unity", skillId: "unity.debug" }); expect(assigned?.skills).toContainEqual({ pluginId: "dev.acme.unity", skillId: "unity.debug" });
expect(agent?.tools).toContainEqual({ expect(assigned?.tools).toContainEqual({
pluginId: "dev.acme.unity", pluginId: "dev.acme.unity",
serverId: "unity-tools", serverId: "unity-tools",
toolName: "unity_build", 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(
<DIProvider gateways={gateways}>
<AgentPluginCapabilities projectId="project-1" agent={agent} active />
</DIProvider>,
);
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(
<DIProvider gateways={gateways}>
<AgentPluginCapabilities projectId="project-1" agent={agent} active={false} />
</DIProvider>,
);
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" }),
);
});
}); });
}); });

View File

@ -36,13 +36,13 @@ export function ProjectPluginsPanel({
<div> <div>
<h2 className="text-sm font-semibold text-content">Plugins du projet</h2> <h2 className="text-sm font-semibold text-content">Plugins du projet</h2>
<p className="mt-1 text-xs text-muted"> <p className="mt-1 text-xs text-muted">
Activez les plugins utilisés par ce projet, puis attribuez leurs skills et Activez les plugins utilisés par ce projet. Les attributions se gèrent
tools MCP à chaque agent. depuis la fiche de chaque agent.
</p> </p>
</div> </div>
{onAssignAgents && ( {onAssignAgents && (
<Button size="sm" variant="primary" onClick={onAssignAgents}> <Button size="sm" variant="ghost" onClick={onAssignAgents}>
Attribuer aux agents Capacités des agents
</Button> </Button>
)} )}
</div> </div>

View File

@ -17,6 +17,8 @@ import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { import {
MockAgentGateway, MockAgentGateway,
MockPermissionGateway,
MockPluginGateway,
MockProfileGateway, MockProfileGateway,
MockSkillGateway, MockSkillGateway,
} from "@/adapters/mock"; } from "@/adapters/mock";
@ -44,7 +46,13 @@ function renderSkillsPanel(agent?: MockAgentGateway, skill?: MockSkillGateway) {
function renderAgentsPanel(agent: MockAgentGateway, skill: MockSkillGateway) { function renderAgentsPanel(agent: MockAgentGateway, skill: MockSkillGateway) {
const profile = new MockProfileGateway(); 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( return render(
<DIProvider gateways={gateways}> <DIProvider gateways={gateways}>
<AgentsPanel projectId={PROJECT_ID} projectRoot="/tmp/proj" /> <AgentsPanel projectId={PROJECT_ID} projectRoot="/tmp/proj" />
@ -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. */ /** Opens the SkillEditor, fills it, and saves. */
async function createSkill( async function createSkill(
name: string, name: string,
@ -172,7 +175,7 @@ describe("SkillsPanel (with MockSkillGateway)", () => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe("Skill assignment (AgentsPanel + 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 agent = new MockAgentGateway();
const skill = new MockSkillGateway(agent); const skill = new MockSkillGateway(agent);
const created = await agent.createAgent(PROJECT_ID, { const created = await agent.createAgent(PROJECT_ID, {
@ -191,14 +194,9 @@ describe("Skill assignment (AgentsPanel + MockSkillGateway)", () => {
// Select the agent // Select the agent
fireEvent.click(screen.getByText("Worker")); fireEvent.click(screen.getByText("Worker"));
await waitFor(() => const toggle = await screen.findByRole("checkbox", { name: /Deploy/ });
expect(screen.getByLabelText("skill to assign")).toBeTruthy(),
);
// Choose the skill and assign
const sk = (await skill.listSkills(PROJECT_ID, "project"))[0]; const sk = (await skill.listSkills(PROJECT_ID, "project"))[0];
chooseDropdownOption("skill to assign", "Deploy (project)"); fireEvent.click(toggle);
fireEvent.click(screen.getByRole("button", { name: "assign skill" }));
// The agent record now carries the skill ref // The agent record now carries the skill ref
await waitFor(async () => { await waitFor(async () => {
@ -206,8 +204,7 @@ describe("Skill assignment (AgentsPanel + MockSkillGateway)", () => {
const a = agents.find((x) => x.id === created.id)!; const a = agents.find((x) => x.id === created.id)!;
expect(a.skills.map((s) => s.skillId)).toContain(sk.id); expect(a.skills.map((s) => s.skillId)).toContain(sk.id);
}); });
// Chip with an unassign control appears expect((toggle as HTMLInputElement).checked).toBe(true);
expect(screen.getByRole("button", { name: "unassign Deploy" })).toBeTruthy();
}); });
it("unassigns a skill from the agent", async () => { 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()); await waitFor(() => expect(screen.getByLabelText("agent name")).toBeTruthy());
fireEvent.click(screen.getByText("Worker2")); fireEvent.click(screen.getByText("Worker2"));
await waitFor(() => const toggle = await screen.findByRole("checkbox", { name: /Cleanup/ });
expect( expect((toggle as HTMLInputElement).checked).toBe(true);
screen.getByRole("button", { name: "unassign Cleanup" }), fireEvent.click(toggle);
).toBeTruthy(),
);
fireEvent.click(screen.getByRole("button", { name: "unassign Cleanup" }));
await waitFor(async () => { await waitFor(async () => {
const agents = await agent.listAgents(PROJECT_ID); const agents = await agent.listAgents(PROJECT_ID);

View File

@ -1133,6 +1133,13 @@ export interface PermissionGateway {
agentId: string, agentId: string,
policy: McpToolPolicy | null, policy: McpToolPolicy | null,
): Promise<ProjectMcpToolPermissions>; ): Promise<ProjectMcpToolPermissions>;
/** Atomically toggles one native IdeA MCP tool for an agent. */
setAgentMcpToolAllowed(
projectId: string,
agentId: string,
toolName: string,
allowed: boolean,
): Promise<ProjectMcpToolPermissions>;
} }
/** Read-only live work-state read-model for conversations/delegations. */ /** Read-only live work-state read-model for conversations/delegations. */