fix(web): démarre le serveur MCP custom CLI via idea-serve au lieu de node — #224

This commit is contained in:
2026-08-20 12:49:00 +02:00
parent b25c17c095
commit 9a3ca51f6b
9 changed files with 600 additions and 56 deletions

View File

@ -3441,23 +3441,9 @@ fn opencode_config_json(
}),
);
let (command, args) = match runtime {
Some(rt) => (
rt.exe.clone(),
vec![
"mcp-server".to_owned(),
"--endpoint".to_owned(),
rt.endpoint.clone(),
"--project".to_owned(),
rt.project_id.clone(),
"--requester".to_owned(),
rt.requester.clone(),
],
),
None => ("idea".to_owned(), vec!["mcp-server".to_owned()]),
};
let command_array = std::iter::once(command)
.chain(args)
let wiring = mcp_server_wiring(domain::profile::McpTransport::Stdio, runtime);
let command_array = std::iter::once(wiring.command)
.chain(wiring.args)
.map(serde_json::Value::String)
.collect::<Vec<_>>();
root.insert(
@ -5334,6 +5320,37 @@ command = "idea-mcp"
assert!(body.get("permission").is_none());
}
#[test]
fn opencode_config_json_with_runtime_uses_idea_exe_not_node() {
let config =
domain::profile::OpenCodeConfig::new("http://localhost:8080", None, "m", None, None)
.unwrap();
let runtime = McpRuntime {
exe: "/opt/idea/idea-serve".to_owned(),
endpoint: "/run/user/1000/idea-mcp/proj.sock".to_owned(),
project_id: "0123456789abcdef0123456789abcdef".to_owned(),
requester: "11112222-3333-4444-5555-666677778888".to_owned(),
};
let body = opencode_config_json(&config, "/project", Some(&runtime), None);
let command = body["mcp"]["idea"]["command"]
.as_array()
.expect("OpenCode local command is an argv array")
.iter()
.filter_map(serde_json::Value::as_str)
.collect::<Vec<_>>();
assert_eq!(command[0], "/opt/idea/idea-serve");
assert_ne!(command[0], "node");
assert_eq!(command[1], "mcp-server");
assert!(command.contains(&"--endpoint"));
assert!(command.contains(&"/run/user/1000/idea-mcp/proj.sock"));
assert!(command.contains(&"--project"));
assert!(command.contains(&"0123456789abcdef0123456789abcdef"));
assert!(command.contains(&"--requester"));
assert!(command.contains(&"11112222-3333-4444-5555-666677778888"));
}
#[test]
fn opencode_config_json_projects_allow_and_deny() {
let config =

View File

@ -2594,6 +2594,80 @@ async fn launch_opencode_writes_isolated_config_and_env() {
);
}
#[tokio::test]
async fn launch_opencode_chat_with_runtime_uses_idea_bridge_without_pty_or_node() {
let profile = opencode_profile(pid(9));
let (launch, agent, fs, pty, _bus, _sessions, tr, _session) = launch_fixture_with_profile(
profile,
Some(ContextInjectionPlan::File {
target: "AGENTS.md".to_owned(),
}),
);
let factory = FakeStructuredFactory::new(Arc::clone(&tr), sid(888));
let structured = Arc::new(StructuredSessions::new());
let launch = launch
.with_structured_routing_mode(StructuredRoutingMode::HumanPtyFallback)
.with_structured(Arc::new(factory.clone()), Arc::clone(&structured));
let runtime = application::McpRuntime {
exe: "/opt/idea/idea-serve".to_owned(),
endpoint: "/run/user/1000/idea-mcp/proj.sock".to_owned(),
project_id: "0123456789abcdef0123456789abcdef".to_owned(),
requester: agent.id.to_string(),
};
let mut input = launch_input_with_runtime(agent.id, runtime);
input.require_structured = true;
input.node_id = Some(nid(224));
let out = launch.execute(input).await.unwrap();
assert_eq!(factory.starts(), vec![pid(9)]);
assert!(
pty.spawns().is_empty(),
"web custom chat must stay structured"
);
assert!(
out.structured.is_some(),
"backend returns a chat descriptor"
);
assert_eq!(
structured.node_for_agent_in_project(project().id, &agent.id),
Some(nid(224))
);
let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
let opencode = writes_ending_with(&fs, "/opencode.json");
assert_eq!(opencode.len(), 1, "one OpenCode config is written");
let body: serde_json::Value = serde_json::from_slice(&opencode[0].1).unwrap();
let command = body["mcp"]["idea"]["command"]
.as_array()
.expect("OpenCode local command is an argv array")
.iter()
.filter_map(serde_json::Value::as_str)
.collect::<Vec<_>>();
assert_eq!(command[0], "/opt/idea/idea-serve");
assert_ne!(command[0], "node");
assert_eq!(command[1], "mcp-server");
assert!(command.contains(&"--endpoint"));
assert!(command.contains(&"/run/user/1000/idea-mcp/proj.sock"));
assert!(command.contains(&"--project"));
assert!(command.contains(&"0123456789abcdef0123456789abcdef"));
assert!(command.contains(&"--requester"));
let requester = agent.id.to_string();
assert!(command.contains(&requester.as_str()));
let env: std::collections::HashMap<_, _> = factory
.envs()
.into_iter()
.next()
.expect("structured start receives env")
.into_iter()
.collect();
assert_eq!(
env.get("OPENCODE_CONFIG").map(String::as_str),
Some(format!("{run_dir}/opencode.json").as_str())
);
}
#[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

