From 3cabeb613c5c8377ed62e99c2a0f0b98d147e7a7 Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Fri, 21 Aug 2026 09:14:36 +0900 Subject: [PATCH 1/2] ci: run the frontend build, sidecar tests, and cargo test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI が npm ci と cargo check しか実行しておらず、#7 で追加した自動テストと 型検査が一度も CI で走っていなかった。フロントエンドの型エラーも素通りする 状態だったため、以下を check job へ追加した。 - npm run build(tsc + vite build) - npm run sidecar:check(サイドカーの型検査) - npm run sidecar:test(サイドカーの往復ハーネス) - cargo test --lib(.mcp.json マージ保全のテスト 3 本) cargo check は残している。cargo test に包含されるが、コンパイル失敗を先に 落とすほうが失敗箇所が読みやすく、README と docs が案内している parity コマンドでもあるため。 cargo test はローカル環境ではテストバイナリが STATUS_ENTRYPOINT_NOT_FOUND で 起動しない。CI 環境で起動するかはこの実行で確認する。 #12 --- .github/workflows/ci.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9963777..a8ffca0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,10 +39,23 @@ jobs: - run: npm ci + - name: Type-check and build the frontend + run: npm run build + + - name: Type-check the sidecar + run: npm run sidecar:check + + - name: Test the sidecar round trip + run: npm run sidecar:test + - name: Check Rust compilation working-directory: src-tauri run: cargo check --target x86_64-pc-windows-gnu + - name: Test Rust + working-directory: src-tauri + run: cargo test --lib --target x86_64-pc-windows-gnu + # Gate job: single Required status check for branch protection. # Add new jobs to `needs` when CI grows. No Settings change needed. CI: From c990c8fa07e92cdd59548f910349e9cf06ad2ffd Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Fri, 21 Aug 2026 09:24:48 +0900 Subject: [PATCH 2/2] refactor: extract the mcp config logic into a tauri-free crate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cargo test --lib は CI 環境でもテストバイナリが STATUS_ENTRYPOINT_NOT_FOUND (0xc0000139) で起動しないことが確定した (run 32431917979)。ローカル環境固有 ではない。test バイナリが tauri の依存ツリー全体をリンクすることによる GNU ターゲット上の不一致と見られる。 そのため .mcp.json への登録と起動フラグの検査を crates/mcp-config/ へ切り出し、 CI の cargo test はこの crate を対象にした。これらのロジックが tauri を必要と する理由はそもそも無く、切り出しはテストを通すための細工ではなく依存の誤りを 正す変更である。 src-tauri/src/session.rs に残したのは tauri の State / AppHandle に触れる部分 だけ。純粋ロジックをそちらへ書き足すと書いた時点で検証不能になるため、その 境界を docs へ明記した。 切り出しに伴いテストは 3 本から 8 本へ増えた。再登録がエントリを蓄積しない こと、--flag=value 形式の非互換フラグを取りこぼさないこと、起動フラグが .mcp.json の登録名と一致していることを追加している。最後の 1 本は、同じ事実が 二箇所にある状態のドリフトを固定するもの。ドリフトすると「何も届かない部屋」 として現れ、原因が最も見えにくい。 crates/*/target/ を .gitignore へ追加。 #12 --- .github/workflows/ci.yml | 11 +- .gitignore | 1 + README.md | 2 +- crates/mcp-config/Cargo.lock | 267 ++++++++++++++++++++++++++++++++ crates/mcp-config/Cargo.toml | 18 +++ crates/mcp-config/src/lib.rs | 291 +++++++++++++++++++++++++++++++++++ docs/0-requirements.md | 19 ++- src-tauri/Cargo.lock | 8 + src-tauri/Cargo.toml | 1 + src-tauri/src/room.rs | 4 - src-tauri/src/session.rs | 195 ++++------------------- 11 files changed, 639 insertions(+), 178 deletions(-) create mode 100644 crates/mcp-config/Cargo.lock create mode 100644 crates/mcp-config/Cargo.toml create mode 100644 crates/mcp-config/src/lib.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8ffca0..98a37e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,7 @@ jobs: ~/.cargo/registry ~/.cargo/git src-tauri/target + crates/mcp-config/target key: rust-${{ runner.os }}-${{ hashFiles('src-tauri/Cargo.lock') }} restore-keys: rust-${{ runner.os }}- @@ -52,9 +53,13 @@ jobs: working-directory: src-tauri run: cargo check --target x86_64-pc-windows-gnu - - name: Test Rust - working-directory: src-tauri - run: cargo test --lib --target x86_64-pc-windows-gnu + # Not in src-tauri: a test binary that links the tauri dependency tree + # does not load on the GNU target (STATUS_ENTRYPOINT_NOT_FOUND). The + # logic that most needs covering lives in a tauri-free crate for that + # reason. See docs/0-requirements.md. + - name: Test the mcp-config crate + working-directory: crates/mcp-config + run: cargo test # Gate job: single Required status check for branch protection. # Add new jobs to `needs` when CI grows. No Settings change needed. diff --git a/.gitignore b/.gitignore index 2d19094..96d47e8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules/ # Build output dist/ src-tauri/target/ +crates/*/target/ # Environment .env diff --git a/README.md b/README.md index 9d8fd27..c6507fa 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,6 @@ liplus-chat は、人間と複数の独立した AI / Li+ セッションが、 - 複数の AI セッションを同一の部屋へ参加させる運用(同時発話の抑制を含む) - 会話ログの永続化と観測 UI -- `cargo test` と `npm run sidecar:test` の CI 実行 - plugin としての allowlist 掲載(配布の第二段階) ## 部屋を動かす @@ -132,6 +131,7 @@ docs/0-requirements.md 要求仕様(設計の source of truth) sidecar/ 部屋の MCP channel サーバ(Node) src/ チャットルーム UI(TypeScript) src-tauri/src/ Tauri、部屋ソケット、PTY、設定・セッション保存の Rust 実装 +crates/mcp-config/ .mcp.json 登録と起動フラグ検査(tauri 非依存、テスト対象) portable-pty-patch/ Windows 対応を含む portable-pty のローカルパッチ .github/workflows/ Windows CI とリリース用 CD ``` diff --git a/crates/mcp-config/Cargo.lock b/crates/mcp-config/Cargo.lock new file mode 100644 index 0000000..e464bb0 --- /dev/null +++ b/crates/mcp-config/Cargo.lock @@ -0,0 +1,267 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "mcp-config" +version = "0.1.0" +dependencies = [ + "serde_json", + "uuid", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "uuid" +version = "1.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/mcp-config/Cargo.toml b/crates/mcp-config/Cargo.toml new file mode 100644 index 0000000..2684c8d --- /dev/null +++ b/crates/mcp-config/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "mcp-config" +version = "0.1.0" +description = "Registration of the liplus-chat room sidecar in a project's .mcp.json" +authors = ["Liplus Project Contributors"] +license = "Apache-2.0" +edition = "2021" + +# Deliberately free of tauri. This crate writes into the user's own project +# directory, which is the part of liplus-chat that most needs test coverage, +# and a test binary that links the tauri dependency tree does not load on the +# GNU target (see the CI note in docs/0-requirements.md). + +[dependencies] +serde_json = "1" + +[dev-dependencies] +uuid = { version = "1", features = ["v4"] } diff --git a/crates/mcp-config/src/lib.rs b/crates/mcp-config/src/lib.rs new file mode 100644 index 0000000..aca4d0b --- /dev/null +++ b/crates/mcp-config/src/lib.rs @@ -0,0 +1,291 @@ +//! Registering the room sidecar in a project's `.mcp.json`, and the flag +//! guard that keeps a launched session able to receive channel pushes. +//! +//! Both are conditions the round trip does not survive without (see the +//! 成立条件 in `docs/0-requirements.md`): +//! +//! - The sidecar must be registered **by name**. A config handed over with +//! `--mcp-config` does not resolve on the channel side. +//! - The launch must carry `--dangerously-load-development-channels +//! server:` and nothing else on that axis. Adding `--channels` +//! registers the same server twice and takes the whole room down. +//! +//! This crate holds no tauri: it writes into the user's own project directory, +//! which is the part of liplus-chat that most needs test coverage, and a test +//! binary linking the tauri tree does not load on the GNU target. + +use serde_json::{json, Map, Value}; +use std::path::{Path, PathBuf}; + +/// The name the sidecar is registered under in `.mcp.json`. The launch flag +/// carries the same name (`server:`), so the two must not drift apart. +pub const SERVER_NAME: &str = "liplus-chat-room"; + +/// Flags that silently stop channel pushes from arriving. +pub const INCOMPATIBLE_FLAGS: &[&str] = + &["--channels", "--print", "--input-format", "--output-format"]; + +/// What a session launch needs to know about the room it is joining. +#[derive(Debug, Clone)] +pub struct RoomRegistration<'a> { + /// `ws://127.0.0.1:` of the room socket. + pub room_url: &'a str, + /// Bearer token the sidecar must present. + pub token: &'a str, + /// Display name this session speaks under. + pub agent_name: &'a str, + /// Absolute path of the sidecar entry point. + pub sidecar_entry: &'a Path, +} + +/// Reject a launch whose flags would leave the session unable to hear the room. +/// +/// Rejected rather than stripped: a session that launches with the flag quietly +/// removed looks like it worked, and the failure then surfaces as silence. +/// Returns the offending flag. +pub fn reject_incompatible_flags(args: &[String]) -> Result<(), &'static str> { + for arg in args { + // `--flag=value` counts; matching the bare flag alone would miss it. + let head = arg.split('=').next().unwrap_or(arg); + if let Some(found) = INCOMPATIBLE_FLAGS.iter().find(|flag| **flag == head) { + return Err(found); + } + } + Ok(()) +} + +/// The launch arguments for a channel-enabled session, given the tab's own. +pub fn channel_launch_args(base: &[String]) -> Vec { + let mut args = base.to_vec(); + args.push("--dangerously-load-development-channels".to_string()); + args.push(format!("server:{SERVER_NAME}")); + args +} + +/// 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()]) + } +} + +/// Merge the room server into the `.mcp.json` at `dir`, preserving whatever +/// else is there. Returns the path written. +/// +/// This writes into the user's own project directory, because project scope is +/// 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 path = dir.join(".mcp.json"); + let mut root: Value = if path.exists() { + let text = std::fs::read_to_string(&path) + .map_err(|e| format!("Failed to read {}: {e}", path.display()))?; + // Truncating a file that failed to parse would destroy whatever it held. + serde_json::from_str(&text).map_err(|e| { + format!( + "{} exists but is not valid JSON ({e}). Fix or move it before starting a session.", + path.display() + ) + })? + } else { + Value::Object(Map::new()) + }; + + if !root.is_object() { + return Err(format!("{} is not a JSON object.", path.display())); + } + let obj = root.as_object_mut().expect("checked above"); + let servers = obj + .entry("mcpServers") + .or_insert_with(|| Value::Object(Map::new())); + if !servers.is_object() { + return Err(format!("{} has a non-object mcpServers.", path.display())); + } + + servers.as_object_mut().expect("checked above").insert( + SERVER_NAME.to_string(), + json!({ + "command": command, + "args": args, + "env": { + "LIPLUS_ROOM_URL": room.room_url, + "LIPLUS_ROOM_TOKEN": room.token, + "LIPLUS_AGENT_NAME": room.agent_name, + "LIPLUS_ROOM_ID": "liplus-chat", + }, + }), + ); + + let text = serde_json::to_string_pretty(&root) + .map_err(|e| format!("Failed to serialize {}: {e}", path.display()))?; + std::fs::write(&path, text).map_err(|e| format!("Failed to write {}: {e}", path.display()))?; + + Ok(path) +} + +#[cfg(test)] +mod tests { + use super::*; + use uuid::Uuid; + + struct Scratch(PathBuf); + + impl Scratch { + fn new() -> Self { + let dir = std::env::temp_dir().join(format!("liplus-chat-test-{}", Uuid::new_v4())); + std::fs::create_dir_all(&dir).expect("scratch dir"); + Scratch(dir) + } + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for Scratch { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).ok(); + } + } + + fn registration<'a>(entry: &'a Path) -> RoomRegistration<'a> { + RoomRegistration { + room_url: "ws://127.0.0.1:1234", + token: "tok", + agent_name: "Lin", + sidecar_entry: entry, + } + } + + fn read(path: &Path) -> Value { + serde_json::from_str(&std::fs::read_to_string(path).expect("read")).expect("parse") + } + + #[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 json = read(&path); + let server = &json["mcpServers"][SERVER_NAME]; + assert_eq!(server["env"]["LIPLUS_ROOM_URL"], "ws://127.0.0.1:1234"); + assert_eq!(server["env"]["LIPLUS_ROOM_TOKEN"], "tok"); + 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"); + } + + #[test] + fn leaves_everything_else_in_the_config_alone() { + // This writes into the user's own project directory. Clobbering a + // server they configured themselves is the failure that matters here. + let scratch = Scratch::new(); + std::fs::write( + scratch.path().join(".mcp.json"), + r#"{"mcpServers":{"theirs":{"command":"their-server"}},"unrelated":42}"#, + ) + .expect("seed"); + + let entry = PathBuf::from("sidecar/src/index.ts"); + let path = register_sidecar(scratch.path(), ®istration(&entry)).expect("register"); + + let json = read(&path); + assert_eq!(json["mcpServers"]["theirs"]["command"], "their-server"); + assert_eq!(json["unrelated"], 42); + assert!(json["mcpServers"][SERVER_NAME].is_object()); + } + + #[test] + fn re_registering_replaces_only_its_own_entry() { + let scratch = Scratch::new(); + let entry = PathBuf::from("sidecar/src/index.ts"); + + register_sidecar(scratch.path(), ®istration(&entry)).expect("first"); + let second = RoomRegistration { + room_url: "ws://127.0.0.1:9999", + token: "tok2", + agent_name: "Lay", + sidecar_entry: &entry, + }; + let path = register_sidecar(scratch.path(), &second).expect("second"); + + let json = read(&path); + let server = &json["mcpServers"][SERVER_NAME]; + assert_eq!(server["env"]["LIPLUS_ROOM_URL"], "ws://127.0.0.1:9999"); + assert_eq!(server["env"]["LIPLUS_AGENT_NAME"], "Lay"); + assert_eq!( + json["mcpServers"].as_object().expect("servers").len(), + 1, + "re-registering must not accumulate entries" + ); + } + + #[test] + fn refuses_to_overwrite_a_config_it_cannot_parse() { + let scratch = Scratch::new(); + 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(); + assert!(err.contains("not valid JSON"), "unexpected error: {err}"); + assert_eq!( + std::fs::read_to_string(scratch.path().join(".mcp.json")).expect("read"), + seeded, + "the unparseable file must be left as it was" + ); + } + + #[test] + fn rejects_flags_that_stop_channel_pushes() { + for arg in ["--channels", "--print", "--input-format", "--output-format"] { + let args = vec!["--verbose".to_string(), arg.to_string()]; + assert_eq!(reject_incompatible_flags(&args), Err(arg)); + } + } + + #[test] + fn rejects_an_incompatible_flag_written_with_an_equals_sign() { + let args = vec!["--output-format=stream-json".to_string()]; + assert_eq!(reject_incompatible_flags(&args), Err("--output-format")); + } + + #[test] + fn accepts_arguments_that_do_not_touch_that_axis() { + let args = vec!["--verbose".to_string(), "--model=opus".to_string()]; + assert_eq!(reject_incompatible_flags(&args), Ok(())); + } + + #[test] + fn the_launch_flag_names_the_server_the_config_registers() { + // The flag and the `.mcp.json` key are one fact in two places; a drift + // between them fails as a room that never receives anything. + let args = channel_launch_args(&["--verbose".to_string()]); + assert_eq!( + args, + vec![ + "--verbose".to_string(), + "--dangerously-load-development-channels".to_string(), + format!("server:{SERVER_NAME}"), + ] + ); + } +} diff --git a/docs/0-requirements.md b/docs/0-requirements.md index 3c2d80a..1a778ed 100644 --- a/docs/0-requirements.md +++ b/docs/0-requirements.md @@ -156,9 +156,26 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre - 複数の AI セッションを同一の部屋へ参加させる運用(同時発話の抑制を含む) - 会話ログの永続化と観測 UI -- `cargo test` と `npm run sidecar:test` の CI 実行 - plugin としての allowlist 掲載(配布の第二段階) +## テストの配置 + +`.mcp.json` への登録と起動フラグの検査は、`crates/mcp-config/` という tauri 非依存の crate に置く。 + +理由は依存の正しさと、テストが実行できることの両方である。これらのロジックが tauri を必要とする理由はそもそも無い。加えて `src-tauri` 側に置くと、テストバイナリが tauri の依存ツリー全体をリンクするため、GNU ターゲットでは `STATUS_ENTRYPOINT_NOT_FOUND`(`0xc0000139`)でプロセスが起動せず、アサーションが一度も実行されない。これはローカル環境固有ではなく CI でも再現する(run 32431917979)。特定の依存クレートまでは切り分けていない。 + +したがって `src-tauri/src/` に残すのは、tauri の `State` / `AppHandle` に触れる部分だけとする。純粋ロジックをそちらへ書き足すと、書いた時点で検証不能になる。 + +CI が実行するもの: + +| コマンド | 対象 | +|---|---| +| `npm run build` | フロントエンドの型検査とビルド | +| `npm run sidecar:check` | サイドカーの型検査 | +| `npm run sidecar:test` | サイドカーの往復ハーネス | +| `cargo check --target x86_64-pc-windows-gnu` | アプリのコンパイル | +| `cargo test`(`crates/mcp-config`) | `.mcp.json` マージ保全と起動フラグ検査 | + ## 往復が成立しないときの切り分け 上流から順に確認する。各段は下流の前提であるため、順序を飛ばさない。 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d7843f5..177cc70 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2136,6 +2136,7 @@ name = "liplus-chat" version = "0.1.0" dependencies = [ "futures-util", + "mcp-config", "parking_lot", "portable-pty", "serde", @@ -2181,6 +2182,13 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "mcp-config" +version = "0.1.0" +dependencies = [ + "serde_json", +] + [[package]] name = "memchr" version = "2.8.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b2e3176..3741199 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,6 +24,7 @@ tauri-plugin-clipboard-manager = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" portable-pty = { path = "../portable-pty-patch" } +mcp-config = { path = "../crates/mcp-config" } uuid = { version = "1", features = ["v4"] } parking_lot = "0.12" tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros"] } diff --git a/src-tauri/src/room.rs b/src-tauri/src/room.rs index de96147..70c67f1 100644 --- a/src-tauri/src/room.rs +++ b/src-tauri/src/room.rs @@ -29,10 +29,6 @@ use uuid::Uuid; /// Bumped when a frame's shape changes in a way a sidecar must notice. pub const PROTOCOL_VERSION: u32 = 1; -/// The name the sidecar is registered under in `.mcp.json`. The launch flag -/// carries the same name (`server:`), so the two must not drift apart. -pub const SERVER_NAME: &str = "liplus-chat-room"; - /// One line of the room, as the frontend sees it. #[derive(Debug, Clone, Serialize)] pub struct RoomMessage { diff --git a/src-tauri/src/session.rs b/src-tauri/src/session.rs index 22c2448..ae6d981 100644 --- a/src-tauri/src/session.rs +++ b/src-tauri/src/session.rs @@ -1,28 +1,20 @@ //! Putting one CLI session into the room. //! -//! Two things have to be true at once, and both are load-bearing (see the -//! 成立条件 in `docs/0-requirements.md`): +//! The conditions the round trip does not survive without — registration by +//! name in `.mcp.json`, and the launch flag carried alone — live in the +//! `mcp-config` crate, which holds no tauri so that they can be tested. What +//! stays here is the part that needs the app: the room's port and token, the +//! working directory, and the PTY the session is held open on. //! -//! 1. The sidecar is registered by name in `.mcp.json`. A config handed over -//! with `--mcp-config` does not resolve on the channel side. -//! 2. The CLI is launched with `--dangerously-load-development-channels -//! server:` and nothing else on that axis. Adding `--channels` -//! registers the same server twice and takes the whole room down. -//! -//! And the session must be interactive — `--print` never receives a push — so -//! it is held open on a PTY. +//! The session must be interactive: `--print` never receives a push. use crate::config::TabConfig; use crate::pty::{self, PtyState}; -use crate::room::{RoomState, SERVER_NAME}; -use serde_json::{json, Map, Value}; -use std::path::{Path, PathBuf}; +use crate::room::RoomState; +use mcp_config::{channel_launch_args, register_sidecar, reject_incompatible_flags, RoomRegistration}; +use std::path::PathBuf; use tauri::AppHandle; -/// Flags that silently break the channel. Rejected rather than stripped: a -/// session that launches with the flag quietly removed looks like it worked. -const INCOMPATIBLE_FLAGS: &[&str] = &["--channels", "--print", "--input-format", "--output-format"]; - /// Where the sidecar entry point lives. /// /// Stage-one distribution runs from the repository, so the walk up from the @@ -51,74 +43,6 @@ fn resolve_sidecar_entry() -> Result { Err("Could not find sidecar/src/index.ts. Set LIPLUS_SIDECAR_ENTRY to its path.".to_string()) } -/// Merge the room server into the `.mcp.json` at `cwd`, preserving whatever -/// else is there. -/// -/// This writes into the user's own project directory, because project scope is -/// where a Claude Code MCP server is normally registered. Only this one key is -/// touched; existing servers and unrelated top-level keys survive verbatim. -fn register_sidecar( - cwd: &Path, - room_url: &str, - token: &str, - agent_name: &str, -) -> Result { - let entry = resolve_sidecar_entry()?; - let entry = entry.to_string_lossy().to_string(); - - // npx resolves through PATHEXT on Windows only when a shell runs it. - let (command, args) = if cfg!(windows) { - ("cmd", vec!["/c", "npx", "tsx", entry.as_str()]) - } else { - ("npx", vec!["tsx", entry.as_str()]) - }; - - let path = cwd.join(".mcp.json"); - let mut root: Value = if path.exists() { - let text = std::fs::read_to_string(&path) - .map_err(|e| format!("Failed to read {}: {e}", path.display()))?; - serde_json::from_str(&text).map_err(|e| { - format!( - "{} exists but is not valid JSON ({e}). Fix or move it before starting a session.", - path.display() - ) - })? - } else { - Value::Object(Map::new()) - }; - - if !root.is_object() { - return Err(format!("{} is not a JSON object.", path.display())); - } - let obj = root.as_object_mut().expect("checked above"); - let servers = obj - .entry("mcpServers") - .or_insert_with(|| Value::Object(Map::new())); - if !servers.is_object() { - return Err(format!("{} has a non-object mcpServers.", path.display())); - } - - servers.as_object_mut().expect("checked above").insert( - SERVER_NAME.to_string(), - json!({ - "command": command, - "args": args, - "env": { - "LIPLUS_ROOM_URL": room_url, - "LIPLUS_ROOM_TOKEN": token, - "LIPLUS_AGENT_NAME": agent_name, - "LIPLUS_ROOM_ID": "liplus-chat", - }, - }), - ); - - let text = serde_json::to_string_pretty(&root) - .map_err(|e| format!("Failed to serialize {}: {e}", path.display()))?; - std::fs::write(&path, text).map_err(|e| format!("Failed to write {}: {e}", path.display()))?; - - Ok(path) -} - /// What the caller gets back after a session joins. #[derive(Debug, serde::Serialize)] pub struct StartedSession { @@ -136,16 +60,12 @@ pub fn start_session( cols: u16, rows: u16, ) -> Result { - for arg in &tab.args { - // `--flag=value` counts; matching the bare flag alone would miss it. - let head = arg.split('=').next().unwrap_or(arg); - if INCOMPATIBLE_FLAGS.contains(&head) { - return Err(format!( - "Tab \"{}\" passes {head}, which stops channel pushes from arriving. \ - Remove it from the tab configuration.", - tab.name - )); - } + if let Err(flag) = reject_incompatible_flags(&tab.args) { + return Err(format!( + "Tab \"{}\" passes {flag}, which stops channel pushes from arriving. \ + Remove it from the tab configuration.", + tab.name + )); } let port = room @@ -162,17 +82,22 @@ pub fn start_session( return Err(format!("Tab \"{}\" points at a missing directory: {}", tab.name, cwd.display())); } - let mcp_config = register_sidecar(&cwd, &room_url, &room.token(), &tab.name)?; - - let mut args = tab.args.clone(); - args.push("--dangerously-load-development-channels".to_string()); - args.push(format!("server:{SERVER_NAME}")); + let sidecar_entry = resolve_sidecar_entry()?; + let mcp_config = register_sidecar( + &cwd, + &RoomRegistration { + room_url: &room_url, + token: &room.token(), + agent_name: &tab.name, + sidecar_entry: &sidecar_entry, + }, + )?; let pty_id = pty::spawn_pty( app, pty_state, tab.command.clone(), - args, + channel_launch_args(&tab.args), cols, rows, Some(cwd.to_string_lossy().to_string()), @@ -183,71 +108,3 @@ pub fn start_session( mcp_config: mcp_config.to_string_lossy().to_string(), }) } - -#[cfg(test)] -mod tests { - use super::*; - use uuid::Uuid; - - fn scratch() -> PathBuf { - let dir = std::env::temp_dir().join(format!("liplus-chat-test-{}", Uuid::new_v4())); - std::fs::create_dir_all(&dir).expect("scratch dir"); - dir - } - - fn read(path: &Path) -> Value { - serde_json::from_str(&std::fs::read_to_string(path).expect("read")).expect("parse") - } - - #[test] - fn registers_the_room_server_when_no_config_exists() { - let dir = scratch(); - let path = register_sidecar(&dir, "ws://127.0.0.1:1234", "tok", "Lin").expect("register"); - - let json = read(&path); - let entry = &json["mcpServers"][SERVER_NAME]; - assert_eq!(entry["env"]["LIPLUS_ROOM_URL"], "ws://127.0.0.1:1234"); - assert_eq!(entry["env"]["LIPLUS_ROOM_TOKEN"], "tok"); - assert_eq!(entry["env"]["LIPLUS_AGENT_NAME"], "Lin"); - - std::fs::remove_dir_all(&dir).ok(); - } - - #[test] - fn leaves_everything_else_in_the_config_alone() { - // This writes into the user's own project directory. Clobbering a - // server they configured themselves is the failure that matters here. - let dir = scratch(); - std::fs::write( - dir.join(".mcp.json"), - r#"{"mcpServers":{"theirs":{"command":"their-server"}},"unrelated":42}"#, - ) - .expect("seed"); - - let path = register_sidecar(&dir, "ws://127.0.0.1:1", "tok", "Lay").expect("register"); - - let json = read(&path); - assert_eq!(json["mcpServers"]["theirs"]["command"], "their-server"); - assert_eq!(json["unrelated"], 42); - assert!(json["mcpServers"][SERVER_NAME].is_object()); - - std::fs::remove_dir_all(&dir).ok(); - } - - #[test] - fn refuses_to_overwrite_a_config_it_cannot_parse() { - // Truncating an unparseable file would destroy whatever it held. - let dir = scratch(); - let seeded = "{ not json"; - std::fs::write(dir.join(".mcp.json"), seeded).expect("seed"); - - let err = register_sidecar(&dir, "ws://127.0.0.1:1", "tok", "Lin").unwrap_err(); - assert!(err.contains("not valid JSON"), "unexpected error: {err}"); - assert_eq!( - std::fs::read_to_string(dir.join(".mcp.json")).expect("read"), - seeded - ); - - std::fs::remove_dir_all(&dir).ok(); - } -}