feat(agent): orchestration v3 — surface MCP model-agnostic (M0→M4) — §14.3

Expose l'orchestration IdeA comme serveur MCP par-dessus le même
OrchestratorService::dispatch, avec repli fichier .ideai/requests pour les
CLI sans MCP. v3 réduite à la surface MCP : la messagerie inter-agents et la
corrélation requête↔réponse étaient déjà résolues par §17 (send_blocking).

- M0 capacité MCP sur le profil (McpCapability/McpConfigStrategy/McpTransport)
- M1 injection conf MCP au LaunchAgent + prose adaptée selon la surface
- M2 serveur/adapter MCP (JSON-RPC 2.0 maison ; outils idea_*) + ListAgents
- M3 câblage par projet (registre mcp_servers jumeau du watcher)
- M4 observabilité UI : OrchestratorRequestProcessed.source = file|mcp + badge

Trois portes d'entrée (fichier, MCP, UI) → un seul dispatch ; aucun nouveau
port applicatif ; MCP confiné à l'adapter infra. Tous lots verts (cycle §3).
Cadrage : .ideai/briefs/orchestration-v3-cadrage.md ; ARCHITECTURE.md §14.3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 12:53:31 +02:00
parent 97daf3fae5
commit 37e72747d3
30 changed files with 3646 additions and 27 deletions

View File

