Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 47 additions & 29 deletions crates/mcp-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -64,21 +66,19 @@ pub fn channel_launch_args(base: &[String]) -> Vec<String> {

/// 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<String>) {
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<String>) {
(
"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
Expand All @@ -88,8 +88,7 @@ fn spawn_form(entry: &str) -> (&'static str, Vec<String>) {
/// 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<PathBuf, String> {
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() {
Expand Down Expand Up @@ -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,
}
}

Expand All @@ -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(), &registration(&entry)).expect("register");
let entry = PathBuf::from(ENTRY);
let runner = PathBuf::from(RUNNER);
let path =
register_sidecar(scratch.path(), &registration(&entry, &runner)).expect("register");

let json = read(&path);
let server = &json["mcpServers"][SERVER_NAME];
Expand All @@ -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]
Expand All @@ -204,8 +217,10 @@ mod tests {
)
.expect("seed");

let entry = PathBuf::from("sidecar/src/index.ts");
let path = register_sidecar(scratch.path(), &registration(&entry)).expect("register");
let entry = PathBuf::from(ENTRY);
let runner = PathBuf::from(RUNNER);
let path =
register_sidecar(scratch.path(), &registration(&entry, &runner)).expect("register");

let json = read(&path);
assert_eq!(json["mcpServers"]["theirs"]["command"], "their-server");
Expand All @@ -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(), &registration(&entry)).expect("first");
register_sidecar(scratch.path(), &registration(&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");

Expand All @@ -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(), &registration(&entry)).unwrap_err();
let entry = PathBuf::from(ENTRY);
let runner = PathBuf::from(RUNNER);
let err = register_sidecar(scratch.path(), &registration(&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"),
Expand Down
9 changes: 9 additions & 0 deletions docs/0-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,15 @@ push の形(参照実装 `Liplus-Project/github-webhook-mcp` `local-mcp/src/in

人間の発言も、エージェントの返信も、フロントエンドへは同一の `room-message` イベントとして届く。並び順の権威を 1 箇所に保つためであり、送信時にフロント側でローカルに追記しない。

### サイドカーの起動形

`.mcp.json` に書く起動コマンドは、絶対パスのみで構成し、名前による解決を含めない。

- command = `node`
- args = [`<liplus-chat>/node_modules/tsx/dist/cli.mjs`, `<sidecar entry>`]

サイドカーを spawn するのは CLI であり、その作業ディレクトリはユーザーのプロジェクトである。名前で引くものはそこで引かれる。`npx tsx` は liplus-chat 側にしか無い `tsx` をユーザーのディレクトリで探し、見つからずインストールの確認を出す——非対話で spawn されたプロセスにその確認へ答える経路は無い。

### 部屋の作法

部屋のルールは MCP サーバが initialize 時に返す `instructions` 文字列により全エージェントへ一元的に配布する。実測では、当該ターンで返信を指示していないにもかかわらずエージェントが返信 tool を呼んだ。駆動源はこの `instructions` である。
Expand Down
53 changes: 40 additions & 13 deletions src-tauri/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf, String> {
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<Option<PathBuf>, 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.
Expand Down Expand Up @@ -89,14 +115,15 @@ 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 {
room_url: &room_url,
token: &room.token(),
agent_name: &tab.name,
sidecar_entry: &sidecar_entry,
sidecar_runner: &sidecar_runner,
},
)?;

Expand Down
Loading