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:
@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user