@ -29,7 +29,9 @@ use domain::ports::{
OutputStream, PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath,
RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
};
use domain::profile::{AgentProfile, ContextInjection, SessionStrategy};
use domain::profile::{
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, SessionStrategy,
};
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::skill::{Skill, SkillScope};
@ -342,6 +344,14 @@ struct FakeFs {
writes: WriteLog<String>,
created_dirs: Arc<Mutex<Vec<String>>>,
project_context: Arc<Mutex<Option<String>>>,
/// Paths reported as **already existing** by [`FileSystem::exists`] (the
/// default empty set keeps the historical `exists == false` behaviour). Used
/// by the MCP non-clobbering test.
existing: Arc<Mutex<Vec<String>>>,
/// Paths whose [`FileSystem::write`] must fail with an I/O error (nothing is
/// recorded for them). Used by the MCP best-effort test to prove that an MCP
/// config write failure never fails the launch.
fail_writes_for: Arc<Mutex<Vec<String>>>,
}
impl FakeFs {
@ -351,11 +361,23 @@ impl FakeFs {
writes: Arc::new(Mutex::new(Vec::new())),
created_dirs: Arc::new(Mutex::new(Vec::new())),
project_context: Arc::new(Mutex::new(None)),
existing: Arc::new(Mutex::new(Vec::new())),
fail_writes_for: Arc::new(Mutex::new(Vec::new())),
}
}
fn set_project_context(&self, content: &str) {
*self.project_context.lock().unwrap() = Some(content.to_owned());
}
/// Marks a path as already existing on disk, so [`FileSystem::exists`] returns
/// `true` for it (non-clobbering scenarios).
fn mark_existing(&self, path: &str) {
self.existing.lock().unwrap().push(path.to_owned());
}
/// Makes any [`FileSystem::write`] whose path ends with `suffix` fail
/// (best-effort scenarios — e.g. the MCP `.mcp.json` config file).
fn fail_write_suffix(&self, suffix: &str) {
self.fail_writes_for.lock().unwrap().push(suffix.to_owned());
}
fn writes(&self) -> Vec<(String, Vec<u8>)> {
self.writes.lock().unwrap().clone()
}
@ -401,15 +423,26 @@ impl FileSystem for FakeFs {
Err(FsError::NotFound(path.as_str().to_owned()))
}
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
let p = path.as_str();
if self
.fail_writes_for
.lock()
.unwrap()
.iter()
.any(|suffix| p.ends_with(suffix.as_str()))
{
return Err(FsError::Io(format!("forced write failure for {p}")));
}
self.trace.lock().unwrap().push("fs.write".to_owned());
self.writes
.lock()
.unwrap()
.push((path.as_str().to_owned(), data.to_vec()));
.push((p.to_owned(), data.to_vec()));
Ok(())
}
async fn exists(&self, _path: &RemotePath) -> Result<bool, FsError> {
Ok(false)
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
let p = path.as_str();
Ok(self.existing.lock().unwrap().iter().any(|e| e == p))
}
async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> {
self.created_dirs
@ -1448,6 +1481,200 @@ async fn launch_unknown_profile_is_not_found() {
assert!(pty.spawns().is_empty(), "no spawn when profile unresolved");
}
// ---------------------------------------------------------------------------
// LaunchAgent — MCP config injection (M1, cadrage v3 Décision 3)
// ---------------------------------------------------------------------------
/// Builds a CLAUDE.md-convention profile carrying an [`McpCapability`], so the
/// MCP injection path (`apply_mcp_config`) is exercised during a launch.
fn profile_with_mcp(id: ProfileId, config: McpConfigStrategy) -> AgentProfile {
profile(id, ContextInjection::convention_file("CLAUDE.md").unwrap())
.with_mcp(McpCapability::new(config, McpTransport::Stdio))
}
/// Returns the writes whose path ends with the given suffix (e.g. `/.mcp.json`).
fn writes_ending_with(fs: &FakeFs, suffix: &str) -> Vec<(String, Vec<u8>)> {
fs.writes()
.into_iter()
.filter(|(p, _)| p.ends_with(suffix))
.collect()
}
#[tokio::test]
async fn launch_without_mcp_writes_no_mcp_config_and_leaves_spec_untouched() {
// Test 1 — profile.mcp = None ⇒ no MCP file written, spec.args/env unchanged.
// The profile has empty args and the runtime adds no env, so a clean launch
// must leave the spawned spec's args/env exactly empty (no MCP flag/env).
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(),
}),
);
launch.execute(launch_input(agent.id)).await.unwrap();
// No MCP-style config file anywhere.
assert!(
writes_ending_with(&fs, "/.mcp.json").is_empty(),
"no MCP config file when profile.mcp is None"
);
// Exactly the convention file write (+ the Claude seed), nothing MCP-related.
assert_eq!(
fs.context_writes().len(),
1,
"only the convention file is written"
);
// Spec args/env carry nothing MCP-related (here: empty).
let spawns = pty.spawns();
assert_eq!(spawns.len(), 1);
assert!(spawns[0].args.is_empty(), "no MCP flag pushed to args");
assert!(spawns[0].env.is_empty(), "no MCP var pushed to env");
}
#[tokio::test]
async fn launch_mcp_config_file_writes_declaration_at_run_dir_target() {
// Test 2 — ConfigFile { target: ".mcp.json" } ⇒ a file is written at
// <run_dir>/.mcp.json with a non-empty, coherent MCP server declaration.
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.execute(launch_input(agent.id)).await.unwrap();
let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
let mcp = writes_ending_with(&fs, "/.mcp.json");
assert_eq!(mcp.len(), 1, "exactly one MCP config file written");
assert_eq!(mcp[0].0, format!("{run_dir}/.mcp.json"), "written at run dir");
let body = String::from_utf8(mcp[0].1.clone()).unwrap();
assert!(!body.trim().is_empty(), "MCP declaration is non-empty");
// Coherent MCP server declaration (an `mcpServers`/`idea` server entry).
assert!(
body.contains("mcpServers") && body.contains("idea"),
"MCP declaration must declare the IdeA MCP server, got: {body}"
);
// It is valid JSON.
let _: serde_json::Value =
serde_json::from_str(&body).expect("MCP declaration is valid JSON");
}
#[tokio::test]
async fn launch_mcp_config_file_is_non_clobbering() {
// Test 3 — if <run_dir>/.mcp.json already exists, the launch must NOT overwrite
// it: no write is recorded against that path.
let profile = profile_with_mcp(
pid(9),
McpConfigStrategy::config_file(".mcp.json").unwrap(),
);
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) =
launch_fixture_with_profile(
profile,
Some(ContextInjectionPlan::File {
target: "CLAUDE.md".to_owned(),
}),
);
// Pre-existing MCP config file on disk.
let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
fs.mark_existing(&format!("{run_dir}/.mcp.json"));
launch.execute(launch_input(agent.id)).await.unwrap();
assert!(
writes_ending_with(&fs, "/.mcp.json").is_empty(),
"an existing MCP config file must not be clobbered"
);
}
#[tokio::test]
async fn launch_mcp_config_file_write_failure_is_best_effort() {
// Test 4 — a write failure on the MCP config file must NOT fail the launch.
let profile = profile_with_mcp(
pid(9),
McpConfigStrategy::config_file(".mcp.json").unwrap(),
);
let (launch, agent, fs, pty, _bus, _sessions, _tr, _session) =
launch_fixture_with_profile(
profile,
Some(ContextInjectionPlan::File {
target: "CLAUDE.md".to_owned(),
}),
);
// Force the MCP config write to fail.
fs.fail_write_suffix("/.mcp.json");
// The launch still succeeds and spawns the CLI.
let out = launch
.execute(launch_input(agent.id))
.await
.expect("MCP write failure must not fail the launch");
assert!(out.structured.is_none());
assert_eq!(pty.spawns().len(), 1, "the CLI is still spawned");
}
#[tokio::test]
async fn launch_mcp_flag_appends_flag_and_path_to_args() {
// Test 5 — Flag { flag: "--mcp-config" } ⇒ spec.args carries the flag followed
// by the run-dir config path.
let (launch, agent, _fs, pty, _bus, _sessions, _tr, _session) =
launch_fixture_with_profile(
profile_with_mcp(pid(9), McpConfigStrategy::flag("--mcp-config").unwrap()),
Some(ContextInjectionPlan::File {
target: "CLAUDE.md".to_owned(),
}),
);
launch.execute(launch_input(agent.id)).await.unwrap();
let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
let spawns = pty.spawns();
assert_eq!(spawns.len(), 1);
let args = &spawns[0].args;
let pos = args
.iter()
.position(|a| a == "--mcp-config")
.expect("the MCP flag must be present in args");
assert_eq!(
args.get(pos + 1),
Some(&run_dir),
"the MCP flag is followed by the run-dir config path"
);
}
#[tokio::test]
async fn launch_mcp_env_appends_var_and_path_to_env() {
// Test 6 — Env { var: "IDEA_MCP" } ⇒ spec.env carries (IDEA_MCP, <run_dir>).
let (launch, agent, _fs, pty, _bus, _sessions, _tr, _session) =
launch_fixture_with_profile(
profile_with_mcp(pid(9), McpConfigStrategy::env("IDEA_MCP").unwrap()),
Some(ContextInjectionPlan::File {
target: "CLAUDE.md".to_owned(),
}),
);
launch.execute(launch_input(agent.id)).await.unwrap();
let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
let spawns = pty.spawns();
assert_eq!(spawns.len(), 1);
assert!(
spawns[0]
.env
.iter()
.any(|(k, v)| k == "IDEA_MCP" && v == &run_dir),
"spec.env must carry (IDEA_MCP, run_dir), got: {:?}",
spawns[0].env
);
}
// ---------------------------------------------------------------------------
// LaunchAgent — session resume (T4)
// ---------------------------------------------------------------------------