merge(cross-project): intègre le backend #260 — tools MCP inter-projets + permissions contactables dans les templates (pré-QA)

This commit is contained in:
2026-09-07 16:31:30 +02:00
18 changed files with 721 additions and 15 deletions

View File

@ -540,6 +540,7 @@ mod open_project_template_tests {
default_agent_name: None,
default_system_permissions: None,
default_mcp_tool_policy: None,
default_contactable: false,
})
.await
.expect("template is created")

View File

@ -78,6 +78,8 @@ impl TemplateToolProvider for AppTemplateToolProvider {
"defaultMcpToolPermissions",
"defaultMcpToolPolicy",
)?,
default_contactable: optional_bool(&arguments, "defaultContactable")?
.unwrap_or(false),
})
.await
.map_err(template_error)?;
@ -160,6 +162,17 @@ fn optional_string<'a>(
}
}
fn optional_bool(value: &Value, key: &str) -> Result<Option<bool>, TemplateToolError> {
match value.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::Bool(value)) => Ok(Some(*value)),
Some(_) => Err(TemplateToolError::new(
"invalid",
format!("field `{key}` must be a boolean"),
)),
}
}
fn optional_json_value<T>(arguments: &Value, key: &str) -> Result<Option<T>, TemplateToolError>
where
T: serde::de::DeserializeOwned,
@ -317,6 +330,7 @@ mod tests {
"defaultAgentName": "Base Agent",
"defaultSystemPermissions": { "network": "allow" },
"defaultMcpToolPermissions": { "allowedTools": ["idea_template_read"] },
"defaultContactable": true,
}),
)
.await
@ -332,6 +346,7 @@ mod tests {
created["defaultMcpToolPermissions"],
json!({ "allowedTools": ["idea_template_read"] })
);
assert_eq!(created["defaultContactable"], true);
let read = provider
.handle_template_tool(
@ -353,6 +368,7 @@ mod tests {
read["defaultMcpToolPermissions"],
json!({ "allowedTools": ["idea_template_read"] })
);
assert_eq!(read["defaultContactable"], true);
let listed = provider
.handle_template_tool(&project(), "agent", "idea_template_list", json!({}))
@ -370,6 +386,7 @@ mod tests {
"content": "v2",
"defaultSystemPermissions": { "network": "deny" },
"defaultMcpToolPermissions": { "allowedTools": ["idea_template_update"] },
"defaultContactable": false,
}),
)
.await
@ -385,6 +402,7 @@ mod tests {
updated["defaultMcpToolPermissions"],
json!({ "allowedTools": ["idea_template_update"] })
);
assert_eq!(updated["defaultContactable"], false);
assert_eq!(
store
.get(TemplateId::from_uuid(

View File

@ -790,6 +790,7 @@ mod tests {
synced_template_version: None,
skills: Vec::new(),
effort: None,
contactable: false,
}],
},
contexts: Mutex::new(contexts),

View File

@ -25,7 +25,8 @@ use domain::conversation_log::{ConversationTurn, TurnId, TurnRole};
use domain::input::{InputMediator, InputSource, SubmitConfig};
use domain::mailbox::{Ticket, TicketId};
use domain::ports::{
BackgroundTaskRunner, BackgroundTaskStore, Clock, EventBus, ProfileStore, PtyHandle,
BackgroundTaskRunner, BackgroundTaskStore, Clock, EventBus, ProfileStore, ProjectStore,
PtyHandle,
};
use domain::project::ProjectPath;
use domain::{
@ -407,6 +408,7 @@ pub struct OrchestratorService {
update_context: Arc<UpdateAgentContext>,
create_skill: Arc<CreateSkill>,
profiles: Arc<dyn ProfileStore>,
projects: Option<Arc<dyn ProjectStore>>,
sessions: Arc<TerminalSessions>,
/// Médiateur d'entrée (cadrage C3 §5.2) — point de convergence unique de l'entrée
/// d'un agent. La cible d'un `agent.message`/`idea_ask_agent` y reçoit un ticket
@ -460,7 +462,7 @@ pub struct OrchestratorService {
///
/// Croissance bornée en pratique au nombre d'agents du projet ; une entrée
/// morte ne coûte qu'un `Arc<Mutex<()>>` vide (pas de session, pas de process).
ask_locks: StdMutex<HashMap<AgentId, Arc<AsyncMutex<()>>>>,
ask_locks: StdMutex<HashMap<RuntimeAgentKey, Arc<AsyncMutex<()>>>>,
/// Fournisseur des faits OS/runtime (exe + endpoint) pour écrire la déclaration
/// MCP **réelle** quand `ensure_live_pty` (re)lance une cible sur le chemin `ask`
/// (B-3). Injecté au câblage via [`Self::with_mcp_runtime_provider`] depuis
@ -600,6 +602,7 @@ impl OrchestratorService {
update_context,
create_skill,
profiles,
projects: None,
sessions,
input: None,
mailbox: None,
@ -634,6 +637,13 @@ impl OrchestratorService {
self
}
/// Branche le registre des projets connus pour les outils MCP cross-project.
#[must_use]
pub fn with_project_store(mut self, projects: Arc<dyn ProjectStore>) -> Self {
self.projects = Some(projects);
self
}
/// Branche le producteur B8 command-backed pour `idea_run_in_background`.
#[must_use]
pub fn with_spawn_background_command(mut self, spawn: Arc<SpawnBackgroundCommand>) -> Self {
@ -719,12 +729,12 @@ impl OrchestratorService {
/// never across an `.await`); the returned `Arc<AsyncMutex<()>>` is the lock the
/// caller acquires (and holds across `send_blocking`) to serialise turns for
/// `agent_id`. Two different agents get two distinct `Arc`s ⇒ no contention.
fn ask_lock_for(&self, agent_id: &AgentId) -> Arc<AsyncMutex<()>> {
fn ask_lock_for(&self, project: &Project, agent_id: AgentId) -> Arc<AsyncMutex<()>> {
let mut locks = self
.ask_locks
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
Arc::clone(locks.entry(*agent_id).or_default())
Arc::clone(locks.entry(runtime_key(project, agent_id)).or_default())
}
fn active_rendezvous_link(
@ -1301,6 +1311,125 @@ impl OrchestratorService {
}
}
fn require_project_store(&self) -> Result<&Arc<dyn ProjectStore>, AppError> {
self.projects.as_ref().ok_or_else(|| {
AppError::Invalid("cross-project MCP tools are not configured".to_owned())
})
}
async fn load_project_for_cross_tool(
&self,
project_id: domain::ProjectId,
) -> Result<Project, AppError> {
self.require_project_store()?
.load_project(project_id)
.await
.map_err(AppError::from)
}
/// Lists every project known to this IdeA instance for cross-project MCP discovery.
pub async fn list_projects_for_mcp(&self) -> Result<OrchestratorOutcome, AppError> {
let projects = self
.require_project_store()?
.list_projects()
.await
.map_err(AppError::from)?;
let reply = serde_json::to_string(&projects)
.map_err(|e| AppError::Invalid(format!("failed to serialise project list: {e}")))?;
Ok(OrchestratorOutcome {
detail: format!("listed {} project(s)", projects.len()),
reply: Some(reply),
})
}
/// Reads the target project's global context, even when it is not currently open.
pub async fn read_project_context_for_mcp(
&self,
project_id: domain::ProjectId,
requester: ConversationParty,
) -> Result<OrchestratorOutcome, AppError> {
let project = self.load_project_for_cross_tool(project_id).await?;
self.read_context(&project, None, requester).await
}
/// Lists contactable agents in the target project, even when it is not currently open.
pub async fn list_project_agents_for_mcp(
&self,
project_id: domain::ProjectId,
) -> Result<OrchestratorOutcome, AppError> {
let project = self.load_project_for_cross_tool(project_id).await?;
let listed = self
.list_agents
.execute(ListAgentsInput {
project: project.clone(),
})
.await?;
let entries: Vec<_> = listed
.discovery_entries()
.into_iter()
.filter(|entry| entry.agent.contactable)
.collect();
let reply = serde_json::to_string(&entries)
.map_err(|e| AppError::Invalid(format!("failed to serialise agent list: {e}")))?;
Ok(OrchestratorOutcome {
detail: format!("listed {} contactable agent(s)", entries.len()),
reply: Some(reply),
})
}
/// Asks one contactable agent in another project by stable ids.
pub async fn ask_project_agent_for_mcp(
&self,
target_project_id: domain::ProjectId,
target_agent_id: AgentId,
task: String,
requester_project_id: domain::ProjectId,
requester_agent_id: AgentId,
correlation_id: String,
) -> Result<OrchestratorOutcome, AppError> {
let project = self.load_project_for_cross_tool(target_project_id).await?;
let target = self
.find_agent_by_id(&project, target_agent_id)
.await?
.ok_or_else(|| AppError::NotFound(format!("agent {target_agent_id}")))?;
if !target.contactable {
return Err(AppError::Invalid(format!(
"agent {target_agent_id} is not contactable cross-project"
)));
}
crate::diag!(
"[cross-project] ask correlation={correlation_id} requester={requester_project_id}/{requester_agent_id} target={target_project_id}/{target_agent_id}"
);
self.ask_agent(&project, target.name, task, Some(requester_agent_id))
.await
}
/// Cancels/stops one contactable agent in another project by stable ids.
pub async fn cancel_project_agent_for_mcp(
&self,
target_project_id: domain::ProjectId,
target_agent_id: AgentId,
requester_project_id: domain::ProjectId,
requester_agent_id: AgentId,
correlation_id: String,
) -> Result<OrchestratorOutcome, AppError> {
let project = self.load_project_for_cross_tool(target_project_id).await?;
let target = self
.find_agent_by_id(&project, target_agent_id)
.await?
.ok_or_else(|| AppError::NotFound(format!("agent {target_agent_id}")))?;
if !target.contactable {
return Err(AppError::Invalid(format!(
"agent {target_agent_id} is not contactable cross-project"
)));
}
crate::diag!(
"[cross-project] cancel correlation={correlation_id} requester={requester_project_id}/{requester_agent_id} target={target_project_id}/{target_agent_id}"
);
let _ = requester_agent_id;
self.stop_agent(&project, target.name, None).await
}
async fn run_in_background(
&self,
project: &Project,
@ -1818,7 +1947,7 @@ impl OrchestratorService {
// Sérialisation FIFO **par agent** (A0) : verrou de tour de la **cible**, tenu
// pour TOUT le tour (enqueue → réponse). RAII : tombe sur chaque early-return.
let lock = self.ask_lock_for(&agent_id);
let lock = self.ask_lock_for(project, agent_id);
// Diagnostics : tracer l'attente du verrou de tour. Un « turn-lock waiting » sans
// « turn-lock acquired » qui suit signe un tour précédent jamais relâché (la cible
// reste Busy) — le verrou n'est libéré qu'à la fin du tour courant.
@ -2886,6 +3015,21 @@ impl OrchestratorService {
.find(|a| a.name.eq_ignore_ascii_case(name)))
}
/// Finds the full [`domain::Agent`] by stable id in the project manifest.
async fn find_agent_by_id(
&self,
project: &Project,
agent_id: AgentId,
) -> Result<Option<domain::Agent>, AppError> {
let listed = self
.list_agents
.execute(ListAgentsInput {
project: project.clone(),
})
.await?;
Ok(listed.agents.into_iter().find(|a| a.id == agent_id))
}
/// Resolves the target agent profile's **submit config**
/// (`submit_sequence`/`submit_delay_ms`, ARCHITECTURE §20.3) **and** whether it
/// declares an **MCP bridge**, in a single profile lookup. The submit config is

View File

@ -45,6 +45,8 @@ pub struct CreateTemplateInput {
pub default_system_permissions: Option<SystemPermissionSet>,
/// Optional agent-level MCP tool permission override applied at creation time.
pub default_mcp_tool_policy: Option<McpToolPolicy>,
/// Whether agents created from this template are cross-project contactable.
pub default_contactable: bool,
}
/// Output of [`CreateTemplate::execute`].
@ -90,6 +92,7 @@ impl CreateTemplate {
input.default_agent_name,
input.default_system_permissions,
input.default_mcp_tool_policy,
input.default_contactable,
)
})
.map_err(|e| AppError::Invalid(e.to_string()))?;
@ -122,6 +125,8 @@ pub struct UpdateTemplateInput {
/// New default MCP tool policy. Outer `None` leaves the current value; inner
/// `None` preserves project inheritance for future agents.
pub default_mcp_tool_policy: Option<Option<McpToolPolicy>>,
/// New default contactable flag. `None` leaves the current value.
pub default_contactable: Option<bool>,
}
/// Output of [`UpdateTemplate::execute`].
@ -175,6 +180,9 @@ impl UpdateTemplate {
input
.default_mcp_tool_policy
.unwrap_or_else(|| updated.default_mcp_tool_policy.clone()),
input
.default_contactable
.unwrap_or(updated.default_contactable),
)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.templates.save(&updated).await?;
@ -397,7 +405,8 @@ impl CreateAgentFromTemplate {
origin,
input.synchronized,
)
.map_err(|e| AppError::Invalid(e.to_string()))?;
.map_err(|e| AppError::Invalid(e.to_string()))?
.with_contactable(template.default_contactable);
let mut entries = manifest.entries;
entries.push(ManifestEntry::from_agent(&agent));

View File

@ -24,8 +24,9 @@ use domain::ports::{
AgentContextStore, AgentRuntime, BackgroundCompletionStream, BackgroundTaskHandle,
BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskSpec, BackgroundTaskStore,
ContextInjectionPlan, DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError,
IdGenerator, MemoryError, MemoryStore, OutputStream, PreparedContext, ProfileStore, PtyError,
PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
IdGenerator, MemoryError, MemoryStore, OutputStream, PreparedContext, ProfileStore,
ProjectStore, PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore,
SpawnSpec, StoreError,
};
use domain::profile::{
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
@ -172,6 +173,45 @@ impl ProfileStore for FakeProfiles {
}
}
#[derive(Clone)]
struct FakeProjects(Arc<Vec<Project>>);
impl FakeProjects {
fn new(projects: Vec<Project>) -> Self {
Self(Arc::new(projects))
}
}
#[async_trait]
impl ProjectStore for FakeProjects {
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
Ok((*self.0).clone())
}
async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
self.0
.iter()
.find(|project| project.id == id)
.cloned()
.ok_or(StoreError::NotFound)
}
async fn save_project(&self, _project: &Project) -> Result<(), StoreError> {
Ok(())
}
async fn save_workspace(
&self,
_workspace: &domain::layout::Workspace,
) -> Result<(), StoreError> {
Ok(())
}
async fn load_workspace(&self) -> Result<domain::layout::Workspace, StoreError> {
Ok(domain::layout::Workspace::default())
}
}
// Empty skill store: the orchestrator tests spawn agents with no assigned skills.
#[derive(Default)]
struct FakeSkills;
@ -795,10 +835,103 @@ fn cmd(json: &str) -> OrchestratorCommand {
.unwrap()
}
fn other_project() -> Project {
Project::new(
ProjectId::from_uuid(Uuid::from_u128(2000)),
"other",
ProjectPath::new("/home/me/other").unwrap(),
RemoteRef::local(),
1_700_000_000_001,
)
.unwrap()
}
// ---------------------------------------------------------------------------
// Basic dispatch coverage (spawn / stop / update / skill) — non-ask commands.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn cross_project_list_projects_reads_known_project_registry() {
let fx = fixture(FakeContexts::new());
let svc = fx
.service
.with_project_store(Arc::new(FakeProjects::new(vec![
project(),
other_project(),
])));
let out = svc
.list_projects_for_mcp()
.await
.expect("project registry is readable");
let rows: serde_json::Value = serde_json::from_str(&out.reply.unwrap()).unwrap();
assert_eq!(rows.as_array().unwrap().len(), 2);
assert_eq!(rows[0]["id"], project_id().to_string());
assert_eq!(rows[1]["id"], other_project().id.to_string());
}
#[tokio::test]
async fn cross_project_list_agents_filters_to_contactable_agents() {
let visible = scratch_agent(aid(1), "Visible", "agents/visible.md").with_contactable(true);
let hidden = scratch_agent(aid(2), "Hidden", "agents/hidden.md");
let contexts = {
let me = FakeContexts::with_agent(&visible, "# visible");
let mut inner = me.0.lock().unwrap();
inner
.manifest
.entries
.push(ManifestEntry::from_agent(&hidden));
inner
.contents
.insert(hidden.context_path.clone(), "# hidden".to_owned());
drop(inner);
me
};
let fx = fixture(contexts);
let target_project = other_project();
let svc = fx
.service
.with_project_store(Arc::new(FakeProjects::new(vec![target_project.clone()])));
let out = svc
.list_project_agents_for_mcp(target_project.id)
.await
.expect("agent manifest is readable");
let rows: serde_json::Value = serde_json::from_str(&out.reply.unwrap()).unwrap();
assert_eq!(rows.as_array().unwrap().len(), 1);
assert_eq!(rows[0]["id"], visible.id.to_string());
assert_eq!(rows[0]["name"], "Visible");
assert_eq!(rows[0]["contactable"], true);
}
#[tokio::test]
async fn cross_project_ask_rejects_non_contactable_agent_before_launch() {
let hidden = scratch_agent(aid(2), "Hidden", "agents/hidden.md");
let fx = fixture(FakeContexts::with_agent(&hidden, "# hidden"));
let target_project = other_project();
let svc = fx
.service
.with_project_store(Arc::new(FakeProjects::new(vec![target_project.clone()])));
let err = svc
.ask_project_agent_for_mcp(
target_project.id,
hidden.id,
"work".to_owned(),
project().id,
aid(99),
"corr-1".to_owned(),
)
.await
.unwrap_err();
assert_eq!(err.code(), "INVALID");
assert!(err.to_string().contains("not contactable"));
assert!(fx.pty.spawns().is_empty(), "agent must not be launched");
}
#[tokio::test]
async fn spawn_unknown_agent_creates_then_launches() {
let fx = fixture(FakeContexts::new());

View File

@ -282,6 +282,7 @@ async fn create_template_starts_at_initial_version() {
default_mcp_tool_policy: Some(McpToolPolicy {
allowed_tools: vec!["idea_template_read".to_owned()],
}),
default_contactable: true,
})
.await
.unwrap();
@ -341,6 +342,7 @@ async fn update_template_bumps_version_and_publishes_event() {
default_agent_name: None,
default_system_permissions: None,
default_mcp_tool_policy: None,
default_contactable: None,
})
.await
.unwrap();
@ -370,6 +372,7 @@ async fn update_unknown_template_is_not_found() {
default_agent_name: None,
default_system_permissions: None,
default_mcp_tool_policy: None,
default_contactable: None,
})
.await
.unwrap_err();
@ -393,6 +396,7 @@ async fn update_template_updates_default_profile_for_future_agents() {
default_mcp_tool_policy: Some(Some(McpToolPolicy {
allowed_tools: vec!["idea_template_read".to_owned()],
})),
default_contactable: Some(true),
})
.await
.unwrap()
@ -411,6 +415,7 @@ async fn update_template_updates_default_profile_for_future_agents() {
allowed_tools: vec!["idea_template_read".to_owned()]
})
);
assert!(updated.default_contactable);
let contexts = FakeContexts::new(vec![]);
let out = CreateAgentFromTemplate::new(
@ -427,6 +432,7 @@ async fn update_template_updates_default_profile_for_future_agents() {
})
.await
.unwrap();
assert!(out.agent.contactable);
assert_eq!(out.agent.name, "Agent Name");
assert_eq!(out.agent.profile_id, pid(42));
@ -486,6 +492,7 @@ async fn create_agent_from_template_projects_template_permission_overrides() {
Some(McpToolPolicy {
allowed_tools: vec!["idea_template_read".to_owned()],
}),
true,
)
.unwrap();
let store = FakeTemplates::with(vec![template]);
@ -512,6 +519,7 @@ async fn create_agent_from_template_projects_template_permission_overrides() {
assert_eq!(out.agent.name, "Backend");
assert_eq!(out.agent.profile_id, pid(7));
assert!(out.agent.contactable);
assert_eq!(
system_permissions.0.lock().unwrap().agents,
vec![AgentSystemPermissionOverride::new(

View File

@ -4295,6 +4295,8 @@ pub struct CreateTemplateRequestDto {
pub default_system_permissions: Option<SystemPermissionSet>,
/// Optional agent-level MCP tool policy override for created agents.
pub default_mcp_tool_policy: Option<McpToolPolicy>,
/// Whether agents created from this template are cross-project contactable.
pub default_contactable: bool,
}
impl<'de> Deserialize<'de> for CreateTemplateRequestDto {
@ -4327,6 +4329,10 @@ impl<'de> Deserialize<'de> for CreateTemplateRequestDto {
)
.map_err(serde::de::Error::custom)?
.flatten();
let default_contactable = take_optional_value::<bool>(object, "defaultContactable")
.map_err(serde::de::Error::custom)?
.flatten()
.unwrap_or(false);
Ok(Self {
name,
content,
@ -4334,6 +4340,7 @@ impl<'de> Deserialize<'de> for CreateTemplateRequestDto {
default_agent_name,
default_system_permissions,
default_mcp_tool_policy,
default_contactable,
})
}
}
@ -4351,6 +4358,7 @@ impl CreateTemplateRequestDto {
default_agent_name: self.default_agent_name,
default_system_permissions: self.default_system_permissions,
default_mcp_tool_policy: self.default_mcp_tool_policy,
default_contactable: self.default_contactable,
})
}
}
@ -4372,6 +4380,8 @@ pub struct UpdateTemplateRequestDto {
pub default_system_permissions: Option<Option<SystemPermissionSet>>,
/// New default MCP tool policy. Missing leaves it unchanged; null clears it.
pub default_mcp_tool_policy: Option<Option<McpToolPolicy>>,
/// New default contactable flag. Missing leaves it unchanged.
pub default_contactable: Option<bool>,
}
impl<'de> Deserialize<'de> for UpdateTemplateRequestDto {
@ -4408,6 +4418,9 @@ impl<'de> Deserialize<'de> for UpdateTemplateRequestDto {
],
)
.map_err(serde::de::Error::custom)?;
let default_contactable = take_optional_value::<bool>(object, "defaultContactable")
.map_err(serde::de::Error::custom)?
.flatten();
Ok(Self {
template_id,
content,
@ -4416,6 +4429,7 @@ impl<'de> Deserialize<'de> for UpdateTemplateRequestDto {
default_agent_name,
default_system_permissions,
default_mcp_tool_policy,
default_contactable,
})
}
}
@ -4438,6 +4452,7 @@ impl UpdateTemplateRequestDto {
default_agent_name: self.default_agent_name,
default_system_permissions: self.default_system_permissions,
default_mcp_tool_policy: self.default_mcp_tool_policy,
default_contactable: self.default_contactable,
})
}
}

