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:
@ -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)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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"]
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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::{
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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?;
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
30
frontend/src/adapters/permission.test.ts
Normal file
30
frontend/src/adapters/permission.test.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 }>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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 knownPluginSkillKeys = new Set(
|
||||||
const serverOptions = useMemo(
|
plugins.installed.flatMap((plugin) =>
|
||||||
() => enabledPlugins.flatMap((plugin) =>
|
(plugin.contributes.skills ?? []).map((skill) => `${plugin.id}:${skill.id}`),
|
||||||
(plugin.contributes.mcpServers ?? []).map((server) => ({
|
|
||||||
value: `${plugin.id}\u0000${server.id}`,
|
|
||||||
label: `${plugin.displayName} — ${server.displayName}`,
|
|
||||||
})),
|
|
||||||
),
|
),
|
||||||
[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) =>
|
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'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">
|
||||||
|
Relance requise pour appliquer ces changements à l’agent actif.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{(nativeError || plugins.error) && (
|
||||||
|
<p role="alert" className="text-xs text-danger">{nativeError ?? plugins.error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<section aria-label={`Skills de plugins — ${agentName}`} className="flex flex-col gap-2">
|
<section aria-label={`Skills — ${agent.name}`} className="flex flex-col gap-2">
|
||||||
<h5 className="text-sm font-semibold text-content">Skills de plugins</h5>
|
<h5 className="text-sm font-semibold text-content">Skills</h5>
|
||||||
{enabledPlugins.every((plugin) => (plugin.contributes.skills ?? []).length === 0) ? (
|
{nativeSkills.length === 0 &&
|
||||||
<p className="text-xs text-muted">Aucun skill fourni par les plugins activés pour ce projet.</p>
|
plugins.installed.every(
|
||||||
) : (
|
(plugin) => (plugin.contributes.skills ?? []).length === 0,
|
||||||
enabledPlugins.map((plugin) => {
|
) &&
|
||||||
const skills = plugin.contributes.skills ?? [];
|
staleNativeSkills.length === 0 &&
|
||||||
if (skills.length === 0) return null;
|
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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{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 (
|
return (
|
||||||
<Panel key={plugin.id} className="flex flex-col gap-2">
|
<label key={`plugin:${plugin.id}:${skill.id}`} className="flex items-start gap-2 rounded bg-raised px-3 py-2 text-xs text-content">
|
||||||
<h6 className="text-xs font-semibold text-content">{plugin.displayName}</h6>
|
<input type="checkbox" checked={assigned} disabled={busy || (!enabled && !assigned)} onChange={(event) => void setPluginSkill(plugin.id, skill.id, event.target.checked)} />
|
||||||
{skills.map((skill) => {
|
<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>
|
||||||
const assigned = assignedSkills.has(`${plugin.id}:${skill.id}`);
|
<Origin>Plugin: {plugin.displayName}</Origin>
|
||||||
return (
|
</label>
|
||||||
<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,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span><span className="font-medium">{skill.name}</span>{skill.description ? ` — ${skill.description}` : ""}</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Panel>
|
|
||||||
);
|
);
|
||||||
})
|
}),
|
||||||
)}
|
)}
|
||||||
{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>
|
||||||
);
|
);
|
||||||
})}
|
}),
|
||||||
</ul>
|
),
|
||||||
)}
|
)}
|
||||||
|
{advancedAssignedTools.map((ref) => {
|
||||||
{serverOptions.length > 0 && (
|
const plugin = plugins.installed.find((candidate) => candidate.id === ref.pluginId);
|
||||||
<div className="flex items-end gap-2">
|
const enabled = enabledPluginIds.has(ref.pluginId);
|
||||||
<SmallDropdown
|
return (
|
||||||
aria-label="serveur MCP de plugin"
|
<div key={`advanced:${pluginToolKey(ref)}`} className="flex items-center justify-between gap-2 rounded bg-raised px-3 py-2 text-xs">
|
||||||
value={serverChoice}
|
<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>
|
||||||
onChange={setServerChoice}
|
<Button size="sm" variant="ghost" disabled={busy} onClick={() => void setPluginTool(ref, false)}>Retirer</Button>
|
||||||
disabled={vm.busy}
|
</div>
|
||||||
className="min-w-0 flex-1"
|
);
|
||||||
size="md"
|
})}
|
||||||
options={[{ value: "", label: "— choisir un serveur MCP —" }, ...serverOptions]}
|
{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)} />
|
||||||
<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>
|
{advancedServers.length > 0 && (
|
||||||
</div>
|
<details>
|
||||||
|
<summary className="cursor-pointer text-xs font-medium text-primary">Ajout avancé d’un tool MCP</summary>
|
||||||
|
<div className="mt-2 flex items-end gap-2">
|
||||||
|
<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 }))]} />
|
||||||
|
<Input aria-label="nom avancé du tool MCP" value={toolName} onChange={(event) => setToolName(event.target.value)} placeholder="Nom exact du tool" disabled={busy} />
|
||||||
|
<Button variant="primary" disabled={busy || !serverChoice || !toolName.trim()} onClick={() => void assignAdvancedTool()}>Attribuer</Button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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. */
|
||||||
|
|||||||
Reference in New Issue
Block a user