#305: nouvel agrégat durable ScheduledProjectTask dans domain (scheduled_task.rs, ids, ports ScheduledProjectTaskStore), source de vérité <app_data_dir>/scheduled-project-tasks/<projectId>.json, schedules bornés V1 interval/daily/weekly avec fuseau, cycle À approuver/Active/Suspendue avec approbation obligatoire des tâches demandées par agent; use cases application/scheduled_task (list/create/update/delete/run_now/reconcile/fire) réutilisant BackgroundTask pour les runs avec distinction planifié/manuel; store infrastructure dédié + scheduler reconcile au boot sans rattrapage en rafale, cwd confiné au projet; 7 tools MCP idea_scheduled_task_* (list/read lecture seule, create/update/delete/run_now/set_enabled write-actions soumises aux permissions natives); commandes Tauri + routes HTTP aux contrats DTO identiques (camelCase nextRunAtMs/lastRunAtMs/lastTaskId/createdBy/updatedBy); frontend: gateways Tauri/HTTP/mock + types ports/domain, surface projet 'Tâches planifiées' (liste utilisateur+agent unifiée, création/édition avec récapitulatif avant activation, approbation/refus, exécuter maintenant, suspendre/reprendre, suppression), catalogage surface web (webSurfaceCatalog/viewPlacement/ViewPanelBody/WebWorkspace); docs/scheduled-project-tasks.md et sdk docs/services.md (047c38b: pas un service plugin V1, pas d'émulation via cron système). #314: correction de 'Conversation liée — transcript indisponible' après reboot: restauration du transcript lié côté application (orchestrator service, lifecycle, session_limit, background sink, terminal registry) et côté frontend (adapters agent/http frames/stream/requestResponse, workStateNormalization, CustomAgentChatView avec état et action utile), tests dto_chat/agent_lifecycle/structured_registry_d1/background_completion_sink étendus. QA verte rapportée par QA sur feature/305-scheduled-project-tasks-mcp, sans bloquant, pour #305 et #314. Hors périmètre worktree: tickets non liés, attachments/, scripts/; aucun push.
1217 lines
49 KiB
Rust
1217 lines
49 KiB
Rust
//! [`McpServer`] — the IdeA MCP **driving adapter** (cadrage Décision 4).
|
|
//!
|
|
//! This is the **pair of [`FsOrchestratorWatcher`](super::super::FsOrchestratorWatcher)**:
|
|
//! another entry door onto the *same* [`OrchestratorService::dispatch`]. Where the
|
|
//! watcher reads a JSON file, this server reads a JSON-RPC `tools/call`; both build
|
|
//! an [`OrchestratorCommand`] and return its [`OrchestratorOutcome`] unchanged. It
|
|
//! invents **no semantics** and **never re-routes** replies.
|
|
//!
|
|
//! It implements the strict MCP minimum over JSON-RPC 2.0:
|
|
//! - `initialize` → advertise protocol version + tool capability,
|
|
//! - `tools/list` → the [`tools::catalogue`],
|
|
//! - `tools/call` → map → `dispatch` → MCP tool result,
|
|
//! - notifications (e.g. `notifications/initialized`) → accepted, no reply.
|
|
//!
|
|
//! It receives `Arc<OrchestratorService>` and the target [`Project`] by injection
|
|
//! at the composition root (M3), exactly like the watcher — no application logic is
|
|
//! duplicated here.
|
|
|
|
use std::str::FromStr;
|
|
use std::sync::Arc;
|
|
use std::time::Instant;
|
|
|
|
use application::OrchestratorService;
|
|
use domain::ports::McpToolPermissionStore;
|
|
use domain::{
|
|
AgentId, AgentToolPolicy, DomainEvent, IssueRef, McpToolPolicy, OrchestrationSource,
|
|
OrchestratorCommand, Project, ProjectId,
|
|
};
|
|
use futures_util::future::join_all;
|
|
use serde_json::{json, Value};
|
|
use tokio::sync::mpsc;
|
|
|
|
use super::jsonrpc::{
|
|
error_codes, JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError,
|
|
JSONRPC_VERSION,
|
|
};
|
|
use super::managed_temp::ManagedTempToolProvider;
|
|
use super::plugins::PluginToolProvider;
|
|
use super::policy::ToolPolicyRegistry;
|
|
use super::templates::TemplateToolProvider;
|
|
use super::tickets::TicketToolProvider;
|
|
use super::tools::{self, ToolMapError};
|
|
|
|
/// The MCP protocol version this server speaks (advertised on `initialize`).
|
|
const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
|
|
|
|
/// Compact MCP instructions advertised at initialization. The first 512 chars are
|
|
/// intentionally self-contained because some clients truncate this field.
|
|
const MCP_INSTRUCTIONS: &str = "Use IdeA tools by intent: ticket work -> idea_ticket_*; ask/delegate to one agent -> idea_ask_agent, several -> idea_ask_agents; durable project facts -> idea_memory_*; project/agent context -> idea_context_*; recurring project maintenance -> idea_scheduled_task_* (never system cron). Then use idea_skill_* for assigned workflows, idea_template_* for agent templates, idea_workstate_* for live status, idea_run_in_background/background tools for long commands, agent lifecycle tools to launch/stop/resume/swap agents, and sprint tools for planning.";
|
|
|
|
/// The IdeA MCP server: an entry adapter over [`OrchestratorService::dispatch`].
|
|
///
|
|
/// Cheap to clone the dependencies it holds; one instance serves one project's
|
|
/// agents over one transport (M3 owns one server per open project, beside the
|
|
/// watcher).
|
|
pub struct McpServer {
|
|
service: Arc<OrchestratorService>,
|
|
project: Project,
|
|
/// Optional event sink (a domain [`EventBus`](domain::ports::EventBus) publish
|
|
/// closure), the twin of the file watcher's. When set, a processed `tools/call`
|
|
/// publishes [`DomainEvent::OrchestratorRequestProcessed`] tagged
|
|
/// [`OrchestrationSource::Mcp`] so the presentation layer can surface that this
|
|
/// delegation arrived through the MCP door. `None` ⇒ no publication (the server
|
|
/// still works), so existing call sites stay valid.
|
|
events: Option<Arc<dyn Fn(DomainEvent) + Send + Sync>>,
|
|
/// Identity of the connected peer — the real agent id carried in the loopback
|
|
/// handshake (cadrage v5 §1.4). When non-empty it becomes
|
|
/// [`DomainEvent::OrchestratorRequestProcessed`]'s `requester_id` instead of the
|
|
/// frozen `"mcp"` placeholder; empty ⇒ the legacy `"mcp"` label (back-compat for
|
|
/// M2 callers and connections that arrive without a requester).
|
|
requester: String,
|
|
/// Sink optionnel de **readiness de démarrage** : appelé avec le `requester` brut
|
|
/// (handshake) la première fois que le peer émet `initialize` (son CLI est up et
|
|
/// parle MCP). La composition root y branche
|
|
/// `OrchestratorService::release_agent_cold_start` pour livrer un 1er tour différé
|
|
/// (fix race cold-launch, signal MCP). L'infra ne connaît pas `AgentId` : la
|
|
/// composition root parse le `requester`. `None` ⇒ no-op.
|
|
ready_sink: Option<Arc<dyn Fn(&str) + Send + Sync>>,
|
|
/// Optional public ticket provider. The MCP surface says `ticket`; the
|
|
/// provider maps those calls to application/domain `Issue` use cases.
|
|
ticket_tools: Option<Arc<dyn TicketToolProvider>>,
|
|
/// Optional public template provider. The MCP surface says `template`; the
|
|
/// provider maps those calls to application/domain `Template` use cases.
|
|
template_tools: Option<Arc<dyn TemplateToolProvider>>,
|
|
/// Optional public managed-temp provider. The MCP surface says `managed_temp`;
|
|
/// the provider maps those calls to application managed-temp use cases.
|
|
managed_temp_tools: Option<Arc<dyn ManagedTempToolProvider>>,
|
|
/// Optional public plugin administration provider.
|
|
plugin_tools: Option<Arc<dyn PluginToolProvider>>,
|
|
/// Optional per-requester MCP tool policy registry for constrained sessions.
|
|
tool_policies: Option<Arc<ToolPolicyRegistry>>,
|
|
/// Optional durable MCP tool permission store. When wired, absence of project or
|
|
/// agent policy resolves to the canonical read-only fallback.
|
|
mcp_tool_permissions: Option<Arc<dyn McpToolPermissionStore>>,
|
|
}
|
|
|
|
impl McpServer {
|
|
/// Builds the server from the injected application service and target project.
|
|
/// No event sink — use [`with_events`](Self::with_events) to surface processed
|
|
/// requests on the bus.
|
|
#[must_use]
|
|
pub fn new(service: Arc<OrchestratorService>, project: Project) -> Self {
|
|
Self {
|
|
service,
|
|
project,
|
|
events: None,
|
|
requester: String::new(),
|
|
ready_sink: None,
|
|
ticket_tools: None,
|
|
template_tools: None,
|
|
managed_temp_tools: None,
|
|
plugin_tools: None,
|
|
tool_policies: None,
|
|
mcp_tool_permissions: None,
|
|
}
|
|
}
|
|
|
|
/// Attaches an event sink so each processed `tools/call` is republished on the
|
|
/// bus tagged [`OrchestrationSource::Mcp`] — the MCP twin of the file watcher's
|
|
/// `events` closure. Additive: callers that do not need observability keep using
|
|
/// [`new`](Self::new).
|
|
#[must_use]
|
|
pub fn with_events(mut self, events: Arc<dyn Fn(DomainEvent) + Send + Sync>) -> Self {
|
|
self.events = Some(events);
|
|
self
|
|
}
|
|
|
|
/// Attache un sink de **readiness de démarrage** : appelé avec l'id (handshake
|
|
/// `requester`) de l'agent connecté la première fois qu'il émet `initialize` (son CLI
|
|
/// est up et parle MCP). La composition root y branche
|
|
/// `OrchestratorService::release_agent_cold_start` pour livrer un éventuel 1er tour
|
|
/// différé (fix race cold-launch, signal MCP). Additif : sans sink, no-op.
|
|
#[must_use]
|
|
pub fn with_ready_sink(mut self, ready_sink: Arc<dyn Fn(&str) + Send + Sync>) -> Self {
|
|
self.ready_sink = Some(ready_sink);
|
|
self
|
|
}
|
|
|
|
/// Attaches the public ticket tool provider.
|
|
#[must_use]
|
|
pub fn with_ticket_tools(mut self, ticket_tools: Arc<dyn TicketToolProvider>) -> Self {
|
|
self.ticket_tools = Some(ticket_tools);
|
|
self
|
|
}
|
|
|
|
/// Attaches the public template tool provider.
|
|
#[must_use]
|
|
pub fn with_template_tools(mut self, template_tools: Arc<dyn TemplateToolProvider>) -> Self {
|
|
self.template_tools = Some(template_tools);
|
|
self
|
|
}
|
|
|
|
/// Attaches the public managed-temp tool provider.
|
|
#[must_use]
|
|
pub fn with_managed_temp_tools(
|
|
mut self,
|
|
managed_temp_tools: Arc<dyn ManagedTempToolProvider>,
|
|
) -> Self {
|
|
self.managed_temp_tools = Some(managed_temp_tools);
|
|
self
|
|
}
|
|
|
|
/// Attaches the public plugin administration provider.
|
|
#[must_use]
|
|
pub fn with_plugin_tools(mut self, plugin_tools: Arc<dyn PluginToolProvider>) -> Self {
|
|
self.plugin_tools = Some(plugin_tools);
|
|
self
|
|
}
|
|
|
|
/// Attaches the requester-scoped MCP tool policy registry.
|
|
#[must_use]
|
|
pub fn with_tool_policies(mut self, tool_policies: Arc<ToolPolicyRegistry>) -> Self {
|
|
self.tool_policies = Some(tool_policies);
|
|
self
|
|
}
|
|
|
|
/// Attaches the durable MCP tool permission store.
|
|
#[must_use]
|
|
pub fn with_mcp_tool_permissions(
|
|
mut self,
|
|
mcp_tool_permissions: Arc<dyn McpToolPermissionStore>,
|
|
) -> Self {
|
|
self.mcp_tool_permissions = Some(mcp_tool_permissions);
|
|
self
|
|
}
|
|
|
|
/// Returns a per-connection clone of this server tagged with the connected
|
|
/// peer's `requester` id (the loopback handshake's `requester`, cadrage v5 §1.4).
|
|
///
|
|
/// The base server (one per project, M3) is shared by every connection; each
|
|
/// accepted peer derives its own contextualized server so concurrent peers never
|
|
/// share a requester. An empty `requester` keeps the legacy `"mcp"` label.
|
|
///
|
|
/// Cheap: only `Arc`s, a `Project` clone, and the requester `String` are copied.
|
|
#[must_use]
|
|
pub fn for_requester(&self, requester: impl Into<String>) -> Self {
|
|
Self {
|
|
service: Arc::clone(&self.service),
|
|
project: self.project.clone(),
|
|
events: self.events.clone(),
|
|
requester: requester.into(),
|
|
ready_sink: self.ready_sink.clone(),
|
|
ticket_tools: self.ticket_tools.clone(),
|
|
template_tools: self.template_tools.clone(),
|
|
managed_temp_tools: self.managed_temp_tools.clone(),
|
|
plugin_tools: self.plugin_tools.clone(),
|
|
tool_policies: self.tool_policies.clone(),
|
|
mcp_tool_permissions: self.mcp_tool_permissions.clone(),
|
|
}
|
|
}
|
|
|
|
/// Serves JSON-RPC messages from `transport`, tagging every processed
|
|
/// `tools/call` with `requester` as the delegating agent (cadrage v5 §1.4).
|
|
///
|
|
/// A thin wrapper over [`serve`](Self::serve) on a per-connection
|
|
/// [`for_requester`](Self::for_requester) clone: the caller (the per-peer accept
|
|
/// loop) need not mutate the shared project server. An empty `requester` yields
|
|
/// the legacy `"mcp"` label.
|
|
pub async fn serve_as<T: Transport>(&self, requester: impl Into<String>, transport: &mut T) {
|
|
self.for_requester(requester).serve(transport).await;
|
|
}
|
|
|
|
/// Serves JSON-RPC messages from `transport` until it closes (clean EOF).
|
|
///
|
|
/// Every inbound line is handled in isolation: a malformed line or an unknown
|
|
/// method yields a JSON-RPC error response, **never a panic** and never a
|
|
/// dropped connection. Notifications (no `id`) are processed without a reply.
|
|
///
|
|
/// **Full-duplex / non-blocking.** A slow request must **not** stall the read
|
|
/// loop: otherwise a single long-running tool call parks the whole connection and
|
|
/// every later call is never even read. So each inbound message is handled on its
|
|
/// own spawned task that owns a cheap per-call clone of the server; finished
|
|
/// responses are funnelled back through an `mpsc` channel and written by the same
|
|
/// loop. Responses carry their JSON-RPC `id`, so out-of-order completion is fine
|
|
/// (the full-duplex bridge correlates by id). Notifications (`handle_raw` ⇒
|
|
/// `None`) emit nothing.
|
|
pub async fn serve<T: Transport>(&self, transport: &mut T) {
|
|
let (tx, mut rx) = mpsc::unbounded_channel::<Option<Vec<u8>>>();
|
|
// Number of spawned handler tasks not yet observed on `rx`. While `> 0`, an
|
|
// EOF on the read side must NOT exit immediately: we keep draining `rx` so
|
|
// already-computed responses still reach the transport (graceful shutdown).
|
|
// A response-less notification decrements without ever sending — see below.
|
|
let mut in_flight: usize = 0;
|
|
// Once the peer closed its read side, stop accepting new inbound; only drain.
|
|
let mut reading = true;
|
|
loop {
|
|
tokio::select! {
|
|
// Drain ready responses first so a flood of inbound never starves
|
|
// writes; correctness does not depend on the bias.
|
|
biased;
|
|
|
|
outbound = rx.recv() => {
|
|
// `tx` is held by the loop, so `recv` only yields `None` once it
|
|
// is dropped — which never happens before the loop returns.
|
|
let Some(slot) = outbound else { break };
|
|
in_flight -= 1;
|
|
if let Some(bytes) = slot {
|
|
if transport.send(&bytes).await.is_err() {
|
|
break;
|
|
}
|
|
}
|
|
// Peer gone and every spawned task accounted for ⇒ done.
|
|
if !reading && in_flight == 0 {
|
|
break;
|
|
}
|
|
}
|
|
|
|
incoming = transport.recv(), if reading => {
|
|
let raw = match incoming {
|
|
Ok(bytes) => bytes,
|
|
// Peer closed / I/O error: stop reading but keep draining the
|
|
// responses of tasks still in flight before returning.
|
|
Err(TransportError::Closed) | Err(TransportError::Io(_)) => {
|
|
reading = false;
|
|
if in_flight == 0 {
|
|
break;
|
|
}
|
|
continue;
|
|
}
|
|
};
|
|
// Own a cheap clone (Arc/String/Project) so the task is `'static`
|
|
// and never borrows the transport. Every spawned task sends
|
|
// exactly one slot back (`Some(bytes)` for a reply, `None` for a
|
|
// notification) so `in_flight` is always reconciled.
|
|
in_flight += 1;
|
|
let server = self.for_requester(self.requester.clone());
|
|
let tx = tx.clone();
|
|
tokio::spawn(async move {
|
|
let slot = match server.handle_raw(&raw).await {
|
|
Some(response) => serde_json::to_vec(&response).ok(),
|
|
None => None,
|
|
};
|
|
// Receiver dropped ⇒ the loop has stopped; discard.
|
|
let _ = tx.send(slot);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Parses one raw message and produces the response to send back, or `None`
|
|
/// for a notification (no `id`) that owes no reply.
|
|
///
|
|
/// Kept standalone (no transport) so the whole request→response behaviour is
|
|
/// unit-testable over plain bytes, with no I/O.
|
|
pub async fn handle_raw(&self, raw: &[u8]) -> Option<JsonRpcResponse> {
|
|
let request: JsonRpcRequest = match serde_json::from_slice(raw) {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
// Could not correlate (no id parsed) ⇒ null id per JSON-RPC.
|
|
return Some(JsonRpcResponse::error(
|
|
Value::Null,
|
|
JsonRpcError::new(error_codes::PARSE_ERROR, format!("invalid json: {e}")),
|
|
));
|
|
}
|
|
};
|
|
|
|
if request.jsonrpc != JSONRPC_VERSION {
|
|
let id = request.id.unwrap_or(Value::Null);
|
|
return Some(JsonRpcResponse::error(
|
|
id,
|
|
JsonRpcError::new(
|
|
error_codes::INVALID_REQUEST,
|
|
format!("unsupported jsonrpc version: {}", request.jsonrpc),
|
|
),
|
|
));
|
|
}
|
|
|
|
// Notification (no id): never reply (the `?` short-circuits to `None`).
|
|
let id = request.id.clone()?;
|
|
|
|
let result = self.dispatch_method(&request.method, request.params).await;
|
|
Some(match result {
|
|
Ok(value) => JsonRpcResponse::success(id, value),
|
|
Err(error) => JsonRpcResponse::error(id, error),
|
|
})
|
|
}
|
|
|
|
/// Routes a method name to its handler.
|
|
async fn dispatch_method(
|
|
&self,
|
|
method: &str,
|
|
params: Option<Value>,
|
|
) -> Result<Value, JsonRpcError> {
|
|
match method {
|
|
"initialize" => {
|
|
self.notify_ready();
|
|
Ok(self.initialize_result())
|
|
}
|
|
"tools/list" => self.tools_list_result().await,
|
|
"tools/call" => self.tools_call(params.unwrap_or(Value::Null)).await,
|
|
other => Err(JsonRpcError::new(
|
|
error_codes::METHOD_NOT_FOUND,
|
|
format!("method not found: {other}"),
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// Notifie le sink de readiness de démarrage avec l'identité du peer connecté
|
|
/// (handshake `requester`). No-op si pas de sink ou requester vide (peer legacy/anonyme).
|
|
fn notify_ready(&self) {
|
|
if let Some(sink) = &self.ready_sink {
|
|
if !self.requester.is_empty() {
|
|
sink(&self.requester);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The `initialize` result: protocol version, server identity, and the fact
|
|
/// that we expose tools.
|
|
fn initialize_result(&self) -> Value {
|
|
json!({
|
|
"protocolVersion": MCP_PROTOCOL_VERSION,
|
|
"capabilities": { "tools": {} },
|
|
"serverInfo": { "name": "idea-orchestrator", "version": env!("CARGO_PKG_VERSION") },
|
|
"instructions": MCP_INSTRUCTIONS
|
|
})
|
|
}
|
|
|
|
/// The `tools/list` result: the catalogue as MCP tool descriptors.
|
|
async fn tools_list_result(&self) -> Result<Value, JsonRpcError> {
|
|
let ephemeral_policy = self.ephemeral_tool_policy();
|
|
let surface = self
|
|
.effective_tool_surface(ephemeral_policy.as_ref())
|
|
.await?;
|
|
let tools: Vec<Value> =
|
|
super::policy::effective_tool_catalogue(ephemeral_policy.as_ref(), &surface)
|
|
.into_iter()
|
|
.map(|t| {
|
|
json!({
|
|
"name": t.name,
|
|
"description": t.description,
|
|
"inputSchema": t.input_schema,
|
|
})
|
|
})
|
|
.collect();
|
|
Ok(json!({ "tools": tools }))
|
|
}
|
|
|
|
fn ephemeral_tool_policy(&self) -> Option<AgentToolPolicy> {
|
|
self.tool_policies
|
|
.as_ref()
|
|
.and_then(|registry| registry.get(&self.requester))
|
|
}
|
|
|
|
async fn durable_tool_policy(&self) -> Result<Option<McpToolPolicy>, JsonRpcError> {
|
|
let ephemeral_policy = self.ephemeral_tool_policy();
|
|
Ok(self
|
|
.effective_tool_surface(ephemeral_policy.as_ref())
|
|
.await?
|
|
.durable_policy)
|
|
}
|
|
|
|
async fn effective_tool_surface(
|
|
&self,
|
|
ephemeral_policy: Option<&AgentToolPolicy>,
|
|
) -> Result<super::policy::EffectiveToolSurface, JsonRpcError> {
|
|
super::policy::resolve_effective_tool_surface(
|
|
self.mcp_tool_permissions.as_deref(),
|
|
&self.project,
|
|
&self.requester,
|
|
ephemeral_policy,
|
|
)
|
|
.await
|
|
.map_err(|e| {
|
|
JsonRpcError::new(
|
|
error_codes::INTERNAL_ERROR,
|
|
format!("failed to resolve MCP tool permissions: {e}"),
|
|
)
|
|
})
|
|
}
|
|
|
|
fn enforce_durable_tool_policy(
|
|
&self,
|
|
policy: &McpToolPolicy,
|
|
name: &str,
|
|
) -> Result<(), JsonRpcError> {
|
|
if policy.permits(name) {
|
|
return Ok(());
|
|
}
|
|
let requester = if self.requester.is_empty() {
|
|
"mcp"
|
|
} else {
|
|
&self.requester
|
|
};
|
|
let guidance = if matches!(name, "idea_project_ask_agent" | "idea_project_cancel_agent") {
|
|
"; enable this cross-project action in the requester's project MCP tool permissions"
|
|
} else {
|
|
""
|
|
};
|
|
Err(JsonRpcError::new(
|
|
error_codes::INVALID_PARAMS,
|
|
format!("MCP tool `{name}` is not permitted for requester {requester}{guidance}"),
|
|
))
|
|
}
|
|
|
|
/// `tools/call`: map the tool to an [`OrchestratorCommand`], `dispatch` it, and
|
|
/// fold the [`OrchestratorOutcome`](application::OrchestratorOutcome) into an MCP
|
|
/// tool result.
|
|
async fn tools_call(&self, params: Value) -> Result<Value, JsonRpcError> {
|
|
let name = params
|
|
.get("name")
|
|
.and_then(Value::as_str)
|
|
.ok_or_else(|| JsonRpcError::new(error_codes::INVALID_PARAMS, "missing tool `name`"))?
|
|
.to_owned();
|
|
let arguments = params.get("arguments").cloned().unwrap_or(json!({}));
|
|
|
|
if let Some(policy) = self.ephemeral_tool_policy() {
|
|
self.enforce_tool_policy(&policy, &name, &arguments)?;
|
|
}
|
|
if let Some(policy) = self.durable_tool_policy().await? {
|
|
self.enforce_durable_tool_policy(&policy, &name)?;
|
|
}
|
|
|
|
// Diagnostics begin beacon (best-effort, jamais le corps task/result) : trace
|
|
// l'entrée d'un `tools/call`, son tool, le peer demandeur et la cible/longueurs.
|
|
let started = Instant::now();
|
|
let requester_label = if self.requester.is_empty() {
|
|
"mcp".to_owned()
|
|
} else {
|
|
self.requester.clone()
|
|
};
|
|
let arg_target = arguments
|
|
.get("target")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("-")
|
|
.to_owned();
|
|
let task_len = arguments
|
|
.get("task")
|
|
.and_then(Value::as_str)
|
|
.map_or(0, str::len);
|
|
application::diag!(
|
|
"[mcp] tools_call begin tool={name} requester={requester_label} target={arg_target} \
|
|
task_len={task_len}",
|
|
);
|
|
|
|
if name == "idea_ask_agents" {
|
|
return self
|
|
.tools_call_ask_agents(arguments, requester_label, arg_target, started)
|
|
.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) => {
|
|
provider
|
|
.handle_ticket_tool(&self.project, &self.requester, &name, arguments)
|
|
.await
|
|
}
|
|
None => Err(super::tickets::TicketToolError::new(
|
|
"notConfigured",
|
|
"ticket tools are not configured",
|
|
)),
|
|
};
|
|
self.publish_processed(&name, result.is_ok());
|
|
return match result {
|
|
Ok(value) => {
|
|
let text = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_owned());
|
|
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 text =
|
|
serde_json::to_string(&err.to_value()).unwrap_or_else(|_| 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={}",
|
|
text.len(),
|
|
started.elapsed().as_millis(),
|
|
);
|
|
Ok(tool_result_text(&text, true))
|
|
}
|
|
};
|
|
}
|
|
|
|
if tools::is_template_tool(&name) {
|
|
let result = match &self.template_tools {
|
|
Some(provider) => {
|
|
provider
|
|
.handle_template_tool(&self.project, &self.requester, &name, arguments)
|
|
.await
|
|
}
|
|
None => Err(super::templates::TemplateToolError::new(
|
|
"notConfigured",
|
|
"template tools are not configured",
|
|
)),
|
|
};
|
|
self.publish_processed(&name, result.is_ok());
|
|
return match result {
|
|
Ok(value) => {
|
|
let text = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_owned());
|
|
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 text =
|
|
serde_json::to_string(&err.to_value()).unwrap_or_else(|_| 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={}",
|
|
text.len(),
|
|
started.elapsed().as_millis(),
|
|
);
|
|
Ok(tool_result_text(&text, true))
|
|
}
|
|
};
|
|
}
|
|
|
|
if tools::is_managed_temp_tool(&name) {
|
|
let result = match &self.managed_temp_tools {
|
|
Some(provider) => {
|
|
provider
|
|
.handle_managed_temp_tool(&self.project, &self.requester, &name, arguments)
|
|
.await
|
|
}
|
|
None => Err(super::managed_temp::ManagedTempToolError::new(
|
|
"notConfigured",
|
|
"managed temp tools are not configured",
|
|
)),
|
|
};
|
|
self.publish_processed(&name, result.is_ok());
|
|
return match result {
|
|
Ok(value) => {
|
|
let text = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_owned());
|
|
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 text =
|
|
serde_json::to_string(&err.to_value()).unwrap_or_else(|_| 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={}",
|
|
text.len(),
|
|
started.elapsed().as_millis(),
|
|
);
|
|
Ok(tool_result_text(&text, true))
|
|
}
|
|
};
|
|
}
|
|
|
|
if tools::is_plugin_tool(&name) {
|
|
let result = match &self.plugin_tools {
|
|
Some(provider) => {
|
|
provider
|
|
.handle_plugin_tool(&self.project, &self.requester, &name, arguments)
|
|
.await
|
|
}
|
|
None => Err(super::plugins::PluginToolError::new(
|
|
"UNAVAILABLE",
|
|
"plugin tools are not configured",
|
|
)),
|
|
};
|
|
self.publish_processed(&name, result.is_ok());
|
|
return match result {
|
|
Ok(value) => {
|
|
let text = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_owned());
|
|
Ok(tool_result_text(&text, false))
|
|
}
|
|
Err(err) => {
|
|
let text =
|
|
serde_json::to_string(&err.to_value()).unwrap_or_else(|_| err.to_string());
|
|
Ok(tool_result_text(&text, true))
|
|
}
|
|
};
|
|
}
|
|
|
|
// The handshake-provided requester is still passed to the mapper for tools that
|
|
// need peer identity.
|
|
let command = match tools::map_tool_call(&name, &arguments, &self.requester) {
|
|
Ok(command) => command,
|
|
Err(e) => {
|
|
application::diag!(
|
|
"[mcp] tools_call end tool={name} requester={requester_label} mapped=err \
|
|
err={e} elapsed_ms={}",
|
|
started.elapsed().as_millis(),
|
|
);
|
|
return Err(map_err_to_jsonrpc(e));
|
|
}
|
|
};
|
|
|
|
let result = self.service.dispatch(&self.project, command).await;
|
|
|
|
// Surface the processed delegation on the bus, tagged as the MCP door — the
|
|
// twin of the file watcher's publish. `ok` mirrors the dispatch outcome; the
|
|
// action is the tool name. No-op when no sink is attached.
|
|
self.publish_processed(&name, result.is_ok());
|
|
// End beacon : longueurs uniquement (jamais le corps), ok/is_error et elapsed —
|
|
// ferme la trace ouverte par « begin ».
|
|
match result {
|
|
Ok(outcome) => {
|
|
// The text the agent sees: the reply for an `ask`, else the detail.
|
|
let text = outcome
|
|
.reply
|
|
.clone()
|
|
.unwrap_or_else(|| outcome.detail.clone());
|
|
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))
|
|
}
|
|
// A failed IdeA command is reported as a tool execution error
|
|
// (`isError: true`) rather than a protocol error: the agent can read
|
|
// it and decide, and the connection stays healthy.
|
|
Err(e) => {
|
|
let detail = e.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))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Comfort wrapper over several parallel `idea_ask_agent` dispatches.
|
|
///
|
|
/// This remains an MCP-adapter-only fan-out/fan-in: each item is validated and
|
|
/// translated to the existing [`OrchestratorCommand::AskAgent`] shape, then sent to
|
|
/// [`OrchestratorService::dispatch`]. Per-item dispatch failures are folded into
|
|
/// the result array so one failed target never cancels the others.
|
|
async fn tools_call_ask_agents(
|
|
&self,
|
|
arguments: Value,
|
|
requester_label: String,
|
|
arg_target: String,
|
|
started: Instant,
|
|
) -> Result<Value, JsonRpcError> {
|
|
let requests = parse_ask_agents_requests(&arguments, &self.requester)?;
|
|
let project = self.project.clone();
|
|
let service = Arc::clone(&self.service);
|
|
|
|
let futures = requests.iter().cloned().map(|request| {
|
|
let project = project.clone();
|
|
let service = Arc::clone(&service);
|
|
async move {
|
|
let command = OrchestratorCommand::AskAgent {
|
|
target: request.target.clone(),
|
|
task: request.task,
|
|
requester: request.requester,
|
|
};
|
|
match service.dispatch(&project, command).await {
|
|
Ok(outcome) => AskAgentsItemResult {
|
|
target: request.target,
|
|
ok: true,
|
|
text: outcome.reply.unwrap_or(outcome.detail),
|
|
},
|
|
Err(err) => AskAgentsItemResult {
|
|
target: request.target,
|
|
ok: false,
|
|
text: err.to_string(),
|
|
},
|
|
}
|
|
}
|
|
});
|
|
|
|
let results = join_all(futures).await;
|
|
self.publish_processed("idea_ask_agents", true);
|
|
let text = serde_json::to_string(
|
|
&results
|
|
.iter()
|
|
.map(AskAgentsItemResult::to_value)
|
|
.collect::<Vec<_>>(),
|
|
)
|
|
.unwrap_or_else(|_| "[]".to_owned());
|
|
application::diag!(
|
|
"[mcp] tools_call end tool=idea_ask_agents 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))
|
|
}
|
|
|
|
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,
|
|
name: &str,
|
|
arguments: &Value,
|
|
) -> Result<(), JsonRpcError> {
|
|
if !policy.permits(name) {
|
|
return Err(JsonRpcError::new(
|
|
error_codes::INVALID_PARAMS,
|
|
format!(
|
|
"tool `{name}` is not permitted for requester {}",
|
|
self.requester
|
|
),
|
|
));
|
|
}
|
|
if is_ticket_policy_bulk_mutation_tool(name) {
|
|
let refs = arguments
|
|
.get("refs")
|
|
.and_then(Value::as_array)
|
|
.ok_or_else(|| {
|
|
JsonRpcError::new(
|
|
error_codes::INVALID_PARAMS,
|
|
format!("tool `{name}` requires ticket refs under the active policy"),
|
|
)
|
|
})?;
|
|
for raw_ref in refs {
|
|
let raw_ref = raw_ref.as_str().ok_or_else(|| {
|
|
JsonRpcError::new(
|
|
error_codes::INVALID_PARAMS,
|
|
format!(
|
|
"tool `{name}` requires string ticket refs under the active policy"
|
|
),
|
|
)
|
|
})?;
|
|
let issue_ref = IssueRef::from_str(raw_ref).map_err(|e| {
|
|
JsonRpcError::new(
|
|
error_codes::INVALID_PARAMS,
|
|
format!("invalid ticket ref: {e}"),
|
|
)
|
|
})?;
|
|
if !policy.permits_ticket_mutation(name, issue_ref) {
|
|
return Err(JsonRpcError::new(
|
|
error_codes::INVALID_PARAMS,
|
|
format!("tool `{name}` is not permitted for ticket {issue_ref}"),
|
|
));
|
|
}
|
|
}
|
|
} else if is_ticket_policy_mutation_tool(name) {
|
|
let raw_ref = arguments
|
|
.get("ref")
|
|
.and_then(Value::as_str)
|
|
.ok_or_else(|| {
|
|
JsonRpcError::new(
|
|
error_codes::INVALID_PARAMS,
|
|
format!("tool `{name}` requires a ticket ref under the active policy"),
|
|
)
|
|
})?;
|
|
let issue_ref = IssueRef::from_str(raw_ref).map_err(|e| {
|
|
JsonRpcError::new(
|
|
error_codes::INVALID_PARAMS,
|
|
format!("invalid ticket ref: {e}"),
|
|
)
|
|
})?;
|
|
if !policy.permits_ticket_mutation(name, issue_ref) {
|
|
return Err(JsonRpcError::new(
|
|
error_codes::INVALID_PARAMS,
|
|
format!("tool `{name}` is not permitted for ticket {issue_ref}"),
|
|
));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Publishes [`DomainEvent::OrchestratorRequestProcessed`] for a handled
|
|
/// `tools/call`, tagged [`OrchestrationSource::Mcp`]. The requester id is the
|
|
/// connected peer's real agent id (carried in the loopback handshake, cadrage v5
|
|
/// §1.4), falling back to the legacy `"mcp"` label when no identity was supplied.
|
|
/// No-op without an event sink.
|
|
fn publish_processed(&self, action: &str, ok: bool) {
|
|
if let Some(publish) = &self.events {
|
|
let requester_id = if self.requester.is_empty() {
|
|
"mcp".to_owned()
|
|
} else {
|
|
self.requester.clone()
|
|
};
|
|
publish(DomainEvent::OrchestratorRequestProcessed {
|
|
requester_id,
|
|
action: action.to_owned(),
|
|
ok,
|
|
source: OrchestrationSource::Mcp,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Builds an MCP `tools/call` result with a single text content block.
|
|
fn tool_result_text(text: &str, is_error: bool) -> Value {
|
|
json!({
|
|
"content": [ { "type": "text", "text": text } ],
|
|
"isError": is_error
|
|
})
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct AskAgentsRequest {
|
|
target: String,
|
|
task: String,
|
|
requester: Option<AgentId>,
|
|
}
|
|
|
|
struct AskAgentsItemResult {
|
|
target: String,
|
|
ok: bool,
|
|
text: String,
|
|
}
|
|
|
|
impl AskAgentsItemResult {
|
|
fn to_value(&self) -> Value {
|
|
json!({
|
|
"target": self.target,
|
|
"ok": self.ok,
|
|
"isError": !self.ok,
|
|
"text": self.text,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn parse_ask_agents_requests(
|
|
arguments: &Value,
|
|
requester: &str,
|
|
) -> Result<Vec<AskAgentsRequest>, JsonRpcError> {
|
|
let args = arguments.as_object().ok_or_else(|| {
|
|
JsonRpcError::new(
|
|
error_codes::INVALID_PARAMS,
|
|
"tool `idea_ask_agents` arguments must be a JSON object",
|
|
)
|
|
})?;
|
|
if args.len() != 1 || !args.contains_key("requests") {
|
|
return Err(JsonRpcError::new(
|
|
error_codes::INVALID_PARAMS,
|
|
"tool `idea_ask_agents` expects exactly one field: requests",
|
|
));
|
|
}
|
|
let requests = args
|
|
.get("requests")
|
|
.and_then(Value::as_array)
|
|
.ok_or_else(|| {
|
|
JsonRpcError::new(
|
|
error_codes::INVALID_PARAMS,
|
|
"tool `idea_ask_agents` field `requests` must be a non-empty array",
|
|
)
|
|
})?;
|
|
if requests.is_empty() {
|
|
return Err(JsonRpcError::new(
|
|
error_codes::INVALID_PARAMS,
|
|
"tool `idea_ask_agents` field `requests` must be a non-empty array",
|
|
));
|
|
}
|
|
|
|
let mut seen = std::collections::HashSet::new();
|
|
let mut out = Vec::with_capacity(requests.len());
|
|
for (index, request) in requests.iter().enumerate() {
|
|
let obj = request.as_object().ok_or_else(|| {
|
|
JsonRpcError::new(
|
|
error_codes::INVALID_PARAMS,
|
|
format!("tool `idea_ask_agents` requests[{index}] must be an object"),
|
|
)
|
|
})?;
|
|
if obj.len() != 2 || !obj.contains_key("target") || !obj.contains_key("task") {
|
|
return Err(JsonRpcError::new(
|
|
error_codes::INVALID_PARAMS,
|
|
format!("tool `idea_ask_agents` requests[{index}] expects exactly target and task"),
|
|
));
|
|
}
|
|
let target = obj
|
|
.get("target")
|
|
.and_then(Value::as_str)
|
|
.filter(|v| !v.trim().is_empty())
|
|
.ok_or_else(|| {
|
|
JsonRpcError::new(
|
|
error_codes::INVALID_PARAMS,
|
|
format!("tool `idea_ask_agents` requests[{index}].target must be a string"),
|
|
)
|
|
})?
|
|
.to_owned();
|
|
let task = obj
|
|
.get("task")
|
|
.and_then(Value::as_str)
|
|
.filter(|v| !v.trim().is_empty())
|
|
.ok_or_else(|| {
|
|
JsonRpcError::new(
|
|
error_codes::INVALID_PARAMS,
|
|
format!("tool `idea_ask_agents` requests[{index}].task must be a string"),
|
|
)
|
|
})?
|
|
.to_owned();
|
|
let duplicate_key = target.to_lowercase();
|
|
if !seen.insert(duplicate_key) {
|
|
return Err(JsonRpcError::new(
|
|
error_codes::INVALID_PARAMS,
|
|
format!("tool `idea_ask_agents` duplicate target `{target}` is not supported"),
|
|
));
|
|
}
|
|
let command = tools::map_tool_call(
|
|
"idea_ask_agent",
|
|
&json!({ "target": target, "task": task }),
|
|
requester,
|
|
)
|
|
.map_err(map_err_to_jsonrpc)?;
|
|
let OrchestratorCommand::AskAgent {
|
|
target,
|
|
task,
|
|
requester,
|
|
} = command
|
|
else {
|
|
unreachable!("idea_ask_agent must map to AskAgent");
|
|
};
|
|
out.push(AskAgentsRequest {
|
|
target,
|
|
task,
|
|
requester,
|
|
});
|
|
}
|
|
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,
|
|
"idea_ticket_update"
|
|
| "idea_ticket_update_status"
|
|
| "idea_ticket_update_priority"
|
|
| "idea_ticket_update_carnet"
|
|
| "idea_ticket_attachment_add"
|
|
| "idea_ticket_attachment_mark_summarized"
|
|
| "idea_ticket_link"
|
|
| "idea_ticket_unlink"
|
|
)
|
|
}
|
|
|
|
fn is_ticket_policy_bulk_mutation_tool(name: &str) -> bool {
|
|
matches!(
|
|
name,
|
|
"idea_ticket_bulk_update_status"
|
|
| "idea_ticket_bulk_update_priority"
|
|
| "idea_ticket_bulk_delete"
|
|
)
|
|
}
|
|
|
|
/// Maps a [`ToolMapError`] to the right JSON-RPC error code.
|
|
fn map_err_to_jsonrpc(err: ToolMapError) -> JsonRpcError {
|
|
match err {
|
|
ToolMapError::UnknownTool(_) => {
|
|
JsonRpcError::new(error_codes::METHOD_NOT_FOUND, err.to_string())
|
|
}
|
|
ToolMapError::BadArguments(_) | ToolMapError::Invalid(_) => {
|
|
JsonRpcError::new(error_codes::INVALID_PARAMS, err.to_string())
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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");
|
|
}
|
|
}
|