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

@ -18,7 +18,9 @@
//! making the reference profiles addressable across runs without a registry.
use domain::ids::ProfileId;
use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter};
use domain::profile::{
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, StructuredAdapter,
};
/// A fixed UUID namespace used to derive stable ids for reference profiles.
/// (Random-looking but constant; only its stability matters.)
@ -57,7 +59,12 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
None,
)
.expect("claude reference profile is valid")
.with_structured_adapter(StructuredAdapter::Claude),
.with_structured_adapter(StructuredAdapter::Claude)
.with_mcp(McpCapability::new(
McpConfigStrategy::config_file(".mcp.json")
.expect(".mcp.json is a valid relative MCP config target"),
McpTransport::Stdio,
)),
AgentProfile::new(
reference_id("codex"),
"OpenAI Codex CLI",
@ -70,7 +77,12 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
None,
)
.expect("codex reference profile is valid")
.with_structured_adapter(StructuredAdapter::Codex),
.with_structured_adapter(StructuredAdapter::Codex)
.with_mcp(McpCapability::new(
McpConfigStrategy::config_file(".mcp.json")
.expect(".mcp.json is a valid relative MCP config target"),
McpTransport::Stdio,
)),
AgentProfile::new(
reference_id("gemini"),
"Gemini CLI",
@ -119,3 +131,50 @@ pub fn selectable_reference_profiles() -> Vec<AgentProfile> {
.filter(AgentProfile::is_selectable)
.collect()
}
#[cfg(test)]
mod mcp_tests {
use super::*;
fn profile(slug: &str) -> AgentProfile {
let id = reference_id(slug);
reference_profiles()
.into_iter()
.find(|p| p.id == id)
.unwrap_or_else(|| panic!("reference profile `{slug}` exists"))
}
#[test]
fn claude_and_codex_expose_mcp_capability() {
for slug in ["claude", "codex"] {
let p = profile(slug);
assert!(
p.mcp.is_some(),
"structured profile `{slug}` must carry an MCP capability"
);
}
}
#[test]
fn claude_and_codex_mcp_use_config_file_mcp_json() {
for slug in ["claude", "codex"] {
let mcp = profile(slug).mcp.expect("mcp present");
assert_eq!(
mcp.config,
McpConfigStrategy::ConfigFile { target: ".mcp.json".to_owned() },
"profile `{slug}` should declare `.mcp.json`"
);
assert_eq!(mcp.transport, McpTransport::Stdio);
}
}
#[test]
fn gemini_and_aider_have_no_mcp_capability() {
for slug in ["gemini", "aider"] {
assert!(
profile(slug).mcp.is_none(),
"non-structured profile `{slug}` must NOT carry MCP (file fallback)"
);
}
}
}

View File

@ -997,10 +997,19 @@ impl LaunchAgent {
&project_context,
&skills,
&memory,
profile.mcp.is_some(),
&mut spec,
)
.await?;
// 5a. ── INJECTION DE LA CONF MCP (cadrage v3, Décision 3) ──
// Strictement APRÈS le convention file (étape 5) et AVANT le spawn /
// `factory.start` (étapes 5b/6). Si le profil porte une `McpCapability`,
// 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;
// 5b. ── POINT DE ROUTAGE §17.4 : IA structuré vs terminal brut ──
// Le convention file (CLAUDE.md / AGENTS.md) vient d'être écrit dans le
// run dir (étape 5) ; la CLI structurée le lira à chaque tour
@ -1226,6 +1235,7 @@ impl LaunchAgent {
/// materialising a `conventionFile` context (write the `.md` to `<cwd>/target`)
/// or attaching the on-disk context path to an environment variable. `Args` is
/// already folded into the spec by the runtime; `Stdin` is handled post-spawn.
#[allow(clippy::too_many_arguments)]
async fn apply_injection(
&self,
project: &Project,
@ -1234,6 +1244,7 @@ impl LaunchAgent {
project_context: &str,
skills: &[Skill],
memory: &[MemoryIndexEntry],
mcp_enabled: bool,
spec: &mut SpawnSpec,
) -> Result<(), AppError> {
match spec.context_plan.clone() {
@ -1251,6 +1262,7 @@ impl LaunchAgent {
content.as_str(),
skills,
memory,
mcp_enabled,
);
let path = RemotePath::new(join(&spec.cwd, &target));
self.fs.write(&path, document.as_bytes()).await?;
@ -1267,6 +1279,92 @@ impl LaunchAgent {
}
Ok(())
}
/// Materialises the IdeA MCP server config for **this** CLI when the profile
/// carries an [`McpCapability`] (cadrage v3, Décision 3). Runs **after** the
/// convention file (`apply_injection`) and **before** the spawn / `factory.start`,
/// in the **same** isolated run dir as the convention file and the permission seed.
///
/// Dispatch by [`McpConfigStrategy`]:
/// - `ConfigFile { target }` → write `<run_dir>/<target>` (e.g. `.mcp.json`) with
/// the IdeA MCP server declaration. **Non-clobbering and best-effort**, exactly
/// like [`Self::seed_cli_permissions`]: an existing file (possibly user-edited)
/// is left untouched, and any write/exists failure is swallowed so it **never**
/// fails the launch.
/// - `Flag { flag }` → append the flag + run-dir config path to [`SpawnSpec::args`].
/// - `Env { var }` → append the variable (run-dir config path) to [`SpawnSpec::env`].
///
/// `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.
async fn apply_mcp_config(
&self,
profile: &AgentProfile,
run_dir: &ProjectPath,
spec: &mut SpawnSpec,
) {
let Some(mcp) = &profile.mcp else {
return;
};
match &mcp.config {
domain::profile::McpConfigStrategy::ConfigFile { target } => {
// Non-clobbering, best-effort — mirrors `seed_cli_permissions`.
let path = RemotePath::new(join(run_dir, target));
match self.fs.exists(&path).await {
Ok(true) => {}
Ok(false) => {
let declaration = mcp_server_declaration(mcp.transport);
// Best-effort: a write failure must not fail the launch.
let _ = self.fs.write(&path, declaration.as_bytes()).await;
}
// An exists() probe failure is swallowed too (best-effort).
Err(_) => {}
}
}
domain::profile::McpConfigStrategy::Flag { flag } => {
// Pass the server via a launch flag (e.g. `--mcp-config {path}`). The
// config path is the run dir itself (the CLI's cwd), where the server
// declaration / connection is anchored.
spec.args.push(flag.clone());
spec.args.push(run_dir.as_str().to_owned());
}
domain::profile::McpConfigStrategy::Env { var } => {
spec.env.push((var.clone(), run_dir.as_str().to_owned()));
}
}
}
}
/// 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.
#[must_use]
fn mcp_server_declaration(transport: domain::profile::McpTransport) -> 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.
let transport_label = match transport {
domain::profile::McpTransport::Stdio => "stdio",
domain::profile::McpTransport::Socket => "socket",
};
format!(
r#"{{
"mcpServers": {{
"idea": {{
"command": "idea",
"args": [
"mcp-server"
],
"transport": "{transport_label}"
}}
}}
}}
"#
)
}
/// Builds an absolute path string by joining a [`ProjectPath`] with a relative
@ -1402,6 +1500,13 @@ fn json_escape(s: &str) -> String {
/// entirely, so an agent with no memory gets exactly the previous document.
///
/// Kept as a **pure** function (no I/O) so it is unit-testable in isolation.
///
/// The orchestration prose adapts to the agent's **surface** (cadrage v3, Décision
/// 3): when `mcp_enabled` is `true` (`profile.mcp.is_some()`), the agent sees the
/// native `idea_*` tools, so the prose points to those instead of the file
/// protocol — while keeping the **same** ban on the provider's native subagents.
/// When `false` (`mcp == None`) the prose is the **current `.ideai/requests`
/// wording, unchanged** (zero regression).
#[must_use]
pub(crate) fn compose_convention_file(
project_root: &str,
@ -1409,6 +1514,7 @@ pub(crate) fn compose_convention_file(
agent_md: &str,
skills: &[Skill],
memory: &[MemoryIndexEntry],
mcp_enabled: bool,
) -> String {
let mut out = String::new();
out.push_str("# Project root\n\n");
@ -1420,12 +1526,26 @@ pub(crate) fn compose_convention_file(
);
out.push_str("---\n\n");
out.push_str("# Orchestration IdeA\n\n");
out.push_str(
"Pour déléguer une tâche à un autre agent, n'utilise jamais les subagents \
natifs du fournisseur IA. Écris une requête d'orchestration IdeA dans \
`.ideai/requests/<ton-agent>/` ; IdeA lancera ou réattachera l'agent cible \
avec son propre AI Profile, son contexte et sa mémoire.\n\n",
);
if mcp_enabled {
// Surface MCP (cadrage v3, D3) : l'alternative native aux subagents est
// exposée comme outils typés `idea_*`. L'interdiction des subagents natifs
// du fournisseur est **conservée** ; seule la voie de délégation change.
out.push_str(
"Pour déléguer une tâche à un autre agent, n'utilise jamais les subagents \
natifs du fournisseur IA. Utilise les outils IdeA natifs : \
`idea_ask_agent` (déléguer une tâche et recevoir la réponse), \
`idea_launch_agent` (lancer/réattacher un agent) et `idea_list_agents` \
(lister les agents du projet). IdeA lancera ou réattachera l'agent cible \
avec son propre AI Profile, son contexte et sa mémoire.\n\n",
);
} else {
out.push_str(
"Pour déléguer une tâche à un autre agent, n'utilise jamais les subagents \
natifs du fournisseur IA. Écris une requête d'orchestration IdeA dans \
`.ideai/requests/<ton-agent>/` ; IdeA lancera ou réattachera l'agent cible \
avec son propre AI Profile, son contexte et sa mémoire.\n\n",
);
}
out.push_str("---\n\n");
if !project_context.trim().is_empty() {
@ -1533,8 +1653,14 @@ mod tests {
#[test]
fn compose_convention_file_carries_root_then_persona() {
let doc =
compose_convention_file("/abs/project/root", "", "# Persona\n\nDo things.", &[], &[]);
let doc = compose_convention_file(
"/abs/project/root",
"",
"# Persona\n\nDo things.",
&[],
&[],
false,
);
// Absolute project root present.
assert!(doc.contains("/abs/project/root"));
@ -1557,6 +1683,7 @@ mod tests {
"# Persona\n\nDo X.",
&[],
&[],
false,
);
assert!(doc.contains("# Contexte projet"));
@ -1589,6 +1716,7 @@ mod tests {
s(2, "review", "REVIEW_BODY"),
],
&[],
false,
);
// Both skill bodies present, after the persona.
@ -1619,7 +1747,8 @@ mod tests {
fn compose_convention_file_empty_memory_is_identical_to_no_memory() {
// An empty `memory` must yield exactly the previous document: no section,
// byte-for-byte identical to the no-skills/no-memory composition.
let with_empty = compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[]);
let with_empty =
compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[], false);
assert!(
!with_empty.contains("# Mémoire projet"),
"no memory ⇒ no memory section"
@ -1628,7 +1757,7 @@ mod tests {
// 4-arg call; this pins the omission contract explicitly).
assert_eq!(
with_empty,
compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[])
compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[], false)
);
}
@ -1648,6 +1777,7 @@ mod tests {
MemoryType::Reference,
),
],
false,
);
// Section present, after the persona.
@ -1684,6 +1814,7 @@ mod tests {
"# Persona",
std::slice::from_ref(&skill),
&[mem("note", "Note", "a hook", MemoryType::Project)],
false,
);
// Both sections present.
@ -1698,6 +1829,52 @@ mod tests {
assert!(skills_at < memory_at, "skills come before memory");
}
#[test]
fn compose_convention_file_mcp_prose_points_to_idea_tools_and_keeps_subagent_ban() {
// mcp_enabled = true ⇒ prose exposes the native `idea_*` tools while keeping
// the ban on the provider's native subagents (cadrage v3, Décision 3).
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], true);
// Native IdeA orchestration tools surfaced.
assert!(
doc.contains("idea_ask_agent"),
"MCP prose must mention idea_ask_agent"
);
assert!(doc.contains("idea_launch_agent"));
assert!(doc.contains("idea_list_agents"));
// Native-subagent ban preserved.
assert!(
doc.contains("n'utilise jamais les subagents"),
"MCP prose must keep the native-subagent ban"
);
// It does NOT fall back to the file protocol wording.
assert!(
!doc.contains(".ideai/requests"),
"MCP prose must not point to the file protocol"
);
}
#[test]
fn compose_convention_file_non_mcp_prose_is_the_unchanged_file_protocol() {
// mcp_enabled = false ⇒ the current `.ideai/requests` wording, unchanged,
// and crucially WITHOUT the `idea_*` tools (zero regression).
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], false);
assert!(
doc.contains(".ideai/requests"),
"non-MCP prose must point to the file protocol"
);
assert!(
doc.contains("n'utilise jamais les subagents"),
"non-MCP prose keeps the native-subagent ban"
);
// No native MCP tools leaked into the file-protocol prose.
assert!(
!doc.contains("idea_ask_agent"),
"non-MCP prose must not mention the idea_* tools"
);
}
#[test]
fn claude_settings_seed_grants_autonomy_and_keeps_guardrails() {
let json = claude_settings_seed("/home/me/proj");

View File

@ -147,6 +147,7 @@ impl OrchestratorService {
OrchestratorCommand::AskAgent { target, task } => {
self.ask_agent(project, target, task).await
}
OrchestratorCommand::ListAgents => self.list_agents(project).await,
OrchestratorCommand::StopAgent { name } => self.stop_agent(project, name).await,
OrchestratorCommand::UpdateAgentContext { name, context } => {
self.update_agent_context(project, name, context).await
@ -355,6 +356,35 @@ impl OrchestratorService {
}
}
/// `list_agents`: discovery — return the project's agents exactly as the UI
/// reads them from the manifest, via the **same** [`ListAgents`] use case.
///
/// The list is serialised as a JSON array into [`OrchestratorOutcome::reply`]
/// (the existing inline-payload channel, also used by `ask`), with a one-line
/// count in [`OrchestratorOutcome::detail`]. Each element carries the agent's
/// `id`, `name`, `contextPath`, `profileId`, `origin`, `synchronized` and
/// `skills` (camelCase, the [`domain::Agent`] serde shape).
///
/// # Errors
/// Propagates [`AppError`] from the use case (manifest load / invariant) or a
/// serialisation failure ([`AppError::Invalid`]).
async fn list_agents(&self, project: &Project) -> Result<OrchestratorOutcome, AppError> {
let listed = self
.list_agents
.execute(ListAgentsInput {
project: project.clone(),
})
.await?;
let reply = serde_json::to_string(&listed.agents)
.map_err(|e| AppError::Invalid(format!("failed to serialise agent list: {e}")))?;
Ok(OrchestratorOutcome {
detail: format!("listed {} agent(s)", listed.agents.len()),
reply: Some(reply),
})
}
/// `stop_agent`: translate the agent name → its live session → `CloseTerminal`.
async fn stop_agent(
&self,