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",
|
"async-trait",
|
||||||
"domain",
|
"domain",
|
||||||
"infrastructure",
|
"infrastructure",
|
||||||
|
"interprocess",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
@ -847,6 +848,12 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "document-features"
|
name = "document-features"
|
||||||
version = "0.2.12"
|
version = "0.2.12"
|
||||||
@ -1946,6 +1953,21 @@ dependencies = [
|
|||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.0"
|
version = "2.12.0"
|
||||||
@ -3236,6 +3258,12 @@ dependencies = [
|
|||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "recvmsg"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
@ -5274,6 +5302,12 @@ dependencies = [
|
|||||||
"windows-core 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "winapi"
|
name = "winapi"
|
||||||
version = "0.3.9"
|
version = "0.3.9"
|
||||||
|
|||||||
@ -25,11 +25,18 @@ application = { workspace = true }
|
|||||||
infrastructure = { workspace = true }
|
infrastructure = { workspace = true }
|
||||||
tauri = { workspace = true }
|
tauri = { workspace = true }
|
||||||
tauri-plugin-dialog = { 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 = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
uuid = { 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]
|
[features]
|
||||||
# Passthrough toggles to enable the real embedders in an IDE build. OFF by default
|
# 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,
|
DetectAgentDriftInput, GetMemoryInput, GitBranchesInput, GitCheckoutInput, GitCommitInput,
|
||||||
GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput,
|
GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput,
|
||||||
InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListLayoutsInput, LiveSessions,
|
InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListLayoutsInput, LiveSessions,
|
||||||
|
McpRuntime,
|
||||||
ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, LoadLayoutInput, MutateLayoutInput,
|
ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, LoadLayoutInput, MutateLayoutInput,
|
||||||
OpenProjectInput,
|
OpenProjectInput,
|
||||||
ReadAgentContextInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput,
|
ReadAgentContextInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput,
|
||||||
@ -1029,6 +1030,22 @@ pub async fn launch_agent(
|
|||||||
// already-live agent is refused).
|
// already-live agent is refused).
|
||||||
let node_id = request.node_id.as_deref().map(parse_node_id).transpose()?;
|
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
|
let output = state
|
||||||
.launch_agent
|
.launch_agent
|
||||||
.execute(LaunchAgentInput {
|
.execute(LaunchAgentInput {
|
||||||
@ -1040,6 +1057,7 @@ pub async fn launch_agent(
|
|||||||
// Resume id is a property of the hosting cell; the frontend passes the
|
// Resume id is a property of the hosting cell; the frontend passes the
|
||||||
// leaf's current conversation id here.
|
// leaf's current conversation id here.
|
||||||
conversation_id: request.conversation_id.clone(),
|
conversation_id: request.conversation_id.clone(),
|
||||||
|
mcp_runtime,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(ErrorDto::from)?;
|
.map_err(ErrorDto::from)?;
|
||||||
|
|||||||
@ -17,13 +17,49 @@ pub mod chat;
|
|||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod dto;
|
pub mod dto;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
|
pub mod mcp_bridge;
|
||||||
|
pub mod mcp_endpoint;
|
||||||
pub mod pty;
|
pub mod pty;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|
||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
use state::AppState;
|
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.
|
/// Builds and runs the Tauri application.
|
||||||
///
|
///
|
||||||
/// Sets up the composition root (resolving the app-data directory via the Tauri
|
/// 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.
|
// Prevents an extra console window on Windows in release builds.
|
||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![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
|
// WebKitGTK's DMABUF renderer causes a blank/white window on many Linux
|
||||||
// setups (recent Mesa/Nvidia drivers, common on Arch). Disable it before
|
// setups (recent Mesa/Nvidia drivers, common on Arch). Disable it before
|
||||||
// the webview initializes, unless the user has explicitly set the variable.
|
// 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")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
{
|
||||||
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
|
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.
|
//! stopping unregisters.
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use app_tauri_lib::mcp_endpoint::mcp_endpoint;
|
||||||
use app_tauri_lib::state::AppState;
|
use app_tauri_lib::state::AppState;
|
||||||
use domain::ports::IdGenerator;
|
use domain::ports::IdGenerator;
|
||||||
use domain::project::{Project, ProjectPath};
|
use domain::project::{Project, ProjectPath};
|
||||||
@ -211,3 +213,120 @@ async fn mcp_servers_are_isolated_per_project() {
|
|||||||
assert!(has_mcp(&state, &b.id));
|
assert!(has_mcp(&state, &b.id));
|
||||||
assert_eq!(mcp_count(&state), 1);
|
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
|
// Conversation id discarded: the previous one belonged to the old
|
||||||
// engine; the new profile starts (or assigns) a fresh one.
|
// engine; the new profile starts (or assigns) a fresh one.
|
||||||
conversation_id: None,
|
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?;
|
.await?;
|
||||||
Ok(Some(output.session))
|
Ok(Some(output.session))
|
||||||
@ -637,6 +642,50 @@ pub struct LaunchAgentInput {
|
|||||||
/// when the profile supports it). The caller (which owns the layout) reads this
|
/// when the profile supports it). The caller (which owns the layout) reads this
|
||||||
/// from the leaf's [`domain::layout::LeafCell::conversation_id`].
|
/// from the leaf's [`domain::layout::LeafCell::conversation_id`].
|
||||||
pub conversation_id: Option<String>,
|
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
|
/// 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**
|
// 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`
|
// run dir isolé que le convention file et le seed de permissions. `None`
|
||||||
// ⇒ aucun write/flag/env (chemin actuel inchangé, zéro régression).
|
// ⇒ 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 ──
|
// 5b. ── POINT DE ROUTAGE §17.4 : IA structuré vs terminal brut ──
|
||||||
// Le convention file (CLAUDE.md / AGENTS.md) vient d'être écrit dans le
|
// 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
|
/// `profile.mcp == None` ⇒ no-op: no write, no flag, no env (current path, zero
|
||||||
/// regression).
|
/// regression).
|
||||||
///
|
///
|
||||||
/// Note (M1): the embedded server declaration (command + transport) is a coherent
|
/// MCP runtime (M5d): when the composition root injects a [`McpRuntime`], the
|
||||||
/// **placeholder** — the real IdeA MCP server and its exact launch command land in
|
/// declaration written for a `ConfigFile` strategy is the **real** one — it points
|
||||||
/// M2/M3. The strategy plumbing here is stable; only the declaration content will
|
/// the spawned `idea mcp-server` bridge at the project's exact loopback endpoint
|
||||||
/// be finalised then.
|
/// (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(
|
async fn apply_mcp_config(
|
||||||
&self,
|
&self,
|
||||||
profile: &AgentProfile,
|
profile: &AgentProfile,
|
||||||
run_dir: &ProjectPath,
|
run_dir: &ProjectPath,
|
||||||
|
runtime: Option<&McpRuntime>,
|
||||||
spec: &mut SpawnSpec,
|
spec: &mut SpawnSpec,
|
||||||
) {
|
) {
|
||||||
let Some(mcp) = &profile.mcp else {
|
let Some(mcp) = &profile.mcp else {
|
||||||
@ -1351,7 +1405,7 @@ impl LaunchAgent {
|
|||||||
match self.fs.exists(&path).await {
|
match self.fs.exists(&path).await {
|
||||||
Ok(true) => {}
|
Ok(true) => {}
|
||||||
Ok(false) => {
|
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.
|
// Best-effort: a write failure must not fail the launch.
|
||||||
let _ = self.fs.write(&path, declaration.as_bytes()).await;
|
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
|
/// 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
|
/// config (cadrage v3 D3 ; cadrage v5 §2). Kept pure (no I/O) so it is unit-testable.
|
||||||
/// 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.
|
/// 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]
|
#[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
|
// 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 {
|
let transport_label = match transport {
|
||||||
domain::profile::McpTransport::Stdio => "stdio",
|
domain::profile::McpTransport::Stdio => "stdio",
|
||||||
domain::profile::McpTransport::Socket => "socket",
|
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!(
|
format!(
|
||||||
r#"{{
|
r#"{{
|
||||||
"mcpServers": {{
|
"mcpServers": {{
|
||||||
"idea": {{
|
"idea": {{
|
||||||
"command": "idea",
|
"command": {command},
|
||||||
"args": [
|
"args": [
|
||||||
"mcp-server"
|
"mcp-server"{extra_args}
|
||||||
],
|
],
|
||||||
"transport": "{transport_label}"
|
"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
|
/// Builds an absolute path string by joining a [`ProjectPath`] with a relative
|
||||||
/// segment using a POSIX separator.
|
/// segment using a POSIX separator.
|
||||||
fn join(base: &ProjectPath, rel: &str) -> String {
|
fn join(base: &ProjectPath, rel: &str) -> String {
|
||||||
|
|||||||
@ -26,7 +26,7 @@ pub use resume::{
|
|||||||
pub use lifecycle::{
|
pub use lifecycle::{
|
||||||
ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch,
|
ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch,
|
||||||
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, LaunchAgent,
|
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, LaunchAgent,
|
||||||
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput,
|
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, McpRuntime,
|
||||||
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredSessionDescriptor,
|
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredSessionDescriptor,
|
||||||
UpdateAgentContext,
|
UpdateAgentContext,
|
||||||
UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET,
|
UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET,
|
||||||
|
|||||||
@ -34,7 +34,7 @@ pub use agent::{
|
|||||||
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput,
|
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput,
|
||||||
InspectConversation, InspectConversationInput, InspectConversationOutput, LaunchAgent,
|
InspectConversation, InspectConversationInput, InspectConversationOutput, LaunchAgent,
|
||||||
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput,
|
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput,
|
||||||
ListProfiles, ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput,
|
ListProfiles, ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, McpRuntime,
|
||||||
ListResumableAgentsOutput, ProfileAvailability, ReadAgentContext, ReadAgentContextInput,
|
ListResumableAgentsOutput, ProfileAvailability, ReadAgentContext, ReadAgentContextInput,
|
||||||
ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile,
|
ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile,
|
||||||
SaveProfileInput, SaveProfileOutput, StructuredSessionDescriptor, UpdateAgentContext,
|
SaveProfileInput, SaveProfileOutput, StructuredSessionDescriptor, UpdateAgentContext,
|
||||||
|
|||||||
@ -308,6 +308,10 @@ impl OrchestratorService {
|
|||||||
cols: DEFAULT_COLS,
|
cols: DEFAULT_COLS,
|
||||||
node_id,
|
node_id,
|
||||||
conversation_id: None,
|
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?;
|
.await?;
|
||||||
|
|
||||||
@ -413,6 +417,9 @@ impl OrchestratorService {
|
|||||||
cols: DEFAULT_COLS,
|
cols: DEFAULT_COLS,
|
||||||
node_id: None,
|
node_id: None,
|
||||||
conversation_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?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@ -812,6 +812,7 @@ fn launch_input(agent_id: AgentId) -> LaunchAgentInput {
|
|||||||
cols: 80,
|
cols: 80,
|
||||||
node_id: None,
|
node_id: None,
|
||||||
conversation_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)
|
// LaunchAgent — session resume (T4)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -611,6 +611,7 @@ fn launch_input(agent_id: AgentId) -> LaunchAgentInput {
|
|||||||
cols: 80,
|
cols: 80,
|
||||||
node_id: None,
|
node_id: None,
|
||||||
conversation_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
|
/// delegation arrived through the MCP door. `None` ⇒ no publication (the server
|
||||||
/// still works), so existing call sites stay valid.
|
/// still works), so existing call sites stay valid.
|
||||||
events: Option<Arc<dyn Fn(DomainEvent) + Send + Sync>>,
|
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 {
|
impl McpServer {
|
||||||
@ -59,6 +65,7 @@ impl McpServer {
|
|||||||
service,
|
service,
|
||||||
project,
|
project,
|
||||||
events: None,
|
events: None,
|
||||||
|
requester: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -72,6 +79,35 @@ impl McpServer {
|
|||||||
self
|
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).
|
/// Serves JSON-RPC messages from `transport` until it closes (clean EOF).
|
||||||
///
|
///
|
||||||
/// Every inbound line is handled in isolation: a malformed line or an unknown
|
/// 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
|
/// Publishes [`DomainEvent::OrchestratorRequestProcessed`] for a handled
|
||||||
/// `tools/call`, tagged [`OrchestrationSource::Mcp`]. The requester id is the
|
/// `tools/call`, tagged [`OrchestrationSource::Mcp`]. The requester id is the
|
||||||
/// stable `"mcp"` label until the per-session transport bind (open point S-MCP)
|
/// connected peer's real agent id (carried in the loopback handshake, cadrage v5
|
||||||
/// carries the connected agent's identity. No-op without an event sink.
|
/// §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) {
|
fn publish_processed(&self, action: &str, ok: bool) {
|
||||||
if let Some(publish) = &self.events {
|
if let Some(publish) = &self.events {
|
||||||
|
let requester_id = if self.requester.is_empty() {
|
||||||
|
"mcp".to_owned()
|
||||||
|
} else {
|
||||||
|
self.requester.clone()
|
||||||
|
};
|
||||||
publish(DomainEvent::OrchestratorRequestProcessed {
|
publish(DomainEvent::OrchestratorRequestProcessed {
|
||||||
requester_id: "mcp".to_owned(),
|
requester_id,
|
||||||
action: action.to_owned(),
|
action: action.to_owned(),
|
||||||
ok,
|
ok,
|
||||||
source: OrchestrationSource::Mcp,
|
source: OrchestrationSource::Mcp,
|
||||||
|
|||||||
@ -42,6 +42,18 @@ where
|
|||||||
writer,
|
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]
|
#[async_trait::async_trait]
|
||||||
|
|||||||
Reference in New Issue
Block a user