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

@ -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(