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

@ -5,6 +5,21 @@ use crate::ids::{AgentId, ProfileId, ProjectId, SessionId, SkillId, TemplateId};
use crate::memory::MemorySlug;
use crate::template::TemplateVersion;
/// Which entry door a processed orchestration request arrived through.
///
/// IdeA exposes the *same* [`crate::OrchestratorService::dispatch`] behind two
/// substitutable driving adapters (ARCHITECTURE §14.3 + cadrage `orchestration-v3`):
/// the `.ideai/requests` filesystem watcher and the MCP server. This tag — set by
/// the adapter that received the request, never inferred in the application — lets
/// the presentation layer surface *how* a delegation came in.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OrchestrationSource {
/// A JSON request file dropped under `.ideai/requests/`.
File,
/// A `tools/call` on the MCP server.
Mcp,
}
/// Events emitted by the domain/application as state changes occur.
///
/// Deliberately *not* `Serialize`/`Deserialize`: events are an in-process
@ -109,6 +124,9 @@ pub enum DomainEvent {
action: String,
/// Whether IdeA handled it successfully.
ok: bool,
/// Which entry door the request arrived through (file watcher vs MCP),
/// tagged by the receiving adapter.
source: OrchestrationSource,
},
/// A memory note was created or updated (`.md` written, index upserted).
MemorySaved {

View File

@ -87,7 +87,7 @@ pub use layout::{
SplitContainer, Tab, WeightedChild, Window, Workspace,
};
pub use events::DomainEvent;
pub use events::{DomainEvent, OrchestrationSource};
pub use orchestrator::{
OrchestratorCommand, OrchestratorError, OrchestratorRequest, OrchestratorVisibility,

View File

@ -150,6 +150,10 @@ pub enum OrchestratorCommand {
/// The task/message to send and await a reply for.
task: String,
},
/// List the project's agents (discovery) — exactly the data the UI reads from
/// the manifest. Carries no argument: the dispatch resolves the agents against
/// the project it is dispatched for.
ListAgents,
/// Create a reusable skill in the given scope — exactly as the UI would.
CreateSkill {
/// Display name (also the `.md` stem on disk).
@ -227,6 +231,7 @@ impl OrchestratorRequest {
target: self.require_target_agent(action)?,
task: self.require("task", action, self.task.as_deref())?,
}),
"list_agents" | "agent.list" => Ok(OrchestratorCommand::ListAgents),
"create_skill" | "skill.create" => Ok(OrchestratorCommand::CreateSkill {
name: self.require_name(action)?,
content: self.require("context", action, self.context.as_deref())?,

View File

@ -134,6 +134,107 @@ pub enum StructuredAdapter {
Codex,
}
/// Transport du serveur MCP IdeA exposé à une CLI (détail invisible au domaine).
///
/// `stdio` = défaut robuste cross-OS ; `socket` = optimisation (point ouvert).
/// Déclaratif, Open/Closed (comme [`StructuredAdapter`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum McpTransport {
/// Pont stdio (défaut) : IdeA parle au serveur MCP via stdin/stdout.
#[default]
Stdio,
/// Socket partagé (adresse) : optimisation, point ouvert (spike S-MCP).
Socket,
}
/// Stratégie de matérialisation de la config MCP propre à UNE CLI : chaque CLI
/// déclare son serveur MCP différemment (fichier `.mcp.json` pour Claude Code,
/// flag de lancement, ou variable d'env). Déclaratif = donnée, pas code (§9).
///
/// Invariants (garantis par les constructeurs) :
/// - `ConfigFile.target` est un chemin relatif sûr (pas de `..`, pas d'absolu),
/// - `Flag.flag` est non vide,
/// - `Env.var` est un identifiant de variable d'environnement valide.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "strategy")]
pub enum McpConfigStrategy {
/// Écrire un fichier de conf MCP au chemin (relatif au run dir isolé §14.1)
/// attendu par la CLI, au format JSON propre à cette CLI (ex. `.mcp.json`).
ConfigFile {
/// Chemin relatif sûr du fichier de conf MCP.
target: String,
},
/// Passer le serveur via un flag de lancement (ex. `--mcp-config {path}`).
Flag {
/// Le flag (template) non vide.
flag: String,
},
/// Passer via une variable d'environnement.
Env {
/// Nom de la variable d'environnement.
var: String,
},
}
impl McpConfigStrategy {
/// Constructeur validé `ConfigFile`.
///
/// # Errors
/// Renvoie [`DomainError::PathNotRelativeSafe`] si `target` est absolu ou
/// contient `..`.
pub fn config_file(target: impl Into<String>) -> Result<Self, DomainError> {
let target = target.into();
crate::validation::relative_safe(&target)?;
Ok(Self::ConfigFile { target })
}
/// Constructeur validé `Flag`.
///
/// # Errors
/// Renvoie [`DomainError::EmptyField`] si `flag` est vide.
pub fn flag(flag: impl Into<String>) -> Result<Self, DomainError> {
let flag = flag.into();
crate::validation::non_empty(&flag, "mcp.config.flag")?;
Ok(Self::Flag { flag })
}
/// Constructeur validé `Env`.
///
/// # Errors
/// Renvoie [`DomainError::InvalidEnvVar`] si `var` n'est pas un identifiant
/// valide.
pub fn env(var: impl Into<String>) -> Result<Self, DomainError> {
let var = var.into();
crate::validation::valid_env_var(&var)?;
Ok(Self::Env { var })
}
}
/// Capacité MCP d'un profil : COMMENT déclarer le serveur MCP IdeA à cette CLI,
/// et QUEL transport. `None` sur le profil ⇒ repli fichier `.ideai/requests` +
/// prose (comportement actuel, zéro régression).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpCapability {
/// Comment matérialiser la config MCP au lancement (relatif au run dir).
pub config: McpConfigStrategy,
/// Transport du serveur MCP IdeA (détail invisible au domaine).
/// `stdio` = défaut robuste cross-OS ; `socket` = optimisation.
#[serde(default)]
pub transport: McpTransport,
}
impl McpCapability {
/// Construit une capacité MCP. La validation est portée par le constructeur
/// de [`McpConfigStrategy`] (parse, don't validate) ; `transport` n'a pas
/// d'invariant.
#[must_use]
pub const fn new(config: McpConfigStrategy, transport: McpTransport) -> Self {
Self { config, transport }
}
}
/// Declarative runtime configuration for one AI CLI.
///
/// Invariants:
@ -173,6 +274,15 @@ pub struct AgentProfile {
/// sérialisation : un profil sans adapter sérialise exactement comme avant.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub structured_adapter: Option<StructuredAdapter>,
/// Capacité **MCP** (ARCHITECTURE §14.3, orchestration v3, Décision 1).
/// `None` ⇒ repli fichier `.ideai/requests` + prose (comportement actuel).
/// `Some(_)` ⇒ IdeA matérialise la config MCP de cette CLI au lancement et
/// l'agent voit les outils `idea_*`. Additif, Open/Closed.
///
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de
/// sérialisation : un profil sans MCP sérialise exactement comme avant.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp: Option<McpCapability>,
}
/// Embedding strategy of an [`EmbedderProfile`] (LOT C, étage 2 vectoriel).
@ -307,6 +417,7 @@ impl AgentProfile {
cwd_template,
session,
structured_adapter: None,
mcp: None,
})
}
@ -319,6 +430,15 @@ impl AgentProfile {
self
}
/// Builder : fixe la [`McpCapability`] (§14.3, orchestration v3) et renvoie le
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
/// profils sans MCP ne l'appellent simplement pas.
#[must_use]
pub fn with_mcp(mut self, mcp: McpCapability) -> Self {
self.mcp = Some(mcp);
self
}
/// Indique si ce profil peut être **proposé à la sélection/création** d'un
/// agent (§17.3, lot D7). Source **unique** de vérité : un profil n'est
/// sélectionnable que s'il porte un [`StructuredAdapter`], c'est-à-dire s'il
@ -334,3 +454,175 @@ impl AgentProfile {
self.structured_adapter.is_some()
}
}
#[cfg(test)]
mod mcp_tests {
use super::*;
use crate::ids::ProfileId;
/// A reference profile without any MCP capability (the historical shape).
fn profile_without_mcp() -> AgentProfile {
AgentProfile::new(
ProfileId::from_uuid(uuid::Uuid::nil()),
"Dev",
"claude",
vec!["-p".to_owned()],
ContextInjection::convention_file("CLAUDE.md").expect("valid target"),
Some("claude --version".to_owned()),
"{agentRunDir}",
None,
)
.expect("valid profile")
}
// -- Critère 1 : zéro régression de sérialisation (mcp = None) --------------
#[test]
fn profile_without_mcp_omits_mcp_key_in_json() {
let profile = profile_without_mcp();
assert!(profile.mcp.is_none());
let json = serde_json::to_string(&profile).expect("serialise");
assert!(
!json.contains("\"mcp\""),
"a profile without MCP must NOT serialise an `mcp` key (zero regression); got: {json}"
);
}
#[test]
fn profile_without_mcp_round_trips_identically() {
let profile = profile_without_mcp();
let json = serde_json::to_string(&profile).expect("serialise");
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
assert_eq!(profile, back);
assert!(back.mcp.is_none());
}
#[test]
fn legacy_json_without_mcp_field_deserialises_to_none() {
// JSON produced before the `mcp` field existed: no `mcp` key at all.
let legacy = r#"{
"id": "00000000-0000-0000-0000-000000000000",
"name": "Dev",
"command": "claude",
"args": [],
"contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" },
"detect": null,
"cwdTemplate": "{agentRunDir}"
}"#;
let profile: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise");
assert!(profile.mcp.is_none());
}
// -- Critère 2 : round-trip avec MCP (les 3 stratégies) ---------------------
#[test]
fn profile_with_mcp_config_file_round_trips() {
let cap = McpCapability::new(
McpConfigStrategy::config_file(".mcp.json").expect("valid target"),
McpTransport::Stdio,
);
let profile = profile_without_mcp().with_mcp(cap.clone());
assert_eq!(profile.mcp, Some(cap));
let json = serde_json::to_string(&profile).expect("serialise");
assert!(json.contains("\"mcp\""), "mcp key must be present: {json}");
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
assert_eq!(profile, back);
}
#[test]
fn mcp_capability_flag_strategy_round_trips() {
let cap = McpCapability::new(
McpConfigStrategy::flag("--mcp-config {path}").expect("valid flag"),
McpTransport::Socket,
);
let json = serde_json::to_string(&cap).expect("serialise");
let back: McpCapability = serde_json::from_str(&json).expect("deserialise");
assert_eq!(cap, back);
assert_eq!(back.transport, McpTransport::Socket);
assert!(matches!(back.config, McpConfigStrategy::Flag { .. }));
}
#[test]
fn mcp_capability_env_strategy_round_trips() {
let cap = McpCapability::new(
McpConfigStrategy::env("IDEA_MCP_SERVER").expect("valid env"),
McpTransport::Stdio,
);
let json = serde_json::to_string(&cap).expect("serialise");
let back: McpCapability = serde_json::from_str(&json).expect("deserialise");
assert_eq!(cap, back);
assert!(matches!(back.config, McpConfigStrategy::Env { .. }));
}
#[test]
fn mcp_config_strategy_uses_tagged_camel_case() {
// The `strategy` tag and camelCase rename are part of the wire contract.
let json = serde_json::to_string(
&McpConfigStrategy::config_file(".mcp.json").expect("valid"),
)
.expect("serialise");
assert!(json.contains("\"strategy\":\"configFile\""), "got: {json}");
}
// -- Critère 4 : transport par défaut = Stdio -------------------------------
#[test]
fn transport_default_is_stdio() {
assert_eq!(McpTransport::default(), McpTransport::Stdio);
}
#[test]
fn mcp_capability_without_transport_defaults_to_stdio() {
// `transport` is `#[serde(default)]`: an MCP capability serialised without
// it must deserialise to Stdio.
let json = r#"{ "config": { "strategy": "configFile", "target": ".mcp.json" } }"#;
let cap: McpCapability = serde_json::from_str(json).expect("deserialise");
assert_eq!(cap.transport, McpTransport::Stdio);
}
// -- Critère 3 : validation des constructeurs -------------------------------
#[test]
fn config_file_rejects_absolute_target() {
let err = McpConfigStrategy::config_file("/etc/mcp.json").unwrap_err();
assert!(matches!(err, DomainError::PathNotRelativeSafe { .. }));
}
#[test]
fn config_file_rejects_parent_traversal() {
let err = McpConfigStrategy::config_file("../escape/.mcp.json").unwrap_err();
assert!(matches!(err, DomainError::PathNotRelativeSafe { .. }));
}
#[test]
fn config_file_accepts_safe_relative_target() {
let s = McpConfigStrategy::config_file(".mcp.json").expect("safe relative");
assert_eq!(s, McpConfigStrategy::ConfigFile { target: ".mcp.json".to_owned() });
}
#[test]
fn flag_rejects_empty() {
let err = McpConfigStrategy::flag("").unwrap_err();
assert!(matches!(err, DomainError::EmptyField { .. }));
}
#[test]
fn flag_accepts_non_empty() {
let s = McpConfigStrategy::flag("--mcp-config {path}").expect("non-empty");
assert_eq!(s, McpConfigStrategy::Flag { flag: "--mcp-config {path}".to_owned() });
}
#[test]
fn env_rejects_invalid_name() {
let err = McpConfigStrategy::env("1BAD-NAME").unwrap_err();
assert!(matches!(err, DomainError::InvalidEnvVar { .. }));
}
#[test]
fn env_accepts_valid_name() {
let s = McpConfigStrategy::env("IDEA_MCP_SERVER").expect("valid");
assert_eq!(s, McpConfigStrategy::Env { var: "IDEA_MCP_SERVER".to_owned() });
}
}