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

@ -47,6 +47,12 @@ pub struct McpServer {
/// delegation arrived through the MCP door. `None` ⇒ no publication (the server
/// still works), so existing call sites stay valid.
events: Option<Arc<dyn Fn(DomainEvent) + Send + Sync>>,
/// Identity of the connected peer — the real agent id carried in the loopback
/// handshake (cadrage v5 §1.4). When non-empty it becomes
/// [`DomainEvent::OrchestratorRequestProcessed`]'s `requester_id` instead of the
/// frozen `"mcp"` placeholder; empty ⇒ the legacy `"mcp"` label (back-compat for
/// M2 callers and connections that arrive without a requester).
requester: String,
}
impl McpServer {
@ -59,6 +65,7 @@ impl McpServer {
service,
project,
events: None,
requester: String::new(),
}
}
@ -72,6 +79,35 @@ impl McpServer {
self
}
/// Returns a per-connection clone of this server tagged with the connected
/// peer's `requester` id (the loopback handshake's `requester`, cadrage v5 §1.4).
///
/// The base server (one per project, M3) is shared by every connection; each
/// accepted peer derives its own contextualized server so concurrent peers never
/// share a requester. An empty `requester` keeps the legacy `"mcp"` label.
///
/// Cheap: only `Arc`s, a `Project` clone, and the requester `String` are copied.
#[must_use]
pub fn for_requester(&self, requester: impl Into<String>) -> Self {
Self {
service: Arc::clone(&self.service),
project: self.project.clone(),
events: self.events.clone(),
requester: requester.into(),
}
}
/// Serves JSON-RPC messages from `transport`, tagging every processed
/// `tools/call` with `requester` as the delegating agent (cadrage v5 §1.4).
///
/// A thin wrapper over [`serve`](Self::serve) on a per-connection
/// [`for_requester`](Self::for_requester) clone: the caller (the per-peer accept
/// loop) need not mutate the shared project server. An empty `requester` yields
/// the legacy `"mcp"` label.
pub async fn serve_as<T: Transport>(&self, requester: impl Into<String>, transport: &mut T) {
self.for_requester(requester).serve(transport).await;
}
/// Serves JSON-RPC messages from `transport` until it closes (clean EOF).
///
/// Every inbound line is handled in isolation: a malformed line or an unknown
@ -211,12 +247,18 @@ impl McpServer {
/// Publishes [`DomainEvent::OrchestratorRequestProcessed`] for a handled
/// `tools/call`, tagged [`OrchestrationSource::Mcp`]. The requester id is the
/// stable `"mcp"` label until the per-session transport bind (open point S-MCP)
/// carries the connected agent's identity. No-op without an event sink.
/// connected peer's real agent id (carried in the loopback handshake, cadrage v5
/// §1.4), falling back to the legacy `"mcp"` label when no identity was supplied.
/// No-op without an event sink.
fn publish_processed(&self, action: &str, ok: bool) {
if let Some(publish) = &self.events {
let requester_id = if self.requester.is_empty() {
"mcp".to_owned()
} else {
self.requester.clone()
};
publish(DomainEvent::OrchestratorRequestProcessed {
requester_id: "mcp".to_owned(),
requester_id,
action: action.to_owned(),
ok,
source: OrchestrationSource::Mcp,