View File

@ -3033,6 +3033,7 @@ impl BackendCore {
// Messagerie inter-agents (cadrage C3) : médiateur d'entrée (file + livraison
// PTV sérialisée) + registre de conversations + bus pour AgentReplied.
.with_input_mediator(Arc::clone(&input_mediator), Arc::clone(&mailbox))
.with_project_store(Arc::clone(&store_port))
.with_conversations(Arc::clone(&conversation_registry))
.with_events(Arc::clone(&events_port))
// Faits OS/runtime (exe $APPIMAGE/current_exe + endpoint loopback) pour
@ -4973,6 +4974,7 @@ mod mcp_serve_peer_tests {
synced_template_version: None,
skills: Vec::new(),
effort: None,
contactable: false,
});
id
}
@ -6310,6 +6312,7 @@ mod mcp_e2e_loopback_tests {
synced_template_version: None,
skills: Vec::new(),
effort: None,
contactable: false,
});
id
}

View File

@ -75,6 +75,9 @@ pub struct Agent {
/// `model_reasoning_effort` at launch.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effort: Option<EffortSelection>,
/// Whether this agent may be discovered/contacted from another project.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub contactable: bool,
}
impl Agent {
@ -110,6 +113,7 @@ impl Agent {
synchronized,
skills: Vec::new(),
effort: None,
contactable: false,
})
}
@ -141,6 +145,13 @@ impl Agent {
self
}
/// Returns a copy with the cross-project contact permission changed.
#[must_use]
pub const fn with_contactable(mut self, contactable: bool) -> Self {
self.contactable = contactable;
self
}
/// Assigns a skill to this agent. Idempotent: re-assigning the same
/// `skill_id` is a no-op (returns `false`); a new assignment returns `true`.
pub fn assign_skill(&mut self, skill: SkillRef) -> bool {
@ -199,6 +210,9 @@ pub struct ManifestEntry {
/// Per-agent effort selection. Missing in older manifests means no override.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effort: Option<EffortSelection>,
/// Whether this agent may be discovered/contacted from another project.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub contactable: bool,
}
impl ManifestEntry {
@ -238,6 +252,7 @@ impl ManifestEntry {
synced_template_version,
skills: Vec::new(),
effort: None,
contactable: false,
})
}
@ -264,6 +279,7 @@ impl ManifestEntry {
synced_template_version,
skills: agent.skills.clone(),
effort: agent.effort.clone(),
contactable: agent.contactable,
}
}
@ -290,7 +306,8 @@ impl ManifestEntry {
self.synchronized,
)?
.with_skills(self.skills.clone())
.with_effort(self.effort.clone()))
.with_effort(self.effort.clone())
.with_contactable(self.contactable))
}
}

