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:
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user