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,
|
||||
ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput,
|
||||
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
|
||||
RotateConversationLogInput, SaveChatDraftInput, SetActiveLayoutInput, SlashCommandEffect,
|
||||
SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput,
|
||||
UnassignSkillFromAgentInput, UpdateAgentContactableInput, UpdateAgentContextInput,
|
||||
UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
|
||||
RotateConversationLogInput, SaveChatDraftInput, SetActiveLayoutInput,
|
||||
SetAgentMcpToolAllowedInput, SlashCommandEffect, SnapshotRunningAgentsInput,
|
||||
StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput,
|
||||
UpdateAgentContactableInput, UpdateAgentContextInput, UpdateAgentEffortInput,
|
||||
UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
|
||||
UpdateAgentSystemPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput,
|
||||
UpdateProjectMcpToolPermissionsInput, UpdateProjectOrchestratorInput,
|
||||
UpdateProjectPermissionsInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput,
|
||||
@ -74,10 +75,10 @@ use crate::dto::{
|
||||
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
|
||||
ResumableAgentListDto, SaveChatDraftRequestDto, SaveEmbedderProfileRequestDto,
|
||||
SaveModelServerRequestDto, SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto,
|
||||
SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto,
|
||||
SlashCommandEffectDto, SlashCommandListDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto,
|
||||
SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
|
||||
TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
|
||||
SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SetAgentMcpToolAllowedRequestDto,
|
||||
SkillDto, SkillListDto, SlashCommandEffectDto, SlashCommandListDto, StopLiveAgentRequestDto,
|
||||
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
|
||||
TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
|
||||
UpdateAgentContactableRequestDto, UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto,
|
||||
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
|
||||
UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
||||
@ -984,6 +985,26 @@ pub async fn update_agent_mcp_tool_permissions(
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// Atomically adds or removes one native MCP tool from an agent override.
|
||||
#[tauri::command]
|
||||
pub async fn set_agent_mcp_tool_allowed(
|
||||
request: SetAgentMcpToolAllowedRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -271,6 +271,7 @@ pub fn run() {
|
||||
commands::get_mcp_tool_permissions,
|
||||
commands::update_project_mcp_tool_permissions,
|
||||
commands::update_agent_mcp_tool_permissions,
|
||||
commands::set_agent_mcp_tool_allowed,
|
||||
commands::open_terminal,
|
||||
commands::write_terminal,
|
||||
commands::resize_terminal,
|
||||
|
||||
@ -142,8 +142,9 @@ pub use managed_temp::{
|
||||
ResolveManagedTempOutput, MANAGED_TEMP_DEFAULT_TTL_MS, MANAGED_TEMP_PURPOSE_MAX_CHARS,
|
||||
};
|
||||
pub use mcp_tool_permissions::{
|
||||
McpToolPermissionCatalogue, ReadMcpToolPermissions, ReadMcpToolPermissionsInput,
|
||||
ReadMcpToolPermissionsOutput, UpdateAgentMcpToolPermissions,
|
||||
McpToolCatalogueAccess, McpToolCatalogueEntry, McpToolPermissionCatalogue,
|
||||
ReadMcpToolPermissions, ReadMcpToolPermissionsInput, ReadMcpToolPermissionsOutput,
|
||||
SetAgentMcpToolAllowed, SetAgentMcpToolAllowedInput, UpdateAgentMcpToolPermissions,
|
||||
UpdateAgentMcpToolPermissionsInput, UpdateProjectMcpToolPermissions,
|
||||
UpdateProjectMcpToolPermissionsInput,
|
||||
};
|
||||
|
||||
@ -22,6 +22,28 @@ pub struct McpToolPermissionCatalogue {
|
||||
pub read_only_tools: Vec<String>,
|
||||
/// Tools denied by default because they write, act, or execute.
|
||||
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 {
|
||||
@ -33,14 +55,43 @@ impl McpToolPermissionCatalogue {
|
||||
read_only_tools: Vec<String>,
|
||||
write_action_tools: Vec<String>,
|
||||
) -> 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 {
|
||||
read_only_tools,
|
||||
write_action_tools,
|
||||
tools,
|
||||
};
|
||||
catalogue.validate()?;
|
||||
Ok(catalogue)
|
||||
}
|
||||
|
||||
/// Enriches the structured view with canonical MCP descriptions while
|
||||
/// retaining the legacy classified name arrays unchanged.
|
||||
#[must_use]
|
||||
pub fn with_descriptions<'a>(
|
||||
mut self,
|
||||
descriptions: impl IntoIterator<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.
|
||||
#[must_use]
|
||||
pub fn known_tool_refs(&self) -> Vec<&str> {
|
||||
@ -218,6 +269,89 @@ pub struct UpdateAgentMcpToolPermissionsInput {
|
||||
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(
|
||||
policy: Option<&McpToolPolicy>,
|
||||
catalogue: &McpToolPermissionCatalogue,
|
||||
@ -399,4 +533,71 @@ mod tests {
|
||||
vec!["idea_memory_write"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn granular_agent_tool_toggle_is_idempotent_and_preserves_other_tools() {
|
||||
let shared = store(ProjectMcpToolPermissions::default());
|
||||
let toggle = SetAgentMcpToolAllowed::new(Arc::clone(&shared), catalogue());
|
||||
let agent_id = agent(11);
|
||||
|
||||
for tool_name in ["idea_memory_write", "idea_ticket_list", "idea_memory_write"] {
|
||||
toggle
|
||||
.execute(SetAgentMcpToolAllowedInput {
|
||||
project: project(),
|
||||
agent_id,
|
||||
tool_name: tool_name.to_owned(),
|
||||
allowed: true,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let output = toggle
|
||||
.execute(SetAgentMcpToolAllowedInput {
|
||||
project: project(),
|
||||
agent_id,
|
||||
tool_name: "idea_memory_write".to_owned(),
|
||||
allowed: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(output.permissions.agents.len(), 1);
|
||||
assert_eq!(
|
||||
output.permissions.agents[0].policy.allowed_tools,
|
||||
vec!["idea_ticket_list"]
|
||||
);
|
||||
|
||||
let output = toggle
|
||||
.execute(SetAgentMcpToolAllowedInput {
|
||||
project: project(),
|
||||
agent_id,
|
||||
tool_name: "idea_memory_write".to_owned(),
|
||||
allowed: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
output.permissions.agents[0].policy.allowed_tools,
|
||||
vec!["idea_ticket_list"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn granular_agent_tool_toggle_rejects_unknown_tool_without_mutating() {
|
||||
let shared = store(ProjectMcpToolPermissions::default());
|
||||
let toggle = SetAgentMcpToolAllowed::new(Arc::clone(&shared), catalogue());
|
||||
|
||||
let error = toggle
|
||||
.execute(SetAgentMcpToolAllowedInput {
|
||||
project: project(),
|
||||
agent_id: agent(12),
|
||||
tool_name: "idea_unknown".to_owned(),
|
||||
allowed: true,
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(error, AppError::Invalid(message) if message.contains("idea_unknown")));
|
||||
let loaded = shared.load_mcp_tool_permissions(&project()).await.unwrap();
|
||||
assert!(loaded.agents.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@ -3416,6 +3416,16 @@ struct RawMcpServer {
|
||||
auto_start: bool,
|
||||
#[serde(default)]
|
||||
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 {
|
||||
@ -3652,6 +3662,38 @@ fn validate_contributes(raw: RawContributes) -> Result<PluginContributionSet, Pl
|
||||
.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 {
|
||||
id: domain::PluginMcpServerId::new(s.id)
|
||||
.map_err(|e| PluginManifestError::Invalid(e.to_string()))?,
|
||||
@ -3663,6 +3705,7 @@ fn validate_contributes(raw: RawContributes) -> Result<PluginContributionSet, Pl
|
||||
transport: s.transport,
|
||||
auto_start: s.auto_start,
|
||||
allow_absolute_command: s.allow_absolute_command,
|
||||
tools,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
@ -3744,7 +3787,7 @@ mod tests {
|
||||
"menuItems": [{"id":"dev.acme.open","targetMenuId":"panels","label":"Open","command":"dev.acme.open"}],
|
||||
"slashCommands": [{"name":"/graph","shortDescription":"Open graph tools","command":"dev.acme.open","requiresConfirmation":true}],
|
||||
"layouts": [{"type":"dev.acme.layout","label":"Graph","component":"Graph"}],
|
||||
"mcpServers": [{"id":"dev.acme.mcp","displayName":"Tools","command":"servers/tool","transport":"stdio","autoStart":true}]
|
||||
"mcpServers": [{"id":"dev.acme.mcp","displayName":"Tools","command":"servers/tool","transport":"stdio","autoStart":true,"tools":[{"name":"unity_get_scene","description":"Read the active Unity scene."},{"name":"unity_run_tests"}]}]
|
||||
}
|
||||
}"#.to_vec()
|
||||
}
|
||||
@ -4080,11 +4123,74 @@ mod tests {
|
||||
"skills/build-debug/SKILL.md"
|
||||
);
|
||||
assert_eq!(m.contributes.mcp_servers.len(), 1);
|
||||
assert_eq!(m.contributes.mcp_servers[0].tools.len(), 2);
|
||||
assert_eq!(
|
||||
m.contributes.mcp_servers[0].tools[0].name,
|
||||
"unity_get_scene"
|
||||
);
|
||||
assert_eq!(
|
||||
m.contributes.mcp_servers[0].tools[0].description.as_deref(),
|
||||
Some("Read the active Unity scene.")
|
||||
);
|
||||
let wire = serde_json::to_value(&m).unwrap();
|
||||
assert_eq!(
|
||||
wire["contributes"]["mcpServers"][0]["tools"],
|
||||
serde_json::json!([
|
||||
{"name": "unity_get_scene", "description": "Read the active Unity scene."},
|
||||
{"name": "unity_run_tests"}
|
||||
])
|
||||
);
|
||||
assert_eq!(m.contributes.slash_commands.len(), 1);
|
||||
assert_eq!(m.contributes.slash_commands[0].name, "/graph");
|
||||
assert!(m.contributes.slash_commands[0].requires_confirmation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_mcp_server_without_static_tool_metadata_for_compatibility() {
|
||||
let mut value: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap();
|
||||
value["contributes"]["mcpServers"][0]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("tools");
|
||||
|
||||
let manifest = validator()
|
||||
.validate(
|
||||
&serde_json::to_vec(&value).unwrap(),
|
||||
&domain::PluginPackageRef {
|
||||
plugin_id: None,
|
||||
root: "x".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(manifest.contributes.mcp_servers[0].tools.is_empty());
|
||||
let serialized = serde_json::to_value(&manifest).unwrap();
|
||||
assert!(serialized["contributes"]["mcpServers"][0]
|
||||
.get("tools")
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_static_mcp_tool_metadata() {
|
||||
let mut value: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap();
|
||||
value["contributes"]["mcpServers"][0]["tools"] = serde_json::json!([
|
||||
{"name": "unity_get_scene"},
|
||||
{"name": "unity_get_scene", "description": "Duplicate"}
|
||||
]);
|
||||
|
||||
let error = validator()
|
||||
.validate(
|
||||
&serde_json::to_vec(&value).unwrap(),
|
||||
&domain::PluginPackageRef {
|
||||
plugin_id: None,
|
||||
root: "x".into(),
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("duplicate MCP tool metadata"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_plugin_skill_outside_markdown_contract() {
|
||||
let mut value: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap();
|
||||
|
||||
@ -2952,6 +2952,20 @@ pub struct McpToolCatalogueDto {
|
||||
pub read_only_tools: Vec<String>,
|
||||
/// Tools treated as writing/action/execution tools.
|
||||
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.
|
||||
@ -2975,6 +2989,20 @@ impl From<ReadMcpToolPermissionsOutput> for ProjectMcpToolPermissionsDto {
|
||||
catalogue: McpToolCatalogueDto {
|
||||
read_only_tools: out.catalogue.read_only_tools,
|
||||
write_action_tools: out.catalogue.write_action_tools,
|
||||
tools: out
|
||||
.catalogue
|
||||
.tools
|
||||
.into_iter()
|
||||
.map(|tool| McpToolCatalogueEntryDto {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
access: match tool.access {
|
||||
application::McpToolCatalogueAccess::Read => "read",
|
||||
application::McpToolCatalogueAccess::WriteAction => "writeAction",
|
||||
}
|
||||
.to_owned(),
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
project_default: out.permissions.project_default,
|
||||
agents: out.permissions.agents,
|
||||
@ -3068,6 +3096,20 @@ pub struct UpdateAgentMcpToolPermissionsRequestDto {
|
||||
pub policy: Option<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`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@ -5782,7 +5824,11 @@ mod tests {
|
||||
vec!["idea_ticket_read".to_owned()],
|
||||
vec!["idea_ticket_update".to_owned()],
|
||||
)
|
||||
.unwrap(),
|
||||
.unwrap()
|
||||
.with_descriptions([
|
||||
("idea_ticket_read", "Read one ticket."),
|
||||
("idea_ticket_update", "Update one ticket."),
|
||||
]),
|
||||
permissions: ProjectMcpToolPermissions {
|
||||
version: 1,
|
||||
project_default: Some(
|
||||
@ -5804,7 +5850,19 @@ mod tests {
|
||||
"version": 1,
|
||||
"catalogue": {
|
||||
"readOnlyTools": ["idea_ticket_read"],
|
||||
"writeActionTools": ["idea_ticket_update"]
|
||||
"writeActionTools": ["idea_ticket_update"],
|
||||
"tools": [
|
||||
{
|
||||
"name": "idea_ticket_read",
|
||||
"description": "Read one ticket.",
|
||||
"access": "read"
|
||||
},
|
||||
{
|
||||
"name": "idea_ticket_update",
|
||||
"description": "Update one ticket.",
|
||||
"access": "writeAction"
|
||||
}
|
||||
]
|
||||
},
|
||||
"projectDefault": {
|
||||
"allowedTools": ["idea_ticket_read"]
|
||||
|
||||
@ -52,16 +52,17 @@ use application::{
|
||||
ResolveManagedTemp, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask,
|
||||
ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveChatDraft,
|
||||
SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveProfile,
|
||||
SessionLimitService, SetActiveLayout, SetPluginEnabled, SetProjectPluginEnabled,
|
||||
SlashCommandRegistry, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand,
|
||||
StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession,
|
||||
SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent,
|
||||
UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContactable,
|
||||
UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions, UpdateAgentPermissions,
|
||||
UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory,
|
||||
UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectOrchestrator,
|
||||
UpdateProjectPermissions, UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate,
|
||||
WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||
SessionLimitService, SetActiveLayout, SetAgentMcpToolAllowed, SetPluginEnabled,
|
||||
SetProjectPluginEnabled, SlashCommandRegistry, SnapshotOpenWindows, SnapshotRunningAgents,
|
||||
SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions,
|
||||
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
|
||||
UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues,
|
||||
UpdateAgentContactable, UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions,
|
||||
UpdateAgentPermissions, UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet,
|
||||
UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectMcpToolPermissions,
|
||||
UpdateProjectOrchestrator, UpdateProjectPermissions, UpdateProjectSystemPermissions,
|
||||
UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal,
|
||||
AGENT_MEMORY_RECALL_BUDGET,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use domain::events::AgentRestartPhase;
|
||||
@ -291,6 +292,32 @@ mod backend_core_build_tests {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_core_exposes_structured_native_mcp_catalogue_with_descriptions() {
|
||||
let dir = std::env::temp_dir().join(format!("idea-mcp-catalogue-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let core = BackendCore::build(dir.clone());
|
||||
let output = core
|
||||
.read_mcp_tool_permissions
|
||||
.execute(application::ReadMcpToolPermissionsInput {
|
||||
project: project(&dir),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
output.catalogue.tools.len(),
|
||||
output.catalogue.read_only_tools.len() + output.catalogue.write_action_tools.len()
|
||||
);
|
||||
assert!(output
|
||||
.catalogue
|
||||
.tools
|
||||
.iter()
|
||||
.all(|tool| !tool.name.is_empty() && !tool.description.is_empty()));
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_directory_resolution_accepts_relative_directory_under_project_root() {
|
||||
let root = std::env::temp_dir().join(format!("idea-plugin-root-{}", Uuid::new_v4()));
|
||||
@ -1939,6 +1966,8 @@ pub struct BackendCore {
|
||||
pub update_project_mcp_tool_permissions: Arc<UpdateProjectMcpToolPermissions>,
|
||||
/// Replace or clear one agent MCP tool policy override.
|
||||
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
|
||||
/// `LaunchAgent`/`ChangeAgentProfile` ; consommé par les commandes de chat (D4)
|
||||
/// pour résoudre la session vivante d'un `sessionId` et l'arrêter à la fermeture.
|
||||
@ -2858,7 +2887,12 @@ impl BackendCore {
|
||||
.map(|tool| (*tool).to_owned())
|
||||
.collect(),
|
||||
)
|
||||
.expect("MCP tool catalogue classification is valid");
|
||||
.expect("MCP tool catalogue classification is valid")
|
||||
.with_descriptions(
|
||||
infrastructure::orchestrator::mcp::tools::catalogue()
|
||||
.into_iter()
|
||||
.map(|tool| (tool.name, tool.description)),
|
||||
);
|
||||
let read_mcp_tool_permissions = Arc::new(ReadMcpToolPermissions::new(
|
||||
Arc::clone(&mcp_tool_permission_store_port),
|
||||
mcp_tool_catalogue.clone(),
|
||||
@ -2868,6 +2902,10 @@ impl BackendCore {
|
||||
mcp_tool_catalogue.clone(),
|
||||
));
|
||||
let update_agent_mcp_tool_permissions = Arc::new(UpdateAgentMcpToolPermissions::new(
|
||||
Arc::clone(&mcp_tool_permission_store_port),
|
||||
mcp_tool_catalogue.clone(),
|
||||
));
|
||||
let set_agent_mcp_tool_allowed = Arc::new(SetAgentMcpToolAllowed::new(
|
||||
Arc::clone(&mcp_tool_permission_store_port),
|
||||
mcp_tool_catalogue,
|
||||
));
|
||||
@ -4061,6 +4099,7 @@ impl BackendCore {
|
||||
read_mcp_tool_permissions,
|
||||
update_project_mcp_tool_permissions,
|
||||
update_agent_mcp_tool_permissions,
|
||||
set_agent_mcp_tool_allowed,
|
||||
structured_sessions,
|
||||
create_agent,
|
||||
list_agents,
|
||||
|
||||
@ -240,11 +240,12 @@ pub use plugin::{
|
||||
PluginBundleUrl, PluginCapability, PluginCommandId, PluginContributionSet, PluginDescriptor,
|
||||
PluginError, PluginId, PluginInstallSource, PluginLayoutContribution, PluginLayoutType,
|
||||
PluginLifecycleState, PluginManifest, PluginMcpServerContribution, PluginMcpServerId,
|
||||
PluginMcpServerSpec, PluginMcpStatus, PluginMcpStatusSet, PluginMenuItemContribution,
|
||||
PluginPackageRef, PluginRegistry, PluginRegistryEntry, PluginSkillContribution, PluginSkillId,
|
||||
PluginSkillRef, PluginSlashCommandContribution, PluginToolRef, PluginTopLevelMenuContribution,
|
||||
PluginTrustLevel, PluginVersion, ProjectPluginActivation, ProjectPluginSettings, RelativePath,
|
||||
RemovalOutcome, StagedPluginPackage,
|
||||
PluginMcpServerSpec, PluginMcpStatus, PluginMcpStatusSet, PluginMcpToolMetadata,
|
||||
PluginMenuItemContribution, PluginPackageRef, PluginRegistry, PluginRegistryEntry,
|
||||
PluginSkillContribution, PluginSkillId, PluginSkillRef, PluginSlashCommandContribution,
|
||||
PluginToolRef, PluginTopLevelMenuContribution, PluginTrustLevel, PluginVersion,
|
||||
ProjectPluginActivation, ProjectPluginSettings, RelativePath, RemovalOutcome,
|
||||
StagedPluginPackage,
|
||||
};
|
||||
|
||||
pub use sandbox::{
|
||||
|
||||
@ -513,6 +513,21 @@ pub struct PluginMcpServerContribution {
|
||||
/// Development-only escape hatch for absolute commands.
|
||||
#[serde(default)]
|
||||
pub allow_absolute_command: bool,
|
||||
/// Optional static tool catalogue for capability-management UI. This does
|
||||
/// not replace MCP `tools/list` runtime introspection.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub tools: Vec<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.
|
||||
|
||||
@ -56,8 +56,8 @@ use application::{
|
||||
ReconcileLayoutsInput, ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput,
|
||||
RenameSprintInput, ReorderSprintsInput, ResizeTerminalInput, ResolveAgentPermissionsInput,
|
||||
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
|
||||
RotateConversationLogInput, SetActiveLayoutInput, StopLiveAgentInput,
|
||||
SyncAgentWithTemplateInput, TouchDeviceInput, UnassignSkillFromAgentInput,
|
||||
RotateConversationLogInput, SetActiveLayoutInput, SetAgentMcpToolAllowedInput,
|
||||
StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput, UnassignSkillFromAgentInput,
|
||||
UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateAgentContactableInput,
|
||||
UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput,
|
||||
UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput,
|
||||
@ -99,11 +99,12 @@ use backend::dto::{
|
||||
ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto,
|
||||
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
|
||||
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto,
|
||||
SaveProfileRequestDto, SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto,
|
||||
SkillListDto, SprintCreateRequestDto, SprintDeleteRequestDto, SprintDto, SprintListDto,
|
||||
SprintListRequestDto, SprintRenameRequestDto, SprintReorderRequestDto, StopLiveAgentRequestDto,
|
||||
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
|
||||
TemplateListDto, TerminalSessionDto, TicketAssignRequestDto, TicketAttachmentAddRequestDto,
|
||||
SaveProfileRequestDto, SetActiveLayoutRequestDto, SetActiveLayoutResultDto,
|
||||
SetAgentMcpToolAllowedRequestDto, SkillDto, SkillListDto, SprintCreateRequestDto,
|
||||
SprintDeleteRequestDto, SprintDto, SprintListDto, SprintListRequestDto, SprintRenameRequestDto,
|
||||
SprintReorderRequestDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto,
|
||||
SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
|
||||
TerminalSessionDto, TicketAssignRequestDto, TicketAttachmentAddRequestDto,
|
||||
TicketAttachmentContentDto, TicketAttachmentMarkSummarizedRequestDto,
|
||||
TicketAttachmentReadRequestDto, TicketCarnetDto, TicketCreateRequestDto,
|
||||
TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, TicketListPageInput,
|
||||
@ -3437,6 +3438,9 @@ async fn invoke(
|
||||
"update_agent_mcp_tool_permissions" => {
|
||||
invoke_update_agent_mcp_tool_permissions(&request.args, &state.app).await
|
||||
}
|
||||
"set_agent_mcp_tool_allowed" => {
|
||||
invoke_set_agent_mcp_tool_allowed(&request.args, &state.app).await
|
||||
}
|
||||
"list_memories" => invoke_list_memories(&request.args, &state.app).await,
|
||||
"get_memory" => invoke_get_memory(&request.args, &state.app).await,
|
||||
"create_memory" => invoke_create_memory(&request.args, &state.app).await,
|
||||
@ -4948,6 +4952,27 @@ async fn invoke_update_agent_mcp_tool_permissions(
|
||||
serde_json::to_value(output).map_err(serialization_error)
|
||||
}
|
||||
|
||||
async fn invoke_set_agent_mcp_tool_allowed(
|
||||
args: &Value,
|
||||
state: &BackendCore,
|
||||
) -> Result<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> {
|
||||
let project =
|
||||
resolve_project_readonly(string_arg(args, "projectId", "list_memories")?, state).await?;
|
||||
|
||||
Reference in New Issue
Block a user