View File

@ -74,6 +74,9 @@ pub struct AgentTemplate {
skip_serializing_if = "Option::is_none"
)]
pub default_mcp_tool_policy: Option<McpToolPolicy>,
/// Whether agents created from this template are cross-project contactable.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub default_contactable: bool,
}
impl AgentTemplate {
@ -98,6 +101,7 @@ impl AgentTemplate {
default_agent_name: None,
default_system_permissions: None,
default_mcp_tool_policy: None,
default_contactable: false,
})
}
@ -125,6 +129,7 @@ impl AgentTemplate {
default_agent_name: Option<String>,
default_system_permissions: Option<SystemPermissionSet>,
default_mcp_tool_policy: Option<McpToolPolicy>,
default_contactable: bool,
) -> Result<Self, DomainError> {
crate::validation::non_empty(&name, "template.name")?;
if let Some(default_agent_name) = &default_agent_name {
@ -136,6 +141,7 @@ impl AgentTemplate {
default_agent_name,
default_system_permissions,
default_mcp_tool_policy,
default_contactable,
..self.clone()
})
}

View File

@ -24,7 +24,7 @@ use application::OrchestratorService;
use domain::ports::McpToolPermissionStore;
use domain::{
AgentId, AgentToolPolicy, DomainEvent, IssueRef, McpToolPolicy, OrchestrationSource,
OrchestratorCommand, Project,
OrchestratorCommand, Project, ProjectId,
};
use futures_util::future::join_all;
use serde_json::{json, Value};
@ -483,6 +483,33 @@ impl McpServer {
.await;
}
if is_project_tool(&name) {
let result = self.tools_call_project(&name, arguments).await;
self.publish_processed(&name, result.is_ok());
return match result {
Ok(outcome) => {
let text = outcome.reply.unwrap_or(outcome.detail);
application::diag!(
"[mcp] tools_call end tool={name} requester={requester_label} \
target={arg_target} ok=true is_error=false result_len={} elapsed_ms={}",
text.len(),
started.elapsed().as_millis(),
);
Ok(tool_result_text(&text, false))
}
Err(err) => {
let detail = err.to_string();
application::diag!(
"[mcp] tools_call end tool={name} requester={requester_label} \
target={arg_target} ok=false is_error=true result_len={} elapsed_ms={}",
detail.len(),
started.elapsed().as_millis(),
);
Ok(tool_result_text(&detail, true))
}
};
}
if tools::is_ticket_tool(&name) {
let result = match &self.ticket_tools {
Some(provider) => {
@ -709,6 +736,78 @@ impl McpServer {
Ok(tool_result_text(&text, false))
}
async fn tools_call_project(
&self,
name: &str,
arguments: Value,
) -> Result<application::OrchestratorOutcome, application::AppError> {
let args = arguments.as_object().ok_or_else(|| {
application::AppError::Invalid(format!("tool `{name}` arguments must be a JSON object"))
})?;
let project_id = || parse_project_id(required_str_arg(args, "projectId")?);
let agent_id = || parse_agent_id(required_str_arg(args, "agentId")?);
match name {
"idea_project_list" => {
reject_extra_project_args(args, &[])?;
self.service.list_projects_for_mcp().await
}
"idea_project_read_context" => {
reject_extra_project_args(args, &["projectId"])?;
self.service
.read_project_context_for_mcp(project_id()?, requester_party(&self.requester))
.await
}
"idea_project_list_agents" => {
reject_extra_project_args(args, &["projectId"])?;
self.service
.list_project_agents_for_mcp(project_id()?)
.await
}
"idea_project_ask_agent" => {
reject_extra_project_args(
args,
&["projectId", "agentId", "correlationId", "task"],
)?;
let requester = parse_agent_id(&self.requester).map_err(|_| {
application::AppError::Invalid(
"idea_project_ask_agent requires an agent requester handshake".to_owned(),
)
})?;
self.service
.ask_project_agent_for_mcp(
project_id()?,
agent_id()?,
required_str_arg(args, "task")?.to_owned(),
self.project.id,
requester,
required_str_arg(args, "correlationId")?.to_owned(),
)
.await
}
"idea_project_cancel_agent" => {
reject_extra_project_args(args, &["projectId", "agentId", "correlationId"])?;
let requester = parse_agent_id(&self.requester).map_err(|_| {
application::AppError::Invalid(
"idea_project_cancel_agent requires an agent requester handshake"
.to_owned(),
)
})?;
self.service
.cancel_project_agent_for_mcp(
project_id()?,
agent_id()?,
self.project.id,
requester,
required_str_arg(args, "correlationId")?.to_owned(),
)
.await
}
other => Err(application::AppError::Invalid(format!(
"unknown project tool {other}"
))),
}
}
fn enforce_tool_policy(
&self,
policy: &AgentToolPolicy,
@ -935,6 +1034,60 @@ fn parse_ask_agents_requests(
Ok(out)
}
fn is_project_tool(name: &str) -> bool {
matches!(
name,
"idea_project_list"
| "idea_project_read_context"
| "idea_project_list_agents"
| "idea_project_ask_agent"
| "idea_project_cancel_agent"
)
}
fn required_str_arg<'a>(
args: &'a serde_json::Map<String, Value>,
key: &str,
) -> Result<&'a str, application::AppError> {
args.get(key)
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| application::AppError::Invalid(format!("missing required field `{key}`")))
}
fn reject_extra_project_args(
args: &serde_json::Map<String, Value>,
allowed: &[&str],
) -> Result<(), application::AppError> {
if args
.keys()
.any(|key| !allowed.iter().any(|allowed| allowed == key))
{
return Err(application::AppError::Invalid(
"unexpected argument for project tool".to_owned(),
));
}
Ok(())
}
fn parse_project_id(value: &str) -> Result<ProjectId, application::AppError> {
uuid::Uuid::parse_str(value)
.map(ProjectId::from_uuid)
.map_err(|err| application::AppError::Invalid(format!("invalid projectId: {err}")))
}
fn parse_agent_id(value: &str) -> Result<AgentId, application::AppError> {
uuid::Uuid::parse_str(value)
.map(AgentId::from_uuid)
.map_err(|err| application::AppError::Invalid(format!("invalid agentId: {err}")))
}
fn requester_party(requester: &str) -> domain::ConversationParty {
parse_agent_id(requester)
.map(domain::ConversationParty::agent)
.unwrap_or(domain::ConversationParty::User)
}
fn is_ticket_policy_mutation_tool(name: &str) -> bool {
matches!(
name,
@ -969,3 +1122,52 @@ fn map_err_to_jsonrpc(err: ToolMapError) -> JsonRpcError {
}
}
}
#[cfg(test)]
mod project_tool_tests {
use super::*;
use serde_json::json;
fn object(value: Value) -> serde_json::Map<String, Value> {
value.as_object().unwrap().clone()
}
#[test]
fn project_tool_helpers_parse_project_and_agent_ids() {
let project = "11111111-1111-1111-1111-111111111111";
let agent = "22222222-2222-2222-2222-222222222222";
assert_eq!(
parse_project_id(project).unwrap(),
ProjectId::from_uuid(uuid::Uuid::parse_str(project).unwrap())
);
assert_eq!(
parse_agent_id(agent).unwrap(),
AgentId::from_uuid(uuid::Uuid::parse_str(agent).unwrap())
);
}
#[test]
fn project_tool_helpers_require_correlation_and_reject_extra_fields() {
let args = object(json!({
"projectId": "11111111-1111-1111-1111-111111111111",
"agentId": "22222222-2222-2222-2222-222222222222",
"task": "work"
}));
let err = required_str_arg(&args, "correlationId").unwrap_err();
assert_eq!(err.code(), "INVALID");
let args = object(json!({
"projectId": "11111111-1111-1111-1111-111111111111",
"agentId": "22222222-2222-2222-2222-222222222222",
"correlationId": "corr-1",
"task": "work",
"target": "ambiguous name"
}));
let err =
reject_extra_project_args(&args, &["projectId", "agentId", "correlationId", "task"])
.unwrap_err();
assert_eq!(err.code(), "INVALID");
}
}

View File

@ -117,7 +117,8 @@ pub fn catalogue() -> Vec<ToolDef> {
},
"required": ["allowedTools"],
"additionalProperties": false
}
},
"defaultContactable": { "type": "boolean" }
},
"required": ["name", "content", "defaultProfileId"],
"additionalProperties": false
@ -151,7 +152,8 @@ pub fn catalogue() -> Vec<ToolDef> {
},
"required": ["allowedTools"],
"additionalProperties": false
}
},
"defaultContactable": { "type": "boolean" }
},
"required": ["templateId"],
"additionalProperties": false

