feat(agent): bind transport S-MCP — outils idea_* vivants de bout en bout (M5a-e) — §14.3.1
Dernier kilomètre de l'orchestration native : une CLI MCP réellement lancée
joint le serveur MCP du projet et ses outils idea_* aboutissent au vrai dispatch.
- M5a endpoint loopback par projet (interprocess UDS/named pipe, source unique
mcp_endpoint, cleanup au close) — zéro port réseau, AppImage/SSH-safe.
- M5b sous-commande `idea mcp-server` : pont stdio↔loopback headless (avant init
Tauri), handshake {"project","requester"}, endpoint-absent borné, EOF propre.
- M5c McpServerHandle boucle accept + serve_peer par pair ; requester réel
propagé jusqu'à OrchestratorRequestProcessed (fin du "mcp" figé) ; isolation
des pairs ; terminaison propre.
- M5d apply_mcp_config écrit la déclaration réelle (current_exe + --endpoint
mcp_endpoint + --project simple-uuid + --requester) ; McpRuntime injecté comme
donnée (application ne dépend pas de app-tauri).
- M5e smoke e2e sur vrai loopback : list/ask inline, cible PTY → erreur typée,
JSON malformé → pas de panic, requester propagé.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
173
crates/app-tauri/src/mcp_endpoint.rs
Normal file
173
crates/app-tauri/src/mcp_endpoint.rs
Normal file
@ -0,0 +1,173 @@
|
||||
//! 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 domain::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).
|
||||
#[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**: `<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 }
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user