//! 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 (Tauri) 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-`. 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 { #[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//idea-mcp/<32 hex>.sock` ≈ 50 bytes). #[cfg(unix)] fn unix_runtime_dir() -> PathBuf { let base = std::env::var_os("XDG_RUNTIME_DIR") .map(PathBuf::from) .or_else(|| std::env::var_os("TMPDIR").map(PathBuf::from)) .unwrap_or_else(|| PathBuf::from("/tmp")); base.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**: `/idea-mcp/.sock` — a UDS file path. /// - **Windows**: `\\.\pipe\idea-mcp-` — a named pipe. /// /// where `` 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 { std::env::var("APPIMAGE") .ok() .or_else(|| std::env::current_exe().ok().map(|p| p.to_string_lossy().into_owned())) } /// Implémentation app-tauri du port [`McpRuntimeProvider`] : 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 { 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::{AgentId, ProjectId, Project}; use domain::project::ProjectPath; use domain::remote::RemoteRef; 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é"); } }