View File

@ -45,6 +45,9 @@ pub enum McpToolAccess {
/// Canonical read-only MCP tools allowed by default.
pub const READ_ONLY_TOOLS: &[&str] = &[
"idea_project_list",
"idea_project_read_context",
"idea_project_list_agents",
"idea_list_agents",
"idea_context_read",
"idea_memory_read",
@ -62,6 +65,8 @@ pub const READ_ONLY_TOOLS: &[&str] = &[
/// Canonical write/action MCP tools denied by default.
pub const WRITE_ACTION_TOOLS: &[&str] = &[
"idea_project_ask_agent",
"idea_project_cancel_agent",
"idea_ask_agent",
"idea_ask_agents",
"idea_run_in_background",
@ -136,7 +141,12 @@ pub enum ToolMapError {
pub fn tool_returns_reply(tool: &str) -> bool {
matches!(
tool,
"idea_ask_agent"
"idea_project_list"
| "idea_project_read_context"
| "idea_project_list_agents"
| "idea_project_ask_agent"
| "idea_project_cancel_agent"
| "idea_ask_agent"
| "idea_ask_agents"
| "idea_list_agents"
| "idea_context_read"
@ -174,6 +184,68 @@ pub fn is_managed_temp_tool(tool: &str) -> bool {
#[must_use]
pub fn catalogue() -> Vec<ToolDef> {
let mut tools = vec![
ToolDef {
name: "idea_project_list",
description: "List known IdeA projects for cross-project agent discovery.",
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
},
ToolDef {
name: "idea_project_read_context",
description: "Read a project's global context by stable project id.",
input_schema: json!({
"type": "object",
"properties": {
"projectId": { "type": "string", "format": "uuid" }
},
"required": ["projectId"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_project_list_agents",
description: "List cross-project contactable agents for a project by stable project id.",
input_schema: json!({
"type": "object",
"properties": {
"projectId": { "type": "string", "format": "uuid" }
},
"required": ["projectId"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_project_ask_agent",
description: "Ask a cross-project contactable agent by projectId and agentId and wait for its final answer.",
input_schema: json!({
"type": "object",
"properties": {
"projectId": { "type": "string", "format": "uuid" },
"agentId": { "type": "string", "format": "uuid" },
"correlationId": { "type": "string" },
"task": { "type": "string" }
},
"required": ["projectId", "agentId", "correlationId", "task"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_project_cancel_agent",
description: "Cancel/stop a cross-project contactable agent by projectId, agentId and correlationId.",
input_schema: json!({
"type": "object",
"properties": {
"projectId": { "type": "string", "format": "uuid" },
"agentId": { "type": "string", "format": "uuid" },
"correlationId": { "type": "string" }
},
"required": ["projectId", "agentId", "correlationId"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_list_agents",
description: "List the IdeA agents declared in the project's manifest. Returns the \
@ -762,6 +834,63 @@ mod tests {
assert!(tool_returns_reply("idea_ask_agents"));
}
#[test]
fn project_tools_are_classified_and_return_inline_replies() {
for tool in [
"idea_project_list",
"idea_project_read_context",
"idea_project_list_agents",
] {
assert_eq!(tool_access(tool), Some(McpToolAccess::Read));
assert!(
tool_returns_reply(tool),
"{tool} must return inline JSON/text"
);
}
for tool in ["idea_project_ask_agent", "idea_project_cancel_agent"] {
assert_eq!(tool_access(tool), Some(McpToolAccess::WriteAction));
assert!(
tool_returns_reply(tool),
"{tool} must return an inline result"
);
}
}
#[test]
fn catalogue_advertises_project_tools_with_stable_identity_fields() {
let cat = catalogue();
let by_name = |name: &str| {
cat.iter()
.find(|tool| tool.name == name)
.unwrap_or_else(|| panic!("catalogue must contain {name}"))
};
assert_eq!(
by_name("idea_project_read_context").input_schema["required"],
json!(["projectId"])
);
assert_eq!(
by_name("idea_project_list_agents").input_schema["required"],
json!(["projectId"])
);
assert_eq!(
by_name("idea_project_ask_agent").input_schema["required"],
json!(["projectId", "agentId", "correlationId", "task"])
);
assert_eq!(
by_name("idea_project_cancel_agent").input_schema["required"],
json!(["projectId", "agentId", "correlationId"])
);
assert_eq!(
by_name("idea_project_ask_agent").input_schema["properties"]["projectId"]["format"],
json!("uuid")
);
assert_eq!(
by_name("idea_project_ask_agent").input_schema["properties"]["agentId"]["format"],
json!("uuid")
);
}
#[test]
fn run_in_background_maps_to_background_command_with_handshake_owner() {
let cmd = map_tool_call(

View File

@ -59,6 +59,8 @@ struct IndexEntry {
skip_serializing_if = "Option::is_none"
)]
default_mcp_tool_policy: Option<McpToolPolicy>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
default_contactable: bool,
}
/// On-disk shape of `templates/index.json`.
@ -170,6 +172,7 @@ impl FsTemplateStore {
default_agent_name: entry.default_agent_name.clone(),
default_system_permissions: entry.default_system_permissions.clone(),
default_mcp_tool_policy: entry.default_mcp_tool_policy.clone(),
default_contactable: entry.default_contactable,
})
}
}
@ -220,6 +223,7 @@ impl TemplateStore for FsTemplateStore {
default_agent_name: template.default_agent_name.clone(),
default_system_permissions: template.default_system_permissions.clone(),
default_mcp_tool_policy: template.default_mcp_tool_policy.clone(),
default_contactable: template.default_contactable,
};
if let Some(slot) = index.templates.iter_mut().find(|e| e.id == template.id) {
*slot = row;

View File

@ -109,6 +109,7 @@ impl FakeContexts {
synced_template_version: None,
skills: Vec::new(),
effort: None,
contactable: false,
});
id
}
@ -725,6 +726,12 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
for expected in [
// Cross-project agent control tools (#263).
"idea_project_list",
"idea_project_read_context",
"idea_project_list_agents",
"idea_project_ask_agent",
"idea_project_cancel_agent",
"idea_list_agents",
"idea_ask_agent",
"idea_ask_agents",
@ -776,8 +783,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
assert!(!names.contains(&"idea_reply"));
assert_eq!(
tools.len(),
37,
"exactly the thirty-seven exposed idea_* tools; got {names:?}"
47,
"exactly the forty-seven exposed idea_* tools; got {names:?}"
);
// Every tool advertises an object input schema.

View File

@ -98,6 +98,7 @@ impl FakeContexts {
synced_template_version: None,
skills: Vec::new(),
effort: None,
contactable: false,
});
id
}

View File

@ -70,6 +70,7 @@ async fn save_then_get_and_list_roundtrip() {
Some(McpToolPolicy {
allowed_tools: vec!["idea_template_read".to_owned()],
}),
true,
)
.unwrap();
store.save(&t).await.unwrap();
@ -137,6 +138,7 @@ async fn index_is_camelcase_with_content_hash() {
Some(McpToolPolicy {
allowed_tools: vec!["idea_template_read".to_owned()],
}),
true,
)
.unwrap();
store.save(&template).await.unwrap();
@ -171,6 +173,10 @@ async fn index_is_camelcase_with_content_hash() {
.and_then(|v| v.as_str()),
Some("idea_template_read")
);
assert_eq!(
entry.get("defaultContactable").and_then(|v| v.as_bool()),
Some(true)
);
assert!(entry.get("content_hash").is_none(), "no snake_case leak");
}