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:
2026-06-10 18:09:41 +02:00
parent 6ca519b815
commit cf89b3b9a5
17 changed files with 3281 additions and 44 deletions

View File

@ -10,7 +10,9 @@
//! stopping unregisters.
use std::path::PathBuf;
use std::time::Duration;
use app_tauri_lib::mcp_endpoint::mcp_endpoint;
use app_tauri_lib::state::AppState;
use domain::ports::IdGenerator;
use domain::project::{Project, ProjectPath};
@ -211,3 +213,120 @@ async fn mcp_servers_are_isolated_per_project() {
assert!(has_mcp(&state, &b.id));
assert_eq!(mcp_count(&state), 1);
}
// --- M5a: per-project loopback MCP endpoint lifecycle ---
//
// `mcp_endpoint(project_id)` is the single source of truth for the loopback
// address (cadrage v5 §2). `ensure_mcp_server` binds it at open; dropping the
// handle on close unlinks it (Unix). On Unix the endpoint is a UDS *file* whose
// existence is directly observable; these tests assert bind → idempotence →
// cleanup → determinism/no-collision → coexistence with the file watcher.
/// Polls until `cond()` holds or the bound elapses (cleanup is async: the handle's
/// supervision task drops the listener — and unlinks the socket — only after the
/// stop signal propagates). Bounded so a regression fails fast, never hangs.
#[cfg(unix)]
async fn wait_until(mut cond: impl FnMut() -> bool) -> bool {
for _ in 0..100 {
if cond() {
return true;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
cond()
}
#[cfg(unix)]
fn socket_exists(project: &Project) -> bool {
mcp_endpoint(&project.id)
.socket_path()
.map(|p| p.exists())
.unwrap_or(false)
}
#[cfg(unix)]
#[tokio::test]
async fn open_binds_the_project_loopback_endpoint() {
let state = AppState::build(temp_path("appdata"));
let project = make_project();
assert!(!socket_exists(&project), "no socket before open");
state.ensure_orchestrator_watch(&project);
assert!(
wait_until(|| socket_exists(&project)).await,
"the project's loopback socket is bound on open"
);
state.stop_orchestrator_watch(&project.id);
}
#[cfg(unix)]
#[tokio::test]
async fn double_open_keeps_a_single_endpoint_no_address_in_use() {
let state = AppState::build(temp_path("appdata"));
let project = make_project();
state.ensure_orchestrator_watch(&project);
assert!(wait_until(|| socket_exists(&project)).await);
// A second open must NOT rebind (which would fail "address in use" on a live
// socket) — it returns early. One endpoint, still bound, no panic.
state.ensure_orchestrator_watch(&project);
assert_eq!(mcp_count(&state), 1, "one endpoint per project");
assert!(socket_exists(&project), "endpoint still bound after re-open");
state.stop_orchestrator_watch(&project.id);
}
#[cfg(unix)]
#[tokio::test]
async fn close_cleans_up_the_endpoint_socket_file() {
let state = AppState::build(temp_path("appdata"));
let project = make_project();
state.ensure_orchestrator_watch(&project);
assert!(wait_until(|| socket_exists(&project)).await);
state.stop_orchestrator_watch(&project.id);
assert!(
wait_until(|| !socket_exists(&project)).await,
"the socket file is unlinked on close — no leak"
);
}
#[test]
fn endpoint_is_deterministic_and_collision_free_across_projects() {
let p1 = make_project();
let p2 = make_project();
// Stable for the same project across calls.
assert_eq!(mcp_endpoint(&p1.id), mcp_endpoint(&p1.id));
// Distinct projects ⇒ distinct endpoints (no collision).
assert_ne!(mcp_endpoint(&p1.id), mcp_endpoint(&p2.id));
assert_ne!(
mcp_endpoint(&p1.id).as_cli_arg(),
mcp_endpoint(&p2.id).as_cli_arg()
);
}
#[cfg(unix)]
#[tokio::test]
async fn file_watcher_and_loopback_endpoint_live_together() {
let state = AppState::build(temp_path("appdata"));
let project = make_project();
state.ensure_orchestrator_watch(&project);
// R0/M3 invariant intact: the file watcher and the MCP server are both live...
assert!(has_watcher(&state, &project.id), "watcher live");
assert!(has_mcp(&state, &project.id), "mcp server live");
// ...and on Unix the loopback endpoint is actually bound beside the watcher.
assert!(
wait_until(|| socket_exists(&project)).await,
"endpoint bound alongside the live file watcher"
);
state.stop_orchestrator_watch(&project.id);
assert!(wait_until(|| !socket_exists(&project)).await);
}