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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/scripts/test_render_pkgbuild.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/bundle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
36 changes: 23 additions & 13 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packaging/arch/PKGBUILD
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
5 changes: 3 additions & 2 deletions packaging/arch/PKGBUILD.ci
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
4 changes: 3 additions & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"] }
5 changes: 5 additions & 0 deletions src-tauri/src/bin/lownotes-mcp.rs
Original file line number Diff line number Diff line change
@@ -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());
}
49 changes: 49 additions & 0 deletions src-tauri/src/credentials.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<String>> {
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::<serde_json::Value>(&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<String> {
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();
Expand Down Expand Up @@ -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::<u128>());
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
144 changes: 137 additions & 7 deletions src-tauri/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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};
Expand All @@ -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);
Expand Down Expand Up @@ -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<String> {
// 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<String> {
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<String> {
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);
Expand Down Expand Up @@ -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"));
Expand Down
Loading
Loading