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");
}
}