diff --git a/.github/workflows/mcp-test-build.yml b/.github/workflows/mcp-test-build.yml new file mode 100644 index 0000000..a65b6dc --- /dev/null +++ b/.github/workflows/mcp-test-build.yml @@ -0,0 +1,165 @@ +# Builds an unsigned Windows test pair and uploads it as a workflow artifact. +# It does not create a GitHub Release, does not sign, and does not run on main. +name: mcp-test-build + +on: + push: + branches: + - cursor/packaged-mcp-* + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: mcp-test-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: 1.90.0 + - name: Chrome for sheet end-to-end + run: | + if ! command -v google-chrome >/dev/null 2>&1 && ! command -v google-chrome-stable >/dev/null 2>&1; then + sudo apt-get update + wget -q -O /tmp/chrome.deb https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb + sudo apt-get install -y /tmp/chrome.deb + fi + (google-chrome --version || google-chrome-stable --version) + - run: npm ci + - run: npm run lint + - run: npm run test:rust + - name: Packaged MCP protocol + run: cargo test --manifest-path crates/mcpserve/Cargo.toml + - run: npm run smoke + - run: npm run smoke:ocr + - name: Real CoMER smoke + run: npx tsx scripts/ocr-real-smoke.ts + - run: npm run smoke:ai + - run: npm run test:pack + - run: npm run smoke:ink + - name: Start the dev server + run: | + npm run dev > dev-server.log 2>&1 & + echo $! > dev-server.pid + for _ in $(seq 1 40); do + if curl -sf http://127.0.0.1:47321/ >/dev/null; then + exit 0 + fi + sleep 2 + done + echo "dev server did not start" + cat dev-server.log + exit 1 + - name: Sheet end-to-end + env: + SHOTS: e2e-shots + run: npm run e2e:sheet + - name: Ink gestures + env: + SHOTS: e2e-shots + run: npm run e2e:ink + - name: Per-formula plot end-to-end + env: + SHOTS: e2e-plots + run: npm run e2e + - name: Upload failure logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: mathscrap-mcp-test-failure + path: | + e2e-shots + e2e-plots + dev-server.log + if-no-files-found: ignore + + linux-packaged-mcp: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: 1.90.0 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + src-tauri + crates/mcpserve + - name: WebKit for the release window + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev patchelf xvfb + - run: npm ci + - name: Release-mode page roundtrip + env: + MATHSCRAP_MCP_PROFILE: mcptest + run: | + npx tauri build --config src-tauri/tauri.mcptest.json --no-bundle --ci + cargo build --manifest-path src-tauri/Cargo.toml --release --bin mathscrap-mcp + GUI=src-tauri/target/release/mathscrap-mcp-test STDIO=src-tauri/target/release/mathscrap-mcp node scripts/mcp-packaged-smoke.mjs + + # Unsigned CI artifact only. It does not create a Release and it does not wait on the suite. + windows-mcp-test: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: 1.90.0 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + src-tauri + crates/mcpserve + - run: npm ci + - name: Windows session ACL + run: cargo test --manifest-path crates/mcpserve/Cargo.toml + - name: Unsigned test binaries + shell: bash + env: + MATHSCRAP_MCP_PROFILE: mcptest + run: bash scripts/package-mcp-test.sh + - name: Local stdio package + shell: bash + run: | + npm pack --pack-destination packaging/out ./mcp/stdio-proxy + cp scripts/mcp-stdio-probe.mjs packaging/out/mcp-stdio-probe.mjs + printf 'stdio_client=npx --yes --package mathscrap-mcp-stdio-0.0.2-dev.tgz mathscrap-mcp-stdio\n' >> packaging/out/PROVENANCE.txt + node scripts/checksum-artifacts.mjs packaging/out + - name: Windows npx handshake + shell: bash + env: + GUI: src-tauri/target/release/mathscrap-mcp-test.exe + TGZ: packaging/out/mathscrap-mcp-stdio-0.0.2-dev.tgz + run: node scripts/mcp-windows-npx-smoke.mjs + - uses: actions/upload-artifact@v4 + with: + name: mathscrap-mcp-test-windows + path: | + packaging/out/MathScrap-mcp-test-windows-x64.exe + packaging/out/mathscrap-mcp.exe + packaging/out/mathscrap-mcp-stdio-0.0.2-dev.tgz + packaging/out/mcp-stdio-probe.mjs + packaging/out/SHA256SUMS + packaging/out/PROVENANCE.txt + if-no-files-found: error diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 7114292..6ff01ff 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -77,6 +77,9 @@ jobs: if: steps.scope.outputs.mode == 'full' - run: npm run test:rust if: steps.scope.outputs.mode == 'full' + - name: Packaged MCP protocol + if: steps.scope.outputs.mode == 'full' + run: cargo test --manifest-path crates/mcpserve/Cargo.toml - run: npm run smoke if: steps.scope.outputs.mode == 'full' - run: npm run smoke:ocr diff --git a/.gitignore b/.gitignore index e261fd1..97c8c5b 100644 --- a/.gitignore +++ b/.gitignore @@ -25,9 +25,7 @@ dist-ssr # Rust crates/mathcore/target - -# Rust -crates/mathcore/target +crates/mcpserve/target # local agent / screenshots ASTER_BG_TASK.md diff --git a/crates/mcpserve/Cargo.lock b/crates/mcpserve/Cargo.lock new file mode 100644 index 0000000..db8b3ef --- /dev/null +++ b/crates/mcpserve/Cargo.lock @@ -0,0 +1,228 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "mcpserve" +version = "0.0.1" +dependencies = [ + "getrandom", + "libc", + "serde", + "serde_json", + "windows-sys", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[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 = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[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", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/mcpserve/Cargo.toml b/crates/mcpserve/Cargo.toml new file mode 100644 index 0000000..c75352a --- /dev/null +++ b/crates/mcpserve/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "mcpserve" +version = "0.0.1" +edition = "2021" +license = "Apache-2.0" +description = "Loopback MCP listener for a running MathScrap window. No network bind." +rust-version = "1.90" +publish = false + +[dependencies] +getrandom = "0.3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.59", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Storage_FileSystem", + "Win32_System_IO", + "Win32_System_Threading", +] } diff --git a/crates/mcpserve/src/lib.rs b/crates/mcpserve/src/lib.rs new file mode 100644 index 0000000..ca81001 --- /dev/null +++ b/crates/mcpserve/src/lib.rs @@ -0,0 +1,1343 @@ +//! Loopback MCP for a running MathScrap window. +//! +//! The listener binds `127.0.0.1` only. The bearer token is written to a +//! private file and is never put in a URL, a log line, or the page's +//! localStorage. On Windows the file is created with a protected ACL: the +//! current user, SYSTEM, and Administrators only. On Unix the directory is +//! mode 0700 and the file is mode 0600, owned by the current user. A +//! same-user process can read that file. An administrator or SYSTEM can too. +//! Another account is not granted access. + +#![deny(unsafe_code)] + +mod secret; + +use serde_json::{json, Value}; +use std::collections::{HashMap, VecDeque}; +use std::io::{BufRead, Read, Write}; +use std::net::{IpAddr, Shutdown, SocketAddr, TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::mpsc::{self, Sender}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +const TOOLS_JSON: &str = include_str!("../../../mcp/tools.json"); +const MAX_HEADER: usize = 8 * 1024; +const MAX_BODY: usize = 64 * 1024; +const MAX_RESULT: usize = 1024 * 1024; +const MAX_SESSIONS: usize = 4; +const MAX_PENDING: usize = 8; +const MAX_CONNECTIONS: usize = 32; +const DEFAULT_PORT: u16 = 47323; + +pub type Dispatch = Arc bool + Send + Sync>; + +struct Pending { + tx: Sender, +} + +struct Outcome { + ok: bool, + result: Option, + error: Option, +} + +struct Inner { + token: String, + dir: PathBuf, + port: u16, + tool_timeout: Duration, + ready: AtomicBool, + shutdown: AtomicBool, + connections: AtomicUsize, + pending: Mutex>, + sessions: Mutex>, + dispatch: Dispatch, +} + +#[derive(Clone)] +pub struct Hub { + inner: Arc, + thread: Arc>>>, +} + +impl Hub { + /// Start nothing. The window still runs; MCP stays closed. + pub fn disabled() -> Self { + Self { + inner: Arc::new(Inner { + token: String::new(), + dir: PathBuf::new(), + port: 0, + tool_timeout: Duration::from_secs(15), + ready: AtomicBool::new(false), + shutdown: AtomicBool::new(true), + connections: AtomicUsize::new(0), + pending: Mutex::new(HashMap::new()), + sessions: Mutex::new(VecDeque::new()), + dispatch: Arc::new(|_| false), + }), + thread: Arc::new(Mutex::new(None)), + } + } + + pub fn listening(&self) -> bool { + !self.inner.shutdown.load(Ordering::SeqCst) && self.inner.port != 0 + } + + pub fn port(&self) -> u16 { + self.inner.port + } + + pub fn dir(&self) -> &Path { + &self.inner.dir + } + + pub fn set_ready(&self, ready: bool) { + self.inner.ready.store(ready, Ordering::SeqCst); + if !ready { + self.inner.fail_all("mcp_page_offline", "The MathScrap window is not ready."); + } + } + + pub fn complete(&self, id: &str, ok: bool, result: Option, error: Option) -> bool { + self.inner.finish(id, Outcome { ok, result, error }) + } + + /// Bind loopback, write the session file, and serve until dropped. + pub fn start(dispatch: Dispatch) -> Result { + if mcp_disabled() { + if let Ok(dir) = mcp_dir() { + let _ = secret::scrub(&dir); + } + return Err("mcp disabled".into()); + } + let dir = mcp_dir()?; + let port = mcp_port()?; + let tool_timeout = tool_timeout(); + start_in(dir, port, tool_timeout, dispatch) + } +} + +impl Hub { + /// Stop the listener and delete the session files. + /// + /// A normal window close calls this from the event loop. The Windows + /// runtime then ends the process with `process::exit`, which does not run + /// `Drop`. A forced kill does not reach this function either. + pub fn close_session(&self) { + self.inner.shutdown.store(true, Ordering::SeqCst); + self.inner.fail_all("mcp_closed", "MathScrap MCP stopped."); + if self.inner.port != 0 { + let addr = SocketAddr::from(([127, 0, 0, 1], self.inner.port)); + let _ = TcpStream::connect_timeout(&addr, Duration::from_millis(200)); + } + if let Some(thread) = self.thread.lock().ok().and_then(|mut slot| slot.take()) { + let _ = thread.join(); + } + if !self.inner.dir.as_os_str().is_empty() { + let _ = secret::scrub(&self.inner.dir); + } + } +} + +impl Drop for Hub { + fn drop(&mut self) { + // Only the last owner closes the listener. Clones share `thread`. + if Arc::strong_count(&self.thread) > 1 { + return; + } + self.close_session(); + } +} + +pub fn server_version() -> &'static str { + match option_env!("MATHSCRAP_MCP_PROFILE") { + Some("mcptest") => "0.0.2-dev", + _ => "0.0.1", + } +} + +pub fn mcp_disabled() -> bool { + matches!( + std::env::var("MATHSCRAP_MCP").ok().as_deref().map(str::trim), + Some("off" | "0" | "false" | "OFF") + ) +} + +pub fn mcp_dir() -> Result { + if let Some(dir) = std::env::var_os("MATHSCRAP_MCP_DIR") { + let path = PathBuf::from(dir); + if path.as_os_str().is_empty() { + return Err("MATHSCRAP_MCP_DIR is empty".into()); + } + return Ok(path); + } + let folder = match option_env!("MATHSCRAP_MCP_PROFILE") { + Some("mcptest") => "MathScrap-mcptest", + _ => "MathScrap", + }; + #[cfg(windows)] + { + let base = std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .ok_or_else(|| "LOCALAPPDATA is unset".to_string())?; + return Ok(base.join(folder).join("mcp")); + } + #[cfg(not(windows))] + { + let base = std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local").join("share"))) + .ok_or_else(|| "HOME is unset".to_string())?; + Ok(base.join(folder).join("mcp")) + } +} + +fn mcp_port() -> Result { + match std::env::var("MATHSCRAP_MCP_PORT") { + Ok(text) => text + .trim() + .parse::() + .map_err(|_| "MATHSCRAP_MCP_PORT is not a port".to_string()), + Err(_) => Ok(DEFAULT_PORT), + } +} + +fn tool_timeout() -> Duration { + let ms = std::env::var("MATHSCRAP_MCP_TOOL_MS") + .ok() + .and_then(|text| text.trim().parse::().ok()) + .unwrap_or(15_000); + Duration::from_millis(ms.clamp(50, 20_000)) +} + +fn start_in(dir: PathBuf, port: u16, tool_timeout: Duration, dispatch: Dispatch) -> Result { + let listener = TcpListener::bind(("127.0.0.1", port)).map_err(|err| format!("mcp bind failed: {err}"))?; + listener.set_nonblocking(false).map_err(|err| format!("mcp bind failed: {err}"))?; + let bound = listener.local_addr().map_err(|err| format!("mcp bind failed: {err}"))?; + if !bound.ip().is_loopback() { + return Err("mcp refused a non-loopback bind".into()); + } + let token = new_token()?; + let endpoint = format!("http://127.0.0.1:{}/mcp", bound.port()); + if let Err(err) = secret::publish(&dir, &token, &endpoint) { + let _ = secret::scrub(&dir); + return Err(format!("{}: {}", err.code, err.message)); + } + let inner = Arc::new(Inner { + token, + dir, + port: bound.port(), + tool_timeout, + ready: AtomicBool::new(false), + shutdown: AtomicBool::new(false), + connections: AtomicUsize::new(0), + pending: Mutex::new(HashMap::new()), + sessions: Mutex::new(VecDeque::new()), + dispatch, + }); + let accept = inner.clone(); + let thread = thread::spawn(move || accept_loop(listener, accept)); + Ok(Hub { inner, thread: Arc::new(Mutex::new(Some(thread))) }) +} + +fn accept_loop(listener: TcpListener, inner: Arc) { + let _ = listener.set_nonblocking(false); + loop { + if inner.shutdown.load(Ordering::SeqCst) { + break; + } + match listener.accept() { + Ok((stream, peer)) => { + if inner.shutdown.load(Ordering::SeqCst) { + break; + } + if !peer_ok(peer) { + let _ = stream.shutdown(Shutdown::Both); + continue; + } + if inner.connections.load(Ordering::SeqCst) >= MAX_CONNECTIONS { + let body = serde_json::to_vec(&rpc_error(None, "mcp_busy", "Too many MCP connections.")).unwrap_or_default(); + let _ = write_http(&stream, 503, &[], &body); + continue; + } + inner.connections.fetch_add(1, Ordering::SeqCst); + let inner = inner.clone(); + thread::spawn(move || { + serve_one(stream, &inner); + inner.connections.fetch_sub(1, Ordering::SeqCst); + }); + } + Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => { + if inner.shutdown.load(Ordering::SeqCst) { + break; + } + } + } + } +} + +fn peer_ok(peer: SocketAddr) -> bool { + match peer.ip() { + IpAddr::V4(ip) => ip.is_loopback(), + IpAddr::V6(_) => false, + } +} + +fn serve_one(mut stream: TcpStream, inner: &Inner) { + let _ = stream.set_read_timeout(Some(Duration::from_secs(5))); + let _ = stream.set_write_timeout(Some(Duration::from_secs(20))); + let incoming = match read_request(&mut stream) { + Ok(incoming) => incoming, + Err(Fail::TooLarge) => { + let body = serde_json::to_vec(&rpc_error(None, "mcp_too_large", "The MCP request is too large.")).unwrap_or_default(); + let _ = write_http(&stream, 413, &[], &body); + return; + } + Err(_) => return, + }; + let response = handle(inner, incoming); + let _ = write_http(&stream, response.status, &response.headers, &response.body); +} + +struct Incoming { + method: String, + target: String, + headers: HashMap, + body: Vec, +} + +struct Outgoing { + status: u16, + headers: Vec<(String, String)>, + body: Vec, +} + +fn handle(inner: &Inner, incoming: Incoming) -> Outgoing { + if incoming.headers.keys().any(|key| key == "upgrade") { + return plain(400, rpc_error(None, "mcp_bad_request", "WebSocket upgrade is refused.")); + } + if incoming.target.chars().any(|ch| ch == '?' || ch == '#') { + return plain(400, rpc_error(None, "mcp_bad_request", "The token must not be in the URL.")); + } + if incoming.method == "OPTIONS" { + return plain(403, rpc_error(None, "mcp_bad_origin", "Browser preflight is refused.")); + } + if incoming.target != "/mcp" { + return plain(404, rpc_error(None, "mcp_unknown_path", "Unknown MCP path.")); + } + if incoming.method != "POST" { + return plain(405, rpc_error(None, "mcp_bad_request", "Only POST /mcp is accepted.")); + } + if !host_ok(&incoming.headers, inner.port) { + return plain(403, rpc_error(None, "mcp_bad_host", "Host is not this loopback listener.")); + } + if !origin_ok(&incoming.headers) { + return plain(403, rpc_error(None, "mcp_bad_origin", "Origin is refused.")); + } + if !bearer_ok(&incoming.headers, &inner.token) { + return plain(401, rpc_error(None, "mcp_unauthorized", "Unauthorized.")); + } + let rpc: Value = match serde_json::from_slice::(&incoming.body) { + Ok(value) if value.is_object() => value, + _ => return plain(400, rpc_error(None, "mcp_bad_json", "The body is not a JSON object.")), + }; + let id = rpc.get("id").cloned(); + let method = rpc.get("method").and_then(Value::as_str).unwrap_or(""); + if method.is_empty() { + return plain(400, rpc_error(id, "mcp_bad_json", "The JSON-RPC method is missing.")); + } + if method == "initialize" { + let session = new_token().unwrap_or_else(|_| "session".into()); + { + let mut sessions = inner.sessions.lock().expect("sessions"); + sessions.push_back(session.clone()); + while sessions.len() > MAX_SESSIONS { + sessions.pop_front(); + } + } + let asked = rpc + .pointer("/params/protocolVersion") + .and_then(Value::as_str) + .unwrap_or("2024-11-05"); + let version = if matches!(asked, "2024-11-05" | "2025-03-26" | "2025-06-18") { + asked + } else { + "2024-11-05" + }; + let body = json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "protocolVersion": version, + "capabilities": { "tools": { "listChanged": false } }, + "serverInfo": { "name": "mathscrap", "version": server_version() } + } + }); + return Outgoing { + status: 200, + headers: vec![("mcp-session-id".into(), session)], + body: serde_json::to_vec(&body).unwrap_or_default(), + }; + } + let session = header(&incoming.headers, "mcp-session-id"); + let known = session + .as_deref() + .map(|value| inner.sessions.lock().expect("sessions").iter().any(|item| item == value)) + .unwrap_or(false); + if !known { + let status = if session.is_none() { 400 } else { 404 }; + let code = if session.is_none() { "mcp_no_session" } else { "mcp_unknown_session" }; + return plain(status, rpc_error(id, code, "The MCP session does not match.")); + } + if id.is_none() { + return Outgoing { status: 202, headers: vec![], body: Vec::new() }; + } + let body = match method { + "tools/list" => json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "tools": tool_list() } + }), + "tools/call" => tool_call(inner, id, rpc.get("params").cloned().unwrap_or(Value::Null)), + "ping" => json!({"jsonrpc": "2.0", "id": id, "result": {}}), + _ => rpc_error(id, "mcp_unknown_method", "Unknown MCP method."), + }; + plain(200, body) +} + +fn tool_call(inner: &Inner, id: Option, params: Value) -> Value { + let name = params.get("name").and_then(Value::as_str).unwrap_or(""); + let tools = tool_list(); + let known = tools.as_array().map(|tools| tools.iter().any(|tool| tool["name"] == name)).unwrap_or(false); + if !known { + return tool_is_error(id, "mcp_unknown_tool", "Unknown tool."); + } + if !inner.ready.load(Ordering::SeqCst) { + return tool_is_error(id, "mcp_page_offline", "The MathScrap window is not ready."); + } + let arguments = params.get("arguments").cloned().unwrap_or_else(|| json!({})); + if !arguments.is_object() { + return tool_is_error(id, "mcp_bad_json", "Tool arguments must be an object."); + } + let call_id = new_token().unwrap_or_else(|_| "call".into()); + { + let pending = inner.pending.lock().expect("pending"); + if pending.len() >= MAX_PENDING { + return tool_is_error(id, "mcp_busy", "Too many tool calls are waiting."); + } + } + let (tx, rx) = mpsc::channel(); + inner.pending.lock().expect("pending").insert(call_id.clone(), Pending { tx }); + let job = json!({"id": call_id, "name": name, "arguments": arguments}); + let accepted = (inner.dispatch)(job); + if !accepted { + inner.pending.lock().expect("pending").remove(&call_id); + return tool_is_error(id, "mcp_page_offline", "The MathScrap window is not ready."); + } + match rx.recv_timeout(inner.tool_timeout) { + Ok(outcome) => { + let _ = inner.pending.lock().expect("pending").remove(&call_id); + if let Some(message) = outcome.error { + return tool_is_error(id, "mcp_tool_failed", &message); + } + let result = outcome.result.unwrap_or(Value::Null); + if let Some(message) = result.get("error").and_then(Value::as_str) { + if result.get("ok").and_then(Value::as_bool) != Some(true) { + return tool_is_error(id, "mcp_tool_failed", message); + } + } + let text = serde_json::to_string(&result).unwrap_or_else(|_| "null".into()); + if text.len() > MAX_RESULT { + return tool_is_error(id, "mcp_too_large", "The tool result is too large."); + } + json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "content": [{ "type": "text", "text": text }], + "isError": !outcome.ok + } + }) + } + Err(_) => { + let _ = inner.pending.lock().expect("pending").remove(&call_id); + tool_is_error(id, "mcp_timeout", "The tool call timed out.") + } + } +} + +fn tool_is_error(id: Option, code: &str, message: &str) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "content": [{ "type": "text", "text": serde_json::to_string(&json!({"code": code, "error": message})).unwrap_or_default() }], + "isError": true + } + }) +} + +fn tool_list() -> Value { + serde_json::from_str(TOOLS_JSON).unwrap_or_else(|_| json!([])) +} + +impl Inner { + fn finish(&self, id: &str, outcome: Outcome) -> bool { + let Some(pending) = self.pending.lock().expect("pending").remove(id) else { + return false; + }; + pending.tx.send(outcome).is_ok() + } + + fn fail_all(&self, code: &str, message: &str) { + let pending = std::mem::take(&mut *self.pending.lock().expect("pending")); + for (_, item) in pending { + let _ = item.tx.send(Outcome { + ok: false, + result: None, + error: Some(format!("{code}: {message}")), + }); + } + } +} + +fn host_ok(headers: &HashMap, port: u16) -> bool { + let Some(host) = header(headers, "host") else { return false }; + let host = host.trim(); + if host.contains('@') || host.contains('\\') { + return false; + } + let (name, found) = match host.rsplit_once(':') { + Some((name, port_text)) => { + let Ok(parsed) = port_text.parse::() else { return false }; + (name, parsed) + } + None => return false, + }; + found == port && (name.eq_ignore_ascii_case("127.0.0.1") || name.eq_ignore_ascii_case("localhost")) +} + +fn origin_ok(headers: &HashMap) -> bool { + // Native clients omit Origin. Any browser Origin, including null, is refused. + // The window uses Tauri IPC, not this port. + header(headers, "origin").is_none() +} + +fn bearer_ok(headers: &HashMap, token: &str) -> bool { + let Some(value) = header(headers, "authorization") else { return false }; + let mut parts = value.trim().split_whitespace(); + let Some(scheme) = parts.next() else { return false }; + let Some(presented) = parts.next() else { return false }; + if parts.next().is_some() || !scheme.eq_ignore_ascii_case("bearer") { + return false; + } + tokens_match(presented, token) +} + +fn tokens_match(presented: &str, token: &str) -> bool { + if presented.len() != token.len() || presented.len() != 64 { + return false; + } + let mut diff = 0u8; + for (left, right) in presented.bytes().zip(token.bytes()) { + diff |= left ^ right; + } + diff == 0 +} + +fn header(headers: &HashMap, name: &str) -> Option { + headers.get(name).cloned() +} + +fn plain(status: u16, body: Value) -> Outgoing { + Outgoing { + status, + headers: vec![], + body: serde_json::to_vec(&body).unwrap_or_default(), + } +} + +fn rpc_error(id: Option, code: &str, message: &str) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": -32001, "message": message, "data": { "code": code } } + }) +} + +fn read_request(stream: &mut TcpStream) -> Result { + let mut buf = Vec::new(); + let mut chunk = [0u8; 1024]; + let header_end; + loop { + if buf.len() > MAX_HEADER { + return Err(Fail::TooLarge); + } + let n = stream.read(&mut chunk).map_err(|_| Fail::Closed)?; + if n == 0 { + return Err(Fail::Closed); + } + buf.extend_from_slice(&chunk[..n]); + if let Some(pos) = find_sub(&buf, b"\r\n\r\n") { + header_end = pos; + break; + } + } + let head = String::from_utf8_lossy(&buf[..header_end]).to_string(); + let mut lines = head.split("\r\n"); + let Some(request) = lines.next() else { return Err(Fail::Bad) }; + let mut parts = request.split(' '); + let method = parts.next().unwrap_or("").to_string(); + let target = parts.next().unwrap_or("").to_string(); + let version = parts.next().unwrap_or(""); + if !matches!(version, "HTTP/1.1" | "HTTP/1.0") || parts.next().is_some() { + return Err(Fail::Bad); + } + let mut headers = HashMap::new(); + for line in lines { + if line.is_empty() { + continue; + } + let Some((name, value)) = line.split_once(':') else { return Err(Fail::Bad) }; + let name = name.trim().to_ascii_lowercase(); + if name.is_empty() || headers.contains_key(&name) { + return Err(Fail::Bad); + } + headers.insert(name, value.trim().to_string()); + } + if headers.contains_key("transfer-encoding") { + return Err(Fail::Bad); + } + let mut body = buf[header_end + 4..].to_vec(); + let length = if method == "POST" { + let text = headers.get("content-length").ok_or(Fail::Bad)?; + let length: usize = text.parse().map_err(|_| Fail::Bad)?; + if length > MAX_BODY { + return Err(Fail::TooLarge); + } + length + } else if headers.contains_key("content-length") { + return Err(Fail::Bad); + } else { + 0 + }; + while body.len() < length { + let n = stream.read(&mut chunk).map_err(|_| Fail::Closed)?; + if n == 0 { + return Err(Fail::Closed); + } + if body.len() + n > MAX_BODY { + return Err(Fail::TooLarge); + } + body.extend_from_slice(&chunk[..n]); + } + body.truncate(length); + Ok(Incoming { method, target, headers, body }) +} + +enum Fail { + Closed, + Bad, + TooLarge, +} + +fn find_sub(hay: &[u8], needle: &[u8]) -> Option { + hay.windows(needle.len()).position(|window| window == needle) +} + +fn write_http(mut stream: impl Write, status: u16, extra: &[(String, String)], body: &[u8]) -> std::io::Result<()> { + let reason = match status { + 200 => "OK", + 202 => "Accepted", + 400 => "Bad Request", + 401 => "Unauthorized", + 403 => "Forbidden", + 404 => "Not Found", + 405 => "Method Not Allowed", + 413 => "Payload Too Large", + 503 => "Service Unavailable", + _ => "Error", + }; + let mut head = format!("HTTP/1.1 {status} {reason}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\nCache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\n", body.len()); + for (name, value) in extra { + if name.eq_ignore_ascii_case("access-control-allow-origin") || name.eq_ignore_ascii_case("location") { + continue; + } + head.push_str(name); + head.push_str(": "); + head.push_str(value); + head.push_str("\r\n"); + } + head.push_str("\r\n"); + stream.write_all(head.as_bytes())?; + stream.write_all(body)?; + Ok(()) +} + +fn new_token() -> Result { + let mut bytes = [0u8; 32]; + getrandom::fill(&mut bytes).map_err(|_| "the system random source failed".to_string())?; + Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect()) +} + +/// Speak MCP on stdio by forwarding to the loopback listener. +/// The token is read from the session file and is not written to stdout or stderr. +/// +/// A request that starts with `{` is one JSON-RPC message ending at the next +/// newline. That is the stdio transport used by the official MCP SDK. A +/// `Content-Length` header is still accepted, and the reply uses the same +/// framing as the request. Every reply is flushed. +pub fn stdio_main() { + let dir = match mcp_dir() { + Ok(dir) => dir, + Err(_) => return, + }; + let stdin = std::io::stdin(); + let mut input = stdin.lock(); + let stdout = std::io::stdout(); + let mut output = stdout.lock(); + let _ = stdio_exchange(&dir, &mut input, &mut output); +} + +fn stdio_exchange(dir: &Path, input: &mut impl BufRead, output: &mut impl Write) -> std::io::Result<()> { + let mut session: Option = None; + loop { + let (frame, line) = match read_client_message(input) { + Ok(message) => message, + Err(_) => return Ok(()), + }; + let message: Value = match serde_json::from_slice(&frame) { + Ok(value) => value, + Err(_) => { + let bytes = serde_json::to_vec(&rpc_error(None, "mcp_bad_json", "The body is not JSON.")).unwrap_or_default(); + write_client_message(output, &bytes, line)?; + continue; + } + }; + let notification = message.get("id").is_none(); + match forward_in(dir, &mut session, &message) { + Ok(Some(bytes)) if !notification => write_client_message(output, &bytes, line)?, + Ok(_) => {} + Err(err) => { + if notification { + continue; + } + let id = message_id(&message); + let bytes = serde_json::to_vec(&rpc_error(id, err.code, err.message)).unwrap_or_default(); + write_client_message(output, &bytes, line)?; + } + } + } +} + +fn message_id(message: &Value) -> Option { + message.get("id").cloned() +} + +fn forward_in(dir: &Path, session: &mut Option, message: &Value) -> Result>, secret::SecretError> { + let (endpoint, token) = secret::load(dir)?; + let port = parse_loopback_endpoint(endpoint.trim()).map_err(|_| secret::SecretError::closed())?; + let body = serde_json::to_vec(message).map_err(|_| secret::SecretError::closed())?; + let mut extra = String::new(); + if let Some(id) = session { + extra = format!("Mcp-Session-Id: {id}\r\n"); + } + let request = format!( + "POST /mcp HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nAuthorization: Bearer {token}\r\nContent-Type: application/json\r\nAccept: application/json, text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n{extra}\r\n", + body.len() + ); + let mut stream = TcpStream::connect(("127.0.0.1", port)).map_err(|_| secret::SecretError::closed())?; + let _ = stream.set_read_timeout(Some(Duration::from_secs(20))); + let _ = stream.set_write_timeout(Some(Duration::from_secs(5))); + stream.write_all(request.as_bytes()).map_err(|_| secret::SecretError::closed())?; + stream.write_all(&body).map_err(|_| secret::SecretError::closed())?; + let (status, headers, response_body) = read_http_response(&mut stream).map_err(|_| secret::SecretError::closed())?; + if (300..400).contains(&status) { + return Err(secret::SecretError::closed()); + } + if let Some(id) = headers.get("mcp-session-id") { + *session = Some(id.clone()); + } + if status == 202 || response_body.is_empty() { + return Ok(None); + } + Ok(Some(response_body)) +} + +fn parse_loopback_endpoint(text: &str) -> Result { + let rest = text.strip_prefix("http://").ok_or_else(|| "The endpoint is not loopback HTTP.".to_string())?; + let (host, port_and_path) = rest.split_once(':').ok_or_else(|| "The endpoint is not loopback HTTP.".to_string())?; + if !host.eq_ignore_ascii_case("127.0.0.1") && !host.eq_ignore_ascii_case("localhost") { + return Err("The endpoint is not loopback HTTP.".into()); + } + let port_text = port_and_path.strip_suffix("/mcp").unwrap_or(port_and_path); + if port_text.contains('/') || port_text.contains('?') { + return Err("The endpoint is not loopback HTTP.".into()); + } + let port: u16 = port_text.parse().map_err(|_| "The endpoint is not loopback HTTP.".to_string())?; + if port == 0 { + return Err("The endpoint is not loopback HTTP.".into()); + } + Ok(port) +} + +/// `true` means the client sent one JSON line. `false` means a Content-Length frame. +fn read_client_message(input: &mut impl BufRead) -> std::io::Result<(Vec, bool)> { + loop { + let available = input.fill_buf()?; + if available.is_empty() { + return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "eof")); + } + if available[0] == b'\n' || available[0] == b'\r' { + input.consume(1); + continue; + } + if available[0] == b'{' { + let mut line = Vec::new(); + loop { + let buf = input.fill_buf()?; + if buf.is_empty() { + break; + } + if let Some(pos) = buf.iter().position(|byte| *byte == b'\n') { + if line.len() + pos > MAX_BODY { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "large")); + } + line.extend_from_slice(&buf[..=pos]); + input.consume(pos + 1); + break; + } + if line.len() + buf.len() > MAX_BODY { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "large")); + } + let n = buf.len(); + line.extend_from_slice(buf); + input.consume(n); + } + if line.last() == Some(&b'\n') { + line.pop(); + } + if line.last() == Some(&b'\r') { + line.pop(); + } + if line.is_empty() { + return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "eof")); + } + return Ok((line, true)); + } + return Ok((read_frame(input)?, false)); + } +} + +fn write_client_message(output: &mut impl Write, body: &[u8], line: bool) -> std::io::Result<()> { + if line { + output.write_all(body)?; + if !body.ends_with(b"\n") { + output.write_all(b"\n")?; + } + return output.flush(); + } + write_frame(output, body) +} + +fn read_frame(input: &mut impl Read) -> std::io::Result> { + let mut header = Vec::new(); + let mut byte = [0u8; 1]; + loop { + if header.len() > MAX_HEADER { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "header")); + } + if input.read(&mut byte)? == 0 { + return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "eof")); + } + header.push(byte[0]); + if header.ends_with(b"\r\n\r\n") { + break; + } + } + let text = String::from_utf8_lossy(&header); + let mut length = None; + for line in text.split("\r\n") { + let Some((name, value)) = line.split_once(':') else { continue }; + if name.eq_ignore_ascii_case("content-length") { + if length.is_some() { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "length")); + } + length = Some(value.trim().parse::().map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidData, "length"))?); + } + } + let length = length.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "length"))?; + if length > MAX_BODY { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "large")); + } + let mut body = vec![0u8; length]; + input.read_exact(&mut body)?; + Ok(body) +} + +fn write_frame(output: &mut impl Write, body: &[u8]) -> std::io::Result<()> { + write!(output, "Content-Length: {}\r\n\r\n", body.len())?; + output.write_all(body)?; + output.flush() +} + +fn read_http_response(stream: &mut TcpStream) -> Result<(u16, HashMap, Vec), String> { + let mut buf = Vec::new(); + let mut chunk = [0u8; 1024]; + let started = Instant::now(); + let header_end; + loop { + if started.elapsed() > Duration::from_secs(20) { + return Err("MathScrap did not answer.".into()); + } + if buf.len() > MAX_HEADER + MAX_RESULT { + return Err("The MCP response is too large.".into()); + } + let n = stream.read(&mut chunk).map_err(|_| "MathScrap is not running.".to_string())?; + if n == 0 { + return Err("MathScrap is not running.".into()); + } + buf.extend_from_slice(&chunk[..n]); + if let Some(pos) = find_sub(&buf, b"\r\n\r\n") { + header_end = pos; + break; + } + } + let head = String::from_utf8_lossy(&buf[..header_end]).to_string(); + let mut lines = head.split("\r\n"); + let status_line = lines.next().unwrap_or(""); + let status: u16 = status_line.split(' ').nth(1).and_then(|text| text.parse().ok()).unwrap_or(0); + let mut headers = HashMap::new(); + for line in lines { + let Some((name, value)) = line.split_once(':') else { continue }; + headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string()); + } + if headers.contains_key("location") { + return Err("MathScrap refused a redirect.".into()); + } + let length = headers.get("content-length").and_then(|text| text.parse::().ok()).unwrap_or(0); + if length > MAX_RESULT { + return Err("The MCP response is too large.".into()); + } + let mut body = buf[header_end + 4..].to_vec(); + while body.len() < length { + let n = stream.read(&mut chunk).map_err(|_| "MathScrap is not running.".to_string())?; + if n == 0 { + break; + } + body.extend_from_slice(&chunk[..n]); + } + body.truncate(length); + Ok((status, headers, body)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn temp_dir() -> PathBuf { + let mut bytes = [0u8; 8]; + getrandom::fill(&mut bytes).unwrap(); + let name: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect(); + let path = std::env::temp_dir().join(format!("mathscrap-mcp-{name}")); + fs::create_dir_all(&path).unwrap(); + path + } + + struct Running { + hub: Hub, + } + + fn boot(timeout_ms: u64, dispatch: Dispatch) -> (Running, u16, String) { + let dir = temp_dir(); + let hub = start_in(dir, 0, Duration::from_millis(timeout_ms), dispatch).expect("start"); + let (_endpoint, token) = secret::load(hub.dir()).expect("private session"); + let port = hub.port(); + assert!(port != 0); + (Running { hub }, port, token) + } + + fn post(port: u16, token: Option<&str>, session: Option<&str>, host: &str, origin: Option<&str>, extra: &str, body: &str) -> (u16, HashMap, Value) { + let mut stream = TcpStream::connect(("127.0.0.1", port)).unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(3))).unwrap(); + stream.set_write_timeout(Some(Duration::from_secs(3))).unwrap(); + let mut headers = format!("POST /mcp HTTP/1.1\r\nHost: {host}\r\nContent-Type: application/json\r\nAccept: application/json, text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n", body.len()); + if let Some(token) = token { + headers.push_str(&format!("Authorization: Bearer {token}\r\n")); + } + if let Some(session) = session { + headers.push_str(&format!("Mcp-Session-Id: {session}\r\n")); + } + if let Some(origin) = origin { + headers.push_str(&format!("Origin: {origin}\r\n")); + } + headers.push_str(extra); + headers.push_str("\r\n"); + stream.write_all(headers.as_bytes()).unwrap(); + stream.write_all(body.as_bytes()).unwrap(); + let (status, response_headers, response_body) = read_http_response(&mut stream).unwrap(); + let value = if response_body.is_empty() { Value::Null } else { serde_json::from_slice(&response_body).unwrap_or(Value::Null) }; + (status, response_headers, value) + } + + fn init(port: u16, token: &str) -> String { + let body = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}"#; + let (status, headers, value) = post(port, Some(token), None, &format!("127.0.0.1:{port}"), None, "", body); + assert_eq!(status, 200, "{value}"); + assert!(headers.get("access-control-allow-origin").is_none()); + assert_eq!(value["result"]["serverInfo"]["name"], "mathscrap"); + assert!(value["result"]["capabilities"]["tools"].is_object()); + headers.get("mcp-session-id").cloned().expect("session") + } + + #[test] + fn protocol_page_roundtrip_and_rejections() { + let sheet = Arc::new(Mutex::new(Vec::::new())); + let sheet_add = sheet.clone(); + let hub_slot: Arc>> = Arc::new(Mutex::new(None)); + let slot = hub_slot.clone(); + let (running, port, token) = boot(400, Arc::new(move |job| { + let id = job["id"].as_str().unwrap().to_string(); + let name = job["name"].as_str().unwrap(); + let args = job["arguments"].clone(); + let result = match name { + "page_read" => json!({"formulas": *sheet_add.lock().unwrap(), "strokes": 0}), + "page_add_formula" => { + let card = json!({"id": format!("c{}", sheet_add.lock().unwrap().len() + 1), "text": args["text"], "x": 0, "y": 0}); + sheet_add.lock().unwrap().push(card.clone()); + card + } + "page_update_formula" => { + let mut cards = sheet_add.lock().unwrap(); + if let Some(card) = cards.iter_mut().find(|card| card["id"] == args["id"]) { + card["text"] = args["text"].clone(); + } + json!({"ok": true, "id": args["id"], "text": args["text"]}) + } + "solve_analyze" => json!({"dim": 2, "texts": args["texts"], "sections": [], "errors": []}), + "ink_read" => json!({"total": 0, "matched": 0, "returned": 0, "strokes": []}), + "plot_open" | "plot_set_view" => json!({"ok": true}), + _ => json!({"error": "unknown"}), + }; + slot.lock().unwrap().as_ref().unwrap().complete(&id, true, Some(result), None); + true + })); + *hub_slot.lock().unwrap() = Some(running.hub.clone()); + assert!(running.hub.listening()); + let bound = TcpListener::bind(("127.0.0.1", port)); + assert!(bound.is_err(), "the listener must own 127.0.0.1:{port}"); + + let (status, _, _) = post(port, None, None, &format!("127.0.0.1:{port}"), None, "", r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#); + assert_eq!(status, 401); + let (status, _, _) = post(port, Some("ab"), None, &format!("127.0.0.1:{port}"), None, "", r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#); + assert_eq!(status, 401); + let (status, _, value) = post(port, Some(&token), None, "evil.example:47323", None, "", r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#); + assert_eq!(status, 403, "{value}"); + assert_eq!(value["error"]["data"]["code"], "mcp_bad_host"); + let (status, _, value) = post(port, Some(&token), None, &format!("127.0.0.1:{port}"), Some("null"), "", r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#); + assert_eq!(status, 403); + assert_eq!(value["error"]["data"]["code"], "mcp_bad_origin"); + let (status, _, _) = post(port, Some(&token), None, &format!("127.0.0.1:{port}"), Some("https://evil.example"), "X-Forwarded-For: 1.2.3.4\r\n", r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#); + assert_eq!(status, 403); + let (status, _, _) = post(port, Some(&token), None, &format!("127.0.0.1:{port}"), None, "Upgrade: websocket\r\n", r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#); + assert_eq!(status, 400); + let (status, _, value) = post(port, Some(&token), None, &format!("127.0.0.1:{port}"), None, "", "not-json"); + assert_eq!(status, 400); + assert_eq!(value["error"]["data"]["code"], "mcp_bad_json"); + + let mut stream = TcpStream::connect(("127.0.0.1", port)).unwrap(); + let huge = format!("POST /mcp HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nAuthorization: Bearer {token}\r\nContent-Length: 999999\r\nConnection: close\r\n\r\n"); + stream.write_all(huge.as_bytes()).unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + let (status, headers, _) = read_http_response(&mut stream).unwrap(); + assert_eq!(status, 413); + assert!(headers.get("access-control-allow-origin").is_none()); + assert!(headers.get("location").is_none()); + + let session = init(port, &token); + let note = r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#; + let (status, _, _) = post(port, Some(&token), Some(&session), &format!("127.0.0.1:{port}"), None, "", note); + assert_eq!(status, 202); + let (status, _, _) = post(port, Some(&token), None, &format!("127.0.0.1:{port}"), None, "", r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#); + assert_eq!(status, 400); + let (status, _, _) = post(port, Some(&token), Some("deadbeef"), &format!("127.0.0.1:{port}"), None, "", r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#); + assert_eq!(status, 404); + + let (status, _, value) = post(port, Some(&token), Some(&session), &format!("127.0.0.1:{port}"), None, "", r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#); + assert_eq!(status, 200, "{value}"); + let names: Vec<&str> = value["result"]["tools"].as_array().unwrap().iter().map(|tool| tool["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["page_read", "page_add_formula", "page_update_formula", "solve_analyze", "plot_open", "plot_set_view", "ink_read"]); + assert!(value["result"]["tools"][1]["inputSchema"]["required"].as_array().unwrap().iter().any(|item| item == "text")); + + let (status, _, value) = post(port, Some(&token), Some(&session), &format!("127.0.0.1:{port}"), None, "", r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"page_read","arguments":{}}}"#); + assert_eq!(status, 200); + let text = value["result"]["content"][0]["text"].as_str().unwrap(); + assert_eq!(value["result"]["isError"], true); + assert!(text.contains("mcp_page_offline"), "{text}"); + + running.hub.set_ready(true); + let (status, _, value) = post(port, Some(&token), Some(&session), &format!("127.0.0.1:{port}"), None, "", r#"{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"page_add_formula","arguments":{"text":"y = x"}}}"#); + assert_eq!(status, 200, "{value}"); + assert_eq!(value["result"]["isError"], false); + let (status, _, value) = post(port, Some(&token), Some(&session), &format!("127.0.0.1:{port}"), None, "", r#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"page_read","arguments":{}}}"#); + assert_eq!(status, 200, "{value}"); + let read = serde_json::from_str::(value["result"]["content"][0]["text"].as_str().unwrap()).unwrap(); + assert_eq!(read["formulas"][0]["text"], "y = x"); + let (status, _, value) = post(port, Some(&token), Some(&session), &format!("127.0.0.1:{port}"), None, "", r#"{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"page_update_formula","arguments":{"id":"c1","text":"y = 2"}}}"#); + assert_eq!(status, 200, "{value}"); + assert_eq!(value["result"]["isError"], false); + let (status, _, value) = post(port, Some(&token), Some(&session), &format!("127.0.0.1:{port}"), None, "", r#"{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"solve_analyze","arguments":{"texts":["y = 2"]}}}"#); + assert_eq!(status, 200, "{value}"); + let solved = serde_json::from_str::(value["result"]["content"][0]["text"].as_str().unwrap()).unwrap(); + assert_eq!(solved["dim"], 2); + assert_eq!(solved["texts"][0], "y = 2"); + let _ = sheet; + } + + #[test] + fn timeout_busy_shutdown_and_stdio() { + let (running, port, token) = boot(200, Arc::new(|_job| true)); + running.hub.set_ready(true); + let session = init(port, &token); + let started = Instant::now(); + let (status, _, value) = post(port, Some(&token), Some(&session), &format!("127.0.0.1:{port}"), None, "", r#"{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"page_read","arguments":{}}}"#); + assert_eq!(status, 200, "{value}"); + assert_eq!(value["result"]["isError"], true); + assert!(value["result"]["content"][0]["text"].as_str().unwrap().contains("mcp_timeout")); + assert!(started.elapsed() < Duration::from_secs(3)); + drop(running); + + let entered = Arc::new(AtomicUsize::new(0)); + let release = Arc::new(AtomicBool::new(false)); + let (running, port, token) = boot(2_000, { + let entered = entered.clone(); + let release = release.clone(); + Arc::new(move |_job| { + entered.fetch_add(1, Ordering::SeqCst); + while !release.load(Ordering::SeqCst) { + thread::sleep(Duration::from_millis(5)); + } + false + }) + }); + running.hub.set_ready(true); + let session = init(port, &token); + let mut handles = Vec::new(); + for _ in 0..MAX_PENDING { + let session = session.clone(); + let token = token.clone(); + handles.push(thread::spawn(move || { + post(port, Some(&token), Some(&session), &format!("127.0.0.1:{port}"), None, "", r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"ink_read","arguments":{}}}"#) + })); + } + let wait_until = Instant::now() + Duration::from_secs(2); + while entered.load(Ordering::SeqCst) < MAX_PENDING && Instant::now() < wait_until { + thread::sleep(Duration::from_millis(5)); + } + assert_eq!(entered.load(Ordering::SeqCst), MAX_PENDING); + let (status, _, value) = post(port, Some(&token), Some(&session), &format!("127.0.0.1:{port}"), None, "", r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"ink_read","arguments":{}}}"#); + assert_eq!(status, 200, "{value}"); + assert!(value["result"]["content"][0]["text"].as_str().unwrap().contains("mcp_busy"), "{value}"); + release.store(true, Ordering::SeqCst); + for handle in handles { + let _ = handle.join(); + } + let old = token.clone(); + let port = running.hub.port(); + let gone = running.hub.dir().to_path_buf(); + drop(running); + assert!(!gone.join("session").exists(), "graceful close left the session file"); + assert!(!gone.join("endpoint").exists(), "graceful close left the endpoint file"); + let refused = TcpStream::connect_timeout(&SocketAddr::from(([127, 0, 0, 1], port)), Duration::from_millis(200)); + assert!(refused.is_err() || refused.unwrap().write_all(b"GET / HTTP/1.1\r\n\r\n").is_err()); + + let (running, port, token) = boot(500, Arc::new(|_job| true)); + let (status, _, _) = post(port, Some(&old), None, &format!("127.0.0.1:{port}"), None, "", r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#); + assert_eq!(status, 401); + let mut session = None; + let response = forward_in(running.hub.dir(), &mut session, &json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{}}})).unwrap().unwrap(); + let value: Value = serde_json::from_slice(&response).unwrap(); + assert_eq!(value["result"]["protocolVersion"], "2025-03-26"); + assert!(session.is_some()); + assert_ne!(token, old); + let listed = forward_in(running.hub.dir(), &mut session, &json!({"jsonrpc":"2.0","id":2,"method":"tools/list"})).unwrap().unwrap(); + let listed: Value = serde_json::from_slice(&listed).unwrap(); + assert_eq!(listed["result"]["tools"][0]["name"], "page_read"); + assert!(parse_loopback_endpoint("http://169.254.169.254/mcp").is_err()); + assert!(parse_loopback_endpoint("https://127.0.0.1:1/mcp").is_err()); + let mode = fs::metadata(running.hub.dir().join("session")).unwrap().permissions(); + let saved_dir = std::env::var_os("MATHSCRAP_MCP_DIR"); + let saved_flag = std::env::var_os("MATHSCRAP_MCP"); + std::env::set_var("MATHSCRAP_MCP_DIR", running.hub.dir()); + std::env::set_var("MATHSCRAP_MCP", "off"); + let disabled = Hub::start(Arc::new(|_| false)); + match saved_flag { + Some(value) => std::env::set_var("MATHSCRAP_MCP", value), + None => std::env::remove_var("MATHSCRAP_MCP"), + } + match saved_dir { + Some(value) => std::env::set_var("MATHSCRAP_MCP_DIR", value), + None => std::env::remove_var("MATHSCRAP_MCP_DIR"), + } + assert!(disabled.is_err(), "disabled MCP started a listener"); + assert!(!running.hub.dir().join("session").exists(), "disabled start left the session file"); + assert!(!running.hub.dir().join("endpoint").exists(), "disabled start left the endpoint file"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(mode.mode() & 0o077, 0); + } + let _ = mode; + } + + #[test] + fn newline_stdio_answers_and_content_length_still_answers() { + let (running, _port, token) = boot(500, Arc::new(|_| true)); + let line = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{}}}"#; + let mut request = line.to_vec(); + request.push(b'\n'); + request.extend_from_slice(br#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#); + request.push(b'\n'); + request.extend_from_slice(br#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#); + request.push(b'\n'); + let mut output = Vec::new(); + stdio_exchange(running.hub.dir(), &mut &request[..], &mut output).unwrap(); + let body = br#"{"jsonrpc":"2.0","id":7,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{}}}"#; + let mut framed = format!("Content-Length: {}\r\n\r\n", body.len()).into_bytes(); + framed.extend_from_slice(body); + let mut framed_output = Vec::new(); + stdio_exchange(running.hub.dir(), &mut &framed[..], &mut framed_output).unwrap(); + let text = String::from_utf8(output).unwrap(); + assert!(!text.contains(&token), "stdio printed the session token"); + assert!(!text.contains("Content-Length"), "a JSON line was answered with a header"); + let lines: Vec<&str> = text.lines().filter(|line| !line.is_empty()).collect(); + assert_eq!(lines.len(), 2, "a notification must not add a stdout line"); + let value: Value = serde_json::from_str(lines[0]).unwrap(); + assert_eq!(value["id"], 1); + assert_eq!(value["result"]["serverInfo"]["name"], "mathscrap"); + assert_eq!(value["result"]["protocolVersion"], "2024-11-05"); + let listed: Value = serde_json::from_str(lines[1]).unwrap(); + assert_eq!(listed["id"], 2); + assert_eq!(listed["result"]["tools"].as_array().map(Vec::len), Some(7)); + + let text = String::from_utf8(framed_output).unwrap(); + assert!(text.starts_with("Content-Length:"), "a header request was answered as a line"); + assert!(!text.contains(&token), "stdio printed the session token"); + let json = text.split("\r\n\r\n").nth(1).unwrap(); + let value: Value = serde_json::from_str(json.trim()).unwrap(); + assert_eq!(value["id"], 7); + assert_eq!(value["result"]["protocolVersion"], "2025-06-18"); + } + + #[test] + fn close_session_removes_files_without_drop() { + let (running, port, _token) = boot(500, Arc::new(|_| false)); + let dir = running.hub.dir().to_path_buf(); + assert!(dir.join("session").is_file(), "the session file was not published"); + running.hub.close_session(); + assert!(!dir.join("session").exists(), "close_session left the session file"); + assert!(!dir.join("endpoint").exists(), "close_session left the endpoint file"); + let refused = TcpStream::connect_timeout(&SocketAddr::from(([127, 0, 0, 1], port)), Duration::from_millis(200)); + assert!(refused.is_err() || refused.unwrap().write_all(b"GET / HTTP/1.1\r\n\r\n").is_err()); + // `process::exit` skips Drop. The files must already be gone. + std::mem::forget(running); + assert!(!dir.join("session").exists(), "forgetting the hub restored the session file"); + } + + #[test] + fn stdio_proxy_reads_the_listener() { + let sheet = Arc::new(Mutex::new(Vec::::new())); + let slot: Arc>> = Arc::new(Mutex::new(None)); + let sheet_call = sheet.clone(); + let slot_call = slot.clone(); + let (running, _port, token) = boot(2_000, Arc::new(move |job| { + let id = job["id"].as_str().unwrap().to_string(); + let result = match job["name"].as_str().unwrap_or("") { + "page_read" => json!({"formulas": *sheet_call.lock().unwrap(), "strokes": 0}), + _ => json!({"ok": true}), + }; + slot_call.lock().unwrap().as_ref().unwrap().complete(&id, true, Some(result), None); + true + })); + *slot.lock().unwrap() = Some(running.hub.clone()); + running.hub.set_ready(true); + let pkg = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../mcp/stdio-proxy"); + let mut command = if cfg!(windows) { + let mut command = std::process::Command::new("node"); + command.arg(pkg.join("bin.js")); + command + } else { + let mut command = std::process::Command::new("npx"); + command.args(["--yes", "--package", pkg.to_str().unwrap(), "mathscrap-mcp-stdio"]); + command.env("NPM_CONFIG_LOGLEVEL", "silent"); + command.env("NPM_CONFIG_UPDATE_NOTIFIER", "false"); + command + }; + let mut child = command + .env("MATHSCRAP_MCP_DIR", running.hub.dir()) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("stdio proxy failed to start"); + let mut stdin = child.stdin.take().unwrap(); + let mut stdout = child.stdout.take().unwrap(); + let mut stderr = child.stderr.take().unwrap(); + let request = concat!( + r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{}}}"#, + "\n", + r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#, + "\n", + r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#, + "\n", + r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"page_read","arguments":{}}}"#, + "\n", + ); + let copied = thread::spawn(move || { + use std::io::Read; + let _ = stdin.write_all(request.as_bytes()); + drop(stdin); + let mut out = Vec::new(); + let mut err = Vec::new(); + let _ = stdout.read_to_end(&mut out); + let _ = stderr.read_to_end(&mut err); + (out, err) + }); + let started = Instant::now(); + loop { + if started.elapsed() > Duration::from_secs(30) { + let _ = child.kill(); + break; + } + if child.try_wait().ok().flatten().is_some() { + break; + } + thread::sleep(Duration::from_millis(20)); + } + let _ = child.wait(); + let (out, err) = copied.join().unwrap(); + let text = String::from_utf8_lossy(&out); + let errors = String::from_utf8_lossy(&err); + assert!(!text.contains(&token) && !errors.contains(&token), "stdio proxy printed the session token"); + let lines: Vec<&str> = text.lines().filter(|line| !line.is_empty()).collect(); + assert!(lines.len() >= 3, "stdio proxy did not answer initialize, tools/list, and page_read"); + let init: Value = serde_json::from_str(lines[0]).unwrap(); + assert_eq!(init["id"], 1); + assert_eq!(init["result"]["serverInfo"]["name"], "mathscrap"); + let listed: Value = serde_json::from_str(lines[1]).unwrap(); + assert_eq!(listed["id"], 2); + assert_eq!(listed["result"]["tools"].as_array().map(Vec::len), Some(7)); + let read: Value = serde_json::from_str(lines[2]).unwrap(); + assert_eq!(read["id"], 3); + assert_eq!(read["result"]["isError"], false); + let body: Value = serde_json::from_str(read["result"]["content"][0]["text"].as_str().unwrap()).unwrap(); + assert!(body["formulas"].as_array().unwrap().is_empty(), "page_read did not return the listener sheet"); + let _ = sheet; + } +} diff --git a/crates/mcpserve/src/secret.rs b/crates/mcpserve/src/secret.rs new file mode 100644 index 0000000..175087a --- /dev/null +++ b/crates/mcpserve/src/secret.rs @@ -0,0 +1,972 @@ +//! Private session files. +//! +//! The token is written only after the directory's ACL or mode already excludes +//! other accounts. Nothing here prints the token. On Windows, SYSTEM and +//! Administrators remain in the ACL because that is the real privileged +//! boundary. The LocalAppData ancestor is never modified. + +#![allow(unsafe_code)] + +use std::fs; +use std::io::Read; +#[cfg(unix)] +use std::io::Write; +use std::path::Path; + +#[derive(Clone, Copy)] +pub(crate) struct SecretError { + pub code: &'static str, + pub message: &'static str, + status: u32, + detail: &'static str, +} + +impl SecretError { + const fn new(code: &'static str, message: &'static str) -> Self { + Self { code, message, status: 0, detail: "" } + } + + #[cfg_attr(not(windows), allow(dead_code))] + const fn with_status(mut self, status: u32) -> Self { + self.status = status; + self + } + + #[cfg_attr(not(windows), allow(dead_code))] + const fn with_detail(mut self, detail: &'static str) -> Self { + self.detail = detail; + self + } + + pub(crate) const fn closed() -> Self { + Self::new("mcp_closed", "MathScrap is not running.") + } + + const fn acl() -> Self { + Self::new("mcp_secret_acl", "The session file is not private to this user.") + } + + const fn owner() -> Self { + Self::new("mcp_secret_owner", "The session file is not owned by this user.") + } + + const fn reparse() -> Self { + Self::new("mcp_secret_reparse", "The session path is a reparse point.") + } +} + +impl std::fmt::Debug for SecretError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.status == 0 && self.detail.is_empty() { + write!(f, "{} ({})", self.code, self.message) + } else { + write!(f, "{} ({}) {} {:08x}", self.code, self.message, self.detail, self.status) + } + } +} + +pub(crate) fn publish(dir: &Path, token: &str, endpoint: &str) -> Result<(), SecretError> { + prepare_dir(dir)?; + write_secret(&dir.join("session"), token.as_bytes())?; + if let Err(err) = write_secret(&dir.join("endpoint"), endpoint.as_bytes()) { + let _ = scrub(dir); + return Err(err); + } + Ok(()) +} + +pub(crate) fn load(dir: &Path) -> Result<(String, String), SecretError> { + if dir.as_os_str().is_empty() { + return Err(SecretError::closed()); + } + verify_dir(dir)?; + let endpoint = read_secret(&dir.join("endpoint"))?; + let token = read_secret(&dir.join("session"))?; + let token = token.trim(); + if token.len() != 64 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(SecretError::closed()); + } + Ok((endpoint, token.to_string())) +} + +pub(crate) fn scrub(dir: &Path) -> Result<(), SecretError> { + if dir.as_os_str().is_empty() { + return Ok(()); + } + match fs::symlink_metadata(dir) { + Ok(meta) if reparse_point(&meta) => return Err(SecretError::reparse()), + Ok(meta) if !meta.is_dir() => return Err(SecretError::reparse()), + Ok(_) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(_) => return Err(SecretError::acl()), + } + for name in ["session", "endpoint"] { + remove_secret(&dir.join(name))?; + } + Ok(()) +} + +fn reparse_point(meta: &fs::Metadata) -> bool { + if meta.file_type().is_symlink() { + return true; + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + // Junctions are reparse points and `is_symlink` is false for them. + return meta.file_attributes() & 0x400 != 0; + } + #[cfg(not(windows))] + { + false + } +} + +fn is_app_folder(path: &Path) -> bool { + matches!(path.file_name().and_then(|name| name.to_str()), Some("MathScrap" | "MathScrap-mcptest")) +} + +fn prepare_dir(dir: &Path) -> Result<(), SecretError> { + let parent = dir.parent().filter(|path| !path.as_os_str().is_empty()).ok_or_else(SecretError::acl)?; + if fs::symlink_metadata(parent).map(|meta| reparse_point(&meta)).unwrap_or(false) { + return Err(SecretError::reparse()); + } + if !parent.exists() { + create_private_dir(parent, is_app_folder(parent))?; + } else if is_app_folder(parent) { + // Our app folder only. LocalAppData and other ancestors stay as they are. + lock_existing_dir(parent)?; + } + if fs::symlink_metadata(dir).map(|meta| reparse_point(&meta)).unwrap_or(false) { + return Err(SecretError::reparse()); + } + if fs::symlink_metadata(dir).is_err() { + create_private_dir(dir, true)?; + } + lock_existing_dir(dir)?; + verify_dir(dir) +} + +#[cfg(unix)] +fn create_private_dir(path: &Path, _private: bool) -> Result<(), SecretError> { + use std::os::unix::fs::DirBuilderExt; + let mut builder = fs::DirBuilder::new(); + builder.mode(0o700); + builder.create(path).map_err(|_| SecretError::acl()) +} + +#[cfg(unix)] +fn lock_existing_dir(path: &Path) -> Result<(), SecretError> { + use std::os::unix::fs::OpenOptionsExt; + if fs::symlink_metadata(path).map(|meta| meta.file_type().is_symlink()).unwrap_or(false) { + return Err(SecretError::reparse()); + } + let file = fs::OpenOptions::new().read(true).custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW).open(path).map_err(|_| SecretError::reparse())?; + if unsafe { libc::fchmod(std::os::fd::AsRawFd::as_raw_fd(&file), 0o700) } != 0 { + return Err(SecretError::acl()); + } + verify_unix(&file, true) +} + +#[cfg(unix)] +fn verify_dir(path: &Path) -> Result<(), SecretError> { + use std::os::unix::fs::OpenOptionsExt; + let file = fs::OpenOptions::new().read(true).custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW).open(path).map_err(|_| SecretError::reparse())?; + verify_unix(&file, true) +} + +#[cfg(unix)] +fn write_secret(path: &Path, bytes: &[u8]) -> Result<(), SecretError> { + use std::os::fd::FromRawFd; + use std::os::unix::ffi::OsStrExt; + use std::os::unix::fs::OpenOptionsExt; + if fs::symlink_metadata(path).map(|meta| meta.file_type().is_symlink()).unwrap_or(false) { + remove_name(path)?; + } else if fs::symlink_metadata(path).is_ok() { + remove_name(path)?; + } + let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).map_err(|_| SecretError::acl())?; + let flags = libc::O_RDWR | libc::O_CREAT | libc::O_EXCL | libc::O_NOFOLLOW | libc::O_CLOEXEC; + let fd = unsafe { libc::open(c_path.as_ptr(), flags, 0o600) }; + if fd < 0 { + return Err(SecretError::acl()); + } + let mut file = unsafe { fs::File::from_raw_fd(fd) }; + if unsafe { libc::fchmod(fd, 0o600) } != 0 { + let _ = remove_name(path); + return Err(SecretError::acl()); + } + verify_unix(&file, false)?; + file.write_all(bytes).map_err(|_| SecretError::acl())?; + drop(file); + verify_unix(&fs::OpenOptions::new().read(true).custom_flags(libc::O_NOFOLLOW).open(path).map_err(|_| SecretError::acl())?, false) +} + +#[cfg(unix)] +fn read_secret(path: &Path) -> Result { + use std::os::unix::fs::OpenOptionsExt; + let mut file = fs::OpenOptions::new().read(true).custom_flags(libc::O_NOFOLLOW).open(path).map_err(|_| SecretError::reparse())?; + verify_unix(&file, false)?; + read_capped(&mut file) +} + +#[cfg(unix)] +fn verify_unix(file: &fs::File, directory: bool) -> Result<(), SecretError> { + use std::os::fd::AsRawFd; + let mut stat = unsafe { std::mem::zeroed::() }; + if unsafe { libc::fstat(file.as_raw_fd(), &mut stat) } != 0 { + return Err(SecretError::acl()); + } + let kind = stat.st_mode & libc::S_IFMT; + if directory { + if kind != libc::S_IFDIR { + return Err(SecretError::reparse()); + } + } else if kind != libc::S_IFREG { + return Err(SecretError::reparse()); + } + if stat.st_uid != unsafe { libc::geteuid() } { + return Err(SecretError::owner()); + } + if stat.st_mode & 0o077 != 0 { + return Err(SecretError::acl()); + } + Ok(()) +} + +#[cfg(unix)] +fn remove_secret(path: &Path) -> Result<(), SecretError> { + remove_name(path) +} + +#[cfg(unix)] +fn remove_name(path: &Path) -> Result<(), SecretError> { + match fs::symlink_metadata(path) { + Ok(meta) if meta.is_dir() && !meta.file_type().is_symlink() => Err(SecretError::reparse()), + Ok(_) => fs::remove_file(path).map_err(|_| SecretError::acl()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(_) => Err(SecretError::acl()), + } +} + +fn read_capped(file: &mut fs::File) -> Result { + let mut buf = [0u8; 256]; + let mut used = 0; + loop { + if used == buf.len() { + let mut extra = [0u8; 1]; + let more = file.read(&mut extra).map_err(|_| SecretError::closed())?; + if more > 0 { + return Err(SecretError::closed()); + } + break; + } + let n = file.read(&mut buf[used..]).map_err(|_| SecretError::closed())?; + if n == 0 { + break; + } + used += n; + } + String::from_utf8(buf[..used].to_vec()).map_err(|_| SecretError::closed()) +} + +#[cfg(windows)] +fn create_private_dir(path: &Path, private: bool) -> Result<(), SecretError> { + if private { + windows::create_dir_private(path) + } else { + fs::create_dir(path).map_err(|_| SecretError::acl()) + } +} + +#[cfg(windows)] +fn lock_existing_dir(path: &Path) -> Result<(), SecretError> { + windows::lock_dir(path) +} + +#[cfg(windows)] +fn verify_dir(path: &Path) -> Result<(), SecretError> { + windows::verify_path(path, true) +} + +#[cfg(windows)] +fn write_secret(path: &Path, bytes: &[u8]) -> Result<(), SecretError> { + windows::write_file(path, bytes) +} + +#[cfg(windows)] +fn read_secret(path: &Path) -> Result { + windows::read_file(path) +} + +#[cfg(windows)] +fn remove_secret(path: &Path) -> Result<(), SecretError> { + windows::remove_file(path) +} + +#[cfg(windows)] +mod windows { + use super::{read_capped, SecretError}; + use std::ffi::c_void; + use std::fs::File; + use std::os::windows::ffi::{OsStrExt, OsStringExt}; + use std::path::{Path, PathBuf}; + use std::ptr::null_mut; + use std::fs; + use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, LocalFree, ERROR_FILE_NOT_FOUND, ERROR_PATH_NOT_FOUND, ERROR_SUCCESS, HANDLE, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::Security::Authorization::{GetSecurityInfo, SetSecurityInfo, SE_FILE_OBJECT}; + use windows_sys::Win32::Security::{ + AddAccessAllowedAceEx, AllocateAndInitializeSid, CopySid, CreateWellKnownSid, EqualSid, FreeSid, GetAce, GetAclInformation, GetLengthSid, GetSecurityDescriptorControl, GetTokenInformation, InitializeAcl, InitializeSecurityDescriptor, MakeSelfRelativeSD, SetSecurityDescriptorControl, SetSecurityDescriptorDacl, AclSizeInformation, ACL_SIZE_INFORMATION, CONTAINER_INHERIT_ACE, DACL_SECURITY_INFORMATION, INHERITED_ACE, OBJECT_INHERIT_ACE, OWNER_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION, TokenUser, WinBuiltinAdministratorsSid, WinLocalSystemSid, ACL, ACL_REVISION, PSECURITY_DESCRIPTOR, PSID, SECURITY_ATTRIBUTES, SECURITY_DESCRIPTOR, SE_DACL_PRESENT, SE_DACL_PROTECTED, SID_IDENTIFIER_AUTHORITY, TOKEN_QUERY, TOKEN_USER, WELL_KNOWN_SID_TYPE, + }; + use windows_sys::Win32::Storage::FileSystem::{ + CreateDirectoryW, CreateFileW, FlushFileBuffers, GetFileInformationByHandle, GetFileInformationByHandleEx, GetFinalPathNameByHandleW, SetFileInformationByHandle, WriteFile, BY_HANDLE_FILE_INFORMATION, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, FILE_ATTRIBUTE_REPARSE_POINT, FILE_ATTRIBUTE_TAG_INFO, FILE_DISPOSITION_INFO, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FileAttributeTagInfo, FileDispositionInfo, OPEN_EXISTING, READ_CONTROL, SYNCHRONIZE, WRITE_DAC, + }; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + const FILE_READ_DATA: u32 = 0x0001; + const FILE_WRITE_DATA: u32 = 0x0002; + const FILE_APPEND_DATA: u32 = 0x0004; + const FILE_READ_ATTRIBUTES: u32 = 0x0080; + const FILE_WRITE_ATTRIBUTES: u32 = 0x0100; + const FILE_LIST_DIRECTORY: u32 = 0x0001; + const FILE_ADD_FILE: u32 = 0x0002; + const FILE_ADD_SUBDIRECTORY: u32 = 0x0004; + const FILE_DELETE_CHILD: u32 = 0x0040; + const DELETE: u32 = 0x00010000; + const FILE_ALL_ACCESS: u32 = 0x001F01FF; + const ACCESS_ALLOWED_ACE_TYPE: u8 = 0; + + struct Owned(HANDLE); + + impl Drop for Owned { + fn drop(&mut self) { + if !self.0.is_null() && self.0 != INVALID_HANDLE_VALUE { + unsafe { CloseHandle(self.0) }; + } + } + } + + struct Local(*mut c_void); + + impl Drop for Local { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { LocalFree(self.0 as *mut _) }; + } + } + } + + #[repr(C, align(8))] + struct AclBuf { + bytes: [u8; 768], + } + + struct Identity { + user: Vec, + system: Vec, + admins: Vec, + } + + pub(super) fn create_dir_private(path: &Path) -> Result<(), SecretError> { + let parent = path.parent().ok_or_else(SecretError::acl)?; + let root = open_existing(parent, true)?; + if is_reparse(root.0)? { + return Err(SecretError::reparse()); + } + let id = identity()?; + let mut sd = relative_sd(&id, true)?; + let wide = wide_path(path); + let sa = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: sd.as_mut_ptr() as *mut c_void, + bInheritHandle: 0, + }; + // CreateDirectory applies the descriptor as the directory is created. CreateFile cannot create a directory. + if unsafe { CreateDirectoryW(wide.as_ptr(), &sa) } == 0 { + return Err(SecretError::acl().with_status(unsafe { GetLastError() }).with_detail("create-dir")); + } + let handle = match open_existing(path, true) { + Ok(handle) => handle, + Err(err) => return Err(err), + }; + if is_reparse(handle.0).unwrap_or(true) || !same_directory(root.0, handle.0).unwrap_or(false) { + let _ = delete_handle(handle.0); + return Err(SecretError::reparse()); + } + if let Err(err) = verify_handle(handle.0, &id, true) { + let _ = delete_handle(handle.0); + return Err(err); + } + Ok(()) + } + + pub(super) fn lock_dir(path: &Path) -> Result<(), SecretError> { + let handle = open_existing(path, true)?; + if is_reparse(handle.0)? { + return Err(SecretError::reparse()); + } + let id = identity()?; + apply_private(handle.0, &id, true)?; + verify_handle(handle.0, &id, true) + } + + pub(super) fn verify_path(path: &Path, directory: bool) -> Result<(), SecretError> { + let handle = open_existing(path, directory)?; + if is_reparse(handle.0)? { + return Err(SecretError::reparse()); + } + verify_handle(handle.0, &identity()?, directory) + } + + pub(super) fn write_file(path: &Path, bytes: &[u8]) -> Result<(), SecretError> { + let parent = path.parent().ok_or_else(SecretError::acl)?; + let name = path.file_name().and_then(|text| text.to_str()).ok_or_else(SecretError::acl)?; + let dir = open_existing(parent, true)?; + if is_reparse(dir.0)? { + return Err(SecretError::reparse()); + } + let id = identity()?; + verify_handle(dir.0, &id, true)?; + let child = parent.join(name); + match remove_existing(&child) { + Ok(()) | Err(SecretError { code: "mcp_closed", .. }) => {} + Err(err) => return Err(err), + } + let sd = relative_sd(&id, false)?; + let handle = create_new(&child, false, &sd)?; + if is_reparse(handle.0).unwrap_or(true) || !same_directory(dir.0, handle.0).unwrap_or(false) { + let _ = delete_handle(handle.0); + return Err(SecretError::reparse()); + } + if let Err(err) = verify_handle(handle.0, &id, false) { + let _ = delete_handle(handle.0); + return Err(err); + } + if let Err(err) = write_all(handle.0, bytes) { + let _ = delete_handle(handle.0); + return Err(err); + } + if let Err(err) = verify_handle(handle.0, &id, false) { + let _ = delete_handle(handle.0); + return Err(err); + } + Ok(()) + } + + pub(super) fn read_file(path: &Path) -> Result { + let parent = path.parent().ok_or_else(SecretError::acl)?; + let name = path.file_name().and_then(|text| text.to_str()).ok_or_else(SecretError::acl)?; + let dir = open_existing(parent, true)?; + if is_reparse(dir.0)? { + return Err(SecretError::reparse()); + } + let id = identity()?; + verify_handle(dir.0, &id, true)?; + let handle = open_existing(&parent.join(name), false)?; + if is_reparse(handle.0)? || !same_directory(dir.0, handle.0)? { + return Err(SecretError::reparse()); + } + verify_handle(handle.0, &id, false)?; + read_handle(handle.0) + } + + pub(super) fn remove_file(path: &Path) -> Result<(), SecretError> { + let Some(parent) = path.parent() else { + return Ok(()); + }; + let Some(name) = path.file_name().and_then(|text| text.to_str()) else { + return Ok(()); + }; + let dir = match open_existing(parent, true) { + Ok(handle) => handle, + Err(SecretError { code: "mcp_closed", .. }) => return Ok(()), + Err(err) => return Err(err), + }; + if is_reparse(dir.0)? { + return Err(SecretError::reparse()); + } + match remove_existing(&parent.join(name)) { + Err(SecretError { code: "mcp_closed", .. }) => Ok(()), + other => other, + } + } + + fn open_existing(path: &Path, directory: bool) -> Result { + let wide = wide_path(path); + let handle = unsafe { CreateFileW(wide.as_ptr(), access_mask(directory), FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, null_mut(), OPEN_EXISTING, open_flags(directory), null_mut()) }; + if handle.is_null() || handle == INVALID_HANDLE_VALUE { + let err = unsafe { GetLastError() }; + return Err(if err == ERROR_FILE_NOT_FOUND || err == ERROR_PATH_NOT_FOUND { + SecretError::closed() + } else { + SecretError::acl().with_status(err).with_detail("open") + }); + } + Ok(Owned(handle)) + } + + fn create_new(path: &Path, directory: bool, sd: &[u8]) -> Result { + let wide = wide_path(path); + let mut owned = sd.to_vec(); + let sa = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: owned.as_mut_ptr() as *mut c_void, + bInheritHandle: 0, + }; + let handle = unsafe { CreateFileW(wide.as_ptr(), access_mask(directory), FILE_SHARE_READ | FILE_SHARE_DELETE, &sa, CREATE_NEW, open_flags(directory), null_mut()) }; + if handle.is_null() || handle == INVALID_HANDLE_VALUE { + return Err(SecretError::acl().with_status(unsafe { GetLastError() }).with_detail("create")); + } + Ok(Owned(handle)) + } + + fn access_mask(directory: bool) -> u32 { + let common = FILE_READ_ATTRIBUTES | FILE_WRITE_ATTRIBUTES | READ_CONTROL | WRITE_DAC | DELETE | SYNCHRONIZE; + if directory { + FILE_LIST_DIRECTORY | FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY | FILE_DELETE_CHILD | common + } else { + FILE_READ_DATA | FILE_WRITE_DATA | FILE_APPEND_DATA | common + } + } + + fn open_flags(directory: bool) -> u32 { + let kind = if directory { 0x00000010 } else { FILE_ATTRIBUTE_NORMAL }; + kind | FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT + } + + fn wide_path(path: &Path) -> Vec { + let raw: Vec = path.as_os_str().encode_wide().collect(); + let mut out = Vec::with_capacity(raw.len() + 5); + let prefixed = raw.len() >= 4 && raw[0] == '\\' as u16 && raw[1] == '\\' as u16 && (raw[2] == '?' as u16 || raw[2] == '.' as u16); + let drive = raw.len() >= 3 && raw[1] == ':' as u16; + if !prefixed && drive { + out.extend(['\\' as u16, '\\' as u16, '?' as u16, '\\' as u16]); + } + out.extend(raw); + out.push(0); + out + } + + fn is_reparse(handle: HANDLE) -> Result { + let mut info = FILE_ATTRIBUTE_TAG_INFO { FileAttributes: 0, ReparseTag: 0 }; + let ok = unsafe { GetFileInformationByHandleEx(handle, FileAttributeTagInfo, &mut info as *mut _ as *mut c_void, std::mem::size_of::() as u32) }; + if ok == 0 { + return Err(SecretError::acl().with_status(unsafe { GetLastError() }).with_detail("reparse-query")); + } + Ok(info.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT != 0) + } + + fn delete_handle(handle: HANDLE) -> Result<(), SecretError> { + let info = FILE_DISPOSITION_INFO { DeleteFile: 1 }; + let ok = unsafe { SetFileInformationByHandle(handle, FileDispositionInfo, &info as *const _ as *const c_void, std::mem::size_of::() as u32) }; + if ok == 0 { + return Err(SecretError::acl().with_status(unsafe { GetLastError() }).with_detail("delete")); + } + Ok(()) + } + + fn remove_existing(path: &Path) -> Result<(), SecretError> { + if fs::symlink_metadata(path).is_err() { + return Ok(()); + } + let wide = wide_path(path); + let handle = unsafe { CreateFileW(wide.as_ptr(), DELETE | FILE_READ_ATTRIBUTES | SYNCHRONIZE, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, null_mut(), OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, null_mut()) }; + if handle.is_null() || handle == INVALID_HANDLE_VALUE { + let err = unsafe { GetLastError() }; + return Err(if err == ERROR_FILE_NOT_FOUND || err == ERROR_PATH_NOT_FOUND { SecretError::closed() } else { SecretError::acl().with_status(err).with_detail("replace") }); + } + let owned = Owned(handle); + if is_reparse(owned.0)? { + return delete_handle(owned.0); + } + if fs::symlink_metadata(path).map(|meta| meta.is_dir()).unwrap_or(false) { + return Err(SecretError::reparse()); + } + delete_handle(owned.0) + } + + fn same_directory(parent: HANDLE, child: HANDLE) -> Result { + let expected = file_id(parent)?; + let path = final_path(child)?; + let Some(parent_path) = path.parent() else { + return Ok(false); + }; + let opened = open_existing(parent_path, true)?; + if is_reparse(opened.0)? { + return Ok(false); + } + Ok(file_id(opened.0)? == expected) + } + + fn file_id(handle: HANDLE) -> Result<(u32, u32, u32), SecretError> { + let mut info = unsafe { std::mem::zeroed::() }; + if unsafe { GetFileInformationByHandle(handle, &mut info) } == 0 { + return Err(SecretError::acl().with_status(unsafe { GetLastError() }).with_detail("file-id")); + } + Ok((info.dwVolumeSerialNumber, info.nFileIndexHigh, info.nFileIndexLow)) + } + + fn final_path(handle: HANDLE) -> Result { + let mut buf = vec![0u16; 1024]; + let length = unsafe { GetFinalPathNameByHandleW(handle, buf.as_mut_ptr(), buf.len() as u32, 0) }; + if length == 0 || length as usize >= buf.len() { + return Err(SecretError::acl().with_status(unsafe { GetLastError() }).with_detail("final-path")); + } + buf.truncate(length as usize); + Ok(PathBuf::from(std::ffi::OsString::from_wide(&buf))) + } + + fn write_all(handle: HANDLE, bytes: &[u8]) -> Result<(), SecretError> { + let mut written = 0u32; + let ok = unsafe { WriteFile(handle, bytes.as_ptr(), bytes.len() as u32, &mut written, null_mut()) }; + if ok == 0 || written as usize != bytes.len() { + let _ = delete_handle(handle); + return Err(SecretError::acl()); + } + unsafe { FlushFileBuffers(handle) }; + Ok(()) + } + + fn read_handle(handle: HANDLE) -> Result { + use std::os::windows::io::{FromRawHandle, RawHandle}; + let mut file = unsafe { File::from_raw_handle(handle as RawHandle) }; + let text = read_capped(&mut file); + std::mem::forget(file); + text + } + + fn fill_acl(acl: &mut AclBuf, id: &Identity, directory: bool) -> Result<(), SecretError> { + let acl_ptr = acl.bytes.as_mut_ptr() as *mut ACL; + if unsafe { InitializeAcl(acl_ptr, acl.bytes.len() as u32, ACL_REVISION) } == 0 { + return Err(SecretError::acl().with_detail("acl-init")); + } + let flags = if directory { OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE } else { 0 }; + for sid in [&id.user, &id.system, &id.admins] { + if unsafe { AddAccessAllowedAceEx(acl_ptr, ACL_REVISION, flags, FILE_ALL_ACCESS, sid.as_ptr() as PSID) } == 0 { + return Err(SecretError::acl().with_detail("acl-ace")); + } + } + Ok(()) + } + + fn relative_sd(id: &Identity, directory: bool) -> Result, SecretError> { + let mut acl = Box::new(AclBuf { bytes: [0; 768] }); + fill_acl(&mut acl, id, directory)?; + let mut descriptor = unsafe { std::mem::zeroed::() }; + let sd_ptr = &mut descriptor as *mut SECURITY_DESCRIPTOR as PSECURITY_DESCRIPTOR; + if unsafe { InitializeSecurityDescriptor(sd_ptr, 1) } == 0 { + return Err(SecretError::acl().with_detail("sd-init")); + } + if unsafe { SetSecurityDescriptorDacl(sd_ptr, 1, acl.bytes.as_mut_ptr() as *mut ACL, 0) } == 0 { + return Err(SecretError::acl().with_detail("sd-dacl")); + } + if unsafe { SetSecurityDescriptorControl(sd_ptr, SE_DACL_PROTECTED, SE_DACL_PROTECTED) } == 0 { + return Err(SecretError::acl().with_detail("sd-protect")); + } + descriptor.Control |= SE_DACL_PROTECTED; + let mut bytes = 0u32; + unsafe { MakeSelfRelativeSD(sd_ptr, null_mut(), &mut bytes) }; + if bytes == 0 { + return Err(SecretError::acl().with_status(unsafe { GetLastError() }).with_detail("sd-size")); + } + let mut buf = vec![0u8; bytes as usize]; + if unsafe { MakeSelfRelativeSD(sd_ptr, buf.as_mut_ptr() as PSECURITY_DESCRIPTOR, &mut bytes) } == 0 { + return Err(SecretError::acl().with_status(unsafe { GetLastError() }).with_detail("sd-relative")); + } + buf.truncate(bytes as usize); + Ok(buf) + } + + fn apply_private(handle: HANDLE, id: &Identity, directory: bool) -> Result<(), SecretError> { + let mut acl = AclBuf { bytes: [0; 768] }; + fill_acl(&mut acl, id, directory)?; + let code = unsafe { SetSecurityInfo(handle, SE_FILE_OBJECT, DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, null_mut(), null_mut(), acl.bytes.as_mut_ptr() as *mut ACL, null_mut()) }; + if code != ERROR_SUCCESS { + return Err(SecretError::acl().with_status(code).with_detail("set-dacl")); + } + Ok(()) + } + + fn verify_handle(handle: HANDLE, id: &Identity, _directory: bool) -> Result<(), SecretError> { + let mut owner: PSID = null_mut(); + let mut dacl = null_mut(); + let mut sd: PSECURITY_DESCRIPTOR = null_mut(); + let code = unsafe { GetSecurityInfo(handle, SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, &mut owner, null_mut(), &mut dacl, null_mut(), &mut sd) }; + if code != ERROR_SUCCESS { + return Err(SecretError::acl()); + } + let _sd = Local(sd as *mut c_void); + let owners = [&id.user, &id.system, &id.admins]; + if owner.is_null() || !owners.iter().any(|sid| unsafe { EqualSid(owner, sid.as_ptr() as PSID) } != 0) { + return Err(SecretError::owner()); + } + let mut control = 0u16; + let mut revision = 0u32; + if unsafe { GetSecurityDescriptorControl(sd, &mut control, &mut revision) } == 0 { + return Err(SecretError::acl().with_detail("control")); + } + if control & SE_DACL_PRESENT == 0 || control & SE_DACL_PROTECTED == 0 || dacl.is_null() { + return Err(SecretError::acl().with_detail("protected")); + } + let mut info = ACL_SIZE_INFORMATION { AceCount: 0, AclBytesInUse: 0, AclBytesFree: 0 }; + if unsafe { GetAclInformation(dacl, &mut info as *mut _ as *mut c_void, std::mem::size_of::() as u32, AclSizeInformation) } == 0 { + return Err(SecretError::acl()); + } + if info.AceCount == 0 { + return Err(SecretError::acl()); + } + for index in 0..info.AceCount { + let mut ace = null_mut(); + if unsafe { GetAce(dacl, index, &mut ace) } == 0 { + return Err(SecretError::acl()); + } + let header = unsafe { &*(ace as *const AceHeader) }; + if header.ace_type != ACCESS_ALLOWED_ACE_TYPE || header.ace_flags & INHERITED_ACE as u8 != 0 { + return Err(SecretError::acl().with_detail("ace")); + } + let sid = unsafe { (ace as *mut u8).add(std::mem::size_of::() + 4) as PSID }; + let allowed = [&id.user, &id.system, &id.admins]; + if !allowed.iter().any(|want| unsafe { EqualSid(sid, want.as_ptr() as PSID) } != 0) { + return Err(SecretError::acl().with_detail("ace")); + } + } + Ok(()) + } + + #[repr(C)] + struct AceHeader { + ace_type: u8, + ace_flags: u8, + ace_size: u16, + } + + fn identity() -> Result { + Ok(Identity { user: current_user()?, system: well_known(WinLocalSystemSid)?, admins: well_known(WinBuiltinAdministratorsSid)? }) + } + + fn current_user() -> Result, SecretError> { + let mut token: HANDLE = null_mut(); + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { + return Err(SecretError::acl()); + } + let _token = Owned(token); + let mut len = 0u32; + unsafe { GetTokenInformation(token, TokenUser, null_mut(), 0, &mut len) }; + if len == 0 { + return Err(SecretError::acl()); + } + let mut buf = vec![0u8; len as usize]; + if unsafe { GetTokenInformation(token, TokenUser, buf.as_mut_ptr() as *mut c_void, len, &mut len) } == 0 { + return Err(SecretError::acl()); + } + let user = unsafe { &*(buf.as_ptr() as *const TOKEN_USER) }; + let sid_len = unsafe { GetLengthSid(user.User.Sid) } as usize; + let mut sid = vec![0u8; sid_len]; + if unsafe { CopySid(sid_len as u32, sid.as_mut_ptr() as PSID, user.User.Sid) } == 0 { + return Err(SecretError::acl()); + } + Ok(sid) + } + + fn well_known(kind: WELL_KNOWN_SID_TYPE) -> Result, SecretError> { + let mut sid = vec![0u8; 68]; + let mut len = sid.len() as u32; + if unsafe { CreateWellKnownSid(kind, null_mut(), sid.as_mut_ptr() as PSID, &mut len) } == 0 { + return Err(SecretError::acl()); + } + sid.truncate(len as usize); + Ok(sid) + } + + #[cfg(test)] + pub(super) fn foreign_sid() -> Vec { + let authority = SID_IDENTIFIER_AUTHORITY { Value: [0, 0, 0, 0, 0, 5] }; + let mut sid: PSID = null_mut(); + let ok = unsafe { AllocateAndInitializeSid(&authority, 5, 21, 9, 8, 7, 1001, 0, 0, 0, &mut sid) }; + assert!(ok != 0, "the foreign SID fixture was not created"); + let len = unsafe { GetLengthSid(sid) } as usize; + let mut bytes = vec![0u8; len]; + assert!(unsafe { CopySid(len as u32, bytes.as_mut_ptr() as PSID, sid) } != 0); + unsafe { FreeSid(sid) }; + bytes + } + + #[cfg(test)] + pub(super) fn grant_inheritable(path: &Path, foreign: &[u8]) { + let id = identity().expect("identity"); + let mut acl = AclBuf { bytes: [0; 768] }; + let acl_ptr = acl.bytes.as_mut_ptr() as *mut ACL; + assert!(unsafe { InitializeAcl(acl_ptr, acl.bytes.len() as u32, ACL_REVISION) } != 0); + let flags = OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE; + for sid in [&id.user, &id.system, &id.admins, foreign] { + assert!(unsafe { AddAccessAllowedAceEx(acl_ptr, ACL_REVISION, flags, FILE_ALL_ACCESS, sid.as_ptr() as PSID) } != 0, "fixture ACE"); + } + let directory = fs::metadata(path).map(|meta| meta.is_dir()).unwrap_or(false); + let handle = open_existing(path, directory).expect("fixture object"); + let code = unsafe { SetSecurityInfo(handle.0, SE_FILE_OBJECT, DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, null_mut(), null_mut(), acl_ptr, null_mut()) }; + assert_eq!(code, ERROR_SUCCESS, "fixture DACL was not installed"); + } + + #[cfg(test)] + pub(super) fn allows_sid(path: &Path, foreign: &[u8]) -> bool { + let Ok(handle) = open_existing(path, fs::metadata(path).map(|meta| meta.is_dir()).unwrap_or(false)) else { + return false; + }; + let mut dacl = null_mut(); + let mut sd: PSECURITY_DESCRIPTOR = null_mut(); + let code = unsafe { GetSecurityInfo(handle.0, SE_FILE_OBJECT, DACL_SECURITY_INFORMATION, null_mut(), null_mut(), &mut dacl, null_mut(), &mut sd) }; + if code != ERROR_SUCCESS { + return false; + } + let _sd = Local(sd as *mut c_void); + if dacl.is_null() { + return true; + } + let mut info = ACL_SIZE_INFORMATION { AceCount: 0, AclBytesInUse: 0, AclBytesFree: 0 }; + if unsafe { GetAclInformation(dacl, &mut info as *mut _ as *mut c_void, std::mem::size_of::() as u32, AclSizeInformation) } == 0 { + return false; + } + for index in 0..info.AceCount { + let mut ace = null_mut(); + if unsafe { GetAce(dacl, index, &mut ace) } == 0 { + continue; + } + let header = unsafe { &*(ace as *const AceHeader) }; + if header.ace_type != ACCESS_ALLOWED_ACE_TYPE { + continue; + } + let sid = unsafe { (ace as *mut u8).add(std::mem::size_of::() + 4) as PSID }; + if unsafe { EqualSid(sid, foreign.as_ptr() as PSID) } != 0 { + return true; + } + } + false + } + +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn token(byte: u8) -> String { + format!("{byte:02x}").repeat(32) + } + + fn endpoint() -> String { + "http://127.0.0.1:47323/mcp".to_string() + } + + fn temp() -> PathBuf { + let mut bytes = [0u8; 8]; + getrandom::fill(&mut bytes).unwrap(); + let name: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect(); + let path = std::env::temp_dir().join(format!("mathscrap-secret-{name}")); + fs::create_dir_all(&path).unwrap(); + path + } + + #[test] + fn permissive_parent_cannot_read_and_close_removes_the_files() { + let root = temp(); + let app = root.join("MathScrap-mcptest"); + fs::create_dir(&app).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&app, fs::Permissions::from_mode(0o777)).unwrap(); + } + #[cfg(unix)] + let root_mode = { + use std::os::unix::fs::PermissionsExt; + fs::symlink_metadata(&root).unwrap().permissions().mode() + }; + let dir = app.join("mcp"); + let first = token(0x11); + let second = token(0x22); + publish(&dir, &first, &endpoint()).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(fs::symlink_metadata(&app).unwrap().permissions().mode() & 0o077, 0, "app folder stayed group-writable"); + assert_eq!(fs::symlink_metadata(&dir).unwrap().permissions().mode() & 0o077, 0, "secret directory is not private"); + assert_eq!(fs::symlink_metadata(dir.join("session")).unwrap().permissions().mode() & 0o077, 0, "session mode is not private"); + assert_eq!(fs::symlink_metadata(&root).unwrap().permissions().mode(), root_mode, "ancestor mode changed"); + } + let (got_endpoint, got) = load(&dir).unwrap(); + assert!(got == first && got_endpoint == endpoint(), "private read did not return the written session"); + publish(&dir, &second, &endpoint()).unwrap(); + let (_, rotated) = load(&dir).unwrap(); + assert!(rotated == second && rotated != first, "restart did not rotate the session"); + scrub(&dir).unwrap(); + assert!(load(&dir).is_err(), "scrub left a usable session"); + assert!(!dir.join("session").exists()); + assert!(!dir.join("endpoint").exists()); + let _ = fs::remove_dir_all(&root); + } + + #[cfg(unix)] + #[test] + fn helper_refuses_a_swapped_symlink() { + let root = temp(); + let dir = root.join("mcp"); + publish(&dir, &token(0x33), &endpoint()).unwrap(); + let outside = root.join("outside"); + fs::write(&outside, token(0x44)).unwrap(); + fs::remove_file(dir.join("session")).unwrap(); + std::os::unix::fs::symlink(&outside, dir.join("session")).unwrap(); + let err = load(&dir).unwrap_err(); + assert_eq!(err.code, "mcp_secret_reparse"); + assert!(!format!("{err:?}").contains(&token(0x44)), "symlink rejection included secret bytes"); + let outside_bytes = fs::read(&outside).unwrap(); + publish(&dir, &token(0x33), &endpoint()).unwrap(); + assert_eq!(fs::read(&outside).unwrap(), outside_bytes, "replacing a symlink wrote through it"); + let linked = root.join("linked-parent"); + std::os::unix::fs::symlink(&root, &linked).unwrap(); + let err = publish(&linked.join("nested"), &token(0x55), &endpoint()).unwrap_err(); + assert_eq!(err.code, "mcp_secret_reparse"); + assert!(!root.join("nested").join("session").exists(), "publish followed a parent symlink"); + let _ = fs::remove_dir_all(&root); + } + + #[cfg(windows)] + #[test] + fn inherited_foreign_ace_never_reaches_the_session() { + let root = temp(); + let foreign = windows::foreign_sid(); + windows::grant_inheritable(&root, &foreign); + let probe = root.join("inherited-probe.txt"); + fs::write(&probe, b"probe").unwrap(); + assert!(windows::allows_sid(&probe, &foreign), "the fixture did not inherit the foreign ACE"); + let app = root.join("MathScrap-mcptest"); + fs::create_dir(&app).unwrap(); + assert!(windows::allows_sid(&app, &foreign), "the app folder did not inherit the foreign ACE"); + let dir = app.join("mcp"); + publish(&dir, &token(0x66), &endpoint()).unwrap(); + assert!(windows::allows_sid(&root, &foreign), "the ancestor ACL was modified"); + assert!(windows::allows_sid(&probe, &foreign), "an ancestor file was modified"); + assert!(!windows::allows_sid(&app, &foreign), "the app folder kept the foreign ACE"); + assert!(!windows::allows_sid(&dir, &foreign), "the secret directory kept the foreign ACE"); + assert!(!windows::allows_sid(&dir.join("session"), &foreign), "the session kept the foreign ACE"); + assert!(!windows::allows_sid(&dir.join("endpoint"), &foreign), "the endpoint kept the foreign ACE"); + let _ = load(&dir).expect("owner read of a private session"); + windows::grant_inheritable(&dir.join("session"), &foreign); + let err = load(&dir).unwrap_err(); + assert_eq!(err.code, "mcp_secret_acl"); + assert!(!format!("{err:?}").contains(&token(0x66)), "rejection included the token"); + let real = root.join("real-target"); + fs::create_dir(&real).unwrap(); + let link = root.join("mcp-link"); + let created = std::process::Command::new("cmd.exe").arg("/c").arg("mklink").arg("/J").arg(&link).arg(&real).status().expect("junction"); + assert!(created.success(), "the reparse fixture was not created"); + let err = publish(&link, &token(0x77), &endpoint()); + assert!(err.is_err(), "a reparse session directory was accepted"); + assert_eq!(err.unwrap_err().code, "mcp_secret_reparse"); + assert!(!real.join("session").exists(), "publish followed a directory reparse point"); + let _ = fs::remove_dir(&link); + scrub(&dir).unwrap(); + assert!(load(&dir).is_err(), "close left a usable session"); + let _ = fs::remove_dir_all(&root); + } +} diff --git a/docs/packaged-mcp.md b/docs/packaged-mcp.md new file mode 100644 index 0000000..ce94bf7 --- /dev/null +++ b/docs/packaged-mcp.md @@ -0,0 +1,70 @@ +# Packaged MCP test build + +This is a development build. It is not the signed v0.0.1 release, and it does not replace an installed MathScrap. + +The window listens on `127.0.0.1` only. A small `mathscrap-mcp` program speaks MCP on stdio and forwards to that listener. The page tools run in the window. There is no Vite server, no repository working directory, and no Node runtime. + +The bearer token is a file in the test profile. It is not printed, not put in a URL, and not stored in the page. The `mcp` directory is created with a protected ACL before the token is written, so a permission inherited from LocalAppData is not copied onto the file. On Windows the current user, SYSTEM, and Administrators can read it. Another local account is not granted access. On Linux the directory is mode 0700 and the files are mode 0600, owned by the current user. A process running as that same user can read the file. This does not block an administrator or SYSTEM. A website cannot: a browser sends `Origin`, and every `Origin` is refused. There is no `Access-Control-Allow-Origin`. + +Closing the window deletes the session and endpoint files and closes the listener. `MATHSCRAP_MCP=off` deletes a leftover session and does not listen. If a private ACL cannot be set, or the `mcp` path is a reparse point that cannot be replaced without following it, MCP stays off and the new token is not written. The stdio helper refuses a session or endpoint that is a reparse point, is owned by someone else, or grants any other account. + +## What the parent runs + +The CI artifact `mathscrap-mcp-test-windows` is an unsigned pair of EXEs plus `SHA256SUMS` and `PROVENANCE.txt`. It is a workflow artifact, not a GitHub Release. Check the hashes on the laptop before running anything. Do not install a trust root, and do not turn signature checking off. Windows will say the publisher is unknown. + +Unzip it into a new folder, for example `C:\Users\AllowCache\AppData\Local\MathScrap-mcptest-run\`. Leave `C:\Users\AllowCache\AppData\Local\MathScrap\mathscrap.exe` where it is. The test identifier is `app.mathscrap.mcptest`, so its notes are not the installed app's notes. The sheet starts empty. + +Start `MathScrap-mcp-test-windows-x64.exe`. The window title is MathScrap MCP Test. It writes: + +``` +%LOCALAPPDATA%\MathScrap-mcptest\mcp\session +%LOCALAPPDATA%\MathScrap-mcptest\mcp\endpoint +``` + +`endpoint` is `http://127.0.0.1:47323/mcp`. `session` is the token. Do not paste the token into chat. + +Back up `C:\Users\AllowCache\.zcode\cli\config.json` and merge one server. Do not use `zcode -p`, and do not restart the chat that is already open. + +```json +"mathscrap-test": { + "type": "stdio", + "command": "npx.cmd", + "args": ["--yes", "--package", "C:\\Users\\AllowCache\\AppData\\Local\\MathScrap-mcptest-run\\mathscrap-mcp-stdio-0.0.2-dev.tgz", "mathscrap-mcp-stdio"] +} +``` + +Start the test window first. `cwd` can be `C:\Users\AllowCache`. No extra env is required. The command has no token in it. `npx` reads the session file and speaks JSON lines; it does not launch `mathscrap-mcp.exe`. In a new ZCode session, `/mcp list` then `/mcp connect mathscrap-test`. A stdio spawn that fails in ZCode is a client failure even when this server is fine. The HTTP fallback below puts the token in the config file. That file stays on the machine. Do not copy the token out of it. + +```json +"mathscrap-test-http": { + "type": "http", + "url": "http://127.0.0.1:47323/mcp", + "headers": { + "Authorization": "Bearer " + } +} +``` + +Replace the angle-bracket text with the session file. Do not send that value anywhere else. + +`initialize` returns `serverInfo.name` `mathscrap` and `serverInfo.version` `0.0.2-dev`. The signed v0.0.1 app has no listener. + +`tools/list` returns `page_read`, `page_add_formula`, `page_update_formula`, `solve_analyze`, `plot_open`, `plot_set_view`, and `ink_read`. `page_read` on a new test window returns an empty formula list. `page_add_formula` with `{"text":"y = 2"}` writes only that test sheet. A following `page_read` returns that same formula. `solve_analyze` with `{"texts":["y = 2"]}` returns the page engine's `dim` and `sections`. Closing the window makes the next call fail to connect. + +`page_read`, `ink_read`, and `solve_analyze` do not change the sheet. `page_add_formula`, `page_update_formula`, `plot_open`, and `plot_set_view` do. + +# 打包后的 MCP 测试构建 + +这是开发构建,不是已签名的 v0.0.1,也不会替换已经装上的 MathScrap。不要动 `C:\Users\AllowCache\AppData\Local\MathScrap\mathscrap.exe`。把测试程序解到新目录,例如 `C:\Users\AllowCache\AppData\Local\MathScrap-mcptest-run\`。窗口标题是 MathScrap MCP Test。窗口只听 `127.0.0.1`。`mathscrap-mcp` 用 stdio 转发,命令行里没有令牌。工具在窗口里执行。不需要 Vite、仓库目录或 Node。 + +会话文件是 `%LOCALAPPDATA%\MathScrap-mcptest\mcp\session`,地址文件是同目录的 `endpoint`。不要把令牌贴到聊天里。先备份 `C:\Users\AllowCache\.zcode\cli\config.json`,再合并 stdio 服务器。先打开测试窗口。`command` 用 `npx.cmd`,`args` 是 `--yes`、`--package`、解压目录里的 `mathscrap-mcp-stdio-0.0.2-dev.tgz`、`mathscrap-mcp-stdio`。`cwd` 可以是 `C:\Users\AllowCache`。这个命令不启动 `mathscrap-mcp.exe`。不要用 `zcode -p`,也不要重启已经打开的对话。 + +令牌在测试配置目录的文件里,不打印,不进网址,不进页面存储。`mcp` 目录在写入令牌之前就带受保护的 ACL,不会继承 LocalAppData 上其他账户的权限。Windows 上当前用户、SYSTEM 和管理员可以读。其他本地账户没有授权。同一用户的进程可以读这个文件,管理员和 SYSTEM 也可以。浏览器带 `Origin` 的请求会被拒绝,响应里没有 `Access-Control-Allow-Origin`。 + +关掉窗口会删除 session 和 endpoint,并停止监听。`MATHSCRAP_MCP=off` 会清掉残留的 session,且不监听。如果建不起私有 ACL,或 `mcp` 路径是无法安全替换的重解析点,MCP 保持关闭,新令牌不会写入。stdio 助手拒绝重解析点、属主不是当前用户、或授权了其他账户的 session 与 endpoint。 + +`initialize` 的 `serverInfo.version` 是 `0.0.2-dev`。已签名的 v0.0.1 没有这个监听。 + +`tools/list` 返回 `page_read`、`page_add_formula`、`page_update_formula`、`solve_analyze`、`plot_open`、`plot_set_view`、`ink_read`。新窗口的 `page_read` 公式列表为空。`page_add_formula` 的 `{"text":"y = 2"}` 只写这张测试纸,随后的 `page_read` 能读回同一条。`solve_analyze` 的 `{"texts":["y = 2"]}` 返回页面引擎的 `dim` 和 `sections`。关掉窗口后,下一次连接失败。 + +`page_read`、`ink_read`、`solve_analyze` 不改纸面。`page_add_formula`、`page_update_formula`、`plot_open`、`plot_set_view` 会改。 diff --git a/mcp/stdio-proxy/bin.js b/mcp/stdio-proxy/bin.js new file mode 100755 index 0000000..d26359a --- /dev/null +++ b/mcp/stdio-proxy/bin.js @@ -0,0 +1,187 @@ +#!/usr/bin/env node +/** + * Standard MCP stdio for a running MathScrap window. + * One JSON-RPC object per line. The bearer token is read from the session + * file and is not written to stdout or stderr. This does not start the + * window and it does not spawn mathscrap-mcp.exe. + */ +import fs from 'node:fs' +import http from 'node:http' +import path from 'node:path' +import readline from 'node:readline' + +const CLOSED = 'MathScrap is not running.' +const REPARSE = 'The session path is a reparse point.' +const ENDPOINT = 'The endpoint is not loopback HTTP.' + +function sessionDir() { + const override = process.env.MATHSCRAP_MCP_DIR + if (override && override.trim()) return override + const base = process.env.LOCALAPPDATA + if (base) return path.join(base, 'MathScrap-mcptest', 'mcp') + const home = process.env.HOME || process.env.USERPROFILE || '' + return path.join(home, '.local', 'share', 'MathScrap-mcptest', 'mcp') +} + +function plainFile(file) { + let info + try { + info = fs.lstatSync(file) + } catch { + const error = new Error(CLOSED) + error.codeName = 'mcp_closed' + throw error + } + if (info.isSymbolicLink()) { + const error = new Error(REPARSE) + error.codeName = 'mcp_secret_reparse' + throw error + } +} + +function loadSession() { + const dir = sessionDir() + plainFile(dir) + const endpointFile = path.join(dir, 'endpoint') + const sessionFile = path.join(dir, 'session') + plainFile(endpointFile) + plainFile(sessionFile) + const endpoint = fs.readFileSync(endpointFile, 'utf8').trim() + const token = fs.readFileSync(sessionFile, 'utf8').trim() + let url + try { + url = new URL(endpoint) + } catch { + const error = new Error(ENDPOINT) + error.codeName = 'mcp_closed' + throw error + } + const host = url.hostname.toLowerCase() + const port = Number(url.port) + if (url.protocol !== 'http:' || url.pathname !== '/mcp' || !port || (host !== '127.0.0.1' && host !== 'localhost')) { + const error = new Error(ENDPOINT) + error.codeName = 'mcp_closed' + throw error + } + if (!/^[0-9a-fA-F]{64}$/.test(token)) { + const error = new Error(CLOSED) + error.codeName = 'mcp_closed' + throw error + } + return { port, token } +} + +function post(port, token, sessionId, body) { + const payload = Buffer.from(body) + return new Promise((resolve, reject) => { + const headers = { + Host: `127.0.0.1:${port}`, + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'Content-Length': String(payload.length), + Connection: 'close', + } + if (sessionId) headers['Mcp-Session-Id'] = sessionId + const req = http.request( + { host: '127.0.0.1', port, path: '/mcp', method: 'POST', headers }, + (res) => { + const chunks = [] + let size = 0 + res.on('data', (chunk) => { + size += chunk.length + if (size <= 1024 * 1024) chunks.push(chunk) + }) + res.on('end', () => { + resolve({ + status: res.statusCode || 0, + session: typeof res.headers['mcp-session-id'] === 'string' ? res.headers['mcp-session-id'] : '', + body: Buffer.concat(chunks), + }) + }) + }, + ) + req.setTimeout(20_000, () => req.destroy(new Error('timeout'))) + req.on('error', reject) + req.end(payload) + }) +} + +function writeLine(value) { + process.stdout.write(`${JSON.stringify(value)}\n`) +} + +function fail(id, codeName, message) { + if (id === undefined) return + writeLine({ jsonrpc: '2.0', id, error: { code: -32001, message, data: { code: codeName } } }) +} + +let sessionId = '' + +async function handle(line) { + let message + try { + message = JSON.parse(line) + } catch { + fail(null, 'mcp_bad_json', 'The body is not JSON.') + return + } + if (!message || typeof message !== 'object' || Array.isArray(message)) { + fail(null, 'mcp_bad_json', 'The body is not JSON.') + return + } + const id = Object.prototype.hasOwnProperty.call(message, 'id') ? message.id : undefined + if (message.method === 'initialize') sessionId = '' + let loaded + try { + loaded = loadSession() + } catch (error) { + fail(id, error.codeName || 'mcp_closed', error.message || CLOSED) + return + } + let response + try { + response = await post(loaded.port, loaded.token, sessionId, JSON.stringify(message)) + } catch { + fail(id, 'mcp_closed', CLOSED) + return + } + if (response.session) sessionId = response.session + if (id === undefined || id === null) return + if (response.status >= 300 && response.status < 400) { + fail(id, 'mcp_closed', CLOSED) + return + } + if (!response.body.length) { + if (response.status !== 202) fail(id, 'mcp_closed', CLOSED) + return + } + process.stdout.write(response.body) + if (response.body[response.body.length - 1] !== 0x0a) process.stdout.write('\n') +} + +const pending = [] +let busy = false + +async function drain() { + if (busy) return + busy = true + try { + while (pending.length) { + await handle(pending.shift()) + } + } catch { + process.stderr.write('MathScrap MCP stdio failed.\n') + process.exit(1) + } finally { + busy = false + if (pending.length) void drain() + } +} + +const lines = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }) +lines.on('line', (line) => { + if (!line.trim()) return + pending.push(line) + void drain() +}) diff --git a/mcp/stdio-proxy/package.json b/mcp/stdio-proxy/package.json new file mode 100644 index 0000000..44f88c3 --- /dev/null +++ b/mcp/stdio-proxy/package.json @@ -0,0 +1,11 @@ +{ + "name": "mathscrap-mcp-stdio", + "version": "0.0.2-dev", + "private": true, + "type": "module", + "description": "stdio MCP proxy for a running MathScrap MCP Test window", + "bin": { + "mathscrap-mcp-stdio": "./bin.js" + }, + "files": ["bin.js"] +} diff --git a/mcp/tools.json b/mcp/tools.json new file mode 100644 index 0000000..990bb87 --- /dev/null +++ b/mcp/tools.json @@ -0,0 +1,85 @@ +[ + { + "name": "page_read", + "description": "读取当前这张纸:写在纸上的所有公式(原文、位置)以及笔迹数量。", + "inputSchema": { "type": "object", "properties": {} } + }, + { + "name": "page_add_formula", + "description": "在纸上写一个公式(不传 x/y 时写在视野左侧的下一行)。text 使用本应用的 ASCII 数学写法,例如 \"y = x^2 - 2\" 或 \"△ABC: a=3, b=4, C=90°\"。向量必须写成粗体加等号,例如 \"**b** = (1, 2)\"。", + "inputSchema": { + "type": "object", + "properties": { + "text": { "type": "string" }, + "x": { "type": "number" }, + "y": { "type": "number" } + }, + "required": ["text"] + } + }, + { + "name": "page_update_formula", + "description": "改写纸上某个公式的文本。向量必须写成粗体加等号,例如 \"**b** = (1, 2)\"。", + "inputSchema": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "text": { "type": "string" } + }, + "required": ["id", "text"] + } + }, + { + "name": "solve_analyze", + "description": "对一组公式 / 几何条件做推导:解未知量、求交点、解三角形等,返回结构化结果(由 Rust/WASM 计算)。", + "inputSchema": { + "type": "object", + "properties": { + "texts": { "type": "array", "items": { "type": "string" } } + }, + "required": ["texts"] + } + }, + { + "name": "plot_open", + "description": "打开这张纸共享的坐标系:纸上所有式子画在同一套坐标轴上并联立推导。传 id 时按该式子决定先看平面还是空间。", + "inputSchema": { "type": "object", "properties": { "id": { "type": "string" } } } + }, + { + "name": "plot_set_view", + "description": "修改坐标系:坐标范围、3D 相机角度、叠加哪些对象。未打开坐标系时会先打开。x/y 为 [min, max]。only 只保留这些公式 id,hide 暂时拿掉,show 放回。fit 为 true 时回到自动框住图形的视野。", + "inputSchema": { + "type": "object", + "properties": { + "x": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2, "description": "x 轴范围 [min, max]" }, + "y": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2, "description": "y 轴范围 [min, max]" }, + "z": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2, "description": "z 轴范围 [min, max](空间)" }, + "camera": { + "type": "object", + "description": "3D 相机。eye / center / up 均为 {x, y, z}", + "properties": { + "eye": { "type": "object" }, + "center": { "type": "object" }, + "up": { "type": "object" } + } + }, + "mode": { "type": "number", "description": "2 平面,3 空间" }, + "only": { "type": "array", "items": { "type": "string" }, "description": "只叠加这些公式 id" }, + "show": { "type": "array", "items": { "type": "string" }, "description": "把这些公式放回坐标系" }, + "hide": { "type": "array", "items": { "type": "string" }, "description": "把这些公式暂时从坐标系拿掉" }, + "fit": { "type": "boolean", "description": "回到自动框住所有图形的视野" } + } + } + }, + { + "name": "ink_read", + "description": "读取手写笔迹(点序列)供识别模型使用。since 为 createdAt 毫秒时间戳,只返回该时间之后的笔迹;省略则从最早的开始。pts 为 [x, y, pressure, ...]。", + "inputSchema": { + "type": "object", + "properties": { + "since": { "type": "number", "description": "只返回 createdAt 大于该值的笔迹" }, + "limit": { "type": "number", "description": "最多返回多少条,默认 40,最大 200" } + } + } + } +] diff --git a/scripts/e2e-sheet.ts b/scripts/e2e-sheet.ts index 15b5541..1c2d3ae 100644 --- a/scripts/e2e-sheet.ts +++ b/scripts/e2e-sheet.ts @@ -180,7 +180,11 @@ async function phone() { await page.waitForFunction(() => !!(window as any).mathscrap) await page.screenshot({ path: `${SHOTS}/sheet-phone-blank.png` }) - await page.getByRole('button', { name: '写公式 · 点哪儿写哪儿' }).tap() + const typeTool = page.getByRole('button', { name: '写公式 · 点哪儿写哪儿' }) + await typeTool.tap() + // The paper tap is a raw touch. It must not run until React has committed the type tool, + // or the touch still sees the pen and draws a stroke instead of opening the composer. + await page.waitForFunction(() => document.querySelector('[aria-label="写公式 · 点哪儿写哪儿"]')?.getAttribute('aria-pressed') === 'true') for (const [y, t] of [[220, 'y = sin(x)'], [300, 'y = x/3']] as const) { await page.touchscreen.tap(60, y) const ta = page.getByRole('textbox', { name: '在纸上写公式' }) diff --git a/scripts/mcp-packaged-smoke.mjs b/scripts/mcp-packaged-smoke.mjs new file mode 100644 index 0000000..1efcf4e --- /dev/null +++ b/scripts/mcp-packaged-smoke.mjs @@ -0,0 +1,300 @@ +/** + * Drive a release-mode MathScrap binary over MCP. + * The sheet lives in a temporary profile. The session token is not printed. + * + * GUI=./src-tauri/target/release/mathscrap-mcp-test \ + * STDIO=./src-tauri/target/release/mathscrap-mcp \ + * node scripts/mcp-packaged-smoke.mjs + */ +import { spawn } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, unlinkSync } from 'node:fs' +import net from 'node:net' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' + +const gui = process.env.GUI +const stdioBin = process.env.STDIO +if (!gui || !stdioBin) { + console.error('Set GUI and STDIO to the release binaries.') + process.exit(1) +} + +const root = mkdtempSync(path.join(tmpdir(), 'mathscrap-mcp-pack-')) +const mcpDir = path.join(root, 'mcp') +const port = 47323 +const leaks = [] + +function assert(cond, message) { + if (!cond) throw new Error(message) +} + +function spawnGui() { + const env = { + ...process.env, + MATHSCRAP_MCP_DIR: mcpDir, + MATHSCRAP_MCP_PORT: String(port), + XDG_DATA_HOME: path.join(root, 'data'), + XDG_CONFIG_HOME: path.join(root, 'config'), + XDG_CACHE_HOME: path.join(root, 'cache'), + HOME: path.join(root, 'home'), + WEBKIT_DISABLE_COMPOSITING_MODE: '1', + GDK_BACKEND: 'x11', + } + delete env.MATHSCRAP_MCP + const command = 'xvfb-run' + const args = ['-a', '-s', '-screen 0 1280x800x24', gui] + const child = spawn(command, args, { env, stdio: ['ignore', 'pipe', 'pipe'], detached: true }) + let output = '' + child.stdout.on('data', (chunk) => { + output += chunk.toString() + }) + child.stderr.on('data', (chunk) => { + output += chunk.toString() + }) + return { child, readOutput: () => output } +} + +function stopTree(child) { + if (!child?.pid) return + try { + process.kill(-child.pid, 'SIGKILL') + } catch { + try { + child.kill('SIGKILL') + } catch { + /* already gone */ + } + } +} + +function forgetSession() { + for (const name of ['session', 'endpoint']) { + try { + unlinkSync(path.join(mcpDir, name)) + } catch { + /* the next window publishes a new pair */ + } + } +} + +function http(token, { host, origin, session, method = 'POST', target = '/mcp', body = '', extra = '' } = {}) { + return new Promise((resolve, reject) => { + const socket = net.connect(port, '127.0.0.1') + const chunks = [] + socket.setTimeout(8000) + socket.on('timeout', () => { + socket.destroy() + reject(new Error('timeout')) + }) + socket.on('error', reject) + socket.on('data', (chunk) => chunks.push(chunk)) + socket.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8') + const [head, ...rest] = raw.split('\r\n\r\n') + const status = Number(head.split(' ')[1]) + const headers = {} + for (const line of head.split('\r\n').slice(1)) { + const idx = line.indexOf(':') + if (idx > 0) headers[line.slice(0, idx).toLowerCase()] = line.slice(idx + 1).trim() + } + const text = rest.join('\r\n\r\n') + let json + try { + json = text ? JSON.parse(text) : null + } catch { + json = null + } + resolve({ status, headers, json, raw }) + }) + const payload = body + let headers = `${method} ${target} HTTP/1.1\r\nHost: ${host ?? `127.0.0.1:${port}`}\r\nContent-Length: ${Buffer.byteLength(payload)}\r\nConnection: close\r\n` + if (token) headers += `Authorization: Bearer ${token}\r\n` + if (session) headers += `Mcp-Session-Id: ${session}\r\n` + if (origin !== undefined) headers += `Origin: ${origin}\r\n` + headers += extra + headers += '\r\n' + socket.end(headers + payload) + }) +} + +function frame(message) { + const body = JSON.stringify(message) + return `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}` +} + +function readFrames(buffer) { + const out = [] + let rest = buffer + while (true) { + const end = rest.indexOf('\r\n\r\n') + if (end < 0) break + const head = rest.slice(0, end) + const match = head.match(/content-length:\s*(\d+)/i) + if (!match) break + const length = Number(match[1]) + const start = end + 4 + if (rest.length < start + length) break + out.push(JSON.parse(rest.slice(start, start + length))) + rest = rest.slice(start + length) + } + return { messages: out, rest } +} + +async function waitForSession() { + const deadline = Date.now() + 45_000 + while (Date.now() < deadline) { + try { + const token = readFileSync(path.join(mcpDir, 'session'), 'utf8').trim() + const endpoint = readFileSync(path.join(mcpDir, 'endpoint'), 'utf8').trim() + if (token.length === 64 && endpoint === `http://127.0.0.1:${port}/mcp`) return token + } catch { + /* the window is still starting */ + } + await delay(200) + } + throw new Error('the packaged window did not publish a loopback session') +} + +const guiProc = spawnGui() +let token = '' +let extra +let stdioChild +try { + token = await waitForSession() + const output = guiProc.readOutput() + assert(!output.includes(token), 'the window printed the session token') + + const initBody = JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'packaged-smoke', version: '0' } }, + }) + const denied = await http(null, { body: initBody }) + assert(denied.status === 401, `missing token status ${denied.status}`) + const foreign = await http(token, { origin: 'null', body: initBody }) + assert(foreign.status === 403, `null origin status ${foreign.status}`) + const rebound = await http(token, { host: 'evil.example:47323', body: initBody }) + assert(rebound.status === 403, `rebound host status ${rebound.status}`) + assert(!denied.raw.includes('access-control-allow-origin') && !foreign.raw.includes('location:'), 'cors or redirect header') + + const opened = await http(token, { body: initBody }) + assert(opened.status === 200, `initialize ${opened.status}`) + const session = opened.headers['mcp-session-id'] + assert(session && session !== token, 'session id missing or equal to the bearer token') + const note = await http(token, { session, body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) }) + assert(note.status === 202, `notification ${note.status}`) + const listed = await http(token, { session, body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list' }) }) + const names = listed.json.result.tools.map((tool) => tool.name) + assert(names.join() === 'page_read,page_add_formula,page_update_formula,solve_analyze,plot_open,plot_set_view,ink_read', names.join()) + + async function call(id, name, args) { + const deadline = Date.now() + 20_000 + let last + while (Date.now() < deadline) { + last = await http(token, { + session, + body: JSON.stringify({ jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args } }), + }) + const text = last.json?.result?.content?.[0]?.text ?? '' + if (!text.includes('mcp_page_offline')) return last + await delay(250) + } + return last + } + + const blank = await call(3, 'page_read', {}) + assert(blank.json.result.isError !== true, `blank read ${blank.json?.result?.content?.[0]?.text}`) + const blankBody = JSON.parse(blank.json.result.content[0].text) + assert(Array.isArray(blankBody.formulas) && blankBody.formulas.length === 0, 'the temporary sheet was not empty') + + const added = await call(4, 'page_add_formula', { text: 'y = x' }) + assert(added.json.result.isError !== true, added.json?.result?.content?.[0]?.text) + const card = JSON.parse(added.json.result.content[0].text) + assert(card.text === 'y = x' && card.id, 'add did not return the new formula') + + const updated = await call(5, 'page_update_formula', { id: card.id, text: 'y = 2' }) + assert(updated.json.result.isError !== true, updated.json?.result?.content?.[0]?.text) + + const read = await call(6, 'page_read', {}) + const formulas = JSON.parse(read.json.result.content[0].text).formulas + assert( + formulas.length === 1 && formulas[0].text === 'y = 2' && formulas[0].id === card.id, + `the sheet did not keep the edit ${JSON.stringify(formulas)}`, + ) + + const solved = await call(7, 'solve_analyze', { texts: ['y = 2'] }) + assert(solved.json.result.isError !== true, solved.json?.result?.content?.[0]?.text) + const analysis = JSON.parse(solved.json.result.content[0].text) + assert(analysis.dim === 2 && Array.isArray(analysis.sections), 'solve_analyze did not come back from the page engine') + + const child = spawn(stdioBin, [], { + env: { ...process.env, MATHSCRAP_MCP_DIR: mcpDir }, + stdio: ['pipe', 'pipe', 'pipe'], + }) + stdioChild = child + let incoming = '' + let stderr = '' + child.stdout.on('data', (chunk) => { + incoming += chunk.toString() + }) + child.stderr.on('data', (chunk) => { + stderr += chunk.toString() + }) + child.stdin.write(frame({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {} } })) + child.stdin.write(frame({ jsonrpc: '2.0', method: 'notifications/initialized' })) + child.stdin.write(frame({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'page_read', arguments: {} } })) + const stdioDeadline = Date.now() + 15_000 + let parsed = [] + while (Date.now() < stdioDeadline && parsed.length < 2) { + await delay(100) + parsed = readFrames(incoming).messages + } + child.kill() + assert(!stderr.includes(token) && !parsed.some((message) => JSON.stringify(message).includes(token)), 'stdio printed the token') + const tool = parsed.find((message) => message.id === 2) + assert(tool && tool.result.isError !== true, `stdio page_read failed ${JSON.stringify(parsed)}`) + const viaStdio = JSON.parse(tool.result.content[0].text) + assert(viaStdio.formulas[0].text === 'y = 2', 'stdio did not read the packaged sheet') + + await new Promise((resolve) => { + guiProc.child.once('exit', resolve) + stopTree(guiProc.child) + }) + forgetSession() + let closed = false + try { + await http(token, { body: initBody }) + } catch { + closed = true + } + assert(closed, 'the listener stayed up after the window exited') + + const again = spawnGui() + extra = again + const nextToken = await waitForSession() + assert(nextToken !== token, 'restart reused the bearer token') + const stale = await http(token, { body: initBody }) + assert(stale.status === 401, 'the old token still worked') + const fresh = await http(nextToken, { body: initBody }) + assert(fresh.status === 200 && fresh.json.result.serverInfo.version === '0.0.2-dev', JSON.stringify(fresh.json)) + stopTree(again.child) + console.log('mcp packaged smoke ok') +} catch (error) { + console.error(error instanceof Error ? error.message : error) + const shown = token ? guiProc.readOutput().replaceAll(token, '[token]') : guiProc.readOutput() + console.error(shown.slice(-2000)) + process.exitCode = 1 +} finally { + stopTree(guiProc.child) + stopTree(extra?.child) + try { + stdioChild?.kill() + } catch { + /* already gone */ + } + rmSync(root, { recursive: true, force: true }) + if (leaks.length) process.exitCode = 1 + process.exit(process.exitCode ?? 0) +} diff --git a/scripts/mcp-stdio-probe.mjs b/scripts/mcp-stdio-probe.mjs new file mode 100644 index 0000000..0ad2632 --- /dev/null +++ b/scripts/mcp-stdio-probe.mjs @@ -0,0 +1,145 @@ +/** + * Speak newline-delimited MCP to a stdio command. + * Drains stdout and stderr, stops at a deadline, and prints counts plus a + * redacted snippet. It does not print a bearer token. + * + * node scripts/mcp-stdio-probe.mjs npx --yes --package ./mathscrap-mcp-stdio-0.0.2-dev.tgz mathscrap-mcp-stdio + */ +import { spawn } from 'node:child_process' +import { setTimeout as delay } from 'node:timers/promises' + +const CAP = 65536 +const HEX = /[0-9a-fA-F]{32,}/g + +export function redact(text) { + return String(text).replace(HEX, '[redacted]') +} + +function summarize(stdout) { + const lines = stdout.split('\n').map((line) => line.trim()).filter(Boolean) + let initialize = 'missing' + let tools = 'missing' + let page = 'missing' + for (const line of lines) { + let message + try { + message = JSON.parse(line) + } catch { + continue + } + if (message.id === 1 && message.result?.serverInfo) { + initialize = `ok name=${message.result.serverInfo.name} version=${message.result.serverInfo.version}` + } else if (message.id === 1) { + initialize = 'error' + } + if (message.id === 2 && Array.isArray(message.result?.tools)) { + const names = message.result.tools.map((tool) => tool.name) + tools = names.includes('page_read') ? `ok ${names.length}` : `missing-page_read ${names.length}` + } else if (message.id === 2) { + tools = 'error' + } + if (typeof message.id === 'number' && message.id >= 3) { + const text = message.result?.content?.[0]?.text ?? '' + if (message.result?.isError === true || text.includes('mcp_page_offline')) { + page = 'offline' + continue + } + try { + const body = JSON.parse(text) + page = Array.isArray(body.formulas) ? `ok formulas=${body.formulas.length}` : 'not-a-sheet' + } catch { + page = 'not-json' + } + } + } + if (!stdout) initialize = initialize === 'missing' ? 'zero-stdout' : initialize + else if (!stdout.includes('\n') && initialize === 'missing') initialize = 'partial-no-newline' + return { initialize, tools, page } +} + +export async function probeChild(child, timeoutMs) { + let stdout = Buffer.alloc(0) + let stderr = Buffer.alloc(0) + child.stdout?.on('data', (chunk) => { + if (stdout.length < CAP) stdout = Buffer.concat([stdout, chunk]).subarray(0, CAP) + }) + child.stderr?.on('data', (chunk) => { + if (stderr.length < CAP) stderr = Buffer.concat([stderr, chunk]).subarray(0, CAP) + }) + const send = (message) => child.stdin.write(`${JSON.stringify(message)}\n`) + send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'stdio-probe', version: '0' } }, + }) + send({ jsonrpc: '2.0', method: 'notifications/initialized' }) + send({ jsonrpc: '2.0', id: 2, method: 'tools/list' }) + let pageId = 3 + send({ jsonrpc: '2.0', id: pageId, method: 'tools/call', params: { name: 'page_read', arguments: {} } }) + const deadline = Date.now() + timeoutMs + let pageSent = Date.now() + let report = summarize('') + while (Date.now() < deadline) { + report = summarize(stdout.toString('utf8')) + if (report.initialize.startsWith('ok') && report.tools.startsWith('ok') && report.page.startsWith('ok')) break + if (report.page === 'offline' && Date.now() - pageSent > 300 && pageId < 12) { + pageId += 1 + pageSent = Date.now() + send({ jsonrpc: '2.0', id: pageId, method: 'tools/call', params: { name: 'page_read', arguments: {} } }) + } + if (child.exitCode !== null && report.initialize !== 'missing') break + await delay(50) + } + const poll = child.exitCode + child.stdin.end() + const stopAt = Date.now() + 5_000 + while (child.exitCode === null && Date.now() < stopAt) await delay(50) + if (child.exitCode === null) child.kill() + await delay(100) + const outText = stdout.toString('utf8') + const errText = stderr.toString('utf8') + const leaked = HEX.test(outText) + HEX.lastIndex = 0 + return { + ok: !leaked && report.initialize.startsWith('ok') && report.tools.startsWith('ok') && report.page.startsWith('ok'), + poll, + returncode: child.exitCode, + stdoutBytes: stdout.length, + stderrBytes: stderr.length, + ...report, + leaked, + stdoutRedacted: redact(outText).slice(0, 180).replaceAll('\n', '\\n'), + stderrRedacted: redact(errText).slice(0, 180).replaceAll('\n', '\\n'), + } +} + +export function printProbe(result) { + console.log(`stdout_bytes=${result.stdoutBytes}`) + console.log(`stderr_bytes=${result.stderrBytes}`) + console.log(`poll_before_close=${result.poll}`) + console.log(`returncode=${result.returncode}`) + console.log(`initialize=${result.initialize}`) + console.log(`tools=${result.tools}`) + console.log(`page_read=${result.page}`) + console.log(`stdout_redacted=${result.stdoutRedacted}`) + console.log(`stderr_redacted=${result.stderrRedacted}`) +} + +async function main() { + const command = process.argv[2] + if (!command) { + console.error('usage: node scripts/mcp-stdio-probe.mjs [args...]') + process.exit(2) + } + const child = spawn(command, process.argv.slice(3), { + stdio: ['pipe', 'pipe', 'pipe'], + shell: process.platform === 'win32', + windowsHide: true, + }) + const result = await probeChild(child, 30_000) + printProbe(result) + process.exit(result.ok ? 0 : 1) +} + +if (process.argv[1] && process.argv[1].endsWith('mcp-stdio-probe.mjs')) main() diff --git a/scripts/mcp-windows-npx-smoke.mjs b/scripts/mcp-windows-npx-smoke.mjs new file mode 100644 index 0000000..8968ff3 --- /dev/null +++ b/scripts/mcp-windows-npx-smoke.mjs @@ -0,0 +1,135 @@ +/** + * Start the packaged MathScrap test window and speak MCP through npx. + * This is the Windows handshake. It drains both pipes and does not print the token. + * + * GUI=src-tauri/target/release/mathscrap-mcp-test.exe \ + * TGZ=packaging/out/mathscrap-mcp-stdio-0.0.2-dev.tgz \ + * node scripts/mcp-windows-npx-smoke.mjs + */ +import { spawn, execFileSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import net from 'node:net' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' +import { printProbe, probeChild } from './mcp-stdio-probe.mjs' + +const gui = process.env.GUI +const tgz = process.env.TGZ +if (!gui || !tgz) { + console.error('Set GUI and TGZ.') + process.exit(1) +} + +const root = mkdtempSync(path.join(tmpdir(), 'mathscrap-npx-')) +const mcpDir = path.join(root, 'mcp') +let guiProc +let port = 0 + +function stopGui() { + if (!guiProc?.pid || guiProc.exitCode !== null) return + try { + if (process.platform === 'win32') execFileSync('taskkill', ['/PID', String(guiProc.pid), '/T', '/F'], { stdio: 'ignore' }) + else process.kill(guiProc.pid, 'SIGKILL') + } catch { + /* already gone */ + } +} + +async function waitForSession() { + const deadline = Date.now() + 60_000 + while (Date.now() < deadline) { + try { + const token = readFileSync(path.join(mcpDir, 'session'), 'utf8').trim() + const endpoint = readFileSync(path.join(mcpDir, 'endpoint'), 'utf8').trim() + const match = endpoint.match(/^http:\/\/127\.0\.0\.1:(\d+)\/mcp$/) + if (token.length === 64 && match) { + port = Number(match[1]) + return + } + } catch { + /* the window is still starting */ + } + await delay(200) + } + throw new Error('the packaged window did not publish a loopback session') +} + +function closeMainWindow(pid) { + const script = [ + `$p = Get-Process -Id ${pid} -ErrorAction SilentlyContinue`, + 'if (-not $p) { Write-Output exited; exit 0 }', + '$ok = $false', + 'try { $ok = $p.CloseMainWindow() } catch { $ok = $false }', + "if (-not $ok) { Write-Output 'no-window'; exit 2 }", + 'if (-not $p.WaitForExit(20000)) { Write-Output \'still-running\'; exit 3 }', + 'Write-Output exited', + ].join('\n') + return execFileSync('powershell.exe', ['-NoProfile', '-Command', script], { encoding: 'utf8' }).trim() +} + +function filesRemain() { + for (const name of ['session', 'endpoint']) { + try { + readFileSync(path.join(mcpDir, name)) + return true + } catch { + /* removed */ + } + } + return false +} + +function listenerOpen() { + return new Promise((resolve) => { + const socket = net.connect(port, '127.0.0.1') + socket.setTimeout(500) + socket.on('connect', () => { + socket.destroy() + resolve(true) + }) + socket.on('timeout', () => { + socket.destroy() + resolve(false) + }) + socket.on('error', () => resolve(false)) + }) +} + +try { + guiProc = spawn(gui, [], { + env: { ...process.env, MATHSCRAP_MCP_DIR: mcpDir, MATHSCRAP_MCP_PORT: '0' }, + stdio: 'ignore', + windowsHide: false, + }) + await waitForSession() + const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx' + const child = spawn(npx, ['--yes', '--package', tgz, 'mathscrap-mcp-stdio'], { + env: { + ...process.env, + MATHSCRAP_MCP_DIR: mcpDir, + NPM_CONFIG_LOGLEVEL: 'silent', + NPM_CONFIG_UPDATE_NOTIFIER: 'false', + }, + stdio: ['pipe', 'pipe', 'pipe'], + shell: process.platform === 'win32', + windowsHide: true, + }) + const result = await probeChild(child, 45_000) + printProbe(result) + if (!result.ok) process.exitCode = 1 + else if (process.platform === 'win32') { + const closed = closeMainWindow(guiProc.pid) + console.log(`close=${closed}`) + if (closed !== 'exited' || filesRemain() || (await listenerOpen())) { + console.error('normal close left the session or the listener') + process.exitCode = 1 + } + } +} catch (error) { + console.error(String(error.message || error).replace(/[0-9a-fA-F]{32,}/g, '[redacted]')) + process.exitCode = 1 +} finally { + stopGui() + rmSync(root, { recursive: true, force: true }) +} diff --git a/scripts/pack-smoke.ts b/scripts/pack-smoke.ts index 91e52f8..fa5589b 100644 --- a/scripts/pack-smoke.ts +++ b/scripts/pack-smoke.ts @@ -1,4 +1,5 @@ import { readFileSync } from 'node:fs' +import { TOOL_SPECS } from '../src/ai/specs.ts' import { assetUrl } from '../src/lib/asset.ts' import { parseSheet, serializeSheet } from '../src/board/sheetFile.ts' import { APP_VERSION } from '../src/version.ts' @@ -57,4 +58,15 @@ for (const sample of junk) { const withProto = parseSheet('{"strokes":[],"cards":[{"id":"c1","x":1,"y":2,"text":"y = 1"}],"stickers":[],"__proto__":{"admin":true},"eval":"process.exit(1)"}') check(withProto.cards.length === 1 && !('eval' in withProto), 'unknown fields are dropped') +function canon(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canon) + if (value && typeof value === 'object') { + return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canon((value as Record)[key])])) + } + return value +} +const published = JSON.parse(readFileSync(new URL('../mcp/tools.json', import.meta.url), 'utf8')) as unknown +const fromSpecs = TOOL_SPECS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) +check(JSON.stringify(canon(fromSpecs)) === JSON.stringify(canon(published)), 'mcp/tools.json does not match the page tool schemas') + console.log(`pack-smoke ok ${APP_VERSION}`) diff --git a/scripts/package-mcp-test.sh b/scripts/package-mcp-test.sh new file mode 100755 index 0000000..48d04db --- /dev/null +++ b/scripts/package-mcp-test.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Unsigned Windows test build of the packaged MCP listener. +# This does not sign, does not create a GitHub Release, and does not use the +# official app identifier. It is not v0.0.1. +set -euo pipefail +cd "$(dirname "$0")/.." + +os="$(uname -s)" +case "$os" in + MINGW*|MSYS*|CYGWIN*) ;; + *) + echo "This machine is ${os}. A Windows test EXE was not built here." + echo "CI workflow mcp-test-build / windows-mcp-test builds it and uploads a CI artifact." + echo "That upload is not a GitHub Release." + exit 3 + ;; +esac + +unset WINDOWS_CERTIFICATE WINDOWS_CERTIFICATE_PASSWORD WINDOWS_CERTIFICATE_THUMBPRINT || true +export MATHSCRAP_MCP_PROFILE=mcptest + +npx tauri build --config src-tauri/tauri.mcptest.json --no-bundle --ci +cargo build --manifest-path src-tauri/Cargo.toml --release --bin mathscrap-mcp + +mkdir -p packaging/out +rm -f packaging/out/MathScrap-mcp-test-windows-x64.exe packaging/out/mathscrap-mcp.exe packaging/out/SHA256SUMS packaging/out/PROVENANCE.txt +cp src-tauri/target/release/mathscrap-mcp-test.exe packaging/out/MathScrap-mcp-test-windows-x64.exe +cp src-tauri/target/release/mathscrap-mcp.exe packaging/out/mathscrap-mcp.exe + +cat > packaging/out/PROVENANCE.txt <) { + if let Some(hub) = app.try_state::() { + hub.close_session(); + } +} + +#[tauri::command] +fn mcp_ready(hub: tauri::State<'_, Hub>) { + hub.set_ready(true); +} + +#[tauri::command] +fn mcp_idle(hub: tauri::State<'_, Hub>) { + hub.set_ready(false); +} + +#[tauri::command] +fn mcp_result( + hub: tauri::State<'_, Hub>, + id: String, + ok: bool, + result: Option, + error: Option, +) -> bool { + hub.complete(&id, ok, result, error) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) - .run(tauri::generate_context!()) - .expect("MathScrap failed to start"); + .setup(|app| { + let handle = app.handle().clone(); + let dispatch = std::sync::Arc::new(move |job: serde_json::Value| handle.emit("mcp-call", job).is_ok()); + let hub = Hub::start(dispatch).unwrap_or_else(|_| Hub::disabled()); + app.manage(hub); + Ok(()) + }) + .invoke_handler(tauri::generate_handler![mcp_ready, mcp_idle, mcp_result]) + .on_window_event(|window, event| { + // CloseMainWindow delivers WM_CLOSE here. The runtime then calls + // process::exit, which does not run Hub's Drop. + if let tauri::WindowEvent::CloseRequested { .. } = event { + close_mcp(window); + } + }) + .build(tauri::generate_context!()) + .expect("MathScrap failed to start") + .run(|app, event| { + if let tauri::RunEvent::Exit = event { + close_mcp(app); + } + }); } diff --git a/src-tauri/tauri.mcptest.json b/src-tauri/tauri.mcptest.json new file mode 100644 index 0000000..ab61a40 --- /dev/null +++ b/src-tauri/tauri.mcptest.json @@ -0,0 +1,19 @@ +{ + "productName": "MathScrap MCP Test", + "mainBinaryName": "mathscrap-mcp-test", + "version": "0.0.2-dev", + "identifier": "app.mathscrap.mcptest", + "app": { + "windows": [ + { + "label": "main", + "title": "MathScrap MCP Test", + "width": 1280, + "height": 800, + "minWidth": 360, + "minHeight": 480, + "dragDropEnabled": false + } + ] + } +} diff --git a/src/App.tsx b/src/App.tsx index 63cffab..32c5900 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -163,8 +163,8 @@ export default function App() { const writingSpot = useCallback(() => { const sx = compact ? 24 : Math.max(120, sheet.w * 0.3) const sy = compact ? 110 : sheet.h * 0.3 - return freeSpot((sx - view.x) / view.scale, (sy - view.y) / view.scale, doc.cards) - }, [compact, doc.cards, sheet, view]) + return freeSpot((sx - view.x) / view.scale, (sy - view.y) / view.scale, docRef.current.cards) + }, [compact, docRef, sheet, view]) const openPlot = useCallback((focusId: string | null = null) => setPlot({ open: true, details: false, full: false, focusId }), []) const ensurePlot = useCallback(() => setPlot((p) => (p.open ? p : { open: true, details: false, full: false, focusId: p.focusId })), []) @@ -188,7 +188,7 @@ export default function App() { const placed: Card[] = [] let y = start.y for (const text of blocks) { - const spot = freeSpot(start.x, y, [...doc.cards, ...placed]) + const spot = freeSpot(start.x, y, [...docRef.current.cards, ...placed]) const card: Card = { id: uid(), text, x: spot.x, y: spot.y } placed.push(card) y = spot.y + footprint(card).h + 16 @@ -196,7 +196,7 @@ export default function App() { commit((d) => ({ ...d, cards: [...d.cards, ...placed] })) return placed }, - [commit, doc.cards, writingSpot], + [commit, docRef, writingSpot], ) const updateCard = useCallback((id: string, text: string) => commit((d) => ({ ...d, cards: d.cards.map((c) => (c.id === id ? { ...c, text } : c)) })), [commit]) @@ -400,37 +400,46 @@ export default function App() { return () => window.removeEventListener('keydown', onKey) }, [undo, redo, plot.open, plot.full, recognize, selected, setSelected, deleteCard, openPlot, closePlot, togglePlot, placeComposer, writingSpot]) - // MCP-ready local tool bridge for experimentation from devtools. - // getDoc reads the React state captured for this render, so the bridge is rebuilt when the sheet changes. - const bridge = useMemo( - () => - createToolBridge({ - getDoc: () => doc, - addCard: (text, x, y) => addFormula(text, x, y), - updateCard, - openPlot: (id) => openPlot(id ?? null), - setPlotView: (input) => { - ensurePlot() - return pushPlotView(input) - }, - }), - [doc, addFormula, updateCard, openPlot, ensurePlot], - ) + // Tool calls read the committed document, not the last rendered snapshot. + // A write is visible to the next call before React paints, and the listener stays up across edits. + const toolHost = useRef({ addFormula, updateCard, openPlot, ensurePlot }) + useEffect(() => { + toolHost.current = { addFormula, updateCard, openPlot, ensurePlot } + }, [addFormula, updateCard, openPlot, ensurePlot]) + const callTool = useCallback((name: string, args: Record) => { + const host = toolHost.current + return createToolBridge({ + getDoc: () => docNow(docRef), + addCard: (text, x, y) => host.addFormula(text, x, y), + updateCard: host.updateCard, + openPlot: (id) => host.openPlot(id ?? null), + setPlotView: (input) => { + host.ensurePlot() + return pushPlotView(input) + }, + }).call(name, args) + }, [docRef, toolHost]) useEffect(() => { - ;(window as unknown as { mathscrap: { tools: typeof bridge } }).mathscrap = { tools: bridge } - // The dev bridge exists only while Vite is running. The production build drops this import. - if (!import.meta.env.DEV) return + ;(window as unknown as { mathscrap: { tools: { call: typeof callTool } } }).mathscrap = { tools: { call: callTool } } let dispose: () => void = () => undefined let cancelled = false - void import('@/ai/browserBridge').then((mod) => { - if (cancelled) return - dispose = mod.startBrowserBridge((name, args) => bridge.call(name, args)) - }) + if (import.meta.env.DEV) { + // The dev bridge exists only while Vite is running. The production build drops this import. + void import('@/ai/browserBridge').then((mod) => { + if (cancelled) return + dispose = mod.startBrowserBridge(callTool) + }) + } else if ('__TAURI_INTERNALS__' in window) { + void import('@/ai/packagedBridge').then((mod) => { + if (cancelled) return + dispose = mod.startPackagedBridge(callTool) + }) + } return () => { cancelled = true dispose() } - }, [bridge]) + }, [callTool]) const exportPage = useCallback(() => { void exportSheetFile(docNow(docRef)) @@ -589,7 +598,7 @@ export default function App() { open={assistOpen} onOpenChange={setAssistOpen} side={compact ? 'bottom' : 'right'} - call={(name, args) => bridge.call(name, args)} + call={callTool} onOpenSettings={() => { setAssistOpen(false) setSettingsOpen(true) diff --git a/src/ai/packagedBridge.ts b/src/ai/packagedBridge.ts new file mode 100644 index 0000000..22444ea --- /dev/null +++ b/src/ai/packagedBridge.ts @@ -0,0 +1,42 @@ +import { invoke } from '@tauri-apps/api/core' +import { listen, type UnlistenFn } from '@tauri-apps/api/event' + +type Call = (name: string, args: Record) => unknown + +interface Job { + id: string + name: string + arguments?: Record +} + +/** + * The installed window answers MCP calls over Tauri IPC. + * This file does not know the session token and does not call the dev server. + */ +export function startPackagedBridge(call: Call): () => void { + let unlisten: UnlistenFn | undefined + let stopped = false + void listen('mcp-call', (event) => { + const job = event.payload + if (!job?.id || !job.name) return + try { + const result = call(job.name, job.arguments ?? {}) + void invoke('mcp_result', { id: job.id, ok: true, result }) + } catch (error) { + const message = error instanceof Error ? error.message : 'tool failed' + void invoke('mcp_result', { id: job.id, ok: false, error: message }) + } + }).then((stop) => { + if (stopped) { + stop() + return + } + unlisten = stop + void invoke('mcp_ready') + }) + return () => { + stopped = true + unlisten?.() + void invoke('mcp_idle') + } +} diff --git a/src/ocr/comer/web.ts b/src/ocr/comer/web.ts index 61272fd..c969a9c 100644 --- a/src/ocr/comer/web.ts +++ b/src/ocr/comer/web.ts @@ -4,8 +4,8 @@ import { recognizeStrokes, type OcrCandidate } from './engine.ts' import type { ComerSession, ComerTensor } from './decode.ts' import { vocabFromJson, type Vocab } from './source.ts' import type { InkStroke } from './preprocess.ts' -import { assetUrl } from '@/lib/asset' -import { t } from '@/i18n/locale' +import { assetUrl } from '../../lib/asset.ts' +import { t } from '../../i18n/locale.ts' const ENC = assetUrl('models/comer/encoder_int8.onnx') const DEC = assetUrl('models/comer/decoder_int8.onnx') diff --git a/tsconfig.node.json b/tsconfig.node.json index 797c0a3..f920478 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -3,7 +3,7 @@ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", "target": "es2023", "lib": ["ES2023"], - "types": ["node"], + "types": ["node", "vite/client"], "skipLibCheck": true, /* Bundler mode */