View File

@ -113,6 +113,7 @@ use infrastructure::{
pub mod dto;
pub mod events;
pub mod mcp_bridge;
pub mod mcp_endpoint;
pub mod openai_tools;
pub mod stream;

View File

@ -0,0 +1,260 @@
//! The `idea mcp-server` bridge shared by desktop and standalone web binaries.
//!
//! An MCP CLI spawns `<exe> mcp-server --endpoint <…> --project <…> --requester <…>`.
//! This process stays headless and relays JSON-RPC lines between stdio and the
//! per-project local loopback endpoint where the real MCP server listens.
use std::process::ExitCode;
use std::time::Duration;
use interprocess::local_socket::tokio::Stream as LocalSocketStream;
use interprocess::local_socket::traits::tokio::Stream as _;
use interprocess::local_socket::{GenericFilePath, ToFsName as _};
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
const DRAIN_GRACE: Duration = Duration::from_secs(1);
/// Parsed `mcp-server` invocation arguments.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BridgeArgs {
/// The loopback address to connect to.
pub endpoint: String,
/// The project id forwarded in the handshake.
pub project: String,
/// The requester id forwarded in the handshake.
pub requester: String,
}
impl BridgeArgs {
/// Parses `mcp-server`'s own arguments.
pub fn parse<I, S>(args: I) -> Result<Self, String>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let mut endpoint: Option<String> = None;
let mut project = String::new();
let mut requester = String::new();
let mut it = args.into_iter().map(Into::into);
while let Some(flag) = it.next() {
match flag.as_str() {
"--endpoint" => {
endpoint = Some(
it.next()
.ok_or_else(|| "--endpoint requires a value".to_string())?,
);
}
"--project" => {
project = it
.next()
.ok_or_else(|| "--project requires a value".to_string())?;
}
"--requester" => {
requester = it
.next()
.ok_or_else(|| "--requester requires a value".to_string())?;
}
other => return Err(format!("unknown argument: {other}")),
}
}
let endpoint = endpoint.ok_or_else(|| "--endpoint is required".to_string())?;
Ok(Self {
endpoint,
project,
requester,
})
}
fn handshake_line(&self) -> Vec<u8> {
let line = serde_json::json!({
"project": self.project,
"requester": self.requester,
})
.to_string();
let mut bytes = line.into_bytes();
bytes.push(b'\n');
bytes
}
}
/// Synchronous entry point for the headless stdio bridge.
#[must_use]
pub fn run_mcp_bridge<I, S>(args: I) -> ExitCode
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let args = match BridgeArgs::parse(args) {
Ok(a) => a,
Err(e) => {
eprintln!("idea mcp-server: {e}");
return ExitCode::FAILURE;
}
};
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_time()
.build()
{
Ok(rt) => rt,
Err(e) => {
eprintln!("idea mcp-server: failed to start runtime: {e}");
return ExitCode::FAILURE;
}
};
runtime.block_on(async move {
match bridge_over_loopback(&args).await {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("idea mcp-server: {e}");
ExitCode::FAILURE
}
}
})
}
async fn bridge_over_loopback(args: &BridgeArgs) -> Result<(), String> {
let conn = connect_loopback(&args.endpoint).await?;
let (lp_read, lp_write) = tokio::io::split(conn);
relay(
args,
tokio::io::stdin(),
tokio::io::stdout(),
lp_read,
lp_write,
)
.await
}
async fn connect_loopback(endpoint: &str) -> Result<LocalSocketStream, String> {
let name = endpoint
.to_fs_name::<GenericFilePath>()
.map_err(|e| format!("invalid endpoint {endpoint:?}: {e}"))?;
match tokio::time::timeout(CONNECT_TIMEOUT, LocalSocketStream::connect(name)).await {
Ok(Ok(stream)) => Ok(stream),
Ok(Err(e)) => Err(format!("cannot connect to endpoint {endpoint:?}: {e}")),
Err(_) => Err(format!(
"timed out connecting to endpoint {endpoint:?} after {}s",
CONNECT_TIMEOUT.as_secs()
)),
}
}
async fn relay<CIn, COut, LIn, LOut>(
args: &BridgeArgs,
cli_in: CIn,
cli_out: COut,
lp_read: LIn,
mut lp_write: LOut,
) -> Result<(), String>
where
CIn: AsyncRead + Unpin,
COut: AsyncWrite + Unpin,
LIn: AsyncRead + Unpin,
LOut: AsyncWrite + Unpin,
{
lp_write
.write_all(&args.handshake_line())
.await
.map_err(|e| format!("handshake write failed: {e}"))?;
lp_write
.flush()
.await
.map_err(|e| format!("handshake flush failed: {e}"))?;
let cli_to_lp = pump_lines(BufReader::new(cli_in), lp_write, "stdin", "loopback");
let lp_to_cli = pump_lines(BufReader::new(lp_read), cli_out, "loopback", "stdout");
tokio::pin!(cli_to_lp);
tokio::pin!(lp_to_cli);
tokio::select! {
r = &mut cli_to_lp => {
let mut lp_write = r?;
let _ = lp_write.shutdown().await;
let _ = tokio::time::timeout(DRAIN_GRACE, &mut lp_to_cli).await;
Ok(())
}
r = &mut lp_to_cli => r.map(|_| ()),
}
}
async fn pump_lines<R, W>(
mut reader: BufReader<R>,
mut writer: W,
src: &str,
dst: &str,
) -> Result<W, String>
where
R: AsyncRead + Unpin,
W: AsyncWrite + Unpin,
{
let mut line = String::new();
loop {
line.clear();
let n = reader
.read_line(&mut line)
.await
.map_err(|e| format!("{src} read failed: {e}"))?;
if n == 0 {
writer
.flush()
.await
.map_err(|e| format!("{dst} flush failed: {e}"))?;
return Ok(writer);
}
writer
.write_all(line.as_bytes())
.await
.map_err(|e| format!("{dst} write failed: {e}"))?;
writer
.flush()
.await
.map_err(|e| format!("{dst} flush failed: {e}"))?;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_all_three_flags() {
let args = BridgeArgs::parse([
"--endpoint",
"/tmp/x.sock",
"--project",
"p1",
"--requester",
"agent-7",
])
.unwrap();
assert_eq!(args.endpoint, "/tmp/x.sock");
assert_eq!(args.project, "p1");
assert_eq!(args.requester, "agent-7");
}
#[test]
fn handshake_line_carries_identity_as_json() {
let args = BridgeArgs {
endpoint: "/tmp/x.sock".into(),
project: "proj".into(),
requester: "rq".into(),
};
let line = args.handshake_line();
assert_eq!(*line.last().unwrap(), b'\n');
let value: serde_json::Value = serde_json::from_slice(&line[..line.len() - 1]).unwrap();
assert_eq!(value["project"], "proj");
assert_eq!(value["requester"], "rq");
}
#[test]
fn missing_endpoint_returns_failure() {
assert_eq!(run_mcp_bridge(["--project", "p"]), ExitCode::FAILURE);
}
}

View File

@ -1,5 +1,12 @@
use std::process::ExitCode;
const MCP_SERVER_SUBCOMMAND: &str = "mcp-server";
fn main() -> ExitCode {
let mut args = std::env::args_os().skip(1);
if args.next().is_some_and(|a| a == *MCP_SERVER_SUBCOMMAND) {
let rest: Vec<String> = args.map(|a| a.to_string_lossy().into_owned()).collect();
return backend::mcp_bridge::run_mcp_bridge(rest);
}
web_server::run_from_args(std::env::args().skip(1).collect())
}

View File

@ -53,10 +53,11 @@ use application::{
PairAttemptDecision, PairDeviceInput, RateLimitKey, ReadAgentContextInput,
ReadConversationPageInput, ReadIssueAttachmentInput, ReadIssueCarnetInput, ReadIssueInput,
ReadMcpToolPermissionsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput,
RenameDeviceInput, RenameLayoutInput, RenameSprintInput, ReorderSprintsInput,
ResizeTerminalInput, ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput,
ResolveMemoryLinksInput, RevokeDeviceInput, RotateConversationLogInput, SetActiveLayoutInput,
StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput, UnassignSkillFromAgentInput,
ReconcileLayoutsInput, ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput,
RenameSprintInput, ReorderSprintsInput, ResizeTerminalInput, ResolveAgentPermissionsInput,
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
RotateConversationLogInput, SetActiveLayoutInput, StopLiveAgentInput,
SyncAgentWithTemplateInput, TouchDeviceInput, UnassignSkillFromAgentInput,
UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateAgentContextInput,
UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput, UpdateMemoryInput,
@ -506,33 +507,55 @@ fn config_with_effective_listen(mut config: ServerConfig, local_addr: SocketAddr
}
fn resolve_web_root(explicit: Option<PathBuf>) -> Result<PathBuf, String> {
if let Some(path) = explicit {
return validate_web_root(path, "--web-root");
}
if let Some(path) = env::var_os("IDEA_WEB_ROOT").map(PathBuf::from) {
return validate_web_root(path, "IDEA_WEB_ROOT");
}
let mut candidates = Vec::new();
if let Ok(exe) = env::current_exe() {
if let Some(dir) = exe.parent() {
candidates.push(dir.join("web"));
candidates.push(dir.join("frontend").join("dist"));
}
}
if let Ok(cwd) = env::current_dir() {
candidates.push(cwd.join("frontend").join("dist"));
}
for candidate in candidates {
for (candidate, source) in web_root_candidates(explicit) {
if candidate.join("index.html").is_file() {
return Ok(candidate);
}
if matches!(source, "--web-root" | "IDEA_WEB_ROOT") {
return validate_web_root(candidate, source);
}
}
Err("web assets not found: build frontend/dist or pass --web-root PATH".to_owned())
}
fn web_root_candidates(explicit: Option<PathBuf>) -> Vec<(PathBuf, &'static str)> {
let mut candidates = Vec::new();
if let Some(path) = explicit {
candidates.push((path, "--web-root"));
}
if let Some(path) = env::var_os("IDEA_WEB_ROOT").map(PathBuf::from) {
candidates.push((path, "IDEA_WEB_ROOT"));
}
if let Some(appdir) = env::var_os("APPDIR").map(PathBuf::from) {
candidates.extend(
web_root_candidates_from_appdir(&appdir)
.into_iter()
.map(|candidate| (candidate, "APPDIR")),
);
}
if let Ok(exe) = env::current_exe() {
if let Some(dir) = exe.parent() {
candidates.push((dir.join("web"), "current_exe"));
candidates.push((dir.join("frontend").join("dist"), "current_exe"));
if let Some(prefix) = dir.parent() {
candidates.push((prefix.join("lib").join("IdeA").join("web"), "current_exe"));
}
}
}
if let Ok(cwd) = env::current_dir() {
candidates.push((cwd.join("frontend").join("dist"), "cwd"));
}
candidates
}
fn web_root_candidates_from_appdir(appdir: &Path) -> Vec<PathBuf> {
vec![
appdir.join("usr").join("lib").join("IdeA").join("web"),
appdir.join("web"),
]
}
fn validate_web_root(path: PathBuf, source: &str) -> Result<PathBuf, String> {
if path.join("index.html").is_file() {
Ok(path)
@ -3099,8 +3122,32 @@ async fn invoke_open_project(args: &Value, state: &BackendCore) -> Result<Value,
code: "INVALID".to_owned(),
message: "open_project requires args.projectId".to_owned(),
})?;
let project = resolve_project_readonly(project_id, state).await?;
serde_json::to_value(ProjectDto::from(project)).map_err(serialization_error)
let project = open_project_for_web(project_id, state).await?;
serde_json::to_value(project).map_err(serialization_error)
}
async fn open_project_for_web(
project_id: &str,
state: &BackendCore,
) -> Result<ProjectDto, ErrorDto> {
let id = parse_project_id(project_id)?;
let output = state
.open_project
.execute(OpenProjectInput { project_id: id })
.await
.map_err(ErrorDto::from)?;
let _ = state
.reconcile_layouts
.execute(ReconcileLayoutsInput { project_id: id })
.await;
let _ = state
.reconcile_live_state
.execute(ReconcileLiveStateInput { project_id: id })
.await;
let _ = state.reconcile_background_tasks.execute(id).await;
state.ensure_orchestrator_watch(&output.project);
state.reconcile_claude_run_dirs(&output.project).await;
Ok(ProjectDto::from(output))
}
async fn invoke_load_layout(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
@ -8359,6 +8406,15 @@ mod tests {
assert!(validate_web_root(create_web_root(), "--web-root").is_ok());
}
#[test]
fn web_root_candidates_include_appimage_resource_dir() {
let appdir = PathBuf::from("/tmp/.mount_IdeA_test");
let candidates = web_root_candidates_from_appdir(&appdir);
assert!(candidates.contains(&appdir.join("usr").join("lib").join("IdeA").join("web")));
assert!(candidates.contains(&appdir.join("web")));
}
#[tokio::test]
async fn non_allowed_origin_is_refused() {
let state = state();
@ -8472,6 +8528,49 @@ mod tests {
assert!(body["root"].is_string());
}
#[tokio::test]
async fn authorized_open_project_arms_orchestrator_watch() {
let config = test_config();
let core = Arc::new(BackendCore::build(config.app_data_dir.clone()));
let project_id = create_project_on_core(&core, "MCP Armed Open").await;
let state = Arc::new(ServerState::with_core(config, core));
state.set_pairing_code_for_test("PAIR1234".to_owned());
let cookie = pair_and_cookie(Arc::clone(&state)).await;
assert!(
state.app.open_project_ids().is_empty(),
"precondition: the project must not already be armed before HTTP open_project"
);
let response = request(
Arc::clone(&state),
Method::POST,
"/api/invoke",
json!({ "command": "open_project", "args": { "projectId": project_id } }),
&[("cookie", &cookie)],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["id"], project_id);
let expected = parse_project_id(&project_id)
.expect("test project id parses")
.as_uuid()
.simple()
.to_string();
let opened = state
.app
.open_project_ids()
.into_iter()
.map(|id| id.as_uuid().simple().to_string())
.collect::<Vec<_>>();
assert!(
opened.iter().any(|id| id == &expected),
"expected open_project HTTP flow to arm the orchestrator watch"
);
}
#[tokio::test]
async fn authorized_layout_invokes_match_tauri_contracts() {
let state = state();