feat(cross-project): découverte et contact inter-projets — tools MCP idea_project_{list,read_context,list_agents,ask_agent,cancel_agent}, permission contactable par agent + default_contactable persistée dans les templates, routage orchestrator par projectId+agentId — #260 (tests ciblés verts, QA en attente)

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

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(