Corrige le wedge survenant lorsqu'un agent délégué ne rappelle pas idea_reply :
le rendez-vous ask_agent ne reste plus bloqué indéfiniment.
Lot 1 — durcissement du préambule de délégation :
- input/mod.rs : delegation_preamble renforcé (obligation explicite idea_reply).
- agent/lifecycle.rs : préambule injecté conditionné à mcp_enabled.
Lot 2 — peuplement du prompt_ready_pattern :
- agent/catalogue.rs : .with_prompt_ready_pattern("? for shortcuts") sur le
profil Claude + tests de seed, pour calibrer la détection de grâce.
Lot 3 — backstop timeout serveur :
- mcp/jsonrpc.rs : code d'erreur typé RENDEZVOUS_NO_REPLY (-32001).
- mcp/server.rs : mapping timeout → erreur typée, with_ask_rendezvous_timeout
promu + resolve_ask_rendezvous_timeout (configurable).
- app-tauri/state.rs : lecture env IDEA_ASK_RENDEZVOUS_TIMEOUT_MS.
- tests/mcp_server.rs mis à jour, fix d'un test input flaky.
Propagation overlay (référence des profils) :
- agent/catalogue.rs : overlay_reference_defaults + reference_index + tests.
- store/profile.rs : ReferenceBackfillProfileStore + tests.
- app-tauri/state.rs : wrap au composition root.
- exports mod.rs / lib.rs (application + infrastructure).
Tests réels verts : application 494, app-tauri 235, infrastructure 248,
mcp_server 22/22, 0 échec. Validation e2e LIVE (rebuild AppImage) en attente
avant merge vers develop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
546 lines
26 KiB
Rust
546 lines
26 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** the reply — for `idea_ask_agent`
|
|
//! it returns `outcome.reply` inline, exactly what `dispatch` produced.
|
|
//!
|
|
//! 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::sync::Arc;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use application::OrchestratorService;
|
|
use domain::{DomainEvent, OrchestrationSource, Project};
|
|
use serde_json::{json, Value};
|
|
use tokio::sync::mpsc;
|
|
|
|
use super::jsonrpc::{
|
|
error_codes, JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError,
|
|
JSONRPC_VERSION,
|
|
};
|
|
use super::tools::{self, ToolMapError};
|
|
|
|
/// The MCP protocol version this server speaks (advertised on `initialize`).
|
|
const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
|
|
|
|
/// **Server-side safety net** bounding the synchronous `idea_ask_agent` rendezvous.
|
|
///
|
|
/// `idea_ask_agent` is the only tool whose `tools/call` *blocks* awaiting another
|
|
/// agent's `idea_reply` (a synchronous rendezvous, resolved deep in the application
|
|
/// layer). The application layer already bounds the rendezvous itself, but this
|
|
/// adapter adds its own **finite** outer bound so a wedged target can never park a
|
|
/// `tools/call` task forever: on expiry the call returns a clean JSON-RPC error
|
|
/// instead of hanging. Generous on purpose (delegated turns can be very long), but
|
|
/// never infinite. Every other tool (`idea_reply`, `idea_list_agents`, …) is left
|
|
/// untouched — they don't rendezvous.
|
|
const ASK_RENDEZVOUS_TIMEOUT: Duration = Duration::from_secs(24 * 60 * 60);
|
|
|
|
/// Resolves the effective `idea_ask_agent` rendezvous bound from an optional
|
|
/// configured override in **milliseconds** (project/profile level), mirroring the
|
|
/// application's [`resolve_turn_timeout`](application) shape: `Some(ms)` ⇒ that bound,
|
|
/// `None`/absent ⇒ the generous [`ASK_RENDEZVOUS_TIMEOUT`] default (statu quo, zero
|
|
/// regression). `Some(0)` is treated as "no override" so a misconfigured zero can never
|
|
/// collapse the safety net to an instant expiry. Pure and unit-testable without wiring.
|
|
#[must_use]
|
|
pub fn resolve_ask_rendezvous_timeout(override_ms: Option<u32>) -> Duration {
|
|
match override_ms {
|
|
Some(ms) if ms > 0 => Duration::from_millis(u64::from(ms)),
|
|
_ => ASK_RENDEZVOUS_TIMEOUT,
|
|
}
|
|
}
|
|
|
|
/// 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>>,
|
|
/// Finite outer bound applied **only** to the `idea_ask_agent` rendezvous (see
|
|
/// [`ASK_RENDEZVOUS_TIMEOUT`]). Carried per instance so tests can shrink it to a
|
|
/// few milliseconds without polluting the public API; production code always uses
|
|
/// the default.
|
|
ask_rendezvous_timeout: Duration,
|
|
}
|
|
|
|
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,
|
|
ask_rendezvous_timeout: ASK_RENDEZVOUS_TIMEOUT,
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
/// 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(),
|
|
ask_rendezvous_timeout: self.ask_rendezvous_timeout,
|
|
}
|
|
}
|
|
|
|
/// Overrides the `idea_ask_agent` rendezvous safety-net bound (default
|
|
/// [`ASK_RENDEZVOUS_TIMEOUT`]).
|
|
///
|
|
/// A genuine **configuration knob** (project/profile level, modelled on the
|
|
/// application's `turn_timeout_ms` / `resolve_turn_timeout`): the composition root
|
|
/// resolves an optional override with [`resolve_ask_rendezvous_timeout`] and applies
|
|
/// it here. Absent override ⇒ the generous 24 h default is preserved unchanged (zero
|
|
/// regression on legitimately long delegated turns). Tests also use it to shrink the
|
|
/// bound to milliseconds.
|
|
#[must_use]
|
|
pub fn with_ask_rendezvous_timeout(mut self, timeout: Duration) -> Self {
|
|
self.ask_rendezvous_timeout = timeout;
|
|
self
|
|
}
|
|
|
|
/// 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 (anti-wedge).** A request whose handling blocks —
|
|
/// notably `idea_ask_agent`, which awaits another agent's `idea_reply` in a
|
|
/// synchronous rendezvous — must **not** stall the read loop: otherwise a single
|
|
/// in-flight ask parks the whole connection and every later call (even a
|
|
/// rendezvous-free `idea_list_agents`) 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" => Ok(self.tools_list_result()),
|
|
"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") }
|
|
})
|
|
}
|
|
|
|
/// The `tools/list` result: the catalogue as MCP tool descriptors.
|
|
fn tools_list_result(&self) -> Value {
|
|
let tools: Vec<Value> = tools::catalogue()
|
|
.into_iter()
|
|
.map(|t| {
|
|
json!({
|
|
"name": t.name,
|
|
"description": t.description,
|
|
"inputSchema": t.input_schema,
|
|
})
|
|
})
|
|
.collect();
|
|
json!({ "tools": tools })
|
|
}
|
|
|
|
/// `tools/call`: map the tool to an [`OrchestratorCommand`], `dispatch` it, and
|
|
/// fold the [`OrchestratorOutcome`](application::OrchestratorOutcome) into an MCP
|
|
/// tool result. The `idea_ask_agent` reply is returned **inline** — never
|
|
/// re-routed.
|
|
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!({}));
|
|
|
|
// 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.
|
|
// C'est l'amorce de la trace `[mcp]` begin → (armed) → (expired) → end qui
|
|
// permet de voir un `idea_ask_agent` entrer sans jamais ressortir (blocage).
|
|
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}",
|
|
);
|
|
|
|
// `idea_reply` needs the connected peer's identity as `from`; every other tool
|
|
// ignores `requester`. The handshake-provided requester is the source of truth.
|
|
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));
|
|
}
|
|
};
|
|
|
|
// `idea_ask_agent` is the only tool that blocks on a synchronous rendezvous
|
|
// (the target's `idea_reply`). Bound *only* that path with a finite outer
|
|
// timeout (server-side safety net, see [`ASK_RENDEZVOUS_TIMEOUT`]): on expiry
|
|
// return a clean JSON-RPC error instead of parking the task forever. Every
|
|
// other tool dispatches unbounded — they never rendezvous.
|
|
let dispatch = self.service.dispatch(&self.project, command);
|
|
let result = if name == "idea_ask_agent" {
|
|
// Armed beacon : le rendezvous synchrone est désormais borné par le filet
|
|
// de sécurité serveur. Si « end » n'apparaît jamais après ce « armed », la
|
|
// cible n'a ni appelé `idea_reply` ni atteint son prompt-ready dans le délai.
|
|
application::diag!(
|
|
"[mcp] ask armed requester={requester_label} target={arg_target} \
|
|
task_len={task_len} timeout_ms={}",
|
|
self.ask_rendezvous_timeout.as_millis(),
|
|
);
|
|
match tokio::time::timeout(self.ask_rendezvous_timeout, dispatch).await {
|
|
Ok(result) => result,
|
|
Err(_elapsed) => {
|
|
// RISQUE #1 (Architect) : on `return` ici ⇒ le futur `dispatch` (déplacé
|
|
// dans `timeout`) est **droppé**, jamais détaché dans un `spawn`. C'est ce
|
|
// Drop qui libère le `BusyTurnGuard` RAII de `ask_agent` (cancel_head +
|
|
// mark_idle) et purge le `Busy`/ticket de la cible — aucune fuite `Busy`.
|
|
application::diag!(
|
|
"[mcp] ask EXPIRED requester={requester_label} target={arg_target} \
|
|
timeout_ms={} elapsed_ms={}",
|
|
self.ask_rendezvous_timeout.as_millis(),
|
|
started.elapsed().as_millis(),
|
|
);
|
|
// Filet serveur : ne plus renvoyer un INTERNAL_ERROR opaque, mais la même
|
|
// sémantique typée et **retryable** que `AppError::TargetReturnedNoReply`,
|
|
// sous un code JSON-RPC DISTINCT (RENDEZVOUS_NO_REPLY) + `data` structuré.
|
|
return Err(rendezvous_no_reply_error(&arg_target));
|
|
}
|
|
}
|
|
} else {
|
|
dispatch.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))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
})
|
|
}
|
|
|
|
/// Builds the JSON-RPC error returned when the `idea_ask_agent` rendezvous expires
|
|
/// against the server-side safety net (the `Elapsed` branch of the outer `timeout`).
|
|
///
|
|
/// Mirrors [`application::AppError::TargetReturnedNoReply`] one-for-one — same message
|
|
/// and same machine-readable `code` (`"TARGET_RETURNED_NO_REPLY"`), flagged `retryable`
|
|
/// — but as a JSON-RPC protocol error under the **distinct**
|
|
/// [`error_codes::RENDEZVOUS_NO_REPLY`] code (never the opaque `INTERNAL_ERROR`), since
|
|
/// at expiry no [`OrchestratorOutcome`](application::OrchestratorOutcome) exists to fold
|
|
/// into a tool result. The `data` object lets a caller branch without parsing the
|
|
/// message.
|
|
fn rendezvous_no_reply_error(target: &str) -> JsonRpcError {
|
|
let message = format!(
|
|
"agent {target} returned to its prompt without calling idea_reply (no answer was \
|
|
rendered) before the rendezvous timeout; retry the request and ensure the agent \
|
|
replies via idea_reply"
|
|
);
|
|
JsonRpcError {
|
|
code: error_codes::RENDEZVOUS_NO_REPLY,
|
|
message,
|
|
data: Some(json!({
|
|
"code": "TARGET_RETURNED_NO_REPLY",
|
|
"retryable": true,
|
|
"target": target,
|
|
})),
|
|
}
|
|
}
|
|
|
|
/// 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())
|
|
}
|
|
}
|
|
}
|