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