diff --git a/crates/mcp-config/src/lib.rs b/crates/mcp-config/src/lib.rs index aca4d0b..209c515 100644 --- a/crates/mcp-config/src/lib.rs +++ b/crates/mcp-config/src/lib.rs @@ -36,6 +36,8 @@ pub struct RoomRegistration<'a> { pub agent_name: &'a str, /// Absolute path of the sidecar entry point. pub sidecar_entry: &'a Path, + /// Absolute path of the TypeScript runner that executes the entry point. + pub sidecar_runner: &'a Path, } /// Reject a launch whose flags would leave the session unable to hear the room. @@ -64,21 +66,19 @@ pub fn channel_launch_args(base: &[String]) -> Vec { /// How the CLI should spawn the sidecar, as a `.mcp.json` command and args. /// -/// npx resolves through PATHEXT on Windows only when a shell runs it. -fn spawn_form(entry: &str) -> (&'static str, Vec) { - if cfg!(windows) { - ( - "cmd", - vec![ - "/c".to_string(), - "npx".to_string(), - "tsx".to_string(), - entry.to_string(), - ], - ) - } else { - ("npx", vec!["tsx".to_string(), entry.to_string()]) - } +/// Absolute paths and nothing to resolve. The CLI spawns this from the user's +/// own project directory, so anything looked up by name is looked up there: +/// `npx tsx` searched for a package that lives in liplus-chat and asked to +/// install it, from a process with no way to answer (#22). `node` is an +/// executable rather than a shell script, so no shell wrapper is needed either. +fn spawn_form(runner: &Path, entry: &Path) -> (&'static str, Vec) { + ( + "node", + vec![ + runner.to_string_lossy().to_string(), + entry.to_string_lossy().to_string(), + ], + ) } /// Merge the room server into the `.mcp.json` at `dir`, preserving whatever @@ -88,8 +88,7 @@ fn spawn_form(entry: &str) -> (&'static str, Vec) { /// where a Claude Code MCP server is normally registered. Only this one key is /// touched; existing servers and unrelated top-level keys survive verbatim. pub fn register_sidecar(dir: &Path, room: &RoomRegistration<'_>) -> Result { - let entry = room.sidecar_entry.to_string_lossy().to_string(); - let (command, args) = spawn_form(&entry); + let (command, args) = spawn_form(room.sidecar_runner, room.sidecar_entry); let path = dir.join(".mcp.json"); let mut root: Value = if path.exists() { @@ -162,12 +161,16 @@ mod tests { } } - fn registration<'a>(entry: &'a Path) -> RoomRegistration<'a> { + const ENTRY: &str = "C:/liplus-chat/sidecar/src/index.ts"; + const RUNNER: &str = "C:/liplus-chat/node_modules/tsx/dist/cli.mjs"; + + fn registration<'a>(entry: &'a Path, runner: &'a Path) -> RoomRegistration<'a> { RoomRegistration { room_url: "ws://127.0.0.1:1234", token: "tok", agent_name: "Lin", sidecar_entry: entry, + sidecar_runner: runner, } } @@ -178,8 +181,10 @@ mod tests { #[test] fn registers_the_room_server_when_no_config_exists() { let scratch = Scratch::new(); - let entry = PathBuf::from("sidecar/src/index.ts"); - let path = register_sidecar(scratch.path(), ®istration(&entry)).expect("register"); + let entry = PathBuf::from(ENTRY); + let runner = PathBuf::from(RUNNER); + let path = + register_sidecar(scratch.path(), ®istration(&entry, &runner)).expect("register"); let json = read(&path); let server = &json["mcpServers"][SERVER_NAME]; @@ -188,9 +193,17 @@ mod tests { assert_eq!(server["env"]["LIPLUS_AGENT_NAME"], "Lin"); assert_eq!(server["env"]["LIPLUS_ROOM_ID"], "liplus-chat"); - let args = server["args"].as_array().expect("args"); - let last = args.last().expect("entry argument"); - assert_eq!(last, "sidecar/src/index.ts"); + // Absolute paths and nothing looked up by name: the CLI runs this from + // the user's own directory, where `npx tsx` found no tsx and asked to + // install one (#22). + assert_eq!(server["command"], "node"); + assert_eq!( + server["args"].as_array().expect("args"), + &vec![ + Value::String(RUNNER.to_string()), + Value::String(ENTRY.to_string()), + ] + ); } #[test] @@ -204,8 +217,10 @@ mod tests { ) .expect("seed"); - let entry = PathBuf::from("sidecar/src/index.ts"); - let path = register_sidecar(scratch.path(), ®istration(&entry)).expect("register"); + let entry = PathBuf::from(ENTRY); + let runner = PathBuf::from(RUNNER); + let path = + register_sidecar(scratch.path(), ®istration(&entry, &runner)).expect("register"); let json = read(&path); assert_eq!(json["mcpServers"]["theirs"]["command"], "their-server"); @@ -216,14 +231,16 @@ mod tests { #[test] fn re_registering_replaces_only_its_own_entry() { let scratch = Scratch::new(); - let entry = PathBuf::from("sidecar/src/index.ts"); + let entry = PathBuf::from(ENTRY); + let runner = PathBuf::from(RUNNER); - register_sidecar(scratch.path(), ®istration(&entry)).expect("first"); + register_sidecar(scratch.path(), ®istration(&entry, &runner)).expect("first"); let second = RoomRegistration { room_url: "ws://127.0.0.1:9999", token: "tok2", agent_name: "Lay", sidecar_entry: &entry, + sidecar_runner: &runner, }; let path = register_sidecar(scratch.path(), &second).expect("second"); @@ -244,8 +261,9 @@ mod tests { let seeded = "{ not json"; std::fs::write(scratch.path().join(".mcp.json"), seeded).expect("seed"); - let entry = PathBuf::from("sidecar/src/index.ts"); - let err = register_sidecar(scratch.path(), ®istration(&entry)).unwrap_err(); + let entry = PathBuf::from(ENTRY); + let runner = PathBuf::from(RUNNER); + let err = register_sidecar(scratch.path(), ®istration(&entry, &runner)).unwrap_err(); assert!(err.contains("not valid JSON"), "unexpected error: {err}"); assert_eq!( std::fs::read_to_string(scratch.path().join(".mcp.json")).expect("read"), diff --git a/docs/0-requirements.md b/docs/0-requirements.md index 5a3c662..81587ff 100644 --- a/docs/0-requirements.md +++ b/docs/0-requirements.md @@ -110,6 +110,15 @@ push の形(参照実装 `Liplus-Project/github-webhook-mcp` `local-mcp/src/in 人間の発言も、エージェントの返信も、フロントエンドへは同一の `room-message` イベントとして届く。並び順の権威を 1 箇所に保つためであり、送信時にフロント側でローカルに追記しない。 +### サイドカーの起動形 + +`.mcp.json` に書く起動コマンドは、絶対パスのみで構成し、名前による解決を含めない。 + +- command = `node` +- args = [`/node_modules/tsx/dist/cli.mjs`, ``] + +サイドカーを spawn するのは CLI であり、その作業ディレクトリはユーザーのプロジェクトである。名前で引くものはそこで引かれる。`npx tsx` は liplus-chat 側にしか無い `tsx` をユーザーのディレクトリで探し、見つからずインストールの確認を出す——非対話で spawn されたプロセスにその確認へ答える経路は無い。 + ### 部屋の作法 部屋のルールは MCP サーバが initialize 時に返す `instructions` 文字列により全エージェントへ一元的に配布する。実測では、当該ターンで返信を指示していないにもかかわらずエージェントが返信 tool を呼んだ。駆動源はこの `instructions` である。 diff --git a/src-tauri/src/session.rs b/src-tauri/src/session.rs index ad1c812..3b296ea 100644 --- a/src-tauri/src/session.rs +++ b/src-tauri/src/session.rs @@ -15,32 +15,58 @@ use mcp_config::{channel_launch_args, register_sidecar, reject_incompatible_flag use std::path::PathBuf; use tauri::AppHandle; -/// Where the sidecar entry point lives. +/// The sidecar entry point and the runner that executes it. +/// +/// Both come from one walk, and both are absolute. The CLI spawns the sidecar +/// from the user's own project directory, so a path resolved by name there +/// resolves against their tree, not ours (#22). /// /// Stage-one distribution runs from the repository, so the walk up from the -/// working directory is the normal path; the env override exists for a layout +/// working directory is the normal path; the env overrides exist for a layout /// this does not predict. -fn resolve_sidecar_entry() -> Result { - if let Ok(explicit) = std::env::var("LIPLUS_SIDECAR_ENTRY") { - let path = PathBuf::from(&explicit); - if path.is_file() { - return Ok(path); +fn resolve_sidecar_paths() -> Result<(PathBuf, PathBuf), String> { + fn from_env(key: &str) -> Result, String> { + match std::env::var(key) { + Err(_) => Ok(None), + Ok(value) => { + let path = PathBuf::from(&value); + if path.is_file() { + Ok(Some(path)) + } else { + Err(format!("{key} points at a missing file: {value}")) + } + } } - return Err(format!("LIPLUS_SIDECAR_ENTRY points at a missing file: {explicit}")); + } + + let entry_override = from_env("LIPLUS_SIDECAR_ENTRY")?; + let runner_override = from_env("LIPLUS_SIDECAR_RUNNER")?; + if let (Some(entry), Some(runner)) = (&entry_override, &runner_override) { + return Ok((entry.clone(), runner.clone())); } let mut dir = std::env::current_dir() .map_err(|e| format!("Failed to resolve the working directory: {e}"))?; for _ in 0..4 { - let candidate = dir.join("sidecar").join("src").join("index.ts"); - if candidate.is_file() { - return Ok(candidate); + let entry = dir.join("sidecar").join("src").join("index.ts"); + let runner = dir + .join("node_modules") + .join("tsx") + .join("dist") + .join("cli.mjs"); + if entry.is_file() && runner.is_file() { + return Ok(( + entry_override.unwrap_or(entry), + runner_override.unwrap_or(runner), + )); } if !dir.pop() { break; } } - Err("Could not find sidecar/src/index.ts. Set LIPLUS_SIDECAR_ENTRY to its path.".to_string()) + Err("Could not find sidecar/src/index.ts next to node_modules/tsx. \ + Run npm install, or set LIPLUS_SIDECAR_ENTRY and LIPLUS_SIDECAR_RUNNER." + .to_string()) } /// What the caller gets back after a session joins. @@ -89,7 +115,7 @@ pub fn start_session( return Err(format!("Tab \"{}\" points at a missing directory: {}", tab.name, cwd.display())); } - let sidecar_entry = resolve_sidecar_entry()?; + let (sidecar_entry, sidecar_runner) = resolve_sidecar_paths()?; let mcp_config = register_sidecar( &cwd, &RoomRegistration { @@ -97,6 +123,7 @@ pub fn start_session( token: &room.token(), agent_name: &tab.name, sidecar_entry: &sidecar_entry, + sidecar_runner: &sidecar_runner, }, )?;