From f6101a824966464912957354d35ba9d41347ba1c Mon Sep 17 00:00:00 2001
From: Blomios
Date: Mon, 7 Sep 2026 18:13:27 +0200
Subject: [PATCH] =?UTF-8?q?fix(permissions):=20tools=20MCP=20inter-projets?=
=?UTF-8?q?=20expos=C3=A9s=20(idea=5Fproject=5F*)=20+=20contactable=20agen?=
=?UTF-8?q?t/template=20persist=C3=A9=20=E2=80=94=20update=5Fagent=5Fconta?=
=?UTF-8?q?ctable=20(application/web-server/app-tauri),=20defaultContactab?=
=?UTF-8?q?le=20TemplateEditor=20save/reopen,=20groupes=20MCP=20'IdeA=20in?=
=?UTF-8?q?ter-projets'=20avec=20fallback,=20permissions=20agent=20Contact?=
=?UTF-8?q?able=20=E2=80=94=20#267=20(QA=20verte)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
crates/app-tauri/src/commands.rs | 29 +++++++-
crates/app-tauri/src/lib.rs | 1 +
crates/app-tauri/tests/dto_agents.rs | 19 +++++-
crates/application/src/agent/lifecycle.rs | 66 +++++++++++++++++++
crates/application/src/agent/mod.rs | 3 +-
crates/application/src/lib.rs | 5 +-
crates/application/tests/agent_lifecycle.rs | 51 +++++++++++++-
crates/backend/src/dto.rs | 12 ++++
crates/backend/src/lib.rs | 15 +++--
crates/web-server/src/lib.rs | 43 +++++++++---
frontend/src/adapters/agent.test.ts | 14 ++++
frontend/src/adapters/agent.ts | 10 +++
.../src/adapters/http/agentGateway.test.ts | 22 +++++++
.../src/adapters/http/httpInvoker.test.ts | 24 ++++---
.../adapters/http/requestResponseGateways.ts | 2 +
frontend/src/adapters/http/streamGateways.ts | 9 +++
frontend/src/adapters/mock/index.ts | 48 ++++++++++++++
frontend/src/adapters/template.ts | 2 +
.../src/adapters/templateNormalization.ts | 1 +
frontend/src/domain/index.ts | 5 +-
.../McpToolPermissionsPanel.test.tsx | 39 ++++++++++-
.../permissions/McpToolPermissionsPanel.tsx | 13 +++-
.../features/permissions/PermissionsPanel.tsx | 45 ++++++++-----
.../src/features/permissions/mcpToolGroups.ts | 42 ++++++++++++
.../features/permissions/permissions.test.tsx | 28 +++++---
.../features/permissions/usePermissions.ts | 26 ++++++++
.../src/features/templates/TemplateEditor.tsx | 44 +++++++------
.../src/features/templates/TemplatesPanel.tsx | 30 ++++++++-
.../src/features/templates/templates.test.tsx | 65 +++++++++++++++++-
frontend/src/ports/index.ts | 8 +++
30 files changed, 632 insertions(+), 89 deletions(-)
diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs
index f805fb0c..10a7f4ba 100644
--- a/crates/app-tauri/src/commands.rs
+++ b/crates/app-tauri/src/commands.rs
@@ -29,8 +29,8 @@ use application::{
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
RotateConversationLogInput, SaveChatDraftInput, SetActiveLayoutInput, SlashCommandEffect,
SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput,
- UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentEffortInput,
- UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
+ UnassignSkillFromAgentInput, UpdateAgentContactableInput, UpdateAgentContextInput,
+ UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
UpdateAgentSystemPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput,
UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
UpdateProjectSystemPermissionsInput, UpdateSkillInput,
@@ -78,7 +78,7 @@ use crate::dto::{
SlashCommandEffectDto, SlashCommandListDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto,
SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
- UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto,
+ UpdateAgentContactableRequestDto, UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto,
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
@@ -777,6 +777,29 @@ pub async fn update_agent_effort(
.map_err(ErrorDto::from)
}
+/// `update_agent_contactable` — enable or disable cross-project contact for one agent.
+///
+/// # Errors
+/// Returns an [`ErrorDto`] on invalid ids or store failure.
+#[tauri::command]
+pub async fn update_agent_contactable(
+ request: UpdateAgentContactableRequestDto,
+ state: State<'_, AppState>,
+) -> Result {
+ let project = resolve_project(&request.project_id, &state).await?;
+ let agent_id = parse_agent_id(&request.agent_id)?;
+ state
+ .update_agent_contactable
+ .execute(UpdateAgentContactableInput {
+ project,
+ agent_id,
+ contactable: request.contactable,
+ })
+ .await
+ .map(|out| AgentDto::from_agent(out.agent))
+ .map_err(ErrorDto::from)
+}
+
/// `resolve_agent_permissions` — resolve project defaults plus agent override.
///
/// # Errors
diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs
index c7ce18b2..5c5e02f1 100644
--- a/crates/app-tauri/src/lib.rs
+++ b/crates/app-tauri/src/lib.rs
@@ -261,6 +261,7 @@ pub fn run() {
commands::update_project_permissions,
commands::update_agent_permissions,
commands::update_agent_effort,
+ commands::update_agent_contactable,
commands::resolve_agent_permissions,
commands::get_project_system_permissions,
commands::update_project_system_permissions,
diff --git a/crates/app-tauri/tests/dto_agents.rs b/crates/app-tauri/tests/dto_agents.rs
index 2a5a9657..f5dceda1 100644
--- a/crates/app-tauri/tests/dto_agents.rs
+++ b/crates/app-tauri/tests/dto_agents.rs
@@ -5,7 +5,8 @@
use app_tauri_lib::dto::{
parse_agent_id, AgentDto, AgentListDto, CellKind, ConversationDetailsDto,
CreateAgentRequestDto, InspectConversationRequestDto, LaunchAgentRequestDto, LiveAgentListDto,
- TerminalSessionDto, UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto,
+ TerminalSessionDto, UpdateAgentContactableRequestDto, UpdateAgentContextRequestDto,
+ UpdateAgentEffortRequestDto,
};
use application::AppError;
use application::{
@@ -40,7 +41,7 @@ fn make_agent(agent_uuid: u128, profile_uuid: u128) -> Agent {
#[test]
fn agent_dto_serialises_camelcase() {
- let agent = make_agent(1, 2);
+ let agent = make_agent(1, 2).with_contactable(true);
let dto = AgentDto::from_agent(agent.clone());
let v = serde_json::to_value(&dto).unwrap();
@@ -52,6 +53,7 @@ fn agent_dto_serialises_camelcase() {
ProfileId::from_uuid(Uuid::from_u128(2)).to_string()
);
assert_eq!(v["synchronized"], false);
+ assert_eq!(v["contactable"], true);
// origin: tagged `{ "type": "scratch" }`
assert_eq!(v["origin"]["type"], "scratch");
assert_eq!(v["capabilities"], json!([]));
@@ -173,6 +175,19 @@ fn update_agent_effort_request_deserialises_and_null_clears() {
assert_eq!(clear.effort, None);
}
+#[test]
+fn update_agent_contactable_request_deserialises_camelcase() {
+ let raw = json!({
+ "projectId": Uuid::from_u128(1).to_string(),
+ "agentId": Uuid::from_u128(2).to_string(),
+ "contactable": true
+ });
+ let dto: UpdateAgentContactableRequestDto = serde_json::from_value(raw).unwrap();
+ assert!(dto.contactable);
+ assert_eq!(dto.project_id, Uuid::from_u128(1).to_string());
+ assert_eq!(dto.agent_id, Uuid::from_u128(2).to_string());
+}
+
#[test]
fn launch_agent_request_deserialises_camelcase() {
let project_id = Uuid::from_u128(1).to_string();
diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs
index bf639e39..6a579d1b 100644
--- a/crates/application/src/agent/lifecycle.rs
+++ b/crates/application/src/agent/lifecycle.rs
@@ -522,6 +522,72 @@ pub struct UpdateAgentEffortOutput {
pub agent: Agent,
}
+// ---------------------------------------------------------------------------
+// UpdateAgentContactable
+// ---------------------------------------------------------------------------
+
+/// Input for [`UpdateAgentContactable::execute`].
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct UpdateAgentContactableInput {
+ /// The owning project.
+ pub project: Project,
+ /// The agent whose cross-project contactability changes.
+ pub agent_id: AgentId,
+ /// Whether agents from other IdeA projects may contact this agent.
+ pub contactable: bool,
+}
+
+/// Output of [`UpdateAgentContactable::execute`].
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct UpdateAgentContactableOutput {
+ /// The updated agent.
+ pub agent: Agent,
+}
+
+/// Updates an agent's cross-project contactability in the project manifest.
+pub struct UpdateAgentContactable {
+ contexts: Arc,
+}
+
+impl UpdateAgentContactable {
+ /// Builds the use case.
+ #[must_use]
+ pub fn new(contexts: Arc) -> Self {
+ Self { contexts }
+ }
+
+ /// Executes the update.
+ ///
+ /// # Errors
+ /// - [`AppError::NotFound`] if the agent is unknown to the project,
+ /// - [`AppError::Invalid`] if the resulting manifest is invalid,
+ /// - [`AppError::Store`] on persistence failure.
+ pub async fn execute(
+ &self,
+ input: UpdateAgentContactableInput,
+ ) -> Result {
+ let mut manifest = self.contexts.load_manifest(&input.project).await?;
+ let entry = manifest
+ .entries
+ .iter_mut()
+ .find(|entry| entry.agent_id == input.agent_id)
+ .ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
+
+ let agent = entry
+ .to_agent()
+ .map_err(|err| AppError::Invalid(err.to_string()))?
+ .with_contactable(input.contactable);
+ *entry = ManifestEntry::from_agent(&agent);
+
+ let manifest = AgentManifest::new(manifest.version, manifest.entries)
+ .map_err(|err| AppError::Invalid(err.to_string()))?;
+ self.contexts
+ .save_manifest(&input.project, &manifest)
+ .await?;
+ Ok(UpdateAgentContactableOutput { agent })
+ }
+}
+
/// Sets or clears an agent's per-agent effort override in the manifest.
///
/// The change applies at the agent's next launch; live sessions are not mutated.
diff --git a/crates/application/src/agent/mod.rs b/crates/application/src/agent/mod.rs
index 9d011020..ef0ee12e 100644
--- a/crates/application/src/agent/mod.rs
+++ b/crates/application/src/agent/mod.rs
@@ -37,7 +37,8 @@ pub use lifecycle::{
LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, ListedAgentCapabilities,
LiveStateLeanProvider, ManagedTempExposureRequest, McpRuntime, PermissionProjectorRegistry,
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
- StructuredRoutingMode, StructuredSessionDescriptor, UpdateAgentContext,
+ StructuredRoutingMode, StructuredSessionDescriptor, UpdateAgentContactable,
+ UpdateAgentContactableInput, UpdateAgentContactableOutput, UpdateAgentContext,
UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentEffortInput, UpdateAgentEffortOutput,
AGENT_MEMORY_RECALL_BUDGET, DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX,
};
diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs
index 8a72cdc2..5bd0436a 100644
--- a/crates/application/src/lib.rs
+++ b/crates/application/src/lib.rs
@@ -66,8 +66,9 @@ pub use agent::{
ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveOpenCodeProviderProfile,
SaveOpenCodeProviderProfileInput, SaveOpenCodeProviderProfileOutput, SaveProfile,
SaveProfileInput, SaveProfileOutput, SessionLimitService, StructuredRoutingMode,
- StructuredSessionDescriptor, TurnOutcome, UpdateAgentContext, UpdateAgentContextInput,
- UpdateAgentEffort, UpdateAgentEffortInput, UpdateAgentEffortOutput, AGENT_MEMORY_RECALL_BUDGET,
+ StructuredSessionDescriptor, TurnOutcome, UpdateAgentContactable, UpdateAgentContactableInput,
+ UpdateAgentContactableOutput, UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort,
+ UpdateAgentEffortInput, UpdateAgentEffortOutput, AGENT_MEMORY_RECALL_BUDGET,
CODEX_SUBMIT_DELAY_MS, LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
};
pub use background::{
diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs
index 55b49277..0c2ddd5a 100644
--- a/crates/application/tests/agent_lifecycle.rs
+++ b/crates/application/tests/agent_lifecycle.rs
@@ -57,8 +57,8 @@ use application::{
DeleteAgent, DeleteAgentInput, LaunchAgent, LaunchAgentInput, ListAgents, ListAgentsInput,
LiveSessions, ManagedTempExposureRequest, PermissionProjectorRegistry, ReadAgentContext,
ReadAgentContextInput, ReleaseManagedTemp, ResolveManagedTemp, StructuredRoutingMode,
- StructuredSessions, TerminalSessions, UpdateAgentContext, UpdateAgentContextInput,
- UpdateAgentEffort, UpdateAgentEffortInput,
+ StructuredSessions, TerminalSessions, UpdateAgentContactable, UpdateAgentContactableInput,
+ UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentEffortInput,
};
// ---------------------------------------------------------------------------
@@ -1163,6 +1163,53 @@ async fn update_agent_effort_not_found_for_unknown_agent_id() {
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
}
+#[tokio::test]
+async fn update_agent_contactable_persists_manifest_and_can_be_cleared() {
+ let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
+ let contexts = FakeContexts::with_agent(&a, "ctx");
+ let update = UpdateAgentContactable::new(Arc::new(contexts.clone()));
+
+ let enabled = update
+ .execute(UpdateAgentContactableInput {
+ project: project(),
+ agent_id: a.id,
+ contactable: true,
+ })
+ .await
+ .unwrap();
+ assert!(enabled.agent.contactable);
+ assert!(contexts.manifest().entries[0].contactable);
+
+ let disabled = update
+ .execute(UpdateAgentContactableInput {
+ project: project(),
+ agent_id: a.id,
+ contactable: false,
+ })
+ .await
+ .unwrap();
+ assert!(!disabled.agent.contactable);
+ assert!(!contexts.manifest().entries[0].contactable);
+}
+
+#[tokio::test]
+async fn update_agent_contactable_not_found_for_unknown_agent_id() {
+ let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
+ let contexts = FakeContexts::with_agent(&a, "ctx");
+ let update = UpdateAgentContactable::new(Arc::new(contexts));
+
+ let err = update
+ .execute(UpdateAgentContactableInput {
+ project: project(),
+ agent_id: aid(404),
+ contactable: true,
+ })
+ .await
+ .unwrap_err();
+
+ assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
+}
+
#[tokio::test]
async fn delete_removes_entry_then_unknown_is_not_found() {
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs
index e31fdb9a..47d3dbe7 100644
--- a/crates/backend/src/dto.rs
+++ b/crates/backend/src/dto.rs
@@ -3067,6 +3067,18 @@ pub struct UpdateAgentEffortRequestDto {
pub effort: Option,
}
+/// Request DTO for `update_agent_contactable`.
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct UpdateAgentContactableRequestDto {
+ /// Id of the owning project.
+ pub project_id: String,
+ /// Id of the agent whose cross-project contactability changes.
+ pub agent_id: String,
+ /// Whether agents from other projects may contact this agent.
+ pub contactable: bool,
+}
+
/// Response DTO for `change_agent_profile`: the mutated agent plus the freshly
/// relaunched session when a live session was hot-swapped (absent otherwise).
#[derive(Debug, Clone, Serialize)]
diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs
index 6b0bdae5..2ff44fc5 100644
--- a/crates/backend/src/lib.rs
+++ b/crates/backend/src/lib.rs
@@ -54,11 +54,11 @@ use application::{
SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions,
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues,
- UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions, UpdateAgentPermissions,
- UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory,
- UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectPermissions,
- UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory,
- WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
+ UpdateAgentContactable, UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions,
+ UpdateAgentPermissions, UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet,
+ UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectMcpToolPermissions,
+ UpdateProjectPermissions, UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate,
+ WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
};
use async_trait::async_trait;
use domain::ports::{
@@ -1287,6 +1287,8 @@ pub struct BackendCore {
pub update_agent_permissions: Arc,
/// Update one agent effort override.
pub update_agent_effort: Arc,
+ /// Update one agent's cross-project contactability.
+ pub update_agent_contactable: Arc,
/// Resolve effective permissions for one agent.
pub resolve_agent_permissions: Arc,
/// Read the project system permission document.
@@ -2454,6 +2456,8 @@ impl BackendCore {
&permission_store_port,
)));
let update_agent_effort = Arc::new(UpdateAgentEffort::new(Arc::clone(&contexts_port)));
+ let update_agent_contactable =
+ Arc::new(UpdateAgentContactable::new(Arc::clone(&contexts_port)));
let resolve_agent_permissions = Arc::new(ResolveAgentPermissions::new(Arc::clone(
&permission_store_port,
)));
@@ -3296,6 +3300,7 @@ impl BackendCore {
update_project_permissions,
update_agent_permissions,
update_agent_effort,
+ update_agent_contactable,
resolve_agent_permissions,
get_project_system_permissions,
update_project_system_permissions,
diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs
index 1dafabae..eb9bfbf5 100644
--- a/crates/web-server/src/lib.rs
+++ b/crates/web-server/src/lib.rs
@@ -58,11 +58,12 @@ use application::{
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
RotateConversationLogInput, SetActiveLayoutInput, StopLiveAgentInput,
SyncAgentWithTemplateInput, TouchDeviceInput, UnassignSkillFromAgentInput,
- UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateAgentContextInput,
- UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
- UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput, UpdateMemoryInput,
- UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
- UpdateProjectSystemPermissionsInput, UpdateSkillInput, WriteToTerminalInput,
+ UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateAgentContactableInput,
+ UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput,
+ UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput,
+ UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput,
+ UpdateProjectPermissionsInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput,
+ WriteToTerminalInput,
};
use domain::ports::{
PtyHandle, ReplyEvent, ReplyProgress, ReplyProgressKind, ReplyProgressSource,
@@ -108,10 +109,10 @@ use backend::dto::{
TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, TicketListPageInput,
TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto,
TicketSprintUnassignRequestDto, TicketUnlinkCommandRequestDto, TicketUpdateCarnetRequestDto,
- TicketUpdateRequestDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
- UpdateAgentEffortRequestDto, UpdateAgentMcpToolPermissionsRequestDto,
- UpdateAgentPermissionsRequestDto, UpdateAgentSystemPermissionsRequestDto,
- UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
+ TicketUpdateRequestDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContactableRequestDto,
+ UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto,
+ UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
+ UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
};
@@ -3273,6 +3274,9 @@ async fn invoke(
invoke_update_agent_permissions(&request.args, &state.app).await
}
"update_agent_effort" => invoke_update_agent_effort(&request.args, &state.app).await,
+ "update_agent_contactable" => {
+ invoke_update_agent_contactable(&request.args, &state.app).await
+ }
"resolve_agent_permissions" => {
invoke_resolve_agent_permissions(&request.args, &state.app).await
}
@@ -4626,6 +4630,26 @@ async fn invoke_update_agent_effort(args: &Value, state: &BackendCore) -> Result
serde_json::to_value(output).map_err(serialization_error)
}
+async fn invoke_update_agent_contactable(
+ args: &Value,
+ state: &BackendCore,
+) -> Result {
+ let request =
+ required_request::("update_agent_contactable", args)?;
+ let project = resolve_project_readonly(&request.project_id, state).await?;
+ let output = state
+ .update_agent_contactable
+ .execute(UpdateAgentContactableInput {
+ project,
+ agent_id: parse_agent_id(&request.agent_id)?,
+ contactable: request.contactable,
+ })
+ .await
+ .map(|out| AgentDto::from_agent(out.agent))
+ .map_err(ErrorDto::from)?;
+ serde_json::to_value(output).map_err(serialization_error)
+}
+
async fn invoke_resolve_agent_permissions(
args: &Value,
state: &BackendCore,
@@ -9939,6 +9963,7 @@ mod tests {
"update_project_permissions",
"update_agent_permissions",
"update_agent_effort",
+ "update_agent_contactable",
"resolve_agent_permissions",
"get_project_system_permissions",
"update_project_system_permissions",
diff --git a/frontend/src/adapters/agent.test.ts b/frontend/src/adapters/agent.test.ts
index 3ec7e811..6d56d963 100644
--- a/frontend/src/adapters/agent.test.ts
+++ b/frontend/src/adapters/agent.test.ts
@@ -162,6 +162,20 @@ describe("TauriAgentGateway invoke payloads", () => {
});
});
+ it("update_agent_contactable wraps the dedicated boolean in the request DTO", async () => {
+ invoke.mockResolvedValueOnce({ id: "agent-2", contactable: true });
+
+ await new TauriAgentGateway().updateAgentContactable(
+ "proj-1",
+ "agent-2",
+ true,
+ );
+
+ expect(invoke).toHaveBeenCalledWith("update_agent_contactable", {
+ request: { projectId: "proj-1", agentId: "agent-2", contactable: true },
+ });
+ });
+
it("cancelAgentChat invokes cancel_agent_chat without closing the session", async () => {
await new TauriAgentGateway().cancelAgentChat("chat-session-1");
diff --git a/frontend/src/adapters/agent.ts b/frontend/src/adapters/agent.ts
index fa192d6f..4e4dae70 100644
--- a/frontend/src/adapters/agent.ts
+++ b/frontend/src/adapters/agent.ts
@@ -144,6 +144,16 @@ export class TauriAgentGateway implements AgentGateway {
});
}
+ updateAgentContactable(
+ projectId: string,
+ agentId: string,
+ contactable: boolean,
+ ): Promise {
+ return invoke("update_agent_contactable", {
+ request: { projectId, agentId, contactable },
+ });
+ }
+
readContext(projectId: string, agentId: string): Promise {
return invoke("read_agent_context", {
projectId,
diff --git a/frontend/src/adapters/http/agentGateway.test.ts b/frontend/src/adapters/http/agentGateway.test.ts
index 8d2ceae5..0e67b55a 100644
--- a/frontend/src/adapters/http/agentGateway.test.ts
+++ b/frontend/src/adapters/http/agentGateway.test.ts
@@ -119,6 +119,28 @@ describe("HttpAgentGateway WS round-trip (B6 frames)", () => {
});
});
+ it("updates agent contactability through the dedicated request DTO", async () => {
+ const response = { id: "agent-1", contactable: true };
+ const { gw, calls } = httpAgentGateway(async () => ({
+ ok: true,
+ status: 200,
+ json: async () => response,
+ text: async () => JSON.stringify(response),
+ }));
+
+ await expect(
+ gw.updateAgentContactable("proj-1", "agent-1", true),
+ ).resolves.toEqual(response);
+
+ const init = calls[0].init as { body: string };
+ expect(JSON.parse(init.body)).toEqual({
+ command: "update_agent_contactable",
+ args: {
+ request: { projectId: "proj-1", agentId: "agent-1", contactable: true },
+ },
+ });
+ });
+
it("launch → attached: conforming agent.launch frame, assignedConversationId consumed", async () => {
const { gw, sockets } = gateway();
const chunks: Uint8Array[] = [];
diff --git a/frontend/src/adapters/http/httpInvoker.test.ts b/frontend/src/adapters/http/httpInvoker.test.ts
index 298c5e9f..f976a2aa 100644
--- a/frontend/src/adapters/http/httpInvoker.test.ts
+++ b/frontend/src/adapters/http/httpInvoker.test.ts
@@ -264,7 +264,8 @@ describe("request/response gateways preserve the Tauri command contract", () =>
contentMd: "# Body",
version: 1,
defaultProfileId: "p1",
- defaultSystemPermissions: { network: "allow", crossProjectContactable: true },
+ defaultContactable: true,
+ defaultSystemPermissions: { network: "allow" },
defaultMcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
};
const spy = vi
@@ -276,7 +277,8 @@ describe("request/response gateways preserve the Tauri command contract", () =>
await expect(gw.listTemplates()).resolves.toMatchObject([
{
- systemPermissions: { network: "allow", crossProjectContactable: true },
+ defaultContactable: true,
+ systemPermissions: { network: "allow" },
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
},
]);
@@ -285,11 +287,13 @@ describe("request/response gateways preserve the Tauri command contract", () =>
name: "Permissioned",
content: "# Body",
defaultProfileId: "p1",
- systemPermissions: { network: "allow", crossProjectContactable: true },
+ defaultContactable: true,
+ systemPermissions: { network: "allow" },
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
}),
).resolves.toMatchObject({
- systemPermissions: { network: "allow", crossProjectContactable: true },
+ defaultContactable: true,
+ systemPermissions: { network: "allow" },
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
});
await expect(
@@ -297,11 +301,13 @@ describe("request/response gateways preserve the Tauri command contract", () =>
name: "Permissioned",
content: "# Body",
defaultProfileId: "p1",
- systemPermissions: { network: "allow", crossProjectContactable: true },
+ defaultContactable: true,
+ systemPermissions: { network: "allow" },
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
}),
).resolves.toMatchObject({
- systemPermissions: { network: "allow", crossProjectContactable: true },
+ defaultContactable: true,
+ systemPermissions: { network: "allow" },
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
});
@@ -311,7 +317,8 @@ describe("request/response gateways preserve the Tauri command contract", () =>
content: "# Body",
defaultProfileId: "p1",
defaultAgentName: null,
- defaultSystemPermissions: { network: "allow", crossProjectContactable: true },
+ defaultContactable: true,
+ defaultSystemPermissions: { network: "allow" },
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
},
});
@@ -322,7 +329,8 @@ describe("request/response gateways preserve the Tauri command contract", () =>
content: "# Body",
defaultProfileId: "p1",
defaultAgentName: null,
- defaultSystemPermissions: { network: "allow", crossProjectContactable: true },
+ defaultContactable: true,
+ defaultSystemPermissions: { network: "allow" },
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
},
});
diff --git a/frontend/src/adapters/http/requestResponseGateways.ts b/frontend/src/adapters/http/requestResponseGateways.ts
index f02bf198..aed8f42c 100644
--- a/frontend/src/adapters/http/requestResponseGateways.ts
+++ b/frontend/src/adapters/http/requestResponseGateways.ts
@@ -293,6 +293,7 @@ export class HttpTemplateGateway implements TemplateGateway {
content: input.content,
defaultProfileId: input.defaultProfileId,
defaultAgentName: input.defaultAgentName ?? null,
+ defaultContactable: input.defaultContactable ?? false,
defaultSystemPermissions: input.systemPermissions ?? null,
mcpToolPermissions: input.mcpToolPermissions ?? null,
},
@@ -306,6 +307,7 @@ export class HttpTemplateGateway implements TemplateGateway {
content: input.content,
defaultProfileId: input.defaultProfileId,
defaultAgentName: input.defaultAgentName ?? null,
+ defaultContactable: input.defaultContactable ?? false,
defaultSystemPermissions: input.systemPermissions ?? null,
mcpToolPermissions: input.mcpToolPermissions ?? null,
},
diff --git a/frontend/src/adapters/http/streamGateways.ts b/frontend/src/adapters/http/streamGateways.ts
index 38fc04df..7924ac36 100644
--- a/frontend/src/adapters/http/streamGateways.ts
+++ b/frontend/src/adapters/http/streamGateways.ts
@@ -249,6 +249,15 @@ export class HttpAgentGateway implements AgentGateway {
request: { projectId, agentId, effort },
});
}
+ updateAgentContactable(
+ projectId: string,
+ agentId: string,
+ contactable: boolean,
+ ): Promise {
+ return this.http.invoke("update_agent_contactable", {
+ request: { projectId, agentId, contactable },
+ });
+ }
readContext(projectId: string, agentId: string): Promise {
return this.http
.invoke("read_agent_context", { projectId, agentId })
diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts
index 5993a742..205cd3b1 100644
--- a/frontend/src/adapters/mock/index.ts
+++ b/frontend/src/adapters/mock/index.ts
@@ -702,6 +702,24 @@ export class MockAgentGateway implements AgentGateway {
return structuredClone(next);
}
+ async updateAgentContactable(
+ projectId: string,
+ agentId: string,
+ contactable: boolean,
+ ): Promise {
+ const list = this.getAgents(projectId);
+ const idx = list.findIndex((a) => a.id === agentId);
+ if (idx === -1) {
+ const err: GatewayError = {
+ code: "NOT_FOUND",
+ message: `agent ${agentId} not found in project ${projectId}`,
+ };
+ throw err;
+ }
+ list[idx] = { ...list[idx], contactable };
+ return structuredClone(list[idx]);
+ }
+
// ── Internal helpers for MockTemplateGateway (same-package use only) ──
/**
@@ -2232,6 +2250,7 @@ export class MockTemplateGateway implements TemplateGateway {
version: 1,
defaultProfileId: input.defaultProfileId,
defaultAgentName: input.defaultAgentName ?? null,
+ defaultContactable: input.defaultContactable ?? false,
systemPermissions: input.systemPermissions ?? null,
mcpToolPermissions: input.mcpToolPermissions ?? null,
};
@@ -2259,6 +2278,7 @@ export class MockTemplateGateway implements TemplateGateway {
content: input,
defaultProfileId: current.defaultProfileId,
defaultAgentName: current.defaultAgentName ?? null,
+ defaultContactable: current.defaultContactable ?? false,
systemPermissions: current.systemPermissions ?? null,
mcpToolPermissions: current.mcpToolPermissions ?? null,
}
@@ -2269,6 +2289,7 @@ export class MockTemplateGateway implements TemplateGateway {
contentMd: update.content,
defaultProfileId: update.defaultProfileId,
defaultAgentName: update.defaultAgentName ?? null,
+ defaultContactable: update.defaultContactable ?? false,
systemPermissions: update.systemPermissions ?? null,
mcpToolPermissions: update.mcpToolPermissions ?? null,
version: current.version + 1,
@@ -2329,6 +2350,7 @@ export class MockTemplateGateway implements TemplateGateway {
},
synchronized,
skills: [],
+ contactable: template.defaultContactable ?? false,
};
this.agentGateway._insertAgent(projectId, agent, template.contentMd);
@@ -2940,6 +2962,9 @@ export class MockPermissionGateway implements PermissionGateway {
// document from the file/command permissions above. ─────────────────────
private mcpCatalogue: McpToolCatalogue = {
readOnlyTools: [
+ "idea_project_list",
+ "idea_project_read_context",
+ "idea_project_list_agents",
"idea_list_agents",
"idea_context_read",
"idea_memory_read",
@@ -2948,9 +2973,15 @@ export class MockPermissionGateway implements PermissionGateway {
"idea_ticket_read",
"idea_ticket_list",
"idea_ticket_read_carnet",
+ "idea_ticket_attachment_read",
"idea_sprint_list",
+ "idea_template_list",
+ "idea_template_read",
+ "idea_managed_temp_resolve",
],
writeActionTools: [
+ "idea_project_ask_agent",
+ "idea_project_cancel_agent",
"idea_ask_agent",
"idea_ask_agents",
"idea_run_in_background",
@@ -2958,6 +2989,7 @@ export class MockPermissionGateway implements PermissionGateway {
"idea_stop_agent",
"idea_update_context",
"idea_context_propose",
+ "idea_context_update",
"idea_memory_write",
"idea_workstate_set",
"idea_create_skill",
@@ -2965,14 +2997,30 @@ export class MockPermissionGateway implements PermissionGateway {
"idea_ticket_update",
"idea_ticket_update_status",
"idea_ticket_update_priority",
+ "idea_ticket_bulk_update_status",
+ "idea_ticket_bulk_update_priority",
+ "idea_ticket_bulk_delete",
"idea_ticket_update_carnet",
+ "idea_ticket_attachment_add",
+ "idea_ticket_attachment_mark_summarized",
"idea_ticket_link",
"idea_ticket_unlink",
+ "idea_template_create",
+ "idea_template_update",
+ "idea_template_delete",
+ "idea_managed_temp_allocate",
+ "idea_managed_temp_release",
+ "idea_managed_temp_gc",
],
};
private mcpDocs = new Map();
+ setMcpToolCatalogue(catalogue: McpToolCatalogue): void {
+ this.mcpCatalogue = structuredClone(catalogue);
+ this.mcpDocs.clear();
+ }
+
private mcpDoc(projectId: string): ProjectMcpToolPermissions {
if (!this.mcpDocs.has(projectId)) {
this.mcpDocs.set(projectId, {
diff --git a/frontend/src/adapters/template.ts b/frontend/src/adapters/template.ts
index 9a3fc79c..27577465 100644
--- a/frontend/src/adapters/template.ts
+++ b/frontend/src/adapters/template.ts
@@ -28,6 +28,7 @@ export class TauriTemplateGateway implements TemplateGateway {
content: input.content,
defaultProfileId: input.defaultProfileId,
defaultAgentName: input.defaultAgentName ?? null,
+ defaultContactable: input.defaultContactable ?? false,
defaultSystemPermissions: input.systemPermissions ?? null,
mcpToolPermissions: input.mcpToolPermissions ?? null,
},
@@ -42,6 +43,7 @@ export class TauriTemplateGateway implements TemplateGateway {
content: input.content,
defaultProfileId: input.defaultProfileId,
defaultAgentName: input.defaultAgentName ?? null,
+ defaultContactable: input.defaultContactable ?? false,
defaultSystemPermissions: input.systemPermissions ?? null,
mcpToolPermissions: input.mcpToolPermissions ?? null,
},
diff --git a/frontend/src/adapters/templateNormalization.ts b/frontend/src/adapters/templateNormalization.ts
index f3b209e8..119bd8dc 100644
--- a/frontend/src/adapters/templateNormalization.ts
+++ b/frontend/src/adapters/templateNormalization.ts
@@ -9,6 +9,7 @@ export function normalizeTemplate(template: TemplateDto): Template {
const { defaultSystemPermissions, defaultMcpToolPermissions, ...rest } = template;
return {
...rest,
+ defaultContactable: rest.defaultContactable ?? false,
systemPermissions: rest.systemPermissions ?? defaultSystemPermissions ?? null,
mcpToolPermissions: rest.mcpToolPermissions ?? defaultMcpToolPermissions ?? null,
};
diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts
index 146104c6..1460da0e 100644
--- a/frontend/src/domain/index.ts
+++ b/frontend/src/domain/index.ts
@@ -820,7 +820,6 @@ export type NetworkPolicy = "allow" | "deny" | "ask";
/** Optional system permission bundle, distinct from file/bash permissions. */
export interface SystemPermissionSet {
network?: NetworkPolicy;
- crossProjectContactable?: boolean;
}
/** One sparse agent system-permission override. */
@@ -1316,6 +1315,8 @@ export interface Agent {
skills: SkillRef[];
/** Per-agent effort override. Omitted for older manifests/profile default. */
effort?: EffortSelection;
+ /** Whether agents from other projects may contact this agent. Legacy absence means false. */
+ contactable?: boolean;
}
/** Response DTO returned by `read_agent_context`; adapters unwrap `content`. */
@@ -1502,6 +1503,8 @@ export interface Template {
version: number;
defaultProfileId: string;
defaultAgentName?: string | null;
+ /** Initial cross-project contactability for agents created from this template. */
+ defaultContactable?: boolean;
systemPermissions?: SystemPermissionSet | null;
mcpToolPermissions?: McpToolPolicy | null;
}
diff --git a/frontend/src/features/permissions/McpToolPermissionsPanel.test.tsx b/frontend/src/features/permissions/McpToolPermissionsPanel.test.tsx
index 33cd6d69..00d96653 100644
--- a/frontend/src/features/permissions/McpToolPermissionsPanel.test.tsx
+++ b/frontend/src/features/permissions/McpToolPermissionsPanel.test.tsx
@@ -121,6 +121,39 @@ describe("PermissionsPanel — Tools MCP IdeA tab", () => {
})).toBeTruthy();
});
+ it("shows cross-project tools in their UX group and catalogued unmapped tools in Autres", async () => {
+ const agent = new MockAgentGateway();
+ const permission = new MockPermissionGateway();
+ permission.setMcpToolCatalogue({
+ readOnlyTools: ["idea_project_list", "idea_future_read"],
+ writeActionTools: ["idea_project_ask_agent"],
+ });
+ const gateways = {
+ agent,
+ permission,
+ profile: new MockProfileGateway(),
+ } as unknown as Gateways;
+ render(
+
+
+ ,
+ );
+
+ await openMcpToolsTab();
+
+ expect(screen.getByRole("button", { name: /IdeA inter-projets/ })).toBeTruthy();
+ expect(
+ screen.getByRole("checkbox", {
+ name: /Demander à un agent d'un autre projet, idea_project_ask_agent$/,
+ }),
+ ).toBeTruthy();
+ expect(screen.getByRole("button", { name: /Autres/ })).toBeTruthy();
+ const fallback = screen.getByRole("checkbox", {
+ name: /idea_future_read, idea_future_read$/,
+ }) as HTMLInputElement;
+ expect(fallback.disabled).toBe(false);
+ });
+
it("creating an override prefills the draft with the current effective allowlist", async () => {
await renderPanel();
await openMcpToolsTab();
@@ -156,14 +189,14 @@ describe("PermissionsPanel — Tools MCP IdeA tab", () => {
await selectAgentTarget("DevFrontend");
fireEvent.click(screen.getByRole("button", { name: "Créer un override" }));
- expect(screen.getByText(/9 lecture autorisés · 0 écriture autorisé/)).toBeTruthy();
+ expect(screen.getByText(/16 lecture autorisés · 0 écriture autorisé/)).toBeTruthy();
const writeCheckbox = screen.getByRole("checkbox", {
name: /idea_ticket_update_carnet$/,
});
fireEvent.click(writeCheckbox);
- expect(screen.getByText(/9 lecture autorisés · 1 écriture autorisé/)).toBeTruthy();
+ expect(screen.getByText(/16 lecture autorisés · 1 écriture autorisé/)).toBeTruthy();
expect(screen.getByText("Modifications non enregistrées")).toBeTruthy();
expect(screen.getByText("Ajouté")).toBeTruthy();
});
@@ -196,7 +229,7 @@ describe("PermissionsPanel — Tools MCP IdeA tab", () => {
"idea_ticket_update_carnet",
]),
);
- expect(calledPolicy?.allowedTools).toHaveLength(10);
+ expect(calledPolicy?.allowedTools).toHaveLength(17);
// Persisted: the left-column badge flips to Override, and Réinitialiser appears.
const nav = screen.getByRole("navigation", { name: "cibles des permissions MCP" });
diff --git a/frontend/src/features/permissions/McpToolPermissionsPanel.tsx b/frontend/src/features/permissions/McpToolPermissionsPanel.tsx
index 772d7ef0..511700be 100644
--- a/frontend/src/features/permissions/McpToolPermissionsPanel.tsx
+++ b/frontend/src/features/permissions/McpToolPermissionsPanel.tsx
@@ -17,7 +17,10 @@ import { useEffect, useMemo, useRef, useState } from "react";
import type { Agent, McpToolCatalogue } from "@/domain";
import { Button, Panel, cn } from "@/shared";
-import { MCP_TOOL_GROUPS, mcpToolLabel } from "./mcpToolGroups";
+import {
+ mcpToolGroupsForCatalogue,
+ mcpToolLabel,
+} from "./mcpToolGroups";
import { useMcpToolPermissions } from "./useMcpToolPermissions";
export interface McpToolPermissionsPanelProps {
@@ -243,9 +246,13 @@ function McpToolEditor({
const [pendingConfirm, setPendingConfirm] = useState(false);
const [savedFlash, setSavedFlash] = useState(false);
const [saveError, setSaveError] = useState(null);
+ const toolGroups = useMemo(
+ () => mcpToolGroupsForCatalogue(catalogue),
+ [catalogue],
+ );
const [expandedGroups, setExpandedGroups] = useState>(() =>
Object.fromEntries(
- MCP_TOOL_GROUPS.map((group) => [
+ mcpToolGroupsForCatalogue(catalogue).map((group) => [
group.id,
READ_GROUP_IDS.has(group.id) ? targetKind === "project" : true,
]),
@@ -375,7 +382,7 @@ function McpToolEditor({
Détail des tools
- {MCP_TOOL_GROUPS.map((group) => (
+ {toolGroups.map((group) => (
= {
const CROSS_PROJECT_HELP =
"Autorise les agents d’autres projets IdeA à solliciter cet agent via l’orchestration inter-projets. L’agent reste soumis à ses permissions et à son contexte.";
-function systemPermissionsFromDraft(
- network: NetworkPolicy | "",
- crossProjectContactable: boolean,
-): SystemPermissionSet | null {
+function systemPermissionsFromDraft(network: NetworkPolicy | ""): SystemPermissionSet | null {
const permissions: SystemPermissionSet = {};
if (network) permissions.network = network;
- if (crossProjectContactable) permissions.crossProjectContactable = true;
return Object.keys(permissions).length > 0 ? permissions : null;
}
@@ -110,11 +106,24 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
}
}
- async function handleSaveNetwork(permissions: SystemPermissionSet | null) {
+ async function handleSaveNetwork(
+ permissions: SystemPermissionSet | null,
+ contactable?: boolean,
+ ) {
if (target.type === "project") {
await vm.saveProjectSystemPermissions(permissions);
} else {
- await vm.saveAgentSystemPermissions(target.agentId, permissions);
+ const networkChanged =
+ (permissions?.network ?? "") !== (selectedAgent?.systemOverride?.network ?? "");
+ const contactableChanged =
+ contactable !== undefined &&
+ contactable !== (selectedAgent?.agent.contactable ?? false);
+ if (networkChanged) {
+ await vm.saveAgentSystemPermissions(target.agentId, permissions);
+ }
+ if (contactableChanged) {
+ await vm.saveAgentContactable(target.agentId, contactable);
+ }
}
}
@@ -261,8 +270,11 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
: null
}
showCrossProjectContactable={target.type === "agent"}
+ contactable={selectedAgent?.agent.contactable ?? false}
busy={vm.busy}
- onSave={(permissions) => void handleSaveNetwork(permissions)}
+ onSave={(permissions, contactable) =>
+ void handleSaveNetwork(permissions, contactable)
+ }
/>
@@ -335,8 +347,9 @@ interface NetworkPermissionEditorProps {
inherited: SystemPermissionSet | null;
resolved: ResolvedAgentSystemPermissions | null;
showCrossProjectContactable: boolean;
+ contactable: boolean;
busy: boolean;
- onSave: (permissions: SystemPermissionSet | null) => void;
+ onSave: (permissions: SystemPermissionSet | null, contactable?: boolean) => void;
}
function NetworkPermissionEditor({
@@ -345,12 +358,12 @@ function NetworkPermissionEditor({
inherited,
resolved,
showCrossProjectContactable,
+ contactable,
busy,
onSave,
}: NetworkPermissionEditorProps) {
const current = source?.network ?? "";
- const currentCrossProjectContactable =
- source?.crossProjectContactable ?? false;
+ const currentCrossProjectContactable = contactable;
const [local, setLocal] = useState(current);
const [localCrossProjectContactable, setLocalCrossProjectContactable] =
useState(currentCrossProjectContactable);
@@ -484,7 +497,7 @@ function NetworkPermissionEditor({
busy ||
readOnly ||
(!source?.network &&
- !(showCrossProjectContactable && source?.crossProjectContactable))
+ !(showCrossProjectContactable && currentCrossProjectContactable))
}
onClick={() => onSave(null)}
>
@@ -498,10 +511,10 @@ function NetworkPermissionEditor({
loading={busy && changed}
onClick={() =>
onSave(
- systemPermissionsFromDraft(
- local,
- showCrossProjectContactable && localCrossProjectContactable,
- ),
+ systemPermissionsFromDraft(local),
+ showCrossProjectContactable
+ ? localCrossProjectContactable
+ : undefined,
)
}
>
diff --git a/frontend/src/features/permissions/mcpToolGroups.ts b/frontend/src/features/permissions/mcpToolGroups.ts
index 603e0311..8937d06f 100644
--- a/frontend/src/features/permissions/mcpToolGroups.ts
+++ b/frontend/src/features/permissions/mcpToolGroups.ts
@@ -1,3 +1,5 @@
+import type { McpToolCatalogue } from "@/domain";
+
/**
* Presentational grouping of the MCP tool catalogue (ticket #82).
*
@@ -15,6 +17,17 @@ export interface McpToolGroupDef {
/** Domain groups, in display order (carnet #82 — Conception UX/F). */
export const MCP_TOOL_GROUPS: McpToolGroupDef[] = [
+ {
+ id: "crossProject",
+ label: "IdeA inter-projets",
+ tools: [
+ "idea_project_list",
+ "idea_project_read_context",
+ "idea_project_list_agents",
+ "idea_project_ask_agent",
+ "idea_project_cancel_agent",
+ ],
+ },
{
id: "readProject",
label: "Lecture projet",
@@ -78,6 +91,11 @@ export const MCP_TOOL_GROUPS: McpToolGroupDef[] = [
/** Short, human French label per exact MCP tool name. */
export const MCP_TOOL_LABELS: Record = {
+ idea_project_list: "Lister les projets IdeA",
+ idea_project_read_context: "Lire le contexte d'un autre projet",
+ idea_project_list_agents: "Lister les agents d'un autre projet",
+ idea_project_ask_agent: "Demander à un agent d'un autre projet",
+ idea_project_cancel_agent: "Annuler une demande inter-projet",
idea_list_agents: "Lister les agents",
idea_context_read: "Lire le contexte d'un agent",
idea_memory_read: "Lire la mémoire projet",
@@ -115,3 +133,27 @@ export function mcpToolLabel(tool: string): string {
export function mcpToolGroupId(tool: string): string | undefined {
return MCP_TOOL_GROUPS.find((group) => group.tools.includes(tool))?.id;
}
+
+/**
+ * Builds the exhaustive presentation groups from the backend catalogue.
+ * Local groups only choose placement/labels; every catalogued tool remains
+ * configurable, with unmapped names collected under the visible fallback.
+ */
+export function mcpToolGroupsForCatalogue(
+ catalogue: McpToolCatalogue,
+): McpToolGroupDef[] {
+ const catalogued = Array.from(
+ new Set([...catalogue.readOnlyTools, ...catalogue.writeActionTools]),
+ );
+ const cataloguedSet = new Set(catalogued);
+ const mapped = new Set();
+ const groups = MCP_TOOL_GROUPS.flatMap((group) => {
+ const tools = group.tools.filter((tool) => cataloguedSet.has(tool));
+ tools.forEach((tool) => mapped.add(tool));
+ return tools.length > 0 ? [{ ...group, tools }] : [];
+ });
+ const otherTools = catalogued.filter((tool) => !mapped.has(tool));
+ return otherTools.length > 0
+ ? [...groups, { id: "other", label: "Autres", tools: otherTools }]
+ : groups;
+}
diff --git a/frontend/src/features/permissions/permissions.test.tsx b/frontend/src/features/permissions/permissions.test.tsx
index 3c66bbc0..068be60c 100644
--- a/frontend/src/features/permissions/permissions.test.tsx
+++ b/frontend/src/features/permissions/permissions.test.tsx
@@ -1,4 +1,4 @@
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import {
@@ -103,7 +103,9 @@ describe("PermissionsPanel", () => {
});
it("defaults cross-project contactability off and persists the agent checkbox", async () => {
- const { permission } = await renderPanel();
+ const { agent, permission } = await renderPanel();
+ const contactableSpy = vi.spyOn(agent, "updateAgentContactable");
+ const systemSpy = vi.spyOn(permission, "updateAgentSystemPermissions");
fireEvent.click(screen.getByText("Builder"));
@@ -117,16 +119,24 @@ describe("PermissionsPanel", () => {
),
).toBeTruthy();
+ await waitFor(() => expect(checkbox.disabled).toBe(false));
fireEvent.click(checkbox);
- fireEvent.click(screen.getByRole("button", { name: "Save network" }));
+ const save = screen.getByRole("button", { name: "Save network" });
+ await waitFor(() => expect(save).toHaveProperty("disabled", false));
+ fireEvent.click(save);
- await waitFor(async () => {
- const doc = await permission.getProjectSystemPermissions(PROJECT_ID);
- expect(doc.agents?.[0]).toEqual({
- agentId: expect.any(String),
- permissions: { crossProjectContactable: true },
- });
+ await waitFor(() => {
+ expect(contactableSpy).toHaveBeenCalledWith(
+ PROJECT_ID,
+ expect.any(String),
+ true,
+ );
});
+ await waitFor(async () => {
+ const agents = await agent.listAgents(PROJECT_ID);
+ expect(agents[0].contactable).toBe(true);
+ });
+ expect(systemSpy).not.toHaveBeenCalled();
});
it("renders the network control read-only when persisted control says so", async () => {
diff --git a/frontend/src/features/permissions/usePermissions.ts b/frontend/src/features/permissions/usePermissions.ts
index 657028f3..3c5f2fac 100644
--- a/frontend/src/features/permissions/usePermissions.ts
+++ b/frontend/src/features/permissions/usePermissions.ts
@@ -54,6 +54,7 @@ export interface PermissionsViewModel {
agentId: string,
permissions: SystemPermissionSet | null,
) => Promise;
+ saveAgentContactable: (agentId: string, contactable: boolean) => Promise;
}
const DEFAULT_DRAFT: PolicyDraft = {
@@ -342,6 +343,30 @@ export function usePermissions(projectId: string): PermissionsViewModel {
[permission, projectId, refresh],
);
+ const saveAgentContactable = useCallback(
+ async (agentId: string, contactable: boolean) => {
+ setBusy(true);
+ setError(null);
+ try {
+ const updated = await agent.updateAgentContactable(
+ projectId,
+ agentId,
+ contactable,
+ );
+ setAgents((current) =>
+ current.map((candidate) =>
+ candidate.id === updated.id ? updated : candidate,
+ ),
+ );
+ } catch (e) {
+ setError(describe(e));
+ } finally {
+ setBusy(false);
+ }
+ },
+ [agent, projectId],
+ );
+
return {
agents,
rows,
@@ -357,5 +382,6 @@ export function usePermissions(projectId: string): PermissionsViewModel {
clearAgentOverride,
saveProjectSystemPermissions,
saveAgentSystemPermissions,
+ saveAgentContactable,
};
}
diff --git a/frontend/src/features/templates/TemplateEditor.tsx b/frontend/src/features/templates/TemplateEditor.tsx
index 8f009b49..55449e01 100644
--- a/frontend/src/features/templates/TemplateEditor.tsx
+++ b/frontend/src/features/templates/TemplateEditor.tsx
@@ -4,12 +4,16 @@ import remarkGfm from "remark-gfm";
import type {
AgentProfile,
+ McpToolCatalogue,
McpToolPolicy,
NetworkPolicy,
SystemPermissionSet,
Template,
} from "@/domain";
-import { MCP_TOOL_GROUPS, mcpToolLabel } from "@/features/permissions/mcpToolGroups";
+import {
+ mcpToolGroupsForCatalogue,
+ mcpToolLabel,
+} from "@/features/permissions/mcpToolGroups";
import { Button, Input, SmallDropdown, cn } from "@/shared";
import { profileLabel } from "./templatePresentation";
@@ -18,6 +22,7 @@ export interface TemplateEditorSaveInput {
content: string;
defaultProfileId: string;
defaultAgentName: string | null;
+ defaultContactable: boolean;
systemPermissions: SystemPermissionSet | null;
mcpToolPermissions: McpToolPolicy | null;
}
@@ -25,6 +30,7 @@ export interface TemplateEditorSaveInput {
export interface TemplateEditorProps {
template?: Template | null;
profiles: AgentProfile[];
+ mcpCatalogue: McpToolCatalogue;
onSave: (input: TemplateEditorSaveInput) => Promise;
onClose: () => void;
busy?: boolean;
@@ -42,13 +48,10 @@ const NETWORK_OPTIONS: { value: "" | NetworkPolicy; label: string }[] = [
const TEMPLATE_CROSS_PROJECT_HELP =
"Les agents créés avec ce template reprendront cette permission.";
-const ALL_MCP_TOOLS = Array.from(
- new Set(MCP_TOOL_GROUPS.flatMap((group) => group.tools)),
-);
-
export function TemplateEditor({
template,
profiles,
+ mcpCatalogue,
onSave,
onClose,
busy = false,
@@ -64,13 +67,21 @@ export function TemplateEditor({
const [network, setNetwork] = useState<"" | NetworkPolicy>(
template?.systemPermissions?.network ?? "",
);
- const [crossProjectContactable, setCrossProjectContactable] = useState(
- template?.systemPermissions?.crossProjectContactable ?? false,
+ const [defaultContactable, setDefaultContactable] = useState(
+ template?.defaultContactable ?? false,
);
const [allowedTools, setAllowedTools] = useState(
template?.mcpToolPermissions?.allowedTools ?? [],
);
const [tab, setTab] = useState("edit");
+ const toolGroups = useMemo(
+ () => mcpToolGroupsForCatalogue(mcpCatalogue),
+ [mcpCatalogue],
+ );
+ const allMcpTools = useMemo(
+ () => toolGroups.flatMap((group) => group.tools),
+ [toolGroups],
+ );
const allowed = useMemo(() => new Set(allowedTools), [allowedTools]);
const canSave = name.trim().length > 0 && !busy;
@@ -91,15 +102,8 @@ export function TemplateEditor({
content,
defaultProfileId,
defaultAgentName: defaultAgentName.trim() || null,
- systemPermissions:
- network || crossProjectContactable
- ? {
- ...(network ? { network } : {}),
- ...(crossProjectContactable
- ? { crossProjectContactable: true }
- : {}),
- }
- : null,
+ defaultContactable,
+ systemPermissions: network ? { network } : null,
mcpToolPermissions:
allowedTools.length > 0 ? { allowedTools: [...allowedTools] } : null,
});
@@ -275,8 +279,8 @@ export function TemplateEditor({
setCrossProjectContactable(e.target.checked)}
+ checked={defaultContactable}
+ onChange={(e) => setDefaultContactable(e.target.checked)}
disabled={busy}
className="mt-0.5 h-4 w-4 shrink-0 accent-primary disabled:cursor-not-allowed disabled:opacity-50"
/>
@@ -298,7 +302,7 @@ export function TemplateEditor({
{allowedTools.length} autorisé(s)
- {ALL_MCP_TOOLS.length === 0 ? (
+ {allMcpTools.length === 0 ? (
Aucun tool MCP IdeA disponible.
) : (
<>
@@ -308,7 +312,7 @@ export function TemplateEditor({
)}
- {MCP_TOOL_GROUPS.map((group) => (
+ {toolGroups.map((group) => (
{group.label}
{group.tools.map((tool) => (
diff --git a/frontend/src/features/templates/TemplatesPanel.tsx b/frontend/src/features/templates/TemplatesPanel.tsx
index 384e177d..104cf670 100644
--- a/frontend/src/features/templates/TemplatesPanel.tsx
+++ b/frontend/src/features/templates/TemplatesPanel.tsx
@@ -17,7 +17,7 @@
import { useEffect, useState } from "react";
-import type { AgentProfile, Template } from "@/domain";
+import type { AgentProfile, McpToolCatalogue, Template } from "@/domain";
import { Button, Panel, Spinner } from "@/shared";
import { useGateways } from "@/app/di";
import { TemplateEditor, type TemplateEditorSaveInput } from "./TemplateEditor";
@@ -33,6 +33,11 @@ export interface TemplatesPanelProps {
projectId: string;
}
+const EMPTY_MCP_CATALOGUE: McpToolCatalogue = {
+ readOnlyTools: [],
+ writeActionTools: [],
+};
+
/** Editor open-state: "create" or edit-mode for a specific template. */
type EditorState =
| { mode: "create" }
@@ -40,11 +45,12 @@ type EditorState =
export function TemplatesPanel({ projectId }: TemplatesPanelProps) {
const vm = useTemplates();
- const { profile } = useGateways();
+ const { permission, profile } = useGateways();
// Profiles list for the editor's default-profile selector
const [profiles, setProfiles] = useState
([]);
const [profilesLoaded, setProfilesLoaded] = useState(false);
+ const [mcpCatalogue, setMcpCatalogue] = useState(null);
const [editorState, setEditorState] = useState(null);
const [editorBusy, setEditorBusy] = useState(false);
@@ -69,6 +75,25 @@ export function TemplatesPanel({ projectId }: TemplatesPanelProps) {
};
}, [profile, profilesLoaded]);
+ useEffect(() => {
+ if (!permission) {
+ setMcpCatalogue(EMPTY_MCP_CATALOGUE);
+ return;
+ }
+ let cancelled = false;
+ void permission
+ .getMcpToolPermissions(projectId)
+ .then((doc) => {
+ if (!cancelled) setMcpCatalogue(doc.catalogue);
+ })
+ .catch(() => {
+ if (!cancelled) setMcpCatalogue(EMPTY_MCP_CATALOGUE);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [permission, projectId]);
+
/** Lazily load profiles when the editor is first opened. */
async function openEditor(state: EditorState) {
if (!profilesLoaded) {
@@ -105,6 +130,7 @@ export function TemplatesPanel({ projectId }: TemplatesPanelProps) {
setEditorState(null)}
busy={editorBusy}
diff --git a/frontend/src/features/templates/templates.test.tsx b/frontend/src/features/templates/templates.test.tsx
index 36a39373..9096e3d4 100644
--- a/frontend/src/features/templates/templates.test.tsx
+++ b/frontend/src/features/templates/templates.test.tsx
@@ -17,7 +17,12 @@
import { describe, it, expect } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
-import { MockAgentGateway, MockProfileGateway, MockTemplateGateway } from "@/adapters/mock";
+import {
+ MockAgentGateway,
+ MockPermissionGateway,
+ MockProfileGateway,
+ MockTemplateGateway,
+} from "@/adapters/mock";
import type { Gateways } from "@/ports";
import { DIProvider } from "@/app/di";
import { TemplatesPanel } from "./TemplatesPanel";
@@ -33,6 +38,7 @@ interface RenderOpts {
agent?: MockAgentGateway;
template?: MockTemplateGateway;
profile?: MockProfileGateway;
+ permission?: MockPermissionGateway;
}
/**
@@ -43,11 +49,13 @@ function renderTemplatesPanel(opts: RenderOpts = {}) {
const agent = opts.agent ?? new MockAgentGateway();
const tmpl = opts.template ?? new MockTemplateGateway(agent);
const profile = opts.profile ?? new MockProfileGateway();
- const gateways = { agent, profile, template: tmpl } as unknown as Gateways;
+ const permission = opts.permission ?? new MockPermissionGateway();
+ const gateways = { agent, permission, profile, template: tmpl } as unknown as Gateways;
return {
agent,
tmpl,
profile,
+ permission,
...render(
@@ -187,11 +195,40 @@ describe("TemplatesPanel (with MockTemplateGateway)", () => {
expect(templates[0]).toMatchObject({
defaultAgentName: "Main",
defaultProfileId: "profile-main",
- systemPermissions: { network: "allow", crossProjectContactable: true },
+ defaultContactable: true,
+ systemPermissions: { network: "allow" },
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
});
});
+ it("preserves defaultContactable after save and reopen", async () => {
+ const { tmpl } = renderTemplatesPanel();
+ await waitForTemplatesIdle();
+
+ fireEvent.click(screen.getByRole("button", { name: "create template" }));
+ await screen.findByLabelText("template name");
+ fireEvent.change(screen.getByLabelText("template name"), {
+ target: { value: "Contactable" },
+ });
+ fireEvent.click(screen.getByLabelText("Contactable depuis d’autres projets"));
+ fireEvent.click(screen.getByRole("button", { name: "Save template" }));
+
+ await screen.findByText("Contactable");
+ expect((await tmpl.listTemplates())[0]).toMatchObject({
+ defaultContactable: true,
+ systemPermissions: null,
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "edit Contactable" }));
+ await waitFor(() => {
+ expect(
+ (screen.getByLabelText(
+ "Contactable depuis d’autres projets",
+ ) as HTMLInputElement).checked,
+ ).toBe(true);
+ });
+ });
+
it("defaults template cross-project contactability off when the field is absent", async () => {
renderTemplatesPanel();
await waitForTemplatesIdle();
@@ -211,6 +248,28 @@ describe("TemplatesPanel (with MockTemplateGateway)", () => {
).toBeTruthy();
});
+ it("shows cross-project and ungrouped catalogued MCP tools in the template editor", async () => {
+ const permission = new MockPermissionGateway();
+ permission.setMcpToolCatalogue({
+ readOnlyTools: ["idea_project_list", "idea_future_read"],
+ writeActionTools: ["idea_project_cancel_agent"],
+ });
+ renderTemplatesPanel({ permission });
+ await waitForTemplatesIdle();
+
+ fireEvent.click(screen.getByRole("button", { name: "create template" }));
+
+ expect(await screen.findByText("IdeA inter-projets")).toBeTruthy();
+ expect(
+ screen.getByLabelText("template MCP tool idea_project_cancel_agent"),
+ ).toBeTruthy();
+ expect(screen.getByText("Autres")).toBeTruthy();
+ const fallback = screen.getByLabelText(
+ "template MCP tool idea_future_read",
+ ) as HTMLInputElement;
+ expect(fallback.disabled).toBe(false);
+ });
+
it("the Create button is disabled when the name is empty", async () => {
renderTemplatesPanel();
await waitForTemplatesIdle();
diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts
index 4177553d..3340a864 100644
--- a/frontend/src/ports/index.ts
+++ b/frontend/src/ports/index.ts
@@ -271,6 +271,12 @@ export interface AgentGateway {
agentId: string,
effort: EffortSelection | null,
): Promise;
+ /** Changes whether this agent may be contacted from another project. */
+ updateAgentContactable(
+ projectId: string,
+ agentId: string,
+ contactable: boolean,
+ ): Promise;
/** Reads an agent's `.md` context by agent id. */
readContext(projectId: string, agentId: string): Promise;
/** Overwrites an agent's `.md` context. */
@@ -630,6 +636,7 @@ export interface CreateTemplateInput {
content: string;
defaultProfileId: string;
defaultAgentName?: string | null;
+ defaultContactable?: boolean;
systemPermissions?: SystemPermissionSet | null;
mcpToolPermissions?: McpToolPolicy | null;
}
@@ -640,6 +647,7 @@ export interface UpdateTemplateInput {
content: string;
defaultProfileId: string;
defaultAgentName?: string | null;
+ defaultContactable?: boolean;
systemPermissions?: SystemPermissionSet | null;
mcpToolPermissions?: McpToolPolicy | null;
}