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:
@ -25,11 +25,18 @@ application = { workspace = true }
|
||||
infrastructure = { workspace = true }
|
||||
tauri = { workspace = true }
|
||||
tauri-plugin-dialog = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
# `io-std` (on top of the workspace features) gives the headless `mcp-server`
|
||||
# bridge access to `tokio::io::{stdin,stdout}` without widening tokio elsewhere.
|
||||
tokio = { workspace = true, features = ["io-std", "rt"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
# Cross-OS local IPC for the MCP loopback transport (M5a): Unix domain socket
|
||||
# (Linux/macOS) + Windows named pipe behind one async API, no network port. Pulled
|
||||
# in only here (the transport is an app-tauri/infra concern); its `tokio` feature
|
||||
# keeps us off tokio's `net` feature workspace-wide.
|
||||
interprocess = { version = "2.4", features = ["tokio"] }
|
||||
|
||||
[features]
|
||||
# Passthrough toggles to enable the real embedders in an IDE build. OFF by default
|
||||
|
||||
@ -16,6 +16,7 @@ use application::{
|
||||
DetectAgentDriftInput, GetMemoryInput, GitBranchesInput, GitCheckoutInput, GitCommitInput,
|
||||
GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput,
|
||||
InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListLayoutsInput, LiveSessions,
|
||||
McpRuntime,
|
||||
ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, LoadLayoutInput, MutateLayoutInput,
|
||||
OpenProjectInput,
|
||||
ReadAgentContextInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput,
|
||||
@ -1029,6 +1030,22 @@ pub async fn launch_agent(
|
||||
// already-live agent is refused).
|
||||
let node_id = request.node_id.as_deref().map(parse_node_id).transpose()?;
|
||||
|
||||
// Compose the MCP runtime (M5d): inject the OS/runtime facts the application
|
||||
// layer cannot compute. The endpoint comes from the **single source of truth**
|
||||
// (`mcp_endpoint`), the same value `ensure_mcp_server` binds the listener on
|
||||
// (cadrage v5 §2). The `--project` is the hyphen-free 32-hex `simple` form the
|
||||
// M5c handshake guard (`serve_peer`) compares against; the `--requester` is the
|
||||
// launching agent's id. A missing executable path (should not happen) degrades
|
||||
// to no runtime ⇒ apply_mcp_config writes the minimal declaration.
|
||||
let mcp_runtime = std::env::current_exe().ok().map(|exe| McpRuntime {
|
||||
exe: exe.to_string_lossy().into_owned(),
|
||||
endpoint: crate::mcp_endpoint::mcp_endpoint(&project.id)
|
||||
.as_cli_arg()
|
||||
.to_owned(),
|
||||
project_id: project.id.as_uuid().simple().to_string(),
|
||||
requester: agent_id.to_string(),
|
||||
});
|
||||
|
||||
let output = state
|
||||
.launch_agent
|
||||
.execute(LaunchAgentInput {
|
||||
@ -1040,6 +1057,7 @@ pub async fn launch_agent(
|
||||
// Resume id is a property of the hosting cell; the frontend passes the
|
||||
// leaf's current conversation id here.
|
||||
conversation_id: request.conversation_id.clone(),
|
||||
mcp_runtime,
|
||||
})
|
||||
.await
|
||||
.map_err(ErrorDto::from)?;
|
||||
|
||||
@ -17,13 +17,49 @@ pub mod chat;
|
||||
pub mod commands;
|
||||
pub mod dto;
|
||||
pub mod events;
|
||||
pub mod mcp_bridge;
|
||||
pub mod mcp_endpoint;
|
||||
pub mod pty;
|
||||
pub mod state;
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
use state::AppState;
|
||||
|
||||
/// The `argv[1]` subcommand token that switches the binary into the headless
|
||||
/// `mcp-server` **bridge** mode (cadrage v5 §1.3) instead of launching Tauri.
|
||||
pub const MCP_SERVER_SUBCOMMAND: &str = "mcp-server";
|
||||
|
||||
/// Process entry point: routes `argv` **before** anything Tauri/WebKit is touched.
|
||||
///
|
||||
/// When invoked as `<exe> mcp-server …` (an MCP CLI spawned us from the injected
|
||||
/// `.mcp.json` declaration), we run the **stdio↔loopback bridge** headless and
|
||||
/// **never** initialise the webview — see [`mcp_bridge::run_mcp_bridge`]. Any other
|
||||
/// invocation is the normal IDE launch: [`run`] (which blocks until the window
|
||||
/// closes and then exits the process itself).
|
||||
///
|
||||
/// Returns the [`ExitCode`] for the bridge path; the normal path does not return.
|
||||
#[must_use]
|
||||
pub fn dispatch() -> ExitCode {
|
||||
let mut args = std::env::args_os().skip(1);
|
||||
if args
|
||||
.next()
|
||||
.is_some_and(|a| a == *MCP_SERVER_SUBCOMMAND)
|
||||
{
|
||||
// Headless bridge: bypass Tauri entirely. Forward the remaining args
|
||||
// (`--endpoint`, `--project`, `--requester`) to the bridge parser.
|
||||
let rest: Vec<String> = args
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
return mcp_bridge::run_mcp_bridge(rest);
|
||||
}
|
||||
|
||||
run();
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
/// Builds and runs the Tauri application.
|
||||
///
|
||||
/// Sets up the composition root (resolving the app-data directory via the Tauri
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
// Prevents an extra console window on Windows in release builds.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
use std::process::ExitCode;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
// WebKitGTK's DMABUF renderer causes a blank/white window on many Linux
|
||||
// setups (recent Mesa/Nvidia drivers, common on Arch). Disable it before
|
||||
// the webview initializes, unless the user has explicitly set the variable.
|
||||
// Harmless for the headless `mcp-server` bridge path, which ignores it.
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
|
||||
@ -12,5 +15,6 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
app_tauri_lib::run();
|
||||
// Route argv before any Tauri/WebKit init: `mcp-server` ⇒ headless bridge.
|
||||
app_tauri_lib::dispatch()
|
||||
}
|
||||
|
||||
578
crates/app-tauri/src/mcp_bridge.rs
Normal file
578
crates/app-tauri/src/mcp_bridge.rs
Normal file
@ -0,0 +1,578 @@
|
||||
//! The `idea mcp-server` **bridge** — a transparent stdio↔loopback tube (M5b).
|
||||
//!
|
||||
//! ## Where this sits in the bind (cadrage v5 §1.3, §2)
|
||||
//!
|
||||
//! An MCP CLI (Claude Code, Codex) reads a `{command,args}` declaration and
|
||||
//! *spawns* `<exe IdeA> mcp-server --endpoint <…> --project <…> --requester <…>`.
|
||||
//! That spawned process is **this bridge**: it must **bypass Tauri/WebKit** and run
|
||||
//! headless. It speaks JSON-RPC (JSON Lines) to the CLI on **stdin/stdout**, and
|
||||
//! relays every line, byte-for-byte, over the project's **loopback** (the Unix
|
||||
//! socket / Windows named pipe bound by [`crate::state::AppState::ensure_mcp_server`]
|
||||
//! at [`crate::mcp_endpoint::mcp_endpoint`]). The real `McpServer` (which holds the
|
||||
//! `OrchestratorService`) lives in the Tauri process and answers in M5c.
|
||||
//!
|
||||
//! **Zero business logic.** The bridge knows only *stdio + loopback + JSON lines*:
|
||||
//! no `OrchestratorService`, no use case (hexagonal boundary, cadrage §5). It never
|
||||
//! parses the JSON-RPC payloads it carries.
|
||||
//!
|
||||
//! ## Handshake format (consumed by M5c)
|
||||
//!
|
||||
//! Right after connecting to the loopback, **before** any JSON-RPC traffic, the
|
||||
//! bridge writes a **single newline-terminated JSON line** carrying the caller's
|
||||
//! identity:
|
||||
//!
|
||||
//! ```text
|
||||
//! {"project":"<project-id>","requester":"<requester-id>"}\n
|
||||
//! ```
|
||||
//!
|
||||
//! - It is a **distinct line** from the JSON-RPC stream that follows: the server
|
||||
//! (M5c) reads exactly one line off a fresh connection, parses it as this
|
||||
//! handshake, then hands the rest of the stream to `McpServer::serve`.
|
||||
//! - `requester` is the **real agent id** (cadrage §1.4) — it lets the server tag
|
||||
//! `OrchestratorRequestProcessed.requester_id` with the actual agent instead of
|
||||
//! the frozen `"mcp"` placeholder. When `--requester` is omitted the field is the
|
||||
//! empty string.
|
||||
//! - Both values are JSON strings, so any future id shape stays escape-safe.
|
||||
//!
|
||||
//! ## Failure posture (never hang)
|
||||
//!
|
||||
//! - **Endpoint absent / unreachable** ⇒ connect with a **bounded timeout**; on
|
||||
//! timeout or connect error, return a **non-zero** exit code immediately. Never
|
||||
//! block forever waiting for a listener that will never appear.
|
||||
//! - **stdin EOF** (the CLI closed) ⇒ **clean exit, code 0**; the loopback
|
||||
//! connection is dropped (closed) on the way out.
|
||||
//! - **Missing required args** ⇒ a clear error on stderr and a non-zero code.
|
||||
|
||||
use std::process::ExitCode;
|
||||
use std::time::Duration;
|
||||
|
||||
use interprocess::local_socket::tokio::Stream as LocalSocketStream;
|
||||
use interprocess::local_socket::traits::tokio::Stream as _;
|
||||
use interprocess::local_socket::{GenericFilePath, ToFsName as _};
|
||||
use tokio::io::{
|
||||
AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader,
|
||||
};
|
||||
|
||||
/// How long the bridge waits to connect to the project loopback before giving up.
|
||||
/// Bounded so an absent/unreachable endpoint fails fast instead of hanging.
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Parsed `mcp-server` invocation arguments.
|
||||
///
|
||||
/// `--endpoint` is **required** (without it the bridge has nowhere to relay).
|
||||
/// `--project` and `--requester` are optional identity hints forwarded verbatim in
|
||||
/// the handshake; a missing one becomes the empty string.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BridgeArgs {
|
||||
/// The loopback address to connect to (the `--endpoint` value, i.e.
|
||||
/// [`crate::mcp_endpoint::McpEndpoint::as_cli_arg`]).
|
||||
pub endpoint: String,
|
||||
/// The project id (`--project`), forwarded in the handshake. Empty if absent.
|
||||
pub project: String,
|
||||
/// The requesting agent id (`--requester`), forwarded in the handshake. Empty
|
||||
/// if absent.
|
||||
pub requester: String,
|
||||
}
|
||||
|
||||
impl BridgeArgs {
|
||||
/// Parses `mcp-server`'s own arguments (i.e. `argv` **after** the `mcp-server`
|
||||
/// subcommand token). Recognises `--endpoint`, `--project`, `--requester`, each
|
||||
/// taking the following token as its value.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns a human-readable message when `--endpoint` is missing, when a flag is
|
||||
/// given without a value, or when an unknown flag is encountered.
|
||||
pub fn parse<I, S>(args: I) -> Result<Self, String>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<String>,
|
||||
{
|
||||
let mut endpoint: Option<String> = None;
|
||||
let mut project = String::new();
|
||||
let mut requester = String::new();
|
||||
|
||||
let mut it = args.into_iter().map(Into::into);
|
||||
while let Some(flag) = it.next() {
|
||||
match flag.as_str() {
|
||||
"--endpoint" => {
|
||||
endpoint = Some(
|
||||
it.next()
|
||||
.ok_or_else(|| "--endpoint requires a value".to_string())?,
|
||||
);
|
||||
}
|
||||
"--project" => {
|
||||
project = it
|
||||
.next()
|
||||
.ok_or_else(|| "--project requires a value".to_string())?;
|
||||
}
|
||||
"--requester" => {
|
||||
requester = it
|
||||
.next()
|
||||
.ok_or_else(|| "--requester requires a value".to_string())?;
|
||||
}
|
||||
other => {
|
||||
return Err(format!("unknown argument: {other}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let endpoint = endpoint.ok_or_else(|| "--endpoint is required".to_string())?;
|
||||
Ok(Self {
|
||||
endpoint,
|
||||
project,
|
||||
requester,
|
||||
})
|
||||
}
|
||||
|
||||
/// The first handshake line the bridge writes on the loopback (newline
|
||||
/// included). See the module docs for the format consumed by M5c.
|
||||
fn handshake_line(&self) -> Vec<u8> {
|
||||
let line = serde_json::json!({
|
||||
"project": self.project,
|
||||
"requester": self.requester,
|
||||
})
|
||||
.to_string();
|
||||
let mut bytes = line.into_bytes();
|
||||
bytes.push(b'\n');
|
||||
bytes
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronous entry point called from `main`/`lib::run` when `argv[1] ==
|
||||
/// "mcp-server"`. Parses the args, stands up a Tokio runtime, and runs the bridge.
|
||||
///
|
||||
/// Returns the process [`ExitCode`]: `0` on a clean stdin-EOF shutdown, non-zero on
|
||||
/// any argument/connection/relay failure. Never blocks indefinitely (connect is
|
||||
/// time-bounded).
|
||||
#[must_use]
|
||||
pub fn run_mcp_bridge<I, S>(args: I) -> ExitCode
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<String>,
|
||||
{
|
||||
let args = match BridgeArgs::parse(args) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
eprintln!("idea mcp-server: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
// A current-thread runtime is enough: the bridge is two coupled I/O loops, no
|
||||
// CPU work. Keeps the headless process lean (no Tauri, no webview).
|
||||
let runtime = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.enable_time()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
eprintln!("idea mcp-server: failed to start runtime: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
runtime.block_on(async move {
|
||||
match bridge_over_loopback(&args).await {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("idea mcp-server: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Connects to the project loopback at `args.endpoint` (time-bounded) and relays
|
||||
/// between the **real stdio** of this process and that connection.
|
||||
async fn bridge_over_loopback(args: &BridgeArgs) -> Result<(), String> {
|
||||
let conn = connect_loopback(&args.endpoint).await?;
|
||||
// Split the duplex loopback into independent owned halves so the two relay
|
||||
// directions can run without sharing a lock.
|
||||
let (lp_read, lp_write) = tokio::io::split(conn);
|
||||
relay(
|
||||
args,
|
||||
tokio::io::stdin(),
|
||||
tokio::io::stdout(),
|
||||
lp_read,
|
||||
lp_write,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Connects to the loopback endpoint with a bounded timeout. An absent or
|
||||
/// unreachable endpoint surfaces as an `Err` (⇒ non-zero exit), **never a hang**.
|
||||
async fn connect_loopback(endpoint: &str) -> Result<LocalSocketStream, String> {
|
||||
let name = endpoint
|
||||
.to_fs_name::<GenericFilePath>()
|
||||
.map_err(|e| format!("invalid endpoint {endpoint:?}: {e}"))?;
|
||||
|
||||
match tokio::time::timeout(CONNECT_TIMEOUT, LocalSocketStream::connect(name)).await {
|
||||
Ok(Ok(stream)) => Ok(stream),
|
||||
Ok(Err(e)) => Err(format!("cannot connect to endpoint {endpoint:?}: {e}")),
|
||||
Err(_) => Err(format!(
|
||||
"timed out connecting to endpoint {endpoint:?} after {}s",
|
||||
CONNECT_TIMEOUT.as_secs()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The transparent relay, generic over its four streams so tests drive it with
|
||||
/// in-memory pipes (no real stdio, no real socket).
|
||||
///
|
||||
/// Sequence:
|
||||
/// 1. Write the **handshake line** (`project`+`requester`) to the loopback.
|
||||
/// 2. Loop: read one **line** from the CLI (`cli_in`) → write it to the loopback;
|
||||
/// read one **line** from the loopback → write it to the CLI (`cli_out`).
|
||||
/// 3. **CLI stdin EOF** ⇒ stop, return `Ok(())` (clean code 0). Dropping the
|
||||
/// loopback writer closes that direction of the connection.
|
||||
///
|
||||
/// The loop is request/response paced (one CLI line in, one loopback line back),
|
||||
/// matching the JSON Lines `StdioTransport` the server speaks. A loopback that
|
||||
/// closes mid-exchange surfaces as an error (non-zero exit).
|
||||
async fn relay<CIn, COut, LIn, LOut>(
|
||||
args: &BridgeArgs,
|
||||
cli_in: CIn,
|
||||
mut cli_out: COut,
|
||||
lp_read: LIn,
|
||||
mut lp_write: LOut,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
CIn: AsyncRead + Unpin,
|
||||
COut: AsyncWrite + Unpin,
|
||||
LIn: AsyncRead + Unpin,
|
||||
LOut: AsyncWrite + Unpin,
|
||||
{
|
||||
// 1. Handshake (identity) before any JSON-RPC byte.
|
||||
lp_write
|
||||
.write_all(&args.handshake_line())
|
||||
.await
|
||||
.map_err(|e| format!("handshake write failed: {e}"))?;
|
||||
lp_write
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| format!("handshake flush failed: {e}"))?;
|
||||
|
||||
let mut cli_reader = BufReader::new(cli_in);
|
||||
let mut lp_reader = BufReader::new(lp_read);
|
||||
let mut cli_line = String::new();
|
||||
let mut lp_line = String::new();
|
||||
|
||||
loop {
|
||||
// 2a. CLI → loopback (one line). EOF ⇒ clean shutdown.
|
||||
cli_line.clear();
|
||||
let n = cli_reader
|
||||
.read_line(&mut cli_line)
|
||||
.await
|
||||
.map_err(|e| format!("stdin read failed: {e}"))?;
|
||||
if n == 0 {
|
||||
// CLI closed stdin: nothing more to forward. Clean exit.
|
||||
return Ok(());
|
||||
}
|
||||
lp_write
|
||||
.write_all(cli_line.as_bytes())
|
||||
.await
|
||||
.map_err(|e| format!("loopback write failed: {e}"))?;
|
||||
lp_write
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| format!("loopback flush failed: {e}"))?;
|
||||
|
||||
// 2b. loopback → CLI (one line). EOF here is a server hangup mid-exchange.
|
||||
lp_line.clear();
|
||||
let m = lp_reader
|
||||
.read_line(&mut lp_line)
|
||||
.await
|
||||
.map_err(|e| format!("loopback read failed: {e}"))?;
|
||||
if m == 0 {
|
||||
return Err("loopback closed before answering".to_string());
|
||||
}
|
||||
cli_out
|
||||
.write_all(lp_line.as_bytes())
|
||||
.await
|
||||
.map_err(|e| format!("stdout write failed: {e}"))?;
|
||||
cli_out
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| format!("stdout flush failed: {e}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
use interprocess::local_socket::tokio::Listener as LocalSocketListener;
|
||||
use interprocess::local_socket::traits::tokio::Listener as _;
|
||||
use interprocess::local_socket::{GenericFilePath, ListenerOptions};
|
||||
use tokio::io::AsyncReadExt as _;
|
||||
|
||||
// ---- argument parsing -------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parses_all_three_flags() {
|
||||
let a = BridgeArgs::parse([
|
||||
"--endpoint", "/tmp/x.sock", "--project", "p1", "--requester", "agent-7",
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(a.endpoint, "/tmp/x.sock");
|
||||
assert_eq!(a.project, "p1");
|
||||
assert_eq!(a.requester, "agent-7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_is_required() {
|
||||
let err = BridgeArgs::parse(["--project", "p1"]).unwrap_err();
|
||||
assert!(err.contains("--endpoint"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_and_requester_default_to_empty() {
|
||||
let a = BridgeArgs::parse(["--endpoint", "/tmp/x.sock"]).unwrap();
|
||||
assert_eq!(a.project, "");
|
||||
assert_eq!(a.requester, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flag_without_value_is_an_error() {
|
||||
assert!(BridgeArgs::parse(["--endpoint"]).is_err());
|
||||
assert!(BridgeArgs::parse(["--endpoint", "/x", "--project"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_flag_is_an_error() {
|
||||
let err = BridgeArgs::parse(["--endpoint", "/x", "--bogus"]).unwrap_err();
|
||||
assert!(err.contains("unknown"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handshake_line_carries_identity_as_json() {
|
||||
let a = BridgeArgs {
|
||||
endpoint: "/tmp/x.sock".into(),
|
||||
project: "proj".into(),
|
||||
requester: "rq".into(),
|
||||
};
|
||||
let line = a.handshake_line();
|
||||
assert_eq!(*line.last().unwrap(), b'\n');
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_slice(&line[..line.len() - 1]).unwrap();
|
||||
assert_eq!(v["project"], "proj");
|
||||
assert_eq!(v["requester"], "rq");
|
||||
}
|
||||
|
||||
// ---- relay (in-memory streams, no socket) -----------------------------
|
||||
|
||||
/// Nominal relay over in-memory duplex pipes: handshake reaches the fake
|
||||
/// server, a scripted request is forwarded, and the canned response comes back
|
||||
/// on the CLI stdout.
|
||||
#[tokio::test]
|
||||
async fn relay_forwards_handshake_request_and_response() {
|
||||
// CLI stdin: one JSON-RPC request line, then EOF.
|
||||
let cli_in = b"{\"method\":\"tools/call\",\"id\":1}\n".to_vec();
|
||||
let mut cli_out: Vec<u8> = Vec::new();
|
||||
|
||||
// The "loopback" is a duplex pipe: the bridge writes to `lp_write`/reads
|
||||
// from `lp_read`; the fake server reads from `srv_read`/writes to
|
||||
// `srv_write`.
|
||||
let (lp_read, srv_write) = tokio::io::duplex(4096); // server → bridge
|
||||
let (srv_read, lp_write) = tokio::io::duplex(4096); // bridge → server
|
||||
|
||||
let args = BridgeArgs {
|
||||
endpoint: "unused".into(),
|
||||
project: "proj-1".into(),
|
||||
requester: "agent-9".into(),
|
||||
};
|
||||
|
||||
// Fake server: read handshake line, then read the request line, then answer.
|
||||
let server = tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(srv_read);
|
||||
let mut handshake = String::new();
|
||||
reader.read_line(&mut handshake).await.unwrap();
|
||||
let mut request = String::new();
|
||||
reader.read_line(&mut request).await.unwrap();
|
||||
|
||||
let mut w = srv_write;
|
||||
w.write_all(b"{\"result\":\"ok\",\"id\":1}\n").await.unwrap();
|
||||
w.flush().await.unwrap();
|
||||
(handshake, request)
|
||||
});
|
||||
|
||||
relay(
|
||||
&args,
|
||||
&cli_in[..],
|
||||
&mut cli_out,
|
||||
lp_read,
|
||||
lp_write,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (handshake, request) = server.await.unwrap();
|
||||
let hs: serde_json::Value =
|
||||
serde_json::from_str(handshake.trim_end()).unwrap();
|
||||
assert_eq!(hs["project"], "proj-1");
|
||||
assert_eq!(hs["requester"], "agent-9");
|
||||
assert_eq!(request.trim_end(), "{\"method\":\"tools/call\",\"id\":1}");
|
||||
assert_eq!(
|
||||
String::from_utf8(cli_out).unwrap(),
|
||||
"{\"result\":\"ok\",\"id\":1}\n"
|
||||
);
|
||||
}
|
||||
|
||||
/// stdin EOF with no request ⇒ relay returns `Ok` (code 0), having still sent
|
||||
/// the handshake.
|
||||
#[tokio::test]
|
||||
async fn relay_clean_exit_on_immediate_eof() {
|
||||
let cli_in: &[u8] = b""; // immediate EOF
|
||||
let mut cli_out: Vec<u8> = Vec::new();
|
||||
|
||||
let (lp_read, srv_write) = tokio::io::duplex(4096);
|
||||
let (srv_read, lp_write) = tokio::io::duplex(4096);
|
||||
|
||||
let args = BridgeArgs {
|
||||
endpoint: "unused".into(),
|
||||
project: "p".into(),
|
||||
requester: String::new(),
|
||||
};
|
||||
|
||||
let server = tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(srv_read);
|
||||
let mut handshake = String::new();
|
||||
reader.read_line(&mut handshake).await.unwrap();
|
||||
// Keep srv_write alive until the bridge drops its writer (EOF).
|
||||
let mut buf = Vec::new();
|
||||
let _ = reader.read_to_end(&mut buf).await;
|
||||
drop(srv_write);
|
||||
handshake
|
||||
});
|
||||
|
||||
relay(&args, cli_in, &mut cli_out, lp_read, lp_write)
|
||||
.await
|
||||
.expect("clean EOF exit");
|
||||
|
||||
let handshake = server.await.unwrap();
|
||||
assert!(handshake.contains("\"project\":\"p\""));
|
||||
assert!(cli_out.is_empty(), "no response expected on immediate EOF");
|
||||
}
|
||||
|
||||
// ---- end-to-end over a real loopback ----------------------------------
|
||||
|
||||
fn temp_endpoint(tag: &str) -> String {
|
||||
let dir = std::env::temp_dir();
|
||||
let pid = std::process::id();
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
dir.join(format!("idea-mcp-bridge-test-{tag}-{pid}-{nanos}.sock"))
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
fn bind(endpoint: &str) -> LocalSocketListener {
|
||||
let name = endpoint.to_fs_name::<GenericFilePath>().unwrap();
|
||||
ListenerOptions::new()
|
||||
.name(name)
|
||||
.reclaim_name(true)
|
||||
.create_tokio()
|
||||
.expect("bind test listener")
|
||||
}
|
||||
|
||||
/// Endpoint absent ⇒ `connect_loopback` fails fast (no hang), so the bridge
|
||||
/// would exit non-zero. Bounded by a test timeout well under CONNECT_TIMEOUT
|
||||
/// for the "connect error" path (a non-existent socket errors immediately).
|
||||
#[tokio::test]
|
||||
async fn connect_to_absent_endpoint_errors_fast() {
|
||||
let endpoint = temp_endpoint("absent");
|
||||
let res = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
connect_loopback(&endpoint),
|
||||
)
|
||||
.await
|
||||
.expect("connect_loopback must not hang");
|
||||
assert!(res.is_err(), "absent endpoint must yield an error");
|
||||
}
|
||||
|
||||
/// `run_mcp_bridge` with a missing `--endpoint` returns a non-zero code.
|
||||
#[test]
|
||||
fn run_bridge_missing_endpoint_is_non_zero() {
|
||||
let code = run_mcp_bridge(["--project", "p"]);
|
||||
assert_eq!(code, ExitCode::FAILURE);
|
||||
}
|
||||
|
||||
/// Full end-to-end over a **real** interprocess loopback: a test listener plays
|
||||
/// the server (reads the handshake, echoes a canned response); the relay drives
|
||||
/// it over an actual connection. Verifies the handshake crosses the real socket
|
||||
/// and the response returns on the CLI stdout.
|
||||
#[tokio::test]
|
||||
async fn end_to_end_over_real_loopback() {
|
||||
let endpoint = temp_endpoint("e2e");
|
||||
let listener = bind(&endpoint);
|
||||
|
||||
let captured = Arc::new(tokio::sync::Mutex::new((String::new(), String::new())));
|
||||
let captured_srv = Arc::clone(&captured);
|
||||
|
||||
// Fake server accepts one connection, reads handshake + request, answers.
|
||||
let server = tokio::spawn(async move {
|
||||
let conn = listener.accept().await.unwrap();
|
||||
let (r, w) = tokio::io::split(conn);
|
||||
let mut reader = BufReader::new(r);
|
||||
let mut handshake = String::new();
|
||||
reader.read_line(&mut handshake).await.unwrap();
|
||||
let mut request = String::new();
|
||||
reader.read_line(&mut request).await.unwrap();
|
||||
*captured_srv.lock().await = (handshake, request);
|
||||
|
||||
let mut w = w;
|
||||
w.write_all(b"{\"result\":\"pong\",\"id\":42}\n")
|
||||
.await
|
||||
.unwrap();
|
||||
w.flush().await.unwrap();
|
||||
// Hold until the bridge finishes (its stdin EOF will end it).
|
||||
let mut rest = Vec::new();
|
||||
let _ = reader.read_to_end(&mut rest).await;
|
||||
});
|
||||
|
||||
let args = BridgeArgs {
|
||||
endpoint: endpoint.clone(),
|
||||
project: "proj-e2e".into(),
|
||||
requester: "agent-e2e".into(),
|
||||
};
|
||||
|
||||
let cli_in = b"{\"method\":\"ping\",\"id\":42}\n".to_vec();
|
||||
let mut cli_out: Vec<u8> = Vec::new();
|
||||
|
||||
// Drive the whole bridge_over_loopback path (real connect + split + relay),
|
||||
// but feed our own stdio streams via `relay` to avoid touching process std.
|
||||
let conn = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
connect_loopback(&endpoint),
|
||||
)
|
||||
.await
|
||||
.expect("no hang")
|
||||
.expect("connect to live endpoint");
|
||||
let (lp_read, lp_write) = tokio::io::split(conn);
|
||||
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
relay(&args, &cli_in[..], &mut cli_out, lp_read, lp_write),
|
||||
)
|
||||
.await
|
||||
.expect("relay must not hang")
|
||||
.expect("relay ok");
|
||||
|
||||
server.await.unwrap();
|
||||
|
||||
let (handshake, request) = captured.lock().await.clone();
|
||||
let hs: serde_json::Value =
|
||||
serde_json::from_str(handshake.trim_end()).unwrap();
|
||||
assert_eq!(hs["project"], "proj-e2e");
|
||||
assert_eq!(hs["requester"], "agent-e2e");
|
||||
assert_eq!(request.trim_end(), "{\"method\":\"ping\",\"id\":42}");
|
||||
assert_eq!(
|
||||
String::from_utf8(cli_out).unwrap(),
|
||||
"{\"result\":\"pong\",\"id\":42}\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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