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

@ -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();