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