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:
34
Cargo.lock
generated
34
Cargo.lock
generated
@ -75,6 +75,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"domain",
|
||||
"infrastructure",
|
||||
"interprocess",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
@ -847,6 +848,12 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "doctest-file"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2db04e74f0a9a93103b50e90b96024c9b2bdca8bce6a632ec71b88736d3d359"
|
||||
|
||||
[[package]]
|
||||
name = "document-features"
|
||||
version = "0.2.12"
|
||||
@ -1946,6 +1953,21 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "interprocess"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "069323743400cb7ab06a8fe5c1ed911d36b6919ec531661d034c89083629595b"
|
||||
dependencies = [
|
||||
"doctest-file",
|
||||
"futures-core",
|
||||
"libc",
|
||||
"recvmsg",
|
||||
"tokio",
|
||||
"widestring",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.0"
|
||||
@ -3236,6 +3258,12 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "recvmsg"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175"
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
@ -5274,6 +5302,12 @@ dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "widestring"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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?;
|
||||
|
||||
|
||||
@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -611,6 +611,7 @@ fn launch_input(agent_id: AgentId) -> LaunchAgentInput {
|
||||
cols: 80,
|
||||
node_id: None,
|
||||
conversation_id: None,
|
||||
mcp_runtime: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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]
|
||||
|
||||
Reference in New Issue
Block a user