diff --git a/.github/scripts/test_render_pkgbuild.py b/.github/scripts/test_render_pkgbuild.py index d734899..51be28b 100644 --- a/.github/scripts/test_render_pkgbuild.py +++ b/.github/scripts/test_render_pkgbuild.py @@ -19,6 +19,7 @@ def test_renders_version_and_real_checksums(self): self.assertIn(hashlib.sha256(b"portable archive").hexdigest(), text) self.assertNotIn("SKIP", text) self.assertNotIn("__", text.split("package()")[0]) + self.assertIn('"${pkgdir}/usr/bin/lownotes-mcp"', text) self.assertNotIn("\r", text) def test_missing_portable_archive_fails(self): diff --git a/.github/workflows/bundle.yml b/.github/workflows/bundle.yml index af373fc..615c175 100644 --- a/.github/workflows/bundle.yml +++ b/.github/workflows/bundle.yml @@ -56,3 +56,9 @@ jobs: run: bun run tauri build --debug --bundles deb --config '{"bundle":{"createUpdaterArtifacts":false}}' - name: Check the package exists run: ls -la src-tauri/target/debug/bundle/deb/*.deb + # MCP clients launch the bridge from next to the app (#102). + - name: Check the package ships the MCP bridge + run: | + binaries=$(dpkg-deb -c src-tauri/target/debug/bundle/deb/*.deb | grep 'usr/bin/') + echo "$binaries" + grep -q 'usr/bin/lownotes-mcp$' <<< "$binaries" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 97c58da..1c6cd61 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,6 +115,8 @@ jobs: $portableDir = "dist/LowNotes-$env:VERSION-windows-x64-portable" New-Item -ItemType Directory -Force -Path $portableDir | Out-Null Copy-Item $rawExe "$portableDir/LowNotes.exe" + # The MCP bridge must sit next to the app (#102). + Copy-Item "src-tauri/target/release/lownotes-mcp.exe" "$portableDir/lownotes-mcp.exe" Copy-Item "README.md" $portableDir Copy-Item "LICENSE" $portableDir $portableZip = "dist/LowNotes-$env:VERSION-windows-x64-portable.zip" @@ -211,7 +213,9 @@ jobs: portable_dir="dist/LowNotes-${VERSION}-linux-x64-portable" mkdir -p "$portable_dir" cp "src-tauri/target/release/lownotes" "$portable_dir/LowNotes" - chmod +x "$portable_dir/LowNotes" + # The MCP bridge must sit next to the app (#102). + cp "src-tauri/target/release/lownotes-mcp" "$portable_dir/lownotes-mcp" + chmod +x "$portable_dir/LowNotes" "$portable_dir/lownotes-mcp" cp README.md LICENSE "$portable_dir/" tar -czf "dist/LowNotes-${VERSION}-linux-x64-portable.tar.gz" -C "$portable_dir" . rm -rf "$portable_dir" @@ -253,12 +257,13 @@ jobs: mkdir -p dist arch_build="dist/arch-build" mkdir -p "$arch_build" - tar -xzf "linux-assets/LowNotes-${VERSION}-linux-x64-portable.tar.gz" -C "$arch_build" ./LowNotes + tar -xzf "linux-assets/LowNotes-${VERSION}-linux-x64-portable.tar.gz" -C "$arch_build" ./LowNotes ./lownotes-mcp mv "$arch_build/LowNotes" "$arch_build/lownotes" cp "src-tauri/icons/128x128.png" "$arch_build/lownotes.png" cp "packaging/arch/lownotes.desktop" "$arch_build/lownotes.desktop" sed -e "s/__VERSION__/${VERSION}/g" \ -e "s/__SHA256_BIN__/$(sha256sum "$arch_build/lownotes" | cut -d' ' -f1)/" \ + -e "s/__SHA256_MCP__/$(sha256sum "$arch_build/lownotes-mcp" | cut -d' ' -f1)/" \ -e "s/__SHA256_DESKTOP__/$(sha256sum "$arch_build/lownotes.desktop" | cut -d' ' -f1)/" \ -e "s/__SHA256_ICON__/$(sha256sum "$arch_build/lownotes.png" | cut -d' ' -f1)/" \ packaging/arch/PKGBUILD.ci > "$arch_build/PKGBUILD" @@ -314,11 +319,13 @@ jobs: run: bun run tauri build --target ${{ matrix.target }} --no-bundle # Not prefixed with lownotes-: the publish job only downloads release files. - - name: Guardar executável + - name: Guardar executáveis uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: macos-binary-${{ matrix.target }} - path: src-tauri/target/${{ matrix.target }}/release/lownotes + path: | + src-tauri/target/${{ matrix.target }}/release/lownotes + src-tauri/target/${{ matrix.target }}/release/lownotes-mcp if-no-files-found: error retention-days: 1 @@ -354,16 +361,19 @@ jobs: shell: bash run: | set -euo pipefail - for target in aarch64-apple-darwin x86_64-apple-darwin; do - mkdir -p "src-tauri/target/$target/release" - cp "macos-binaries/macos-binary-$target/lownotes" "src-tauri/target/$target/release/lownotes" - done mkdir -p src-tauri/target/universal-apple-darwin/release - lipo -create -output src-tauri/target/universal-apple-darwin/release/lownotes \ - src-tauri/target/aarch64-apple-darwin/release/lownotes \ - src-tauri/target/x86_64-apple-darwin/release/lownotes - chmod +x src-tauri/target/universal-apple-darwin/release/lownotes - lipo -info src-tauri/target/universal-apple-darwin/release/lownotes + # The app and the MCP bridge (#102); the bundler looks for both. + for binary in lownotes lownotes-mcp; do + for target in aarch64-apple-darwin x86_64-apple-darwin; do + mkdir -p "src-tauri/target/$target/release" + cp "macos-binaries/macos-binary-$target/$binary" "src-tauri/target/$target/release/$binary" + done + lipo -create -output "src-tauri/target/universal-apple-darwin/release/$binary" \ + "src-tauri/target/aarch64-apple-darwin/release/$binary" \ + "src-tauri/target/x86_64-apple-darwin/release/$binary" + chmod +x "src-tauri/target/universal-apple-darwin/release/$binary" + lipo -info "src-tauri/target/universal-apple-darwin/release/$binary" + done - name: Empacotar aplicativo com Tauri (universal) run: bun run tauri bundle --target universal-apple-darwin diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 9932e4a..ccad1a3 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -21,6 +21,7 @@ sha256sums=('__SHA256_PORTABLE__' '__SHA256_DESKTOP__' '__SHA256_ICON__') package() { install -Dm755 "${srcdir}/LowNotes" "${pkgdir}/usr/bin/lownotes" + install -Dm755 "${srcdir}/lownotes-mcp" "${pkgdir}/usr/bin/lownotes-mcp" install -Dm644 "${srcdir}/lownotes.desktop" "${pkgdir}/usr/share/applications/lownotes.desktop" install -Dm644 "${srcdir}/lownotes.png" "${pkgdir}/usr/share/icons/hicolor/128x128/apps/lownotes.png" } diff --git a/packaging/arch/PKGBUILD.ci b/packaging/arch/PKGBUILD.ci index e788a82..88d6991 100644 --- a/packaging/arch/PKGBUILD.ci +++ b/packaging/arch/PKGBUILD.ci @@ -10,11 +10,12 @@ depends=('webkit2gtk-4.1' 'gtk3' 'libappindicator-gtk3' 'dbus') provides=('lownotes') conflicts=('lownotes') options=('!strip') -source=('lownotes' 'lownotes.desktop' 'lownotes.png') -sha256sums=('__SHA256_BIN__' '__SHA256_DESKTOP__' '__SHA256_ICON__') +source=('lownotes' 'lownotes-mcp' 'lownotes.desktop' 'lownotes.png') +sha256sums=('__SHA256_BIN__' '__SHA256_MCP__' '__SHA256_DESKTOP__' '__SHA256_ICON__') package() { install -Dm755 "${srcdir}/lownotes" "${pkgdir}/usr/bin/lownotes" + install -Dm755 "${srcdir}/lownotes-mcp" "${pkgdir}/usr/bin/lownotes-mcp" install -Dm644 "${srcdir}/lownotes.desktop" "${pkgdir}/usr/share/applications/lownotes.desktop" install -Dm644 "${srcdir}/lownotes.png" "${pkgdir}/usr/share/icons/hicolor/128x128/apps/lownotes.png" } diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 17174f6..93e4514 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -5,6 +5,8 @@ description = "Local-first Markdown notes with serverless P2P sync and real-time authors = ["LowBloat"] license = "AGPL-3.0-only" edition = "2021" +# src/bin/lownotes-mcp.rs is the bridge MCP clients launch; Tauri bundles it next to the app. +default-run = "lownotes" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -71,4 +73,4 @@ tauri-plugin-single-instance = "2" # Already in the tree through hyper-util; writes the "Open with LowNotes" keys. windows-registry = "0.6" # Already in the tree through tokio; restricts the MCP pipe to the current user. -windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading"] } +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Pipes", "Win32_System_Threading"] } diff --git a/src-tauri/src/bin/lownotes-mcp.rs b/src-tauri/src/bin/lownotes-mcp.rs new file mode 100644 index 0000000..daf085c --- /dev/null +++ b/src-tauri/src/bin/lownotes-mcp.rs @@ -0,0 +1,5 @@ +//! The bridge MCP clients launch: stdio on one side, the running LowNotes on +//! the other. See `mcp_bridge.rs`. +fn main() { + std::process::exit(lownotes_lib::mcp_bridge::run()); +} diff --git a/src-tauri/src/credentials.rs b/src-tauri/src/credentials.rs index f09e88c..f0b0ecd 100644 --- a/src-tauri/src/credentials.rs +++ b/src-tauri/src/credentials.rs @@ -75,6 +75,36 @@ fn visit(settings: &mut AppSettings, mut f: impl FnMut(String, &mut String)) { /// The settings slot of the MCP token, also read by the bridge. pub const MCP_TOKEN: &str = "mcp:token"; +/// The MCP token for the `lownotes-mcp` bridge, read without changing the +/// settings file (the app owns it). `None` while MCP is off or the app has not +/// created the token yet. +pub fn mcp_token_for_bridge() -> anyhow::Result> { + let path = AppSettings::config_file()?; + let saved = [path.clone(), storage::backup_path(&path)] + .into_iter() + .find_map(|candidate| { + let bytes = fs::read(candidate).ok()?; + serde_json::from_slice::(&bytes).ok() + }) + .context("storage.corrupt")?; + let Some(reference) = mcp_token_reference(&saved) else { + return Ok(None); + }; + Ok(Some( + keyring::Entry::new(SERVICE, &reference)?.get_password()?, + )) +} + +fn mcp_token_reference(saved: &serde_json::Value) -> Option { + if saved.pointer("/mcp/enabled") != Some(&serde_json::Value::Bool(true)) { + return None; + } + saved["credential_refs"][MCP_TOKEN] + .as_str() + .filter(|reference| reference.starts_with("v1-")) + .map(String::from) +} + fn hydrate(settings: &mut AppSettings, store: &mut impl Store) { let references = settings.credential_refs.clone(); let mut unavailable = BTreeSet::new(); @@ -451,6 +481,25 @@ mod tests { assert_eq!(recovered.ai.providers[0].api_key, "test-provider-secret"); } #[test] + fn the_bridge_finds_the_mcp_token_the_app_saved_only_while_mcp_is_on() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("settings.json"); + let mut store = FakeStore::default(); + let mut settings = AppSettings::default(); + settings.mcp.enabled = true; + settings.mcp.token = "bridge-token".into(); + save_at(&path, &settings, &mut store).unwrap(); + let saved: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + assert_eq!(saved["mcp"]["token"], ""); + let reference = mcp_token_reference(&saved).unwrap(); + assert_eq!(store.get(&reference).unwrap(), "bridge-token"); + + settings.mcp.enabled = false; + save_at(&path, &settings, &mut store).unwrap(); + let saved: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + assert_eq!(mcp_token_reference(&saved), None); + } + #[test] #[ignore = "Requires an unlocked native credential store; uses an isolated synthetic entry"] fn native_store_round_trip() { let reference = format!("test-{:032x}", rand::rng().random::()); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ba58b32..4cffe34 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -31,6 +31,7 @@ pub mod image_upload; pub mod local_images; pub mod logging; pub mod mcp; +pub mod mcp_bridge; pub mod note_history; pub mod note_transaction; pub mod retention; diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 0da6f77..ccd7902 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -43,7 +43,7 @@ use crate::{ commands::AppState, config::VaultConfig, credentials::MCP_TOKEN, vault, vault_service, }; -const HANDSHAKE: &[u8] = b"LOWNOTES-MCP/1 "; +pub(crate) const HANDSHAKE: &[u8] = b"LOWNOTES-MCP/1 "; const MAX_HANDSHAKE_BYTES: usize = 256; const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5); const SESSION_START_TIMEOUT: Duration = Duration::from_secs(30); @@ -635,9 +635,10 @@ impl ServerHandler for Server { } } -/// A security descriptor that lets only the current user open the pipe. +/// A security descriptor that lets only the current user open the pipe, and +/// the owner checks the bridge makes before it sends the token. #[cfg(windows)] -mod pipe_security { +pub(crate) mod pipe_security { use std::{ffi::c_void, io, ptr}; use tokio::net::windows::named_pipe::{NamedPipeServer, ServerOptions}; @@ -651,7 +652,12 @@ mod pipe_security { GetTokenInformation, TokenUser, PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES, TOKEN_QUERY, TOKEN_USER, }, - System::Threading::{GetCurrentProcess, OpenProcessToken}, + System::{ + Pipes::GetNamedPipeServerProcessId, + Threading::{ + GetCurrentProcess, OpenProcess, OpenProcessToken, PROCESS_QUERY_LIMITED_INFORMATION, + }, + }, }; pub struct Descriptor(PSECURITY_DESCRIPTOR); @@ -712,10 +718,33 @@ mod pipe_security { /// The current user's SID in string form, such as `S-1-5-21-...`. pub fn current_user_sid() -> io::Result { + // SAFETY: the pseudo handle of the current process needs no closing. + process_user_sid(unsafe { GetCurrentProcess() }) + } + + /// The SID of the user running the process that serves `pipe`. Pipe names + /// are machine-wide, so another user could create ours first. + pub fn server_user_sid(pipe: HANDLE) -> io::Result { + let mut pid = 0u32; + // SAFETY: `pipe` is an open client handle of a named pipe. + if unsafe { GetNamedPipeServerProcessId(pipe, &mut pid) } == 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: the process handle is closed below. + let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; + if process.is_null() { + return Err(io::Error::last_os_error()); + } + let sid = process_user_sid(process); + // SAFETY: `process` came from OpenProcess. + unsafe { CloseHandle(process) }; + sid + } + + fn process_user_sid(process: HANDLE) -> io::Result { let mut token: HANDLE = ptr::null_mut(); - // SAFETY: the pseudo handle of the current process needs no closing; - // the token handle is closed below. - if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { + // SAFETY: the token handle is closed below. + if unsafe { OpenProcessToken(process, TOKEN_QUERY, &mut token) } == 0 { return Err(io::Error::last_os_error()); } let sid = user_sid(token); @@ -1013,6 +1042,107 @@ mod tests { .unwrap(); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn the_bridge_relays_a_client_session_and_reports_a_refused_token() { + let fixture = fixture(); + let endpoint = test_endpoint(fixture.dir.path()); + let (stop, shutdown) = watch::channel(false); + let server = tokio::spawn({ + let endpoint = endpoint.clone(); + let state = fixture.state.clone(); + async move { listen(&endpoint, TOKEN.into(), state, shutdown).await } + }); + tokio::time::sleep(Duration::from_millis(200)).await; + let initialize = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": { "name": "test", "version": "1" }, + }, + }); + + let (mut client_in, bridge_in) = tokio::io::duplex(64 * 1024); + let (bridge_out, client_out) = tokio::io::duplex(64 * 1024); + let bridge = tokio::spawn(crate::mcp_bridge::bridge( + Ok(endpoint.clone()), + Ok(Some(TOKEN.into())), + bridge_in, + bridge_out, + )); + for message in [ + initialize.clone(), + json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }), + json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { "name": "list_notes", "arguments": {} }, + }), + ] { + client_in + .write_all(format!("{message}\n").as_bytes()) + .await + .unwrap(); + } + let mut replies = BufReader::new(client_out).lines(); + let listed = loop { + let line = tokio::time::timeout(Duration::from_secs(20), replies.next_line()) + .await + .expect("reply in time") + .unwrap() + .expect("open bridge"); + let reply: Value = serde_json::from_str(&line).unwrap(); + if reply["id"] == 2 { + break reply; + } + }; + let text = listed["result"]["content"][0]["text"].as_str().unwrap(); + assert!(text.contains("alpha.md")); + drop(client_in); + let code = tokio::time::timeout(Duration::from_secs(10), bridge) + .await + .expect("bridge exits when the client closes") + .unwrap(); + assert_eq!(code, 0); + + let (mut client_in, bridge_in) = tokio::io::duplex(4096); + let (bridge_out, client_out) = tokio::io::duplex(4096); + client_in + .write_all(format!("{initialize}\n").as_bytes()) + .await + .unwrap(); + let code = crate::mcp_bridge::bridge( + Ok(endpoint.clone()), + Ok(Some("wrong".into())), + bridge_in, + bridge_out, + ) + .await; + assert_eq!(code, 1); + let line = BufReader::new(client_out) + .lines() + .next_line() + .await + .unwrap() + .unwrap(); + let reply: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(reply["id"], 1); + assert!(reply["error"]["message"] + .as_str() + .unwrap() + .contains("refused the token")); + + stop.send(true).unwrap(); + tokio::time::timeout(Duration::from_secs(10), server) + .await + .expect("server stops") + .unwrap() + .unwrap(); + } + #[test] fn handshake_compares_the_whole_secret() { assert!(same_secret(b"abc", b"abc")); diff --git a/src-tauri/src/mcp_bridge.rs b/src-tauri/src/mcp_bridge.rs new file mode 100644 index 0000000..394a58d --- /dev/null +++ b/src-tauri/src/mcp_bridge.rs @@ -0,0 +1,334 @@ +//! `lownotes-mcp`, the process MCP clients launch. It relays JSON-RPC lines +//! between the client's stdio and the running LowNotes (see `mcp.rs`) after +//! checking who serves the endpoint and presenting the token. It never opens +//! the vault itself. +//! +//! When LowNotes cannot be reached, the bridge answers the client's first +//! request with a JSON-RPC error that says why, then exits. +use std::{io, time::Duration}; + +use serde_json::{json, Value}; +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader}; + +use crate::mcp::{self, Endpoint, HANDSHAKE}; + +const REPLY_TIMEOUT: Duration = Duration::from_secs(10); +const FIRST_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +/// How long to wait while every pipe instance is busy. +#[cfg(windows)] +const BUSY_RETRIES: u32 = 40; +#[cfg(windows)] +const BUSY_RETRY_DELAY: Duration = Duration::from_millis(50); +const MAX_REPLY_BYTES: usize = 64; + +const NOT_ENABLED: &str = + "The LowNotes MCP server is off. Turn it on in LowNotes: Settings > MCP server."; +const NOT_RUNNING: &str = + "LowNotes is not open. Start LowNotes (it can stay in the tray) and reconnect."; +#[cfg(windows)] +const OTHER_USER: &str = + "Refused to connect: the LowNotes MCP endpoint belongs to another user's process."; +const DENIED: &str = + "LowNotes refused the token. If it was regenerated in LowNotes, reconnect the MCP client."; +const NO_CREDENTIALS: &str = "Could not read the MCP token from the system credential store."; +#[cfg(windows)] +const BUSY: &str = "LowNotes is serving too many MCP clients. Try again later."; +const FAILED: &str = "Could not reach LowNotes. See the bridge's log for details."; + +/// Entry point of the `lownotes-mcp` binary; returns the exit code. +pub fn run() -> i32 { + let filter = tracing_subscriber::EnvFilter::try_from_env("LOWNOTES_LOG") + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")); + tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .with_env_filter(filter) + .init(); + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(error) => { + tracing::error!("could not start the MCP bridge: {error}"); + return 1; + } + }; + let code = runtime.block_on(async { + let token = tokio::task::spawn_blocking(crate::credentials::mcp_token_for_bridge) + .await + .unwrap_or_else(|error| Err(error.into())); + bridge( + mcp::endpoint(), + token, + tokio::io::stdin(), + tokio::io::stdout(), + ) + .await + }); + // Reading stdin blocks a thread that cannot be interrupted; do not wait for it. + runtime.shutdown_background(); + code +} + +/// Connects to LowNotes and relays until either side closes. Without a +/// connection, answers the first request with the reason instead. +pub(crate) async fn bridge( + endpoint: io::Result, + token: anyhow::Result>, + input: I, + output: O, +) -> i32 +where + I: AsyncRead + Unpin, + O: AsyncWrite + Unpin, +{ + match open(endpoint, token).await { + Ok(stream) => relay(stream, input, output).await, + Err(reason) => { + tracing::error!("{reason}"); + refuse(reason, input, output).await; + 1 + } + } +} + +async fn open( + endpoint: io::Result, + token: anyhow::Result>, +) -> Result { + let token = match token { + Ok(Some(token)) => token, + Ok(None) => return Err(NOT_ENABLED), + Err(error) => { + tracing::warn!("could not read the MCP token: {error:#}"); + return Err(NO_CREDENTIALS); + } + }; + let endpoint = endpoint.map_err(|error| { + tracing::warn!("could not locate the MCP endpoint: {error}"); + FAILED + })?; + let mut stream = connect(&endpoint).await?; + let mut hello = HANDSHAKE.to_vec(); + hello.extend_from_slice(token.as_bytes()); + hello.push(b'\n'); + stream.write_all(&hello).await.map_err(|error| { + tracing::warn!("MCP handshake failed: {error}"); + NOT_RUNNING + })?; + match tokio::time::timeout(REPLY_TIMEOUT, read_reply(&mut stream)).await { + Ok(Ok(reply)) if reply == "OK" => Ok(stream), + Ok(Ok(reply)) if reply == "DENIED" => Err(DENIED), + _ => Err(NOT_RUNNING), + } +} + +/// The server's one-line answer, read byte by byte so no MCP data is consumed. +async fn read_reply(stream: &mut Stream) -> io::Result { + let mut line = Vec::new(); + loop { + let byte = stream.read_u8().await?; + if byte == b'\n' || line.len() == MAX_REPLY_BYTES { + break; + } + line.push(byte); + } + Ok(String::from_utf8_lossy(&line).trim().to_owned()) +} + +#[cfg(windows)] +type Stream = tokio::net::windows::named_pipe::NamedPipeClient; +#[cfg(unix)] +type Stream = tokio::net::UnixStream; + +#[cfg(windows)] +async fn connect(endpoint: &Endpoint) -> Result { + use std::os::windows::io::AsRawHandle; + use tokio::net::windows::named_pipe::ClientOptions; + use windows_sys::Win32::Foundation::ERROR_PIPE_BUSY; + + for _ in 0..BUSY_RETRIES { + match ClientOptions::new().open(endpoint) { + Ok(client) => { + let owner = mcp::pipe_security::server_user_sid(client.as_raw_handle()); + let me = mcp::pipe_security::current_user_sid(); + return match (owner, me) { + (Ok(owner), Ok(me)) if owner == me => Ok(client), + (Ok(_), Ok(_)) => Err(OTHER_USER), + (Err(error), _) | (_, Err(error)) => { + tracing::warn!("could not check the MCP endpoint owner: {error}"); + Err(OTHER_USER) + } + }; + } + Err(error) if error.raw_os_error() == Some(ERROR_PIPE_BUSY as i32) => { + tokio::time::sleep(BUSY_RETRY_DELAY).await; + } + Err(error) if error.kind() == io::ErrorKind::NotFound => return Err(NOT_RUNNING), + Err(error) => { + tracing::warn!("could not open the MCP endpoint: {error}"); + return Err(NOT_RUNNING); + } + } + } + Err(BUSY) +} + +/// The socket sits in a folder only this user can enter, so whoever serves +/// it runs as this user. +#[cfg(unix)] +async fn connect(endpoint: &Endpoint) -> Result { + tokio::net::UnixStream::connect(endpoint) + .await + .map_err(|error| { + if !matches!( + error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::ConnectionRefused + ) { + tracing::warn!("could not open the MCP endpoint: {error}"); + } + NOT_RUNNING + }) +} + +async fn relay(stream: Stream, mut input: I, mut output: O) -> i32 +where + I: AsyncRead + Unpin, + O: AsyncWrite + Unpin, +{ + let (mut from_app, mut to_app) = tokio::io::split(stream); + let upstream = async { + let copied = tokio::io::copy(&mut input, &mut to_app).await; + let _ = to_app.shutdown().await; + copied + }; + let downstream = async { + let mut buffer = vec![0u8; 64 * 1024]; + loop { + let read = from_app.read(&mut buffer).await?; + if read == 0 { + return Ok::<_, io::Error>(()); + } + output.write_all(&buffer[..read]).await?; + output.flush().await?; + } + }; + tokio::select! { + copied = upstream => match copied { + Ok(_) => 0, + Err(error) => { + tracing::error!("reading from the MCP client failed: {error}"); + 1 + } + }, + forwarded = downstream => { + match forwarded { + Ok(()) => tracing::error!("LowNotes closed the MCP connection"), + Err(error) => tracing::error!("relaying to the MCP client failed: {error}"), + } + 1 + } + } +} + +/// Answers the first request the client sends with `reason`. +async fn refuse(reason: &str, input: I, mut output: O) +where + I: AsyncRead + Unpin, + O: AsyncWrite + Unpin, +{ + let mut lines = BufReader::new(input).lines(); + let first_request = async { + while let Ok(Some(line)) = lines.next_line().await { + if let Some(id) = request_id(&line) { + return Some(id); + } + } + None + }; + let Ok(Some(id)) = tokio::time::timeout(FIRST_REQUEST_TIMEOUT, first_request).await else { + return; + }; + let _ = output + .write_all(error_response(id, reason).as_bytes()) + .await; + let _ = output.flush().await; +} + +/// The id of a JSON-RPC request; notifications have none. +fn request_id(line: &str) -> Option { + let message: Value = serde_json::from_str(line).ok()?; + message.get("method")?; + message.get("id").filter(|id| !id.is_null()).cloned() +} + +fn error_response(id: Value, reason: &str) -> String { + let response = json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": -32000, "message": reason }, + }); + format!("{response}\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn answers_requests_but_not_notifications() { + assert_eq!( + request_id(r#"{"jsonrpc":"2.0","id":7,"method":"initialize"}"#), + Some(json!(7)) + ); + assert_eq!( + request_id(r#"{"jsonrpc":"2.0","id":"a","method":"tools/list"}"#), + Some(json!("a")) + ); + assert_eq!( + request_id(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#), + None + ); + assert_eq!(request_id("not json"), None); + let reply: Value = serde_json::from_str(&error_response(json!(7), NOT_RUNNING)).unwrap(); + assert_eq!(reply["id"], 7); + assert_eq!(reply["error"]["message"], NOT_RUNNING); + } + + async fn refused_with( + endpoint: io::Result, + token: anyhow::Result>, + ) -> (i32, Value) { + let (mut client_in, bridge_in) = tokio::io::duplex(4096); + let (bridge_out, client_out) = tokio::io::duplex(4096); + client_in + .write_all(b"{\"jsonrpc\":\"2.0\",\"method\":\"notifications/x\"}\n{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}\n") + .await + .unwrap(); + let code = bridge(endpoint, token, bridge_in, bridge_out).await; + let line = BufReader::new(client_out) + .lines() + .next_line() + .await + .unwrap() + .unwrap(); + (code, serde_json::from_str(&line).unwrap()) + } + + #[tokio::test] + async fn tells_the_client_why_lownotes_cannot_be_reached() { + use rand::Rng; + let suffix = rand::rng().random::(); + #[cfg(unix)] + let missing = std::env::temp_dir().join(format!("lownotes-mcp-missing-{suffix:016x}")); + #[cfg(windows)] + let missing = format!(r"\\.\pipe\lownotes-mcp-missing-{suffix:016x}"); + let (code, reply) = refused_with(Ok(missing), Ok(Some("token".into()))).await; + assert_eq!(code, 1); + assert_eq!(reply["id"], 1); + assert_eq!(reply["error"]["message"], NOT_RUNNING); + + let (_, reply) = refused_with(Err(io::Error::other("no dir")), Ok(None)).await; + assert_eq!(reply["error"]["message"], NOT_ENABLED); + } +}