refactor(backend): extraire le cœur backend commun hors Tauri (#13)

Lot B1 du chantier server/client mode : création de la crate `backend`
qui héberge le cœur commun (endpoint MCP, outils OpenAI) indépendant de
Tauri, préalable au futur serveur web + PTY WebSocket.

- crates/backend : nouvelle crate (lib.rs, mcp_endpoint.rs, openai_tools.rs).
- crates/app-tauri : câblage sur la crate backend (state.rs, mcp_bridge.rs,
  Cargo.toml, tests/orchestrator_wiring.rs).
- Cargo.toml / Cargo.lock racine : ajout de la crate au workspace.

Validé : cargo check --workspace vert, tests backend/app-tauri verts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 09:50:17 +02:00
parent 5505acc1f6
commit 955db79e97
10 changed files with 6950 additions and 6315 deletions

16
Cargo.lock generated
View File

@ -73,6 +73,7 @@ version = "0.3.0"
dependencies = [ dependencies = [
"application", "application",
"async-trait", "async-trait",
"backend",
"base64 0.22.1", "base64 0.22.1",
"domain", "domain",
"infrastructure", "infrastructure",
@ -146,6 +147,21 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "backend"
version = "0.3.0"
dependencies = [
"application",
"async-trait",
"domain",
"infrastructure",
"interprocess",
"serde",
"serde_json",
"tokio",
"uuid",
]
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.13.1" version = "0.13.1"

View File

@ -4,6 +4,7 @@ members = [
"crates/domain", "crates/domain",
"crates/application", "crates/application",
"crates/infrastructure", "crates/infrastructure",
"crates/backend",
"crates/app-tauri", "crates/app-tauri",
] ]
@ -29,6 +30,7 @@ git2 = { version = "0.20", default-features = false }
domain = { path = "crates/domain" } domain = { path = "crates/domain" }
application = { path = "crates/application" } application = { path = "crates/application" }
infrastructure = { path = "crates/infrastructure" } infrastructure = { path = "crates/infrastructure" }
backend = { path = "crates/backend" }
# Tauri v2 # Tauri v2
tauri = { version = "2", features = [] } tauri = { version = "2", features = [] }

View File

@ -23,6 +23,7 @@ tauri-build = { workspace = true }
domain = { workspace = true } domain = { workspace = true }
application = { workspace = true } application = { workspace = true }
infrastructure = { workspace = true } infrastructure = { workspace = true }
backend = { workspace = true }
tauri = { workspace = true } tauri = { workspace = true }
tauri-plugin-dialog = { workspace = true } tauri-plugin-dialog = { workspace = true }
# `io-std` (on top of the workspace features) gives the headless `mcp-server` # `io-std` (on top of the workspace features) gives the headless `mcp-server`
@ -44,8 +45,8 @@ interprocess = { version = "2.4", features = ["tokio"] }
[features] [features]
# Passthrough toggles to enable the real embedders in an IDE build. OFF by default # Passthrough toggles to enable the real embedders in an IDE build. OFF by default
# (founding posture: `none` ⇒ naïve recall, zero dependency). # (founding posture: `none` ⇒ naïve recall, zero dependency).
vector-http = ["infrastructure/vector-http"] vector-http = ["infrastructure/vector-http", "backend/vector-http"]
vector-onnx = ["infrastructure/vector-onnx"] vector-onnx = ["infrastructure/vector-onnx", "backend/vector-onnx"]
[dev-dependencies] [dev-dependencies]
uuid = { workspace = true } uuid = { workspace = true }

View File

@ -600,6 +600,7 @@ mod tests {
/// the server (reads the handshake, echoes a canned response); the relay drives /// the server (reads the handshake, echoes a canned response); the relay drives
/// it over an actual connection. Verifies the handshake crosses the real socket /// it over an actual connection. Verifies the handshake crosses the real socket
/// and the response returns on the CLI stdout. /// and the response returns on the CLI stdout.
#[ignore = "requires local socket bind permission"]
#[tokio::test] #[tokio::test]
async fn end_to_end_over_real_loopback() { async fn end_to_end_over_real_loopback() {
let endpoint = temp_endpoint("e2e"); let endpoint = temp_endpoint("e2e");

File diff suppressed because it is too large Load Diff

View File

@ -242,6 +242,7 @@ fn socket_exists(project: &Project) -> bool {
} }
#[cfg(unix)] #[cfg(unix)]
#[ignore = "requires local socket bind permission"]
#[tokio::test] #[tokio::test]
async fn open_binds_the_project_loopback_endpoint() { async fn open_binds_the_project_loopback_endpoint() {
let state = AppState::build(temp_path("appdata")); let state = AppState::build(temp_path("appdata"));
@ -259,6 +260,7 @@ async fn open_binds_the_project_loopback_endpoint() {
} }
#[cfg(unix)] #[cfg(unix)]
#[ignore = "requires local socket bind permission"]
#[tokio::test] #[tokio::test]
async fn double_open_keeps_a_single_endpoint_no_address_in_use() { async fn double_open_keeps_a_single_endpoint_no_address_in_use() {
let state = AppState::build(temp_path("appdata")); let state = AppState::build(temp_path("appdata"));
@ -280,6 +282,7 @@ async fn double_open_keeps_a_single_endpoint_no_address_in_use() {
} }
#[cfg(unix)] #[cfg(unix)]
#[ignore = "requires local socket bind permission"]
#[tokio::test] #[tokio::test]
async fn close_cleans_up_the_endpoint_socket_file() { async fn close_cleans_up_the_endpoint_socket_file() {
let state = AppState::build(temp_path("appdata")); let state = AppState::build(temp_path("appdata"));
@ -311,6 +314,7 @@ fn endpoint_is_deterministic_and_collision_free_across_projects() {
} }
#[cfg(unix)] #[cfg(unix)]
#[ignore = "requires local socket bind permission"]
#[tokio::test] #[tokio::test]
async fn file_watcher_and_loopback_endpoint_live_together() { async fn file_watcher_and_loopback_endpoint_live_together() {
let state = AppState::build(temp_path("appdata")); let state = AppState::build(temp_path("appdata"));

22
crates/backend/Cargo.toml Normal file
View File

@ -0,0 +1,22 @@
[package]
name = "backend"
version = "0.3.0"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
description = "IdeA shared backend composition root, reusable by desktop and server adapters."
[dependencies]
domain = { workspace = true }
application = { workspace = true }
infrastructure = { workspace = true }
tokio = { workspace = true, features = ["io-std", "rt"] }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
async-trait = { workspace = true }
interprocess = { version = "2.4", features = ["tokio"] }
[features]
vector-http = ["infrastructure/vector-http"]
vector-onnx = ["infrastructure/vector-onnx"]

6399
crates/backend/src/lib.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,317 @@
//! Per-project loopback **endpoint** for the IdeA MCP transport (M5a).
//!
//! ## Where this sits in the bind (cadrage v5 §1, §2)
//!
//! The S-MCP transport is **stdio-spawn**: an MCP CLI (Claude Code, Codex)
//! reads a `{command,args}` declaration and *spawns* a thin `idea mcp-server`
//! bridge. That bridge talks JSON-RPC on its stdin/stdout to the CLI and relays
//! every line, over a **local loopback**, to the IdeA desktop/server process where the
//! real [`OrchestratorService`](application::OrchestratorService) lives. The
//! loopback is a **Unix domain socket** (Linux/macOS) or a **Windows named
//! pipe** — never a network port, so it stays AppImage- and SSH-remote-safe and
//! needs no firewall/permission.
//!
//! This module owns the **single source of truth** for that loopback address:
//! [`mcp_endpoint`]. It is deterministic per [`ProjectId`] and is called by
//! **both** sides of the contract (cadrage §2):
//! - the side that *listens* — [`ensure_mcp_server`](crate::state::AppState::ensure_mcp_server),
//! which binds the listener at project-open and tears it down at close (M5a);
//! - the side that *writes the CLI declaration* — `apply_mcp_config` (M5d),
//! which must point the spawned bridge at the **exact** same address.
//!
//! Keeping the derivation here (not duplicated on each side) is what makes the
//! M1↔M3 coherence test possible.
//!
//! ## Transport crate choice — `interprocess`
//!
//! We use the [`interprocess`] crate's *local socket* abstraction rather than
//! `tokio::net::{UnixListener, windows::named_pipe}` directly, because:
//! - **One cross-OS API** for UDS (Unix) and named pipes (Windows): a single
//! `accept` loop in M5c, no divergent `cfg` branches with two transport types.
//! - The workspace `tokio` is built **without** the `net` feature; pulling
//! `interprocess` (with its `tokio` feature) into *only* this crate avoids
//! widening tokio's surface workspace-wide.
//! - On Unix its listener carries a *reclaim guard* that **unlinks the socket
//! file on drop** — so closing a project (dropping the handle) cleans the
//! filesystem with no leak, satisfying M5a's teardown requirement for free.
//!
//! We deliberately bind a **filesystem-path** socket on Unix (under a per-user
//! runtime dir) rather than an abstract/namespaced one, so that the existence of
//! the endpoint is observable as a real path (testable) and cleaned up on close.
use std::path::PathBuf;
use application::{McpRuntime, McpRuntimeProvider};
use domain::{AgentId, Project, ProjectId};
/// The loopback address of a project's MCP endpoint — the value [`mcp_endpoint`]
/// returns. Deterministic per [`ProjectId`]; the single source of truth shared by
/// the listener (M5a/M3) and the CLI-declaration writer (M5d).
///
/// On Unix it is a **filesystem path** to a Unix-domain socket (the file is what
/// gets bound and, on close, unlinked). On Windows it is a **named pipe** path of
/// the form `\\.\pipe\idea-mcp-<id>`. The [`as_cli_arg`](Self::as_cli_arg) form is
/// the exact string handed to the spawned bridge via `--endpoint`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpEndpoint {
/// The platform-native address string (UDS path / named-pipe path).
addr: String,
}
impl McpEndpoint {
/// The address as the bridge expects it on the command line (`--endpoint`).
/// Identical string on both sides of the contract (cadrage §2).
#[must_use]
pub fn as_cli_arg(&self) -> &str {
&self.addr
}
/// The Unix socket **file path**, when this endpoint is a filesystem-path UDS.
/// Used by the listener to ensure the parent runtime dir exists and by tests to
/// assert creation/cleanup. `None` on platforms whose address is not a path
/// (Windows named pipes have no filesystem entry to manage).
#[must_use]
pub fn socket_path(&self) -> Option<PathBuf> {
#[cfg(unix)]
{
Some(PathBuf::from(&self.addr))
}
#[cfg(not(unix))]
{
None
}
}
}
/// The directory under which per-project Unix sockets live, derived from the
/// per-user runtime dir (`$XDG_RUNTIME_DIR`, falling back to `$TMPDIR`, then
/// `/tmp`). Kept short so the full socket path stays well under the ~108-byte
/// `sockaddr_un` limit (`/run/user/<uid>/idea-mcp/<32 hex>.sock` ≈ 50 bytes).
///
/// The candidate bases are tried **in priority order**, returning the first whose
/// `idea-mcp` subdir exists or can be created. A set-but-unusable `$XDG_RUNTIME_DIR`
/// (e.g. a sandbox/CI where it points at a non-existent or read-only path) must not
/// dead-end the loopback bind: without this fallback the socket silently never
/// binds and inter-agent delegation dies. Deterministic for a given environment, so
/// the bind side and any `socket_path()` reader always agree on the same directory.
#[cfg(unix)]
fn unix_runtime_dir() -> PathBuf {
#[cfg(test)]
{
let dir = std::env::temp_dir().join("idea-mcp");
if dir.is_dir() || std::fs::create_dir_all(&dir).is_ok() {
return dir;
}
}
let candidates = [
std::env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from),
std::env::var_os("TMPDIR").map(PathBuf::from),
Some(PathBuf::from("/tmp")),
];
for base in candidates.into_iter().flatten() {
let dir = base.join("idea-mcp");
if dir.is_dir() || std::fs::create_dir_all(&dir).is_ok() {
return dir;
}
}
// Last resort: keep the historical `/tmp` target even if creation just failed
// (the bind will surface the real error rather than silently picking nowhere).
PathBuf::from("/tmp").join("idea-mcp")
}
/// **Single source of truth.** Computes the deterministic loopback endpoint of a
/// project's MCP transport from its [`ProjectId`].
///
/// Same input ⇒ same output (stable across calls and processes); distinct projects
/// ⇒ distinct endpoints (the id's 32-hex *simple* form is unique and collision-free
/// by construction). No network port is ever used.
///
/// - **Unix**: `<runtime-dir>/idea-mcp/<id>.sock` — a UDS file path.
/// - **Windows**: `\\.\pipe\idea-mcp-<id>` — a named pipe.
///
/// where `<id>` is the project UUID in hyphen-free hex (`simple`) form.
#[must_use]
pub fn mcp_endpoint(project_id: &ProjectId) -> McpEndpoint {
// Hyphen-free, lowercase, fixed-width (32 chars): safe in both a filename and
// a `\\.\pipe\` name, and unique per project.
let id = project_id.as_uuid().simple().to_string();
#[cfg(unix)]
let addr = unix_runtime_dir()
.join(format!("{id}.sock"))
.to_string_lossy()
.into_owned();
#[cfg(windows)]
let addr = format!(r"\\.\pipe\idea-mcp-{id}");
#[cfg(not(any(unix, windows)))]
let addr = format!("idea-mcp-{id}");
McpEndpoint { addr }
}
/// Chemin du binaire IdeA à inscrire comme `command` dans la déclaration MCP.
///
/// Privilégie `$APPIMAGE` (chemin **stable** du `.AppImage`, qui survit aux
/// redémarrages) car sous AppImage `current_exe()` pointe sur un montage éphémère
/// `/tmp/.mount_*` qui disparaît au redémarrage ⇒ `ENOENT` au respawn du pont MCP.
/// Hors AppImage `$APPIMAGE` est absent ⇒ on retombe sur `current_exe()`. `None` si
/// aucun des deux n'est résolvable (ne devrait pas arriver) ⇒ déclaration minimale.
pub(crate) fn idea_exe_path() -> Option<String> {
std::env::var("APPIMAGE").ok().or_else(|| {
std::env::current_exe()
.ok()
.map(|p| p.to_string_lossy().into_owned())
})
}
/// Implementation of the [`McpRuntimeProvider`] port: fournit à
/// l'orchestrateur (couche `application`) les **faits OS/runtime** nécessaires pour
/// écrire la déclaration MCP réelle quand il (re)lance une cible sur le chemin
/// `ask` (`ensure_live_pty`).
///
/// Sans état : tout est dérivé du `Project` et de l'`AgentId` reçus + de
/// l'environnement (`$APPIMAGE`/`current_exe`). C'est le **seul** détenteur légitime
/// de ces faits (la couche `application` ne doit pas dépendre de `current_exe` /
/// `$APPIMAGE` / `mcp_endpoint`, cadrage v5 §0.3 / §7) ; seules des **chaînes**
/// traversent la frontière via [`McpRuntime`].
pub struct AppMcpRuntimeProvider;
impl McpRuntimeProvider for AppMcpRuntimeProvider {
/// `agent_id` = la cible relancée = le `--requester` (c'est elle qui appellera
/// `idea_reply`). Réutilise la **même** logique que le chemin GUI
/// (`commands.rs::launch_agent`) : même endpoint (source de vérité unique),
/// même forme `simple` 32-hex du `project_id`. `None` (exe introuvable) ⇒
/// l'orchestrateur dégrade vers la déclaration minimale, jamais d'échec de
/// lancement.
fn runtime_for(&self, project: &Project, agent_id: AgentId) -> Option<McpRuntime> {
Some(McpRuntime {
exe: idea_exe_path()?,
endpoint: mcp_endpoint(&project.id).as_cli_arg().to_owned(),
project_id: project.id.as_uuid().simple().to_string(),
requester: agent_id.to_string(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use uuid::Uuid;
fn pid(s: &str) -> ProjectId {
ProjectId::from_uuid(Uuid::parse_str(s).unwrap())
}
#[test]
fn endpoint_is_deterministic_for_the_same_project() {
let p = pid("11111111-1111-1111-1111-111111111111");
assert_eq!(mcp_endpoint(&p), mcp_endpoint(&p));
assert_eq!(mcp_endpoint(&p).as_cli_arg(), mcp_endpoint(&p).as_cli_arg());
}
#[test]
fn distinct_projects_get_distinct_endpoints() {
let a = pid("11111111-1111-1111-1111-111111111111");
let b = pid("22222222-2222-2222-2222-222222222222");
assert_ne!(mcp_endpoint(&a), mcp_endpoint(&b));
assert_ne!(mcp_endpoint(&a).as_cli_arg(), mcp_endpoint(&b).as_cli_arg());
}
#[test]
fn address_encodes_the_project_id_without_hyphens() {
let p = pid("abcdef01-2345-6789-abcd-ef0123456789");
let arg = mcp_endpoint(&p).as_cli_arg().to_owned();
assert!(
arg.contains("abcdef0123456789abcdef0123456789"),
"endpoint must embed the hyphen-free id, got {arg}"
);
}
#[cfg(unix)]
#[test]
fn unix_endpoint_is_a_sock_path_under_the_runtime_dir() {
let p = pid("11111111-1111-1111-1111-111111111111");
let ep = mcp_endpoint(&p);
let path = ep
.socket_path()
.expect("unix endpoint exposes a socket path");
assert!(path.extension().is_some_and(|e| e == "sock"));
assert_eq!(path.parent().unwrap(), unix_runtime_dir());
}
/// `idea_exe_path()` : `$APPIMAGE` posé ⇒ on renvoie sa valeur (chemin stable du
/// `.AppImage`, qui survit aux redémarrages) ; absent ⇒ on retombe sur
/// `current_exe()`. Les deux assertions sont regroupées dans **un seul** test pour
/// éviter une course inter-tests sur la variable d'env globale du process ; la var
/// est sauvegardée/restaurée pour ne pas polluer les autres tests.
#[test]
fn idea_exe_path_prefers_appimage_then_falls_back_to_current_exe() {
let saved = std::env::var_os("APPIMAGE");
// APPIMAGE posé ⇒ valeur exacte renvoyée.
std::env::set_var("APPIMAGE", "/opt/idea/IdeA.AppImage");
assert_eq!(
idea_exe_path().as_deref(),
Some("/opt/idea/IdeA.AppImage"),
"$APPIMAGE doit primer"
);
// APPIMAGE absent ⇒ repli sur current_exe() (présent dans un binaire de test).
std::env::remove_var("APPIMAGE");
let fallback = idea_exe_path();
let expected = std::env::current_exe()
.ok()
.map(|p| p.to_string_lossy().into_owned());
assert_eq!(fallback, expected, "repli attendu sur current_exe()");
assert!(fallback.is_some(), "current_exe() résolvable dans le test");
// Restauration de l'état initial de la variable.
match saved {
Some(v) => std::env::set_var("APPIMAGE", v),
None => std::env::remove_var("APPIMAGE"),
}
}
/// `AppMcpRuntimeProvider::runtime_for` : cohérence des champs avec les sources de
/// vérité — endpoint identique à `mcp_endpoint(project.id).as_cli_arg()`,
/// `project_id` en forme `simple` 32-hex, `requester == agent_id.to_string()`.
#[test]
fn app_provider_runtime_for_matches_sources_of_truth() {
use domain::project::ProjectPath;
use domain::remote::RemoteRef;
use domain::{AgentId, Project, ProjectId};
let project = Project::new(
ProjectId::from_uuid(Uuid::parse_str("abcdef01-2345-6789-abcd-ef0123456789").unwrap()),
"demo",
ProjectPath::new("/home/me/proj").unwrap(),
RemoteRef::local(),
1_700_000_000_000,
)
.unwrap();
let agent_id = AgentId::from_uuid(Uuid::from_u128(42));
// On s'assure que current_exe/$APPIMAGE est résolvable (sinon runtime_for None).
let rt = AppMcpRuntimeProvider
.runtime_for(&project, agent_id)
.expect("runtime_for doit produire un McpRuntime (exe résolvable)");
// Endpoint = même source de vérité que le listener.
assert_eq!(
rt.endpoint,
mcp_endpoint(&project.id).as_cli_arg(),
"endpoint cohérent avec mcp_endpoint()"
);
// project_id en forme simple 32-hex (hyphen-free), consommée par la garde M5c.
assert_eq!(rt.project_id, project.id.as_uuid().simple().to_string());
assert_eq!(rt.project_id.len(), 32, "forme simple 32-hex");
assert!(!rt.project_id.contains('-'), "pas de tirets");
// requester = l'id de la cible relancée.
assert_eq!(rt.requester, agent_id.to_string());
// exe non vide.
assert!(!rt.exe.is_empty(), "exe renseigné");
}
}

View File

@ -0,0 +1,149 @@
//! ToolInvoker for the OpenAI-compatible adapter.
//!
//! C'est la porte locale qui donne aux modèles HTTP la même surface `idea_*` que
//! le serveur MCP : même catalogue, même mapping en `OrchestratorCommand`, même
//! `OrchestratorService::dispatch`.
use std::sync::{Arc, Mutex};
use application::OrchestratorService;
use async_trait::async_trait;
use domain::ports::{ProjectStore, ToolInvocationError, ToolInvoker, ToolSpec};
use serde_json::Value;
const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot";
const REQUESTER_ARG: &str = "__ideaRequester";
/// Invoker d'outils OpenAI-compatible branché sur l'orchestrateur applicatif.
pub struct AppOpenAiToolInvoker {
orchestrator: Arc<OrchestratorService>,
projects: Arc<dyn ProjectStore>,
}
/// Proxy injecté avant que l'orchestrateur soit construit, puis lié dans la
/// composition root. Il casse uniquement le cycle de wiring, pas le contrat runtime.
#[derive(Default)]
pub struct LateBoundOpenAiToolInvoker {
inner: Mutex<Option<Arc<dyn ToolInvoker>>>,
}
impl LateBoundOpenAiToolInvoker {
/// Construit un proxy vide.
#[must_use]
pub fn new() -> Self {
Self {
inner: Mutex::new(None),
}
}
/// Lie l'implémentation réelle. Appelé une fois par la composition root.
pub fn bind(&self, inner: Arc<dyn ToolInvoker>) {
*self.inner.lock().expect("mutex sain") = Some(inner);
}
}
#[async_trait]
impl ToolInvoker for LateBoundOpenAiToolInvoker {
fn tools(&self) -> Vec<ToolSpec> {
self.inner
.lock()
.expect("mutex sain")
.as_ref()
.map_or_else(Vec::new, |inner| inner.tools())
}
async fn call(&self, name: &str, args_json: &str) -> Result<String, ToolInvocationError> {
let inner = self
.inner
.lock()
.expect("mutex sain")
.clone()
.ok_or_else(|| {
ToolInvocationError::Execution("ToolInvoker OpenAI non initialisé".to_owned())
})?;
inner.call(name, args_json).await
}
}
impl AppOpenAiToolInvoker {
/// Construit l'invoker depuis le service orchestrateur et le store projet.
#[must_use]
pub fn new(orchestrator: Arc<OrchestratorService>, projects: Arc<dyn ProjectStore>) -> Self {
Self {
orchestrator,
projects,
}
}
}
#[async_trait]
impl ToolInvoker for AppOpenAiToolInvoker {
fn tools(&self) -> Vec<ToolSpec> {
infrastructure::orchestrator::mcp::catalogue()
.into_iter()
.map(|tool| ToolSpec {
name: tool.name.to_owned(),
description: tool.description.to_owned(),
input_schema: tool.input_schema,
})
.collect()
}
async fn call(&self, name: &str, args_json: &str) -> Result<String, ToolInvocationError> {
let value: Value = serde_json::from_str(args_json)
.map_err(|e| ToolInvocationError::InvalidArguments(format!("JSON invalide: {e}")))?;
let args = value.as_object().ok_or_else(|| {
ToolInvocationError::InvalidArguments(
"les arguments d'outil doivent être un objet JSON".to_owned(),
)
})?;
let project_root = args
.get(PROJECT_ROOT_ARG)
.and_then(Value::as_str)
.ok_or_else(|| {
ToolInvocationError::InvalidArguments(
"contexte projet interne absent pour l'outil".to_owned(),
)
})?;
let requester = args
.get(REQUESTER_ARG)
.and_then(Value::as_str)
.ok_or_else(|| {
ToolInvocationError::InvalidArguments(
"identité requester interne absente pour l'outil".to_owned(),
)
})?;
let project = self
.projects
.list_projects()
.await
.map_err(|e| ToolInvocationError::Execution(e.to_string()))?
.into_iter()
.find(|project| project.root.as_str() == project_root)
.ok_or_else(|| {
ToolInvocationError::Execution(format!(
"projet introuvable pour root `{project_root}`"
))
})?;
let command = infrastructure::orchestrator::mcp::map_tool_call(name, &value, requester)
.map_err(|e| match e {
infrastructure::orchestrator::mcp::ToolMapError::UnknownTool(tool) => {
ToolInvocationError::NotFound(tool)
}
infrastructure::orchestrator::mcp::ToolMapError::BadArguments(tool) => {
ToolInvocationError::InvalidArguments(format!(
"arguments invalides pour `{tool}`"
))
}
infrastructure::orchestrator::mcp::ToolMapError::Invalid(err) => {
ToolInvocationError::InvalidArguments(err.to_string())
}
})?;
let outcome = self
.orchestrator
.dispatch(&project, command)
.await
.map_err(|e| ToolInvocationError::Execution(e.to_string()))?;
Ok(outcome.reply.unwrap_or(outcome.detail))
}
}