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

@ -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

View File

@ -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)?;

View File

@ -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

View File

@ -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()
}

View 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"
);
}
}

View 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

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);
}

View File

@ -512,6 +512,11 @@ impl ChangeAgentProfile {
// Conversation id discarded: the previous one belonged to the old
// engine; the new profile starts (or assigns) a fresh one.
conversation_id: None,
// Internal relaunch (no app-tauri composition root in scope) ⇒ the
// MCP runtime is not injected here; `apply_mcp_config` falls back to
// the minimal declaration. A profile-hot-swap that needs the real
// endpoint is re-driven through the app-tauri launch path.
mcp_runtime: None,
})
.await?;
Ok(Some(output.session))
@ -637,6 +642,50 @@ pub struct LaunchAgentInput {
/// when the profile supports it). The caller (which owns the layout) reads this
/// from the leaf's [`domain::layout::LeafCell::conversation_id`].
pub conversation_id: Option<String>,
/// Runtime facts needed to write the **real** IdeA MCP server declaration
/// (M5d). These are **OS/runtime data** (the IdeA executable path, the
/// project's loopback endpoint) that live in `app-tauri` — they are *injected
/// as data* from the composition root, never computed in `application` (which
/// must not depend on `app-tauri`, cadrage v5 §0.3 / §7).
///
/// `None` ⇒ no runtime injected (launches issued from inside `application`:
/// the orchestrator's `spawn_agent`/`ask_agent`, a profile hot-swap relaunch,
/// or tests). In that case [`apply_mcp_config`](LaunchAgent::apply_mcp_config)
/// still honours the profile's `McpConfigStrategy`, but falls back to a
/// **coherent minimal** declaration (the `idea mcp-server` command without the
/// endpoint/project/requester args) rather than a project-bound one — see
/// [`mcp_server_declaration`].
pub mcp_runtime: Option<McpRuntime>,
}
/// OS/runtime facts injected by the composition root (`app-tauri`) to materialise
/// the **real** IdeA MCP server declaration in an agent's `.mcp.json` (cadrage v5
/// §2). Carrying these as **plain data** on [`LaunchAgentInput`] keeps
/// `application` free of any `app-tauri` / `current_exe` / `mcp_endpoint`
/// dependency: the endpoint stays computed by the *single source of truth*
/// (`app-tauri::mcp_endpoint`) and only its **string** crosses the layer boundary.
///
/// All four fields are the exact strings the spawned bridge expects on its command
/// line (`<exe> mcp-server --endpoint <endpoint> --project <project_id> --requester
/// <requester>`); the `project_id` must already be in the **hyphen-free 32-hex
/// `simple` form** consumed by the M5c handshake guard (`serve_peer`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpRuntime {
/// Absolute path to the IdeA executable (`std::env::current_exe()`), used as the
/// declaration's `command` — the CLI spawns *this* binary in its `mcp-server`
/// bridge mode.
pub exe: String,
/// The project's loopback endpoint string (`mcp_endpoint(project).as_cli_arg()`),
/// passed as `--endpoint`. **Same source of truth** as the listener bound by
/// `ensure_mcp_server` (cadrage v5 §2 coherence invariant).
pub endpoint: String,
/// The project id in the **hyphen-free 32-hex `simple` form** consumed by the
/// M5c handshake guard, passed as `--project`.
pub project_id: String,
/// The launching agent's id, passed as `--requester` so the server tags
/// `OrchestratorRequestProcessed.requester_id` with the real agent (cadrage v5
/// §1.4) instead of the frozen `"mcp"` placeholder.
pub requester: String,
}
/// Descripteur d'une session **structurée** (IA, cellule chat) démarrée par
@ -1042,7 +1091,8 @@ impl LaunchAgent {
// IdeA matérialise SA config MCP au format de CETTE CLI, dans le **même**
// run dir isolé que le convention file et le seed de permissions. `None`
// ⇒ aucun write/flag/env (chemin actuel inchangé, zéro régression).
self.apply_mcp_config(&profile, &run_dir, &mut spec).await;
self.apply_mcp_config(&profile, &run_dir, input.mcp_runtime.as_ref(), &mut spec)
.await;
// 5b. ── POINT DE ROUTAGE §17.4 : IA structuré vs terminal brut ──
// Le convention file (CLAUDE.md / AGENTS.md) vient d'être écrit dans le
@ -1331,14 +1381,18 @@ impl LaunchAgent {
/// `profile.mcp == None` ⇒ no-op: no write, no flag, no env (current path, zero
/// regression).
///
/// Note (M1): the embedded server declaration (command + transport) is a coherent
/// **placeholder** — the real IdeA MCP server and its exact launch command land in
/// M2/M3. The strategy plumbing here is stable; only the declaration content will
/// be finalised then.
/// MCP runtime (M5d): when the composition root injects a [`McpRuntime`], the
/// declaration written for a `ConfigFile` strategy is the **real** one — it points
/// the spawned `idea mcp-server` bridge at the project's exact loopback endpoint
/// (same source of truth as `ensure_mcp_server`, cadrage v5 §2). When no runtime is
/// injected (launches issued from inside `application`), a coherent **minimal**
/// declaration is written instead (see [`mcp_server_declaration`]). `Flag`/`Env`
/// are unaffected by the runtime: they keep passing the run-dir config path.
async fn apply_mcp_config(
&self,
profile: &AgentProfile,
run_dir: &ProjectPath,
runtime: Option<&McpRuntime>,
spec: &mut SpawnSpec,
) {
let Some(mcp) = &profile.mcp else {
@ -1351,7 +1405,7 @@ impl LaunchAgent {
match self.fs.exists(&path).await {
Ok(true) => {}
Ok(false) => {
let declaration = mcp_server_declaration(mcp.transport);
let declaration = mcp_server_declaration(mcp.transport, runtime);
// Best-effort: a write failure must not fail the launch.
let _ = self.fs.write(&path, declaration.as_bytes()).await;
}
@ -1448,24 +1502,67 @@ fn reattach_decision(
}
/// Builds the IdeA MCP server declaration written into a CLI's `ConfigFile` MCP
/// config (cadrage v3, Décision 3). Coherent **placeholder** for M1: it describes a
/// stdio/socket server launched by the `idea` binary — the exact command and the
/// real server land in **M2/M3**. Kept pure (no I/O) so it is unit-testable.
/// config (cadrage v3 D3 ; cadrage v5 §2). Kept pure (no I/O) so it is unit-testable.
///
/// Two shapes, by whether the composition root injected a [`McpRuntime`]:
///
/// - **`Some(runtime)` — the real declaration (M5d, end of the placeholder)**:
/// `command = runtime.exe` (the IdeA executable), and `args` carry the
/// `mcp-server` subcommand plus the project's exact loopback `--endpoint`, its
/// `--project` (hyphen-free 32-hex form, consumed by the M5c guard) and the
/// launching agent as `--requester`. The endpoint string comes verbatim from the
/// **single source of truth** (`app-tauri::mcp_endpoint`), so the bridge connects
/// to exactly what `ensure_mcp_server` listens on.
///
/// - **`None` — coherent minimal declaration**: launches issued from inside
/// `application` (orchestrator/hot-swap/tests) have no OS/runtime facts to inject;
/// rather than fabricate a project-bound endpoint, we write the `idea mcp-server`
/// command without the endpoint/project/requester args. It stays a valid,
/// self-consistent `mcpServers/idea` entry (the bridge would resolve the endpoint
/// from its own project context), surfacing `transport` for the future socket path.
#[must_use]
fn mcp_server_declaration(transport: domain::profile::McpTransport) -> String {
fn mcp_server_declaration(
transport: domain::profile::McpTransport,
runtime: Option<&McpRuntime>,
) -> String {
// Common `.mcp.json`-style shape (Claude Code et CLIs apparentées). The transport
// is surfaced so a socket-based CLI can be wired in M2 without changing M1's seam.
// is surfaced so a socket-based CLI can be wired later without changing this seam.
let transport_label = match transport {
domain::profile::McpTransport::Stdio => "stdio",
domain::profile::McpTransport::Socket => "socket",
};
// `command` + extra args depend on whether OS/runtime facts were injected.
// Each `args` entry is emitted as an escaped JSON string so an exe path or
// endpoint with spaces/backslashes/quotes stays valid JSON.
let (command, extra_args) = match runtime {
Some(rt) => {
let arg = |label: &str, value: &str| {
format!(
",\n {},\n {}",
json_string(label),
json_string(value)
)
};
let extra = format!(
"{}{}{}",
arg("--endpoint", &rt.endpoint),
arg("--project", &rt.project_id),
arg("--requester", &rt.requester),
);
(rt.exe.as_str(), extra)
}
None => ("idea", String::new()),
};
let command = json_string(command);
format!(
r#"{{
"mcpServers": {{
"idea": {{
"command": "idea",
"command": {command},
"args": [
"mcp-server"
"mcp-server"{extra_args}
],
"transport": "{transport_label}"
}}
@ -1475,6 +1572,13 @@ fn mcp_server_declaration(transport: domain::profile::McpTransport) -> String {
)
}
/// Wraps a string as a JSON string literal (quotes + escaping), reusing the path
/// escaper so an exe path / endpoint with spaces, backslashes or quotes stays valid
/// JSON in the generated `.mcp.json`.
fn json_string(s: &str) -> String {
format!("\"{}\"", json_escape(s))
}
/// Builds an absolute path string by joining a [`ProjectPath`] with a relative
/// segment using a POSIX separator.
fn join(base: &ProjectPath, rel: &str) -> String {

View File

@ -26,7 +26,7 @@ pub use resume::{
pub use lifecycle::{
ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch,
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, LaunchAgent,
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput,
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, McpRuntime,
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredSessionDescriptor,
UpdateAgentContext,
UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET,

View File

@ -34,7 +34,7 @@ pub use agent::{
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput,
InspectConversation, InspectConversationInput, InspectConversationOutput, LaunchAgent,
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput,
ListProfiles, ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput,
ListProfiles, ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, McpRuntime,
ListResumableAgentsOutput, ProfileAvailability, ReadAgentContext, ReadAgentContextInput,
ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile,
SaveProfileInput, SaveProfileOutput, StructuredSessionDescriptor, UpdateAgentContext,

View File

@ -308,6 +308,10 @@ impl OrchestratorService {
cols: DEFAULT_COLS,
node_id,
conversation_id: None,
// Orchestrator-driven launch (inside `application`): no OS/runtime
// facts to inject here; the real MCP declaration is written when the
// agent is (re)launched through the app-tauri composition root.
mcp_runtime: None,
})
.await?;
@ -413,6 +417,9 @@ impl OrchestratorService {
cols: DEFAULT_COLS,
node_id: None,
conversation_id: None,
// ask_agent revival launch (inside `application`): MCP runtime not
// injected here (see the `spawn_agent` note above).
mcp_runtime: None,
})
.await?;

View File

@ -812,6 +812,7 @@ fn launch_input(agent_id: AgentId) -> LaunchAgentInput {
cols: 80,
node_id: None,
conversation_id: None,
mcp_runtime: None,
}
}
@ -1726,6 +1727,258 @@ async fn launch_mcp_env_appends_var_and_path_to_env() {
);
}
// ---------------------------------------------------------------------------
// LaunchAgent — MCP runtime declaration (M5d, cadrage v5 §2)
// ---------------------------------------------------------------------------
/// A `LaunchAgentInput` carrying an injected [`McpRuntime`], otherwise identical
/// to [`launch_input`]. Lets the M5d tests drive the real declaration path while
/// reusing the M1 harness (`launch_fixture_with_profile`, `FakeFs` write trace).
fn launch_input_with_runtime(
agent_id: AgentId,
runtime: application::McpRuntime,
) -> LaunchAgentInput {
LaunchAgentInput {
mcp_runtime: Some(runtime),
..launch_input(agent_id)
}
}
/// Reads the single `.mcp.json` written by a launch, parsing it as JSON. Panics
/// with a clear message if zero or several were written, so an unexpected write
/// shape is surfaced rather than silently picked.
fn read_single_mcp_json(fs: &FakeFs) -> serde_json::Value {
let mcp = writes_ending_with(fs, "/.mcp.json");
assert_eq!(mcp.len(), 1, "exactly one .mcp.json must be written");
let body = String::from_utf8(mcp[0].1.clone()).unwrap();
serde_json::from_str(&body)
.unwrap_or_else(|e| panic!("written .mcp.json must be valid JSON ({e}): {body}"))
}
#[tokio::test]
async fn launch_mcp_runtime_writes_real_declaration_with_ordered_args() {
// M5d-1 — ConfigFile{".mcp.json"} + an injected McpRuntime ⇒ the written
// declaration is the REAL one: command == exe, and args == the ordered
// ["mcp-server","--endpoint",endpoint,"--project",project_id,"--requester",
// requester]. Structure-asserted via parsed JSON (not a fragile string match).
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) =
launch_fixture_with_profile(
profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()),
Some(ContextInjectionPlan::File {
target: "CLAUDE.md".to_owned(),
}),
);
let exe = "/abs/idea";
let endpoint = "/run/idea-mcp/abc.sock";
// project_id in the hyphen-free 32-hex `simple` form consumed by the M5c guard
// (`as_uuid().simple()` — see the coherence note / M5e smoke e2e below).
let project_id = "0123456789abcdef0123456789abcdef";
let requester = "agent-7";
let runtime = application::McpRuntime {
exe: exe.to_owned(),
endpoint: endpoint.to_owned(),
project_id: project_id.to_owned(),
requester: requester.to_owned(),
};
launch
.execute(launch_input_with_runtime(agent.id, runtime))
.await
.unwrap();
let doc = read_single_mcp_json(&fs);
let server = &doc["mcpServers"]["idea"];
assert_eq!(
server["command"].as_str(),
Some(exe),
"command must be the injected exe, got: {doc}"
);
let args: Vec<&str> = server["args"]
.as_array()
.expect("args must be a JSON array")
.iter()
.map(|v| v.as_str().expect("each arg is a JSON string"))
.collect();
assert_eq!(
args,
vec![
"mcp-server",
"--endpoint",
endpoint,
"--project",
project_id,
"--requester",
requester,
],
"args must carry the ordered mcp-server flags, got: {args:?}"
);
}
#[tokio::test]
async fn launch_mcp_runtime_special_chars_stay_valid_json() {
// M5d-2 — an exe/endpoint with a space and a backslash ⇒ the .mcp.json stays
// valid JSON (parse OK, asserted by read_single_mcp_json) and the values are
// faithfully preserved (correct escaping, no corruption).
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) =
launch_fixture_with_profile(
profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()),
Some(ContextInjectionPlan::File {
target: "CLAUDE.md".to_owned(),
}),
);
let exe = r"C:\Program Files\IdeA\idea.exe";
let endpoint = r"/tmp/idea mcp/a b\c.sock";
let runtime = application::McpRuntime {
exe: exe.to_owned(),
endpoint: endpoint.to_owned(),
project_id: "0123456789abcdef0123456789abcdef".to_owned(),
requester: "agent X".to_owned(),
};
launch
.execute(launch_input_with_runtime(agent.id, runtime))
.await
.unwrap();
// Parsing already proves the JSON is valid despite the special chars.
let doc = read_single_mcp_json(&fs);
let server = &doc["mcpServers"]["idea"];
assert_eq!(
server["command"].as_str(),
Some(exe),
"exe with backslashes/spaces must round-trip faithfully"
);
let args: Vec<&str> = server["args"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(
args.get(2).copied(),
Some(endpoint),
"endpoint with a space and a backslash must round-trip faithfully, got: {args:?}"
);
}
#[tokio::test]
async fn launch_mcp_runtime_none_writes_minimal_declaration() {
// M5d-3 — mcp_runtime = None + an MCP profile ⇒ the minimal declaration is
// written: command == "idea", args == ["mcp-server"]. Still valid JSON.
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) =
launch_fixture_with_profile(
profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()),
Some(ContextInjectionPlan::File {
target: "CLAUDE.md".to_owned(),
}),
);
// launch_input has mcp_runtime: None.
launch.execute(launch_input(agent.id)).await.unwrap();
let doc = read_single_mcp_json(&fs);
let server = &doc["mcpServers"]["idea"];
assert_eq!(
server["command"].as_str(),
Some("idea"),
"the minimal declaration must use the `idea` command, got: {doc}"
);
let args: Vec<&str> = server["args"]
.as_array()
.expect("args must be a JSON array")
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(
args,
vec!["mcp-server"],
"the minimal declaration must carry only the mcp-server subcommand, got: {args:?}"
);
}
#[tokio::test]
async fn launch_mcp_runtime_with_no_mcp_profile_writes_nothing() {
// M5d-4 — a profile WITHOUT MCP ⇒ no .mcp.json is written, even though a
// runtime is supplied (unchanged from M1: mcp == None short-circuits).
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture(
ContextInjection::convention_file("CLAUDE.md").unwrap(),
Some(ContextInjectionPlan::File {
target: "CLAUDE.md".to_owned(),
}),
);
let runtime = application::McpRuntime {
exe: "/abs/idea".to_owned(),
endpoint: "/run/idea-mcp/abc.sock".to_owned(),
project_id: "0123456789abcdef0123456789abcdef".to_owned(),
requester: "agent-7".to_owned(),
};
launch
.execute(launch_input_with_runtime(agent.id, runtime))
.await
.unwrap();
assert!(
writes_ending_with(&fs, "/.mcp.json").is_empty(),
"no MCP config file when the profile carries no McpCapability"
);
}
#[tokio::test]
async fn launch_mcp_runtime_is_non_clobbering() {
// M5d-5 — a pre-existing .mcp.json is NOT overwritten, even when a real runtime
// is injected (unchanged non-clobbering contract from M1).
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) =
launch_fixture_with_profile(
profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()),
Some(ContextInjectionPlan::File {
target: "CLAUDE.md".to_owned(),
}),
);
let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
fs.mark_existing(&format!("{run_dir}/.mcp.json"));
let runtime = application::McpRuntime {
exe: "/abs/idea".to_owned(),
endpoint: "/run/idea-mcp/abc.sock".to_owned(),
project_id: "0123456789abcdef0123456789abcdef".to_owned(),
requester: "agent-7".to_owned(),
};
launch
.execute(launch_input_with_runtime(agent.id, runtime))
.await
.unwrap();
assert!(
writes_ending_with(&fs, "/.mcp.json").is_empty(),
"an existing .mcp.json must not be clobbered even with an injected runtime"
);
}
#[test]
fn mcp_runtime_project_id_uses_simple_uuid_form() {
// M5d-6 (coherence, pure) — app-tauri builds the McpRuntime.project_id as
// `project.id.as_uuid().simple().to_string()` (commands.rs::launch_agent),
// i.e. the hyphen-free 32-hex `simple` form the M5c handshake guard
// (`serve_peer`) compares against. The McpRuntime construction is inlined in
// the Tauri command (not an extractable pure fn), so we cannot unit-test the
// wiring here without an AppState. Instead we PIN the format contract the M5d
// tests above rely on, and flag that the end-to-end injection coherence
// (--endpoint == mcp_endpoint(..).as_cli_arg() and --project == this form) is
// exercised by the M5e smoke e2e.
let uuid = Uuid::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef);
let simple = uuid.as_simple().to_string();
assert_eq!(simple, "0123456789abcdef0123456789abcdef");
assert!(
!simple.contains('-') && simple.len() == 32,
"the `simple` form is hyphen-free 32-hex, matching the project_id used in the M5d tests"
);
}
// ---------------------------------------------------------------------------
// LaunchAgent — session resume (T4)
// ---------------------------------------------------------------------------

View File

@ -611,6 +611,7 @@ fn launch_input(agent_id: AgentId) -> LaunchAgentInput {
cols: 80,
node_id: None,
conversation_id: None,
mcp_runtime: None,
}
}

View File

@ -47,6 +47,12 @@ pub struct McpServer {
/// delegation arrived through the MCP door. `None` ⇒ no publication (the server
/// still works), so existing call sites stay valid.
events: Option<Arc<dyn Fn(DomainEvent) + Send + Sync>>,
/// Identity of the connected peer — the real agent id carried in the loopback
/// handshake (cadrage v5 §1.4). When non-empty it becomes
/// [`DomainEvent::OrchestratorRequestProcessed`]'s `requester_id` instead of the
/// frozen `"mcp"` placeholder; empty ⇒ the legacy `"mcp"` label (back-compat for
/// M2 callers and connections that arrive without a requester).
requester: String,
}
impl McpServer {
@ -59,6 +65,7 @@ impl McpServer {
service,
project,
events: None,
requester: String::new(),
}
}
@ -72,6 +79,35 @@ impl McpServer {
self
}
/// Returns a per-connection clone of this server tagged with the connected
/// peer's `requester` id (the loopback handshake's `requester`, cadrage v5 §1.4).
///
/// The base server (one per project, M3) is shared by every connection; each
/// accepted peer derives its own contextualized server so concurrent peers never
/// share a requester. An empty `requester` keeps the legacy `"mcp"` label.
///
/// Cheap: only `Arc`s, a `Project` clone, and the requester `String` are copied.
#[must_use]
pub fn for_requester(&self, requester: impl Into<String>) -> Self {
Self {
service: Arc::clone(&self.service),
project: self.project.clone(),
events: self.events.clone(),
requester: requester.into(),
}
}
/// Serves JSON-RPC messages from `transport`, tagging every processed
/// `tools/call` with `requester` as the delegating agent (cadrage v5 §1.4).
///
/// A thin wrapper over [`serve`](Self::serve) on a per-connection
/// [`for_requester`](Self::for_requester) clone: the caller (the per-peer accept
/// loop) need not mutate the shared project server. An empty `requester` yields
/// the legacy `"mcp"` label.
pub async fn serve_as<T: Transport>(&self, requester: impl Into<String>, transport: &mut T) {
self.for_requester(requester).serve(transport).await;
}
/// Serves JSON-RPC messages from `transport` until it closes (clean EOF).
///
/// Every inbound line is handled in isolation: a malformed line or an unknown
@ -211,12 +247,18 @@ impl McpServer {
/// Publishes [`DomainEvent::OrchestratorRequestProcessed`] for a handled
/// `tools/call`, tagged [`OrchestrationSource::Mcp`]. The requester id is the
/// stable `"mcp"` label until the per-session transport bind (open point S-MCP)
/// carries the connected agent's identity. No-op without an event sink.
/// connected peer's real agent id (carried in the loopback handshake, cadrage v5
/// §1.4), falling back to the legacy `"mcp"` label when no identity was supplied.
/// No-op without an event sink.
fn publish_processed(&self, action: &str, ok: bool) {
if let Some(publish) = &self.events {
let requester_id = if self.requester.is_empty() {
"mcp".to_owned()
} else {
self.requester.clone()
};
publish(DomainEvent::OrchestratorRequestProcessed {
requester_id: "mcp".to_owned(),
requester_id,
action: action.to_owned(),
ok,
source: OrchestrationSource::Mcp,

View File

@ -42,6 +42,18 @@ where
writer,
}
}
/// Wraps an **already-buffered** reader plus a writer as a line-delimited JSON
/// transport.
///
/// Used by the per-peer accept loop (M5c): the handshake line (cadrage v5 §1.4)
/// is read off a `BufReader` over the loopback's read half *before* serving, and
/// that same `BufReader` — carrying any bytes already buffered past the handshake
/// — is handed here so no JSON-RPC byte is lost between the handshake and the
/// serve loop.
pub fn from_buffered(reader: BufReader<R>, writer: W) -> Self {
Self { reader, writer }
}
}
#[async_trait::async_trait]