From ac9216785fd0f7a16046c614ab063fece5bc0f5f Mon Sep 17 00:00:00 2001 From: DNT_OF <3174600270@qq.com> Date: Sun, 8 Nov 2026 02:53:13 +0000 Subject: [PATCH 1/5] feat: serve packaged page tools on loopback MCP The window listens on 127.0.0.1 and a stdio helper forwards MCP into the open page. The test profile is unsigned, uses its own identifier, and is not a release. Co-authored-by: DNT_OF --- .github/workflows/mcp-test-build.yml | 148 ++++ .github/workflows/package.yml | 3 + .gitignore | 4 +- crates/mcpserve/Cargo.lock | 153 ++++ crates/mcpserve/Cargo.toml | 13 + crates/mcpserve/src/lib.rs | 1130 ++++++++++++++++++++++++++ docs/packaged-mcp.md | 69 ++ mcp/tools.json | 85 ++ scripts/mcp-packaged-smoke.mjs | 300 +++++++ scripts/pack-smoke.ts | 12 + scripts/package-mcp-test.sh | 43 + src-tauri/Cargo.lock | 10 + src-tauri/Cargo.toml | 2 + src-tauri/capabilities/default.json | 1 + src-tauri/permissions/mcp.toml | 4 + src-tauri/src/bin/mathscrap-mcp.rs | 3 + src-tauri/src/lib.rs | 36 + src-tauri/tauri.mcptest.json | 19 + src/App.tsx | 67 +- src/ai/packagedBridge.ts | 42 + src/ocr/comer/web.ts | 4 +- tsconfig.node.json | 2 +- 22 files changed, 2115 insertions(+), 35 deletions(-) create mode 100644 .github/workflows/mcp-test-build.yml create mode 100644 crates/mcpserve/Cargo.lock create mode 100644 crates/mcpserve/Cargo.toml create mode 100644 crates/mcpserve/src/lib.rs create mode 100644 docs/packaged-mcp.md create mode 100644 mcp/tools.json create mode 100644 scripts/mcp-packaged-smoke.mjs create mode 100755 scripts/package-mcp-test.sh create mode 100644 src-tauri/permissions/mcp.toml create mode 100644 src-tauri/src/bin/mathscrap-mcp.rs create mode 100644 src-tauri/tauri.mcptest.json create mode 100644 src/ai/packagedBridge.ts diff --git a/.github/workflows/mcp-test-build.yml b/.github/workflows/mcp-test-build.yml new file mode 100644 index 0000000..bc834c0 --- /dev/null +++ b/.github/workflows/mcp-test-build.yml @@ -0,0 +1,148 @@ +# 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: Unsigned test binaries + shell: bash + env: + MATHSCRAP_MCP_PROFILE: mcptest + run: bash scripts/package-mcp-test.sh + - 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/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..15abd34 --- /dev/null +++ b/crates/mcpserve/Cargo.lock @@ -0,0 +1,153 @@ +# 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", + "serde", + "serde_json", +] + +[[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 = "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..ed3619b --- /dev/null +++ b/crates/mcpserve/Cargo.toml @@ -0,0 +1,13 @@ +[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" diff --git a/crates/mcpserve/src/lib.rs b/crates/mcpserve/src/lib.rs new file mode 100644 index 0000000..9194f9b --- /dev/null +++ b/crates/mcpserve/src/lib.rs @@ -0,0 +1,1130 @@ +//! Loopback MCP for a running MathScrap window. +//! +//! The listener binds `127.0.0.1` only. The bearer token is written to a +//! user-only file and is never put in a URL, a log line, or the page's +//! localStorage. A same-user process can read that file. This is not an +//! OS-account boundary beyond the file mode and the user profile. + +#![forbid(unsafe_code)] + +use serde_json::{json, Value}; +use std::collections::{HashMap, VecDeque}; +use std::fs::{self, OpenOptions}; +use std::io::{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() { + 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 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.inner.shutdown.store(true, Ordering::SeqCst); + self.inner.fail_all("mcp_closed", "MathScrap MCP stopped."); + if self.inner.port != 0 { + let _ = TcpStream::connect(("127.0.0.1", self.inner.port)); + } + if let Some(thread) = self.thread.lock().ok().and_then(|mut slot| slot.take()) { + let _ = thread.join(); + } + } +} + +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()?; + write_private(&dir, &token, bound.port())?; + 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()) +} + +fn write_private(dir: &Path, token: &str, port: u16) -> Result<(), String> { + if dir.symlink_metadata().map(|meta| meta.file_type().is_symlink()).unwrap_or(false) { + return Err("mcp directory is a symlink".into()); + } + fs::create_dir_all(dir).map_err(|_| "mcp directory was not created".to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o700)); + } + write_file(&dir.join("session"), token)?; + write_file(&dir.join("endpoint"), &format!("http://127.0.0.1:{port}/mcp"))?; + Ok(()) +} + +fn write_file(path: &Path, text: &str) -> Result<(), String> { + if path.symlink_metadata().map(|meta| meta.file_type().is_symlink()).unwrap_or(false) { + return Err("mcp file is a symlink".into()); + } + let mut options = OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(path).map_err(|_| "mcp file was not written".to_string())?; + file.write_all(text.as_bytes()).map_err(|_| "mcp file was not written".to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = file.set_permissions(fs::Permissions::from_mode(0o600)); + } + Ok(()) +} + +/// 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. +pub fn stdio_main() { + let mut session: Option = None; + let stdin = std::io::stdin(); + let mut input = stdin.lock(); + let stdout = std::io::stdout(); + let mut output = stdout.lock(); + loop { + let frame = match read_frame(&mut input) { + Ok(frame) => frame, + Err(_) => break, + }; + let message: Value = match serde_json::from_slice(&frame) { + Ok(value) => value, + Err(_) => { + let _ = write_frame(&mut output, &serde_json::to_vec(&rpc_error(None, "mcp_bad_json", "The body is not JSON.")).unwrap_or_default()); + continue; + } + }; + let notification = message.get("id").is_none(); + match forward(&mut session, &message) { + Ok(Some(bytes)) if !notification => { + let _ = write_frame(&mut output, &bytes); + } + Ok(_) => {} + Err(reason) => { + if notification { + continue; + } + let id = message_id(&message); + let _ = write_frame(&mut output, &serde_json::to_vec(&rpc_error(id, "mcp_closed", &reason)).unwrap_or_default()); + } + } + } +} + +fn message_id(message: &Value) -> Option { + message.get("id").cloned() +} + +fn forward(session: &mut Option, message: &Value) -> Result>, String> { + forward_in(&mcp_dir()?, session, message) +} + +fn forward_in(dir: &Path, session: &mut Option, message: &Value) -> Result>, String> { + let (port, token) = read_local_endpoint(dir)?; + let body = serde_json::to_vec(message).map_err(|_| "The request could not be encoded.".to_string())?; + 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(|_| "MathScrap is not running.".to_string())?; + 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(|_| "MathScrap is not running.".to_string())?; + stream.write_all(&body).map_err(|_| "MathScrap is not running.".to_string())?; + let (status, headers, response_body) = read_http_response(&mut stream)?; + if (300..400).contains(&status) { + return Err("MathScrap refused a redirect.".into()); + } + 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 read_local_endpoint(dir: &Path) -> Result<(u16, String), String> { + let endpoint = fs::read_to_string(dir.join("endpoint")).map_err(|_| "MathScrap is not running.".to_string())?; + let token = fs::read_to_string(dir.join("session")).map_err(|_| "MathScrap is not running.".to_string())?; + let token = token.trim(); + if token.len() != 64 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("MathScrap is not running.".into()); + } + let port = parse_loopback_endpoint(endpoint.trim())?; + Ok((port, token.to_string())) +} + +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) +} + +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::*; + + 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 token = fs::read_to_string(hub.dir().join("session")).unwrap(); + 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(); + drop(running); + 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(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(mode.mode() & 0o077, 0); + } + let _ = mode; + } +} diff --git a/docs/packaged-mcp.md b/docs/packaged-mcp.md new file mode 100644 index 0000000..e4f01ab --- /dev/null +++ b/docs/packaged-mcp.md @@ -0,0 +1,69 @@ +# 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. A process running as the same OS user can read the file. Another user, and a machine on the network, cannot. A website cannot: a browser sends `Origin`, and every `Origin` is refused. There is no `Access-Control-Allow-Origin`. + +Closing the window closes the listener. `MATHSCRAP_MCP=off` starts the window without one. + +## 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": "C:\\Users\\AllowCache\\AppData\\Local\\MathScrap-mcptest-run\\mathscrap-mcp.exe" +} +``` + +The command has no token in it. The program reads the session file. 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 服务器,命令用 `mathscrap-mcp.exe` 的完整路径。不要用 `zcode -p`,也不要重启已经打开的对话。 + +令牌在测试配置目录的文件里,不打印,不进网址,不进页面存储。同一系统用户的进程可以读这个文件。其他用户和局域网不行。浏览器带 `Origin` 的请求会被拒绝,响应里没有 `Access-Control-Allow-Origin`。 + +关掉窗口,监听就停。`MATHSCRAP_MCP=off` 时窗口仍在,但不监听。 + +`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/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/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/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 <) { + 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()) + .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]) .run(tauri::generate_context!()) .expect("MathScrap failed to start"); } 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 */ From 521828390cd31e0f8bf18703bf501f2d97f0a0e2 Mon Sep 17 00:00:00 2001 From: DNT_OF <3174600270@qq.com> Date: Sun, 8 Nov 2026 03:35:29 +0000 Subject: [PATCH 2/5] fix: keep the MCP session off inherited Windows ACLs Create the session directory and files with a protected DACL before the token is written, and delete them when the window closes or MCP is turned off. A swapped or overly broad session file is refused. Co-authored-by: DNT_OF --- .github/workflows/mcp-test-build.yml | 2 + crates/mcpserve/Cargo.lock | 75 +++ crates/mcpserve/Cargo.toml | 13 + crates/mcpserve/src/lib.rs | 120 ++-- crates/mcpserve/src/secret.rs | 960 +++++++++++++++++++++++++++ docs/packaged-mcp.md | 8 +- src-tauri/Cargo.lock | 97 ++- 7 files changed, 1194 insertions(+), 81 deletions(-) create mode 100644 crates/mcpserve/src/secret.rs diff --git a/.github/workflows/mcp-test-build.yml b/.github/workflows/mcp-test-build.yml index bc834c0..04f1450 100644 --- a/.github/workflows/mcp-test-build.yml +++ b/.github/workflows/mcp-test-build.yml @@ -132,6 +132,8 @@ jobs: 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: diff --git a/crates/mcpserve/Cargo.lock b/crates/mcpserve/Cargo.lock index 15abd34..db8b3ef 100644 --- a/crates/mcpserve/Cargo.lock +++ b/crates/mcpserve/Cargo.lock @@ -37,8 +37,10 @@ name = "mcpserve" version = "0.0.1" dependencies = [ "getrandom", + "libc", "serde", "serde_json", + "windows-sys", ] [[package]] @@ -140,6 +142,79 @@ 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" diff --git a/crates/mcpserve/Cargo.toml b/crates/mcpserve/Cargo.toml index ed3619b..c75352a 100644 --- a/crates/mcpserve/Cargo.toml +++ b/crates/mcpserve/Cargo.toml @@ -11,3 +11,16 @@ publish = false 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 index 9194f9b..c05dcc2 100644 --- a/crates/mcpserve/src/lib.rs +++ b/crates/mcpserve/src/lib.rs @@ -1,15 +1,19 @@ //! Loopback MCP for a running MathScrap window. //! //! The listener binds `127.0.0.1` only. The bearer token is written to a -//! user-only file and is never put in a URL, a log line, or the page's -//! localStorage. A same-user process can read that file. This is not an -//! OS-account boundary beyond the file mode and the user profile. +//! 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. -#![forbid(unsafe_code)] +#![deny(unsafe_code)] + +mod secret; use serde_json::{json, Value}; use std::collections::{HashMap, VecDeque}; -use std::fs::{self, OpenOptions}; use std::io::{Read, Write}; use std::net::{IpAddr, Shutdown, SocketAddr, TcpListener, TcpStream}; use std::path::{Path, PathBuf}; @@ -105,6 +109,9 @@ impl Hub { /// 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()?; @@ -128,6 +135,9 @@ impl Drop for Hub { 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); + } } } @@ -200,7 +210,11 @@ fn start_in(dir: PathBuf, port: u16, tool_timeout: Duration, dispatch: Dispatch) return Err("mcp refused a non-loopback bind".into()); } let token = new_token()?; - write_private(&dir, &token, bound.port())?; + 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, @@ -659,42 +673,6 @@ fn new_token() -> Result { Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect()) } -fn write_private(dir: &Path, token: &str, port: u16) -> Result<(), String> { - if dir.symlink_metadata().map(|meta| meta.file_type().is_symlink()).unwrap_or(false) { - return Err("mcp directory is a symlink".into()); - } - fs::create_dir_all(dir).map_err(|_| "mcp directory was not created".to_string())?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o700)); - } - write_file(&dir.join("session"), token)?; - write_file(&dir.join("endpoint"), &format!("http://127.0.0.1:{port}/mcp"))?; - Ok(()) -} - -fn write_file(path: &Path, text: &str) -> Result<(), String> { - if path.symlink_metadata().map(|meta| meta.file_type().is_symlink()).unwrap_or(false) { - return Err("mcp file is a symlink".into()); - } - let mut options = OpenOptions::new(); - options.write(true).create(true).truncate(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - let mut file = options.open(path).map_err(|_| "mcp file was not written".to_string())?; - file.write_all(text.as_bytes()).map_err(|_| "mcp file was not written".to_string())?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = file.set_permissions(fs::Permissions::from_mode(0o600)); - } - Ok(()) -} - /// 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. pub fn stdio_main() { @@ -721,12 +699,12 @@ pub fn stdio_main() { let _ = write_frame(&mut output, &bytes); } Ok(_) => {} - Err(reason) => { + Err(err) => { if notification { continue; } let id = message_id(&message); - let _ = write_frame(&mut output, &serde_json::to_vec(&rpc_error(id, "mcp_closed", &reason)).unwrap_or_default()); + let _ = write_frame(&mut output, &serde_json::to_vec(&rpc_error(id, err.code, err.message)).unwrap_or_default()); } } } @@ -736,13 +714,14 @@ fn message_id(message: &Value) -> Option { message.get("id").cloned() } -fn forward(session: &mut Option, message: &Value) -> Result>, String> { - forward_in(&mcp_dir()?, session, message) +fn forward(session: &mut Option, message: &Value) -> Result>, secret::SecretError> { + forward_in(&mcp_dir().map_err(|_| secret::SecretError::closed())?, session, message) } -fn forward_in(dir: &Path, session: &mut Option, message: &Value) -> Result>, String> { - let (port, token) = read_local_endpoint(dir)?; - let body = serde_json::to_vec(message).map_err(|_| "The request could not be encoded.".to_string())?; +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"); @@ -751,14 +730,14 @@ fn forward_in(dir: &Path, session: &mut Option, message: &Value) -> Resu "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(|_| "MathScrap is not running.".to_string())?; + 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(|_| "MathScrap is not running.".to_string())?; - stream.write_all(&body).map_err(|_| "MathScrap is not running.".to_string())?; - let (status, headers, response_body) = read_http_response(&mut stream)?; + 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("MathScrap refused a redirect.".into()); + return Err(secret::SecretError::closed()); } if let Some(id) = headers.get("mcp-session-id") { *session = Some(id.clone()); @@ -769,17 +748,6 @@ fn forward_in(dir: &Path, session: &mut Option, message: &Value) -> Resu Ok(Some(response_body)) } -fn read_local_endpoint(dir: &Path) -> Result<(u16, String), String> { - let endpoint = fs::read_to_string(dir.join("endpoint")).map_err(|_| "MathScrap is not running.".to_string())?; - let token = fs::read_to_string(dir.join("session")).map_err(|_| "MathScrap is not running.".to_string())?; - let token = token.trim(); - if token.len() != 64 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) { - return Err("MathScrap is not running.".into()); - } - let port = parse_loopback_endpoint(endpoint.trim())?; - Ok((port, token.to_string())) -} - 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())?; @@ -891,6 +859,7 @@ fn read_http_response(stream: &mut TcpStream) -> Result<(u16, HashMap PathBuf { let mut bytes = [0u8; 8]; @@ -908,7 +877,7 @@ mod tests { 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 token = fs::read_to_string(hub.dir().join("session")).unwrap(); + let (_endpoint, token) = secret::load(hub.dir()).expect("private session"); let port = hub.port(); assert!(port != 0); (Running { hub }, port, token) @@ -1101,7 +1070,10 @@ mod tests { } 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()); @@ -1120,6 +1092,22 @@ mod tests { 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; diff --git a/crates/mcpserve/src/secret.rs b/crates/mcpserve/src/secret.rs new file mode 100644 index 0000000..14fbf58 --- /dev/null +++ b/crates/mcpserve/src/secret.rs @@ -0,0 +1,960 @@ +//! 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, +} + +impl SecretError { + const fn new(code: &'static str, message: &'static str) -> Self { + Self { code, message } + } + + 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 { + write!(f, "{} ({})", self.code, self.message) + } +} + +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; + use std::path::Path; + use std::ptr::null_mut; + use std::fs; + use windows_sys::Win32::Foundation::{CloseHandle, LocalFree, 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, SetSecurityDescriptorControl, SetSecurityDescriptorDacl, SetSecurityDescriptorOwner, 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_DESCRIPTOR, SE_DACL_PRESENT, SE_DACL_PROTECTED, SID_IDENTIFIER_AUTHORITY, TOKEN_QUERY, TOKEN_USER, WELL_KNOWN_SID_TYPE, + }; + use windows_sys::Win32::Storage::FileSystem::{ + FlushFileBuffers, WriteFile, FILE_ATTRIBUTE_NORMAL, FILE_ATTRIBUTE_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, 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 FILE_DIRECTORY_FILE: u32 = 0x00000001; + const FILE_NON_DIRECTORY_FILE: u32 = 0x00000040; + const FILE_SYNCHRONOUS_IO_NONALERT: u32 = 0x00000020; + const FILE_OPEN_REPARSE_POINT: u32 = 0x00200000; + const FILE_OPEN: u32 = 1; + const FILE_CREATE: u32 = 2; + const OBJ_CASE_INSENSITIVE: u32 = 0x00000040; + const FILE_DISPOSITION_INFORMATION: u32 = 13; + const ACCESS_ALLOWED_ACE_TYPE: u8 = 0; + + #[repr(C)] + struct UnicodeString { + length: u16, + maximum_length: u16, + buffer: *mut u16, + } + + #[repr(C)] + struct ObjectAttributes { + length: u32, + root_directory: HANDLE, + object_name: *mut UnicodeString, + attributes: u32, + security_descriptor: *mut c_void, + security_quality_of_service: *mut c_void, + } + + #[repr(C)] + struct IoStatus { + status: usize, + information: usize, + } + + #[repr(C)] + struct Disposition { + delete_file: u8, + } + + #[link(name = "ntdll")] + extern "system" { + fn NtCreateFile(handle: *mut HANDLE, access: u32, attributes: *const ObjectAttributes, io: *mut IoStatus, allocation: *const i64, attributes_file: u32, share: u32, disposition: u32, options: u32, ea: *const c_void, ea_length: u32) -> i32; + fn NtSetInformationFile(handle: HANDLE, io: *mut IoStatus, info: *const c_void, length: u32, class: u32) -> i32; + fn NtQueryInformationFile(handle: HANDLE, io: *mut IoStatus, info: *mut c_void, length: u32, class: u32) -> i32; + } + + const FILE_ATTRIBUTE_TAG_INFORMATION: u32 = 35; + + #[repr(C)] + struct AttributeTag { + file_attributes: u32, + reparse_tag: u32, + } + + 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, + } + + fn nt_ok(status: i32) -> bool { + status >= 0 + } + + pub(super) fn create_dir_private(path: &Path) -> 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 root = open_existing(parent, true)?; + if is_reparse(root.0)? { + return Err(SecretError::reparse()); + } + let id = identity()?; + let mut sd = descriptor(&id, true)?; + match create_relative(root.0, name, true, FILE_CREATE, Some(&mut sd))? { + Some(handle) => { + if is_reparse(handle.0)? { + delete_handle(handle.0)?; + return Err(SecretError::reparse()); + } + verify_handle(handle.0, &id, true) + } + None => Err(SecretError::acl()), + } + } + + 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)?; + if let Some(existing) = create_relative(dir.0, name, false, FILE_OPEN, None)? { + if is_reparse(existing.0)? { + delete_handle(existing.0)?; + } else { + delete_handle(existing.0)?; + } + } + let mut sd = descriptor(&id, false)?; + let handle = create_relative(dir.0, name, false, FILE_CREATE, Some(&mut sd))?.ok_or_else(SecretError::acl)?; + if is_reparse(handle.0)? { + delete_handle(handle.0)?; + return Err(SecretError::reparse()); + } + verify_handle(handle.0, &id, false)?; + write_all(handle.0, bytes)?; + 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 = create_relative(dir.0, name, false, FILE_OPEN, None)?.ok_or_else(SecretError::closed)?; + if is_reparse(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()); + } + if let Some(handle) = create_relative(dir.0, name, false, FILE_OPEN, None)? { + delete_handle(handle.0)?; + } + Ok(()) + } + + fn open_existing(path: &Path, directory: bool) -> Result { + let mut wide = nt_path(path); + let mut uni = UnicodeString { + length: ((wide.len() - 1) * 2) as u16, + maximum_length: (wide.len() * 2) as u16, + buffer: wide.as_mut_ptr(), + }; + let attributes = ObjectAttributes { + length: std::mem::size_of::() as u32, + root_directory: null_mut(), + object_name: &mut uni, + attributes: OBJ_CASE_INSENSITIVE, + security_descriptor: null_mut(), + security_quality_of_service: null_mut(), + }; + let mut handle: HANDLE = null_mut(); + let mut io = IoStatus { status: 0, information: 0 }; + let access = if directory { + FILE_LIST_DIRECTORY | FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY | FILE_DELETE_CHILD | FILE_READ_ATTRIBUTES | FILE_WRITE_ATTRIBUTES | READ_CONTROL | WRITE_DAC | DELETE | SYNCHRONIZE + } else { + FILE_READ_DATA | FILE_WRITE_DATA | FILE_READ_ATTRIBUTES | READ_CONTROL | WRITE_DAC | DELETE | SYNCHRONIZE + }; + let options = if directory { FILE_DIRECTORY_FILE } else { FILE_NON_DIRECTORY_FILE } | FILE_OPEN_REPARSE_POINT | FILE_SYNCHRONOUS_IO_NONALERT; + let status = unsafe { NtCreateFile(&mut handle, access, &attributes, &mut io, null_mut(), FILE_ATTRIBUTE_NORMAL, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, FILE_OPEN, options, null_mut(), 0) }; + if !nt_ok(status) { + return Err(if (status as u32) == 0xC0000034 { SecretError::closed() } else { SecretError::acl() }); + } + Ok(Owned(handle)) + } + + fn create_relative(root: HANDLE, name: &str, directory: bool, disposition: u32, sd: Option<&mut Security>) -> Result, SecretError> { + let mut wide: Vec = name.encode_utf16().chain(Some(0)).collect(); + let mut uni = UnicodeString { + length: ((wide.len() - 1) * 2) as u16, + maximum_length: (wide.len() * 2) as u16, + buffer: wide.as_mut_ptr(), + }; + let mut attributes = ObjectAttributes { + length: std::mem::size_of::() as u32, + root_directory: root, + object_name: &mut uni, + attributes: OBJ_CASE_INSENSITIVE, + security_descriptor: null_mut(), + security_quality_of_service: null_mut(), + }; + if let Some(sd) = sd { + attributes.security_descriptor = &mut sd.descriptor as *mut SECURITY_DESCRIPTOR as *mut c_void; + } + let mut handle: HANDLE = null_mut(); + let mut io = IoStatus { status: 0, information: 0 }; + let access = if directory { + FILE_LIST_DIRECTORY | FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY | FILE_DELETE_CHILD | FILE_READ_ATTRIBUTES | FILE_WRITE_ATTRIBUTES | READ_CONTROL | WRITE_DAC | DELETE | SYNCHRONIZE + } else { + FILE_READ_DATA | FILE_WRITE_DATA | FILE_APPEND_DATA | FILE_READ_ATTRIBUTES | FILE_WRITE_ATTRIBUTES | READ_CONTROL | WRITE_DAC | DELETE | SYNCHRONIZE + }; + let options = if directory { FILE_DIRECTORY_FILE } else { FILE_NON_DIRECTORY_FILE } | FILE_OPEN_REPARSE_POINT | FILE_SYNCHRONOUS_IO_NONALERT; + let status = unsafe { NtCreateFile(&mut handle, access, &attributes, &mut io, null_mut(), FILE_ATTRIBUTE_NORMAL, FILE_SHARE_READ | FILE_SHARE_DELETE, disposition, options, null_mut(), 0) }; + if (status as u32) == 0xC0000034 { + return Ok(None); + } + if !nt_ok(status) { + return Err(SecretError::acl()); + } + Ok(Some(Owned(handle))) + } + + fn is_reparse(handle: HANDLE) -> Result { + let mut info = AttributeTag { file_attributes: 0, reparse_tag: 0 }; + let mut io = IoStatus { status: 0, information: 0 }; + let status = unsafe { NtQueryInformationFile(handle, &mut io, &mut info as *mut _ as *mut c_void, std::mem::size_of::() as u32, FILE_ATTRIBUTE_TAG_INFORMATION) }; + if !nt_ok(status) { + return Err(SecretError::acl()); + } + Ok(info.file_attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0) + } + + fn delete_handle(handle: HANDLE) -> Result<(), SecretError> { + let info = Disposition { delete_file: 1 }; + let mut io = IoStatus { status: 0, information: 0 }; + let status = unsafe { NtSetInformationFile(handle, &mut io, &info as *const _ as *const c_void, std::mem::size_of::() as u32, FILE_DISPOSITION_INFORMATION) }; + if !nt_ok(status) { + return Err(SecretError::acl()); + } + Ok(()) + } + + 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 + } + + struct Security { + descriptor: SECURITY_DESCRIPTOR, + _acl: Box, + } + + fn descriptor(id: &Identity, directory: bool) -> Result { + let mut acl = Box::new(AclBuf { bytes: [0; 768] }); + 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()); + } + 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()); + } + } + 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()); + } + if unsafe { SetSecurityDescriptorOwner(sd_ptr, id.user.as_ptr() as PSID, 0) } == 0 { + return Err(SecretError::owner()); + } + if unsafe { SetSecurityDescriptorDacl(sd_ptr, 1, acl_ptr, 0) } == 0 { + return Err(SecretError::acl()); + } + if unsafe { SetSecurityDescriptorControl(sd_ptr, SE_DACL_PROTECTED, SE_DACL_PROTECTED) } == 0 { + return Err(SecretError::acl()); + } + Ok(Security { descriptor, _acl: acl }) + } + + fn apply_private(handle: HANDLE, id: &Identity, directory: bool) -> Result<(), SecretError> { + let mut sd = descriptor(id, directory)?; + let acl = sd._acl.bytes.as_mut_ptr() as *mut ACL; + let code = unsafe { + SetSecurityInfo(handle, SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, id.user.as_ptr() as PSID, null_mut(), acl, null_mut()) + }; + if code != ERROR_SUCCESS { + return Err(SecretError::acl()); + } + 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); + if owner.is_null() || unsafe { EqualSid(owner, id.user.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()); + } + if control & SE_DACL_PRESENT == 0 || control & SE_DACL_PROTECTED == 0 || dacl.is_null() { + return Err(SecretError::acl()); + } + 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()); + } + 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()); + } + } + 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) + } + + fn nt_path(path: &Path) -> Vec { + let raw: Vec = path.as_os_str().encode_wide().collect(); + let mut out = Vec::with_capacity(raw.len() + 5); + out.extend(['\\' as u16, '?' as u16, '?' as u16, '\\' as u16]); + if raw.len() >= 4 && raw[0] == '\\' as u16 && raw[1] == '\\' as u16 && raw[2] == '?' as u16 && raw[3] == '\\' as u16 { + out.extend_from_slice(&raw[4..]); + } else { + out.extend(raw); + } + out.push(0); + out + } + + #[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 index e4f01ab..2d9ec5f 100644 --- a/docs/packaged-mcp.md +++ b/docs/packaged-mcp.md @@ -4,9 +4,9 @@ This is a development build. It is not the signed v0.0.1 release, and it does no 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. A process running as the same OS user can read the file. Another user, and a machine on the network, cannot. A website cannot: a browser sends `Origin`, and every `Origin` is refused. There is no `Access-Control-Allow-Origin`. +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 closes the listener. `MATHSCRAP_MCP=off` starts the window without one. +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 @@ -58,9 +58,9 @@ Replace the angle-bracket text with the session file. Do not send that value any 会话文件是 `%LOCALAPPDATA%\MathScrap-mcptest\mcp\session`,地址文件是同目录的 `endpoint`。不要把令牌贴到聊天里。先备份 `C:\Users\AllowCache\.zcode\cli\config.json`,再合并 stdio 服务器,命令用 `mathscrap-mcp.exe` 的完整路径。不要用 `zcode -p`,也不要重启已经打开的对话。 -令牌在测试配置目录的文件里,不打印,不进网址,不进页面存储。同一系统用户的进程可以读这个文件。其他用户和局域网不行。浏览器带 `Origin` 的请求会被拒绝,响应里没有 `Access-Control-Allow-Origin`。 +令牌在测试配置目录的文件里,不打印,不进网址,不进页面存储。`mcp` 目录在写入令牌之前就带受保护的 ACL,不会继承 LocalAppData 上其他账户的权限。Windows 上当前用户、SYSTEM 和管理员可以读。其他本地账户没有授权。同一用户的进程可以读这个文件,管理员和 SYSTEM 也可以。浏览器带 `Origin` 的请求会被拒绝,响应里没有 `Access-Control-Allow-Origin`。 -关掉窗口,监听就停。`MATHSCRAP_MCP=off` 时窗口仍在,但不监听。 +关掉窗口会删除 session 和 endpoint,并停止监听。`MATHSCRAP_MCP=off` 会清掉残留的 session,且不监听。如果建不起私有 ACL,或 `mcp` 路径是无法安全替换的重解析点,MCP 保持关闭,新令牌不会写入。stdio 助手拒绝重解析点、属主不是当前用户、或授权了其他账户的 session 与 endpoint。 `initialize` 的 `serverInfo.version` 是 `0.0.2-dev`。已签名的 v0.0.1 没有这个监听。 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3b64f6b..483ecc1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -588,7 +588,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1748,8 +1748,10 @@ name = "mcpserve" version = "0.0.1" dependencies = [ "getrandom 0.3.4", + "libc", "serde", "serde_json", + "windows-sys 0.59.0", ] [[package]] @@ -3987,7 +3989,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4119,7 +4121,16 @@ version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" dependencies = [ - "windows-targets", + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", ] [[package]] @@ -4137,13 +4148,29 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -4170,42 +4197,90 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[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.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[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.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[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.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[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.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[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.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[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.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.5.40" @@ -4231,7 +4306,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" dependencies = [ "cfg-if", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] From 39f6c9e02d2afcddd2c227f6fa0840bbfbe3abde Mon Sep 17 00:00:00 2001 From: DNT_OF <3174600270@qq.com> Date: Sun, 8 Nov 2026 03:59:44 +0000 Subject: [PATCH 3/5] fix: create Windows session files with a protected DACL Use CreateDirectory and CreateFile so the protected ACL exists before the token is written. The phone sheet test waits until the type tool is selected before the touch, so the tap cannot still see the pen. Co-authored-by: DNT_OF --- crates/mcpserve/src/secret.rs | 404 +++++++++++++++++----------------- scripts/e2e-sheet.ts | 6 +- 2 files changed, 213 insertions(+), 197 deletions(-) diff --git a/crates/mcpserve/src/secret.rs b/crates/mcpserve/src/secret.rs index 14fbf58..175087a 100644 --- a/crates/mcpserve/src/secret.rs +++ b/crates/mcpserve/src/secret.rs @@ -17,11 +17,25 @@ use std::path::Path; 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 } + 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 { @@ -43,7 +57,11 @@ impl SecretError { impl std::fmt::Debug for SecretError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{} ({})", self.code, self.message) + 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) + } } } @@ -291,17 +309,17 @@ mod windows { use super::{read_capped, SecretError}; use std::ffi::c_void; use std::fs::File; - use std::os::windows::ffi::OsStrExt; - use std::path::Path; + 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, LocalFree, ERROR_SUCCESS, HANDLE, INVALID_HANDLE_VALUE}; + 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, SetSecurityDescriptorControl, SetSecurityDescriptorDacl, SetSecurityDescriptorOwner, 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_DESCRIPTOR, SE_DACL_PRESENT, SE_DACL_PROTECTED, SID_IDENTIFIER_AUTHORITY, TOKEN_QUERY, TOKEN_USER, WELL_KNOWN_SID_TYPE, + 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::{ - FlushFileBuffers, WriteFile, FILE_ATTRIBUTE_NORMAL, FILE_ATTRIBUTE_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, READ_CONTROL, SYNCHRONIZE, WRITE_DAC, + 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}; @@ -316,59 +334,8 @@ mod windows { const FILE_DELETE_CHILD: u32 = 0x0040; const DELETE: u32 = 0x00010000; const FILE_ALL_ACCESS: u32 = 0x001F01FF; - const FILE_DIRECTORY_FILE: u32 = 0x00000001; - const FILE_NON_DIRECTORY_FILE: u32 = 0x00000040; - const FILE_SYNCHRONOUS_IO_NONALERT: u32 = 0x00000020; - const FILE_OPEN_REPARSE_POINT: u32 = 0x00200000; - const FILE_OPEN: u32 = 1; - const FILE_CREATE: u32 = 2; - const OBJ_CASE_INSENSITIVE: u32 = 0x00000040; - const FILE_DISPOSITION_INFORMATION: u32 = 13; const ACCESS_ALLOWED_ACE_TYPE: u8 = 0; - #[repr(C)] - struct UnicodeString { - length: u16, - maximum_length: u16, - buffer: *mut u16, - } - - #[repr(C)] - struct ObjectAttributes { - length: u32, - root_directory: HANDLE, - object_name: *mut UnicodeString, - attributes: u32, - security_descriptor: *mut c_void, - security_quality_of_service: *mut c_void, - } - - #[repr(C)] - struct IoStatus { - status: usize, - information: usize, - } - - #[repr(C)] - struct Disposition { - delete_file: u8, - } - - #[link(name = "ntdll")] - extern "system" { - fn NtCreateFile(handle: *mut HANDLE, access: u32, attributes: *const ObjectAttributes, io: *mut IoStatus, allocation: *const i64, attributes_file: u32, share: u32, disposition: u32, options: u32, ea: *const c_void, ea_length: u32) -> i32; - fn NtSetInformationFile(handle: HANDLE, io: *mut IoStatus, info: *const c_void, length: u32, class: u32) -> i32; - fn NtQueryInformationFile(handle: HANDLE, io: *mut IoStatus, info: *mut c_void, length: u32, class: u32) -> i32; - } - - const FILE_ATTRIBUTE_TAG_INFORMATION: u32 = 35; - - #[repr(C)] - struct AttributeTag { - file_attributes: u32, - reparse_tag: u32, - } - struct Owned(HANDLE); impl Drop for Owned { @@ -400,29 +367,37 @@ mod windows { admins: Vec, } - fn nt_ok(status: i32) -> bool { - status >= 0 - } - pub(super) fn create_dir_private(path: &Path) -> 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 root = open_existing(parent, true)?; if is_reparse(root.0)? { return Err(SecretError::reparse()); } let id = identity()?; - let mut sd = descriptor(&id, true)?; - match create_relative(root.0, name, true, FILE_CREATE, Some(&mut sd))? { - Some(handle) => { - if is_reparse(handle.0)? { - delete_handle(handle.0)?; - return Err(SecretError::reparse()); - } - verify_handle(handle.0, &id, true) - } - None => Err(SecretError::acl()), + 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> { @@ -452,21 +427,25 @@ mod windows { } let id = identity()?; verify_handle(dir.0, &id, true)?; - if let Some(existing) = create_relative(dir.0, name, false, FILE_OPEN, None)? { - if is_reparse(existing.0)? { - delete_handle(existing.0)?; - } else { - delete_handle(existing.0)?; - } + let child = parent.join(name); + match remove_existing(&child) { + Ok(()) | Err(SecretError { code: "mcp_closed", .. }) => {} + Err(err) => return Err(err), } - let mut sd = descriptor(&id, false)?; - let handle = create_relative(dir.0, name, false, FILE_CREATE, Some(&mut sd))?.ok_or_else(SecretError::acl)?; - if is_reparse(handle.0)? { - delete_handle(handle.0)?; + 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()); } - verify_handle(handle.0, &id, false)?; - write_all(handle.0, bytes)?; + 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); @@ -483,8 +462,8 @@ mod windows { } let id = identity()?; verify_handle(dir.0, &id, true)?; - let handle = create_relative(dir.0, name, false, FILE_OPEN, None)?.ok_or_else(SecretError::closed)?; - if is_reparse(handle.0)? { + 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)?; @@ -506,98 +485,137 @@ mod windows { if is_reparse(dir.0)? { return Err(SecretError::reparse()); } - if let Some(handle) = create_relative(dir.0, name, false, FILE_OPEN, None)? { - delete_handle(handle.0)?; + match remove_existing(&parent.join(name)) { + Err(SecretError { code: "mcp_closed", .. }) => Ok(()), + other => other, } - Ok(()) } fn open_existing(path: &Path, directory: bool) -> Result { - let mut wide = nt_path(path); - let mut uni = UnicodeString { - length: ((wide.len() - 1) * 2) as u16, - maximum_length: (wide.len() * 2) as u16, - buffer: wide.as_mut_ptr(), - }; - let attributes = ObjectAttributes { - length: std::mem::size_of::() as u32, - root_directory: null_mut(), - object_name: &mut uni, - attributes: OBJ_CASE_INSENSITIVE, - security_descriptor: null_mut(), - security_quality_of_service: null_mut(), - }; - let mut handle: HANDLE = null_mut(); - let mut io = IoStatus { status: 0, information: 0 }; - let access = if directory { - FILE_LIST_DIRECTORY | FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY | FILE_DELETE_CHILD | FILE_READ_ATTRIBUTES | FILE_WRITE_ATTRIBUTES | READ_CONTROL | WRITE_DAC | DELETE | SYNCHRONIZE - } else { - FILE_READ_DATA | FILE_WRITE_DATA | FILE_READ_ATTRIBUTES | READ_CONTROL | WRITE_DAC | DELETE | SYNCHRONIZE - }; - let options = if directory { FILE_DIRECTORY_FILE } else { FILE_NON_DIRECTORY_FILE } | FILE_OPEN_REPARSE_POINT | FILE_SYNCHRONOUS_IO_NONALERT; - let status = unsafe { NtCreateFile(&mut handle, access, &attributes, &mut io, null_mut(), FILE_ATTRIBUTE_NORMAL, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, FILE_OPEN, options, null_mut(), 0) }; - if !nt_ok(status) { - return Err(if (status as u32) == 0xC0000034 { SecretError::closed() } else { SecretError::acl() }); + 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_relative(root: HANDLE, name: &str, directory: bool, disposition: u32, sd: Option<&mut Security>) -> Result, SecretError> { - let mut wide: Vec = name.encode_utf16().chain(Some(0)).collect(); - let mut uni = UnicodeString { - length: ((wide.len() - 1) * 2) as u16, - maximum_length: (wide.len() * 2) as u16, - buffer: wide.as_mut_ptr(), + 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 mut attributes = ObjectAttributes { - length: std::mem::size_of::() as u32, - root_directory: root, - object_name: &mut uni, - attributes: OBJ_CASE_INSENSITIVE, - security_descriptor: null_mut(), - security_quality_of_service: null_mut(), - }; - if let Some(sd) = sd { - attributes.security_descriptor = &mut sd.descriptor as *mut SECURITY_DESCRIPTOR as *mut c_void; + 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")); } - let mut handle: HANDLE = null_mut(); - let mut io = IoStatus { status: 0, information: 0 }; - let access = if directory { - FILE_LIST_DIRECTORY | FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY | FILE_DELETE_CHILD | FILE_READ_ATTRIBUTES | FILE_WRITE_ATTRIBUTES | READ_CONTROL | WRITE_DAC | DELETE | SYNCHRONIZE + 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 | FILE_READ_ATTRIBUTES | FILE_WRITE_ATTRIBUTES | READ_CONTROL | WRITE_DAC | DELETE | SYNCHRONIZE - }; - let options = if directory { FILE_DIRECTORY_FILE } else { FILE_NON_DIRECTORY_FILE } | FILE_OPEN_REPARSE_POINT | FILE_SYNCHRONOUS_IO_NONALERT; - let status = unsafe { NtCreateFile(&mut handle, access, &attributes, &mut io, null_mut(), FILE_ATTRIBUTE_NORMAL, FILE_SHARE_READ | FILE_SHARE_DELETE, disposition, options, null_mut(), 0) }; - if (status as u32) == 0xC0000034 { - return Ok(None); + FILE_READ_DATA | FILE_WRITE_DATA | FILE_APPEND_DATA | common } - if !nt_ok(status) { - return Err(SecretError::acl()); + } + + 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]); } - Ok(Some(Owned(handle))) + out.extend(raw); + out.push(0); + out } fn is_reparse(handle: HANDLE) -> Result { - let mut info = AttributeTag { file_attributes: 0, reparse_tag: 0 }; - let mut io = IoStatus { status: 0, information: 0 }; - let status = unsafe { NtQueryInformationFile(handle, &mut io, &mut info as *mut _ as *mut c_void, std::mem::size_of::() as u32, FILE_ATTRIBUTE_TAG_INFORMATION) }; - if !nt_ok(status) { - return Err(SecretError::acl()); + 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.file_attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0) + Ok(info.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT != 0) } fn delete_handle(handle: HANDLE) -> Result<(), SecretError> { - let info = Disposition { delete_file: 1 }; - let mut io = IoStatus { status: 0, information: 0 }; - let status = unsafe { NtSetInformationFile(handle, &mut io, &info as *const _ as *const c_void, std::mem::size_of::() as u32, FILE_DISPOSITION_INFORMATION) }; - if !nt_ok(status) { - return Err(SecretError::acl()); + 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()) }; @@ -617,48 +635,54 @@ mod windows { text } - struct Security { - descriptor: SECURITY_DESCRIPTOR, - _acl: Box, - } - - fn descriptor(id: &Identity, directory: bool) -> Result { - let mut acl = Box::new(AclBuf { bytes: [0; 768] }); + 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()); + 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()); + 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()); - } - if unsafe { SetSecurityDescriptorOwner(sd_ptr, id.user.as_ptr() as PSID, 0) } == 0 { - return Err(SecretError::owner()); + return Err(SecretError::acl().with_detail("sd-init")); } - if unsafe { SetSecurityDescriptorDacl(sd_ptr, 1, acl_ptr, 0) } == 0 { - return Err(SecretError::acl()); + 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()); + 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")); } - Ok(Security { descriptor, _acl: acl }) + buf.truncate(bytes as usize); + Ok(buf) } fn apply_private(handle: HANDLE, id: &Identity, directory: bool) -> Result<(), SecretError> { - let mut sd = descriptor(id, directory)?; - let acl = sd._acl.bytes.as_mut_ptr() as *mut ACL; - let code = unsafe { - SetSecurityInfo(handle, SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, id.user.as_ptr() as PSID, null_mut(), acl, null_mut()) - }; + 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()); + return Err(SecretError::acl().with_status(code).with_detail("set-dacl")); } Ok(()) } @@ -672,16 +696,17 @@ mod windows { return Err(SecretError::acl()); } let _sd = Local(sd as *mut c_void); - if owner.is_null() || unsafe { EqualSid(owner, id.user.as_ptr() as PSID) } == 0 { + 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()); + 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()); + 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 { @@ -697,12 +722,12 @@ mod windows { } 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()); + 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()); + return Err(SecretError::acl().with_detail("ace")); } } Ok(()) @@ -753,19 +778,6 @@ mod windows { Ok(sid) } - fn nt_path(path: &Path) -> Vec { - let raw: Vec = path.as_os_str().encode_wide().collect(); - let mut out = Vec::with_capacity(raw.len() + 5); - out.extend(['\\' as u16, '?' as u16, '?' as u16, '\\' as u16]); - if raw.len() >= 4 && raw[0] == '\\' as u16 && raw[1] == '\\' as u16 && raw[2] == '?' as u16 && raw[3] == '\\' as u16 { - out.extend_from_slice(&raw[4..]); - } else { - out.extend(raw); - } - out.push(0); - out - } - #[cfg(test)] pub(super) fn foreign_sid() -> Vec { let authority = SID_IDENTIFIER_AUTHORITY { Value: [0, 0, 0, 0, 0, 5] }; 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: '在纸上写公式' }) From 67d0882cd0d7e6f9e9a792126dba9c1deba328a2 Mon Sep 17 00:00:00 2001 From: DNT_OF <3174600270@qq.com> Date: Sun, 8 Nov 2026 04:42:13 +0000 Subject: [PATCH 4/5] fix: speak MCP JSON lines through an npx stdio proxy A bare JSON line never completed the helper's Content-Length read, so initialize produced no reply. npx mathscrap-mcp-stdio forwards newline JSON to the loopback window and does not spawn the helper. A normal window close deletes the session files before the runtime exits. Co-authored-by: DNT_OF --- .github/workflows/mcp-test-build.yml | 13 ++ crates/mcpserve/src/lib.rs | 269 ++++++++++++++++++++++++--- docs/packaged-mcp.md | 7 +- mcp/stdio-proxy/bin.js | 187 +++++++++++++++++++ mcp/stdio-proxy/package.json | 11 ++ scripts/mcp-stdio-probe.mjs | 145 +++++++++++++++ scripts/mcp-windows-npx-smoke.mjs | 135 ++++++++++++++ src-tauri/src/lib.rs | 22 ++- 8 files changed, 762 insertions(+), 27 deletions(-) create mode 100755 mcp/stdio-proxy/bin.js create mode 100644 mcp/stdio-proxy/package.json create mode 100644 scripts/mcp-stdio-probe.mjs create mode 100644 scripts/mcp-windows-npx-smoke.mjs diff --git a/.github/workflows/mcp-test-build.yml b/.github/workflows/mcp-test-build.yml index 04f1450..5c66d9a 100644 --- a/.github/workflows/mcp-test-build.yml +++ b/.github/workflows/mcp-test-build.yml @@ -139,12 +139,25 @@ jobs: 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 + 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/SHA256SUMS packaging/out/PROVENANCE.txt if-no-files-found: error diff --git a/crates/mcpserve/src/lib.rs b/crates/mcpserve/src/lib.rs index c05dcc2..ca81001 100644 --- a/crates/mcpserve/src/lib.rs +++ b/crates/mcpserve/src/lib.rs @@ -14,7 +14,7 @@ mod secret; use serde_json::{json, Value}; use std::collections::{HashMap, VecDeque}; -use std::io::{Read, Write}; +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}; @@ -121,16 +121,18 @@ impl Hub { } } -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; - } +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 _ = TcpStream::connect(("127.0.0.1", self.inner.port)); + 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(); @@ -141,6 +143,16 @@ impl Drop for Hub { } } +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", @@ -675,36 +687,49 @@ fn new_token() -> Result { /// 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 mut session: Option = None; + 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 = match read_frame(&mut input) { - Ok(frame) => frame, - Err(_) => break, + 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 _ = write_frame(&mut output, &serde_json::to_vec(&rpc_error(None, "mcp_bad_json", "The body is not JSON.")).unwrap_or_default()); + 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(&mut session, &message) { - Ok(Some(bytes)) if !notification => { - let _ = write_frame(&mut output, &bytes); - } + 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 _ = write_frame(&mut output, &serde_json::to_vec(&rpc_error(id, err.code, err.message)).unwrap_or_default()); + let bytes = serde_json::to_vec(&rpc_error(id, err.code, err.message)).unwrap_or_default(); + write_client_message(output, &bytes, line)?; } } } @@ -714,10 +739,6 @@ fn message_id(message: &Value) -> Option { message.get("id").cloned() } -fn forward(session: &mut Option, message: &Value) -> Result>, secret::SecretError> { - forward_in(&mcp_dir().map_err(|_| secret::SecretError::closed())?, session, message) -} - 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())?; @@ -765,6 +786,65 @@ fn parse_loopback_endpoint(text: &str) -> Result { 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]; @@ -1115,4 +1195,149 @@ mod tests { } 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/docs/packaged-mcp.md b/docs/packaged-mcp.md index 2d9ec5f..ce94bf7 100644 --- a/docs/packaged-mcp.md +++ b/docs/packaged-mcp.md @@ -28,11 +28,12 @@ Back up `C:\Users\AllowCache\.zcode\cli\config.json` and merge one server. Do no ```json "mathscrap-test": { "type": "stdio", - "command": "C:\\Users\\AllowCache\\AppData\\Local\\MathScrap-mcptest-run\\mathscrap-mcp.exe" + "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"] } ``` -The command has no token in it. The program reads the session file. 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. +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": { @@ -56,7 +57,7 @@ Replace the angle-bracket text with the session file. Do not send that value any 这是开发构建,不是已签名的 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 服务器,命令用 `mathscrap-mcp.exe` 的完整路径。不要用 `zcode -p`,也不要重启已经打开的对话。 +会话文件是 `%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`。 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/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/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 65dcfcb..54d56c1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,12 @@ pub fn mcp_stdio() { } use tauri::{Emitter, Manager}; +fn close_mcp(app: &impl Manager) { + if let Some(hub) = app.try_state::() { + hub.close_session(); + } +} + #[tauri::command] fn mcp_ready(hub: tauri::State<'_, Hub>) { hub.set_ready(true); @@ -39,6 +45,18 @@ pub fn run() { Ok(()) }) .invoke_handler(tauri::generate_handler![mcp_ready, mcp_idle, mcp_result]) - .run(tauri::generate_context!()) - .expect("MathScrap failed to start"); + .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); + } + }); } From 7617d696213d3f2bc824f41ed18b1ab904a80335 Mon Sep 17 00:00:00 2001 From: DNT_OF <3174600270@qq.com> Date: Sun, 8 Nov 2026 04:42:49 +0000 Subject: [PATCH 5/5] fix: ship the stdio probe beside the npx package Co-authored-by: DNT_OF --- .github/workflows/mcp-test-build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/mcp-test-build.yml b/.github/workflows/mcp-test-build.yml index 5c66d9a..a65b6dc 100644 --- a/.github/workflows/mcp-test-build.yml +++ b/.github/workflows/mcp-test-build.yml @@ -143,6 +143,7 @@ jobs: 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 @@ -158,6 +159,7 @@ jobs: 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