feat(agent): bind transport S-MCP — outils idea_* vivants de bout en bout (M5a-e) — §14.3.1

Dernier kilomètre de l'orchestration native : une CLI MCP réellement lancée
joint le serveur MCP du projet et ses outils idea_* aboutissent au vrai dispatch.

- M5a endpoint loopback par projet (interprocess UDS/named pipe, source unique
  mcp_endpoint, cleanup au close) — zéro port réseau, AppImage/SSH-safe.
- M5b sous-commande `idea mcp-server` : pont stdio↔loopback headless (avant init
  Tauri), handshake {"project","requester"}, endpoint-absent borné, EOF propre.
- M5c McpServerHandle boucle accept + serve_peer par pair ; requester réel
  propagé jusqu'à OrchestratorRequestProcessed (fin du "mcp" figé) ; isolation
  des pairs ; terminaison propre.
- M5d apply_mcp_config écrit la déclaration réelle (current_exe + --endpoint
  mcp_endpoint + --project simple-uuid + --requester) ; McpRuntime injecté comme
  donnée (application ne dépend pas de app-tauri).
- M5e smoke e2e sur vrai loopback : list/ask inline, cible PTY → erreur typée,
  JSON malformé → pas de panic, requester propagé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 18:09:41 +02:00
parent 6ca519b815
commit cf89b3b9a5
17 changed files with 3281 additions and 44 deletions

View File

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