diff --git a/crates/app-tauri/tests/dto_templates.rs b/crates/app-tauri/tests/dto_templates.rs index 3e93b00d..7db93c3c 100644 --- a/crates/app-tauri/tests/dto_templates.rs +++ b/crates/app-tauri/tests/dto_templates.rs @@ -255,6 +255,24 @@ fn create_template_request_deserialises_template_defaults() { ); } +#[test] +fn create_template_request_deserialises_frontend_mcp_tool_permissions_key() { + let profile_id = Uuid::from_u128(22).to_string(); + let raw = json!({ + "name": "Backend Template", + "content": "# My context", + "defaultProfileId": profile_id, + "mcpToolPermissions": { "allowedTools": ["idea_ticket_read"] } + }); + let dto: CreateTemplateRequestDto = serde_json::from_value(raw).unwrap(); + assert_eq!( + dto.default_mcp_tool_policy, + Some(McpToolPolicy { + allowed_tools: vec!["idea_ticket_read".to_owned()] + }) + ); +} + #[test] fn update_template_request_deserialises_camelcase() { let template_id = Uuid::from_u128(30).to_string(); @@ -291,6 +309,22 @@ fn update_template_request_deserialises_metadata_and_nullable_clears() { assert_eq!(dto.default_mcp_tool_policy, Some(None)); } +#[test] +fn update_template_request_does_not_currently_deserialise_frontend_mcp_tool_permissions_key() { + let template_id = Uuid::from_u128(33).to_string(); + let raw = json!({ + "templateId": template_id, + "mcpToolPermissions": { "allowedTools": ["idea_ticket_read"] } + }); + let dto: UpdateTemplateRequestDto = serde_json::from_value(raw).unwrap(); + assert_eq!( + dto.default_mcp_tool_policy, + Some(Some(McpToolPolicy { + allowed_tools: vec!["idea_ticket_read".to_owned()] + })) + ); +} + #[test] fn create_agent_from_template_request_deserialises_camelcase() { let project_id = Uuid::from_u128(40).to_string(); diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index ffaebb88..f0f49250 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -4281,8 +4281,7 @@ impl From for TemplateDto { } /// Request DTO for `create_template`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] +#[derive(Debug, Clone)] pub struct CreateTemplateRequestDto { /// Display name. pub name: String, @@ -4291,20 +4290,54 @@ pub struct CreateTemplateRequestDto { /// Default runtime profile id for agents created from this template. pub default_profile_id: String, /// Optional default agent name for agents created from this template. - #[serde(default)] pub default_agent_name: Option, /// Optional agent-level system permission override for created agents. - #[serde(default)] pub default_system_permissions: Option, /// Optional agent-level MCP tool policy override for created agents. - #[serde( - default, - rename = "defaultMcpToolPermissions", - alias = "defaultMcpToolPolicy" - )] pub default_mcp_tool_policy: Option, } +impl<'de> Deserialize<'de> for CreateTemplateRequestDto { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let mut value = Value::deserialize(deserializer)?; + let object = value + .as_object_mut() + .ok_or_else(|| serde::de::Error::custom("expected create_template object"))?; + let name = take_required_string(object, "name").map_err(serde::de::Error::custom)?; + let content = take_required_string(object, "content").map_err(serde::de::Error::custom)?; + let default_profile_id = + take_required_string(object, "defaultProfileId").map_err(serde::de::Error::custom)?; + let default_agent_name = take_optional_value::(object, "defaultAgentName") + .map_err(serde::de::Error::custom)? + .flatten(); + let default_system_permissions = + take_optional_value::(object, "defaultSystemPermissions") + .map_err(serde::de::Error::custom)? + .flatten(); + let default_mcp_tool_policy = take_optional_value_preferred_aliases::( + object, + &[ + "mcpToolPermissions", + "defaultMcpToolPermissions", + "defaultMcpToolPolicy", + ], + ) + .map_err(serde::de::Error::custom)? + .flatten(); + Ok(Self { + name, + content, + default_profile_id, + default_agent_name, + default_system_permissions, + default_mcp_tool_policy, + }) + } +} + impl CreateTemplateRequestDto { /// Converts to the use-case input, parsing the profile id. /// @@ -4366,10 +4399,13 @@ impl<'de> Deserialize<'de> for UpdateTemplateRequestDto { let default_system_permissions = take_optional_value::(object, "defaultSystemPermissions") .map_err(serde::de::Error::custom)?; - let default_mcp_tool_policy = take_optional_value_alias::( + let default_mcp_tool_policy = take_optional_value_preferred_aliases::( object, - "defaultMcpToolPermissions", - "defaultMcpToolPolicy", + &[ + "mcpToolPermissions", + "defaultMcpToolPermissions", + "defaultMcpToolPolicy", + ], ) .map_err(serde::de::Error::custom)?; Ok(Self { @@ -4434,23 +4470,25 @@ where } } -fn take_optional_value_alias( +fn take_optional_value_preferred_aliases( object: &mut serde_json::Map, - primary_key: &str, - legacy_key: &str, + keys: &[&str], ) -> Result>, String> where T: DeserializeOwned, { - let primary = take_optional_value(object, primary_key)?; - let legacy = take_optional_value(object, legacy_key)?; - match (primary, legacy) { - (Some(value), None) | (None, Some(value)) => Ok(Some(value)), - (None, None) => Ok(None), - (Some(_), Some(_)) => Err(format!( - "{primary_key} and {legacy_key} cannot both be provided" - )), + for key in keys { + let parsed = take_optional_value(object, key)?; + if parsed.is_some() { + for ignored_key in keys { + if ignored_key != key { + object.remove(*ignored_key); + } + } + return Ok(parsed); + } } + Ok(None) } /// Parses a template-id string (UUID) coming from the frontend. diff --git a/frontend/src/features/templates/templates.test.tsx b/frontend/src/features/templates/templates.test.tsx index b35e5fc6..ea3cc561 100644 --- a/frontend/src/features/templates/templates.test.tsx +++ b/frontend/src/features/templates/templates.test.tsx @@ -239,6 +239,46 @@ describe("TemplatesPanel (with MockTemplateGateway)", () => { expect(templates[0].contentMd).toBe("# Updated content"); }); + it("reopening an edited template preserves saved MCP tool permissions in the editor", async () => { + const agent = new MockAgentGateway(); + const tmpl = new MockTemplateGateway(agent); + await tmpl.createTemplate({ + name: "Permissioned", + content: "# Initial", + defaultProfileId: "", + mcpToolPermissions: null, + }); + renderTemplatesPanel({ agent, template: tmpl }); + + await screen.findByText("Permissioned"); + + fireEvent.click(screen.getByRole("button", { name: "edit Permissioned" })); + await waitFor(() => { + expect(screen.getByLabelText("template MCP tool idea_ticket_read")).toBeTruthy(); + }); + + const checkbox = screen.getByLabelText( + "template MCP tool idea_ticket_read", + ) as HTMLInputElement; + expect(checkbox.checked).toBe(false); + fireEvent.click(checkbox); + expect(checkbox.checked).toBe(true); + fireEvent.click(screen.getByRole("button", { name: "Save template" })); + + await waitFor(() => { + expect(screen.queryByLabelText("template editor")).toBeNull(); + }); + + fireEvent.click(screen.getByRole("button", { name: "edit Permissioned" })); + await waitFor(() => { + expect(screen.getByLabelText("template MCP tool idea_ticket_read")).toBeTruthy(); + }); + + expect( + (screen.getByLabelText("template MCP tool idea_ticket_read") as HTMLInputElement).checked, + ).toBe(true); + }); + it("deleting a template removes it from the list", async () => { renderTemplatesPanel(); await createTemplate("ToDelete");