What is Notepatra?
-
Notepatra is a native Qt5 + QScintilla code editor with an original C++17/Rust core for hot paths (memory-mapped I/O, Aho-Corasick search, Myers diff, formatters). Runs on Linux x64 / ARM64, macOS Apple Silicon, and Windows x64 from a single codebase. ~12 MB bare executable, no Electron runtime, no telemetry, no mandatory account. Local-first AI is built in: pick from Ollama, llama.cpp, OpenRouter, OpenAI, Azure OpenAI, or Ollama Cloud. As of v0.1.119: 238 file types · 82 language lexers, a Data Analyst mode that works out of the box on CSV files and an in-memory SQLite engine (the Full download bundles the DuckDB v1.1.3 engine on every platform), agentic Coding Mode with read / list / search / write / apply_diff tools, Cursor-style Composer + Ctrl+I inline edit, multi-cursor, AI-driven git tools (read-only: status / diff / log / branch / show).
+
Notepatra is a native Qt5 + QScintilla code editor with an original C++17/Rust core for hot paths (memory-mapped I/O, Aho-Corasick search, Myers diff, formatters). Runs on Linux x64 / ARM64, macOS Apple Silicon, and Windows x64 from a single codebase. ~12 MB bare executable, no Electron runtime, no telemetry, no mandatory account. Local-first AI is built in: pick from Ollama, llama.cpp, OpenRouter, OpenAI, Azure OpenAI, or Ollama Cloud. As of v0.1.120: 238 file types · 82 language lexers, a Data Analyst mode that works out of the box on CSV files and an in-memory SQLite engine (the Full download bundles the DuckDB v1.1.3 engine on every platform), agentic Coding Mode with read / list / search / write / apply_diff tools, Cursor-style Composer + Ctrl+I inline edit, multi-cursor, AI-driven git tools (read-only: status / diff / log / branch / show).
Is it really free? What's the catch?
@@ -2185,7 +2207,7 @@ Can I install Notepatra at my company?
How big is the binary?
-
The bare executable is ~12 MB (12.4 MB on Linux x64) across every platform. Latest v0.1.119 download sizes: 4.4 MB Linux x64 tar.gz · 4.1 MB Linux ARM64 tar.gz · 27.7 MB macOS DMG · 42.7 MB Windows MSI / 32.8 MB NSIS / 37.4 MB portable zip. Installed on Windows the on-disk footprint grows to ~75-85 MB because the MSI extracts bundled Qt5 + QScintilla DLLs out of the compressed payload — normal for every Qt-based installer. Linux stays tiny (~12 MB on disk) because Qt is a system package; macOS and Windows bundle Qt + QScintilla DLLs for portability.
+
The bare executable is ~12 MB (12.4 MB on Linux x64) across every platform. Latest v0.1.120 download sizes: 4.4 MB Linux x64 tar.gz · 4.1 MB Linux ARM64 tar.gz · 27.7 MB macOS DMG · 42.7 MB Windows MSI / 32.8 MB NSIS / 37.4 MB portable zip. Installed on Windows the on-disk footprint grows to ~75-85 MB because the MSI extracts bundled Qt5 + QScintilla DLLs out of the compressed payload — normal for every Qt-based installer. Linux stays tiny (~12 MB on disk) because Qt is a system package; macOS and Windows bundle Qt + QScintilla DLLs for portability.
Does my code go to the cloud?
@@ -2193,7 +2215,7 @@ Does my code go to the cloud?
What languages does Notepatra highlight?
-
238 file types via 82 language lexers as of v0.1.119 — Python, JavaScript, TypeScript, C, C++, C#, Java, Kotlin, Rust, Go, Dart, Solidity, Zig, Vala, Hack, Julia, R, Protobuf, F#, HCL/Terraform, Thrift, GraphQL, GDScript, Nim, Cython, Mojo, Crystal, Elixir, Scala, Groovy, Apex, HTML, CSS, SQL (6 dialect presets), JSON, JSON5, YAML, TOML, Markdown, Bash, Fish, Nushell, Ruby, Perl, Lua, Fortran, Verilog, VHDL, MATLAB, LaTeX, BibTeX, Jinja, Liquid, Twig, Dockerfile, DotEnv, Gitignore, Pascal, CMake, Makefile, and more. Each lexer ships with comment / uncomment / block-comment syntax (Ctrl+Q / Ctrl+Shift+Q) and proper file-extension routing.
+
238 file types via 82 language lexers as of v0.1.120 — Python, JavaScript, TypeScript, C, C++, C#, Java, Kotlin, Rust, Go, Dart, Solidity, Zig, Vala, Hack, Julia, R, Protobuf, F#, HCL/Terraform, Thrift, GraphQL, GDScript, Nim, Cython, Mojo, Crystal, Elixir, Scala, Groovy, Apex, HTML, CSS, SQL (6 dialect presets), JSON, JSON5, YAML, TOML, Markdown, Bash, Fish, Nushell, Ruby, Perl, Lua, Fortran, Verilog, VHDL, MATLAB, LaTeX, BibTeX, Jinja, Liquid, Twig, Dockerfile, DotEnv, Gitignore, Pascal, CMake, Makefile, and more. Each lexer ships with comment / uncomment / block-comment syntax (Ctrl+Q / Ctrl+Shift+Q) and proper file-extension routing.
What file encodings are supported?
diff --git a/docs/llms.txt b/docs/llms.txt
index 7d9027b..42b3880 100644
--- a/docs/llms.txt
+++ b/docs/llms.txt
@@ -16,9 +16,9 @@ Key facts:
## MCP (new in v0.1.118)
-- From v0.1.118 Notepatra ships notepatra-mcp, a stdio JSON-RPC 2.0 Model Context Protocol server (protocol revision 2025-06-18; 2025-03-26 and 2024-11-05 also accepted) that connects AI assistants to the running editor over a local socket. Since v0.1.119 the sidecar also supports Windows over a named pipe.
+- From v0.1.118 Notepatra ships notepatra-mcp, a stdio JSON-RPC 2.0 Model Context Protocol server (protocol revision 2025-06-18; 2025-03-26 and 2024-11-05 also accepted) that connects AI assistants to the running editor over a local socket. Windows named-pipe support was added in v0.1.119 but deadlocked on every verb; it works from v0.1.120.
- Works with Claude Desktop, Claude Code, OpenAI Codex CLI, the OpenAI Agents SDK, and any spec-compliant stdio MCP client; cloud-only connector surfaces (ChatGPT connectors, claude.ai web connectors) cannot reach a desktop editor.
-- Exposes 35 tools in three tiers (v0.1.119): read (18: tabs, selection, search, Noter notes, reminders, read-only Git status/diff/log/show/branch, npd validation, read-only SELECT SQL), act (9: open, compare, format, navigate, open note), and write (8: insert/replace/edit/save, create/append note, set reminder, export diagram) — every write requires the user to click Approve on a card inside the editor (120-second auto-deny, no headless bypass). find_in_tab and search_project take an optional regex flag. run_sql is SELECT-only and, on the Full/DuckDB edition, runs in an engine sandbox (file materialized in-memory, then enable_external_access=false) so it cannot read host files. Also publishes open tabs and Noter notes as MCP resources plus 3 prompts.
+- Exposes 48 tools in three tiers: read (24: tabs, selection, search, Noter notes, reminders, read-only Git status/diff/log/show/branch, npd validation + diagram source read, read-only SELECT SQL, language list, capability probe, plus saved-connection list/query/tables), act (13: open, compare, format, navigate, open note, create diagram, open Noter panel, open Data Analyst, render chart), and write (11: insert/replace/edit/save, create/append note, set reminder, set diagram source, export diagram, export query results, export chart) — every write requires the user to click Approve on a card inside the editor (120-second auto-deny, no headless bypass). run_query reaches saved PostgreSQL/MySQL/SQL Server/SQLite/DuckDB connections read-only (mutations rejected) that run_sql cannot reach; the chart verbs (render_chart, export_chart) are Full/WebEngine only. find_in_tab and search_project take an optional regex flag. run_sql is SELECT-only and, on the Full/DuckDB edition, runs in an engine sandbox (file materialized in-memory, then enable_external_access=false) so it cannot read host files. Also publishes open tabs and Noter notes as MCP resources plus 3 prompts.
- Privacy: stdio + local socket only; notepatra-mcp itself makes no network connections.
- [MCP documentation](https://notepatra.org/mcp.html): tool reference, per-client setup, security model, FAQ.
diff --git a/docs/mcp.html b/docs/mcp.html
index 2633fed..3a8c02f 100644
--- a/docs/mcp.html
+++ b/docs/mcp.html
@@ -4,7 +4,7 @@
Notepatra MCP — Your editor, readable by your AI
-
+
@@ -229,13 +229,13 @@
Your editor, readable by your AI
From v0.1.118, Notepatra ships notepatra-mcp — a Model Context Protocol server that lets Claude Desktop, Claude Code, OpenAI Codex, the OpenAI Agents SDK, and any spec-compliant MCP client see what you have open, search your workspace, run read-only SQL over your data files, read your Git status and Noter notes, and — only with your explicit per-action approval — edit, save, and create notes. Everything runs over stdio and a local socket. Nothing leaves your machine.
- Release status. MCP first shipped in v0.1.118; the current release is v0.1.119, which expands the server to 35 tools (Git, read-only SQL, Noter write verbs, diagram export) and adds Windows named-pipe support. Everything on this page describes v0.1.119.
+ Release status. MCP first shipped in v0.1.118; the current release is v0.1.120. The server provides 48 tools (Git, read-only SQL, Noter write verbs, diagram export) and supports Windows named-pipe transport, and v0.1.120 adds a prebuilt, cosign-signed Windows sidecar (notepatra-mcp-windows-x64.zip) plus a one-click notepatra-mcp.mcpb Claude Desktop bundle as release assets. Everything on this page describes v0.1.120.
On this page:
What it is ·
- The 35 tools ·
+ The 48 tools ·
Write approval ·
Setup per client ·
Resources & prompts ·
@@ -252,15 +252,15 @@ What it is
Transport: a stdio JSON-RPC 2.0 MCP server. It speaks MCP protocol revision 2025-06-18 and also accepts 2025-03-26 and 2024-11-05 from older clients.
Editor connection: pass --socket and the server talks to the running editor over a dedicated per-user local socket (a Unix domain socket; the editor opens it on launch). Without --socket, the server runs against a built-in mock editor, so you can exercise the protocol without Notepatra running.
- Capabilities: tools (35 of them), resources (your open tabs and Noter notes), and prompts (three ready-made ones).
+ Capabilities: tools (48 of them), resources (your open tabs and Noter notes), and prompts (three ready-made ones).
Safety: reads never need permission; every write shows an approval card inside the editor window and does nothing until you click Approve. Details below .
In practice: ask your assistant "what do I have open?", "find where the config is parsed", "diff these two tabs", "format this JSON and put it in a new tab" — and it drives the actual editor on your screen.
-
- notepatra-mcp exposes 35 tools, grouped in three tiers by what they can touch. Read tools observe. Act tools drive visible, non-destructive editor actions. Write tools change buffer contents or disk — and every one of those is gated behind a human approval card.
+
+ notepatra-mcp exposes 48 tools, grouped in three tiers by what they can touch. Read tools observe. Act tools drive visible, non-destructive editor actions. Write tools change buffer contents or disk — and every one of those is gated behind a human approval card.
- Tier 1 — Read (18 tools, no approval needed)
+ Tier 1 — Read (24 tools, no approval needed)
Tool What it does
@@ -282,29 +282,39 @@ Tier 1 — Read (18 tools, no approval needed)
git_branchRead-only list of branches in the workspace repository.
validate_npdValidate a Notepatra diagram (.npd) — a tab or supplied source; returns parse errors with line numbers.
run_sqlRun a read-only SELECT over a CSV / Parquet / JSON file (or the active tab) and return columns + rows. SELECT-only and sandboxed — see the note below.
+ list_languagesList the canonical language names set_language accepts (as shown in the Language menu).
+ get_capabilitiesEditor capability profile: edition, platform, version, tool count and tiers, and feature flags (DuckDB, WebEngine, Noter).
+ get_diagram_sourceRead the .npd source of an open Diagram tab.
+ list_connectionsList your saved database connections (name, driver, database) — never passwords.
+ run_queryRun a read-only SELECT against a saved named connection (PostgreSQL / MySQL / SQL Server / SQLite / DuckDB) — what run_sql cannot reach; mutations rejected. Full edition (DuckDB) for DuckDB-driver connections.
+ list_tablesList the user tables available over a saved named connection.
How run_sql stays safe. Every query is checked by the SQL classifier and rejected unless it is a pure SELECT — no INSERT, UPDATE, ATTACH, COPY, or DDL ever runs. On the Full (DuckDB) edition there is a second wall: the target CSV / Parquet / JSON is first materialized into an in-memory table, then DuckDB's external filesystem access is turned off (SET enable_external_access=false) before the untrusted SQL executes — so the query cannot reach host files even through read_text, read_csv_auto, glob, or a replacement scan. The csv_path argument is confined to your open workspace. Results are row- and cell-capped so a single answer can't exfiltrate a whole file.
- Tier 2 — Act (9 tools, visible and non-destructive)
+ Tier 2 — Act (13 tools, visible and non-destructive)
Tool What it does
open_fileOpen a file in a new tab, or focus the tab that already has it.
new_tabCreate a new untitled tab, optionally pre-filled with text.
goto_lineMove the cursor to a line in a tab — for pointing you at a location.
- set_languageSet a tab's syntax-highlighting language.
+ set_languageSet a tab's syntax-highlighting language. Accepts friendly aliases (python, js, cpp, go) and resolves them to the canonical name.
compare_tabsOpen Notepatra's side-by-side Compare view for two open tabs.
format_jsonFormat JSON text and return the result (fails on invalid JSON).
format_sqlFormat SQL text and return the result.
format_htmlFormat HTML text and return the result.
open_noteOpen one Noter note in the editor by its file path.
+ create_diagramCreate a new Diagram (.npd) tab — optionally pre-filled — and focus it; reports parse validity.
+ open_noterOpen or focus the Noter panel tab.
+ open_data_analystReveal and focus the AI dock in Data Analyst mode.
+ render_chartRender a chart inline in the Data Analyst transcript (Vega-Lite or the simplified {type,x,y,data} form). Full edition (WebEngine) only.
- Tier 3 — Write, with approval (8 tools, human-gated)
+ Tier 3 — Write, with approval (11 tools, human-gated)
Tool What it does
@@ -316,11 +326,14 @@ Tier 3 — Write, with approval (8 tools, human-gated)
append_noteAppend text to an existing Noter note.
set_reminderSet a due-time reminder on a Noter note.
export_diagramExport a diagram tab to a PNG or PDF file on disk.
+ set_diagram_sourceReplace the entire .npd source of a Diagram tab; the canvas re-renders.
+ export_query_resultsRun a read-only SELECT against a saved connection and write the results to a CSV or JSON file.
+ export_chartRender a chart off-screen and export it to a PNG / SVG / HTML / spec file. Full edition (WebEngine) only.
The write-approval model
- The eight write tools never execute on arrival . Each incoming write request shows a non-modal approval card inside the Notepatra window — it does not steal focus or block your typing — describing what the assistant wants to do, with a preview of the text involved and two buttons: Approve and Deny .
+ The eleven write tools never execute on arrival . Each incoming write request shows a non-modal approval card inside the Notepatra window — it does not steal focus or block your typing — describing what the assistant wants to do, with a preview of the text involved and two buttons: Approve and Deny .
Nothing happens until you click Approve. The tool call blocks; the assistant just waits.
120-second auto-deny. If you don't respond within two minutes, the request is denied automatically and the assistant receives the error approval timed out.
@@ -347,12 +360,12 @@ Get notepatra-mcp
Or build it from source — the sidecar lives in notepatra-mcp/ in the repo and needs only stable Rust, no other dependencies:
cd notepatra-mcp
cargo build --release # binary at target/release/notepatra-mcp
- Windows is supported from v0.1.119: the sidecar connects to the running editor over a named pipe (\\.\pipe\…), so --socket works there too. Prebuilt Windows binaries aren't in the signed release bundle yet, so on Windows install it with cargo install notepatra-mcp or build from source (above).
+ Windows named-pipe transport arrived in v0.1.119 but did not actually work: it deadlocked on the first verb after the greeting, so no tool call ever completed. It is fixed in v0.1.120 — the sidecar connects to the running editor over a named pipe (\\.\pipe\…) and --socket works there for real. As of v0.1.120 the signed release bundle provides a prebuilt Windows binary (notepatra-mcp-windows-x64.zip, cosign-signed like the others) and a one-click Claude Desktop bundle (notepatra-mcp.mcpb — install it from Claude Desktop's Settings → Extensions), so Windows users no longer need to build from source. cargo install notepatra-mcp remains a fully supported alternative on every platform.
Client Vendor ecosystem How it connects Works with Notepatra?
- Claude Desktop Anthropic stdio, via claude_desktop_config.json Yes
+ Claude Desktop Anthropic stdio, via claude_desktop_config.json — or one-click via the notepatra-mcp.mcpb bundle from the next release Yes
Claude Code Anthropic stdio, via claude mcp add Yes
Codex CLI OpenAI stdio, via config.toml Yes
OpenAI Agents SDK OpenAI stdio, via MCPServerStdio Yes
@@ -377,7 +390,7 @@ Claude Desktop
Claude Code
One command:
claude mcp add notepatra -- notepatra-mcp --socket
- Then /mcp inside Claude Code shows the server and its 35 tools.
+ Then /mcp inside Claude Code shows the server and its 48 tools.
OpenAI Codex CLI
Add a block to ~/.codex/config.toml:
@@ -427,16 +440,29 @@ Limitations — the honest list
The editor must be running. With --socket, tools need a live Notepatra to talk to; if it isn't running, they return the clean error "Notepatra is not running (start the editor first)" rather than hanging.
Writes need a visible window. The approval card must appear somewhere; with no visible window, writes are denied with approval unavailable. That is deliberate — see the approval model .
Cloud-only connector surfaces can't reach it. ChatGPT connectors and claude.ai web connectors only speak to servers reachable at a public URL. A desktop editor on your machine is not that, so those surfaces cannot use notepatra-mcp. Use the desktop or CLI clients above instead.
- Windows named-pipe transport (new in v0.1.119). The sidecar now connects to the running editor over a Windows named pipe (\\.\pipe\…), so --socket works on Linux, macOS, and Windows alike. The one remaining gap: prebuilt Windows binaries aren't in the signed release bundle yet, so on Windows you install with cargo install notepatra-mcp or build from source.
+ Windows named-pipe transport (added v0.1.119, working from v0.1.120). The v0.1.119 implementation deadlocked on every verb, so Windows tool calls never completed; v0.1.120 fixes it. The sidecar connects to the running editor over a Windows named pipe (\\.\pipe\…), so --socket works on Linux, macOS, and Windows alike. Prebuilt Windows binaries (notepatra-mcp-windows-x64.zip) and the one-click notepatra-mcp.mcpb Claude Desktop bundle ship as v0.1.120 release assets; cargo install notepatra-mcp remains a fully supported alternative on every platform.
Large reads are capped. read_tab truncates at 5 MB (and says so with a marker in the text); search_project returns at most 200 matches per search.
Search defaults to literal. find_in_tab and search_project match a literal substring by default; pass {regex: true} for a regular-expression search (an invalid pattern is rejected with a clear error).
FAQ
Is my data sent anywhere?
- Not by Notepatra. notepatra-mcp makes no network connections: it talks to your AI client over stdio (pipes between two processes on your machine) and to the editor over a local per-user socket. Nothing leaves the machine through this path. One honest caveat that applies to every MCP server: whatever your assistant reads through these tools becomes part of its conversation, and goes wherever that client sends its conversations — a local model stays local, a cloud model does not. Choose the client and model accordingly.
+ Not by Notepatra. The notepatra-mcp binary you install makes no network connections: it talks to your AI client over stdio (pipes between two processes on your machine) and to the editor over a local per-user socket. Nothing leaves the machine through this path — and the shipped binary is not merely configured that way, it is compiled without networking at all. Every CI job runs the released binary and fails the build unless it reports built without remote support. One honest caveat that applies to every MCP server: whatever your assistant reads through these tools becomes part of its conversation, and goes wherever that client sends its conversations — a local model stays local, a cloud model does not. Choose the client and model accordingly.
+ What is the remote gateway in v0.1.120, and does it weaken any of this?
+ v0.1.120 adds an opt-in remote gateway, and it is off unless you deliberately build it: the code sits behind a
+ remote Cargo feature that is not in the default feature set, so cargo install notepatra-mcp and every
+ signed release binary contain no gateway and no networking or crypto dependencies at all. If you do build it
+ (cargo build --features remote), serve binds loopback only — 127.0.0.1, never a LAN or public
+ interface — and a client must complete an 8-digit one-time HMAC pairing to receive a 256-bit bearer token, of which only the SHA-256
+ is stored, mode 0600. Scopes are fail-closed : a client is read-only unless it paired for more, calls above its
+ scope are rejected at dispatch, and tools/list is filtered to the tiers it is allowed. A missing or invalid token never
+ elevates.
+ The approval gate is unchanged, and there is no remote approval. A remote write request is only forwarded to the
+ editor, which raises the same Approve / Deny card on your screen. Nobody can approve a write from the far end of the
+ connection, and there is no headless bypass. The editor itself never listens on the network — only the opt-in sidecar gateway binds
+ a socket. Until LAN support and TLS land, use an SSH tunnel rather than exposing the port.
Can the AI edit my files without me noticing?
- No. The only tools that can change a buffer or touch disk are the eight write tools, and each call requires you to click Approve on a card inside the editor within 120 seconds. No response means denied.
+ No. The only tools that can change a buffer or touch disk are the eleven write tools, and each call requires you to click Approve on a card inside the editor within 120 seconds. No response means denied.
Is this the same as Notepatra's built-in AI Assistant?
No — they're complementary. The AI Assistant dock (Ctrl+Shift+A) is Notepatra talking to a model you configure. MCP is the reverse direction: an external assistant you already use (Claude, Codex, ...) talking to Notepatra. Neither requires the other.
Does it work with the Lite build?
@@ -447,7 +473,7 @@ Which tab is index 0?
diff --git a/notepatra-mcp/.gitignore b/notepatra-mcp/.gitignore
index ea8c4bf..aaf3218 100644
--- a/notepatra-mcp/.gitignore
+++ b/notepatra-mcp/.gitignore
@@ -1 +1,5 @@
/target
+# Byproducts of the CI "flavor assertion" step (build.yml), which runs with
+# this directory as its cwd. Harmless locally, but they must never be committed.
+/flavor.txt
+/flavor.err
diff --git a/notepatra-mcp/Cargo.lock b/notepatra-mcp/Cargo.lock
index 8de6a57..48a47cf 100644
--- a/notepatra-mcp/Cargo.lock
+++ b/notepatra-mcp/Cargo.lock
@@ -2,12 +2,93 @@
# It is not intended for manual editing.
version = 4
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+ "subtle",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "hmac"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+dependencies = [
+ "digest",
+]
+
[[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.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
[[package]]
name = "memchr"
version = "2.8.3"
@@ -16,10 +97,13 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "notepatra-mcp"
-version = "0.1.119"
+version = "0.1.120"
dependencies = [
+ "getrandom",
+ "hmac",
"serde",
"serde_json",
+ "sha2",
]
[[package]]
@@ -83,6 +167,23 @@ dependencies = [
"zmij",
]
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
[[package]]
name = "syn"
version = "2.0.119"
@@ -94,12 +195,30 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
[[package]]
name = "zmij"
version = "1.0.23"
diff --git a/notepatra-mcp/Cargo.toml b/notepatra-mcp/Cargo.toml
index 6eaacad..d25d8b9 100644
--- a/notepatra-mcp/Cargo.toml
+++ b/notepatra-mcp/Cargo.toml
@@ -1,9 +1,9 @@
[package]
name = "notepatra-mcp"
-version = "0.1.119"
+version = "0.1.120"
edition = "2021"
license = "GPL-3.0-or-later"
-description = "Model Context Protocol (MCP) stdio server for the Notepatra editor — 35 tools, human-approved writes, local-only"
+description = "Model Context Protocol (MCP) stdio server for the Notepatra editor — 48 tools, human-approved writes, local-only"
repository = "https://github.com/singhpratech/notepatra"
homepage = "https://notepatra.org/"
documentation = "https://notepatra.org/mcp.html"
@@ -14,6 +14,22 @@ categories = ["command-line-utilities", "development-tools"]
# Standalone on purpose: must never join a parent workspace or affect the app build.
[workspace]
+[features]
+# Phase 3a opt-in remote gateway. The DEFAULT build enables nothing here and
+# pulls in ZERO new crates; these deps are compiled only under `--features remote`.
+remote = ["dep:hmac", "dep:sha2", "dep:getrandom"]
+
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+# Remote-only (optional): pairing HMAC, token hashing, OS randomness. `getrandom`
+# is the minimal equivalent of `rand` — raw OS bytes, one dep, lowest MSRV.
+hmac = { version = "0.12", optional = true }
+sha2 = { version = "0.10", optional = true }
+getrandom = { version = "0.2", optional = true }
+
+# The remote gateway integration suite compiles only with the feature enabled,
+# so the default `cargo test` never sees it.
+[[test]]
+name = "remote_gateway"
+required-features = ["remote"]
diff --git a/notepatra-mcp/README.md b/notepatra-mcp/README.md
index 7d53f7b..f5f78f3 100644
--- a/notepatra-mcp/README.md
+++ b/notepatra-mcp/README.md
@@ -2,7 +2,7 @@
A [Model Context Protocol](https://modelcontextprotocol.io) stdio server for the [Notepatra](https://notepatra.org) editor. Lets Claude Desktop, Claude Code, OpenAI Codex CLI, the OpenAI Agents SDK, and any spec-compliant MCP client see what you have open, search your workspace, read Noter notes — and, only with your explicit per-action approval inside the editor, edit and save.
-- **35 tools in three tiers**: read (tabs, selection, search, notes, reminders, Git status/diff/log/show/branch, npd validation, read-only SQL), act (open, compare, format, navigate, open notes), write (insert/replace/edit/save, create/append notes, set reminders, export diagrams — every write shows an Approve / Deny card inside Notepatra; 120 s auto-deny, no headless bypass).
+- **48 tools in three tiers**: read (tabs, selection, search, notes, reminders, Git status/diff/log/show/branch, npd validation, read-only SQL, language list, capabilities, diagram source read, saved-connection list/query/tables), act (open, compare, format, navigate, open notes, create diagram, open Noter panel, open Data Analyst, render chart), write (insert/replace/edit/save, create/append notes, set reminders, set diagram source, export diagrams, export query results, export chart — every write shows an Approve / Deny card inside Notepatra; 120 s auto-deny, no headless bypass).
- **Local only**: stdio + a per-user local socket. Nothing leaves your machine.
- **Resources and prompts**: open tabs as `notepatra://tab/N`, notes as `notepatra://note/`, plus ready-made review/explain/summarize prompts.
diff --git a/notepatra-mcp/e2e.sh b/notepatra-mcp/e2e.sh
index 91dee52..40ddaac 100755
--- a/notepatra-mcp/e2e.sh
+++ b/notepatra-mcp/e2e.sh
@@ -190,14 +190,18 @@ check("initialize", r.get("protocolVersion") == "2025-06-18"
and r.get("serverInfo", {}).get("name") == "notepatra-mcp",
json.dumps(resp.get(1)))
-# 2: tools/list — all 35 tools present (22 from v0.1.118 + 13 from v0.1.119).
+# 2: tools/list — all 48 tools present (22 from v0.1.118 + 13 from v0.1.119 + 2 from p0a + 4 from phase 1 + 7 from phase 2).
# tools/list is served by the Rust sidecar itself, so the count is independent
# of which verbs the live editor bridge implements.
tools = [t["name"] for t in (result(2) or {}).get("tools", [])]
-check("tools/list (35 tools)", len(tools) == 35 and "read_note" in tools
+check("tools/list (48 tools)", len(tools) == 48 and "read_note" in tools
and "insert_text" in tools and "save_tab" in tools
and "open_file" in tools and "list_reminders" in tools
- and "git_status" in tools and "export_diagram" in tools, str(tools))
+ and "git_status" in tools and "export_diagram" in tools
+ and "list_languages" in tools and "get_capabilities" in tools
+ and "create_diagram" in tools and "open_noter" in tools
+ and "list_connections" in tools and "run_query" in tools
+ and "export_chart" in tools, str(tools))
# 3: list_open_tabs — our document is an open tab
t = tool_text(3)
diff --git a/notepatra-mcp/mcpb/build-mcpb.py b/notepatra-mcp/mcpb/build-mcpb.py
new file mode 100755
index 0000000..ac8557d
--- /dev/null
+++ b/notepatra-mcp/mcpb/build-mcpb.py
@@ -0,0 +1,115 @@
+#!/usr/bin/env python3
+"""Assemble notepatra-mcp.mcpb — an MCP Bundle (zip) Claude Desktop installs one-click.
+
+Layout inside the zip:
+ manifest.json
+ server/linux/run.sh arch-picking launcher (see below)
+ server//notepatra-mcp[.exe] platform ∈ {linux-x64, linux-arm64, darwin, win32-x64}
+
+Why 'darwin' and not 'darwin-arm64': MCPB platform_overrides key on process.platform
+only — there is no arch selector — so one darwin entry must serve both Macs. CI lipos
+the arm64+x86_64 slices into a single universal binary and passes it as `--binary
+darwin=`; it lands at server/darwin/notepatra-mcp whatever the source basename.
+Linux has the same problem with the opposite fix: two separate ELF binaries stay
+packed and server/linux/run.sh picks between them at exec time via uname -m.
+Intel-macOS coverage therefore exists ONLY as the universal binary's x86_64 slice —
+no native Intel build is produced or tested here.
+
+Local smoke run (linux binary as stand-in, no --require-all):
+ python3 notepatra-mcp/mcpb/build-mcpb.py \
+ --manifest notepatra-mcp/mcpb/manifest.json \
+ --cargo-toml notepatra-mcp/Cargo.toml \
+ --binary linux-x64=notepatra-mcp/target/release/notepatra-mcp \
+ --out /tmp/.../notepatra-mcp.mcpb
+CI passes all four platforms plus --require-all and --expect-version.
+"""
+import argparse, json, os, re, sys, zipfile
+
+# 'darwin' REPLACES the old 'darwin-arm64' key on purpose: a stale CI invocation
+# passing --binary darwin-arm64=... now dies on the unknown-platform fail() below
+# rather than quietly producing a bundle no Intel Mac can run.
+ALL_PLATFORMS = ("linux-x64", "linux-arm64", "darwin", "win32-x64")
+
+def fail(msg):
+ print(f"ERROR: {msg}", file=sys.stderr); sys.exit(1)
+
+def main():
+ p = argparse.ArgumentParser()
+ p.add_argument("--manifest", required=True)
+ p.add_argument("--cargo-toml", required=True)
+ p.add_argument("--expect-version")
+ p.add_argument("--require-all", action="store_true")
+ p.add_argument("--binary", action="append", default=[], metavar="PLATFORM=PATH")
+ p.add_argument("--out", required=True)
+ a = p.parse_args()
+
+ manifest = json.load(open(a.manifest)) # dies loudly on invalid JSON
+ for key in ("manifest_version", "name", "version", "description", "author", "server"):
+ if key not in manifest: fail(f"manifest.json missing required field '{key}'")
+ mver = manifest["version"]
+
+ cargo = open(a.cargo_toml).read()
+ m = re.search(r'^version\s*=\s*"([0-9]+\.[0-9]+\.[0-9]+)"', cargo, re.M)
+ if not m: fail("could not parse version from Cargo.toml")
+ if m.group(1) != mver: fail(f"version skew: manifest {mver} != Cargo.toml {m.group(1)}")
+ if a.expect_version and a.expect_version != mver:
+ fail(f"version skew: manifest {mver} != release tag {a.expect_version}")
+
+ binaries = {}
+ for spec in a.binary:
+ plat, _, path = spec.partition("=")
+ if plat not in ALL_PLATFORMS: fail(f"unknown platform '{plat}' (expected one of {ALL_PLATFORMS})")
+ binaries[plat] = path
+ if not binaries: fail("no --binary given")
+ if a.require_all:
+ missing = [pl for pl in ALL_PLATFORMS if pl not in binaries]
+ if missing: fail(f"--require-all set but platforms missing: {missing}")
+
+ # The launcher ships in EVERY bundle, even partial local ones — the manifest's
+ # linux command points at it unconditionally, so a bundle without it is broken.
+ launcher = os.path.join(os.path.dirname(a.manifest), "run-linux.sh")
+ if not os.path.isfile(launcher): fail(f"launcher not found: {launcher}")
+ launcher_bytes = open(launcher, "rb").read()
+ # A CRLF anywhere means the checkout mangled line endings; the kernel would then
+ # look for an interpreter literally named "/bin/sh\r" and exec would fail.
+ if b"\r" in launcher_bytes: fail(f"{launcher} has CRLF line endings (must be LF)")
+
+ with zipfile.ZipFile(a.out, "w", zipfile.ZIP_DEFLATED) as z:
+ z.write(a.manifest, "manifest.json")
+ linfo = zipfile.ZipInfo("server/linux/run.sh")
+ linfo.external_attr = 0o755 << 16 # must stay executable to be exec'd
+ linfo.compress_type = zipfile.ZIP_DEFLATED
+ z.writestr(linfo, launcher_bytes)
+ for plat, path in sorted(binaries.items()):
+ exe = "notepatra-mcp.exe" if plat == "win32-x64" else "notepatra-mcp"
+ # Renames on the way in: the darwin universal may arrive as e.g.
+ # notepatra-mcp-universal and still lands at server/darwin/notepatra-mcp.
+ info = zipfile.ZipInfo(f"server/{plat}/{exe}")
+ info.external_attr = 0o755 << 16 # keep the unix exec bit
+ info.compress_type = zipfile.ZIP_DEFLATED
+ with open(path, "rb") as f: z.writestr(info, f.read())
+
+ with zipfile.ZipFile(a.out) as z: # self-check: reopen + reparse
+ bad = z.testzip()
+ if bad: fail(f"corrupt entry in bundle: {bad}")
+ packed = json.loads(z.read("manifest.json"))
+ names = z.namelist()
+ # Pin manifest↔builder agreement forever: the paths the host will exec must be
+ # paths this builder actually wrote. Drift here is invisible until install time.
+ if "server/linux/run.sh" not in names: fail("bundle is missing server/linux/run.sh")
+ cfg = packed.get("server", {}).get("mcp_config", {})
+ cmd = cfg.get("command", "")
+ if not cmd.endswith("server/linux/run.sh"):
+ fail(f"manifest command '{cmd}' does not point at server/linux/run.sh")
+ dcmd = cfg.get("platform_overrides", {}).get("darwin", {}).get("command", "")
+ if not dcmd.endswith("server/darwin/notepatra-mcp"):
+ fail(f"manifest darwin override '{dcmd}' does not point at server/darwin/notepatra-mcp")
+ if a.require_all:
+ for plat in ALL_PLATFORMS:
+ if not any(n.startswith(f"server/{plat}/") for n in names):
+ fail(f"--require-all set but bundle has no server/{plat}/ entry")
+ print(f"OK: {a.out} — version {mver}, {len(binaries)} platform binaries")
+ for n in names: print(f" {n}")
+
+if __name__ == "__main__":
+ main()
diff --git a/notepatra-mcp/mcpb/manifest.json b/notepatra-mcp/mcpb/manifest.json
new file mode 100644
index 0000000..8a1026d
--- /dev/null
+++ b/notepatra-mcp/mcpb/manifest.json
@@ -0,0 +1,26 @@
+{
+ "manifest_version": "0.3",
+ "name": "notepatra-mcp",
+ "display_name": "Notepatra",
+ "version": "0.1.120",
+ "description": "Notepatra editor MCP server — 48 tools; every write human-approved in the editor; local-only.",
+ "author": { "name": "Notepatra project", "url": "https://notepatra.org/" },
+ "repository": { "type": "git", "url": "https://github.com/singhpratech/notepatra" },
+ "homepage": "https://notepatra.org/",
+ "documentation": "https://notepatra.org/mcp.html",
+ "license": "GPL-3.0-or-later",
+ "keywords": ["editor", "notepatra", "notes", "sql", "git", "diagrams"],
+ "server": {
+ "type": "binary",
+ "entry_point": "server/linux/run.sh",
+ "mcp_config": {
+ "command": "${__dirname}/server/linux/run.sh",
+ "args": ["--socket"],
+ "platform_overrides": {
+ "win32": { "command": "${__dirname}/server/win32-x64/notepatra-mcp.exe" },
+ "darwin": { "command": "${__dirname}/server/darwin/notepatra-mcp" }
+ }
+ }
+ },
+ "compatibility": { "platforms": ["darwin", "win32", "linux"] }
+}
diff --git a/notepatra-mcp/mcpb/run-linux.sh b/notepatra-mcp/mcpb/run-linux.sh
new file mode 100755
index 0000000..af82f56
--- /dev/null
+++ b/notepatra-mcp/mcpb/run-linux.sh
@@ -0,0 +1,14 @@
+#!/bin/sh
+# Notepatra MCP launcher — the MCPB manifest cannot select by CPU arch,
+# so this picks the right packed linux binary at runtime.
+#
+# Residuals, stated honestly:
+# * assumes the host execs a shebang script (true for the Node spawn-based
+# MCPB hosts; a host that exec()s the file with no shell would break);
+# * unknown archs deliberately fall back to linux-x64 — best effort beats a
+# hard failure on a machine that may well be running x86 emulation.
+dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
+case "$(uname -m)" in
+ aarch64|arm64) exec "$dir/../linux-arm64/notepatra-mcp" "$@" ;;
+ *) exec "$dir/../linux-x64/notepatra-mcp" "$@" ;;
+esac
diff --git a/notepatra-mcp/server.json b/notepatra-mcp/server.json
index 969bc47..e3dbef8 100644
--- a/notepatra-mcp/server.json
+++ b/notepatra-mcp/server.json
@@ -1,19 +1,19 @@
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.singhpratech/notepatra-mcp",
- "description": "Notepatra editor MCP server — 35 tools; every write human-approved in the editor; local-only.",
+ "description": "Notepatra editor MCP server — 48 tools; every write human-approved in the editor; local-only.",
"repository": {
"url": "https://github.com/singhpratech/notepatra",
"source": "github"
},
- "version": "0.1.119",
+ "version": "0.1.120",
"websiteUrl": "https://notepatra.org/mcp.html",
"packages": [
{
"registryType": "cargo",
"registryBaseUrl": "https://crates.io",
"identifier": "notepatra-mcp",
- "version": "0.1.119",
+ "version": "0.1.120",
"transport": {
"type": "stdio"
},
diff --git a/notepatra-mcp/src/config_dir.rs b/notepatra-mcp/src/config_dir.rs
new file mode 100644
index 0000000..cb1133a
--- /dev/null
+++ b/notepatra-mcp/src/config_dir.rs
@@ -0,0 +1,75 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+//! The per-user Notepatra config directory, mirroring the C++
+//! `Config::appConfigDir()` (src/config.h:413) EXACTLY — the editor writes
+//! `mcp-endpoint.json` there and this sidecar reads it, so any divergence
+//! silently breaks endpoint discovery.
+//!
+//! Deliberately UNGATED (not behind `--features remote`): the DEFAULT std-only
+//! build must be able to locate `mcp-endpoint.json`. `remote::token`
+//! re-exports this so the gateway keeps its existing API.
+
+use std::path::PathBuf;
+
+/// Config root (also the parent of the remote gateway's `mcp-remote` dir).
+/// `NOTEPATRA_MCP_CONFIG_DIR` overrides everything (tests point it at a
+/// tempdir); otherwise the platform per-user config dir.
+///
+/// This function NEVER creates the directory — the editor's `appConfigDir()`
+/// already mkpaths it, and a reader that materialized the path could mask a
+/// "no editor ever ran here" state.
+pub fn config_root() -> PathBuf {
+ if let Ok(d) = std::env::var("NOTEPATRA_MCP_CONFIG_DIR") {
+ return PathBuf::from(d);
+ }
+ // Every branch is pure env-var + path arithmetic, so `cfg!` (not `#[cfg]`)
+ // keeps them all type-checked on every host — the same style
+ // `transport::socket::socket_path` uses. Only one is ever live.
+ if cfg!(windows) {
+ // config.h:413 — %APPDATA%\Notepatra, falling back to the literal
+ // Roaming path under %USERPROFILE% when APPDATA is unset (services,
+ // stripped environments). A "." fallback would point the reader at the
+ // CWD, where the editor never writes.
+ match std::env::var("APPDATA") {
+ Ok(appdata) if !appdata.is_empty() => PathBuf::from(appdata).join("Notepatra"),
+ _ => PathBuf::from(std::env::var("USERPROFILE").unwrap_or_else(|_| ".".into()))
+ .join("AppData")
+ .join("Roaming")
+ .join("Notepatra"),
+ }
+ } else if cfg!(target_os = "macos") {
+ PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".into()))
+ .join("Library")
+ .join("Application Support")
+ .join("Notepatra")
+ } else if cfg!(unix) {
+ match std::env::var("XDG_CONFIG_HOME") {
+ Ok(x) if !x.is_empty() => PathBuf::from(x).join("notepatra"),
+ _ => PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".into()))
+ .join(".config")
+ .join("notepatra"),
+ }
+ } else {
+ PathBuf::from(".").join("notepatra")
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// The override wins on every platform — the whole test suite depends on
+ /// it, and so does the `mcp-endpoint.json` discovery test.
+ #[test]
+ fn env_override_wins() {
+ // Serialized against other env-mutating tests only by being the sole
+ // reader/writer of this variable inside this module.
+ let key = "NOTEPATRA_MCP_CONFIG_DIR";
+ let prev = std::env::var(key).ok();
+ std::env::set_var(key, "/tmp/np-mcp-config-root-test");
+ assert_eq!(config_root(), PathBuf::from("/tmp/np-mcp-config-root-test"));
+ match prev {
+ Some(v) => std::env::set_var(key, v),
+ None => std::env::remove_var(key),
+ }
+ }
+}
diff --git a/notepatra-mcp/src/lib.rs b/notepatra-mcp/src/lib.rs
index 7ffe914..33e03e5 100644
--- a/notepatra-mcp/src/lib.rs
+++ b/notepatra-mcp/src/lib.rs
@@ -1,5 +1,16 @@
// SPDX-License-Identifier: GPL-3.0-or-later
+// The tools::definitions() json! array is large enough (48 tools) to exceed
+// the default macro recursion limit.
+#![recursion_limit = "512"]
+// UNGATED on purpose: the default std-only build needs the per-user config dir
+// to find the editor's published `mcp-endpoint.json`.
+pub mod config_dir;
pub mod prompts;
+// Phase 3a — the opt-in remote gateway. Compiled ONLY under `--features remote`;
+// the default build never touches it and pulls in zero new crates. This single
+// line is the whole feature choke point.
+#[cfg(feature = "remote")]
+pub mod remote;
pub mod server;
pub mod tools;
pub mod transport;
diff --git a/notepatra-mcp/src/main.rs b/notepatra-mcp/src/main.rs
index a7cee30..2a224e0 100644
--- a/notepatra-mcp/src/main.rs
+++ b/notepatra-mcp/src/main.rs
@@ -3,6 +3,14 @@ use notepatra_mcp::server::Server;
use notepatra_mcp::transport::{mock::MockEditor, socket::SocketEditor};
fn main() -> std::io::Result<()> {
+ // Phase 3a subcommands (serve/pair/connect) are dispatched FIRST and only
+ // when they are the first argument, so every existing invocation — no args,
+ // `--socket`, anything else — reaches the unchanged stdio path below,
+ // byte-for-byte identical to HEAD.
+ if let Some(mode @ ("serve" | "pair" | "connect")) = std::env::args().nth(1).as_deref() {
+ return run_remote_mode(mode);
+ }
+
// `--socket` targets the running editor over its dedicated MCP bridge
// socket; default is the in-memory mock so any MCP client can exercise
// the protocol without a running editor.
@@ -15,3 +23,17 @@ fn main() -> std::io::Result<()> {
Server::new(MockEditor::default()).run(stdin, stdout)
}
}
+
+#[cfg(feature = "remote")]
+fn run_remote_mode(mode: &str) -> std::io::Result<()> {
+ notepatra_mcp::remote::cli::run(mode)
+}
+
+#[cfg(not(feature = "remote"))]
+fn run_remote_mode(_mode: &str) -> std::io::Result<()> {
+ eprintln!(
+ "notepatra-mcp: built without remote support; \
+ rebuild with `cargo build --features remote`"
+ );
+ std::process::exit(2);
+}
diff --git a/notepatra-mcp/src/remote/cli.rs b/notepatra-mcp/src/remote/cli.rs
new file mode 100644
index 0000000..134bc36
--- /dev/null
+++ b/notepatra-mcp/src/remote/cli.rs
@@ -0,0 +1,189 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+//! Argument parsing + wiring for the three remote subcommands (`serve`, `pair`,
+//! `connect`). All additive — the existing bare-stdio / `--socket` modes are
+//! untouched and dispatched before this module is ever reached.
+
+use std::io::{self, Write};
+use std::net::SocketAddr;
+use std::sync::{Arc, Mutex};
+
+use serde_json::{json, Value};
+
+use crate::server::Server;
+use crate::transport::mock::MockEditor;
+use crate::transport::socket::SocketEditor;
+
+use super::gateway;
+use super::http;
+use super::pairing::{self, PairingState};
+use super::scope::Scope;
+use super::token::TokenStore;
+
+/// Entry point from main.rs for a remote subcommand.
+pub fn run(mode: &str) -> io::Result<()> {
+ let args: Vec = std::env::args().skip(2).collect();
+ match mode {
+ "serve" => serve(&args),
+ "pair" => pair(&args),
+ "connect" => connect(&args),
+ _ => {
+ eprintln!("notepatra-mcp: unknown remote mode {mode:?}");
+ std::process::exit(2);
+ }
+ }
+}
+
+fn flag_value<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
+ args.iter()
+ .position(|a| a == name)
+ .and_then(|i| args.get(i + 1))
+ .map(String::as_str)
+}
+
+fn has_flag(args: &[String], name: &str) -> bool {
+ args.iter().any(|a| a == name)
+}
+
+// ── serve ────────────────────────────────────────────────────────────────────
+
+fn serve(args: &[String]) -> io::Result<()> {
+ let port: u16 = flag_value(args, "--port")
+ .and_then(|p| p.parse().ok())
+ .unwrap_or(0); // 0 = ephemeral, actual port printed
+ let max_scope = flag_value(args, "--max-scope")
+ .and_then(Scope::parse)
+ .unwrap_or(Scope::WriteRequest);
+ // Opt-in deny-all posture: refuse unauthenticated /rpc entirely instead of
+ // serving the read-only floor. Off by default (brief's read floor).
+ let require_token = has_flag(args, "--require-token");
+
+ let addr: SocketAddr = format!("127.0.0.1:{port}")
+ .parse()
+ .expect("literal loopback addr");
+ let listener = gateway::bind_loopback(addr)?;
+ let bound = listener.local_addr()?;
+
+ let tokens = Arc::new(TokenStore::from_env()?);
+ let pairing = Arc::new(Mutex::new(PairingState::new(
+ max_scope,
+ pairing::DEFAULT_TTL,
+ pairing::DEFAULT_ATTEMPTS,
+ )));
+
+ // The code is printed to STDOUT; a token is NEVER printed or logged.
+ {
+ let p = pairing.lock().unwrap_or_else(|e| e.into_inner());
+ println!("listening on http://{bound}");
+ println!(
+ "pairing code: {} (valid {}s, {} attempts, single use)",
+ p.code(),
+ pairing::DEFAULT_TTL.as_secs(),
+ pairing::DEFAULT_ATTEMPTS,
+ );
+ let _ = io::stdout().flush();
+ }
+
+ // Backend: --socket reaches the running editor, else the in-memory mock.
+ if has_flag(args, "--socket") {
+ let server = Arc::new(Mutex::new(Server::new(SocketEditor::new())));
+ gateway::accept_loop(listener, server, tokens, pairing, require_token)
+ } else {
+ let server = Arc::new(Mutex::new(Server::new(MockEditor::default())));
+ gateway::accept_loop(listener, server, tokens, pairing, require_token)
+ }
+}
+
+// ── pair ─────────────────────────────────────────────────────────────────────
+
+fn pair(args: &[String]) -> io::Result<()> {
+ let port: u16 = flag_value(args, "--port")
+ .and_then(|p| p.parse().ok())
+ .unwrap_or(0);
+ if port == 0 {
+ eprintln!("notepatra-mcp pair: --port is required (the serve port)");
+ std::process::exit(2);
+ }
+ let requested = flag_value(args, "--scope")
+ .and_then(Scope::parse)
+ .unwrap_or(Scope::WriteRequest);
+ let base = format!("http://127.0.0.1:{port}");
+
+ // The one-time code is not a secret token: accept it via --code, otherwise
+ // prompt on stderr and read one line from stdin.
+ let code = match flag_value(args, "--code") {
+ Some(c) => c.to_string(),
+ None => {
+ eprint!("enter pairing code: ");
+ let _ = io::stderr().flush();
+ let mut line = String::new();
+ io::stdin().read_line(&mut line)?;
+ line.trim().to_string()
+ }
+ };
+
+ // 1) start → nonce.
+ let start = http::post_json(&base, "/pair/start", None, b"{}")?;
+ let start_body: Value = serde_json::from_slice(&start.body).unwrap_or(Value::Null);
+ if start.status != 200 {
+ eprintln!(
+ "notepatra-mcp pair: start rejected: {}",
+ start_body
+ .get("error")
+ .and_then(Value::as_str)
+ .unwrap_or("unknown")
+ );
+ std::process::exit(1);
+ }
+ let pair_id = start_body
+ .get("pair_id")
+ .and_then(Value::as_str)
+ .unwrap_or("");
+ let nonce = start_body
+ .get("nonce")
+ .and_then(Value::as_str)
+ .unwrap_or("");
+
+ // 2) prove knowledge of the code via HMAC(code, nonce).
+ let Some(mac) = pairing::client_mac(&code, nonce) else {
+ eprintln!("notepatra-mcp pair: server sent a malformed nonce");
+ std::process::exit(1);
+ };
+ let complete_body = json!({
+ "pair_id": pair_id,
+ "mac": mac,
+ "scope": requested.as_str(),
+ })
+ .to_string();
+ let complete = http::post_json(&base, "/pair/complete", None, complete_body.as_bytes())?;
+ let cb: Value = serde_json::from_slice(&complete.body).unwrap_or(Value::Null);
+ if complete.status != 200 {
+ eprintln!(
+ "notepatra-mcp pair: pairing failed: {}",
+ cb.get("error").and_then(Value::as_str).unwrap_or("unknown")
+ );
+ std::process::exit(1);
+ }
+
+ // 3) store the plaintext token (0600) — never printed, never in argv.
+ let token = cb.get("token").and_then(Value::as_str).unwrap_or("");
+ let scope = cb
+ .get("scope")
+ .and_then(Value::as_str)
+ .and_then(Scope::parse)
+ .unwrap_or(requested);
+ let store = TokenStore::from_env()?;
+ store.store_client(&base, token, scope)?;
+ println!("paired (scope: {}); token stored", scope.as_str());
+ Ok(())
+}
+
+// ── connect ──────────────────────────────────────────────────────────────────
+
+fn connect(args: &[String]) -> io::Result<()> {
+ // The URL is the first non-flag argument.
+ let Some(url) = args.iter().find(|a| !a.starts_with("--")) else {
+ eprintln!("notepatra-mcp connect: usage: connect (e.g. http://127.0.0.1:8080)");
+ std::process::exit(2);
+ };
+ super::connect::run(url)
+}
diff --git a/notepatra-mcp/src/remote/connect.rs b/notepatra-mcp/src/remote/connect.rs
new file mode 100644
index 0000000..1688c29
--- /dev/null
+++ b/notepatra-mcp/src/remote/connect.rs
@@ -0,0 +1,59 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+//! `connect ` — a stdio front-end that forwards JSON-RPC to a REMOTE
+//! gateway over HTTP with the stored bearer token. Lets an existing stdio MCP
+//! client config reach a remote editor by only swapping the sidecar's args.
+//! The remote-machine story this phase is an SSH port-forward to the loopback
+//! gateway (`ssh -L 8080:127.0.0.1:8080 host`), then `connect http://127.0.0.1:8080`.
+
+use std::io::{self, BufRead, Write};
+
+use super::http;
+use super::token::TokenStore;
+
+/// Reads newline-delimited JSON-RPC from stdin, POSTs each to `/rpc` with
+/// the stored token, and writes each response body (one line) to stdout —
+/// mirroring `Server::run`. `204 No Content` (notifications) produce no output.
+pub fn run(url: &str) -> io::Result<()> {
+ if url.starts_with("https://") {
+ eprintln!("notepatra-mcp connect: TLS not supported yet (Phase 3b); use http:// over an SSH port-forward");
+ std::process::exit(2);
+ }
+ if !url.starts_with("http://") {
+ eprintln!("notepatra-mcp connect: url must start with http://");
+ std::process::exit(2);
+ }
+ let base = url.trim_end_matches('/').to_string();
+
+ let store = TokenStore::from_env()?;
+ let token = store.load_client(&base)?.map(|c| c.token);
+ if token.is_none() {
+ eprintln!(
+ "notepatra-mcp connect: no stored token for {base}; run `notepatra-mcp pair` against it first"
+ );
+ }
+
+ let stdin = io::stdin().lock();
+ let stdout = io::stdout();
+ for line in stdin.lines() {
+ let line = line?;
+ if line.trim().is_empty() {
+ continue;
+ }
+ let resp = match http::post_json(&base, "/rpc", token.as_deref(), line.as_bytes()) {
+ Ok(r) => r,
+ Err(e) => {
+ eprintln!("notepatra-mcp connect: request failed: {e}");
+ continue;
+ }
+ };
+ // 204 = notification (no reply owed). Anything else carries a body.
+ if resp.status == 204 || resp.body.is_empty() {
+ continue;
+ }
+ let mut out = stdout.lock();
+ out.write_all(&resp.body)?;
+ out.write_all(b"\n")?;
+ out.flush()?;
+ }
+ Ok(())
+}
diff --git a/notepatra-mcp/src/remote/gateway.rs b/notepatra-mcp/src/remote/gateway.rs
new file mode 100644
index 0000000..9ab9f14
--- /dev/null
+++ b/notepatra-mcp/src/remote/gateway.rs
@@ -0,0 +1,361 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+//! The loopback gateway: a std `TcpListener` on 127.0.0.1, thread-per-
+//! connection, no async runtime. Each request is authenticated (fail-closed to
+//! read_only), scope-checked at DISPATCH, then forwarded to the backend
+//! `EditorTransport` through the existing `Server` machinery.
+//!
+//! ## Concurrency
+//! One shared `Arc>>` across connection threads. `SocketEditor`
+//! is `Send` but not `Sync` (its `RefCell` connection), so a `Mutex` — never an
+//! `RwLock` — is the correct sharing primitive, and serializing requests
+//! matches the single editor bridge anyway. KNOWN TRADE-OFF: a write verb
+//! blocking up to ~120 s on the local approval card holds the lock and stalls
+//! other requests. Acceptable for a single remote client in 3a; Phase 3b can
+//! move to per-connection backends.
+//!
+//! ## Approval invariant
+//! The gateway NEVER approves anything. A `write_request`-scoped call is merely
+//! forwarded to the editor bridge, which raises the LOCAL approval card on the
+//! host exactly as today.
+
+use std::io::{self, BufReader, Write};
+use std::net::{SocketAddr, TcpListener, TcpStream};
+use std::sync::{Arc, Mutex};
+use std::time::Instant;
+
+use serde_json::{json, Value};
+
+use crate::server::Server;
+use crate::transport::EditorTransport;
+
+use super::http::{self, HttpRequest};
+use super::pairing::PairingState;
+use super::scope::{self, Scope};
+use super::token::TokenStore;
+use super::SCOPE_DENIED;
+
+/// Binds `addr` — but ONLY if it is a loopback address. The check lives here
+/// (defense-in-depth for a future LAN flag) so it is unit-testable independent
+/// of the CLI, which only ever constructs `127.0.0.1:{port}`.
+pub fn bind_loopback(addr: SocketAddr) -> io::Result {
+ if !addr.ip().is_loopback() {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidInput,
+ format!(
+ "refusing to bind non-loopback address {addr}; the gateway is \
+ loopback-only this phase (LAN binding + TLS is Phase 3b)"
+ ),
+ ));
+ }
+ TcpListener::bind(addr)
+}
+
+/// Shared per-connection context.
+struct Ctx {
+ server: Arc>>,
+ tokens: Arc,
+ pairing: Arc>,
+ /// When true, an unauthenticated (no/invalid token) `/rpc` is refused
+ /// outright instead of degrading to read_only. Default false preserves the
+ /// brief's read-only floor; operators exposing the loopback port (e.g. an
+ /// SSH forward) can opt into a deny-all posture with `serve --require-token`.
+ require_token: bool,
+}
+
+impl Clone for Ctx {
+ fn clone(&self) -> Self {
+ Self {
+ server: self.server.clone(),
+ tokens: self.tokens.clone(),
+ pairing: self.pairing.clone(),
+ require_token: self.require_token,
+ }
+ }
+}
+
+/// Accept loop. Blocks forever, spawning a thread per connection. Backend and
+/// state are shared via `Arc`; callers keep their own `Arc` clones to inspect
+/// state (tests read the token store after pairing).
+pub fn accept_loop(
+ listener: TcpListener,
+ server: Arc>>,
+ tokens: Arc,
+ pairing: Arc>,
+ require_token: bool,
+) -> io::Result<()> {
+ let ctx = Ctx {
+ server,
+ tokens,
+ pairing,
+ require_token,
+ };
+ for stream in listener.incoming() {
+ match stream {
+ Ok(stream) => {
+ let ctx = ctx.clone();
+ std::thread::spawn(move || {
+ let _ = handle_conn(stream, ctx);
+ });
+ }
+ // A single failed accept must not kill the gateway.
+ Err(_) => continue,
+ }
+ }
+ Ok(())
+}
+
+fn handle_conn(
+ stream: TcpStream,
+ ctx: Ctx,
+) -> io::Result<()> {
+ // Bound the SEND side: a client that stops draining must not park this
+ // handler forever holding a connection slot. The RECEIVE side is left
+ // unbounded on purpose — blocking in `HttpRequest::read` between keep-alive
+ // requests is the correct idle state for a persistent connection, and a
+ // read deadline here would tear down healthy idle sockets. This runs on a
+ // detached per-connection thread, so an idle read costs a parked thread,
+ // never a stalled caller.
+ stream.set_write_timeout(Some(http::WRITE_TIMEOUT))?;
+ let mut writer = stream.try_clone()?;
+ let mut reader = BufReader::new(stream);
+ loop {
+ let req = match HttpRequest::read(&mut reader) {
+ Ok(Some(r)) => r,
+ Ok(None) => break, // client closed
+ // An over-limit Content-Length (or other malformed framing) surfaces
+ // as InvalidData: answer 413 and drop the connection rather than
+ // allocating for it.
+ Err(e) if e.kind() == io::ErrorKind::InvalidData => {
+ let _ = http::write_response(
+ &mut writer,
+ 413,
+ "Payload Too Large",
+ b"{\"error\":\"request body too large\"}",
+ false,
+ );
+ break;
+ }
+ Err(e) => return Err(e),
+ };
+ let keep_alive = req.keep_alive();
+ route(&req, &ctx, &mut writer)?;
+ if !keep_alive {
+ break;
+ }
+ }
+ Ok(())
+}
+
+fn route(
+ req: &HttpRequest,
+ ctx: &Ctx,
+ w: &mut impl Write,
+) -> io::Result<()> {
+ if req.method != "POST" {
+ return http::write_response(w, 400, "Bad Request", b"{\"error\":\"POST only\"}", true);
+ }
+ match req.path.as_str() {
+ "/pair/start" => pair_start(ctx, w),
+ "/pair/complete" => pair_complete(req, ctx, w),
+ "/rpc" => rpc(req, ctx, w),
+ _ => http::write_response(w, 404, "Not Found", b"{\"error\":\"unknown path\"}", true),
+ }
+}
+
+fn pair_start(
+ ctx: &Ctx,
+ w: &mut impl Write,
+) -> io::Result<()> {
+ let mut pairing = ctx.pairing.lock().unwrap_or_else(|e| e.into_inner());
+ match pairing.start(Instant::now()) {
+ Ok((pair_id, nonce)) => {
+ let body = json!({ "pair_id": pair_id, "nonce": nonce }).to_string();
+ http::write_response(w, 200, "OK", body.as_bytes(), true)
+ }
+ Err(e) => {
+ let body = json!({ "error": e.message() }).to_string();
+ http::write_response(w, 403, "Forbidden", body.as_bytes(), true)
+ }
+ }
+}
+
+fn pair_complete(
+ req: &HttpRequest,
+ ctx: &Ctx,
+ w: &mut impl Write,
+) -> io::Result<()> {
+ let body: Value = serde_json::from_slice(&req.body).unwrap_or(Value::Null);
+ let pair_id = body.get("pair_id").and_then(Value::as_str).unwrap_or("");
+ let mac = body.get("mac").and_then(Value::as_str).unwrap_or("");
+ let requested = body
+ .get("scope")
+ .and_then(Value::as_str)
+ .and_then(Scope::parse)
+ .unwrap_or(Scope::WriteRequest);
+
+ let granted = {
+ let mut pairing = ctx.pairing.lock().unwrap_or_else(|e| e.into_inner());
+ pairing.complete(pair_id, mac, requested, Instant::now())
+ };
+ match granted {
+ Ok(scope) => {
+ // Issue the token only AFTER a verified handshake; server persists
+ // its SHA-256 only.
+ match ctx.tokens.issue(scope) {
+ Ok(token) => {
+ let resp = json!({ "token": token, "scope": scope.as_str() }).to_string();
+ http::write_response(w, 200, "OK", resp.as_bytes(), true)
+ }
+ Err(e) => {
+ let resp = json!({ "error": format!("token storage failed: {e}") }).to_string();
+ http::write_response(w, 500, "Internal Server Error", resp.as_bytes(), true)
+ }
+ }
+ }
+ Err(e) => {
+ let resp = json!({ "error": e.message() }).to_string();
+ http::write_response(w, 401, "Unauthorized", resp.as_bytes(), true)
+ }
+ }
+}
+
+fn rpc(
+ req: &HttpRequest,
+ ctx: &Ctx,
+ w: &mut impl Write,
+) -> io::Result<()> {
+ // AUTH — fail-closed. A present-but-invalid token degrades to read_only,
+ // exactly as an absent one (per the phase brief). ALTERNATIVE considered: a
+ // presented-but-invalid token could hard-401 so a client notices token
+ // revocation; the brief specifies degrade, implemented here. `authed` tracks
+ // whether a VALID token was presented, for the opt-in --require-token gate.
+ let (scope, authed) = match req.bearer() {
+ Some(tok) => match ctx.tokens.lookup(tok).unwrap_or(None) {
+ Some(s) => (s, true),
+ None => (Scope::ReadOnly, false),
+ },
+ None => (Scope::ReadOnly, false),
+ };
+
+ let line = match std::str::from_utf8(&req.body) {
+ Ok(s) => s,
+ Err(_) => {
+ return http::write_response(
+ w,
+ 400,
+ "Bad Request",
+ b"{\"error\":\"body not UTF-8\"}",
+ true,
+ )
+ }
+ };
+
+ // SCOPE ENFORCEMENT at dispatch (not just in tools/list). Parse the message
+ // to gate tools/call BEFORE it can reach the backend.
+ let parsed: Option = serde_json::from_str(line).ok();
+ let method = parsed
+ .as_ref()
+ .and_then(|v| v.get("method"))
+ .and_then(Value::as_str);
+ let id = parsed
+ .as_ref()
+ .and_then(|v| v.get("id"))
+ .cloned()
+ .filter(|v| !v.is_null());
+
+ // --require-token: refuse any unauthenticated request outright (no read
+ // floor). An id-bearing request gets a JSON-RPC auth error; a notification
+ // is silently dropped (204). Backend is never touched.
+ if ctx.require_token && !authed {
+ if let Some(id) = id.clone() {
+ let err = json!({
+ "jsonrpc": "2.0",
+ "id": id,
+ "error": {
+ "code": SCOPE_DENIED,
+ "message": "authentication required (gateway started with --require-token)"
+ }
+ })
+ .to_string();
+ return http::write_response(w, 200, "OK", err.as_bytes(), true);
+ }
+ return http::write_response(w, 204, "No Content", b"", true);
+ }
+
+ if method == Some("tools/call") {
+ if let Some(name) = parsed
+ .as_ref()
+ .and_then(|v| v.get("params"))
+ .and_then(|p| p.get("name"))
+ .and_then(Value::as_str)
+ {
+ if let Some(tier) = scope::tier_of(name) {
+ // A tools/call WITHOUT an id is a notification: handle_line
+ // drops it unexecuted, so no scope risk and no reply is owed.
+ if !scope.allows(tier) {
+ if let Some(id) = id.clone() {
+ let err = json!({
+ "jsonrpc": "2.0",
+ "id": id,
+ "error": {
+ "code": SCOPE_DENIED,
+ "message": format!(
+ "insufficient scope: {name} requires {}; this connection is {}",
+ tier_name(tier), scope.as_str()
+ )
+ }
+ })
+ .to_string();
+ return http::write_response(w, 200, "OK", err.as_bytes(), true);
+ }
+ // Notification over-scope: silently dropped, backend untouched.
+ return http::write_response(w, 204, "No Content", b"", true);
+ }
+ }
+ }
+ }
+
+ // FORWARD through the existing server machinery.
+ let response = {
+ let mut server = ctx.server.lock().unwrap_or_else(|e| e.into_inner());
+ server.handle_line(line)
+ };
+
+ match response {
+ None => http::write_response(w, 204, "No Content", b"", true),
+ Some(mut resp) => {
+ // tools/list: filter the advertised set to the connection's scope.
+ if method == Some("tools/list") {
+ scope::filter_tools_list(scope, &mut resp);
+ }
+ let body = resp.to_string();
+ http::write_response(w, 200, "OK", body.as_bytes(), true)
+ }
+ }
+}
+
+fn tier_name(t: super::scope::Tier) -> &'static str {
+ use super::scope::Tier;
+ match t {
+ Tier::Read => "read",
+ Tier::Act => "read_act",
+ Tier::Write => "write_request",
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn refuses_non_loopback() {
+ assert!(bind_loopback("0.0.0.0:0".parse().unwrap()).is_err());
+ assert!(bind_loopback("8.8.8.8:0".parse().unwrap()).is_err());
+ }
+
+ #[test]
+ fn accepts_loopback() {
+ let l = bind_loopback("127.0.0.1:0".parse().unwrap()).expect("loopback binds");
+ assert!(l.local_addr().unwrap().ip().is_loopback());
+ }
+}
diff --git a/notepatra-mcp/src/remote/http.rs b/notepatra-mcp/src/remote/http.rs
new file mode 100644
index 0000000..4073140
--- /dev/null
+++ b/notepatra-mcp/src/remote/http.rs
@@ -0,0 +1,377 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+//! Minimal std-only HTTP/1.1 — just enough for the private gateway protocol.
+//! Server side parses a request; client side (used by `connect`/`pair`) issues
+//! one POST and reads one response. No chunked encoding, no keep-alive pooling
+//! on the client (one connection per request, `Connection: close`); the server
+//! honors keep-alive so a `connect` front-end can reuse its socket.
+
+use std::io::{self, BufRead, BufReader, Write};
+use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
+use std::time::Duration;
+
+// ── Client deadlines ───────────────────────────────────────────────────────
+//
+// Every socket op below carries one. A blocking read with no timeout is not a
+// slow test, it is a HANG: the integration suite that drives this client runs
+// on the Windows CI job, where an unbounded `read_line` waiting on a wedged
+// gateway handler burns the whole job budget and reports nothing. A deadline
+// turns that into a fast, attributable red.
+
+/// TCP connect ceiling. The gateway is loopback-only, so a healthy connect is
+/// sub-millisecond; this only bounds a listener that accepted the SYN and then
+/// went away.
+pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
+
+/// Response ceiling. Deliberately GENEROUS: a remote tool call can legitimately
+/// sit behind the local approval card a human has not answered yet. It is set
+/// above the transport's own 130 s approval window on purpose, so the inner
+/// timeout fires first and the agent gets the specific "approval timed out"
+/// message rather than this generic one.
+pub const READ_TIMEOUT: Duration = Duration::from_secs(180);
+
+/// Request-send ceiling. Unblocking a write needs the peer to drain; a gateway
+/// that stops reading must fail here rather than park the caller forever.
+pub const WRITE_TIMEOUT: Duration = Duration::from_secs(30);
+
+/// Hard ceiling on an inbound request body. The gateway's JSON-RPC requests are
+/// small (tool RESPONSES, which can be large, are written OUT, never read here),
+/// so this bounds a pre-auth attacker `Content-Length` from forcing a giant
+/// allocation (memory DoS). An over-limit request is refused before any body
+/// bytes are allocated; the router answers 413.
+pub const MAX_REQUEST_BODY: usize = 16 * 1024 * 1024;
+
+/// A parsed inbound HTTP request. Header names are lowercased.
+pub struct HttpRequest {
+ pub method: String,
+ pub path: String,
+ pub headers: Vec<(String, String)>,
+ pub body: Vec,
+}
+
+impl HttpRequest {
+ /// Reads one request. `Ok(None)` on a clean connection close before any
+ /// bytes (keep-alive loop end). A malformed request line still parses to a
+ /// request with an empty method/path — the router answers 400.
+ pub fn read(r: &mut R) -> io::Result> {
+ let mut request_line = String::new();
+ if r.read_line(&mut request_line)? == 0 {
+ return Ok(None); // EOF at message boundary
+ }
+ let request_line = request_line.trim_end_matches(['\r', '\n']);
+ if request_line.is_empty() {
+ return Ok(None);
+ }
+ let mut parts = request_line.split_whitespace();
+ let method = parts.next().unwrap_or_default().to_string();
+ let path = parts.next().unwrap_or_default().to_string();
+
+ let mut headers = Vec::new();
+ loop {
+ let mut line = String::new();
+ if r.read_line(&mut line)? == 0 {
+ break; // EOF mid-headers
+ }
+ let line = line.trim_end_matches(['\r', '\n']);
+ if line.is_empty() {
+ break; // end of headers
+ }
+ if let Some((k, v)) = line.split_once(':') {
+ headers.push((k.trim().to_ascii_lowercase(), v.trim().to_string()));
+ }
+ }
+
+ let len = header_of(&headers, "content-length")
+ .and_then(|v| v.parse::().ok())
+ .unwrap_or(0);
+ if len > MAX_REQUEST_BODY {
+ // Refuse BEFORE allocating: a bogus Content-Length must not OOM us.
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ "request body exceeds limit",
+ ));
+ }
+ let mut body = vec![0u8; len];
+ if len > 0 {
+ r.read_exact(&mut body)?;
+ }
+ Ok(Some(HttpRequest {
+ method,
+ path,
+ headers,
+ body,
+ }))
+ }
+
+ pub fn header(&self, name: &str) -> Option<&str> {
+ header_of(&self.headers, name)
+ }
+
+ /// The bearer token from `Authorization: Bearer `, if present.
+ pub fn bearer(&self) -> Option<&str> {
+ self.header("authorization")
+ .and_then(|v| {
+ v.strip_prefix("Bearer ")
+ .or_else(|| v.strip_prefix("bearer "))
+ })
+ .map(str::trim)
+ }
+
+ /// True unless the client asked to close (HTTP/1.1 default is keep-alive).
+ pub fn keep_alive(&self) -> bool {
+ !self
+ .header("connection")
+ .map(|c| c.eq_ignore_ascii_case("close"))
+ .unwrap_or(false)
+ }
+}
+
+fn header_of<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
+ headers
+ .iter()
+ .find(|(k, _)| k == name)
+ .map(|(_, v)| v.as_str())
+}
+
+/// Writes a JSON response. `body` is sent verbatim with a `Content-Length`;
+/// `204` carries an empty body.
+pub fn write_response(
+ w: &mut W,
+ status: u16,
+ reason: &str,
+ body: &[u8],
+ keep_alive: bool,
+) -> io::Result<()> {
+ let conn = if keep_alive { "keep-alive" } else { "close" };
+ write!(
+ w,
+ "HTTP/1.1 {status} {reason}\r\n\
+ Content-Type: application/json\r\n\
+ Content-Length: {}\r\n\
+ Connection: {conn}\r\n\r\n",
+ body.len()
+ )?;
+ w.write_all(body)?;
+ w.flush()
+}
+
+/// A parsed client-side response.
+#[derive(Debug)]
+pub struct HttpResponse {
+ pub status: u16,
+ pub body: Vec,
+}
+
+/// Issues one `POST {path}` to `base_url` (e.g. `http://127.0.0.1:8080`),
+/// optionally bearer-authenticated, and reads the whole response. Only plain
+/// `http://` is supported — `https://` is rejected (TLS arrives in Phase 3b).
+pub fn post_json(
+ base_url: &str,
+ path: &str,
+ token: Option<&str>,
+ body: &[u8],
+) -> io::Result {
+ if base_url.starts_with("https://") {
+ return Err(io::Error::new(
+ io::ErrorKind::Unsupported,
+ "TLS not supported yet (Phase 3b); use http:// over an SSH port-forward",
+ ));
+ }
+ let hostport = base_url
+ .strip_prefix("http://")
+ .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "url must start with http://"))?
+ .trim_end_matches('/');
+
+ let mut stream = connect_with_deadline(hostport)?;
+ let mut head = format!(
+ "POST {path} HTTP/1.1\r\n\
+ Host: {hostport}\r\n\
+ Content-Type: application/json\r\n\
+ Content-Length: {}\r\n\
+ Connection: close\r\n",
+ body.len()
+ );
+ if let Some(t) = token {
+ head.push_str(&format!("Authorization: Bearer {t}\r\n"));
+ }
+ head.push_str("\r\n");
+ stream
+ .write_all(head.as_bytes())
+ .and_then(|()| stream.write_all(body))
+ .and_then(|()| stream.flush())
+ .map_err(|e| explain_timeout(e, "sending the request to"))?;
+
+ read_response(&mut BufReader::new(stream))
+ .map_err(|e| explain_timeout(e, "waiting for a response from"))
+}
+
+/// Rewrites a bare socket-timeout errno into something an operator can act on.
+///
+/// std surfaces an elapsed `SO_RCVTIMEO`/`SO_SNDTIMEO` as `WouldBlock`, which
+/// prints as "Resource temporarily unavailable (os error 11)" — technically
+/// true and completely useless in a CI log. A deadline that fires must say WHAT
+/// timed out and for how long, or it just trades a silent hang for a cryptic
+/// red.
+fn explain_timeout(e: io::Error, what: &str) -> io::Error {
+ match e.kind() {
+ io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut => io::Error::new(
+ io::ErrorKind::TimedOut,
+ format!(
+ "timed out {what} the gateway: it accepted the connection but \
+ never completed the exchange (a wedged handler). Deadlines: \
+ connect {CONNECT_TIMEOUT:?}, write {WRITE_TIMEOUT:?}, read \
+ {READ_TIMEOUT:?}."
+ ),
+ ),
+ _ => e,
+ }
+}
+
+/// Connects to `hostport` with a bounded connect, then arms read AND write
+/// deadlines before a single byte is exchanged.
+///
+/// `TcpStream::connect` is NOT used: it takes a `ToSocketAddrs` and applies no
+/// deadline. `connect_timeout` takes a resolved `SocketAddr`, so resolution
+/// happens here and each candidate address is tried in turn (a host may resolve
+/// to both v4 and v6; only the last error is worth reporting).
+///
+/// HONEST RESIDUAL: `to_socket_addrs` performs DNS, and std exposes no timeout
+/// for it. For this client that is not a live risk — the gateway is
+/// loopback-only, so the input is a literal `127.0.0.1:port` that resolves
+/// without touching the network. Bounding DNS would need a resolver thread and
+/// buys nothing here.
+fn connect_with_deadline(hostport: &str) -> io::Result {
+ let addrs: Vec = hostport.to_socket_addrs()?.collect();
+ let mut last_err = None;
+ for addr in &addrs {
+ match TcpStream::connect_timeout(addr, CONNECT_TIMEOUT) {
+ Ok(s) => {
+ s.set_read_timeout(Some(READ_TIMEOUT))?;
+ s.set_write_timeout(Some(WRITE_TIMEOUT))?;
+ return Ok(s);
+ }
+ Err(e) => last_err = Some(e),
+ }
+ }
+ Err(last_err.unwrap_or_else(|| {
+ io::Error::new(
+ io::ErrorKind::NotFound,
+ format!("no address resolved for {hostport}"),
+ )
+ }))
+}
+
+fn read_response(r: &mut R) -> io::Result {
+ let mut status_line = String::new();
+ r.read_line(&mut status_line)?;
+ let status = status_line
+ .split_whitespace()
+ .nth(1)
+ .and_then(|s| s.parse::().ok())
+ .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "malformed status line"))?;
+
+ let mut content_length: Option = None;
+ loop {
+ let mut line = String::new();
+ if r.read_line(&mut line)? == 0 {
+ break;
+ }
+ let line = line.trim_end_matches(['\r', '\n']);
+ if line.is_empty() {
+ break;
+ }
+ if let Some((k, v)) = line.split_once(':') {
+ if k.trim().eq_ignore_ascii_case("content-length") {
+ content_length = v.trim().parse().ok();
+ }
+ }
+ }
+
+ let body = match content_length {
+ Some(n) => {
+ let mut b = vec![0u8; n];
+ r.read_exact(&mut b)?;
+ b
+ }
+ None => {
+ // No length: server used Connection: close, read to EOF.
+ let mut b = Vec::new();
+ r.read_to_end(&mut b)?;
+ b
+ }
+ };
+ Ok(HttpResponse { status, body })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::io::Cursor;
+
+ #[test]
+ fn parses_post_with_body() {
+ let raw = "POST /rpc HTTP/1.1\r\n\
+ Host: 127.0.0.1\r\n\
+ Content-Type: application/json\r\n\
+ Content-Length: 13\r\n\
+ Authorization: Bearer abc123\r\n\r\n\
+ {\"ping\":true}";
+ let mut c = Cursor::new(raw.as_bytes());
+ let req = HttpRequest::read(&mut c).unwrap().unwrap();
+ assert_eq!(req.method, "POST");
+ assert_eq!(req.path, "/rpc");
+ assert_eq!(req.body, b"{\"ping\":true}");
+ assert_eq!(req.bearer(), Some("abc123"));
+ assert!(req.keep_alive());
+ }
+
+ #[test]
+ fn headers_are_case_insensitive() {
+ let raw = "POST /x HTTP/1.1\r\nCONTENT-LENGTH: 2\r\nConnection: close\r\n\r\nhi";
+ let mut c = Cursor::new(raw.as_bytes());
+ let req = HttpRequest::read(&mut c).unwrap().unwrap();
+ assert_eq!(req.body, b"hi");
+ assert!(!req.keep_alive());
+ }
+
+ #[test]
+ fn garbage_yields_empty_method_path() {
+ let mut c = Cursor::new(b"garbage line\r\n\r\n".to_vec());
+ let req = HttpRequest::read(&mut c).unwrap().unwrap();
+ assert_eq!(req.method, "garbage");
+ assert_eq!(req.path, "line");
+ // A request line with no recognizable path is what the router 400s on;
+ // here the second token is a path-shaped word, so cover the true-empty
+ // case too:
+ let mut c2 = Cursor::new(b"OOPS\r\n\r\n".to_vec());
+ let req2 = HttpRequest::read(&mut c2).unwrap().unwrap();
+ assert_eq!(req2.path, "");
+ }
+
+ #[test]
+ fn rejects_oversized_content_length() {
+ // A giant Content-Length is refused before the body is even allocated —
+ // note the request carries no body bytes at all here.
+ let raw = format!(
+ "POST /rpc HTTP/1.1\r\nContent-Length: {}\r\n\r\n",
+ MAX_REQUEST_BODY + 1
+ );
+ let mut c = Cursor::new(raw.into_bytes());
+ // HttpRequest is not Debug, so match rather than unwrap_err().
+ match HttpRequest::read(&mut c) {
+ Err(e) => assert_eq!(e.kind(), io::ErrorKind::InvalidData),
+ Ok(_) => panic!("oversized Content-Length must be refused"),
+ }
+ }
+
+ #[test]
+ fn clean_eof_is_none() {
+ let mut c = Cursor::new(b"".to_vec());
+ assert!(HttpRequest::read(&mut c).unwrap().is_none());
+ }
+
+ #[test]
+ fn rejects_https() {
+ let e = post_json("https://example", "/rpc", None, b"{}").unwrap_err();
+ assert_eq!(e.kind(), io::ErrorKind::Unsupported);
+ }
+}
diff --git a/notepatra-mcp/src/remote/mod.rs b/notepatra-mcp/src/remote/mod.rs
new file mode 100644
index 0000000..70fbcf8
--- /dev/null
+++ b/notepatra-mcp/src/remote/mod.rs
@@ -0,0 +1,112 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+//! Phase 3a — the feature-gated REMOTE GATEWAY CORE.
+//!
+//! This whole tree is compiled ONLY under `--features remote`; the default
+//! build (`cargo build`) never touches it and pulls in zero new crates. Gating
+//! lives at a single choke point — `#[cfg(feature = "remote")] pub mod remote`
+//! in lib.rs — so the default dependency graph stays byte-identical to HEAD.
+//!
+//! ## What this is
+//! An OPT-IN, LOOPBACK-ONLY gateway that lets an existing stdio MCP client
+//! config reach a *locally running* Notepatra editor over a small HTTP/JSON-RPC
+//! protocol by only swapping the sidecar's args (`connect ` instead of the
+//! bare stdio server). The remote-machine story this phase is an SSH port-
+//! forward to the loopback gateway — there is NO network binding beyond
+//! 127.0.0.1 here.
+//!
+//! ## Wire protocol (PRIVATE gateway protocol — NOT MCP streamable-HTTP)
+//! Minimal HTTP/1.1, one JSON-RPC message per `POST /rpc`:
+//! ```text
+//! POST /rpc HTTP/1.1
+//! Authorization: Bearer <64-hex token> (omitted when unpaired)
+//! Content-Type: application/json
+//! Content-Length: N
+//!
+//! {"jsonrpc":"2.0","id":1,"method":"tools/call",...}
+//! ```
+//! The response is `200` + the JSON-RPC response body (JSON-RPC-level errors —
+//! auth/scope rejections — ride INSIDE a 200 so the `connect` front-end just
+//! pipes bodies to stdout). Notifications (`handle_line` → None) answer `204`.
+//! Pairing uses `POST /pair/start` then `POST /pair/complete`.
+//!
+//! ## Security posture (this phase)
+//! * Bind is LOOPBACK-ONLY — [`gateway::bind_loopback`] refuses any non-loopback
+//! address. The editor itself NEVER listens on the network; only this opt-in
+//! sidecar binds, and only on 127.0.0.1.
+//! * Auth is FAIL-CLOSED — a request with no/invalid token is served at
+//! [`Scope::ReadOnly`] at most; act/write verbs are rejected. Identity is
+//! never elevated on a missing token.
+//! * READ-FLOOR EXPOSURE: with the default posture, an UNPAIRED request still
+//! reaches the full read tier — including `read_tab`, `git_*`, and `run_sql`.
+//! Anyone who can reach the loopback port (a local user, or a peer via the
+//! documented `ssh -L` forward) reads open buffers, git history, and SQL
+//! results with no credential. That read tier is only as tight as the editor
+//! bridge's untrusted-SQL denylist (DuckDB `read_text`/`read_csv_auto`/`glob`
+//! are file-reading SELECTs — an editor-side concern, out of this Rust phase).
+//! Operators exposing the port beyond their own machine should start
+//! `serve --require-token`, which refuses every unauthenticated `/rpc`
+//! (no read floor). Hardening the read floor by default is a Phase 3b (LAN)
+//! decision for the brief author.
+//! * The APPROVAL INVARIANT is untouched — a `write_request`-scoped call is only
+//! FORWARDED to the editor bridge, which raises the LOCAL approval card on the
+//! host exactly as today. The gateway never sees or answers an approval.
+//! * MITM is OUT OF SCOPE this phase: loopback-only means no untrusted network
+//! path. TLS (rustls) + cert-pinning for LAN binding is the NEXT phase (3b).
+
+pub mod cli;
+pub mod connect;
+pub mod gateway;
+pub mod http;
+pub mod pairing;
+pub mod scope;
+pub mod token;
+
+pub use scope::{Scope, Tier};
+
+/// JSON-RPC error code for a call whose tier exceeds the connection's scope.
+/// Chosen in the MCP/implementation-defined server range (-32000..-32099).
+pub const SCOPE_DENIED: i64 = -32001;
+
+// ── Small crypto/random helpers (only exist under the `remote` feature) ──────
+
+/// Fills `buf` with OS randomness. Panics only if the OS RNG is unavailable,
+/// which on a supported platform indicates a broken system — there is no safe
+/// fallback for security-critical bytes.
+pub(crate) fn random_bytes(buf: &mut [u8]) {
+ getrandom::getrandom(buf).expect("OS RNG unavailable");
+}
+
+/// Lowercase hex of `bytes`.
+pub(crate) fn to_hex(bytes: &[u8]) -> String {
+ let mut s = String::with_capacity(bytes.len() * 2);
+ for b in bytes {
+ s.push_str(&format!("{b:02x}"));
+ }
+ s
+}
+
+/// `n` random bytes as a lowercase hex string (token = `random_hex(32)` → 64 hex).
+pub(crate) fn random_hex(n: usize) -> String {
+ let mut b = vec![0u8; n];
+ random_bytes(&mut b);
+ to_hex(&b)
+}
+
+/// SHA-256 of `data` as lowercase hex (server stores ONLY this for a token).
+pub(crate) fn sha256_hex(data: &[u8]) -> String {
+ use sha2::{Digest, Sha256};
+ to_hex(&Sha256::digest(data))
+}
+
+/// A one-time 8-digit pairing code, uniformly distributed (rejection sampling
+/// removes modulo bias: 4_200_000_000 = largest multiple of 1e8 ≤ u32::MAX+1).
+pub(crate) fn gen_pairing_code() -> String {
+ loop {
+ let mut b = [0u8; 4];
+ random_bytes(&mut b);
+ let x = u32::from_le_bytes(b);
+ if x < 4_200_000_000 {
+ return format!("{:08}", x % 100_000_000);
+ }
+ }
+}
diff --git a/notepatra-mcp/src/remote/pairing.rs b/notepatra-mcp/src/remote/pairing.rs
new file mode 100644
index 0000000..049e7cb
--- /dev/null
+++ b/notepatra-mcp/src/remote/pairing.rs
@@ -0,0 +1,321 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+//! One-shot pairing handshake. `serve` prints a one-time 8-digit code (short
+//! TTL, single-use, attempt-limited). The client proves knowledge of the code
+//! via HMAC-SHA256(code, server_nonce) — the code itself never crosses the
+//! wire. On success `serve` issues a 256-bit token (stored SHA-256-only).
+//!
+//! MITM is out of scope this phase (loopback-only ⇒ no untrusted network path);
+//! TLS + cert-pinning for LAN binding is Phase 3b.
+
+use std::collections::HashMap;
+use std::time::{Duration, Instant};
+
+use hmac::{Hmac, Mac};
+use sha2::Sha256;
+
+use super::scope::Scope;
+use super::{gen_pairing_code, random_bytes, random_hex, to_hex};
+
+type HmacSha256 = Hmac;
+
+/// Default pairing window: 120 s, 5 attempts, single successful use.
+pub const DEFAULT_TTL: Duration = Duration::from_secs(120);
+pub const DEFAULT_ATTEMPTS: u32 = 5;
+
+/// Ceiling on outstanding `/pair/start` nonces. `/pair/start` is unauthenticated,
+/// so a flood could otherwise grow `pending` without bound (memory DoS); we prune
+/// TTL-expired entries and evict the oldest at capacity. A legitimate pairer only
+/// ever needs one live nonce, so eviction cannot lock them out.
+const MAX_PENDING: usize = 64;
+
+/// A pairing failure (all forms collapse to a terse client message so a probe
+/// learns nothing beyond "try again / restart serve").
+#[derive(Debug, PartialEq, Eq)]
+pub enum PairError {
+ /// Consumed, expired, or attempts exhausted.
+ Closed,
+ /// Unknown/absent pair_id in `/pair/complete`.
+ UnknownPairId,
+ /// HMAC did not verify (wrong code).
+ BadMac,
+}
+
+impl PairError {
+ pub fn message(&self) -> &'static str {
+ match self {
+ PairError::Closed => "pairing closed; restart serve to pair again",
+ PairError::UnknownPairId => "unknown pairing session; call /pair/start first",
+ PairError::BadMac => "pairing failed (incorrect code)",
+ }
+ }
+}
+
+/// The nonce a `/pair/start` handed out, awaiting its `/pair/complete`.
+struct Pending {
+ nonce: [u8; 32],
+ created: Instant,
+}
+
+/// Server-side pairing session. Single code; multiple `/pair/start` nonces may
+/// be outstanding but the first correct `/pair/complete` consumes the session.
+pub struct PairingState {
+ code: String,
+ created: Instant,
+ ttl: Duration,
+ attempts_left: u32,
+ consumed: bool,
+ max_scope: Scope,
+ pending: HashMap,
+}
+
+impl PairingState {
+ /// Fresh session with a random code, printed by `serve`.
+ pub fn new(max_scope: Scope, ttl: Duration, attempts: u32) -> Self {
+ Self::with_code(gen_pairing_code(), max_scope, ttl, attempts)
+ }
+
+ /// Explicit-code constructor (tests, and any deterministic driver).
+ pub fn with_code(code: String, max_scope: Scope, ttl: Duration, attempts: u32) -> Self {
+ Self {
+ code,
+ created: Instant::now(),
+ ttl,
+ attempts_left: attempts,
+ consumed: false,
+ max_scope,
+ pending: HashMap::new(),
+ }
+ }
+
+ pub fn code(&self) -> &str {
+ &self.code
+ }
+
+ /// The `Instant` the session was created (tests derive an "expired" now).
+ pub fn created(&self) -> Instant {
+ self.created
+ }
+
+ pub fn max_scope(&self) -> Scope {
+ self.max_scope
+ }
+
+ fn open(&self, now: Instant) -> bool {
+ !self.consumed
+ && self.attempts_left > 0
+ && now.saturating_duration_since(self.created) < self.ttl
+ }
+
+ /// `/pair/start`: issues a fresh nonce bound to a new pair_id. Fails if the
+ /// session is closed. Returns `(pair_id, nonce_hex)`.
+ pub fn start(&mut self, now: Instant) -> Result<(String, String), PairError> {
+ if !self.open(now) {
+ return Err(PairError::Closed);
+ }
+ // Bound `pending`: drop TTL-expired nonces, then evict the oldest until
+ // there is room. Keeps an unauthenticated /pair/start flood from growing
+ // the map without limit.
+ self.pending
+ .retain(|_, p| now.saturating_duration_since(p.created) < self.ttl);
+ while self.pending.len() >= MAX_PENDING {
+ let Some(oldest) = self
+ .pending
+ .iter()
+ .min_by_key(|(_, p)| p.created)
+ .map(|(k, _)| k.clone())
+ else {
+ break;
+ };
+ self.pending.remove(&oldest);
+ }
+ let pair_id = random_hex(8); // 8 bytes → 16 hex
+ let mut nonce = [0u8; 32];
+ random_bytes(&mut nonce);
+ let nonce_hex = to_hex(&nonce);
+ self.pending.insert(
+ pair_id.clone(),
+ Pending {
+ nonce,
+ created: now,
+ },
+ );
+ Ok((pair_id, nonce_hex))
+ }
+
+ /// `/pair/complete`: verifies `mac_hex == HMAC(code, nonce)` in constant
+ /// time. Success consumes the session and returns the granted scope
+ /// (min of requested and `max_scope`). A wrong MAC decrements the attempt
+ /// budget and closes the session when it hits zero.
+ pub fn complete(
+ &mut self,
+ pair_id: &str,
+ mac_hex: &str,
+ requested: Scope,
+ now: Instant,
+ ) -> Result {
+ if !self.open(now) {
+ return Err(PairError::Closed);
+ }
+ let Some(pending) = self.pending.get(pair_id) else {
+ return Err(PairError::UnknownPairId);
+ };
+ let Some(mac_bytes) = hex_to_bytes(mac_hex) else {
+ self.charge_attempt();
+ return Err(PairError::BadMac);
+ };
+
+ let mut mac =
+ HmacSha256::new_from_slice(self.code.as_bytes()).expect("HMAC accepts any key length");
+ mac.update(&pending.nonce);
+ if mac.verify_slice(&mac_bytes).is_ok() {
+ // Single-use: consume the whole session, drop all pending nonces.
+ self.consumed = true;
+ self.pending.clear();
+ Ok(requested.min(self.max_scope))
+ } else {
+ self.charge_attempt();
+ Err(PairError::BadMac)
+ }
+ }
+
+ fn charge_attempt(&mut self) {
+ self.attempts_left = self.attempts_left.saturating_sub(1);
+ if self.attempts_left == 0 {
+ self.consumed = true;
+ self.pending.clear();
+ }
+ }
+}
+
+/// Client helper: `HMAC-SHA256(code, nonce_bytes)` as hex, for `/pair/complete`.
+pub fn client_mac(code: &str, nonce_hex: &str) -> Option {
+ let nonce = hex_to_bytes(nonce_hex)?;
+ let mut mac = HmacSha256::new_from_slice(code.as_bytes()).ok()?;
+ mac.update(&nonce);
+ Some(to_hex(&mac.finalize().into_bytes()))
+}
+
+fn hex_to_bytes(s: &str) -> Option> {
+ if !s.len().is_multiple_of(2) {
+ return None;
+ }
+ (0..s.len())
+ .step_by(2)
+ .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
+ .collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const CODE: &str = "48211937";
+
+ fn fresh() -> PairingState {
+ PairingState::with_code(
+ CODE.into(),
+ Scope::WriteRequest,
+ DEFAULT_TTL,
+ DEFAULT_ATTEMPTS,
+ )
+ }
+
+ // Drives a full correct handshake, returning the granted scope.
+ fn pair_ok(st: &mut PairingState, requested: Scope) -> Result {
+ let now = Instant::now();
+ let (pid, nonce) = st.start(now).unwrap();
+ let mac = client_mac(CODE, &nonce).unwrap();
+ st.complete(&pid, &mac, requested, now)
+ }
+
+ #[test]
+ fn correct_code_issues_token_scope() {
+ let mut st = fresh();
+ assert_eq!(
+ pair_ok(&mut st, Scope::WriteRequest),
+ Ok(Scope::WriteRequest)
+ );
+ }
+
+ #[test]
+ fn granted_scope_is_min_of_requested_and_max() {
+ let mut st =
+ PairingState::with_code(CODE.into(), Scope::ReadAct, DEFAULT_TTL, DEFAULT_ATTEMPTS);
+ // Requesting write_request but capped at read_act → read_act.
+ assert_eq!(pair_ok(&mut st, Scope::WriteRequest), Ok(Scope::ReadAct));
+ }
+
+ #[test]
+ fn wrong_code_rejected_and_charges_attempt() {
+ let mut st = fresh();
+ let now = Instant::now();
+ let (pid, nonce) = st.start(now).unwrap();
+ let bad = client_mac("00000000", &nonce).unwrap();
+ assert_eq!(
+ st.complete(&pid, &bad, Scope::ReadOnly, now),
+ Err(PairError::BadMac)
+ );
+ // Correct code still works afterward (attempts remain).
+ assert!(pair_ok(&mut st, Scope::ReadOnly).is_ok());
+ }
+
+ #[test]
+ fn attempts_exhaust_then_closed() {
+ let mut st = fresh();
+ let now = Instant::now();
+ for _ in 0..DEFAULT_ATTEMPTS {
+ let (pid, nonce) = st.start(now).unwrap();
+ let bad = client_mac("00000000", &nonce).unwrap();
+ assert_eq!(
+ st.complete(&pid, &bad, Scope::ReadOnly, now),
+ Err(PairError::BadMac)
+ );
+ }
+ // Session is now closed: even the correct code is rejected.
+ let now2 = Instant::now();
+ assert_eq!(st.start(now2), Err(PairError::Closed));
+ }
+
+ #[test]
+ fn single_use_second_complete_closed() {
+ let mut st = fresh();
+ assert!(pair_ok(&mut st, Scope::ReadOnly).is_ok());
+ // Any further pairing is refused.
+ let now = Instant::now();
+ assert_eq!(st.start(now), Err(PairError::Closed));
+ }
+
+ #[test]
+ fn expired_code_rejected() {
+ let mut st =
+ PairingState::with_code(CODE.into(), Scope::WriteRequest, Duration::from_secs(1), 5);
+ // A `now` past the TTL closes the window without any real sleep.
+ let expired = st.created() + Duration::from_secs(2);
+ assert_eq!(st.start(expired), Err(PairError::Closed));
+ }
+
+ #[test]
+ fn pending_map_is_bounded_under_start_flood() {
+ let mut st = fresh();
+ let now = Instant::now();
+ // Far more starts than the cap: the map must never exceed MAX_PENDING.
+ for _ in 0..(MAX_PENDING * 4) {
+ st.start(now).unwrap();
+ assert!(st.pending.len() <= MAX_PENDING);
+ }
+ // A correct handshake against the newest nonce still succeeds.
+ let (pid, nonce) = st.start(now).unwrap();
+ let mac = client_mac(CODE, &nonce).unwrap();
+ assert!(st.complete(&pid, &mac, Scope::ReadOnly, now).is_ok());
+ }
+
+ #[test]
+ fn unknown_pair_id_rejected() {
+ let mut st = fresh();
+ let now = Instant::now();
+ assert_eq!(
+ st.complete("deadbeef", "00", Scope::ReadOnly, now),
+ Err(PairError::UnknownPairId)
+ );
+ }
+}
diff --git a/notepatra-mcp/src/remote/scope.rs b/notepatra-mcp/src/remote/scope.rs
new file mode 100644
index 0000000..d86c6f8
--- /dev/null
+++ b/notepatra-mcp/src/remote/scope.rs
@@ -0,0 +1,160 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+//! Scope + tier model. The tier partition is derived ENTIRELY from
+//! `tools::{READ_TOOLS, ACT_TOOLS, WRITE_TOOLS}` (already `pub`, already
+//! partition-tested in tests/protocol.rs), so scope enforcement stays SSOT-
+//! driven: a future untiered tool fails the existing partition test before it
+//! could dodge the gate here.
+
+use serde_json::Value;
+
+use crate::tools::{ACT_TOOLS, READ_TOOLS, WRITE_TOOLS};
+
+/// A tool's capability tier, ordered Read < Act < Write.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+pub enum Tier {
+ Read,
+ Act,
+ Write,
+}
+
+/// A connection's granted scope. Each scope admits its tier and every lower
+/// one: read_only ⊂ read_act ⊂ write_request.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+pub enum Scope {
+ ReadOnly,
+ ReadAct,
+ WriteRequest,
+}
+
+impl Scope {
+ /// Wire token: `"read_only" | "read_act" | "write_request"`.
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Scope::ReadOnly => "read_only",
+ Scope::ReadAct => "read_act",
+ Scope::WriteRequest => "write_request",
+ }
+ }
+
+ pub fn parse(s: &str) -> Option {
+ match s {
+ "read_only" => Some(Scope::ReadOnly),
+ "read_act" => Some(Scope::ReadAct),
+ "write_request" => Some(Scope::WriteRequest),
+ _ => None,
+ }
+ }
+
+ /// Highest tier this scope may invoke.
+ fn max_tier(self) -> Tier {
+ match self {
+ Scope::ReadOnly => Tier::Read,
+ Scope::ReadAct => Tier::Act,
+ Scope::WriteRequest => Tier::Write,
+ }
+ }
+
+ /// Whether a tool of `tier` is allowed at this scope.
+ pub fn allows(self, tier: Tier) -> bool {
+ tier <= self.max_tier()
+ }
+}
+
+/// The tier of `name`, or `None` if it is not a known tool (unknown tools are
+/// forwarded and answered "Unknown tool" by the server — no elevation possible).
+pub fn tier_of(name: &str) -> Option {
+ if READ_TOOLS.contains(&name) {
+ Some(Tier::Read)
+ } else if ACT_TOOLS.contains(&name) {
+ Some(Tier::Act)
+ } else if WRITE_TOOLS.contains(&name) {
+ Some(Tier::Write)
+ } else {
+ None
+ }
+}
+
+/// Filters a `tools/list` result in place, dropping every tool whose tier
+/// exceeds `scope`. Enforcement at dispatch (the gateway rejecting an
+/// over-scope `tools/call`) is the real gate; this is honesty in advertising.
+pub fn filter_tools_list(scope: Scope, resp: &mut Value) {
+ if let Some(tools) = resp
+ .get_mut("result")
+ .and_then(|r| r.get_mut("tools"))
+ .and_then(Value::as_array_mut)
+ {
+ tools.retain(|t| {
+ let name = t.get("name").and_then(Value::as_str).unwrap_or("");
+ // An untiered name (shouldn't happen — partition-tested) is hidden
+ // rather than advertised, matching the fail-closed dispatch rule.
+ tier_of(name)
+ .map(|tier| scope.allows(tier))
+ .unwrap_or(false)
+ });
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::tools::definitions;
+
+ #[test]
+ fn every_tool_is_tiered() {
+ let defs = definitions();
+ for t in defs.as_array().expect("tools array") {
+ let name = t.get("name").and_then(Value::as_str).expect("name");
+ assert!(tier_of(name).is_some(), "tool {name} has no tier");
+ }
+ }
+
+ #[test]
+ fn allow_matrix() {
+ // read_only: read only.
+ assert!(Scope::ReadOnly.allows(Tier::Read));
+ assert!(!Scope::ReadOnly.allows(Tier::Act));
+ assert!(!Scope::ReadOnly.allows(Tier::Write));
+ // read_act: +act.
+ assert!(Scope::ReadAct.allows(Tier::Read));
+ assert!(Scope::ReadAct.allows(Tier::Act));
+ assert!(!Scope::ReadAct.allows(Tier::Write));
+ // write_request: everything.
+ assert!(Scope::WriteRequest.allows(Tier::Read));
+ assert!(Scope::WriteRequest.allows(Tier::Act));
+ assert!(Scope::WriteRequest.allows(Tier::Write));
+ }
+
+ #[test]
+ fn filter_sizes_are_cumulative() {
+ let full = serde_json::json!({ "result": { "tools": definitions() } });
+ let total = definitions().as_array().unwrap().len();
+ assert_eq!(total, 48);
+
+ for (scope, want) in [
+ (Scope::ReadOnly, READ_TOOLS.len()),
+ (Scope::ReadAct, READ_TOOLS.len() + ACT_TOOLS.len()),
+ (Scope::WriteRequest, total),
+ ] {
+ let mut r = full.clone();
+ filter_tools_list(scope, &mut r);
+ let n = r["result"]["tools"].as_array().unwrap().len();
+ assert_eq!(
+ n,
+ want,
+ "scope {} filtered to {n}, want {want}",
+ scope.as_str()
+ );
+ }
+ // Concretely: 24 / 37 / 48.
+ assert_eq!(READ_TOOLS.len(), 24);
+ assert_eq!(READ_TOOLS.len() + ACT_TOOLS.len(), 37);
+ }
+
+ #[test]
+ fn scope_roundtrip() {
+ for s in [Scope::ReadOnly, Scope::ReadAct, Scope::WriteRequest] {
+ assert_eq!(Scope::parse(s.as_str()), Some(s));
+ }
+ assert_eq!(Scope::parse("bogus"), None);
+ }
+}
diff --git a/notepatra-mcp/src/remote/token.rs b/notepatra-mcp/src/remote/token.rs
new file mode 100644
index 0000000..b1ed9dc
--- /dev/null
+++ b/notepatra-mcp/src/remote/token.rs
@@ -0,0 +1,320 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+//! Token storage. The SERVER persists ONLY the SHA-256 of each issued token;
+//! the plaintext lives only on the pairing CLIENT. Both files are 0600 (dir
+//! 0700) on Unix. On Windows the per-user %APPDATA% ACLs already exclude other
+//! users; on top of that, `icacls` strips inheritance and re-grants only the
+//! owning account (best-effort, failures ignored — same posture as the Unix
+//! `let _ = set_permissions`). No token is ever logged or placed in argv.
+
+use std::fs::{self, OpenOptions};
+use std::io::{self, BufRead, BufReader, Write};
+use std::path::{Path, PathBuf};
+
+use serde_json::{json, Value};
+
+use super::scope::Scope;
+use super::{random_hex, sha256_hex};
+
+/// Config root (parent of the `mcp-remote` dir). The definition moved to the
+/// UNGATED [`crate::config_dir`] once endpoint discovery — a DEFAULT-build
+/// concern — needed the same directory; re-exported here so
+/// `remote::token::config_root` keeps working for the gateway.
+pub use crate::config_dir::config_root;
+
+/// One authorized token record, server side.
+#[derive(Debug, Clone)]
+pub struct ClientToken {
+ pub token: String,
+ pub scope: Scope,
+}
+
+/// Reads/writes the two token files under `/mcp-remote/`.
+pub struct TokenStore {
+ dir: PathBuf,
+}
+
+impl TokenStore {
+ /// Store rooted directly at `dir` (created if needed). Tests pass a tempdir.
+ pub fn at(dir: impl Into) -> io::Result {
+ let dir = dir.into();
+ fs::create_dir_all(&dir)?;
+ set_dir_private(&dir);
+ Ok(Self { dir })
+ }
+
+ /// The real store: `/mcp-remote/`.
+ pub fn from_env() -> io::Result {
+ Self::at(config_root().join("mcp-remote"))
+ }
+
+ fn authorized_path(&self) -> PathBuf {
+ self.dir.join("authorized_tokens.jsonl")
+ }
+
+ fn client_path(&self) -> PathBuf {
+ self.dir.join("client_tokens.jsonl")
+ }
+
+ // ── Server side ──────────────────────────────────────────────────────────
+
+ /// Generates a 256-bit token, persists ONLY its SHA-256 with `scope`, and
+ /// returns the plaintext (the caller hands it to the pairing client and
+ /// then drops it — it is never stored server-side).
+ pub fn issue(&self, scope: Scope) -> io::Result {
+ let token = random_hex(32); // 32 bytes → 64 hex
+ let rec = json!({
+ "sha256": sha256_hex(token.as_bytes()),
+ "scope": scope.as_str(),
+ "created": unix_now(),
+ });
+ append_line(&self.authorized_path(), &rec.to_string())?;
+ Ok(token)
+ }
+
+ /// Resolves a presented token to its granted scope by SHA-256 lookup, or
+ /// `None` if unknown (the caller then fails closed to read_only).
+ pub fn lookup(&self, token: &str) -> io::Result> {
+ let want = sha256_hex(token.as_bytes());
+ let path = self.authorized_path();
+ if !path.exists() {
+ return Ok(None);
+ }
+ for line in read_lines(&path)? {
+ let v: Value = match serde_json::from_str(&line) {
+ Ok(v) => v,
+ Err(_) => continue,
+ };
+ if v.get("sha256").and_then(Value::as_str) == Some(want.as_str()) {
+ return Ok(v
+ .get("scope")
+ .and_then(Value::as_str)
+ .and_then(Scope::parse));
+ }
+ }
+ Ok(None)
+ }
+
+ // ── Client side ──────────────────────────────────────────────────────────
+
+ /// Stores the plaintext token for `url` (0600). Last write wins on lookup.
+ pub fn store_client(&self, url: &str, token: &str, scope: Scope) -> io::Result<()> {
+ let rec = json!({ "url": url, "token": token, "scope": scope.as_str() });
+ append_line(&self.client_path(), &rec.to_string())
+ }
+
+ /// The most recently stored token for `url`, if any.
+ pub fn load_client(&self, url: &str) -> io::Result > {
+ let path = self.client_path();
+ if !path.exists() {
+ return Ok(None);
+ }
+ let mut found = None;
+ for line in read_lines(&path)? {
+ let v: Value = match serde_json::from_str(&line) {
+ Ok(v) => v,
+ Err(_) => continue,
+ };
+ if v.get("url").and_then(Value::as_str) == Some(url) {
+ if let (Some(t), Some(s)) = (
+ v.get("token").and_then(Value::as_str),
+ v.get("scope")
+ .and_then(Value::as_str)
+ .and_then(Scope::parse),
+ ) {
+ found = Some(ClientToken {
+ token: t.to_string(),
+ scope: s,
+ });
+ }
+ }
+ }
+ Ok(found)
+ }
+}
+
+fn unix_now() -> u64 {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_secs())
+ .unwrap_or(0)
+}
+
+/// Appends one newline-terminated line to a 0600 file (created private).
+fn append_line(path: &Path, line: &str) -> io::Result<()> {
+ let mut opts = OpenOptions::new();
+ opts.create(true).append(true);
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::OpenOptionsExt;
+ opts.mode(0o600);
+ }
+ let mut f = opts.open(path)?;
+ // Re-assert 0600 even if the file pre-existed with looser perms.
+ set_file_private(path);
+ f.write_all(line.as_bytes())?;
+ f.write_all(b"\n")?;
+ f.flush()
+}
+
+fn read_lines(path: &Path) -> io::Result> {
+ let f = fs::File::open(path)?;
+ BufReader::new(f).lines().collect()
+}
+
+#[cfg(unix)]
+fn set_dir_private(dir: &Path) {
+ use std::os::unix::fs::PermissionsExt;
+ let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o700));
+}
+/// Windows defense-in-depth: `%APPDATA%` is already per-user, but an explicit
+/// ACL removes inherited grants (BUILTIN\Users, Everyone) that a customized
+/// profile root can still propagate. `icacls` ships with Windows, so this
+/// costs ZERO new dependencies — the alternative is a winapi crate, which the
+/// std-only rule forbids. Best-effort by design: any failure leaves the
+/// inherited (per-user) ACL in place, exactly as the Unix arm ignores a failed
+/// `set_permissions`.
+#[cfg(windows)]
+fn set_dir_private(dir: &Path) {
+ let Ok(user) = std::env::var("USERNAME") else {
+ return;
+ };
+ // /inheritance:r drops inherited ACEs; /grant:r replaces (not appends) the
+ // user's ACE. (OI)(CI)F = full control, inherited by files and subdirs.
+ let _ = std::process::Command::new("icacls")
+ .arg(dir)
+ .arg("/inheritance:r")
+ .arg("/grant:r")
+ .arg(format!("{user}:(OI)(CI)F"))
+ .output();
+}
+
+#[cfg(not(any(unix, windows)))]
+fn set_dir_private(_dir: &Path) {}
+
+#[cfg(unix)]
+fn set_file_private(path: &Path) {
+ use std::os::unix::fs::PermissionsExt;
+ let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
+}
+/// File counterpart of [`set_dir_private`] — no inheritance flags, since a
+/// file has nothing to propagate to.
+#[cfg(windows)]
+fn set_file_private(path: &Path) {
+ let Ok(user) = std::env::var("USERNAME") else {
+ return;
+ };
+ let _ = std::process::Command::new("icacls")
+ .arg(path)
+ .arg("/inheritance:r")
+ .arg("/grant:r")
+ .arg(format!("{user}:F"))
+ .output();
+}
+
+#[cfg(not(any(unix, windows)))]
+fn set_file_private(_path: &Path) {}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn tmp() -> PathBuf {
+ let mut p = std::env::temp_dir();
+ p.push(format!(
+ "np-mcp-tok-{}-{}",
+ std::process::id(),
+ random_hex(6)
+ ));
+ p
+ }
+
+ #[test]
+ fn only_sha256_persisted_server_side() {
+ let store = TokenStore::at(tmp()).unwrap();
+ let token = store.issue(Scope::WriteRequest).unwrap();
+ let raw = fs::read_to_string(store.authorized_path()).unwrap();
+ // Plaintext token must NOT appear in the server file; its hash must.
+ assert!(
+ !raw.contains(&token),
+ "plaintext token leaked into server file"
+ );
+ assert!(raw.contains(&sha256_hex(token.as_bytes())));
+ }
+
+ #[test]
+ fn lookup_roundtrip() {
+ let store = TokenStore::at(tmp()).unwrap();
+ let t = store.issue(Scope::ReadAct).unwrap();
+ assert_eq!(store.lookup(&t).unwrap(), Some(Scope::ReadAct));
+ assert_eq!(store.lookup("deadbeef").unwrap(), None);
+ }
+
+ #[test]
+ fn client_store_roundtrip() {
+ let store = TokenStore::at(tmp()).unwrap();
+ store
+ .store_client("http://127.0.0.1:9", "tok", Scope::ReadOnly)
+ .unwrap();
+ let got = store.load_client("http://127.0.0.1:9").unwrap().unwrap();
+ assert_eq!(got.token, "tok");
+ assert_eq!(got.scope, Scope::ReadOnly);
+ assert!(store.load_client("http://elsewhere").unwrap().is_none());
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn files_are_0600() {
+ use std::os::unix::fs::PermissionsExt;
+ let store = TokenStore::at(tmp()).unwrap();
+ store.issue(Scope::ReadOnly).unwrap();
+ store
+ .store_client("http://x", "t", Scope::ReadOnly)
+ .unwrap();
+ for p in [store.authorized_path(), store.client_path()] {
+ let mode = fs::metadata(&p).unwrap().permissions().mode() & 0o777;
+ assert_eq!(mode, 0o600, "{p:?} mode was {mode:o}");
+ }
+ let dmode = fs::metadata(&store.dir).unwrap().permissions().mode() & 0o777;
+ assert_eq!(dmode, 0o700);
+ }
+
+ /// Windows counterpart of `files_are_0600`. Two assertions: the ACL work
+ /// must not break normal I/O (a botched `icacls` could lock the process
+ /// out of its own files), and when `icacls` is queryable the resulting ACL
+ /// must not list the broad principals that inheritance would have added.
+ /// Skips the ACL assertion when `icacls` itself is unavailable so a locked
+ /// down CI image reports a pass, not a spurious failure.
+ #[cfg(windows)]
+ #[test]
+ fn windows_acls_do_not_break_io_and_drop_broad_principals() {
+ let store = TokenStore::at(tmp()).unwrap();
+ let t = store.issue(Scope::ReadAct).unwrap();
+ store
+ .store_client("http://127.0.0.1:9", "tok", Scope::ReadOnly)
+ .unwrap();
+ assert_eq!(store.lookup(&t).unwrap(), Some(Scope::ReadAct));
+ assert_eq!(
+ store
+ .load_client("http://127.0.0.1:9")
+ .unwrap()
+ .unwrap()
+ .token,
+ "tok"
+ );
+
+ let path = store.authorized_path();
+ if let Ok(out) = std::process::Command::new("icacls").arg(&path).output() {
+ if out.status.success() {
+ let acl = String::from_utf8_lossy(&out.stdout);
+ assert!(
+ !acl.contains("BUILTIN\\Users"),
+ "inherited BUILTIN\\Users ACE survived: {acl}"
+ );
+ assert!(
+ !acl.contains("Everyone"),
+ "Everyone ACE present on a token file: {acl}"
+ );
+ }
+ }
+ }
+}
diff --git a/notepatra-mcp/src/tools.rs b/notepatra-mcp/src/tools.rs
index 8e6c370..20545e5 100644
--- a/notepatra-mcp/src/tools.rs
+++ b/notepatra-mcp/src/tools.rs
@@ -9,6 +9,67 @@ const DEFAULT_MAX_RESULTS: usize = 50;
/// client-side instead of asking for results that cannot come back.
const MAX_RESULTS_CAP: usize = 200;
+/// Tier partition of the tool surface. get_capabilities reports these sizes;
+/// tests/protocol.rs asserts the three lists partition definitions() exactly,
+/// so a new tool that isn't tiered here fails the suite.
+pub const READ_TOOLS: &[&str] = &[
+ "list_open_tabs",
+ "read_tab",
+ "get_selection",
+ "get_status",
+ "app_info",
+ "list_recent_files",
+ "find_in_tab",
+ "search_project",
+ "list_notes",
+ "read_note",
+ "list_reminders",
+ "git_status",
+ "git_diff",
+ "git_log",
+ "git_show",
+ "git_branch",
+ "validate_npd",
+ "run_sql",
+ "list_languages",
+ "get_capabilities",
+ "get_diagram_source",
+ // phase 2
+ "list_connections",
+ "run_query",
+ "list_tables",
+];
+pub const ACT_TOOLS: &[&str] = &[
+ "open_file",
+ "new_tab",
+ "goto_line",
+ "set_language",
+ "compare_tabs",
+ "format_json",
+ "format_sql",
+ "format_html",
+ "open_note",
+ "create_diagram",
+ "open_noter",
+ // phase 2
+ "open_data_analyst",
+ "render_chart",
+];
+pub const WRITE_TOOLS: &[&str] = &[
+ "insert_text",
+ "replace_selection",
+ "apply_edit",
+ "save_tab",
+ "create_note",
+ "append_note",
+ "set_reminder",
+ "export_diagram",
+ "set_diagram_source",
+ // phase 2
+ "export_query_results",
+ "export_chart",
+];
+
/// Mandatory sentence on every write tool's description: these tools are
/// gated behind an in-editor human approval card.
const APPROVAL_NOTE: &str = "Requires the user to click Approve on a card inside Notepatra; \
@@ -429,6 +490,155 @@ pub fn definitions() -> Value {
"required": ["tab_index", "path", "format"],
"additionalProperties": false
}
+ },
+ // ── p0a read tier ───────────────────────────────────────────────
+ {
+ "name": "list_languages",
+ "description": "List the canonical language names Notepatra's syntax highlighting accepts (exactly as shown in the Language menu). Use before set_language when unsure of the exact name.",
+ "inputSchema": no_args_schema()
+ },
+ {
+ "name": "get_capabilities",
+ "description": "Get the editor's capability profile: edition (Lite/Full), platform, version, tool count, tool tiers, and feature flags (duckdb, webengine, noter). Use to self-diagnose what this Notepatra build supports before relying on edition-specific tools like run_sql over CSV.",
+ "inputSchema": no_args_schema()
+ },
+ // ── phase 1: diagram control + Noter panel ─────────────────────
+ {
+ "name": "create_diagram",
+ "description": "Create a new Notepatra Diagram (.npd) tab in the editor, optionally pre-filled with source, and focus it. The tab is created even if the source has parse errors (the canvas shows its parse state); the result reports valid plus any errors with line numbers. Use set_diagram_source to edit it and export_diagram to render it to a file.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "source": { "type": "string", "description": "Initial .npd document text (default: an empty diagram)" },
+ "title": { "type": "string", "description": "Tab title (default \"Diagram\")" }
+ },
+ "required": [],
+ "additionalProperties": false
+ }
+ },
+ {
+ "name": "get_diagram_source",
+ "description": "Read the .npd source of an open Diagram tab by index. Errors if the tab is not a Diagram tab; use list_open_tabs to find it.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "tab_index": { "type": "integer", "minimum": 0, "description": "Zero-based index of the Diagram tab" }
+ },
+ "required": ["tab_index"],
+ "additionalProperties": false
+ }
+ },
+ {
+ "name": "set_diagram_source",
+ "description": format!(
+ "Replace the entire .npd source of an open Diagram tab; the canvas \
+ re-renders immediately and the result reports valid plus any parse \
+ errors. {APPROVAL_NOTE}"
+ ),
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "tab_index": { "type": "integer", "minimum": 0, "description": "Zero-based index of the Diagram tab" },
+ "source": { "type": "string", "description": "The new .npd document text (replaces the whole source)" }
+ },
+ "required": ["tab_index", "source"],
+ "additionalProperties": false
+ }
+ },
+ {
+ "name": "open_noter",
+ "description": "Open (or focus) the Noter panel tab in the editor — the same surface the user gets from the Noter menu. Use before pointing the user at their notes.",
+ "inputSchema": no_args_schema()
+ },
+ // ── phase 2: data-analyst + charts ─────────────────────────────
+ {
+ "name": "list_connections",
+ "description": "List the user's SAVED database connections (name, driver, database, read_only) — never passwords. These are the named PostgreSQL / MySQL / SQL Server / SQLite / DuckDB connections managed in the Data Analyst panel, which run_query can read but run_sql cannot reach. Use before run_query or list_tables to discover which connections exist.",
+ "inputSchema": no_args_schema()
+ },
+ {
+ "name": "run_query",
+ "description": "Run a read-only SQL query against a SAVED named connection (PostgreSQL / MySQL / SQL Server / SQLite / DuckDB) and return columns and rows. SELECT-only: mutations (INSERT/UPDATE/DELETE/DDL) are rejected by the editor. Passwords are never involved. Unlike run_sql, which only reaches the built-in scratch engine, this reaches the user's real saved databases. DuckDB-driver connections require the Full edition.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "connection_name": { "type": "string", "description": "Name of a saved connection (see list_connections)" },
+ "sql": { "type": "string", "description": "A read-only SQL SELECT (or WITH) statement; non-read statements are rejected by the editor" },
+ "max_rows": { "type": "integer", "minimum": 1, "description": "Maximum rows to return (default 200; clamped to the editor's 200-row cap)" }
+ },
+ "required": ["connection_name", "sql"],
+ "additionalProperties": false
+ }
+ },
+ {
+ "name": "list_tables",
+ "description": "List the user tables available over a saved named connection (see list_connections). Use to discover the schema before writing a run_query SELECT.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "connection_name": { "type": "string", "description": "Name of a saved connection (see list_connections)" }
+ },
+ "required": ["connection_name"],
+ "additionalProperties": false
+ }
+ },
+ {
+ "name": "open_data_analyst",
+ "description": "Reveal and focus the editor's AI dock in Data Analyst mode — the surface for querying saved connections and rendering charts. Use to bring the Data Analyst on screen for the user.",
+ "inputSchema": no_args_schema()
+ },
+ {
+ "name": "render_chart",
+ "description": "Render a chart inline in the Data Analyst transcript and return its id. The spec may be a Vega-Lite v5 spec, or the simplified {type,x,y,data} form the editor translates. Requires the Full edition (WebEngine); the Lite edition returns an error.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "spec": { "type": "object", "description": "A Vega-Lite v5 spec, or the simplified {type,x,y,data} form the editor translates" },
+ "title": { "type": "string", "description": "Optional title shown above the chart card" }
+ },
+ "required": ["spec"],
+ "additionalProperties": false
+ }
+ },
+ {
+ "name": "export_query_results",
+ "description": format!(
+ "Run a read-only SELECT against a saved named connection and write the \
+ results to a file on disk as CSV or JSON. Mutations are rejected. \
+ {APPROVAL_NOTE}"
+ ),
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "connection_name": { "type": "string", "description": "Name of a saved connection (see list_connections)" },
+ "sql": { "type": "string", "description": "A read-only SQL SELECT (or WITH) statement" },
+ "path": { "type": "string", "description": "Absolute output path for the exported file" },
+ "format": { "type": "string", "enum": ["csv", "json"], "description": "Serialization format: csv or json" },
+ "max_rows": { "type": "integer", "minimum": 1, "description": "Maximum rows to export (default 10000; capped at 100000)" }
+ },
+ "required": ["connection_name", "sql", "path", "format"],
+ "additionalProperties": false
+ }
+ },
+ {
+ "name": "export_chart",
+ "description": format!(
+ "Render a chart off-screen and export it to a file on disk as PNG, SVG, \
+ self-contained HTML, or the Vega-Lite spec JSON. The spec may be a \
+ Vega-Lite v5 spec or the simplified {{type,x,y,data}} form. Requires the \
+ Full edition (WebEngine). {APPROVAL_NOTE}"
+ ),
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "spec": { "type": "object", "description": "A Vega-Lite v5 spec, or the simplified {type,x,y,data} form the editor translates" },
+ "path": { "type": "string", "description": "Absolute output path for the exported file" },
+ "format": { "type": "string", "enum": ["png", "svg", "html", "spec"], "description": "Export format: png, svg, html, or spec (Vega-Lite JSON)" },
+ "scale": { "type": "integer", "minimum": 1, "maximum": 4, "description": "PNG raster scale factor 1-4 (default 2; ignored for non-png formats)" }
+ },
+ "required": ["spec", "path", "format"],
+ "additionalProperties": false
+ }
}
])
}
@@ -475,6 +685,22 @@ pub fn call(
"append_note" => append_note(transport, args),
"set_reminder" => set_reminder(transport, args),
"export_diagram" => export_diagram(transport, args),
+ // p0a
+ "list_languages" => no_arg_json(args, || transport.list_languages()),
+ "get_capabilities" => get_capabilities(transport, args),
+ // phase 1
+ "create_diagram" => create_diagram(transport, args),
+ "get_diagram_source" => get_diagram_source(transport, args),
+ "set_diagram_source" => set_diagram_source(transport, args),
+ "open_noter" => open_noter(transport, args),
+ // phase 2
+ "list_connections" => no_arg_json(args, || transport.list_connections()),
+ "run_query" => run_query(transport, args),
+ "list_tables" => list_tables(transport, args),
+ "open_data_analyst" => no_arg_json(args, || transport.open_data_analyst()),
+ "render_chart" => render_chart(transport, args),
+ "export_query_results" => export_query_results(transport, args),
+ "export_chart" => export_chart(transport, args),
other => CallOutcome::UnknownTool(other.to_string()),
}
}
@@ -1029,3 +1255,232 @@ fn export_diagram(transport: &mut dyn EditorTransport, args: &Map
}
to_outcome(transport.export_diagram(tab_index, path, format))
}
+
+// ── p0a read-tier handlers ──────────────────────────────────────────────────
+
+fn get_capabilities(transport: &mut dyn EditorTransport, args: &Map) -> CallOutcome {
+ if let Err(e) = reject_extras(args, &[]) {
+ return e;
+ }
+ match transport.get_capabilities() {
+ Ok(mut v) => {
+ // tool_count and tiers are DERIVED here, where the tool surface
+ // lives — the editor cannot know the sidecar's tool list.
+ if let Value::Object(m) = &mut v {
+ let count = definitions().as_array().map_or(0, |a| a.len());
+ m.insert("tool_count".into(), json!(count));
+ m.insert(
+ "tiers".into(),
+ json!({
+ "read": READ_TOOLS.len(),
+ "act": ACT_TOOLS.len(),
+ "write": WRITE_TOOLS.len(),
+ }),
+ );
+ }
+ json_text(&v)
+ }
+ Err(e) => CallOutcome::ToolError(e.0),
+ }
+}
+
+// ── Phase 1 handlers ────────────────────────────────────────────────────────
+
+fn create_diagram(transport: &mut dyn EditorTransport, args: &Map) -> CallOutcome {
+ if let Err(e) = reject_extras(args, &["source", "title"]) {
+ return e;
+ }
+ let source = match optional_str(args, "source") {
+ Ok(s) => s,
+ Err(e) => return e,
+ };
+ let title = match optional_str(args, "title") {
+ Ok(t) => t,
+ Err(e) => return e,
+ };
+ to_outcome(transport.create_diagram(source, title))
+}
+
+fn get_diagram_source(
+ transport: &mut dyn EditorTransport,
+ args: &Map,
+) -> CallOutcome {
+ if let Err(e) = reject_extras(args, &["tab_index"]) {
+ return e;
+ }
+ let tab_index = match required_index(args, "tab_index") {
+ Ok(i) => i,
+ Err(e) => return e,
+ };
+ to_outcome(transport.get_diagram_source(tab_index))
+}
+
+// Approval-gated in the editor; deny/timeout errors pass through verbatim.
+fn set_diagram_source(
+ transport: &mut dyn EditorTransport,
+ args: &Map,
+) -> CallOutcome {
+ if let Err(e) = reject_extras(args, &["tab_index", "source"]) {
+ return e;
+ }
+ let tab_index = match required_index(args, "tab_index") {
+ Ok(i) => i,
+ Err(e) => return e,
+ };
+ let source = match required_str(args, "source") {
+ Ok(s) => s,
+ Err(e) => return e,
+ };
+ to_outcome(transport.set_diagram_source(tab_index, source))
+}
+
+fn open_noter(transport: &mut dyn EditorTransport, args: &Map) -> CallOutcome {
+ if let Err(e) = reject_extras(args, &[]) {
+ return e;
+ }
+ to_outcome(transport.open_noter())
+}
+
+// ── Phase 2 handlers — data-analyst + charts ────────────────────────────────
+
+/// run_query's `max_rows` cap mirrors the editor's 200-row ceiling.
+const RUN_QUERY_ROWS_CAP: usize = 200;
+/// export_query_results hard cap (mirrors the bridge's kExportRowsMax).
+const EXPORT_ROWS_CAP: usize = 100_000;
+
+fn required_object<'a>(
+ args: &'a Map,
+ key: &str,
+) -> Result<&'a Map, CallOutcome> {
+ match args.get(key) {
+ Some(Value::Object(o)) => Ok(o),
+ Some(_) => Err(CallOutcome::InvalidParams(format!(
+ "{key} must be an object"
+ ))),
+ None => Err(CallOutcome::InvalidParams(format!("{key} is required"))),
+ }
+}
+
+fn run_query(transport: &mut dyn EditorTransport, args: &Map) -> CallOutcome {
+ if let Err(e) = reject_extras(args, &["connection_name", "sql", "max_rows"]) {
+ return e;
+ }
+ let connection_name = match required_str(args, "connection_name") {
+ Ok(c) => c,
+ Err(e) => return e,
+ };
+ let sql = match required_str(args, "sql") {
+ Ok(s) => s,
+ Err(e) => return e,
+ };
+ let max_rows = match args.get("max_rows") {
+ None => None,
+ Some(v) => match v.as_u64() {
+ Some(n) if n >= 1 => Some((n as usize).min(RUN_QUERY_ROWS_CAP)),
+ _ => return CallOutcome::InvalidParams("max_rows must be an integer >= 1".into()),
+ },
+ };
+ to_outcome(transport.run_query(connection_name, sql, max_rows))
+}
+
+fn list_tables(transport: &mut dyn EditorTransport, args: &Map) -> CallOutcome {
+ if let Err(e) = reject_extras(args, &["connection_name"]) {
+ return e;
+ }
+ let connection_name = match required_str(args, "connection_name") {
+ Ok(c) => c,
+ Err(e) => return e,
+ };
+ to_outcome(transport.list_tables(connection_name))
+}
+
+fn render_chart(transport: &mut dyn EditorTransport, args: &Map) -> CallOutcome {
+ if let Err(e) = reject_extras(args, &["spec", "title"]) {
+ return e;
+ }
+ if let Err(e) = required_object(args, "spec") {
+ return e;
+ }
+ let spec = args.get("spec").cloned().unwrap_or(Value::Null);
+ let title = match optional_str(args, "title") {
+ Ok(t) => t,
+ Err(e) => return e,
+ };
+ to_outcome(transport.render_chart(&spec, title))
+}
+
+// Approval-gated in the editor; deny/timeout errors pass through verbatim.
+fn export_query_results(
+ transport: &mut dyn EditorTransport,
+ args: &Map,
+) -> CallOutcome {
+ if let Err(e) = reject_extras(
+ args,
+ &["connection_name", "sql", "path", "format", "max_rows"],
+ ) {
+ return e;
+ }
+ let connection_name = match required_str(args, "connection_name") {
+ Ok(c) => c,
+ Err(e) => return e,
+ };
+ let sql = match required_str(args, "sql") {
+ Ok(s) => s,
+ Err(e) => return e,
+ };
+ let path = match required_str(args, "path") {
+ Ok(p) => p,
+ Err(e) => return e,
+ };
+ let format = match required_str(args, "format") {
+ Ok(f) => f,
+ Err(e) => return e,
+ };
+ if format != "csv" && format != "json" {
+ return CallOutcome::InvalidParams("format must be \"csv\" or \"json\"".into());
+ }
+ let max_rows = match args.get("max_rows") {
+ None => None,
+ Some(v) => match v.as_u64() {
+ Some(n) if n >= 1 => Some((n as usize).min(EXPORT_ROWS_CAP)),
+ _ => return CallOutcome::InvalidParams("max_rows must be an integer >= 1".into()),
+ },
+ };
+ to_outcome(transport.export_query_results(connection_name, sql, path, format, max_rows))
+}
+
+// Approval-gated in the editor; deny/timeout errors pass through verbatim.
+fn export_chart(transport: &mut dyn EditorTransport, args: &Map) -> CallOutcome {
+ if let Err(e) = reject_extras(args, &["spec", "path", "format", "scale"]) {
+ return e;
+ }
+ if let Err(e) = required_object(args, "spec") {
+ return e;
+ }
+ let spec = args.get("spec").cloned().unwrap_or(Value::Null);
+ let path = match required_str(args, "path") {
+ Ok(p) => p,
+ Err(e) => return e,
+ };
+ let format = match required_str(args, "format") {
+ Ok(f) => f,
+ Err(e) => return e,
+ };
+ if !["png", "svg", "html", "spec"].contains(&format) {
+ return CallOutcome::InvalidParams(
+ "format must be \"png\", \"svg\", \"html\", or \"spec\"".into(),
+ );
+ }
+ let scale = match args.get("scale") {
+ None => None,
+ Some(v) => match v.as_u64() {
+ Some(n) if (1..=4).contains(&n) => Some(n as usize),
+ _ => {
+ return CallOutcome::InvalidParams(
+ "scale must be an integer between 1 and 4".into(),
+ )
+ }
+ },
+ };
+ to_outcome(transport.export_chart(&spec, path, format, scale))
+}
diff --git a/notepatra-mcp/src/transport/endpoint.rs b/notepatra-mcp/src/transport/endpoint.rs
new file mode 100644
index 0000000..25393ea
--- /dev/null
+++ b/notepatra-mcp/src/transport/endpoint.rs
@@ -0,0 +1,225 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+//! Endpoint discovery — reads `/mcp-endpoint.json`, the file the
+//! editor publishes after `QLocalServer::listen()` succeeds, naming the
+//! ACTUAL bound endpoint.
+//!
+//! Why this exists: the computed guess in [`super::socket::socket_path`] is
+//! `$TMPDIR||/tmp + /`, but on macOS Qt binds under
+//! `NSTemporaryDirectory()` — `/private/var/folders/.../T/` — which the guess
+//! never reproduces. The published value is verbatim truth; the guess stays as
+//! the fallback for older editors that don't publish.
+//!
+//! Staleness is NOT our problem to detect. An editor crash leaves the file
+//! behind by design; the connect attempt is the only liveness test, so this
+//! module never looks at `pid`, never stats mtimes, never retries, and never
+//! creates the file or its directory. One cheap read, then out.
+
+use std::path::PathBuf;
+
+use serde_json::Value;
+
+/// Refuse to read anything larger than this — the real file is ~200 bytes, so
+/// a huge one is corruption or a hostile plant, not an endpoint record.
+const MAX_ENDPOINT_FILE: u64 = 64 * 1024;
+
+/// `/mcp-endpoint.json` — the same path the C++ writer uses.
+pub fn endpoint_file() -> PathBuf {
+ crate::config_dir::config_root().join("mcp-endpoint.json")
+}
+
+/// Validates a published endpoint record and returns its `value` (the actual
+/// bound endpoint). Pure — no I/O — so every rejection path is unit-testable
+/// on every platform. ANY validation failure returns `None`: the caller then
+/// behaves exactly as if the file did not exist.
+pub fn parse_endpoint(contents: &str) -> Option {
+ let v: Value = serde_json::from_str(contents).ok()?;
+ // Marker first: this rejects any unrelated JSON that happens to live at
+ // this path before we interpret any other field.
+ if v.get("notepatra_mcp_endpoint").and_then(Value::as_u64) != Some(1) {
+ return None;
+ }
+ let want_kind = if cfg!(windows) {
+ "named_pipe"
+ } else {
+ "unix_socket"
+ };
+ if v.get("kind").and_then(Value::as_str) != Some(want_kind) {
+ return None;
+ }
+ // `as_str` also rejects a non-string value (e.g. an integer), which must
+ // never reach a connect call.
+ let value = v.get("value").and_then(Value::as_str)?;
+ if value.is_empty() {
+ return None;
+ }
+ // Shape check per platform: an absolute socket path on Unix, a pipe NAME
+ // (not a filesystem path) on Windows.
+ let shape_ok = if cfg!(windows) {
+ value.starts_with(r"\\.\pipe\")
+ } else {
+ value.starts_with('/')
+ };
+ if !shape_ok {
+ return None;
+ }
+ Some(value.to_string())
+}
+
+/// The published endpoint, or `None` when absent/oversized/invalid. A single
+/// cheap `metadata` + `read_to_string`; it can never block or hang, so calling
+/// it on the `SocketEditor::new()` path is free.
+pub fn published_endpoint() -> Option {
+ let path = endpoint_file();
+ let meta = std::fs::metadata(&path).ok()?;
+ // Regular files ONLY. A FIFO planted at this path reports len 0 — it would
+ // sail through the size gate and then block read_to_string FOREVER, hanging
+ // every sidecar startup. Same-user write access to the config dir is needed
+ // to plant one (so no privilege boundary is crossed), but the no-hang
+ // guarantee above is absolute and must not depend on the dir's contents.
+ if !meta.is_file() {
+ return None;
+ }
+ // Size-gate BEFORE reading so a planted multi-gigabyte file can't be
+ // slurped into memory just to be rejected.
+ if meta.len() > MAX_ENDPOINT_FILE {
+ return None;
+ }
+ let contents = std::fs::read_to_string(&path).ok()?;
+ parse_endpoint(&contents)
+}
+
+/// Connection order: the published endpoint first (verbatim truth), then the
+/// computed guess. Exact duplicates are collapsed so a correct guess isn't
+/// dialed twice.
+pub fn candidates(published: Option, guess: String) -> Vec {
+ let mut out: Vec = Vec::with_capacity(2);
+ if let Some(p) = published {
+ out.push(p);
+ }
+ if !out.contains(&guess) {
+ out.push(guess);
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// A record whose `kind` matches the platform under test — the two shapes
+ /// are mutually exclusive, so each is asserted only where it is valid.
+ #[test]
+ fn valid_record_for_this_platform() {
+ if cfg!(windows) {
+ let json = r#"{"notepatra_mcp_endpoint":1,"kind":"named_pipe","value":"\\\\.\\pipe\\notepatra-0123456789abcdef-mcp","name":"notepatra-0123456789abcdef-mcp","pid":42,"version":"0.1.120"}"#;
+ assert_eq!(
+ parse_endpoint(json).as_deref(),
+ Some(r"\\.\pipe\notepatra-0123456789abcdef-mcp")
+ );
+ } else {
+ let json = r#"{"notepatra_mcp_endpoint":1,"kind":"unix_socket","value":"/private/var/folders/ab/T/notepatra-0123456789abcdef-mcp","name":"notepatra-0123456789abcdef-mcp","pid":42,"version":"0.1.120"}"#;
+ assert_eq!(
+ parse_endpoint(json).as_deref(),
+ Some("/private/var/folders/ab/T/notepatra-0123456789abcdef-mcp")
+ );
+ }
+ }
+
+ /// The other platform's `kind` must be rejected, not coerced.
+ #[test]
+ fn wrong_kind_for_this_platform_is_rejected() {
+ let unix_rec =
+ r#"{"notepatra_mcp_endpoint":1,"kind":"unix_socket","value":"/tmp/notepatra-mcp"}"#;
+ let win_rec =
+ r#"{"notepatra_mcp_endpoint":1,"kind":"named_pipe","value":"\\\\.\\pipe\\np-mcp"}"#;
+ let foreign = if cfg!(windows) { unix_rec } else { win_rec };
+ assert_eq!(parse_endpoint(foreign), None);
+ }
+
+ #[test]
+ fn missing_marker_is_rejected() {
+ assert_eq!(
+ parse_endpoint(r#"{"kind":"unix_socket","value":"/tmp/np-mcp"}"#),
+ None
+ );
+ assert_eq!(
+ parse_endpoint(
+ r#"{"notepatra_mcp_endpoint":2,"kind":"unix_socket","value":"/tmp/np-mcp"}"#
+ ),
+ None
+ );
+ }
+
+ #[test]
+ fn empty_or_missing_value_is_rejected() {
+ let kind = if cfg!(windows) {
+ "named_pipe"
+ } else {
+ "unix_socket"
+ };
+ assert_eq!(
+ parse_endpoint(&format!(
+ r#"{{"notepatra_mcp_endpoint":1,"kind":"{kind}","value":""}}"#
+ )),
+ None
+ );
+ assert_eq!(
+ parse_endpoint(&format!(
+ r#"{{"notepatra_mcp_endpoint":1,"kind":"{kind}"}}"#
+ )),
+ None
+ );
+ }
+
+ /// A relative Unix path or a bare Windows name would be dialed against the
+ /// CWD / the wrong namespace — reject the shape outright.
+ #[test]
+ fn wrong_path_prefix_is_rejected() {
+ let kind = if cfg!(windows) {
+ "named_pipe"
+ } else {
+ "unix_socket"
+ };
+ assert_eq!(
+ parse_endpoint(&format!(
+ r#"{{"notepatra_mcp_endpoint":1,"kind":"{kind}","value":"relative/notepatra-mcp"}}"#
+ )),
+ None
+ );
+ }
+
+ #[test]
+ fn garbage_json_is_rejected() {
+ assert_eq!(parse_endpoint(""), None);
+ assert_eq!(parse_endpoint("not json at all"), None);
+ assert_eq!(parse_endpoint("{"), None);
+ assert_eq!(parse_endpoint("[1,2,3]"), None);
+ }
+
+ /// `value` must be a string; an integer must not be stringified.
+ #[test]
+ fn integer_typed_value_is_rejected() {
+ let kind = if cfg!(windows) {
+ "named_pipe"
+ } else {
+ "unix_socket"
+ };
+ assert_eq!(
+ parse_endpoint(&format!(
+ r#"{{"notepatra_mcp_endpoint":1,"kind":"{kind}","value":12345}}"#
+ )),
+ None
+ );
+ }
+
+ #[test]
+ fn candidates_order_and_dedup() {
+ assert_eq!(
+ candidates(Some("/a".into()), "/b".into()),
+ vec!["/a".to_string(), "/b".to_string()]
+ );
+ assert_eq!(candidates(None, "/b".into()), vec!["/b".to_string()]);
+ // A published value equal to the guess is dialed once, not twice.
+ assert_eq!(candidates(Some("/b".into()), "/b".into()), vec!["/b"]);
+ }
+}
diff --git a/notepatra-mcp/src/transport/mock.rs b/notepatra-mcp/src/transport/mock.rs
index c627de6..cc37e2d 100644
--- a/notepatra-mcp/src/transport/mock.rs
+++ b/notepatra-mcp/src/transport/mock.rs
@@ -70,6 +70,7 @@ struct MockTab {
modified: bool,
language: String,
truncated: bool,
+ is_diagram: bool,
}
struct MockNote {
@@ -86,6 +87,12 @@ struct MockReminder {
bucket: &'static str,
}
+struct MockConnection {
+ name: &'static str,
+ driver: &'static str,
+ database: &'static str,
+}
+
/// In-memory fake editor used by default and in tests.
pub struct MockEditor {
tabs: Vec,
@@ -93,9 +100,27 @@ pub struct MockEditor {
selection: (usize, String),
notes: Vec,
reminders: Vec,
+ connections: Vec,
approval: ApprovalMode,
}
+/// Believable stand-in for the editor's npd parser: a line beginning with
+/// "!!" is treated as a malformed directive. Shared by validate_npd,
+/// create_diagram and set_diagram_source so their `errors` shapes match.
+fn mock_npd_errors(text: &str) -> Vec {
+ let mut errors = Vec::new();
+ for (n, line) in text.lines().enumerate() {
+ if line.trim_start().starts_with("!!") {
+ // Bridge error shape: {line, message} (no column).
+ errors.push(json!({
+ "line": n + 1,
+ "message": "unknown directive",
+ }));
+ }
+ }
+ errors
+}
+
fn language_for(path: &str) -> String {
match path.rsplit('.').next() {
Some("rs") => "Rust".into(),
@@ -117,6 +142,7 @@ impl Default for MockEditor {
modified: false,
language: "Rust".into(),
truncated: false,
+ is_diagram: false,
},
MockTab {
title: "NOTES.md".into(),
@@ -126,6 +152,7 @@ impl Default for MockEditor {
modified: true,
language: "Markdown".into(),
truncated: false,
+ is_diagram: false,
},
MockTab {
title: "Untitled 1".into(),
@@ -134,6 +161,7 @@ impl Default for MockEditor {
modified: false,
language: "Plain Text".into(),
truncated: false,
+ is_diagram: false,
},
],
selection: (0, "println!(\"hello from notepatra\");".into()),
@@ -193,6 +221,13 @@ impl Default for MockEditor {
bucket: "Later",
},
],
+ // One saved connection so the phase-2 data verbs demo believably
+ // offline (in-memory SQLite; no real database needed).
+ connections: vec![MockConnection {
+ name: "demo",
+ driver: "QSQLITE",
+ database: ":memory:",
+ }],
approval: ApprovalMode::Approve,
}
}
@@ -288,6 +323,7 @@ impl EditorTransport for MockEditor {
modified: false,
language: language_for(path),
truncated: false,
+ is_diagram: false,
});
Ok(self.tabs.len() - 1)
}
@@ -426,6 +462,7 @@ impl EditorTransport for MockEditor {
modified: text.is_some_and(|t| !t.is_empty()),
language: "Plain Text".into(),
truncated: false,
+ is_diagram: false,
});
Ok(json!({ "tab_index": self.tabs.len() - 1 }))
}
@@ -694,18 +731,7 @@ impl EditorTransport for MockEditor {
))
}
};
- // Believable stand-in for the editor's npd parser: a line beginning
- // with "!!" is treated as a malformed directive (one error case).
- let mut errors = Vec::new();
- for (n, line) in text.lines().enumerate() {
- if line.trim_start().starts_with("!!") {
- // Bridge error shape: {line, message} (no column).
- errors.push(json!({
- "line": n + 1,
- "message": "unknown directive",
- }));
- }
- }
+ let errors = mock_npd_errors(&text);
Ok(json!({ "valid": errors.is_empty(), "errors": errors }))
}
@@ -730,6 +756,27 @@ impl EditorTransport for MockEditor {
}))
}
+ // ── Phase 0A read verbs ────────────────────────────────────────────
+
+ fn list_languages(&self) -> Result {
+ // Canonical-token subset mirroring the editor's Language menu.
+ Ok(json!({ "languages": [
+ "Plain Text", "Bash", "C", "C#", "C++", "CSS", "HTML", "Java",
+ "JavaScript", "JSON", "Lua", "Markdown", "Perl", "Python",
+ "Ruby", "Rust", "SQL", "XML", "YAML",
+ ]}))
+ }
+
+ fn get_capabilities(&self) -> Result {
+ // Bridge shape only — the tool layer adds tool_count and tiers.
+ Ok(json!({
+ "edition": "Lite",
+ "platform": std::env::consts::OS,
+ "version": "0.1.119",
+ "features": { "duckdb": false, "webengine": false, "noter": true },
+ }))
+ }
+
// ── v0.1.119 act verb ──────────────────────────────────────────────
fn open_note(&mut self, file: &str) -> Result {
@@ -750,6 +797,7 @@ impl EditorTransport for MockEditor {
modified: false,
language: "HTML".into(),
truncated: false,
+ is_diagram: false,
});
// Bridge result shape: {opened, title}.
Ok(json!({ "opened": true, "title": title }))
@@ -827,4 +875,167 @@ impl EditorTransport for MockEditor {
// Bridge result shape: {path}.
Ok(json!({ "path": path }))
}
+
+ // ── Phase 1 verbs ──────────────────────────────────────────────────
+
+ fn create_diagram(
+ &mut self,
+ source: Option<&str>,
+ title: Option<&str>,
+ ) -> Result {
+ let src = source.unwrap_or("").to_string();
+ let errors = mock_npd_errors(&src);
+ self.tabs.push(MockTab {
+ title: title.unwrap_or("Diagram").to_string(),
+ path: None,
+ content: src,
+ modified: false,
+ language: "Plain Text".into(),
+ truncated: false,
+ is_diagram: true,
+ });
+ Ok(json!({
+ "tab_index": self.tabs.len() - 1,
+ "valid": errors.is_empty(),
+ "errors": errors,
+ }))
+ }
+
+ fn get_diagram_source(&self, tab_index: usize) -> Result {
+ let i = self.check_index(tab_index)?;
+ if !self.tabs[i].is_diagram {
+ return Err(TransportError(format!(
+ "tab {i} is not a diagram (.npd) tab"
+ )));
+ }
+ Ok(json!({ "source": self.tabs[i].content }))
+ }
+
+ fn set_diagram_source(
+ &mut self,
+ tab_index: usize,
+ source: &str,
+ ) -> Result {
+ self.check_approval()?;
+ let i = self.check_index(tab_index)?;
+ if !self.tabs[i].is_diagram {
+ return Err(TransportError(format!(
+ "tab {i} is not a diagram (.npd) tab"
+ )));
+ }
+ self.tabs[i].content = source.to_string();
+ self.tabs[i].modified = true;
+ let errors = mock_npd_errors(source);
+ Ok(json!({
+ "ok": true,
+ "tab_index": i,
+ "valid": errors.is_empty(),
+ "errors": errors,
+ }))
+ }
+
+ fn open_noter(&mut self) -> Result {
+ // Bridge result shape: {opened}.
+ Ok(json!({ "opened": true }))
+ }
+
+ // ── Phase 2 — data-analyst + charts ────────────────────────────────
+
+ fn list_connections(&self) -> Result {
+ let connections: Vec = self
+ .connections
+ .iter()
+ .map(|c| {
+ json!({
+ "name": c.name,
+ "driver": c.driver,
+ "database": c.database,
+ "read_only": true,
+ })
+ })
+ .collect();
+ Ok(json!({ "connections": connections }))
+ }
+
+ fn run_query(
+ &self,
+ connection_name: &str,
+ sql: &str,
+ _max_rows: Option,
+ ) -> Result {
+ if !self.connections.iter().any(|c| c.name == connection_name) {
+ return Err(TransportError(format!(
+ "no connection named: {connection_name}"
+ )));
+ }
+ // Same SELECT-only contract as run_sql's mock (observable rejection).
+ let head = sql
+ .split_whitespace()
+ .next()
+ .unwrap_or("")
+ .to_ascii_uppercase();
+ if head != "SELECT" && head != "WITH" {
+ return Err(TransportError(
+ "only read-only SELECT statements are allowed".into(),
+ ));
+ }
+ Ok(json!({
+ "columns": [ "id", "name" ],
+ "rows": [ ["1", "alpha"], ["2", "bravo"] ],
+ "truncated": false,
+ "engine": "sqlite",
+ }))
+ }
+
+ fn list_tables(&self, connection_name: &str) -> Result {
+ if !self.connections.iter().any(|c| c.name == connection_name) {
+ return Err(TransportError(format!(
+ "no connection named: {connection_name}"
+ )));
+ }
+ Ok(json!({ "tables": ["invoices", "customers"] }))
+ }
+
+ fn open_data_analyst(&mut self) -> Result {
+ Ok(json!({ "opened": true }))
+ }
+
+ fn render_chart(
+ &mut self,
+ _spec: &Value,
+ _title: Option<&str>,
+ ) -> Result {
+ // The mock pretends the charts pack is present even though its
+ // capability flag reports webengine:false — capabilities describe the
+ // editor, while the mock is a demo surface that answers every verb.
+ Ok(json!({ "chart_id": "mock-chart-1", "rendered": true }))
+ }
+
+ fn export_query_results(
+ &mut self,
+ connection_name: &str,
+ _sql: &str,
+ path: &str,
+ _format: &str,
+ _max_rows: Option,
+ ) -> Result {
+ self.check_approval()?;
+ if !self.connections.iter().any(|c| c.name == connection_name) {
+ return Err(TransportError(format!(
+ "no connection named: {connection_name}"
+ )));
+ }
+ Ok(json!({ "ok": true, "path": path, "rows": 2 }))
+ }
+
+ fn export_chart(
+ &mut self,
+ _spec: &Value,
+ path: &str,
+ _format: &str,
+ _scale: Option,
+ ) -> Result {
+ self.check_approval()?;
+ Ok(json!({ "path": path }))
+ }
}
diff --git a/notepatra-mcp/src/transport/mod.rs b/notepatra-mcp/src/transport/mod.rs
index 5b60e03..b9f3eb5 100644
--- a/notepatra-mcp/src/transport/mod.rs
+++ b/notepatra-mcp/src/transport/mod.rs
@@ -3,6 +3,7 @@ use std::fmt;
use serde_json::Value;
+pub mod endpoint;
pub mod mock;
pub mod socket;
@@ -137,6 +138,10 @@ impl std::error::Error for TransportError {}
/// `tab_index` key here, NOT `index`) → `{valid:bool,errors:[{line,message}]}`
/// * `run_sql` (args `{sql,csv_path?}`, SELECT-only — the editor rejects any
/// non-read statement) → `{columns:[str],rows:[[…]],truncated:bool,engine:str}`
+/// * `list_languages` → `{languages:[str]}` (canonical Language-menu tokens) (p0a)
+/// * `get_capabilities` → `{edition,platform,version,features:{duckdb,webengine,noter}}`
+/// (p0a; the TOOL layer augments the result with `tool_count` and `tiers`
+/// before it reaches the client — the sidecar owns the tool surface)
///
/// Act tier:
/// * `open_note` (args `{file}`) → `{opened:bool,title:str}`
@@ -147,6 +152,37 @@ impl std::error::Error for TransportError {}
/// * `append_note` (args `{file,text}`) → `{file:str}`
/// * `set_reminder` (args `{file,due_iso}`) → `{file:str,due_iso:str}`
/// * `export_diagram` (args `{tab_index,path,format}`) → `{path:str}`
+///
+/// Phase 1 (diagram control + Noter panel):
+/// * `create_diagram` (args `{source?,title?}`) → `{tab_index,valid,errors:[{line,message}]}`
+/// (ACT — the tab is created even when the source is invalid .npd)
+/// * `get_diagram_source` (args `{tab_index}`) → `{source}` (READ; errors when
+/// the tab is not a DiagramEditor)
+/// * `open_noter` → `{opened:true}` (ACT — reveals/focuses the Noter tab)
+/// * `set_diagram_source` (args `{tab_index,source}`) →
+/// `{ok:true,tab_index,valid,errors}` (WRITE — same approval card + verbatim
+/// deny/timeout errors as the other write verbs)
+///
+/// Phase 2 (data-analyst + charts) — verbatim JSON passthrough:
+///
+/// Read tier:
+/// * `list_connections` → `{connections:[{name,driver,database,read_only}]}`
+/// (never passwords)
+/// * `run_query` (args `{connection_name,sql,max_rows?}`) →
+/// `{columns:[str],rows:[[…]],truncated:bool,engine:str}` — SELECT-only over
+/// a SAVED connection; mutations are rejected by the editor
+/// * `list_tables` (args `{connection_name}`) → `{tables:[str]}`
+///
+/// Act tier:
+/// * `open_data_analyst` → `{opened:true}`
+/// * `render_chart` (args `{spec,title?}`) → `{chart_id:str,rendered:bool}`;
+/// Lite editions error with `"charts require the Full edition (WebEngine)"`
+///
+/// Write tier (same human-approval card + verbatim deny/timeout errors):
+/// * `export_query_results` (args `{connection_name,sql,path,format,max_rows?}`)
+/// → `{ok:true,path:str,rows:int}`
+/// * `export_chart` (args `{spec,path,format,scale?}`) → `{path:str}`;
+/// Full/WebEngine only (same gate error as render_chart)
pub trait EditorTransport {
/// Returns the tab index the file landed in.
fn open_file(&mut self, path: &str) -> Result;
@@ -224,6 +260,10 @@ pub trait EditorTransport {
/// error that passes straight through.
fn run_sql(&self, sql: &str, csv_path: Option<&str>) -> Result;
+ // Phase 0A read verbs — verbatim JSON passthrough.
+ fn list_languages(&self) -> Result;
+ fn get_capabilities(&self) -> Result;
+
// v0.1.119 act verb — opens a Noter note in a tab (not approval-gated).
fn open_note(&mut self, file: &str) -> Result;
@@ -238,4 +278,48 @@ pub trait EditorTransport {
path: &str,
format: &str,
) -> Result;
+
+ // Phase 1 — diagram control + Noter panel.
+ fn create_diagram(
+ &mut self,
+ source: Option<&str>,
+ title: Option<&str>,
+ ) -> Result;
+ fn get_diagram_source(&self, tab_index: usize) -> Result;
+ /// Approval-gated in the editor, like the other write verbs.
+ fn set_diagram_source(
+ &mut self,
+ tab_index: usize,
+ source: &str,
+ ) -> Result;
+ fn open_noter(&mut self) -> Result;
+
+ // Phase 2 — data-analyst + charts (verbatim JSON passthrough).
+ fn list_connections(&self) -> Result;
+ fn run_query(
+ &self,
+ connection_name: &str,
+ sql: &str,
+ max_rows: Option,
+ ) -> Result;
+ fn list_tables(&self, connection_name: &str) -> Result;
+ fn open_data_analyst(&mut self) -> Result;
+ fn render_chart(&mut self, spec: &Value, title: Option<&str>) -> Result;
+ /// Approval-gated in the editor, like the other write verbs.
+ fn export_query_results(
+ &mut self,
+ connection_name: &str,
+ sql: &str,
+ path: &str,
+ format: &str,
+ max_rows: Option,
+ ) -> Result;
+ /// Approval-gated in the editor, like the other write verbs.
+ fn export_chart(
+ &mut self,
+ spec: &Value,
+ path: &str,
+ format: &str,
+ scale: Option,
+ ) -> Result;
}
diff --git a/notepatra-mcp/src/transport/socket.rs b/notepatra-mcp/src/transport/socket.rs
index 9225a55..29e0ea1 100644
--- a/notepatra-mcp/src/transport/socket.rs
+++ b/notepatra-mcp/src/transport/socket.rs
@@ -7,9 +7,15 @@
//!
//! * Endpoint name: `"notepatra-" + first 16 hex of SHA-1(UTF-8 home dir) +
//! "-mcp"` — the single-instance name (`SingleInstance::serverName()`) plus
-//! an `-mcp` suffix, so each user gets a distinct bridge. Qt materializes it
-//! at `$TMPDIR/` (default `/tmp/`) on Unix and as the named pipe
-//! `\\.\pipe\` on Windows.
+//! an `-mcp` suffix, so each user gets a distinct bridge.
+//! * Endpoint DISCOVERY (v0.1.120): the editor publishes its actually-bound
+//! endpoint to `/mcp-endpoint.json` once `listen()` succeeds,
+//! and that value is dialed FIRST (see [`super::endpoint`]). The computed
+//! guess `$TMPDIR/` (Unix) / `\\.\pipe\` (Windows) is only the
+//! FALLBACK, for editors older than v0.1.120. The guess is not merely
+//! redundant-but-correct: on macOS Qt binds under `NSTemporaryDirectory()`
+//! (`/private/var/folders/.../T/`), which `$TMPDIR||/tmp` does not
+//! reproduce, so publication is the only thing that makes macOS work.
//! * Greeting before payload (proof-of-life law): on accept, the editor sends
//! ONE greeting line `{"notepatra_mcp":1,"app":"Notepatra","version":...}`.
//! The client MUST read and validate it before sending anything — receiving
@@ -29,14 +35,15 @@
//! client runs a dedicated reader thread and enforces the same 5s/15s/130s
//! windows with `mpsc::recv_timeout`. No extra dependencies — std only.
//!
-//! ASSUMPTION (flag for C++ reconciliation): the Windows endpoint name equals
-//! Qt's `QLocalServer` name verbatim under the `\\.\pipe\` prefix, and the
-//! HOME-equivalent used for the SHA-1 name derivation is what
-//! `QDir::homePath()` returns on Windows — `%USERPROFILE%` with FORWARD
-//! slashes. The Rust side hashes `$USERPROFILE` verbatim; if Qt normalizes
-//! separators differently the two names would diverge. Runtime behavior on
-//! Windows is UNVERIFIED here (Linux dev host); only cross-compilation is
-//! checked.
+//! ASSUMPTION (flag for C++ reconciliation), now only load-bearing for the
+//! FALLBACK guess: the Windows endpoint name equals Qt's `QLocalServer` name
+//! verbatim under the `\\.\pipe\` prefix, and the HOME-equivalent used for the
+//! SHA-1 name derivation is what `QDir::homePath()` returns on Windows —
+//! `%USERPROFILE%` with FORWARD slashes. The Rust side hashes `$USERPROFILE`
+//! verbatim; if Qt normalizes separators differently the two names diverge —
+//! but a v0.1.120+ editor publishes its real endpoint, so the guess is never
+//! reached. Runtime behavior on Windows is UNVERIFIED here (Linux dev host);
+//! only cross-compilation is checked.
#[cfg(any(unix, windows))]
use std::cell::RefCell;
@@ -45,8 +52,8 @@ use std::time::Duration;
use serde_json::{json, Value};
use super::{
- EditorTransport, SearchHit, SearchResults, Selection, TabContent, TabInfo, TabSelector,
- TransportError,
+ endpoint, EditorTransport, SearchHit, SearchResults, Selection, TabContent, TabInfo,
+ TabSelector, TransportError,
};
/// Exact user-facing message when the bridge socket cannot be reached.
@@ -60,8 +67,8 @@ const APPROVAL_TIMEOUT: Duration = Duration::from_secs(130);
/// The approval-gated write verbs (must match the C++ bridge's card-gated
/// verb set exactly). v0.1.119 adds create_note/append_note/set_reminder/
-/// export_diagram to the v0.1.118 quartet.
-const WRITE_VERBS: [&str; 8] = [
+/// export_diagram to the v0.1.118 quartet; phase 1 adds set_diagram_source.
+const WRITE_VERBS: [&str; 11] = [
"insert_text",
"replace_selection",
"apply_edit",
@@ -70,6 +77,10 @@ const WRITE_VERBS: [&str; 8] = [
"append_note",
"set_reminder",
"export_diagram",
+ "set_diagram_source",
+ // phase 2
+ "export_query_results",
+ "export_chart",
];
/// Mirrors `SingleInstance::serverName()` in src/singleinstance.cpp.
@@ -95,7 +106,10 @@ pub fn socket_path(name: &str) -> String {
}
pub struct SocketEditor {
- path: String,
+ /// Endpoints to dial, in order (published first, then the guess). Always
+ /// non-empty; `candidates[0]` is what [`SocketEditor::socket_path`]
+ /// reports.
+ candidates: Vec,
base_timeout: Duration,
search_timeout: Duration,
approval_timeout: Duration,
@@ -104,7 +118,8 @@ pub struct SocketEditor {
}
impl SocketEditor {
- /// Targets the current user's bridge socket (derived from the home dir).
+ /// Targets the current user's bridge: the endpoint the editor published,
+ /// falling back to the endpoint computed from the home dir.
pub fn new() -> Self {
// The name derivation hashes the same string Qt's
// SingleInstance::serverName() hashes: `QDir::homePath()`. On Windows
@@ -118,13 +133,29 @@ impl SocketEditor {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_default();
- Self::with_socket_path(socket_path(&mcp_server_name(&home)))
+ // One cheap file read — no watching, no retries — so discovery can
+ // never delay or hang startup.
+ Self::with_candidates(endpoint::candidates(
+ endpoint::published_endpoint(),
+ socket_path(&mcp_server_name(&home)),
+ ))
}
- /// Targets an explicit socket path (tests use this with a fake bridge).
+ /// Targets an explicit socket path, BYPASSING discovery (`--socket-path`
+ /// and tests with a fake bridge). Exactly one candidate, so an explicit
+ /// path can never silently fall through to some other editor.
pub fn with_socket_path(path: impl Into) -> Self {
+ Self::with_candidates(vec![path.into()])
+ }
+
+ /// Targets an ordered candidate list; the first one that CONNECTS wins.
+ pub fn with_candidates(paths: Vec) -> Self {
+ assert!(
+ !paths.is_empty(),
+ "SocketEditor needs at least one candidate endpoint"
+ );
Self {
- path: path.into(),
+ candidates: paths,
base_timeout: DEFAULT_TIMEOUT,
search_timeout: SEARCH_TIMEOUT,
approval_timeout: APPROVAL_TIMEOUT,
@@ -147,8 +178,31 @@ impl SocketEditor {
self
}
+ /// The preferred endpoint (first candidate) — what diagnostics report.
pub fn socket_path(&self) -> &str {
- &self.path
+ &self.candidates[0]
+ }
+
+ /// Dials the candidates in order and returns the first LIVE bridge.
+ ///
+ /// Only a failed CONNECT advances to the next candidate — that is the
+ /// staleness test (a published endpoint left behind by a crashed editor
+ /// simply refuses). If a candidate connects but its greeting is missing or
+ /// wrong, that error surfaces immediately: something is listening there
+ /// speaking the wrong protocol, and quietly dialing on would mask it.
+ #[cfg(any(unix, windows))]
+ fn establish(&self) -> Result {
+ for path in &self.candidates {
+ match Conn::connect(path, self.base_timeout) {
+ Ok(mut conn) => {
+ conn.handshake(self.base_timeout)?;
+ return Ok(conn);
+ }
+ Err(e) if e.fatal => return Err(e.err),
+ Err(_) => continue,
+ }
+ }
+ Err(TransportError(NOT_RUNNING.to_string()))
}
/// One blocking request/response round-trip; lazily connects (greeting
@@ -169,7 +223,7 @@ impl SocketEditor {
};
let mut guard = self.conn.borrow_mut();
if guard.is_none() {
- *guard = Some(Conn::establish(&self.path, self.base_timeout)?);
+ *guard = Some(self.establish()?);
}
let conn = guard.as_mut().expect("connection just established");
match conn.round_trip(verb, args, timeout) {
@@ -189,7 +243,7 @@ impl SocketEditor {
"the Notepatra MCP bridge socket is not supported on this platform; \
run notepatra-mcp without --socket to use the built-in mock editor \
(expected endpoint: {})",
- self.path
+ self.socket_path()
)))
}
}
@@ -202,6 +256,34 @@ impl Default for SocketEditor {
// ── Shared wire helpers (both transports) ──────────────────────────────────
+/// A connect-phase failure. `fatal` distinguishes "nothing is listening here"
+/// (keep trying the next candidate) from a genuine local failure such as an
+/// un-clonable handle, which no other candidate can fix and which must not be
+/// laundered into the generic NOT_RUNNING message.
+#[cfg(any(unix, windows))]
+struct ConnectError {
+ err: TransportError,
+ fatal: bool,
+}
+
+#[cfg(any(unix, windows))]
+impl ConnectError {
+ /// The endpoint is unreachable — try the next candidate.
+ fn unreachable() -> Self {
+ Self {
+ err: TransportError(NOT_RUNNING.to_string()),
+ fatal: false,
+ }
+ }
+
+ fn fatal(msg: String) -> Self {
+ Self {
+ err: TransportError(msg),
+ fatal: true,
+ }
+ }
+}
+
/// Validates the editor's proof-of-life greeting line (JSON with
/// `notepatra_mcp:1`). Shared by both transports so the greeting law is
/// enforced identically on Unix and Windows.
@@ -264,20 +346,29 @@ struct Conn {
#[cfg(unix)]
impl Conn {
- fn establish(path: &str, timeout: Duration) -> Result {
+ /// Connect phase ONLY — no bytes are read. Failing here means this
+ /// candidate is dead, so the caller may move on to the next one.
+ fn connect(path: &str, timeout: Duration) -> Result {
use std::os::unix::net::UnixStream;
- let stream =
- UnixStream::connect(path).map_err(|_| TransportError(NOT_RUNNING.to_string()))?;
+ let stream = UnixStream::connect(path).map_err(|_| ConnectError::unreachable())?;
stream
.set_read_timeout(Some(timeout))
- .map_err(|e| TransportError(format!("cannot set socket timeout: {e}")))?;
- let mut reader = std::io::BufReader::new(stream);
- // Proof-of-life: the editor speaks first. Nothing is sent until the
- // greeting has been read and validated.
- let greeting = read_line_from(&mut reader, "the editor's greeting")?;
- validate_greeting(&greeting)?;
- Ok(Self { reader, next_id: 1 })
+ .map_err(|e| ConnectError::fatal(format!("cannot set socket timeout: {e}")))?;
+ Ok(Self {
+ reader: std::io::BufReader::new(stream),
+ next_id: 1,
+ })
+ }
+
+ /// Proof-of-life: the editor speaks first. Nothing is sent until the
+ /// greeting has been read and validated. Kept separate from
+ /// [`Conn::connect`] so a live peer that greets wrongly is reported rather
+ /// than skipped over.
+ fn handshake(&mut self, _timeout: Duration) -> Result<(), TransportError> {
+ // The read timeout was already armed by `connect`.
+ let greeting = read_line_from(&mut self.reader, "the editor's greeting")?;
+ validate_greeting(&greeting)
}
fn round_trip(
@@ -335,12 +426,58 @@ fn read_line_from(
// ── Windows named-pipe transport (v0.1.119) ────────────────────────────────
//
// `std::fs::File` over a named pipe is a byte stream with no per-read timeout,
-// so a dedicated reader thread pumps lines into a channel and the caller uses
-// `recv_timeout` to honor the same 5s/15s/130s windows the Unix path gets from
-// `set_read_timeout`. std-only; no winapi. UNVERIFIED at runtime (built and
-// checked on Linux via `--target x86_64-pc-windows-gnu`).
-
-/// One item from the pipe reader thread.
+// so reads are delegated to a worker thread and the caller uses `recv_timeout`
+// to honor the same 5s/15s/130s windows the Unix path gets from
+// `set_read_timeout`. std-only; no winapi.
+//
+// WHY THE WORKER IS COMMAND-GATED (v0.1.120 — fixes a shipped deadlock):
+// The pipe is opened WITHOUT `FILE_FLAG_OVERLAPPED`, and `try_clone` duplicates
+// the handle but NOT the underlying kernel file object. A synchronous file
+// object serializes every I/O request against it, so a thread parked in
+// `ReadFile` holds that lock and the next `WriteFile` — even from another
+// thread, even in the opposite direction — waits behind it. (This is precisely
+// why MSDN requires overlapped I/O to read and write a pipe simultaneously.)
+// The original design kept the reader thread ALWAYS parked in `read_line`, so
+// the first request write after the greeting blocked forever: never sent, never
+// answered, and not covered by any timeout because the block was on the
+// un-timeboxed write. It also meant `drop` could not close the client's handles
+// (the parked read still owned the cloned one), so the peer never saw EOF.
+//
+// The worker below idles blocked on a COMMAND channel, never in `ReadFile`. It
+// performs exactly one `read_line` per command and returns to channel-idle.
+// The half-duplex protocol is therefore enforced structurally: a write is only
+// ever issued while the file object has no pending read, and `drop` on an idle
+// worker closes the command channel, the worker exits at once, and every client
+// handle is released so the peer sees EOF promptly.
+//
+// Residual, bounded: a read ABANDONED by timeout leaves the worker in
+// `ReadFile` on that connection's file object. `Conn::dead` then refuses any
+// further write on this connection, and `SocketEditor::call` already drops the
+// `Conn` and reconnects with a FRESH file object on any error — so a stale
+// worker can never block a future write. It exits when the editor finally
+// answers or closes. Runtime behavior on Windows is reasoned from the
+// documented synchronous-file-object semantics, not observed on this host.
+//
+// WHY WRITES GET A WORKER TOO (v0.1.120 — closes the last unbounded wait):
+// `Conn::dead` bounds only the STALE-WORKER case. It does not bound a FULL PIPE
+// BUFFER. The pipe is byte-mode, so unblocking a writer requires the peer to
+// drain; if the editor is alive but its Qt event loop is not reading — exactly
+// the approval-card / modal-dialog state this transport exists to serve — a
+// request larger than QLocalServer's buffer parks `WriteFile` forever. That is
+// not hypothetical: `text`, `source`, `sql` and Vega `spec` payloads
+// (insert_text, apply_edit, set_diagram_source, run_sql, render_chart,
+// export_chart, format_text) routinely exceed 64 KB. The greeting
+// proof-of-life law does not help, because the stall begins AFTER the greeting.
+//
+// std exposes no write timeout for a `File`, so the write is delegated to its
+// own command-gated worker and the caller bounds it with `recv_timeout` — the
+// mirror image of the read path, reusing the SAME per-verb timeout (so a write
+// waiting behind an approval card gets the long window, not the short one).
+// The residual matches the read side exactly: an abandoned write leaves that
+// worker in `WriteFile`, the connection is marked `dead`, and the caller
+// reconnects on a fresh file object.
+
+/// One item from the pipe read worker.
#[cfg(windows)]
enum LineMsg {
/// A complete newline-delimited line.
@@ -353,17 +490,33 @@ enum LineMsg {
#[cfg(windows)]
struct Conn {
- /// Write half of the pipe (the reader thread owns a cloned read half).
- writer: std::fs::File,
- rx: std::sync::mpsc::Receiver,
- /// Detached on drop; the thread exits when the pipe closes.
+ /// One send == "write these bytes and flush". The write worker owns the
+ /// write half of the pipe; dropping this releases an idle worker (and with
+ /// it that handle).
+ write_tx: std::sync::mpsc::Sender>,
+ /// One result per `write_tx` send.
+ write_rx: std::sync::mpsc::Receiver>,
+ /// Detached; exits as soon as `write_tx` drops while it is idle.
+ _writer: std::thread::JoinHandle<()>,
+ /// One send == "perform exactly one `read_line`". Dropping it is what
+ /// releases an idle worker (and with it the cloned read handle).
+ cmd_tx: std::sync::mpsc::Sender<()>,
+ line_rx: std::sync::mpsc::Receiver,
+ /// Detached; exits as soon as `cmd_tx` drops while it is idle.
_reader: std::thread::JoinHandle<()>,
+ /// Set once a read or write has failed or been abandoned: a worker may
+ /// still be parked in `ReadFile`/`WriteFile` on this file object, so no
+ /// further I/O may be issued here. The caller reconnects instead.
+ dead: bool,
next_id: u64,
}
#[cfg(windows)]
impl Conn {
- fn establish(path: &str, timeout: Duration) -> Result {
+ /// Connect phase ONLY (open the pipe + start the read worker); no protocol
+ /// bytes are consumed — and no read is even ISSUED — so a failure here just
+ /// means this candidate is dead and the caller may try the next one.
+ fn connect(path: &str, _timeout: Duration) -> Result {
use std::fs::OpenOptions;
use std::io::{BufRead, BufReader};
use std::sync::mpsc;
@@ -375,62 +528,141 @@ impl Conn {
.read(true)
.write(true)
.open(path)
- .map_err(|_| TransportError(NOT_RUNNING.to_string()))?;
+ .map_err(|_| ConnectError::unreachable())?;
let read_half = file
.try_clone()
- .map_err(|e| TransportError(format!("cannot clone pipe handle: {e}")))?;
+ .map_err(|e| ConnectError::fatal(format!("cannot clone pipe handle: {e}")))?;
+
+ // Write worker: idles on the channel, never inside `WriteFile`, so a
+ // stalled peer parks IT rather than the caller. Owns the write half —
+ // dropping `write_tx` closes that handle once the worker is idle.
+ let (write_tx, write_cmd_rx) = mpsc::channel::>();
+ let (write_res_tx, write_rx) = mpsc::channel::>();
+ let writer = std::thread::spawn(move || {
+ use std::io::Write;
+ let mut file = file;
+ while let Ok(bytes) = write_cmd_rx.recv() {
+ let res = file
+ .write_all(&bytes)
+ .and_then(|()| file.flush())
+ .map_err(|e| e.to_string());
+ // A failed write is terminal for this connection, and a send
+ // failure means the Conn is gone: either way, stop.
+ let terminal = res.is_err();
+ if write_res_tx.send(res).is_err() || terminal {
+ break;
+ }
+ }
+ });
- let (tx, rx) = mpsc::channel::();
+ let (cmd_tx, cmd_rx) = mpsc::channel::<()>();
+ let (line_tx, line_rx) = mpsc::channel::();
let reader = std::thread::spawn(move || {
+ // The BufReader lives across commands so bytes read ahead of a
+ // newline are not lost between round-trips.
let mut buf = BufReader::new(read_half);
- loop {
+ // Idle HERE — on the channel, never inside ReadFile.
+ while cmd_rx.recv().is_ok() {
let mut line = String::new();
- match buf.read_line(&mut line) {
- Ok(0) => {
- let _ = tx.send(LineMsg::Eof);
- break;
- }
- Ok(_) => {
- // Receiver gone (Conn dropped) — stop pumping.
- if tx.send(LineMsg::Line(line)).is_err() {
- break;
- }
- }
- Err(e) => {
- let _ = tx.send(LineMsg::Err(e.to_string()));
- break;
- }
+ let msg = match buf.read_line(&mut line) {
+ Ok(0) => LineMsg::Eof,
+ Ok(_) => LineMsg::Line(line),
+ Err(e) => LineMsg::Err(e.to_string()),
+ };
+ // Anything but a line is terminal for this connection, and a
+ // send failure means the Conn is gone: either way, stop.
+ let terminal = !matches!(msg, LineMsg::Line(_));
+ if line_tx.send(msg).is_err() || terminal {
+ break;
}
}
+ // Falling out of the loop drops `buf`, closing the cloned read
+ // handle — the other half of "the peer sees EOF promptly".
});
- let conn = Self {
- writer: file,
- rx,
+ Ok(Self {
+ write_tx,
+ write_rx,
+ _writer: writer,
+ cmd_tx,
+ line_rx,
_reader: reader,
+ dead: false,
next_id: 1,
- };
- // Proof-of-life: read + validate the greeting BEFORE sending anything.
- let greeting = conn.recv_line(timeout, "the editor's greeting")?;
- validate_greeting(&greeting)?;
- Ok(conn)
+ })
+ }
+
+ /// Proof-of-life: read + validate the greeting BEFORE sending anything.
+ /// Separate from [`Conn::connect`] so a live peer that greets wrongly is
+ /// reported rather than skipped over.
+ fn handshake(&mut self, timeout: Duration) -> Result<(), TransportError> {
+ let greeting = self.recv_line(timeout, "the editor's greeting")?;
+ validate_greeting(&greeting)
}
- /// Blocks up to `timeout` for the next line from the reader thread.
- fn recv_line(&self, timeout: Duration, what: &str) -> Result {
+ /// Commands exactly one read and blocks up to `timeout` for its result.
+ /// Any non-success marks the connection dead (see [`Conn::dead`]).
+ fn recv_line(&mut self, timeout: Duration, what: &str) -> Result {
use std::sync::mpsc::RecvTimeoutError;
- match self.rx.recv_timeout(timeout) {
- Ok(LineMsg::Line(l)) => Ok(l),
- Ok(LineMsg::Eof) | Err(RecvTimeoutError::Disconnected) => Err(TransportError(format!(
+
+ let closed = |what: &str| {
+ TransportError(format!(
"the editor closed the connection while waiting for {what}"
- ))),
- Ok(LineMsg::Err(e)) => Err(TransportError(format!(
+ ))
+ };
+ if self.cmd_tx.send(()).is_err() {
+ // The worker already exited (terminal read result).
+ self.dead = true;
+ return Err(closed(what));
+ }
+ let out = match self.line_rx.recv_timeout(timeout) {
+ Ok(LineMsg::Line(l)) => return Ok(l),
+ Ok(LineMsg::Eof) | Err(RecvTimeoutError::Disconnected) => closed(what),
+ Ok(LineMsg::Err(e)) => TransportError(format!(
"editor connection failed while waiting for {what}: {e}"
- ))),
- Err(RecvTimeoutError::Timeout) => Err(TransportError(format!(
- "timed out waiting for {what} from the editor"
- ))),
+ )),
+ Err(RecvTimeoutError::Timeout) => {
+ TransportError(format!("timed out waiting for {what} from the editor"))
+ }
+ };
+ self.dead = true;
+ Err(out)
+ }
+
+ /// Hands `bytes` to the write worker and blocks up to `timeout` for the
+ /// result. Any non-success marks the connection dead (see [`Conn::dead`]).
+ ///
+ /// The deadline is the caller's per-verb window, so a large write parked
+ /// behind an approval card gets the same long budget as the response it is
+ /// waiting on — a write is never the reason an approved edit fails.
+ fn send_line(
+ &mut self,
+ bytes: Vec,
+ timeout: Duration,
+ verb: &str,
+ ) -> Result<(), TransportError> {
+ use std::sync::mpsc::RecvTimeoutError;
+
+ let lost = |detail: &str| {
+ TransportError(format!("editor connection lost while sending: {detail}"))
+ };
+ if self.write_tx.send(bytes).is_err() {
+ // The worker already exited (a previous write failed).
+ self.dead = true;
+ return Err(lost("the write worker is gone"));
}
+ let out = match self.write_rx.recv_timeout(timeout) {
+ Ok(Ok(())) => return Ok(()),
+ Ok(Err(e)) => lost(&e),
+ Err(RecvTimeoutError::Disconnected) => lost("the write worker is gone"),
+ Err(RecvTimeoutError::Timeout) => TransportError(format!(
+ "timed out sending the {verb} request to the editor: it is not \
+ reading its side of the pipe (its window may be blocked by a \
+ dialog)"
+ )),
+ };
+ self.dead = true;
+ Err(out)
}
fn round_trip(
@@ -439,15 +671,18 @@ impl Conn {
args: Value,
timeout: Duration,
) -> Result {
- use std::io::Write;
-
+ // Belt-and-braces on the invariant `call` already upholds: never write
+ // to a connection whose worker may still be parked in ReadFile.
+ if self.dead {
+ return Err(TransportError(
+ "editor connection lost while sending: the previous request did not complete"
+ .into(),
+ ));
+ }
let id = self.next_id;
self.next_id += 1;
let line = build_request(id, verb, &args)?;
- self.writer
- .write_all(line.as_bytes())
- .and_then(|()| self.writer.flush())
- .map_err(|e| TransportError(format!("editor connection lost while sending: {e}")))?;
+ self.send_line(line.into_bytes(), timeout, verb)?;
let resp = self.recv_line(timeout, &format!("the {verb} response"))?;
parse_response(&resp, id, verb)
}
@@ -781,6 +1016,16 @@ impl EditorTransport for SocketEditor {
self.call("run_sql", args)
}
+ // Phase 0A read verbs.
+
+ fn list_languages(&self) -> Result {
+ self.call("list_languages", json!({}))
+ }
+
+ fn get_capabilities(&self) -> Result {
+ self.call("get_capabilities", json!({}))
+ }
+
// v0.1.119 act verb.
fn open_note(&mut self, file: &str) -> Result {
@@ -812,6 +1057,113 @@ impl EditorTransport for SocketEditor {
json!({ "tab_index": tab_index, "path": path, "format": format }),
)
}
+
+ // Phase 1 verbs — verbatim JSON passthrough.
+
+ fn create_diagram(
+ &mut self,
+ source: Option<&str>,
+ title: Option<&str>,
+ ) -> Result {
+ // Optional keys are sent only when present so the wire stays minimal.
+ let mut args = json!({});
+ if let Some(s) = source {
+ args["source"] = json!(s);
+ }
+ if let Some(t) = title {
+ args["title"] = json!(t);
+ }
+ self.call("create_diagram", args)
+ }
+
+ fn get_diagram_source(&self, tab_index: usize) -> Result {
+ self.call("get_diagram_source", json!({ "tab_index": tab_index }))
+ }
+
+ fn set_diagram_source(
+ &mut self,
+ tab_index: usize,
+ source: &str,
+ ) -> Result {
+ self.call(
+ "set_diagram_source",
+ json!({ "tab_index": tab_index, "source": source }),
+ )
+ }
+
+ fn open_noter(&mut self) -> Result {
+ self.call("open_noter", json!({}))
+ }
+
+ // Phase 2 — data-analyst + charts. Optional keys are sent only when
+ // present so the wire stays minimal (mirrors create_diagram).
+
+ fn list_connections(&self) -> Result {
+ self.call("list_connections", json!({}))
+ }
+
+ fn run_query(
+ &self,
+ connection_name: &str,
+ sql: &str,
+ max_rows: Option,
+ ) -> Result {
+ let mut args = json!({ "connection_name": connection_name, "sql": sql });
+ if let Some(n) = max_rows {
+ args["max_rows"] = json!(n);
+ }
+ self.call("run_query", args)
+ }
+
+ fn list_tables(&self, connection_name: &str) -> Result {
+ self.call("list_tables", json!({ "connection_name": connection_name }))
+ }
+
+ fn open_data_analyst(&mut self) -> Result {
+ self.call("open_data_analyst", json!({}))
+ }
+
+ fn render_chart(&mut self, spec: &Value, title: Option<&str>) -> Result {
+ let mut args = json!({ "spec": spec.clone() });
+ if let Some(t) = title {
+ args["title"] = json!(t);
+ }
+ self.call("render_chart", args)
+ }
+
+ fn export_query_results(
+ &mut self,
+ connection_name: &str,
+ sql: &str,
+ path: &str,
+ format: &str,
+ max_rows: Option,
+ ) -> Result {
+ let mut args = json!({
+ "connection_name": connection_name,
+ "sql": sql,
+ "path": path,
+ "format": format,
+ });
+ if let Some(n) = max_rows {
+ args["max_rows"] = json!(n);
+ }
+ self.call("export_query_results", args)
+ }
+
+ fn export_chart(
+ &mut self,
+ spec: &Value,
+ path: &str,
+ format: &str,
+ scale: Option,
+ ) -> Result {
+ let mut args = json!({ "spec": spec.clone(), "path": path, "format": format });
+ if let Some(s) = scale {
+ args["scale"] = json!(s);
+ }
+ self.call("export_chart", args)
+ }
}
/// Minimal SHA-1 (only for the server-name derivation above; deps are
diff --git a/notepatra-mcp/tests/common/mod.rs b/notepatra-mcp/tests/common/mod.rs
new file mode 100644
index 0000000..8a85815
--- /dev/null
+++ b/notepatra-mcp/tests/common/mod.rs
@@ -0,0 +1,161 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+//! Shared hang containment for the integration suites.
+//!
+//! WHY THIS EXISTS: a hung CI job is strictly worse than a failing one. A red
+//! test names its culprit in seconds; a hang burns the full job budget
+//! (~80 minutes on the Windows runner) and reports NOTHING. Every suite that
+//! drives a real transport — named pipes (`pipe_bridge`), unix sockets
+//! (`socket_bridge`), loopback TCP (`remote_gateway`) — can block on a peer
+//! that never answers, so all three arm the same two-layer net:
+//!
+//! 1. [`finish`] — a BOUNDED wait for a helper thread. Never `join()` bare:
+//! `join` is unbounded, and a server closure parked in `read_line` turns
+//! it into a permanent hang.
+//! 2. [`Watchdog`] — the backstop for the case no bounded join can catch: a
+//! regression that blocks the TEST thread itself (a write that never
+//! returns, a socket read with no deadline). It aborts the binary so CI
+//! reports a failure instead of hanging.
+//!
+//! Living in `tests/common/` (a directory module, not a `tests/*.rs` file) it
+//! is compiled into each suite that says `mod common;` and is NOT itself
+//! auto-discovered as a test target.
+#![allow(dead_code)] // each suite uses a subset; the unused half must not warn
+
+use std::io::Write;
+use std::path::PathBuf;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::mpsc::{Receiver, RecvTimeoutError};
+use std::sync::Arc;
+use std::thread::JoinHandle;
+use std::time::{Duration, Instant};
+
+/// How long a helper thread may take to finish after the client is done.
+/// Healthy runs are sub-second; the deliberate 3 s server sleep in
+/// `pipe_bridge::response_timeout_enforced` is the slowest legitimate case.
+pub const JOIN_BUDGET: Duration = Duration::from_secs(15);
+
+/// Backstop deadline for a test that blocks its OWN thread. Must stay
+/// comfortably above [`JOIN_BUDGET`] so the bounded join reports first — the
+/// join produces an attributable panic, the watchdog only an abort.
+pub const WATCHDOG_SECS: u64 = 30;
+
+/// `/np--tracker.log`. CI prints every `np-*-tracker.log` in an
+/// `if: always()` step.
+pub fn tracker_path(suite: &str) -> PathBuf {
+ std::env::temp_dir().join(format!("np-{suite}-tracker.log"))
+}
+
+/// Append one line to this suite's tracker file, flushed and closed per line.
+///
+/// WHY A FILE: when this binary aborts, libtest's captured stdout/stderr is
+/// DISCARDED — that is exactly how a previous CI incident lost the name of the
+/// hanging test and cost multiple 80-minute cycles. A file survives `abort()`
+/// and survives capture. The same localizer pattern rescued the C++
+/// offscreen-Windows teardown crash. An abort therefore leaves "ENTER x" +
+/// "TIMEOUT x" with no "LEAVE x", naming the culprit in one log read.
+/// (A `finish` timeout unwinds normally, so it DOES write "LEAVE"; that path
+/// needs no tracker, since libtest attributes the panic to the test itself.)
+/// ONE `write_all` of a pre-formatted line, never `writeln!`. `File` is
+/// unbuffered, so `writeln!(f, "{event} {label}")` issues a separate syscall
+/// per format fragment; under the default parallel harness those interleave and
+/// the tracker becomes unreadable garbage ("ENTERENTERENTER bridge_error..."),
+/// destroying the one artifact that survives an abort. A single append-mode
+/// write is atomic, so lines from concurrent tests stay whole.
+fn track(suite: &str, event: &str, label: &str) {
+ let line = format!("{event} {label}\n");
+ if let Ok(mut f) = std::fs::OpenOptions::new()
+ .create(true)
+ .append(true)
+ .open(tracker_path(suite))
+ {
+ let _ = f.write_all(line.as_bytes());
+ let _ = f.flush();
+ }
+}
+
+/// Fails the whole test binary FAST if a test overruns, instead of letting it
+/// hang. This is the LAST line of defence, not the first: waits that CAN be
+/// bounded are bounded at their own call site by [`finish`] or by a socket
+/// timeout. What only the watchdog catches is a block on the test thread.
+///
+/// `abort()` rather than `panic!` on purpose: the panic would land on the
+/// watchdog's own thread, where libtest cannot attribute it to the test, and
+/// the blocked thread would keep the binary alive regardless. Because abort
+/// throws away captured output, the name goes to the tracker FILE too.
+pub struct Watchdog {
+ done: Arc,
+ suite: &'static str,
+ label: &'static str,
+}
+
+impl Watchdog {
+ /// Arms the watchdog for `label` and records "ENTER label".
+ pub fn new(suite: &'static str, label: &'static str) -> Self {
+ Self::with_secs(suite, label, WATCHDOG_SECS)
+ }
+
+ pub fn with_secs(suite: &'static str, label: &'static str, secs: u64) -> Self {
+ track(suite, "ENTER", label);
+ let done = Arc::new(AtomicBool::new(false));
+ let flag = done.clone();
+ std::thread::spawn(move || {
+ let deadline = Instant::now() + Duration::from_secs(secs);
+ while Instant::now() < deadline {
+ if flag.load(Ordering::Relaxed) {
+ return;
+ }
+ std::thread::sleep(Duration::from_millis(50));
+ }
+ track(suite, "TIMEOUT", label);
+ eprintln!(
+ "{suite}: {label} exceeded {secs}s — a transport call is BLOCKED \
+ (no peer attached, or a peer that never answered). Aborting so \
+ CI reports a failure rather than hanging."
+ );
+ std::process::abort();
+ });
+ Self { done, suite, label }
+ }
+}
+
+impl Drop for Watchdog {
+ fn drop(&mut self) {
+ self.done.store(true, Ordering::Relaxed);
+ track(self.suite, "LEAVE", self.label);
+ }
+}
+
+/// A running fake bridge: its thread plus the channel it signals on completion.
+pub struct Bridge {
+ handle: JoinHandle<()>,
+ done: Receiver<()>,
+}
+
+impl Bridge {
+ /// `done` must be signalled as the thread's LAST act (see [`finish`]).
+ pub fn new(handle: JoinHandle<()>, done: Receiver<()>) -> Self {
+ Self { handle, done }
+ }
+}
+
+/// Waits for a bridge thread with a DEADLINE, then surfaces its panics.
+///
+/// No bare `join()` may remain in these suites. `join()` is unbounded, and a
+/// server closure blocked in `read_line` (waiting for a client teardown that
+/// never comes) turns it into a permanent hang — and a hang is not a red, it is
+/// a full-budget CI burn that reports nothing. The completion channel is
+/// signalled as the thread's LAST act, so once `recv_timeout` returns the
+/// `join` is instantaneous and exists only to re-raise assertion failures.
+pub fn finish(bridge: Bridge, label: &str) {
+ match bridge.done.recv_timeout(JOIN_BUDGET) {
+ // Sent on the way out, or the sender was dropped by an unwinding
+ // panic — either way the thread is finished, so join to surface it.
+ Ok(()) | Err(RecvTimeoutError::Disconnected) => {}
+ Err(RecvTimeoutError::Timeout) => panic!(
+ "{label}: the bridge thread did not finish within {JOIN_BUDGET:?} — \
+ it is blocked (almost certainly in read_line, waiting for a client \
+ EOF that never arrived)"
+ ),
+ }
+ bridge.handle.join().expect("bridge thread panicked");
+}
diff --git a/notepatra-mcp/tests/endpoint_discovery.rs b/notepatra-mcp/tests/endpoint_discovery.rs
new file mode 100644
index 0000000..f358d67
--- /dev/null
+++ b/notepatra-mcp/tests/endpoint_discovery.rs
@@ -0,0 +1,183 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+//! End-to-end endpoint discovery: the editor publishes `mcp-endpoint.json`,
+//! the sidecar reads it, dials it first, and falls through to the next
+//! candidate when it is stale.
+//!
+//! Unix-only because it needs a real `UnixListener` to prove the fall-through
+//! against a LIVE peer. The pure validation matrix (kinds, prefixes, garbage)
+//! lives in the `transport::endpoint` unit tests and runs on every platform.
+#![cfg(unix)]
+
+use std::io::{BufRead, BufReader, Write};
+use std::os::unix::net::{UnixListener, UnixStream};
+use std::path::PathBuf;
+use std::sync::mpsc;
+use std::time::Duration;
+
+mod common;
+use common::{finish, Bridge, Watchdog};
+
+/// Names this suite's tracker file (`np-endpoint-discovery-tracker.log`).
+const SUITE: &str = "endpoint-discovery";
+
+use notepatra_mcp::transport::endpoint::published_endpoint;
+use notepatra_mcp::transport::socket::{SocketEditor, NOT_RUNNING};
+use notepatra_mcp::transport::EditorTransport;
+use serde_json::{json, Value};
+
+const GREETING: &str = r#"{"notepatra_mcp":1,"app":"Notepatra","version":"0.1.120"}"#;
+
+/// Unique, SHORT socket path — unix socket paths cap near 108 bytes, so the
+/// system temp dir is used rather than the (long) config tempdir.
+fn temp_socket_path() -> PathBuf {
+ std::env::temp_dir().join(format!("np-mcp-ep-{}.sock", std::process::id()))
+}
+
+/// Binds a listener, then serves the greeting + one `app_info` request on the
+/// first accepted connection. Mirrors the helper in tests/socket_bridge.rs.
+///
+/// HONEST RESIDUAL: `listener.accept()` is unbounded (std has no accept
+/// timeout), so a regression that stops the client connecting would park this
+/// thread forever. Its deadline is enforced one level up — by [`finish`]'s
+/// `recv_timeout` and by the [`Watchdog`] — so that regression is a fast NAMED
+/// failure instead of a job-length hang.
+fn spawn_bridge(path: &PathBuf) -> Bridge {
+ let _ = std::fs::remove_file(path);
+ let listener = UnixListener::bind(path).expect("bind fake bridge socket");
+ let (done_tx, done) = mpsc::channel::<()>();
+ let handle = std::thread::spawn(move || {
+ let (stream, _) = listener.accept().expect("accept");
+ serve_app_info(stream);
+ // LAST act: unblocks `finish`. On a panic this send never happens, but
+ // `done_tx` drops during unwind and `finish` treats Disconnected as
+ // "finished", so the panic is re-raised by the join.
+ let _ = done_tx.send(());
+ });
+ Bridge::new(handle, done)
+}
+
+fn serve_app_info(stream: UnixStream) {
+ let mut reader = BufReader::new(stream.try_clone().expect("clone"));
+ let mut stream = stream;
+ // Greeting before payload: the bridge speaks first.
+ writeln!(stream, "{GREETING}").unwrap();
+ let mut line = String::new();
+ reader.read_line(&mut line).unwrap();
+ let req: Value = serde_json::from_str(&line).expect("request is JSON");
+ assert_eq!(req["verb"], "app_info");
+ let resp = json!({
+ "id": req["id"],
+ "ok": true,
+ "result": {"name": "Notepatra", "version": "0.1.120",
+ "edition": "Full", "platform": "linux"},
+ });
+ writeln!(stream, "{resp}").unwrap();
+}
+
+/// ONE test on purpose: `NOTEPATRA_MCP_CONFIG_DIR` is process-global state, so
+/// splitting these steps into separate `#[test]` fns would let the parallel
+/// test threads clobber each other's env.
+#[test]
+fn endpoint_file_is_discovered_and_stale_entries_fall_through() {
+ let _wd = Watchdog::new(
+ SUITE,
+ "endpoint_file_is_discovered_and_stale_entries_fall_through",
+ );
+ // (a) A private config dir that the editor would have written into.
+ let config_dir = std::env::temp_dir().join(format!("np-mcp-epcfg-{}", std::process::id()));
+ std::fs::create_dir_all(&config_dir).expect("create config dir");
+ std::env::set_var("NOTEPATRA_MCP_CONFIG_DIR", &config_dir);
+ let endpoint_json = config_dir.join("mcp-endpoint.json");
+
+ // (b) A fake bridge at a path the computed guess would never produce —
+ // exactly the macOS situation the published file exists to solve.
+ let live = temp_socket_path();
+ let live_str = live.to_str().unwrap().to_string();
+ let bridge = spawn_bridge(&live);
+
+ // (c) Publication is read back verbatim.
+ let record = json!({
+ "notepatra_mcp_endpoint": 1,
+ "kind": "unix_socket",
+ "value": live_str,
+ "name": "notepatra-0123456789abcdef-mcp",
+ "pid": std::process::id(),
+ "version": "0.1.120",
+ });
+ std::fs::write(&endpoint_json, format!("{record}\n")).expect("write endpoint file");
+ assert_eq!(published_endpoint().as_deref(), Some(live_str.as_str()));
+
+ // (d) A STALE first candidate (crashed editor) must not shadow a live
+ // later one — only connect failure advances, and it does.
+ let ed = SocketEditor::with_candidates(vec![
+ "/nonexistent/stale-endpoint".to_string(),
+ live_str.clone(),
+ ])
+ .with_timeouts(Duration::from_secs(1), Duration::from_secs(1));
+ let info = ed.app_info().expect("live later candidate must be reached");
+ assert_eq!(info["name"], "Notepatra");
+ finish(
+ bridge,
+ "endpoint_file_is_discovered_and_stale_entries_fall_through",
+ );
+ let _ = std::fs::remove_file(&live);
+
+ // (e) Every candidate dead → the EXACT user-facing message, not a
+ // per-candidate io error.
+ let dead = SocketEditor::with_candidates(vec![
+ "/nonexistent/one".to_string(),
+ "/nonexistent/two".to_string(),
+ ])
+ .with_timeouts(Duration::from_secs(1), Duration::from_secs(1));
+ assert_eq!(dead.app_info().unwrap_err().0, NOT_RUNNING);
+
+ // (f) No file (editor shut down cleanly) → nothing published.
+ std::fs::remove_file(&endpoint_json).expect("remove endpoint file");
+ assert_eq!(published_endpoint(), None);
+
+ // (g) Oversized file → rejected without being parsed.
+ std::fs::write(&endpoint_json, "x".repeat(64 * 1024 + 1)).expect("write oversized");
+ assert_eq!(published_endpoint(), None);
+
+ // (h) The other platform's kind is never accepted here.
+ let foreign = json!({
+ "notepatra_mcp_endpoint": 1,
+ "kind": "named_pipe",
+ "value": r"\\.\pipe\notepatra-0123456789abcdef-mcp",
+ });
+ std::fs::write(&endpoint_json, format!("{foreign}\n")).expect("write foreign kind");
+ assert_eq!(published_endpoint(), None);
+
+ // (i) A FIFO planted at the endpoint path must be REFUSED, not read. Its
+ // metadata reports len 0, so it slips past the size gate; without the
+ // is_file() guard read_to_string would block forever and hang every sidecar
+ // startup. Guard the assertion with a watchdog thread so a regression fails
+ // this test LOUDLY instead of hanging CI (a hang is not a red).
+ #[cfg(unix)]
+ {
+ std::fs::remove_file(&endpoint_json).expect("clear before fifo");
+ // Shell out rather than link libc: the crate is deliberately
+ // dependency-free, and this is a test-only one-shot.
+ let made = std::process::Command::new("mkfifo")
+ .arg(&endpoint_json)
+ .status()
+ .map(|s| s.success())
+ .unwrap_or(false);
+ if made {
+ let (tx, rx) = std::sync::mpsc::channel();
+ std::thread::spawn(move || {
+ let _ = tx.send(published_endpoint());
+ });
+ match rx.recv_timeout(Duration::from_secs(5)) {
+ Ok(v) => assert_eq!(v, None, "a FIFO must be refused, not parsed"),
+ Err(_) => panic!(
+ "published_endpoint() BLOCKED on a FIFO — the is_file() guard \
+ in transport::endpoint is missing or broken"
+ ),
+ }
+ }
+ }
+
+ std::env::remove_var("NOTEPATRA_MCP_CONFIG_DIR");
+ let _ = std::fs::remove_dir_all(&config_dir);
+}
diff --git a/notepatra-mcp/tests/pipe_bridge.rs b/notepatra-mcp/tests/pipe_bridge.rs
new file mode 100644
index 0000000..ce9fc25
--- /dev/null
+++ b/notepatra-mcp/tests/pipe_bridge.rs
@@ -0,0 +1,260 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+//! Drives SocketEditor against a fake C++ bridge on a REAL Windows named pipe:
+//! a std-thread server speaking the greeting + newline-delimited verb protocol.
+//! The unix twin lives in tests/socket_bridge.rs; this file is the only place
+//! the `#[cfg(windows)]` `Conn` (command-gated read worker +
+//! `mpsc::recv_timeout`) is exercised at runtime — the Windows CI job runs
+//! `cargo test --release`. It earned its keep in v0.1.120 by catching a
+//! first-request deadlock in the SHIPPED Windows transport that nothing else
+//! covers (the C++ suite tests the server half only).
+//!
+//! std alone cannot CREATE a named pipe, only open one, so the server half is
+//! three lines of `extern "system"` against kernel32 — always linked on
+//! Windows, so the default build's dependency graph stays untouched (no
+//! winapi / windows-sys). Test-only unsafe.
+//!
+//! HONEST RESIDUAL: this proves the CLIENT half against a byte-mode Win32 pipe
+//! created with default security. It does NOT cover QLocalServer's server-side
+//! behavior — its security descriptor / `setSocketOptions(UserAccessOption)`
+//! ACL, its own framing choices, or its connection backlog. Those are covered
+//! only by the C++ `test_mcp_bridge` suite and by live use of the editor.
+#![cfg(windows)]
+
+use std::io::{BufRead, BufReader, Write};
+use std::os::windows::io::FromRawHandle;
+use std::sync::atomic::{AtomicU32, Ordering};
+use std::sync::mpsc;
+use std::time::{Duration, Instant};
+
+mod common;
+use common::{finish, Bridge, Watchdog};
+
+/// Names this suite's tracker file (`np-pipe-bridge-tracker.log`).
+const SUITE: &str = "pipe-bridge";
+
+use notepatra_mcp::transport::socket::{SocketEditor, NOT_RUNNING};
+use notepatra_mcp::transport::{EditorTransport, TabSelector};
+use serde_json::{json, Value};
+
+const GREETING: &str = r#"{"notepatra_mcp":1,"app":"Notepatra","version":"0.1.120"}"#;
+
+#[link(name = "kernel32")]
+extern "system" {
+ fn CreateNamedPipeW(
+ name: *const u16,
+ dw_open_mode: u32,
+ dw_pipe_mode: u32,
+ n_max_instances: u32,
+ n_out_buffer: u32,
+ n_in_buffer: u32,
+ n_default_timeout: u32,
+ security: *mut core::ffi::c_void,
+ ) -> isize;
+ fn ConnectNamedPipe(h: isize, overlapped: *mut core::ffi::c_void) -> i32;
+}
+
+const PIPE_ACCESS_DUPLEX: u32 = 0x0000_0003;
+/// PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT — all zero. Byte mode
+/// deliberately mirrors Qt's QLocalServer pipes: a message-mode pipe would
+/// hide framing bugs the real editor would expose.
+const PIPE_BYTE_MODE_WAIT: u32 = 0x0;
+const PIPE_BUF_BYTES: u32 = 64 * 1024;
+const INVALID_HANDLE_VALUE: isize = -1;
+
+/// Unique pipe name for this process + call. Pipe names live in a global
+/// kernel namespace, so the pid keeps concurrent CI jobs from colliding.
+fn unique_pipe_name() -> String {
+ static N: AtomicU32 = AtomicU32::new(0);
+ let n = N.fetch_add(1, Ordering::Relaxed);
+ format!(r"\\.\pipe\np-mcp-test-{}-{n}", std::process::id())
+}
+
+/// Creates the pipe instance BEFORE returning (so the client's open can never
+/// race the server thread's startup), then runs `behavior` on the connected
+/// pipe once a client attaches.
+///
+/// HONEST RESIDUAL: `ConnectNamedPipe` and the server closure's `read_line` are
+/// synchronous and std-only, so neither can carry a native deadline. Their
+/// deadline is enforced one level up, by [`finish`]'s `recv_timeout` and by the
+/// [`Watchdog`]. Overlapped I/O would add unsafe surface for no extra safety —
+/// the pipe already exists before this function returns, so the client's open
+/// cannot race it, and a client that never arrives is now a fast NAMED failure.
+fn spawn_pipe_bridge(behavior: impl FnOnce(std::fs::File) + Send + 'static) -> (String, Bridge) {
+ let name = unique_pipe_name();
+ let wide: Vec = name.encode_utf16().chain(std::iter::once(0)).collect();
+ let handle = unsafe {
+ CreateNamedPipeW(
+ wide.as_ptr(),
+ PIPE_ACCESS_DUPLEX,
+ PIPE_BYTE_MODE_WAIT,
+ 1, // one instance: each test gets its own pipe name
+ PIPE_BUF_BYTES,
+ PIPE_BUF_BYTES,
+ 0, // default timeout only matters for WaitNamedPipe
+ core::ptr::null_mut(),
+ )
+ };
+ assert_ne!(
+ handle,
+ INVALID_HANDLE_VALUE,
+ "CreateNamedPipeW({name}) failed: {}",
+ std::io::Error::last_os_error()
+ );
+ let (done_tx, done) = mpsc::channel::<()>();
+ let join = std::thread::spawn(move || {
+ // Any return is "proceed": a 0 return with ERROR_PIPE_CONNECTED means
+ // the client got in between create and connect, and a genuine failure
+ // surfaces immediately as an I/O error in `behavior`.
+ unsafe { ConnectNamedPipe(handle, core::ptr::null_mut()) };
+ let file = unsafe { std::fs::File::from_raw_handle(handle as *mut _) };
+ behavior(file);
+ // LAST act: unblocks `finish`. On a panic this send never happens, but
+ // `done_tx` drops during unwind and `finish` treats Disconnected as
+ // "finished" so the panic is re-raised by the join.
+ let _ = done_tx.send(());
+ });
+ (name, Bridge::new(join, done))
+}
+
+fn editor_for(pipe: &str) -> SocketEditor {
+ SocketEditor::with_socket_path(pipe)
+ .with_timeouts(Duration::from_secs(1), Duration::from_secs(1))
+}
+
+#[test]
+fn greeting_then_round_trips_with_sequential_ids() {
+ let _wd = Watchdog::new(SUITE, "greeting_then_round_trips_with_sequential_ids");
+ let (pipe, bridge) = spawn_pipe_bridge(|file| {
+ let mut reader = BufReader::new(file.try_clone().expect("clone pipe handle"));
+ let mut file = file;
+ // Greeting before payload: the bridge speaks first.
+ writeln!(file, "{GREETING}").unwrap();
+ for expected_id in 1..=2u64 {
+ let mut line = String::new();
+ reader.read_line(&mut line).unwrap();
+ let req: Value = serde_json::from_str(&line).expect("request is JSON");
+ assert_eq!(req["id"], expected_id, "ids must be sequential");
+ assert!(req["args"].is_object());
+ let result = match req["verb"].as_str().unwrap() {
+ "app_info" => {
+ assert_eq!(req["args"], json!({}));
+ json!({ "name": "Notepatra", "version": "0.1.120",
+ "edition": "Full", "platform": "windows" })
+ }
+ "open_file" => {
+ assert_eq!(req["args"], json!({ "path": "/tmp/x.txt" }));
+ // Exact bridge shape (src/mcp_bridge.cpp verbOpenFile).
+ json!({ "opened": true, "tab_index": 4 })
+ }
+ other => panic!("unexpected verb {other}"),
+ };
+ let resp = json!({ "id": expected_id, "ok": true, "result": result });
+ writeln!(file, "{resp}").unwrap();
+ }
+ });
+ let mut ed = editor_for(&pipe);
+ let info = ed.app_info().expect("app_info round-trip");
+ assert_eq!(info["edition"], "Full");
+ // Second call reuses the same pipe connection with the next id, proving
+ // the reader thread survives between round-trips.
+ let index = ed.open_file("/tmp/x.txt").expect("open_file round-trip");
+ assert_eq!(index, 4);
+ finish(bridge, "greeting_then_round_trips_with_sequential_ids");
+}
+
+#[test]
+fn missing_pipe_is_not_running() {
+ let _wd = Watchdog::new(SUITE, "missing_pipe_is_not_running");
+ // Never created: opening it must surface the clean tool error, not an
+ // OS-error string leaking into the agent's transcript.
+ let pipe = format!(r"\\.\pipe\np-mcp-definitely-absent-{}", std::process::id());
+ let ed = editor_for(&pipe);
+ let err = ed.list_open_tabs().unwrap_err();
+ assert_eq!(err.0, NOT_RUNNING);
+ let err = ed.get_status().unwrap_err();
+ assert_eq!(err.0, NOT_RUNNING);
+}
+
+#[test]
+fn invalid_greeting_rejected() {
+ let _wd = Watchdog::new(SUITE, "invalid_greeting_rejected");
+ let (pipe, bridge) = spawn_pipe_bridge(|file| {
+ let mut reader = BufReader::new(file.try_clone().expect("clone pipe handle"));
+ let mut file = file;
+ writeln!(file, r#"{{"hello":true}}"#).unwrap();
+ // Proof-of-life law: the client must hang up without sending anything.
+ // std maps ERROR_BROKEN_PIPE to Ok(0), so a disconnect reads as EOF.
+ let mut line = String::new();
+ let n = reader.read_line(&mut line).unwrap_or(0);
+ assert_eq!(n, 0, "client sent a payload after a bad greeting: {line}");
+ });
+ let ed = editor_for(&pipe);
+ let err = ed.read_tab(TabSelector::Index(0)).unwrap_err();
+ assert!(
+ err.0.contains("unexpected greeting"),
+ "unexpected error: {}",
+ err.0
+ );
+ drop(ed); // closes the client handle so the bridge's read sees EOF
+ finish(bridge, "invalid_greeting_rejected");
+}
+
+#[test]
+fn response_timeout_enforced() {
+ let _wd = Watchdog::new(SUITE, "response_timeout_enforced");
+ // The Windows path has no socket read timeout — it fakes one with a reader
+ // thread plus `recv_timeout`. This pins that plumbing: a bridge that reads
+ // the request and then goes silent must fail FAST, never hang the agent.
+ let (pipe, bridge) = spawn_pipe_bridge(|file| {
+ let mut reader = BufReader::new(file.try_clone().expect("clone pipe handle"));
+ let mut file = file;
+ writeln!(file, "{GREETING}").unwrap();
+ let mut line = String::new();
+ let _ = reader.read_line(&mut line);
+ // Hold the pipe open, well past the client's 1 s window, so the
+ // failure is a TIMEOUT and not a closed connection.
+ std::thread::sleep(Duration::from_secs(3));
+ });
+ let ed = editor_for(&pipe);
+ let started = Instant::now();
+ let err = ed.list_open_tabs().unwrap_err();
+ let elapsed = started.elapsed();
+ assert!(
+ err.0.contains("timed out waiting"),
+ "unexpected error: {}",
+ err.0
+ );
+ assert!(
+ elapsed < Duration::from_millis(2500),
+ "recv_timeout did not fire: took {elapsed:?} against a 1 s timeout"
+ );
+ finish(bridge, "response_timeout_enforced");
+}
+
+#[test]
+fn editor_close_detected() {
+ let _wd = Watchdog::new(SUITE, "editor_close_detected");
+ let (pipe, bridge) = spawn_pipe_bridge(|file| {
+ let mut file = file;
+ writeln!(file, "{GREETING}").unwrap();
+ // Returning drops the server handle: the editor went away mid-session.
+ });
+ let ed = editor_for(&pipe);
+ let err = ed.list_open_tabs().unwrap_err();
+ // Which side notices first is a genuine race — the request write may hit
+ // the broken pipe before the reader thread's queued EOF is drained — so
+ // both wordings are accepted. What is NOT acceptable (and is what this
+ // test guards) is a hang or a timeout: a dead editor must be reported at
+ // once, so the message must not be the timeout one.
+ assert!(
+ err.0.contains("closed the connection") || err.0.contains("connection lost"),
+ "unexpected error: {}",
+ err.0
+ );
+ assert!(
+ !err.0.contains("timed out"),
+ "a closed pipe must fail immediately, not time out: {}",
+ err.0
+ );
+ finish(bridge, "editor_close_detected");
+}
diff --git a/notepatra-mcp/tests/protocol.rs b/notepatra-mcp/tests/protocol.rs
index 0c9e93d..8092d09 100644
--- a/notepatra-mcp/tests/protocol.rs
+++ b/notepatra-mcp/tests/protocol.rs
@@ -65,7 +65,7 @@ fn initialize_handshake() {
assert_eq!(r["protocolVersion"], LATEST_PROTOCOL_VERSION);
assert_eq!(r["serverInfo"]["name"], "notepatra-mcp");
// Default version comes from Cargo.toml (no env override).
- assert_eq!(r["serverInfo"]["version"], "0.1.119");
+ assert_eq!(r["serverInfo"]["version"], "0.1.120");
assert!(r["capabilities"]["tools"].is_object());
assert!(r["capabilities"]["resources"].is_object());
assert!(r["capabilities"]["prompts"].is_object());
@@ -139,10 +139,26 @@ fn tools_list_shape() {
"create_note",
"append_note",
"set_reminder",
- "export_diagram"
+ "export_diagram",
+ // p0a
+ "list_languages",
+ "get_capabilities",
+ // phase 1
+ "create_diagram",
+ "get_diagram_source",
+ "set_diagram_source",
+ "open_noter",
+ // phase 2 — data-analyst + charts
+ "list_connections",
+ "run_query",
+ "list_tables",
+ "open_data_analyst",
+ "render_chart",
+ "export_query_results",
+ "export_chart"
]
);
- assert_eq!(names.len(), 35);
+ assert_eq!(names.len(), 48);
for tool in tools {
assert!(tool["description"].as_str().is_some_and(|d| !d.is_empty()));
let schema = &tool["inputSchema"];
@@ -593,7 +609,7 @@ fn write_tool_descriptions_state_the_approval_gate() {
let responses =
run_lines(&[json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list" }).to_string()]);
let tools = responses[0]["result"]["tools"].as_array().unwrap();
- assert_eq!(tools.len(), 35);
+ assert_eq!(tools.len(), 48);
let write_tools = [
"insert_text",
"replace_selection",
@@ -603,6 +619,9 @@ fn write_tool_descriptions_state_the_approval_gate() {
"append_note",
"set_reminder",
"export_diagram",
+ "set_diagram_source",
+ "export_query_results",
+ "export_chart",
];
for tool in tools {
let name = tool["name"].as_str().unwrap();
@@ -1201,3 +1220,282 @@ fn search_project_clamps_max_results_to_200() {
assert_eq!(hits["results"].as_array().unwrap().len(), 200);
assert_eq!(hits["truncated"], true);
}
+
+#[test]
+fn p0a_list_languages_and_get_capabilities() {
+ let responses = run_lines(&[
+ initialize_line(1, LATEST_PROTOCOL_VERSION),
+ call_line(2, "list_languages", json!({})),
+ call_line(3, "get_capabilities", json!({})),
+ call_line(4, "get_capabilities", json!({ "bogus": 1 })),
+ ]);
+ // list_languages: canonical tokens, exact case.
+ assert_eq!(responses[1]["result"]["isError"], false);
+ let langs: Value = serde_json::from_str(
+ responses[1]["result"]["content"][0]["text"]
+ .as_str()
+ .unwrap(),
+ )
+ .unwrap();
+ let arr = langs["languages"].as_array().unwrap();
+ assert_eq!(arr[0], "Plain Text");
+ assert!(arr.iter().any(|l| l == "Python"));
+ assert!(arr.iter().any(|l| l == "C++"));
+ // get_capabilities: editor fields pass through; tool_count and tiers are
+ // injected by the tool layer and MUST match the definitions list.
+ assert_eq!(responses[2]["result"]["isError"], false);
+ let caps: Value = serde_json::from_str(
+ responses[2]["result"]["content"][0]["text"]
+ .as_str()
+ .unwrap(),
+ )
+ .unwrap();
+ let expected = notepatra_mcp::tools::definitions()
+ .as_array()
+ .unwrap()
+ .len();
+ assert_eq!(caps["tool_count"], expected as u64);
+ assert_eq!(caps["tool_count"], 48);
+ assert_eq!(caps["tiers"]["read"], 24);
+ assert_eq!(caps["tiers"]["act"], 13);
+ assert_eq!(caps["tiers"]["write"], 11);
+ assert!(caps["features"]["duckdb"].is_boolean());
+ assert!(caps["features"]["webengine"].is_boolean());
+ assert!(caps["features"]["noter"].is_boolean());
+ assert_eq!(caps["edition"], "Lite"); // mock's canned edition
+ // Extra argument → -32602 (additionalProperties: false enforced).
+ assert_eq!(responses[3]["error"]["code"], -32602);
+}
+
+#[test]
+fn p0a_tier_lists_partition_the_tool_surface() {
+ use notepatra_mcp::tools::{definitions, ACT_TOOLS, READ_TOOLS, WRITE_TOOLS};
+ let defs = definitions();
+ let names: std::collections::BTreeSet = defs
+ .as_array()
+ .unwrap()
+ .iter()
+ .map(|t| t["name"].as_str().unwrap().to_string())
+ .collect();
+ let mut tiered: Vec<&str> = Vec::new();
+ tiered.extend(READ_TOOLS);
+ tiered.extend(ACT_TOOLS);
+ tiered.extend(WRITE_TOOLS);
+ // No tool in two tiers, no tool untiered, no phantom tier entry.
+ assert_eq!(tiered.len(), names.len());
+ let tiered_set: std::collections::BTreeSet =
+ tiered.iter().map(|s| s.to_string()).collect();
+ assert_eq!(tiered_set, names);
+}
+
+#[test]
+fn phase1_diagram_and_noter_tools() {
+ let responses = run_lines(&[
+ initialize_line(1, LATEST_PROTOCOL_VERSION),
+ call_line(
+ 2,
+ "create_diagram",
+ json!({ "source": "diagram flow\n", "title": "flow" }),
+ ),
+ call_line(3, "get_diagram_source", json!({ "tab_index": 3 })),
+ call_line(
+ 4,
+ "set_diagram_source",
+ json!({ "tab_index": 3, "source": "diagram er\n" }),
+ ),
+ call_line(5, "get_diagram_source", json!({ "tab_index": 3 })),
+ call_line(6, "get_diagram_source", json!({ "tab_index": 0 })), // not a diagram
+ call_line(7, "create_diagram", json!({ "source": "!!bad\n" })), // invalid still creates
+ call_line(8, "open_noter", json!({})),
+ ]);
+ let created = json_of(&responses[1]);
+ assert_eq!(created["tab_index"], 3);
+ assert_eq!(created["valid"], true);
+ assert_eq!(json_of(&responses[2])["source"], "diagram flow\n");
+ let set = json_of(&responses[3]);
+ assert_eq!(set["ok"], true);
+ assert_eq!(set["tab_index"], 3);
+ assert_eq!(set["valid"], true);
+ assert_eq!(json_of(&responses[4])["source"], "diagram er\n");
+ assert_eq!(responses[5]["result"]["isError"], true);
+ assert!(text_of(&responses[5]).contains("not a diagram"));
+ let bad = json_of(&responses[6]);
+ assert_eq!(bad["valid"], false);
+ assert!(bad["errors"].as_array().is_some_and(|e| !e.is_empty()));
+ assert_eq!(json_of(&responses[7])["opened"], true);
+}
+
+#[test]
+fn phase1_set_diagram_source_is_approval_gated_but_create_is_not() {
+ let mut editor = MockEditor::default();
+ editor.set_approval(ApprovalMode::Deny);
+ let responses = run_lines_with(
+ editor,
+ &[
+ call_line(1, "create_diagram", json!({ "source": "diagram flow\n" })), // ACT: unaffected
+ call_line(
+ 2,
+ "set_diagram_source",
+ json!({ "tab_index": 3, "source": "x" }),
+ ),
+ ],
+ );
+ assert_eq!(responses[0]["result"]["isError"], false);
+ assert_eq!(responses[1]["result"]["isError"], true);
+ assert_eq!(text_of(&responses[1]), "denied by user");
+}
+
+#[test]
+fn phase1_malformed_arguments_are_invalid_params() {
+ let responses = run_lines(&[
+ call_line(1, "get_diagram_source", json!({})), // missing tab_index
+ call_line(2, "set_diagram_source", json!({ "tab_index": 0 })), // missing source
+ call_line(3, "create_diagram", json!({ "source": 7 })), // mistyped
+ call_line(4, "open_noter", json!({ "bogus": true })), // extra key
+ ]);
+ for r in &responses {
+ assert_eq!(r["error"]["code"], -32602, "expected -32602 in {r}");
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Phase 2 — data-analyst + charts (7 new tools, 41 → 48)
+// ---------------------------------------------------------------------------
+
+#[test]
+fn phase2_data_tools_happy_paths() {
+ let responses = run_lines(&[
+ call_line(1, "list_connections", json!({})),
+ call_line(
+ 2,
+ "run_query",
+ json!({ "connection_name": "demo", "sql": "SELECT id, name FROM t" }),
+ ),
+ call_line(3, "list_tables", json!({ "connection_name": "demo" })),
+ call_line(4, "open_data_analyst", json!({})),
+ ]);
+ for r in &responses {
+ assert_eq!(r["result"]["isError"], false, "unexpected error in {r}");
+ }
+ // list_connections: sanitized entry, NO password leak.
+ let conns = json_of(&responses[0]);
+ let list = conns["connections"].as_array().unwrap();
+ assert_eq!(list[0]["name"], "demo");
+ assert_eq!(list[0]["read_only"], true);
+ assert!(list[0].get("password").is_none());
+ // run_query over a saved connection: columns + rows + engine.
+ let q = json_of(&responses[1]);
+ assert_eq!(q["columns"], json!(["id", "name"]));
+ assert_eq!(q["rows"].as_array().unwrap().len(), 2);
+ assert!(q["engine"].as_str().is_some());
+ // list_tables.
+ let t = json_of(&responses[2]);
+ assert!(t["tables"].as_array().unwrap().contains(&json!("invoices")));
+ // open_data_analyst.
+ assert_eq!(json_of(&responses[3])["opened"], true);
+}
+
+#[test]
+fn phase2_run_query_rejects_mutations_and_unknown_connections() {
+ let responses = run_lines(&[
+ call_line(
+ 1,
+ "run_query",
+ json!({ "connection_name": "demo", "sql": "UPDATE t SET x = 1" }),
+ ),
+ call_line(
+ 2,
+ "run_query",
+ json!({ "connection_name": "nope", "sql": "SELECT 1" }),
+ ),
+ ]);
+ assert_eq!(responses[0]["result"]["isError"], true);
+ assert!(text_of(&responses[0]).contains("SELECT"));
+ assert_eq!(responses[1]["result"]["isError"], true);
+ assert!(text_of(&responses[1]).contains("no connection named"));
+}
+
+#[test]
+fn phase2_render_chart_returns_id() {
+ let responses = run_lines(&[call_line(
+ 1,
+ "render_chart",
+ json!({ "spec": { "mark": "bar" }, "title": "sales" }),
+ )]);
+ assert_eq!(responses[0]["result"]["isError"], false);
+ let c = json_of(&responses[0]);
+ assert!(c["chart_id"].as_str().is_some());
+ assert_eq!(c["rendered"], true);
+}
+
+#[test]
+fn phase2_write_tools_approval_gate() {
+ // Approve (default): both write verbs succeed.
+ let responses = run_lines(&[
+ call_line(
+ 1,
+ "export_query_results",
+ json!({
+ "connection_name": "demo", "sql": "SELECT 1", "path": "/tmp/out.csv", "format": "csv"
+ }),
+ ),
+ call_line(
+ 2,
+ "export_chart",
+ json!({
+ "spec": { "mark": "bar" }, "path": "/tmp/chart.png", "format": "png"
+ }),
+ ),
+ ]);
+ let eqr = json_of(&responses[0]);
+ assert_eq!(eqr["ok"], true);
+ assert_eq!(eqr["path"], "/tmp/out.csv");
+ assert_eq!(json_of(&responses[1])["path"], "/tmp/chart.png");
+ // Deny: both fail with the verbatim editor error.
+ let mut editor = MockEditor::default();
+ editor.set_approval(ApprovalMode::Deny);
+ let denied = run_lines_with(
+ editor,
+ &[
+ call_line(
+ 1,
+ "export_query_results",
+ json!({
+ "connection_name": "demo", "sql": "SELECT 1", "path": "/tmp/out.csv", "format": "csv"
+ }),
+ ),
+ call_line(
+ 2,
+ "export_chart",
+ json!({
+ "spec": { "mark": "bar" }, "path": "/tmp/chart.png", "format": "png"
+ }),
+ ),
+ ],
+ );
+ for r in &denied {
+ assert_eq!(r["result"]["isError"], true);
+ assert_eq!(text_of(r), "denied by user");
+ }
+}
+
+#[test]
+fn phase2_malformed_arguments_are_invalid_params() {
+ let responses = run_lines(&[
+ call_line(1, "run_query", json!({ "connection_name": "demo" })), // missing sql
+ call_line(
+ 2,
+ "export_chart",
+ json!({ "spec": { "mark": "bar" }, "path": "/tmp/x.gif", "format": "gif" }),
+ ),
+ call_line(3, "render_chart", json!({ "spec": "not-an-object" })), // spec not object
+ call_line(
+ 4,
+ "export_chart",
+ json!({ "spec": { "mark": "bar" }, "path": "/tmp/x.png", "format": "png", "scale": 9 }),
+ ),
+ ]);
+ for r in &responses {
+ assert_eq!(r["error"]["code"], -32602, "expected -32602 in {r}");
+ }
+}
diff --git a/notepatra-mcp/tests/remote_gateway.rs b/notepatra-mcp/tests/remote_gateway.rs
new file mode 100644
index 0000000..740b78a
--- /dev/null
+++ b/notepatra-mcp/tests/remote_gateway.rs
@@ -0,0 +1,361 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+//! Phase 3a integration — the loopback gateway end-to-end, offline, driven over
+//! real HTTP against a MockEditor backend (no running editor needed). Compiles
+//! ONLY with `--features remote` (see the `[[test]]` entry in Cargo.toml).
+
+use std::path::PathBuf;
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::{Arc, Mutex};
+
+use notepatra_mcp::remote::gateway::{accept_loop, bind_loopback};
+use notepatra_mcp::remote::http;
+use notepatra_mcp::remote::pairing::{self, PairingState};
+use notepatra_mcp::remote::scope::Scope;
+use notepatra_mcp::remote::token::TokenStore;
+use notepatra_mcp::server::Server;
+use notepatra_mcp::transport::mock::MockEditor;
+use serde_json::{json, Value};
+
+mod common;
+use common::Watchdog;
+
+/// Names this suite's tracker file (`np-remote-gateway-tracker.log`).
+const SUITE: &str = "remote-gateway";
+
+const CODE: &str = "13572468";
+
+static SEQ: AtomicU64 = AtomicU64::new(0);
+
+fn unique_dir() -> PathBuf {
+ let n = SEQ.fetch_add(1, Ordering::Relaxed);
+ let nanos = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos();
+ let mut p = std::env::temp_dir();
+ p.push(format!("np-mcp-gw-{}-{n}-{nanos}", std::process::id()));
+ p
+}
+
+struct Harness {
+ base: String,
+ tokens: Arc,
+ dir: PathBuf,
+}
+
+/// Boots a gateway on an ephemeral loopback port with the given pairing session
+/// (default posture: unauthenticated requests degrade to the read-only floor).
+fn start(pairing: PairingState) -> Harness {
+ start_opts(pairing, false)
+}
+
+/// As `start`, but with an explicit `require_token` posture.
+fn start_opts(pairing: PairingState, require_token: bool) -> Harness {
+ let dir = unique_dir();
+ let tokens = Arc::new(TokenStore::at(&dir).unwrap());
+ let listener = bind_loopback("127.0.0.1:0".parse().unwrap()).unwrap();
+ let port = listener.local_addr().unwrap().port();
+ let server = Arc::new(Mutex::new(Server::new(MockEditor::default())));
+ let pairing = Arc::new(Mutex::new(pairing));
+
+ // Detached on purpose: `accept_loop` blocks in `listener.incoming()` for
+ // the life of the harness and is reaped at process exit — there is nothing
+ // to join. The hang risk here was never this thread but the CLIENT half:
+ // `http::post_json` used to read with no deadline, so a wedged handler
+ // parked the TEST thread forever. That is now bounded twice over — by the
+ // socket timeouts in `remote::http` (fast, attributable red) and by the
+ // `Watchdog` each test arms (backstop abort that NAMES the test).
+ let (s, t, p) = (server.clone(), tokens.clone(), pairing.clone());
+ std::thread::spawn(move || {
+ let _ = accept_loop(listener, s, t, p, require_token);
+ });
+
+ Harness {
+ base: format!("http://127.0.0.1:{port}"),
+ tokens,
+ dir,
+ }
+}
+
+fn default_pairing() -> PairingState {
+ PairingState::with_code(
+ CODE.into(),
+ Scope::WriteRequest,
+ pairing::DEFAULT_TTL,
+ pairing::DEFAULT_ATTEMPTS,
+ )
+}
+
+fn rpc(base: &str, token: Option<&str>, body: Value) -> (u16, Value) {
+ let resp = http::post_json(base, "/rpc", token, body.to_string().as_bytes()).unwrap();
+ let v = if resp.body.is_empty() {
+ Value::Null
+ } else {
+ serde_json::from_slice(&resp.body).unwrap()
+ };
+ (resp.status, v)
+}
+
+fn call(id: u64, name: &str, args: Value) -> Value {
+ json!({
+ "jsonrpc": "2.0", "id": id, "method": "tools/call",
+ "params": { "name": name, "arguments": args }
+ })
+}
+
+/// Runs the full pairing handshake over HTTP; returns the issued token.
+fn do_pair(base: &str, code: &str, scope: Scope) -> (u16, Value) {
+ let start = http::post_json(base, "/pair/start", None, b"{}").unwrap();
+ let sb: Value = serde_json::from_slice(&start.body).unwrap();
+ if start.status != 200 {
+ return (start.status, sb);
+ }
+ let pair_id = sb["pair_id"].as_str().unwrap();
+ let nonce = sb["nonce"].as_str().unwrap();
+ let mac = pairing::client_mac(code, nonce).unwrap();
+ let complete = http::post_json(
+ base,
+ "/pair/complete",
+ None,
+ json!({ "pair_id": pair_id, "mac": mac, "scope": scope.as_str() })
+ .to_string()
+ .as_bytes(),
+ )
+ .unwrap();
+ let cb: Value = serde_json::from_slice(&complete.body).unwrap();
+ (complete.status, cb)
+}
+
+#[test]
+fn pair_happy_path_then_write_reaches_backend() {
+ let _wd = Watchdog::new(SUITE, "pair_happy_path_then_write_reaches_backend");
+ let h = start(default_pairing());
+ let (status, body) = do_pair(&h.base, CODE, Scope::WriteRequest);
+ assert_eq!(status, 200, "pairing failed: {body}");
+ let token = body["token"].as_str().unwrap();
+ assert_eq!(body["scope"], "write_request");
+
+ // A write verb at write_request scope is FORWARDED — the mock approves by
+ // default (raises no card) and returns ok, proving it reached the backend.
+ let (s, resp) = rpc(
+ &h.base,
+ Some(token),
+ call(1, "insert_text", json!({ "text": "hi" })),
+ );
+ assert_eq!(s, 200);
+ assert!(resp.get("error").is_none(), "unexpected error: {resp}");
+ assert_eq!(resp["result"]["isError"], false, "resp: {resp}");
+}
+
+#[test]
+fn no_token_is_read_only_and_write_is_denied() {
+ let _wd = Watchdog::new(SUITE, "no_token_is_read_only_and_write_is_denied");
+ let h = start(default_pairing());
+
+ // tools/list is filtered to the read tier (24 tools).
+ let (s, resp) = rpc(
+ &h.base,
+ None,
+ json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list" }),
+ );
+ assert_eq!(s, 200);
+ let tools = resp["result"]["tools"].as_array().unwrap();
+ assert_eq!(
+ tools.len(),
+ 24,
+ "read_only should see exactly the read tier"
+ );
+ let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
+ assert!(names.contains(&"read_tab"));
+ assert!(
+ !names.contains(&"insert_text"),
+ "write tool leaked into read_only list"
+ );
+ assert!(
+ !names.contains(&"open_file"),
+ "act tool leaked into read_only list"
+ );
+
+ // A read verb works unauthenticated.
+ let (_s, r) = rpc(
+ &h.base,
+ None,
+ call(2, "read_tab", json!({ "tab_index": 0 })),
+ );
+ assert_eq!(
+ r["result"]["isError"], false,
+ "read_tab should succeed: {r}"
+ );
+
+ // A write verb is rejected at DISPATCH with -32001, backend untouched.
+ let (_s, w) = rpc(
+ &h.base,
+ None,
+ call(3, "insert_text", json!({ "text": "x" })),
+ );
+ assert_eq!(w["error"]["code"], -32001, "expected scope denial: {w}");
+ assert!(w.get("result").is_none());
+}
+
+#[test]
+fn garbage_bearer_degrades_to_read_only() {
+ let _wd = Watchdog::new(SUITE, "garbage_bearer_degrades_to_read_only");
+ let h = start(default_pairing());
+ // An invalid token behaves exactly like no token (brief's degrade rule).
+ let (_s, w) = rpc(
+ &h.base,
+ Some("deadbeefdeadbeef"),
+ call(1, "insert_text", json!({ "text": "x" })),
+ );
+ assert_eq!(
+ w["error"]["code"], -32001,
+ "invalid token must not elevate: {w}"
+ );
+}
+
+#[test]
+fn read_act_token_allows_act_denies_write() {
+ let _wd = Watchdog::new(SUITE, "read_act_token_allows_act_denies_write");
+ let h = start(default_pairing());
+ // Seed a read_act token directly (same store the gateway reads).
+ let token = h.tokens.issue(Scope::ReadAct).unwrap();
+
+ // open_file is ACT → allowed.
+ let (_s, a) = rpc(
+ &h.base,
+ Some(&token),
+ call(1, "open_file", json!({ "path": "/tmp/np-x.txt" })),
+ );
+ assert_eq!(
+ a["result"]["isError"], false,
+ "open_file should be allowed: {a}"
+ );
+
+ // save_tab is WRITE → denied at read_act.
+ let (_s, w) = rpc(&h.base, Some(&token), call(2, "save_tab", json!({})));
+ assert_eq!(
+ w["error"]["code"], -32001,
+ "save_tab must be denied at read_act: {w}"
+ );
+}
+
+#[test]
+fn notification_yields_204_no_body() {
+ let _wd = Watchdog::new(SUITE, "notification_yields_204_no_body");
+ let h = start(default_pairing());
+ let resp = http::post_json(
+ &h.base,
+ "/rpc",
+ None,
+ json!({ "jsonrpc": "2.0", "method": "notifications/initialized" })
+ .to_string()
+ .as_bytes(),
+ )
+ .unwrap();
+ assert_eq!(resp.status, 204);
+ assert!(resp.body.is_empty());
+}
+
+#[test]
+fn server_persists_only_hashed_token() {
+ let _wd = Watchdog::new(SUITE, "server_persists_only_hashed_token");
+ let h = start(default_pairing());
+ let (status, body) = do_pair(&h.base, CODE, Scope::WriteRequest);
+ assert_eq!(status, 200);
+ let token = body["token"].as_str().unwrap();
+
+ let server_file = h.dir.join("authorized_tokens.jsonl");
+ let raw = std::fs::read_to_string(&server_file).unwrap();
+ assert!(
+ !raw.contains(token),
+ "plaintext token must NOT be persisted server-side"
+ );
+
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let mode = std::fs::metadata(&server_file)
+ .unwrap()
+ .permissions()
+ .mode()
+ & 0o777;
+ assert_eq!(mode, 0o600, "server token file must be 0600");
+ }
+}
+
+#[test]
+fn wrong_code_pairing_is_rejected() {
+ let _wd = Watchdog::new(SUITE, "wrong_code_pairing_is_rejected");
+ let h = start(default_pairing());
+ let (status, body) = do_pair(&h.base, "00000000", Scope::WriteRequest);
+ assert_eq!(status, 401, "wrong code must not issue a token: {body}");
+ assert!(body.get("token").is_none());
+}
+
+#[test]
+fn requested_scope_capped_by_serve_max_scope() {
+ let _wd = Watchdog::new(SUITE, "requested_scope_capped_by_serve_max_scope");
+ // serve started with --max-scope read_only: a write_request request is
+ // capped to read_only even with a correct code.
+ let h = start(PairingState::with_code(
+ CODE.into(),
+ Scope::ReadOnly,
+ pairing::DEFAULT_TTL,
+ pairing::DEFAULT_ATTEMPTS,
+ ));
+ let (status, body) = do_pair(&h.base, CODE, Scope::WriteRequest);
+ assert_eq!(status, 200, "{body}");
+ assert_eq!(
+ body["scope"], "read_only",
+ "granted scope must be min(requested, max)"
+ );
+}
+
+#[test]
+fn require_token_refuses_unauthenticated_reads() {
+ let _wd = Watchdog::new(SUITE, "require_token_refuses_unauthenticated_reads");
+ // --require-token removes the read floor: even a READ verb is denied without
+ // a valid token, and a notification is silently dropped (204).
+ let h = start_opts(default_pairing(), true);
+
+ let (_s, r) = rpc(
+ &h.base,
+ None,
+ call(1, "read_tab", json!({ "tab_index": 0 })),
+ );
+ assert_eq!(
+ r["error"]["code"], -32001,
+ "unauthenticated read must be refused: {r}"
+ );
+ assert!(r.get("result").is_none());
+
+ let (s, w) = rpc(
+ &h.base,
+ Some("deadbeefdeadbeef"),
+ call(2, "insert_text", json!({ "text": "x" })),
+ );
+ assert_eq!(s, 200);
+ assert_eq!(
+ w["error"]["code"], -32001,
+ "invalid token must be refused: {w}"
+ );
+
+ // A VALID token still works (read here; writes still hit the local card).
+ let token = h.tokens.issue(Scope::ReadOnly).unwrap();
+ let (_s, ok) = rpc(
+ &h.base,
+ Some(&token),
+ call(3, "read_tab", json!({ "tab_index": 0 })),
+ );
+ assert_eq!(
+ ok["result"]["isError"], false,
+ "authed read must pass: {ok}"
+ );
+}
+
+#[test]
+fn loopback_only_bind() {
+ let _wd = Watchdog::new(SUITE, "loopback_only_bind");
+ assert!(bind_loopback("0.0.0.0:0".parse().unwrap()).is_err());
+ assert!(bind_loopback("127.0.0.1:0".parse().unwrap()).is_ok());
+}
diff --git a/notepatra-mcp/tests/socket_bridge.rs b/notepatra-mcp/tests/socket_bridge.rs
index 59ebada..72b8020 100644
--- a/notepatra-mcp/tests/socket_bridge.rs
+++ b/notepatra-mcp/tests/socket_bridge.rs
@@ -7,9 +7,15 @@ use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
-use std::thread::JoinHandle;
+use std::sync::mpsc;
use std::time::Duration;
+mod common;
+use common::{finish, Bridge, Watchdog};
+
+/// Names this suite's tracker file (`np-socket-bridge-tracker.log`).
+const SUITE: &str = "socket-bridge";
+
use notepatra_mcp::transport::socket::{SocketEditor, NOT_RUNNING};
use notepatra_mcp::transport::{EditorTransport, TabSelector};
use serde_json::{json, Value};
@@ -25,14 +31,26 @@ fn temp_socket_path() -> PathBuf {
}
/// Binds a listener, then runs `behavior` on the first accepted connection.
-fn spawn_bridge(behavior: impl FnOnce(UnixStream) + Send + 'static) -> (PathBuf, JoinHandle<()>) {
+///
+/// HONEST RESIDUAL: `listener.accept()` is unbounded and std offers no accept
+/// timeout, so a client that never connects would park this thread forever.
+/// Its deadline is enforced one level up instead — by [`finish`]'s
+/// `recv_timeout` in [`cleanup`] and, for a block on the TEST thread, by the
+/// [`Watchdog`]. The listener is bound BEFORE this function returns, so the
+/// client's connect can never race the thread's startup.
+fn spawn_bridge(behavior: impl FnOnce(UnixStream) + Send + 'static) -> (PathBuf, Bridge) {
let path = temp_socket_path();
let listener = UnixListener::bind(&path).expect("bind fake bridge socket");
+ let (done_tx, done) = mpsc::channel::<()>();
let handle = std::thread::spawn(move || {
let (stream, _) = listener.accept().expect("accept");
behavior(stream);
+ // LAST act: unblocks `finish`. On a panic this send never happens, but
+ // `done_tx` drops during unwind and `finish` treats Disconnected as
+ // "finished", so the panic is re-raised by the join.
+ let _ = done_tx.send(());
});
- (path, handle)
+ (path, Bridge::new(handle, done))
}
fn editor_for(path: &Path) -> SocketEditor {
@@ -40,14 +58,16 @@ fn editor_for(path: &Path) -> SocketEditor {
.with_timeouts(Duration::from_secs(1), Duration::from_secs(1))
}
-fn finish(path: PathBuf, handle: JoinHandle<()>) {
- handle.join().expect("bridge thread panicked");
+/// Bounded join (never a bare `join()`) plus socket-file removal.
+fn cleanup(path: PathBuf, bridge: Bridge, label: &str) {
+ finish(bridge, label);
let _ = std::fs::remove_file(path);
}
#[test]
fn greeting_then_round_trips_with_sequential_ids() {
- let (path, handle) = spawn_bridge(|stream| {
+ let _wd = Watchdog::new(SUITE, "greeting_then_round_trips_with_sequential_ids");
+ let (path, bridge) = spawn_bridge(|stream| {
let mut reader = BufReader::new(stream.try_clone().expect("clone"));
let mut stream = stream;
// Greeting before payload: the bridge speaks first.
@@ -82,13 +102,18 @@ fn greeting_then_round_trips_with_sequential_ids() {
// wave-1 parsing maps {opened,tab_index} into the tab index.
let index = ed.open_file("/tmp/x.txt").expect("open_file round-trip");
assert_eq!(index, 4);
- finish(path, handle);
+ cleanup(
+ path,
+ bridge,
+ "greeting_then_round_trips_with_sequential_ids",
+ );
}
#[test]
fn wave1_typed_responses_parse() {
+ let _wd = Watchdog::new(SUITE, "wave1_typed_responses_parse");
// Every fixture below is the EXACT shape src/mcp_bridge.cpp emits.
- let (path, handle) = spawn_bridge(|stream| {
+ let (path, bridge) = spawn_bridge(|stream| {
let mut reader = BufReader::new(stream.try_clone().expect("clone"));
let mut stream = stream;
writeln!(stream, "{GREETING}").unwrap();
@@ -155,14 +180,15 @@ fn wave1_typed_responses_parse() {
assert!(!found.truncated);
let matches = ed.find_in_tab(Some(0), "x", false).expect("find_in_tab");
assert_eq!(matches["matches"][0]["line"], 3);
- finish(path, handle);
+ cleanup(path, bridge, "wave1_typed_responses_parse");
}
#[test]
fn legacy_lenient_shapes_are_rejected() {
+ let _wd = Watchdog::new(SUITE, "legacy_lenient_shapes_are_rejected");
// The old client accepted bare arrays and a "hits" key; the contract is
// exact-match now — anything but the bridge's shape is malformed.
- let (path, handle) = spawn_bridge(|stream| {
+ let (path, bridge) = spawn_bridge(|stream| {
let mut reader = BufReader::new(stream.try_clone().expect("clone"));
let mut stream = stream;
writeln!(stream, "{GREETING}").unwrap();
@@ -198,12 +224,13 @@ fn legacy_lenient_shapes_are_rejected() {
"unexpected error: {}",
err.0
);
- finish(path, handle);
+ cleanup(path, bridge, "legacy_lenient_shapes_are_rejected");
}
#[test]
fn silent_bridge_times_out_cleanly() {
- let (path, handle) = spawn_bridge(|stream| {
+ let _wd = Watchdog::new(SUITE, "silent_bridge_times_out_cleanly");
+ let (path, bridge) = spawn_bridge(|stream| {
// Accept, say nothing, and hold the socket open past the client's
// timeout so the failure is a timeout, not a closed connection.
std::thread::sleep(Duration::from_millis(600));
@@ -217,11 +244,12 @@ fn silent_bridge_times_out_cleanly() {
"unexpected error: {}",
err.0
);
- finish(path, handle);
+ cleanup(path, bridge, "silent_bridge_times_out_cleanly");
}
#[test]
fn editor_not_running_is_a_clean_error() {
+ let _wd = Watchdog::new(SUITE, "editor_not_running_is_a_clean_error");
let path = temp_socket_path(); // never bound
let ed = editor_for(&path);
let err = ed.list_open_tabs().unwrap_err();
@@ -232,7 +260,8 @@ fn editor_not_running_is_a_clean_error() {
#[test]
fn invalid_greeting_is_rejected_before_any_send() {
- let (path, handle) = spawn_bridge(|stream| {
+ let _wd = Watchdog::new(SUITE, "invalid_greeting_is_rejected_before_any_send");
+ let (path, bridge) = spawn_bridge(|stream| {
let mut reader = BufReader::new(stream.try_clone().expect("clone"));
let mut stream = stream;
writeln!(stream, r#"{{"hello":"world"}}"#).unwrap();
@@ -245,12 +274,13 @@ fn invalid_greeting_is_rejected_before_any_send() {
let err = ed.app_info().unwrap_err();
assert!(err.0.contains("greeting"), "unexpected error: {}", err.0);
drop(ed); // closes the socket so the bridge's read_line sees EOF
- finish(path, handle);
+ cleanup(path, bridge, "invalid_greeting_is_rejected_before_any_send");
}
#[test]
fn write_verbs_wait_out_the_approval_window() {
- let (path, handle) = spawn_bridge(|stream| {
+ let _wd = Watchdog::new(SUITE, "write_verbs_wait_out_the_approval_window");
+ let (path, bridge) = spawn_bridge(|stream| {
let mut reader = BufReader::new(stream.try_clone().expect("clone"));
let mut stream = stream;
writeln!(stream, "{GREETING}").unwrap();
@@ -306,15 +336,19 @@ fn write_verbs_wait_out_the_approval_window() {
assert_eq!(v["count"], 2);
let v = ed.save_tab(None).expect("save_tab");
assert_eq!(v, json!({ "ok": true }));
- finish(path, handle);
+ cleanup(path, bridge, "write_verbs_wait_out_the_approval_window");
}
#[test]
fn approval_denial_and_timeout_errors_pass_through_verbatim() {
+ let _wd = Watchdog::new(
+ SUITE,
+ "approval_denial_and_timeout_errors_pass_through_verbatim",
+ );
// One connection per error: the client drops the connection after any
// error round-trip, and the fake bridge accepts only once.
for expected in ["denied by user", "approval timed out"] {
- let (path, handle) = spawn_bridge(move |stream| {
+ let (path, bridge) = spawn_bridge(move |stream| {
let mut reader = BufReader::new(stream.try_clone().expect("clone"));
let mut stream = stream;
writeln!(stream, "{GREETING}").unwrap();
@@ -328,7 +362,11 @@ fn approval_denial_and_timeout_errors_pass_through_verbatim() {
let mut ed = editor_for(&path);
let err = ed.save_tab(None).unwrap_err();
assert_eq!(err.0, expected);
- finish(path, handle);
+ cleanup(
+ path,
+ bridge,
+ "approval_denial_and_timeout_errors_pass_through_verbatim",
+ );
}
}
@@ -338,7 +376,8 @@ fn approval_denial_and_timeout_errors_pass_through_verbatim() {
#[test]
fn v0119_read_verbs_send_exact_arg_keys() {
- let (path, handle) = spawn_bridge(|stream| {
+ let _wd = Watchdog::new(SUITE, "v0119_read_verbs_send_exact_arg_keys");
+ let (path, bridge) = spawn_bridge(|stream| {
let mut reader = BufReader::new(stream.try_clone().expect("clone"));
let mut stream = stream;
writeln!(stream, "{GREETING}").unwrap();
@@ -408,12 +447,13 @@ fn v0119_read_verbs_send_exact_arg_keys() {
.expect("validate_npd source");
ed.run_sql("SELECT 1", None).expect("run_sql");
ed.run_sql("SELECT 1", Some("/d.csv")).expect("run_sql csv");
- finish(path, handle);
+ cleanup(path, bridge, "v0119_read_verbs_send_exact_arg_keys");
}
#[test]
fn v0119_act_and_write_verbs_send_exact_arg_keys() {
- let (path, handle) = spawn_bridge(|stream| {
+ let _wd = Watchdog::new(SUITE, "v0119_act_and_write_verbs_send_exact_arg_keys");
+ let (path, bridge) = spawn_bridge(|stream| {
let mut reader = BufReader::new(stream.try_clone().expect("clone"));
let mut stream = stream;
writeln!(stream, "{GREETING}").unwrap();
@@ -471,12 +511,17 @@ fn v0119_act_and_write_verbs_send_exact_arg_keys() {
.export_diagram(1, "/o.png", "png")
.expect("export_diagram");
assert_eq!(exported["path"], "/o.png");
- finish(path, handle);
+ cleanup(
+ path,
+ bridge,
+ "v0119_act_and_write_verbs_send_exact_arg_keys",
+ );
}
#[test]
fn regex_flag_is_serialized_only_when_true() {
- let (path, handle) = spawn_bridge(|stream| {
+ let _wd = Watchdog::new(SUITE, "regex_flag_is_serialized_only_when_true");
+ let (path, bridge) = spawn_bridge(|stream| {
let mut reader = BufReader::new(stream.try_clone().expect("clone"));
let mut stream = stream;
writeln!(stream, "{GREETING}").unwrap();
@@ -524,12 +569,13 @@ fn regex_flag_is_serialized_only_when_true() {
ed.find_in_tab(Some(0), "lit", false).expect("literal find");
ed.search_project("a.*b", 50, true).expect("regex search");
ed.search_project("lit", 50, false).expect("literal search");
- finish(path, handle);
+ cleanup(path, bridge, "regex_flag_is_serialized_only_when_true");
}
#[test]
fn bridge_error_response_maps_to_transport_error() {
- let (path, handle) = spawn_bridge(|stream| {
+ let _wd = Watchdog::new(SUITE, "bridge_error_response_maps_to_transport_error");
+ let (path, bridge) = spawn_bridge(|stream| {
let mut reader = BufReader::new(stream.try_clone().expect("clone"));
let mut stream = stream;
writeln!(stream, "{GREETING}").unwrap();
@@ -542,5 +588,193 @@ fn bridge_error_response_maps_to_transport_error() {
let ed = editor_for(&path);
let err = ed.read_note("x.md").unwrap_err();
assert_eq!(err.0, "no note named \"x.md\"");
- finish(path, handle);
+ cleanup(
+ path,
+ bridge,
+ "bridge_error_response_maps_to_transport_error",
+ );
+}
+
+#[test]
+fn p0a_read_verbs_send_empty_args_and_parse_replies() {
+ let _wd = Watchdog::new(SUITE, "p0a_read_verbs_send_empty_args_and_parse_replies");
+ let (path, bridge) = spawn_bridge(|stream| {
+ let mut reader = BufReader::new(stream.try_clone().expect("clone"));
+ let mut stream = stream;
+ writeln!(stream, "{GREETING}").unwrap();
+ for expected_id in 1..=2u64 {
+ let mut line = String::new();
+ reader.read_line(&mut line).unwrap();
+ let req: Value = serde_json::from_str(&line).unwrap();
+ assert_eq!(req["id"], expected_id);
+ assert_eq!(req["args"], json!({}), "both p0a verbs are no-arg");
+ let result = match req["verb"].as_str().unwrap() {
+ "list_languages" => json!({ "languages": ["Plain Text", "Python", "C++"] }),
+ // Exact bridge shape (src/mcp_bridge.cpp verbGetCapabilities):
+ // no tool_count / tiers on the wire — the tool layer adds them.
+ "get_capabilities" => json!({
+ "edition": "Full", "platform": "linux", "version": "0.1.120",
+ "features": { "duckdb": true, "webengine": true, "noter": true }
+ }),
+ other => panic!("unexpected verb {other}"),
+ };
+ let resp = json!({ "id": expected_id, "ok": true, "result": result });
+ writeln!(stream, "{resp}").unwrap();
+ }
+ });
+ let ed = editor_for(&path);
+ let langs = ed.list_languages().expect("list_languages round-trip");
+ assert_eq!(langs["languages"][1], "Python");
+ let caps = ed.get_capabilities().expect("get_capabilities round-trip");
+ assert_eq!(caps["edition"], "Full");
+ assert_eq!(caps["features"]["duckdb"], true);
+ assert!(caps.get("tool_count").is_none()); // wire shape has no tool_count
+ cleanup(
+ path,
+ bridge,
+ "p0a_read_verbs_send_empty_args_and_parse_replies",
+ );
+}
+
+#[test]
+fn phase1_verbs_send_exact_arg_keys() {
+ let _wd = Watchdog::new(SUITE, "phase1_verbs_send_exact_arg_keys");
+ let (path, bridge) = spawn_bridge(|stream| {
+ let mut reader = BufReader::new(stream.try_clone().expect("clone"));
+ let mut stream = stream;
+ writeln!(stream, "{GREETING}").unwrap();
+ for _ in 0..5 {
+ let mut line = String::new();
+ if reader.read_line(&mut line).unwrap() == 0 {
+ return;
+ }
+ let req: Value = serde_json::from_str(&line).unwrap();
+ let result = match req["verb"].as_str().unwrap() {
+ "create_diagram" => {
+ // Optional keys present only when supplied.
+ assert!(
+ req["args"] == json!({})
+ || req["args"]
+ == json!({ "source": "diagram flow\n", "title": "flow" })
+ );
+ json!({ "tab_index": 4, "valid": true, "errors": [] })
+ }
+ "get_diagram_source" => {
+ assert_eq!(req["args"], json!({ "tab_index": 4 }));
+ json!({ "source": "diagram flow\n" })
+ }
+ "set_diagram_source" => {
+ assert_eq!(
+ req["args"],
+ json!({ "tab_index": 4, "source": "diagram er\n" })
+ );
+ json!({ "ok": true, "tab_index": 4, "valid": true, "errors": [] })
+ }
+ "open_noter" => {
+ assert_eq!(req["args"], json!({}));
+ json!({ "opened": true })
+ }
+ other => panic!("unexpected verb {other}"),
+ };
+ let resp = json!({ "id": req["id"], "ok": true, "result": result });
+ writeln!(stream, "{resp}").unwrap();
+ }
+ });
+ let mut ed = SocketEditor::with_socket_path(path.to_str().unwrap())
+ .with_timeouts(Duration::from_secs(1), Duration::from_secs(1))
+ .with_approval_timeout(Duration::from_secs(2));
+ let created = ed
+ .create_diagram(Some("diagram flow\n"), Some("flow"))
+ .expect("create_diagram");
+ assert_eq!(created["tab_index"], 4);
+ ed.create_diagram(None, None).expect("create_diagram bare");
+ let src = ed.get_diagram_source(4).expect("get_diagram_source");
+ assert_eq!(src["source"], "diagram flow\n");
+ let set = ed
+ .set_diagram_source(4, "diagram er\n")
+ .expect("set_diagram_source");
+ assert_eq!(set["ok"], true);
+ assert_eq!(ed.open_noter().expect("open_noter")["opened"], true);
+ cleanup(path, bridge, "phase1_verbs_send_exact_arg_keys");
+}
+
+#[test]
+fn phase2_verbs_send_exact_arg_keys() {
+ let _wd = Watchdog::new(SUITE, "phase2_verbs_send_exact_arg_keys");
+ let (path, bridge) = spawn_bridge(|stream| {
+ let mut reader = BufReader::new(stream.try_clone().expect("clone"));
+ let mut stream = stream;
+ writeln!(stream, "{GREETING}").unwrap();
+ for _ in 0..7 {
+ let mut line = String::new();
+ if reader.read_line(&mut line).unwrap() == 0 {
+ return;
+ }
+ let req: Value = serde_json::from_str(&line).unwrap();
+ let result = match req["verb"].as_str().unwrap() {
+ "list_connections" => {
+ assert_eq!(req["args"], json!({}));
+ json!({ "connections": [] })
+ }
+ "run_query" => {
+ assert_eq!(
+ req["args"],
+ json!({ "connection_name": "demo", "sql": "SELECT 1", "max_rows": 50 })
+ );
+ json!({ "columns": [], "rows": [], "truncated": false, "engine": "sqlite" })
+ }
+ "list_tables" => {
+ assert_eq!(req["args"], json!({ "connection_name": "demo" }));
+ json!({ "tables": ["t"] })
+ }
+ "open_data_analyst" => {
+ assert_eq!(req["args"], json!({}));
+ json!({ "opened": true })
+ }
+ "render_chart" => {
+ // No title key when None.
+ assert_eq!(req["args"], json!({ "spec": { "mark": "bar" } }));
+ assert!(req["args"].get("title").is_none());
+ json!({ "chart_id": "c1", "rendered": true })
+ }
+ "export_query_results" => {
+ assert_eq!(
+ req["args"],
+ json!({
+ "connection_name": "demo", "sql": "SELECT 1",
+ "path": "/tmp/o.csv", "format": "csv"
+ })
+ );
+ json!({ "ok": true, "path": "/tmp/o.csv", "rows": 1 })
+ }
+ "export_chart" => {
+ // No scale key when None.
+ assert_eq!(
+ req["args"],
+ json!({ "spec": { "mark": "bar" }, "path": "/tmp/c.png", "format": "png" })
+ );
+ assert!(req["args"].get("scale").is_none());
+ json!({ "path": "/tmp/c.png" })
+ }
+ other => panic!("unexpected verb {other}"),
+ };
+ let resp = json!({ "id": req["id"], "ok": true, "result": result });
+ writeln!(stream, "{resp}").unwrap();
+ }
+ });
+ let mut ed = SocketEditor::with_socket_path(path.to_str().unwrap())
+ .with_timeouts(Duration::from_secs(1), Duration::from_secs(1))
+ .with_approval_timeout(Duration::from_secs(2));
+ ed.list_connections().expect("list_connections");
+ ed.run_query("demo", "SELECT 1", Some(50))
+ .expect("run_query");
+ ed.list_tables("demo").expect("list_tables");
+ ed.open_data_analyst().expect("open_data_analyst");
+ let spec = json!({ "mark": "bar" });
+ ed.render_chart(&spec, None).expect("render_chart");
+ ed.export_query_results("demo", "SELECT 1", "/tmp/o.csv", "csv", None)
+ .expect("export_query_results");
+ ed.export_chart(&spec, "/tmp/c.png", "png", None)
+ .expect("export_chart");
+ cleanup(path, bridge, "phase2_verbs_send_exact_arg_keys");
}
diff --git a/release_notes/v0.1.120.md b/release_notes/v0.1.120.md
new file mode 100644
index 0000000..a68bda4
--- /dev/null
+++ b/release_notes/v0.1.120.md
@@ -0,0 +1,61 @@
+# Notepatra v0.1.120
+
+**MCP full control — 48 tools + a remote gateway. The `notepatra-mcp` sidecar grows from 35 to 48 tools (Read 24 / Act 13 / Write 11), turning every Notepatra sub-application into something an AI assistant (Claude or OpenAI) can drive: create and edit rendered `.npd` diagrams, open Noter, query saved databases and render charts in the Data-analyst, plus capability discovery so a client never guesses. A new opt-in remote gateway (feature-gated, loopback-only) lets a paired client reach the editor over an authenticated channel with fail-closed scopes — while every write still requires a physical Approve click in the editor window. Windows now gets a prebuilt signed sidecar zip and a one-click `.mcpb` Claude Desktop bundle. Every write verb goes through the same in-editor Approve/Deny card; `run_query` is SELECT-only and its file-read denylist now spans SQLite, MySQL, PostgreSQL, and DuckDB. This release also fixes two defects that made the sidecar unusable outside Linux: the Windows named-pipe transport deadlocked on every tool call, and on macOS the sidecar could not locate a running editor at all.**
+
+### New — 13 more MCP tools (35 → 48)
+
+New three-tier totals: **Read 24 / Act 13 / Write 11 = 48**.
+
+- **Diagram control** — an AI can now draw and edit a diagram in-app, not just validate one:
+ - `create_diagram` (Act) — open a rendered `.npd` diagram tab from source; returns the tab index plus a parse result.
+ - `get_diagram_source` (Read) / `set_diagram_source` (Write, approval-gated) — read and replace a diagram's source; the canvas re-renders on approve.
+- **Data-analyst** — reach the user's real saved databases, which `run_sql` cannot:
+ - `list_connections` (Read) — saved connections (name, driver, database, read-only), never credentials.
+ - `run_query` (Read) — a SELECT against a named saved connection (PostgreSQL / MySQL / SQL Server / SQLite / DuckDB); mutations rejected, row-capped.
+ - `list_tables` (Read) — user tables over a saved connection.
+ - `open_data_analyst` (Act) — reveal the AI dock in Data-analyst mode.
+ - `export_query_results` (Write, approval-gated) — write a query's results to CSV / JSON on disk.
+- **Charts** (Full / WebEngine edition):
+ - `render_chart` (Act) — render an inline Vega-Lite chart from a full or simplified spec.
+ - `export_chart` (Write, approval-gated) — export a chart to PNG / SVG / HTML / spec on disk.
+- **Noter:**
+ - `open_noter` (Act) — reveal and focus the Noter panel.
+- **Discovery (Read):**
+ - `list_languages` — the exact syntax-highlighting language tokens the editor accepts.
+ - `get_capabilities` — edition, platform, version, tool count, tier counts, and feature flags (duckdb / webengine / noter), so a client self-diagnoses instead of guessing.
+
+### Improved — `set_language` and Git just work
+
+- `set_language` now resolves case-insensitively and accepts common aliases (`python` → `Python`, `js` → `JavaScript`, `cpp` → `C++`, …) and echoes the canonical name it applied — an AI no longer has to guess the exact menu token.
+- `git_status` / `git_diff` / `git_log` / `git_show` / `git_branch` and `search_project` now fall back to the active file's directory when no workspace folder is open, so Git works on any open repository file instead of reporting "not a git repository".
+
+### New — remote gateway (opt-in, loopback-only)
+
+- The sidecar gains a feature-gated `remote` build with three modes: `serve` (a loopback-only HTTP gateway on `127.0.0.1`), `pair` (an 8-digit one-time HMAC handshake that issues a 256-bit bearer token — only its SHA-256 is stored, `0600`), and `connect ` (a stdio↔HTTP forwarder, so an existing stdio client config reaches a remote editor by swapping args; use an SSH tunnel until LAN/TLS lands).
+- **Scopes are fail-closed:** a client is `read_only` unless it paired for `read_act` or `write_request`; act/write calls are rejected at dispatch below the required scope, and `tools/list` is filtered to the allowed tiers. A missing or invalid token never elevates.
+- **The default build is unchanged and crypto-free** — the gateway and its `hmac` / `sha2` dependencies are behind the optional `remote` feature, so `cargo install notepatra-mcp` still builds anywhere with the standard library alone. The editor never listens on the network; only the opt-in sidecar gateway binds a loopback socket.
+- **The approval gate is untouched:** a remote `write_request` call is merely forwarded to the editor, which raises the same local Approve/Deny card. There is no remote approval and no headless bypass.
+
+### New — Windows prebuilt sidecar + one-click `.mcpb`
+
+- The release now ships a prebuilt, signed **`notepatra-mcp-windows-x64.zip`** — Windows users no longer need a Rust toolchain to run the sidecar (`cargo install notepatra-mcp` remains an alternative).
+- A one-click **`.mcpb`** Claude Desktop bundle (`notepatra-mcp.mcpb`) packages the per-platform sidecar binaries with a manifest, so Claude Desktop can install the server without editing `claude_desktop_config.json`.
+- Both new assets are covered by the same SHA-256 checksums, cosign signatures, and SLSA provenance as the existing platform tarballs.
+
+### Security — reads over saved databases are file-safe on every engine
+
+- `run_query` reaches saved named connections without an approval card (it is a Read-tier verb), so its read-only classifier's filesystem denylist was extended beyond DuckDB to also reject MySQL `LOAD_FILE` and PostgreSQL `pg_read_server_files` / `pg_ls_dir` / `pg_stat_file` / `pg_ls_logdir` / `pg_ls_waldir` / `pg_ls_tmpdir` / `pg_ls_archive_statusdir`. A five-engine regression test guards it. The invariant holds: a read-only SQL surface is never an arbitrary-file-read primitive.
+- Every write verb — including the new `set_diagram_source`, `export_query_results`, and `export_chart` — goes through the same in-editor Approve/Deny card (120-second auto-deny, one card at a time, no headless bypass), and the gate lives in the editor process, not the sidecar.
+
+### Fixed — the sidecar now actually works on Windows and macOS
+
+Two defects meant the MCP server was effectively Linux-only before this release. Both are fixed, and both are now pinned by tests that run on the platform they protect.
+
+- **Windows: the named-pipe transport deadlocked on every verb.** The pipe was opened without `FILE_FLAG_OVERLAPPED` and a reader thread sat permanently parked in `ReadFile`. A synchronous Windows file object serializes I/O, so that parked read held the file-object lock and blocked every outgoing write indefinitely — and because the write had no timeout, the read timeout meant to bound the wait was never reached. Crucially, only the first request *after* the greeting hung; the greeting itself succeeded because the bridge speaks unprompted, which is exactly why the transport appeared healthy. Reads and writes are now gated on a command channel, so a write is only ever issued while the reader is channel-idle and the deadlock is impossible by construction. The same change fixes an EOF-starvation and thread/handle leak on teardown, and a latent hang when a payload over 64 KB was written to an editor busy displaying an approval dialog.
+- **macOS: the sidecar could not find a running editor.** It computed the socket path as `$TMPDIR` (or `/tmp`) plus the endpoint name, but Qt binds under `NSTemporaryDirectory()` — `/private/var/folders/…/T/` — which that guess never reproduces, so the sidecar reported "Notepatra is not running" against a running editor. The editor now publishes its actual bound endpoint to `/mcp-endpoint.json` (written atomically, `0600`, removed on exit) and the sidecar dials that first, falling back to the computed guess when the file is absent. A newer sidecar works with an older editor and vice versa.
+- **`.mcpb` bundles shipped the wrong binary on two platforms.** Every `darwin` install received the arm64 build — a hard failure on Intel Macs, since Rosetta cannot translate arm64 to x86_64 — and `linux-arm64`, though packed, had no selector, so ARM64 Linux users got the x64 binary. macOS now ships a `lipo`'d universal binary with per-slice architecture assertions, and Linux dispatches on `uname -m`.
+- **Remote-gateway token files are ACL-restricted on Windows** (`icacls /inheritance:r`), matching the `0600` posture already enforced on Unix.
+
+### Compatibility
+
+- No new MCP tools change existing verb shapes; the 35 v0.1.119 tools behave identically. The remote gateway is entirely opt-in and off by default. The editor remains local-first with no telemetry.
diff --git a/scripts/release-check.sh b/scripts/release-check.sh
index cff46a7..1c8ca3f 100755
--- a/scripts/release-check.sh
+++ b/scripts/release-check.sh
@@ -125,7 +125,14 @@ echo "── full-flavor build sanity (WebEngine code path) ──"
# If no, emit a loud warning — CI runners HAVE WebEngine, so a missing
# local install means we're shipping blind.
WEBENGINE_PROBE_LOG=/tmp/release-webengine-probe.log
-if dpkg -l libqt5webengine5-dev libqt5webenginewidgets5 2>/dev/null | grep -q '^ii' \
+# Probe by what actually MATTERS — the CMake package + the header — not by a
+# dpkg package NAME. The old check looked for "libqt5webengine5-dev", which is
+# not the Debian package that ships these headers (it is "qtwebengine5-dev"),
+# so it reported "NOT installed" on a machine that had WebEngine all along and
+# silently skipped the very build it exists to verify. A checker bug reads
+# exactly like the defect it is supposed to catch.
+if [ -f /usr/lib/"$(uname -m)"-linux-gnu/cmake/Qt5WebEngineWidgets/Qt5WebEngineWidgetsConfig.cmake ] \
+ || dpkg -l qtwebengine5-dev libqt5webenginewidgets5 2>/dev/null | grep -q '^ii' \
|| (echo 'find_package(Qt5 REQUIRED COMPONENTS WebEngineWidgets)' | cmake -DCMAKE_BUILD_TYPE=Release -P /dev/stdin >/dev/null 2>&1); then
rm -rf build-full && mkdir -p build-full
if (cd build-full && cmake .. -DCMAKE_BUILD_TYPE=Release -DNOTEPATRA_WITH_WEBENGINE=ON >"$WEBENGINE_PROBE_LOG" 2>&1 \
@@ -136,11 +143,12 @@ if dpkg -l libqt5webengine5-dev libqt5webenginewidgets5 2>/dev/null | grep -q '^
tail -25 "$WEBENGINE_PROBE_LOG" | sed 's/^/ /'
fi
else
- echo " ⚠ libqt5webengine5-dev NOT installed locally — CANNOT verify the WebEngine"
+ echo " ⚠ Qt5WebEngineWidgets NOT found locally — CANNOT verify the WebEngine"
echo " code path compiles. CI runners DO have WebEngine, so a regression in"
echo " src/charts/vega_chart_renderer.cpp (or anything else inside"
echo " #ifdef NOTEPATRA_WITH_WEBENGINE) will fail CI silently like v0.1.63 did."
echo " Install with: sudo apt-get install qtwebengine5-dev libqt5webenginewidgets5"
+ echo " (note: the dev headers come from qtwebengine5-dev, NOT libqt5webengine5-dev)"
# Don't fail the gate — devs without WebEngine should still be able to cut
# releases, but the warning makes the risk visible.
fi
diff --git a/scripts/stale-text-check.sh b/scripts/stale-text-check.sh
index 3cbdeba..f464cd7 100755
--- a/scripts/stale-text-check.sh
+++ b/scripts/stale-text-check.sh
@@ -34,7 +34,7 @@ cd "$(dirname "$0")/.."
LEXER_COUNT=82
FILE_EXT_COUNT=238
BACKEND_COUNT=6
-MCP_TOOL_COUNT=35
+MCP_TOOL_COUNT=48
BACKEND_LIST="Ollama / llama.cpp / OpenRouter / Ollama Cloud / OpenAI / Azure OpenAI"
# BARE_BIN_MB removed v0.1.86 — verify-download-sizes.sh now downloads the
# actual artifact and asserts byte count + stripped-vs-not, which is strictly
@@ -197,6 +197,33 @@ else
echo " ⓘ notepatra-mcp/src/tools.rs not present on this branch — skipping MCP tool-count gate"
fi
+echo ""
+echo "── MCP distribution wiring (Windows sidecar zip + .mcpb bundle) ──"
+if [[ -f notepatra-mcp/mcpb/manifest.json ]]; then
+ mcpb_v=$(python3 -c "import json;print(json.load(open('notepatra-mcp/mcpb/manifest.json'))['version'])" 2>/dev/null || echo INVALID)
+ cargo_v=$(grep -m1 -oE '^version = "[0-9]+\.[0-9]+\.[0-9]+"' notepatra-mcp/Cargo.toml | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')
+ # Must equal the project $VERSION too: CI's .mcpb step runs with
+ # --expect-version "${TAG#v}", which hard-aborts the WHOLE release job
+ # AFTER the tag is public if the sidecar/manifest lag the tag. Comparing
+ # only manifest==Cargo lets both lag together and stay green. Tying both
+ # to $VERSION (derived from CMakeLists) makes release-check.sh catch the
+ # skew BEFORE tagging.
+ if [[ "$mcpb_v" != "INVALID" && "$mcpb_v" == "$cargo_v" && "$mcpb_v" == "$VERSION" ]]; then
+ printf " ✓ mcpb/manifest.json + Cargo.toml version %s matches project VERSION\n" "$mcpb_v"
+ PASS=$((PASS + 1))
+ else
+ printf " ✗ version skew: manifest '%s' / Cargo.toml '%s' / project VERSION '%s' must all match (or invalid JSON)\n" "$mcpb_v" "$cargo_v" "$VERSION"
+ FAIL=$((FAIL + 1))
+ fi
+ assert_contains "build.yml packages notepatra-mcp-windows-x64.zip" .github/workflows/build.yml "notepatra-mcp-windows-x64.zip"
+ assert_contains "build.yml builds + attaches notepatra-mcp.mcpb" .github/workflows/build.yml "notepatra-mcp.mcpb"
+ assert_contains "mcp.html mentions the Windows sidecar zip" docs/mcp.html "notepatra-mcp-windows-x64.zip"
+ assert_contains "mcp.html mentions the .mcpb bundle" docs/mcp.html "notepatra-mcp.mcpb"
+ assert_contains "README mentions the .mcpb bundle" README.md ".mcpb"
+else
+ echo " ⓘ notepatra-mcp/mcpb/manifest.json not present on this branch — skipping"
+fi
+
echo
echo "── stale version-ref sweep (user-facing phrases must point at v$VERSION) ──"
# This section catches the v0.1.82 → v0.1.83 drift: when a release bumps the
@@ -360,7 +387,7 @@ echo "── installer-filename pinning (README admin/fleet install commands)
# install commands meant for the current user). Skip lines inside the
# CHANGELOG-style release history that document old versions.
stale_installers=$(grep -vE 'releases/tag/v0\.1\.' README.md \
- | grep -hoE 'notepatra(-local-ai)?[-_][^"`]*?0\.1\.[0-9]+(_amd64|_arm64|-1\.x86_64|-1\.aarch64|-x86_64|-aarch64)?\.(msi|deb|rpm|AppImage|tar\.gz|zip|dmg|exe)' \
+ | grep -hoiE 'notepatra(-local-ai)?[-_][^"`]*?0\.1\.[0-9]+(_amd64|_arm64|-1\.x86_64|-1\.aarch64|-x86_64|-aarch64)?\.(msi|deb|rpm|AppImage|tar\.gz|zip|dmg|exe)' \
| grep -oE "0\.1\.[0-9]+" \
| sort -u \
| grep -v "^$VERSION$" || true)
@@ -591,6 +618,44 @@ else
echo " ⓘ no build/ dir or ctest unavailable — skipping suite-count gate (release-check builds first)"
fi
+echo "── no HOLES in the version-card history on the site ──"
+# v0.1.119 shipped and the website never got a card: the list ran 115, 116,
+# 117, 118, then jumped straight to 120. A reader could not tell 119 had
+# happened at all. Nothing caught it, because every other gate checks the
+# CURRENT version — none checked the history for GAPS.
+#
+# The site deliberately shows a ROLLING WINDOW of recent releases, so this
+# does not demand a card for every release ever. It asserts only that, between
+# the oldest and newest card actually displayed, no release is skipped.
+if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
+ cards=$(grep -oE 'releases/tag/v0\.1\.[0-9]+"' docs/index.html \
+ | grep -oE '0\.1\.[0-9]+' | sort -u -t. -k3 -n || true)
+ if [[ -z "$cards" ]]; then
+ echo " ⓘ no version cards found in index.html — skipping gap gate"
+ else
+ oldest=$(echo "$cards" | head -1 | cut -d. -f3)
+ newest=$(echo "$cards" | tail -1 | cut -d. -f3)
+ published=$(gh release list -R singhpratech/notepatra --limit 40 2>/dev/null \
+ | grep -oE 'v0\.1\.[0-9]+' | grep -oE '[0-9]+$' | sort -un || true)
+ holes=""
+ for n in $published; do
+ (( n >= oldest && n <= newest )) || continue
+ echo "$cards" | grep -qx "0.1.$n" || holes="$holes v0.1.$n"
+ done
+ if [[ -z "$holes" ]]; then
+ printf " ✓ version-card history has no gaps (v0.1.%s → v0.1.%s)\n" "$oldest" "$newest"
+ PASS=$((PASS + 1))
+ else
+ echo " ✗ published release(s) MISSING a version card inside the displayed range:$holes"
+ echo " file: docs/index.html (range shown: v0.1.$oldest → v0.1.$newest)"
+ echo " a shipped release the website skips is invisible to users"
+ FAIL=$((FAIL + 1))
+ fi
+ fi
+else
+ echo " ⓘ gh unavailable/unauthenticated — skipping version-card gap gate"
+fi
+
echo
if (( FAIL == 0 )); then
echo "=== ALL SURFACES MATCH ($PASS passed) ==="
diff --git a/src/aipanel.cpp b/src/aipanel.cpp
index 705e6d9..a1182d6 100644
--- a/src/aipanel.cpp
+++ b/src/aipanel.cpp
@@ -6121,89 +6121,7 @@ void AIPanel::handleToolCall(const QString &id, const QString &name,
const QJsonObject spec = body.value("spec").toObject();
const QString chartId = body.value("chart_id").toString();
const QString title = body.value("title").toString();
- const AiPalette palChart = aiPalette();
-
- // Outer card frame: 1px border + 8px padding per the v0.1.63 spec.
- auto *card = new QFrame(m_chatContent);
- card->setObjectName("chartCard");
- card->setStyleSheet(QString(
- "#chartCard { background: %1; border: 1px solid %2; "
- "border-radius: 8px; padding: 0px; }"
- "QLabel#chartTitle { color: %3; font-size: 11px; "
- "font-weight: 600; letter-spacing: 0.4px; "
- "padding: 6px 10px 0px 10px; background: transparent; }")
- .arg(palChart.assistBg, palChart.assistBorder, palChart.muted));
- auto *cardLay = new QVBoxLayout(card);
- cardLay->setContentsMargins(8, 8, 8, 8);
- cardLay->setSpacing(4);
-
- if (!title.isEmpty()) {
- auto *titleLbl = new QLabel(title, card);
- titleLbl->setObjectName("chartTitle");
- titleLbl->setWordWrap(true);
- cardLay->addWidget(titleLbl);
- }
-
- auto *renderer = new VegaChartRenderer(card);
- cardLay->addWidget(renderer);
-
- // Error row sits hidden until the JS shell reports a parse /
- // runtime failure. Connecting the lambda by-value to `card`
- // means it survives the dispatch loop returning.
- auto *errLbl = new QLabel(card);
- errLbl->setStyleSheet(QString(
- "color: %1; font-size: 10px; font-style: italic;").arg(palChart.errBorder));
- errLbl->setWordWrap(true);
- errLbl->setVisible(false);
- cardLay->addWidget(errLbl);
- QObject::connect(renderer, &VegaChartRenderer::renderError, errLbl,
- [errLbl](const QString &msg) {
- errLbl->setText(QStringLiteral("⚠ Chart error: ") + msg);
- errLbl->setVisible(true);
- });
-
- // v0.1.64 — lite-mode (no WebEngine) "Charts Pack required" actions.
- // The renderer emits installRequested when the user clicks the
- // primary button; we hand off to AIPanel::openChartsPackInstall(),
- // which routes the user to the matching "full" GitHub Release
- // asset for their OS. viewJsonRequested appends the Vega-Lite
- // spec as a fenced code block under the card so users can copy
- // it into a standalone Vega-Lite editor.
- QObject::connect(renderer, &VegaChartRenderer::installRequested,
- this, &AIPanel::openChartsPackInstall);
- QObject::connect(renderer, &VegaChartRenderer::viewJsonRequested,
- this, [this, cardLay](const QJsonObject &liteSpec) {
- const QString json = QString::fromUtf8(
- QJsonDocument(liteSpec).toJson(QJsonDocument::Indented));
- const AiPalette p = aiPalette();
- auto *jsonBlock = new QLabel(cardLay->parentWidget());
- jsonBlock->setTextFormat(Qt::PlainText);
- jsonBlock->setTextInteractionFlags(Qt::TextSelectableByMouse);
- jsonBlock->setText(json);
- jsonBlock->setWordWrap(false);
- jsonBlock->setStyleSheet(QString(
- "background: %1; color: %2; border: 1px solid %3; "
- "border-radius: 4px; padding: 6px 8px; "
- "font-family: 'JetBrains Mono', 'Cascadia Code', "
- "'Consolas', monospace; font-size: 11px;")
- .arg(p.codeBg, p.codeFg, p.assistBorder));
- cardLay->addWidget(jsonBlock);
- });
-
- // Wrap in a margin row, matching the bubble cadence.
- auto *row = new QWidget(m_chatContent);
- row->setStyleSheet(QStringLiteral("background: transparent;"));
- auto *rowLay = new QVBoxLayout(row);
- rowLay->setContentsMargins(0, 0, 0, 12);
- rowLay->setSpacing(0);
- rowLay->addWidget(card);
- m_chatLayout->insertWidget(m_chatLayout->count() - 1, row);
-
- // Feed the spec in. setSpec stashes it if the WebEngine page
- // isn't ready yet and replays on loadFinished. In lite-mode the
- // stub renderer simply stashes the spec for [View JSON instead].
- renderer->setSpec(spec);
-
+ VegaChartRenderer *renderer = addChartCard(spec, title);
resultSummary = renderer->isLiteStub()
? QStringLiteral("charts pack required (id=") + chartId + ")"
: QStringLiteral("rendered (id=") + chartId + ")";
@@ -8165,6 +8083,93 @@ void AIPanel::updateInputAvailability() {
// In-app HTTP download + dynamic-load ships in v0.1.65 once we have the
// Qt plugin shim + macOS/Windows CI runner test coverage that's the only
// reliable way to verify the install actually works across platforms.
+// v0.1.120 (MCP phase 2) — reveal Data Analyst mode. The m_dataMode toggled
+// handler unchecks Chat/Coding and runs applyModeWithCancel(); just set it.
+void AIPanel::showDataMode() {
+ if (m_dataMode && !m_dataMode->isChecked()) m_dataMode->setChecked(true);
+}
+
+// v0.1.120 (MCP phase 2) — factored out of the generate_chart tool-result
+// path so the MCP render_chart verb reuses the exact same inline card.
+VegaChartRenderer *AIPanel::addChartCard(const QJsonObject &vegaLiteSpec,
+ const QString &title) {
+ const AiPalette palChart = aiPalette();
+
+ // Outer card frame: 1px border + 8px padding per the v0.1.63 spec.
+ auto *card = new QFrame(m_chatContent);
+ card->setObjectName("chartCard");
+ card->setStyleSheet(QString(
+ "#chartCard { background: %1; border: 1px solid %2; "
+ "border-radius: 8px; padding: 0px; }"
+ "QLabel#chartTitle { color: %3; font-size: 11px; "
+ "font-weight: 600; letter-spacing: 0.4px; "
+ "padding: 6px 10px 0px 10px; background: transparent; }")
+ .arg(palChart.assistBg, palChart.assistBorder, palChart.muted));
+ auto *cardLay = new QVBoxLayout(card);
+ cardLay->setContentsMargins(8, 8, 8, 8);
+ cardLay->setSpacing(4);
+
+ if (!title.isEmpty()) {
+ auto *titleLbl = new QLabel(title, card);
+ titleLbl->setObjectName("chartTitle");
+ titleLbl->setWordWrap(true);
+ cardLay->addWidget(titleLbl);
+ }
+
+ auto *renderer = new VegaChartRenderer(card);
+ cardLay->addWidget(renderer);
+
+ // Error row sits hidden until the JS shell reports a parse / runtime
+ // failure. Connecting the lambda by-value to `card` means it survives.
+ auto *errLbl = new QLabel(card);
+ errLbl->setStyleSheet(QString(
+ "color: %1; font-size: 10px; font-style: italic;").arg(palChart.errBorder));
+ errLbl->setWordWrap(true);
+ errLbl->setVisible(false);
+ cardLay->addWidget(errLbl);
+ QObject::connect(renderer, &VegaChartRenderer::renderError, errLbl,
+ [errLbl](const QString &msg) {
+ errLbl->setText(QStringLiteral("⚠ Chart error: ") + msg);
+ errLbl->setVisible(true);
+ });
+
+ // v0.1.64 — lite-mode (no WebEngine) "Charts Pack required" actions.
+ QObject::connect(renderer, &VegaChartRenderer::installRequested,
+ this, &AIPanel::openChartsPackInstall);
+ QObject::connect(renderer, &VegaChartRenderer::viewJsonRequested,
+ this, [this, cardLay](const QJsonObject &liteSpec) {
+ const QString json = QString::fromUtf8(
+ QJsonDocument(liteSpec).toJson(QJsonDocument::Indented));
+ const AiPalette p = aiPalette();
+ auto *jsonBlock = new QLabel(cardLay->parentWidget());
+ jsonBlock->setTextFormat(Qt::PlainText);
+ jsonBlock->setTextInteractionFlags(Qt::TextSelectableByMouse);
+ jsonBlock->setText(json);
+ jsonBlock->setWordWrap(false);
+ jsonBlock->setStyleSheet(QString(
+ "background: %1; color: %2; border: 1px solid %3; "
+ "border-radius: 4px; padding: 6px 8px; "
+ "font-family: 'JetBrains Mono', 'Cascadia Code', "
+ "'Consolas', monospace; font-size: 11px;")
+ .arg(p.codeBg, p.codeFg, p.assistBorder));
+ cardLay->addWidget(jsonBlock);
+ });
+
+ // Wrap in a margin row, matching the bubble cadence.
+ auto *row = new QWidget(m_chatContent);
+ row->setStyleSheet(QStringLiteral("background: transparent;"));
+ auto *rowLay = new QVBoxLayout(row);
+ rowLay->setContentsMargins(0, 0, 0, 12);
+ rowLay->setSpacing(0);
+ rowLay->addWidget(card);
+ m_chatLayout->insertWidget(m_chatLayout->count() - 1, row);
+
+ // Feed the spec in. setSpec stashes it if the WebEngine page isn't ready
+ // yet and replays on loadFinished; in lite-mode the stub just stashes it.
+ renderer->setSpec(vegaLiteSpec);
+ return renderer;
+}
+
void AIPanel::openChartsPackInstall() {
// Compose the canonical Releases-tag URL. The user lands on a page
// that lists both flavors side by side; they pick the "full" one.
diff --git a/src/aipanel.h b/src/aipanel.h
index 0c19514..e8794f4 100644
--- a/src/aipanel.h
+++ b/src/aipanel.h
@@ -30,6 +30,7 @@ class QCompleter;
class QDragEnterEvent;
class QDropEvent;
class EditPlanList;
+class VegaChartRenderer;
// v0.1.67 — friend hook for the chat-history regression test. Lets
// test_ai_chat_history.cpp read the three per-mode vectors directly and
@@ -71,6 +72,15 @@ class AIPanel : public QWidget {
using ContextProvider = std::function;
void setContextProvider(ContextProvider provider) { m_contextProvider = std::move(provider); }
+ // v0.1.120 (MCP phase 2) — switch the panel to Data Analyst mode (checks
+ // m_dataMode, whose toggled handler unchecks the siblings + applies).
+ void showDataMode();
+ // v0.1.120 (MCP phase 2) — insert an inline Vega-Lite chart card into the
+ // transcript (factored out of the generate_chart tool-result path).
+ // Returns the renderer (chartId()/isLiteStub()); never null on success.
+ VegaChartRenderer *addChartCard(const QJsonObject &vegaLiteSpec,
+ const QString &title);
+
// Exposed for unit tests — pure function that assembles the
// "workspace awareness" block the AI sees before the user's prompt.
// Budget-capped so small local models don't overflow their context.
diff --git a/src/dbconnections.cpp b/src/dbconnections.cpp
index 1d99473..f580e29 100644
--- a/src/dbconnections.cpp
+++ b/src/dbconnections.cpp
@@ -515,21 +515,30 @@ static const QSet &sideEffectFunctions() {
return k;
}
-// DuckDB filesystem-READING table functions (Full edition). These are
-// read-only by SQL semantics, so the read-only classifier passes them — but
-// on the MCP run_sql path that makes the sandbox an arbitrary-file-read
-// primitive (SELECT * FROM read_text('/home/user/.ssh/id_rsa')). Rejected
-// ONLY when restrictFilesystem is true (the MCP path), matched call-form only
-// (name immediately followed by '(') so a column/string named the same stays
-// safe. Legit ingestion (Client::registerCsv) builds its view in TRUSTED code
-// that never calls classifySql, so this denylist never touches ingestion.
+// Filesystem-READING functions across engines (DuckDB, MySQL, Postgres).
+// These are read-only by SQL semantics, so the read-only classifier passes
+// them — but on the MCP path (run_sql AND the phase-2 run_query verb, which
+// reaches saved MySQL/Postgres/DuckDB connections with no in-panel approval)
+// that makes any of them an arbitrary server-side-file-read primitive
+// (SELECT read_text('…') / SELECT LOAD_FILE('/etc/passwd') /
+// SELECT pg_read_server_files, pg_stat_file('…')). Rejected ONLY when
+// restrictFilesystem is true (the MCP path), matched call-form only (name
+// immediately followed by '(') so a column/string named the same stays safe.
+// Legit ingestion (Client::registerCsv) builds its view in TRUSTED code that
+// never calls classifySql, so this denylist never touches ingestion.
static const QSet &fileReadingFunctions() {
static const QSet k = {
+ // DuckDB
"READ_CSV", "READ_CSV_AUTO", "READ_TEXT", "READ_TEXT_AUTO",
"READ_BLOB", "READ_PARQUET", "READ_JSON", "READ_JSON_AUTO",
"READ_JSON_OBJECTS", "READ_NDJSON", "GLOB", "SNIFF_CSV",
"PARQUET_METADATA", "PARQUET_SCHEMA", "PARQUET_FILE_METADATA",
- "PARQUET_KV_METADATA"
+ "PARQUET_KV_METADATA",
+ // MySQL — reads a server-side file with FILE privilege.
+ "LOAD_FILE",
+ // Postgres — server-side file/dir read + metadata leakage.
+ "PG_READ_SERVER_FILES", "PG_LS_DIR", "PG_STAT_FILE",
+ "PG_LS_LOGDIR", "PG_LS_WALDIR", "PG_LS_TMPDIR", "PG_LS_ARCHIVE_STATUSDIR"
};
return k;
}
@@ -808,8 +817,15 @@ QStringList listTables(const Record &r, bool *outOk) {
// current DB (which the connection string's DATABASE= selects).
sql = QStringLiteral(
"SELECT name FROM sys.tables ORDER BY name");
+ } else if (r.driver.compare(QStringLiteral("DUCKDB"), Qt::CaseInsensitive) == 0) {
+ // DuckDB routes through runQuery's native path below. Exclude the
+ // system catalogues so only user tables/views surface.
+ sql = QStringLiteral(
+ "SELECT table_name FROM information_schema.tables "
+ "WHERE table_schema NOT IN ('information_schema','pg_catalog') "
+ "ORDER BY table_name");
} else {
- return tables; // DuckDB handled separately via runQuery if needed.
+ return tables; // unknown driver — caller reports the failure.
}
QueryResult q = runQuery(r, sql, 500, /*allowMutation=*/false);
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 3c1b2e3..799b4b3 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -46,6 +46,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -268,6 +269,10 @@ static QString tolerantPrettyJson(const QString &input, int indentSize = 4) {
#include "ai_tools.h"
#include "dbconnections.h"
#include "tool_colors.h"
+// v0.1.120 — MCP phase 2 chart verbs (both safe in Lite; the renderer paints
+// an "install charts pack" stub without WebEngine).
+#include "chart_spec_to_vega.h"
+#include "charts/vega_chart_renderer.h"
#include
#include
#include
@@ -276,6 +281,7 @@ static QString tolerantPrettyJson(const QString &input, int indentSize = 4) {
#include
#include
#include
+#include
#include
#include
#include
@@ -331,6 +337,117 @@ static QString mcpInjectNoteBody(QString html, const QString &text) {
return html;
}
+#ifdef NOTEPATRA_WITH_WEBENGINE
+// MCP render_chart/export_chart accept either a Vega-Lite v5 spec or the
+// simplified {type,x,y,data} form; translate the latter. Empty result + *err
+// on an unsupported/malformed simplified spec.
+static QJsonObject mcpChartToVegaLite(const QJsonObject &spec, QString *err) {
+ const bool isVegaLite = spec.contains(QLatin1String("mark")) ||
+ spec.contains(QLatin1String("$schema")) ||
+ spec.contains(QLatin1String("encoding"));
+ if (isVegaLite) return spec;
+ QString terr;
+ const QJsonObject vl = ChartSpecToVega::translate(
+ spec, ChartSpecToVega::Theme{false, QStringLiteral("Light")}, &terr);
+ if (vl.isEmpty() && err)
+ *err = terr.isEmpty() ? QStringLiteral("unsupported chart spec") : terr;
+ return vl;
+}
+#endif
+
+// SSOT for the Language surface: the menu, MCP set_language resolution, and
+// list_languages all read these lists — add a language in ONE place.
+static const QStringList &commonLanguageTokens() {
+ static const QStringList list = {
+ QStringLiteral("Bash"), QStringLiteral("C"), QStringLiteral("C#"),
+ QStringLiteral("C++"), QStringLiteral("CSS"), QStringLiteral("HTML"),
+ QStringLiteral("Java"), QStringLiteral("JavaScript"),
+ QStringLiteral("JSON"), QStringLiteral("Lua"),
+ QStringLiteral("Markdown"), QStringLiteral("Perl"),
+ QStringLiteral("Python"), QStringLiteral("Ruby"),
+ QStringLiteral("SQL"), QStringLiteral("XML"), QStringLiteral("YAML")};
+ return list;
+}
+
+static const QStringList &moreLanguageTokens() {
+ static const QStringList list = [] {
+ QStringList l = {
+ QStringLiteral("ASM"), QStringLiteral("Apex"), QStringLiteral("AVS"),
+ QStringLiteral("Batch"), QStringLiteral("BibTeX"),
+ QStringLiteral("CMake"), QStringLiteral("CoffeeScript"),
+ QStringLiteral("Crystal"), QStringLiteral("Cython"),
+ QStringLiteral("D"), QStringLiteral("Dart"), QStringLiteral("Diff"),
+ QStringLiteral("Dockerfile"), QStringLiteral("DotEnv"),
+ QStringLiteral("Elixir"), QStringLiteral("F#"), QStringLiteral("Fish"),
+ QStringLiteral("Fortran"), QStringLiteral("Fortran77"),
+ QStringLiteral("GDScript"), QStringLiteral("Gitignore"),
+ QStringLiteral("Go"), QStringLiteral("GraphQL"), QStringLiteral("Groovy"),
+ QStringLiteral("Hack"), QStringLiteral("HCL"), QStringLiteral("IDL"),
+ QStringLiteral("IntelHex"), QStringLiteral("Jinja"),
+ QStringLiteral("JSON5"), QStringLiteral("Julia"),
+ QStringLiteral("Kotlin"), QStringLiteral("Liquid"),
+ QStringLiteral("Makefile"), QStringLiteral("MASM"),
+ QStringLiteral("Matlab"), QStringLiteral("Mojo"),
+ QStringLiteral("NASM"), QStringLiteral("Nim"), QStringLiteral("Nushell"),
+ QStringLiteral("Octave"), QStringLiteral("Pascal"), QStringLiteral("PO"),
+ QStringLiteral("PostScript"), QStringLiteral("POV"),
+ QStringLiteral("PowerShell"), QStringLiteral("Properties"),
+ QStringLiteral("Protobuf"), QStringLiteral("R"), QStringLiteral("Rust"),
+ QStringLiteral("Scala"), QStringLiteral("Solidity"),
+ QStringLiteral("Spice"), QStringLiteral("SRecord"),
+ QStringLiteral("Swift"), QStringLiteral("TCL"), QStringLiteral("TeX"),
+ QStringLiteral("Thrift"), QStringLiteral("TOML"), QStringLiteral("Twig"),
+ QStringLiteral("TypeScript"), QStringLiteral("Vala"),
+ QStringLiteral("Verilog"), QStringLiteral("VHDL"), QStringLiteral("Zig")};
+ l.sort(Qt::CaseInsensitive);
+ return l;
+ }();
+ return list;
+}
+
+static const QStringList &allKnownLanguageTokens() {
+ static const QStringList list = QStringList()
+ << QStringLiteral("Plain Text") << commonLanguageTokens()
+ << moreLanguageTokens();
+ return list;
+}
+
+// Canonicalizes an agent-supplied language token; "" when unknown.
+static QString resolveLanguageToken(const QString &input) {
+ const QString token = input.trimmed();
+ if (token.isEmpty()) return QString();
+ // (a) exact factory success — accepts every token the menu can set.
+ if (QsciLexer *probe = createLexerForLanguage(token, nullptr)) {
+ delete probe;
+ return token;
+ }
+ // (b) case-insensitive match against the canonical set.
+ for (const QString &l : allKnownLanguageTokens())
+ if (l.compare(token, Qt::CaseInsensitive) == 0) return l;
+ // (c) common agent aliases.
+ static const QHash aliases = {
+ {QStringLiteral("python"), QStringLiteral("Python")},
+ {QStringLiteral("py"), QStringLiteral("Python")},
+ {QStringLiteral("js"), QStringLiteral("JavaScript")},
+ {QStringLiteral("ts"), QStringLiteral("TypeScript")},
+ {QStringLiteral("cpp"), QStringLiteral("C++")},
+ {QStringLiteral("c++"), QStringLiteral("C++")},
+ {QStringLiteral("csharp"), QStringLiteral("C#")},
+ {QStringLiteral("c#"), QStringLiteral("C#")},
+ {QStringLiteral("sh"), QStringLiteral("Bash")},
+ {QStringLiteral("shell"), QStringLiteral("Bash")},
+ {QStringLiteral("bash"), QStringLiteral("Bash")},
+ {QStringLiteral("yml"), QStringLiteral("YAML")},
+ {QStringLiteral("yaml"), QStringLiteral("YAML")},
+ {QStringLiteral("md"), QStringLiteral("Markdown")},
+ {QStringLiteral("golang"), QStringLiteral("Go")},
+ {QStringLiteral("go"), QStringLiteral("Go")},
+ {QStringLiteral("rs"), QStringLiteral("Rust")},
+ {QStringLiteral("rb"), QStringLiteral("Ruby")},
+ {QStringLiteral("kt"), QStringLiteral("Kotlin")}};
+ return aliases.value(token.toLower());
+}
+
static void drawAiFeatureGlyph(QPainter &painter, const QRectF &rect) {
painter.setPen(QPen(Qt::white, 1.8, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
painter.setBrush(Qt::NoBrush);
@@ -1640,9 +1757,18 @@ MainWindow::MainWindow(bool standaloneNoSession)
auto *ed = currentEditor();
return ed ? ed->selectedText() : QString();
};
- host.workspaceRoot = [this] {
- return m_explorer ? m_explorer->rootPath() : QString();
+ // Explorer root, else the current file's directory (git resolves the
+ // enclosing repo from there); "" only when neither exists.
+ auto effectiveWorkspaceRoot = [this]() -> QString {
+ QString root = m_explorer ? m_explorer->rootPath() : QString();
+ if (root.isEmpty()) {
+ if (auto *ed = currentEditor())
+ if (!ed->filePath().isEmpty())
+ root = QFileInfo(ed->filePath()).absolutePath();
+ }
+ return root;
};
+ host.workspaceRoot = effectiveWorkspaceRoot;
// ── v0.1.118 expansive wave — every lambda routes through the SAME
// code path the equivalent menu/status-bar surface uses. ──
host.currentTabIndex = [this] { return m_tabs->currentIndex(); };
@@ -1740,6 +1866,11 @@ MainWindow::MainWindow(bool standaloneNoSession)
return out;
};
host.notesRoot = [] { return NotesPanel::defaultNotesFolder(); };
+ // ── Phase 0A — language SSOT surface for the MCP bridge. ──
+ host.knownLanguages = [] { return allKnownLanguageTokens(); };
+ host.resolveLanguage = [](const QString &in) {
+ return resolveLanguageToken(in);
+ };
// ── v0.1.118 WRITE tier — each lambda reuses the SAME Editor/save
// path the equivalent menu action uses, wrapped in one undo step.
// The bridge calls them only after the human clicks Approve. ──
@@ -1839,17 +1970,22 @@ MainWindow::MainWindow(bool standaloneNoSession)
return arr;
};
// READ: read-only git — reuses git_tools.cpp (no new QProcess path).
- host.runGit = [this](const QString &sub, const QJsonObject &args,
- QString *err) -> QString {
- // Same anchor the AI Composer git tools use: Explorer root, else
- // the current file's directory.
- QString root = m_explorer ? m_explorer->rootPath() : QString();
- if (root.isEmpty()) {
- if (auto *ed = currentEditor())
- if (!ed->filePath().isEmpty())
- root = QFileInfo(ed->filePath()).absolutePath();
- }
- if (root.isEmpty()) {
+ host.runGit = [this, effectiveWorkspaceRoot](
+ const QString &sub, const QJsonObject &args,
+ QString *err) -> QString {
+ // Candidate roots: the workspace (Explorer) folder first, then the
+ // current file's directory — so git "just works" on an open repo
+ // file even when the workspace folder isn't itself a repository.
+ QStringList roots;
+ const QString wsRoot = effectiveWorkspaceRoot();
+ if (!wsRoot.isEmpty()) roots << wsRoot;
+ if (auto *ed = currentEditor())
+ if (!ed->filePath().isEmpty()) {
+ const QString fdir =
+ QFileInfo(ed->filePath()).absolutePath();
+ if (!roots.contains(fdir)) roots << fdir;
+ }
+ if (roots.isEmpty()) {
if (err) *err = QStringLiteral("no workspace folder open");
return QString();
}
@@ -1863,21 +1999,28 @@ MainWindow::MainWindow(bool standaloneNoSession)
AiTools::ToolCall call;
call.name = QStringLiteral("git_") + sub;
call.args = a;
- AiTools::ToolResult res;
- if (sub == QLatin1String("status"))
- res = GitTools::executeGitStatus(call, root);
- else if (sub == QLatin1String("diff"))
- res = GitTools::executeGitDiff(call, root);
- else if (sub == QLatin1String("log"))
- res = GitTools::executeGitLog(call, root);
- else if (sub == QLatin1String("show"))
- res = GitTools::executeGitShow(call, root);
- else if (sub == QLatin1String("branch"))
- res = GitTools::executeGitBranchList(call, root);
- else {
- if (err) *err = QStringLiteral("unknown git subcommand");
- return QString();
- }
+ auto runOnce = [&](const QString &root) -> AiTools::ToolResult {
+ if (sub == QLatin1String("status"))
+ return GitTools::executeGitStatus(call, root);
+ if (sub == QLatin1String("diff"))
+ return GitTools::executeGitDiff(call, root);
+ if (sub == QLatin1String("log"))
+ return GitTools::executeGitLog(call, root);
+ if (sub == QLatin1String("show"))
+ return GitTools::executeGitShow(call, root);
+ if (sub == QLatin1String("branch"))
+ return GitTools::executeGitBranchList(call, root);
+ AiTools::ToolResult e;
+ e.isError = true;
+ e.content =
+ QStringLiteral("{\"message\":\"unknown git subcommand\"}");
+ return e;
+ };
+ // Workspace root wins when it is a repo; otherwise fall through to
+ // the file's repo so an agent editing a repo file always gets git.
+ AiTools::ToolResult res = runOnce(roots.first());
+ for (int i = 1; i < roots.size() && res.isError; ++i)
+ res = runOnce(roots.at(i));
if (res.isError) {
const QJsonObject body =
QJsonDocument::fromJson(res.content.toUtf8()).object();
@@ -1928,6 +2071,8 @@ MainWindow::MainWindow(bool standaloneNoSession)
// directory canonicalizing cleanly). When no folder root is open
// the root is empty and resolveSafePath rejects everything —
// matching the git verbs' behavior.
+ // Deliberately NOT the workspaceRoot fallback: csv sandbox root
+ // stays folder-open-only.
const QString wsRoot =
m_explorer ? m_explorer->rootPath() : QString();
QString csvCanon;
@@ -2097,6 +2242,228 @@ MainWindow::MainWindow(bool standaloneNoSession)
}
return true;
};
+ // ACT: create a Diagram tab via the same helper the menu uses.
+ host.createDiagram = [this](const QString &source, const QString &title) {
+ return newDiagramTab(source, title);
+ };
+ // WRITE: replace a DiagramEditor's .npd source (canvas re-renders).
+ host.setDiagramSource = [this](int i, const QString &src) -> bool {
+ auto *de = qobject_cast(m_tabs->widget(i));
+ if (!de) return false;
+ de->setNpdText(src);
+ return true;
+ };
+ // ACT: reveal/focus the Noter tab — same path as the "+ Noter" UI.
+ host.openNoter = [this]() -> bool {
+ return ensureNoterTab() != nullptr;
+ };
+ // ── Phase 2: Data-analyst + Charts ──
+ // READ: sanitized saved-connection list — name/driver/database only,
+ // never credentials.
+ host.listConnections = [] {
+ QJsonArray out;
+ const QVector recs = DbConnections::loadAll();
+ for (const DbConnections::Record &r : recs) {
+ QJsonObject o;
+ o[QStringLiteral("name")] = r.name;
+ o[QStringLiteral("driver")] = r.driver;
+ o[QStringLiteral("database")] = r.database;
+ // The MCP surface is read-only by construction (allowMutation
+ // is false everywhere it can reach a named connection).
+ o[QStringLiteral("read_only")] = true;
+ out.append(o);
+ }
+ return out;
+ };
+ // Pure classification for the bridge's export fail-fast.
+ host.classifySqlReadOnly = [](const QString &sql, QString *reason) {
+ const DbConnections::SqlVerdict v =
+ DbConnections::classifySql(sql, /*restrictFilesystem=*/true);
+ if (v.singleStatement && v.readOnly) return true;
+ if (reason)
+ *reason = v.reason.isEmpty()
+ ? QStringLiteral("query is not read-only (SELECT only)")
+ : v.reason;
+ return false;
+ };
+ // READ: SELECT-only query on a SAVED connection (what run_sql can't reach).
+ host.runNamedQuery = [](const QString &name, const QString &sql,
+ int maxRows, QString *err) -> QJsonObject {
+ const DbConnections::SqlVerdict v =
+ DbConnections::classifySql(sql, /*restrictFilesystem=*/true);
+ if (!(v.singleStatement && v.readOnly)) {
+ if (err)
+ *err = v.reason.isEmpty()
+ ? QStringLiteral("query is not read-only (SELECT only)")
+ : QStringLiteral("query rejected: %1").arg(v.reason);
+ return QJsonObject();
+ }
+ DbConnections::Record rec;
+ if (!DbConnections::findByName(name, &rec)) {
+ if (err) *err = QStringLiteral("no connection named: %1").arg(name);
+ return QJsonObject();
+ }
+ if (rec.driver == QLatin1String("DUCKDB")) {
+#ifdef NOTEPATRA_HAVE_DUCKDB
+ rec.sandboxFilesystem = true; // engine-level file-read lockdown
+#else
+ if (err)
+ *err = QStringLiteral(
+ "this connection requires the Full edition (DuckDB)");
+ return QJsonObject();
+#endif
+ }
+ const DbConnections::QueryResult qr =
+ DbConnections::runQuery(rec, sql, maxRows, /*allowMutation=*/false,
+ nullptr);
+ if (!qr.ok) {
+ if (err)
+ *err = qr.error.isEmpty() ? QStringLiteral("query failed")
+ : qr.error;
+ return QJsonObject();
+ }
+ QJsonArray cols;
+ for (const QString &c : qr.columns) cols.append(c);
+ QJsonArray rows;
+ for (const QVector &row : qr.rows) {
+ QJsonArray jr;
+ for (const QString &cell : row) jr.append(cell);
+ rows.append(jr);
+ }
+ QString engine = QStringLiteral("odbc");
+ if (rec.driver == QLatin1String("QSQLITE"))
+ engine = QStringLiteral("sqlite");
+ else if (rec.driver == QLatin1String("QPSQL"))
+ engine = QStringLiteral("postgres");
+ else if (rec.driver == QLatin1String("QMYSQL"))
+ engine = QStringLiteral("mysql");
+ else if (rec.driver == QLatin1String("DUCKDB"))
+ engine = QStringLiteral("duckdb");
+ QJsonObject out;
+ out[QStringLiteral("columns")] = cols;
+ out[QStringLiteral("rows")] = rows;
+ out[QStringLiteral("truncated")] = qr.truncated;
+ out[QStringLiteral("engine")] = engine;
+ return out;
+ };
+ // READ: table list over a saved connection.
+ host.listTables = [](const QString &name, QString *err) -> QJsonArray {
+ DbConnections::Record rec;
+ if (!DbConnections::findByName(name, &rec)) {
+ if (err) *err = QStringLiteral("no connection named: %1").arg(name);
+ return QJsonArray();
+ }
+ if (rec.driver == QLatin1String("DUCKDB")) {
+#ifdef NOTEPATRA_HAVE_DUCKDB
+ rec.sandboxFilesystem = true; // engine-level file-read lockdown
+#else
+ if (err)
+ *err = QStringLiteral(
+ "this connection requires the Full edition (DuckDB)");
+ return QJsonArray();
+#endif
+ }
+ bool ok = true;
+ const QStringList tables = DbConnections::listTables(rec, &ok);
+ if (!ok) {
+ if (err) *err = QStringLiteral("could not connect to: %1").arg(name);
+ return QJsonArray();
+ }
+ QJsonArray out;
+ for (const QString &t : tables) out.append(t);
+ return out;
+ };
+ // ACT: reveal the AI dock in Data Analyst mode.
+ host.openDataAnalyst = [this]() -> bool {
+ if (!m_aiDockPanel) return false;
+ showAiDockForInvocation();
+ m_aiDockPanel->showDataMode();
+ return true;
+ };
+#ifdef NOTEPATRA_WITH_WEBENGINE
+ // ACT: inline chart card in the Data transcript (Full/WebEngine only).
+ host.renderChart = [this](const QJsonObject &spec, const QString &title,
+ QString *err) -> QJsonObject {
+ const QJsonObject vl = mcpChartToVegaLite(spec, err);
+ if (vl.isEmpty()) return QJsonObject();
+ if (!m_aiDockPanel) {
+ if (err) *err = QStringLiteral("AI panel unavailable");
+ return QJsonObject();
+ }
+ showAiDockForInvocation();
+ m_aiDockPanel->showDataMode();
+ VegaChartRenderer *r = m_aiDockPanel->addChartCard(vl, title);
+ QJsonObject out;
+ out[QStringLiteral("chart_id")] = r->chartId();
+ out[QStringLiteral("rendered")] = !r->isLiteStub();
+ return out;
+ };
+ // WRITE: off-screen render + async export → file (Full/WebEngine only).
+ host.exportChart = [this](const QJsonObject &spec, const QString &path,
+ const QString &format, int scale,
+ QString *err) -> bool {
+ const QJsonObject vl = mcpChartToVegaLite(spec, err);
+ if (vl.isEmpty()) return false;
+ VegaChartRenderer renderer;
+ renderer.setAttribute(Qt::WA_DontShowOnScreen);
+ renderer.resize(900, 600);
+ renderer.show(); // realizes the WebEngine page off-screen
+ // Bounded wait for the first render before exporting.
+ {
+ QEventLoop loop;
+ bool done = false;
+ QObject::connect(&renderer, &VegaChartRenderer::renderReady,
+ &loop, [&] { done = true; loop.quit(); });
+ QObject::connect(&renderer, &VegaChartRenderer::renderError,
+ &loop, [&](const QString &m) {
+ if (err) *err = m;
+ loop.quit();
+ });
+ QTimer::singleShot(20000, &loop, [&] { loop.quit(); });
+ renderer.setSpec(vl);
+ if (!done) loop.exec();
+ if (!done) {
+ if (err && err->isEmpty())
+ *err = QStringLiteral("chart render timed out");
+ return false;
+ }
+ }
+ QByteArray bytes;
+ {
+ QEventLoop loop;
+ bool got = false;
+ auto cb = [&](const QByteArray &b) {
+ bytes = b;
+ got = true;
+ loop.quit();
+ };
+ if (format == QLatin1String("png"))
+ renderer.exportPngAsync(scale, cb);
+ else if (format == QLatin1String("svg"))
+ renderer.exportSvgAsync(cb);
+ else if (format == QLatin1String("html"))
+ renderer.exportHtmlAsync(cb);
+ else
+ renderer.exportSpecAsync(cb);
+ QTimer::singleShot(20000, &loop, [&] { loop.quit(); });
+ if (!got) loop.exec();
+ }
+ if (bytes.isEmpty()) {
+ if (err && err->isEmpty())
+ *err = QStringLiteral("chart export produced no data");
+ return false;
+ }
+ QFile f(path);
+ if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
+ if (err) *err = QStringLiteral("could not write: %1")
+ .arg(QDir::toNativeSeparators(path));
+ return false;
+ }
+ f.write(bytes);
+ f.close();
+ return true;
+ };
+#endif
new McpBridge(std::move(host), this);
}
}
@@ -3390,16 +3757,8 @@ void MainWindow::buildMenus() {
diagAct->setChecked(false);
return;
}
- if (!diag) {
- diag = new DiagramEditor;
- exitAiFullscreenIfActive();
- existingIdx = m_tabs->addTab(diag, "Diagram");
- connect(diag, &DiagramEditor::titleChanged, this, [this, diag](const QString &t) {
- const int idx = m_tabs->indexOf(diag);
- if (idx >= 0) m_tabs->setTabText(idx, t);
- });
- }
- m_tabs->setCurrentIndex(existingIdx);
+ if (!diag) existingIdx = newDiagramTab(QString(), QString());
+ else m_tabs->setCurrentIndex(existingIdx);
diagAct->setChecked(true);
});
@@ -3539,9 +3898,8 @@ void MainWindow::buildMenus() {
lang->addSeparator();
// Common languages — flat, alphabetical for predictable scanning.
- for (const auto &l : {"Bash", "C", "C#", "C++", "CSS", "HTML", "Java",
- "JavaScript", "JSON", "Lua", "Markdown", "Perl",
- "Python", "Ruby", "SQL", "XML", "YAML"}) {
+ // SSOT: commonLanguageTokens() (also feeds MCP list_languages).
+ for (const QString &l : commonLanguageTokens()) {
lang->addAction(l, this, [this, E, l]() {
if (auto *e = E()) { e->setLanguage(l); m_statusBar->updateLanguage(l); }
});
@@ -3571,24 +3929,10 @@ void MainWindow::buildMenus() {
lang->addSeparator();
// More Languages — the long tail. Single alphabetical submenu listing
- // everything else. Adding a new language is one line: append to the
- // initializer list.
+ // everything else. Adding a new language is one line: append to
+ // moreLanguageTokens() (pre-sorted; also feeds MCP list_languages).
auto *moreLang = lang->addMenu("More Languages");
- QStringList more = {
- "ASM", "Apex", "AVS", "Batch", "BibTeX",
- "CMake", "CoffeeScript", "Crystal", "Cython",
- "D", "Dart", "Diff", "Dockerfile", "DotEnv",
- "Elixir", "F#", "Fish", "Fortran", "Fortran77",
- "GDScript", "Gitignore", "Go", "GraphQL", "Groovy",
- "Hack", "HCL", "IDL", "IntelHex", "Jinja", "JSON5", "Julia",
- "Kotlin", "Liquid", "Makefile", "MASM", "Matlab", "Mojo",
- "NASM", "Nim", "Nushell", "Octave", "Pascal", "PO",
- "PostScript", "POV", "PowerShell", "Properties", "Protobuf",
- "R", "Rust", "Scala", "Solidity", "Spice", "SRecord", "Swift",
- "TCL", "TeX", "Thrift", "TOML", "Twig", "TypeScript",
- "Vala", "Verilog", "VHDL", "Zig",
- };
- more.sort(Qt::CaseInsensitive);
+ const QStringList &more = moreLanguageTokens();
for (const QString &l : more) {
moreLang->addAction(l, this, [this, E, l]() {
if (auto *e = E()) { e->setLanguage(l); m_statusBar->updateLanguage(l); }
@@ -5087,6 +5431,23 @@ NotesPanel *MainWindow::ensureNoterTab() {
return noter;
}
+// One diagram-tab creation path — the Features->Diagram menu action and the
+// MCP create_diagram verb both call this (no duplicated creation logic).
+int MainWindow::newDiagramTab(const QString &source, const QString &title) {
+ auto *diag = new DiagramEditor;
+ if (!source.isEmpty()) diag->setNpdText(source);
+ exitAiFullscreenIfActive();
+ const int idx = m_tabs->addTab(diag, title.isEmpty()
+ ? QStringLiteral("Diagram") : title);
+ connect(diag, &DiagramEditor::titleChanged, this,
+ [this, diag](const QString &t) {
+ const int i = m_tabs->indexOf(diag);
+ if (i >= 0) m_tabs->setTabText(i, t);
+ });
+ m_tabs->setCurrentIndex(idx);
+ return idx;
+}
+
void MainWindow::buildToolbar() {
auto *featureTb = addToolBar("Built-in Tools");
featureTb->setObjectName("featureShortcutBar");
diff --git a/src/mainwindow.h b/src/mainwindow.h
index 7ada1f8..f722387 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -292,6 +292,7 @@ class MainWindow : public QMainWindow {
void ensureNoterReminderService();
NotesPanel *findNoterPanel(int *indexOut = nullptr) const;
NotesPanel *ensureNoterTab();
+ int newDiagramTab(const QString &source, const QString &title); // Features->Diagram + MCP create_diagram
void onNoterTrayMessageClicked();
// Macro recording/playback
diff --git a/src/mcp_bridge.cpp b/src/mcp_bridge.cpp
index 20720ef..6254312 100644
--- a/src/mcp_bridge.cpp
+++ b/src/mcp_bridge.cpp
@@ -38,12 +38,33 @@
// (v0.1.119)
// run_sql {sql,csv_path?} → {columns:[...],rows:[[...]],
// truncated,engine} (v0.1.119)
+// list_languages {} → {languages:[...]} (p0a)
+// get_capabilities {} → {edition,platform,version,
+// features:{duckdb,webengine,
+// noter}} (p0a)
+// (the Rust sidecar augments the
+// tool result with tool_count
+// and tiers — derived there)
+// get_diagram_source {tab_index} → {source} (phase 1)
+// list_connections {} → {connections:[{name,driver,
+// database,read_only}]} (phase 2)
+// run_query {connection_name,sql,max_rows?(=200,cap 200)}
+// → {columns,rows,truncated,engine} (phase 2)
+// list_tables{connection_name} → {tables:[...]} (phase 2)
//
// ACT tier — visible, non-destructive, NO approval card.
// new_tab {text?} → {tab_index}
// goto_line {line,tab_index?} → {ok,tab_index,line}
// set_language{language,tab_index?} → {ok,tab_index,language}
+// (language echoes the RESOLVED
+// canonical token since p0a)
// open_note {file} → {opened,title} (v0.1.119)
+// create_diagram {source?,title?} → {tab_index,valid,
+// errors:[{line,message}]} (phase 1)
+// open_noter {} → {opened} (phase 1)
+// open_data_analyst {} → {opened} (phase 2)
+// render_chart {spec,title?} → {chart_id,rendered} (phase 2,
+// Full/WebEngine only)
//
// WRITE tier — held behind the in-window human-approval card; a mutation
// runs ONLY on Approve (Deny / timeout / disconnect ⇒ nothing happened).
@@ -55,6 +76,13 @@
// append_note {file,text} → {file} (v0.1.119)
// set_reminder{file,due_iso} → {file,due_iso} (v0.1.119)
// export_diagram {tab_index,path,format("png"|"pdf")} → {path} (v0.1.119)
+// set_diagram_source {tab_index,source} → {ok,tab_index,valid,
+// errors:[{line,message}]} (phase 1)
+// export_query_results {connection_name,sql,path,format("csv"|"json"),
+// max_rows?(=10000,cap 100000)} → {ok,path,rows} (phase 2)
+// export_chart {spec,path,format("png"|"svg"|"html"|"spec"),scale?(=2,1..4)}
+// → {path} (phase 2,
+// Full/WebEngine only)
//
// NOTE: write verbs take "tab_index"; read tab-arg verbs keep their existing
// keys ("index" for read_tab/find_in_tab). This asymmetry is deliberate and
@@ -62,6 +90,7 @@
// ═══════════════════════════════════════════════════════════════════════
#include "mcp_bridge.h"
#include "build_flavor.h"
+#include "config.h"
#include "diagram/npd_parser.h"
#include "notes_storage.h"
#include "singleinstance.h"
@@ -84,6 +113,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -120,6 +150,11 @@ constexpr int kMaxSqlRows = 200; // run_sql row cap
constexpr int kMaxSqlCells = 1024; // per-cell char cap
constexpr int kAppendNoteMaxChars = 1 * 1024 * 1024;
constexpr int kCreateNoteMaxChars = 1 * 1024 * 1024;
+constexpr int kDiagramSourceMaxChars = 1 * 1024 * 1024; // create/set_diagram_source
+// Phase 2 — data-analyst + charts.
+constexpr int kExportRowsDefault = 10000; // export_query_results default cap
+constexpr int kExportRowsMax = 100000; // export_query_results hard cap
+constexpr int kChartScaleDefault = 2; // export_chart png scale
QString elideForCard(const QString &s, int cap) {
if (s.size() <= cap) return s;
@@ -245,6 +280,75 @@ QJsonObject scanWorkspace(const QString &root, const QString &query,
result[QStringLiteral("truncated")] = truncated;
return result;
}
+
+// Defensive re-cap of a {columns,rows,truncated,engine} result: clamp to
+// kMaxSqlRows and truncate any string cell over kMaxSqlCells (shared by
+// run_sql and the phase-2 run_query so both enforce the same wire ceiling).
+void capSqlResultRows(QJsonObject &result) {
+ QJsonArray rows = result.value(QLatin1String("rows")).toArray();
+ bool truncated = result.value(QLatin1String("truncated")).toBool(false);
+ if (rows.size() > kMaxSqlRows) {
+ QJsonArray capped;
+ for (int i = 0; i < kMaxSqlRows; ++i) capped.append(rows.at(i));
+ rows = capped;
+ truncated = true;
+ }
+ for (int ri = 0; ri < rows.size(); ++ri) {
+ QJsonArray row = rows.at(ri).toArray();
+ bool changed = false;
+ for (int ci = 0; ci < row.size(); ++ci) {
+ const QString cell = row.at(ci).toString();
+ if (cell.size() > kMaxSqlCells) {
+ row.replace(ci, cell.left(kMaxSqlCells) + QChar(0x2026)
+ + QStringLiteral("[truncated]"));
+ changed = true;
+ }
+ }
+ if (changed) {
+ rows.replace(ri, row);
+ truncated = true;
+ }
+ }
+ result[QStringLiteral("rows")] = rows;
+ result[QStringLiteral("truncated")] = truncated;
+}
+
+// One CSV cell: quote when it carries a comma/quote/newline (RFC-4180).
+QString csvCell(const QString &s) {
+ if (s.contains(QLatin1Char('"')) || s.contains(QLatin1Char(',')) ||
+ s.contains(QLatin1Char('\n')) || s.contains(QLatin1Char('\r'))) {
+ QString q = s;
+ q.replace(QLatin1String("\""), QLatin1String("\"\""));
+ return QLatin1Char('"') + q + QLatin1Char('"');
+ }
+ return s;
+}
+
+// Serialize a {columns,rows} query result to CSV bytes (\n endings, UTF-8).
+QByteArray sqlResultToCsv(const QJsonObject &r) {
+ const QJsonArray cols = r.value(QLatin1String("columns")).toArray();
+ const QJsonArray rows = r.value(QLatin1String("rows")).toArray();
+ QString out;
+ QStringList header;
+ for (const QJsonValue &c : cols) header << csvCell(c.toString());
+ out += header.join(QLatin1Char(',')) + QLatin1Char('\n');
+ for (const QJsonValue &rv : rows) {
+ const QJsonArray row = rv.toArray();
+ QStringList cells;
+ for (const QJsonValue &cell : row) cells << csvCell(cell.toString());
+ out += cells.join(QLatin1Char(',')) + QLatin1Char('\n');
+ }
+ return out.toUtf8();
+}
+
+// Serialize a {columns,rows,engine} query result to pretty JSON bytes.
+QByteArray sqlResultToJson(const QJsonObject &r) {
+ QJsonObject o;
+ o[QStringLiteral("columns")] = r.value(QLatin1String("columns")).toArray();
+ o[QStringLiteral("rows")] = r.value(QLatin1String("rows")).toArray();
+ o[QStringLiteral("engine")] = r.value(QLatin1String("engine")).toString();
+ return QJsonDocument(o).toJson(QJsonDocument::Indented);
+}
} // namespace
QString McpBridge::defaultServerName() {
@@ -252,7 +356,8 @@ QString McpBridge::defaultServerName() {
}
McpBridge::McpBridge(McpEditorHost host, QObject *parent,
- const QString &serverName)
+ const QString &serverName,
+ const QString &endpointDirForTests)
: QObject(parent), m_host(std::move(host)),
m_serverName(serverName.isEmpty() ? defaultServerName() : serverName) {
m_server = new QLocalServer(this);
@@ -274,6 +379,19 @@ McpBridge::McpBridge(McpEditorHost host, QObject *parent,
if (m_server->isListening()) {
connect(m_server, &QLocalServer::newConnection,
this, &McpBridge::onNewConnection);
+ // Publish the endpoint only once we know what we actually bound —
+ // after the stale-probe/relisten dance above, never before.
+ //
+ // A test-named bridge with no directory override publishes NOTHING:
+ // several existing tests construct bridges with custom names and must
+ // not stomp the real user's mcp-endpoint.json (which a live editor may
+ // own while the suite runs).
+ if (serverName.isEmpty())
+ publishEndpoint(endpointDirForTests.isEmpty()
+ ? Config::appConfigDir()
+ : endpointDirForTests);
+ else if (!endpointDirForTests.isEmpty())
+ publishEndpoint(endpointDirForTests);
} else {
qWarning("Notepatra MCP bridge: bind failed on %s: %s",
qPrintable(m_serverName),
@@ -290,6 +408,64 @@ McpBridge::~McpBridge() {
// The card is a child of the HOST widget, not of the bridge — take it
// down with us so no orphan Approve button outlives its plumbing.
dismissActiveCard();
+ unpublishEndpoint();
+}
+
+QString McpBridge::fullServerName() const {
+ return m_server ? m_server->fullServerName() : QString();
+}
+
+// Write /mcp-endpoint.json: the sidecar's ONLY reliable way to learn where
+// we bound. Guessing $TMPDIR/ is wrong on macOS, where Qt resolves a bare
+// server name under NSTemporaryDirectory() (/private/var/folders/…/T/), so the
+// sidecar reported "Notepatra is not running" against a perfectly live editor.
+//
+// Failure here is never fatal — the sidecar still falls back to its guess, and
+// on Linux that guess is correct — so we warn and carry on.
+void McpBridge::publishEndpoint(const QString &dir) {
+ if (dir.isEmpty() || !m_server) return;
+ const QString path = dir + QStringLiteral("/mcp-endpoint.json");
+
+ QJsonObject o;
+ o[QStringLiteral("notepatra_mcp_endpoint")] = 1;
+#ifdef Q_OS_WIN
+ o[QStringLiteral("kind")] = QStringLiteral("named_pipe");
+#else
+ o[QStringLiteral("kind")] = QStringLiteral("unix_socket");
+#endif
+ // Verbatim: an absolute socket path on Unix, \\.\pipe\ on Windows.
+ o[QStringLiteral("value")] = m_server->fullServerName();
+ // name/pid/version are DIAGNOSTICS only — the reader must never gate on
+ // pid liveness; the connect attempt is the liveness test.
+ o[QStringLiteral("name")] = m_serverName;
+ o[QStringLiteral("pid")] =
+ static_cast(QCoreApplication::applicationPid());
+ o[QStringLiteral("version")] = QStringLiteral(NOTEPATRA_VERSION);
+
+ // Atomic: a reader must never observe a half-written object. Compact + a
+ // trailing newline, UTF-8, no BOM.
+ QSaveFile f(path);
+ if (!f.open(QIODevice::WriteOnly) ||
+ f.write(QJsonDocument(o).toJson(QJsonDocument::Compact) + '\n') < 0 ||
+ !f.commit()) {
+ qWarning("Notepatra MCP bridge: could not publish endpoint file %s",
+ qPrintable(path));
+ return;
+ }
+ // 0600 — the socket path is a capability. A no-op on Windows, where the
+ // per-user %APPDATA% ACL already covers it (same posture as the sidecar's
+ // token files).
+ QFile::setPermissions(path,
+ QFileDevice::ReadOwner | QFileDevice::WriteOwner);
+ m_endpointFile = path;
+}
+
+// Remove only what THIS instance wrote. A crash leaves a stale file by design:
+// the reader tolerates it because a failed connect falls through to the guess.
+void McpBridge::unpublishEndpoint() {
+ if (m_endpointFile.isEmpty()) return;
+ QFile::remove(m_endpointFile);
+ m_endpointFile.clear();
}
void McpBridge::setApprovalTimeoutMs(int ms) {
@@ -422,6 +598,10 @@ void McpBridge::handleLine(QLocalSocket *client, const QByteArray &line) {
verbValidateNpd(client, id, args);
else if (verb == QLatin1String("run_sql"))
verbRunSql(client, id, args);
+ else if (verb == QLatin1String("list_languages"))
+ verbListLanguages(client, id);
+ else if (verb == QLatin1String("get_capabilities"))
+ verbGetCapabilities(client, id);
else if (verb == QLatin1String("open_note"))
verbOpenNote(client, id, args);
else if (verb == QLatin1String("create_note"))
@@ -432,6 +612,28 @@ void McpBridge::handleLine(QLocalSocket *client, const QByteArray &line) {
verbSetReminder(client, id, args);
else if (verb == QLatin1String("export_diagram"))
verbExportDiagram(client, id, args);
+ else if (verb == QLatin1String("create_diagram"))
+ verbCreateDiagram(client, id, args);
+ else if (verb == QLatin1String("get_diagram_source"))
+ verbGetDiagramSource(client, id, args);
+ else if (verb == QLatin1String("set_diagram_source"))
+ verbSetDiagramSource(client, id, args);
+ else if (verb == QLatin1String("open_noter"))
+ verbOpenNoter(client, id);
+ else if (verb == QLatin1String("list_connections"))
+ verbListConnections(client, id);
+ else if (verb == QLatin1String("run_query"))
+ verbRunQuery(client, id, args);
+ else if (verb == QLatin1String("list_tables"))
+ verbListTables(client, id, args);
+ else if (verb == QLatin1String("open_data_analyst"))
+ verbOpenDataAnalyst(client, id);
+ else if (verb == QLatin1String("render_chart"))
+ verbRenderChart(client, id, args);
+ else if (verb == QLatin1String("export_query_results"))
+ verbExportQueryResults(client, id, args);
+ else if (verb == QLatin1String("export_chart"))
+ verbExportChart(client, id, args);
else
sendError(client, id,
QStringLiteral("unknown verb: %1").arg(verb));
@@ -826,15 +1028,26 @@ void McpBridge::verbSetLanguage(QLocalSocket *client, int id,
QStringLiteral("tab index out of range: %1").arg(idx));
return;
}
- if (!m_host.setLanguage(idx, lang)) {
+ // Resolve to the canonical menu token; a null resolver (test hosts)
+ // falls back to the raw token so old fakes keep working.
+ QString resolved = lang;
+ if (m_host.resolveLanguage) {
+ resolved = m_host.resolveLanguage(lang);
+ if (resolved.isEmpty()) {
+ sendError(client, id,
+ QStringLiteral("unknown language: %1").arg(lang));
+ return;
+ }
+ }
+ if (!m_host.setLanguage(idx, resolved)) {
sendError(client, id,
- QStringLiteral("unknown language: %1").arg(lang));
+ QStringLiteral("unknown language: %1").arg(resolved));
return;
}
QJsonObject result;
result[QStringLiteral("ok")] = true;
result[QStringLiteral("tab_index")] = idx;
- result[QStringLiteral("language")] = lang;
+ result[QStringLiteral("language")] = resolved; // honest: what was set
sendResult(client, id, result);
}
@@ -1276,6 +1489,31 @@ void McpBridge::verbGit(QLocalSocket *client, int id, const QJsonObject &args,
// Parse-only .npd validation. NEVER touches a tab/canvas — Npd::parse is a
// pure QtCore function the bridge links directly.
+// Npd::parse + "line N: msg" split, shared by validate_npd / create_diagram /
+// set_diagram_source.
+static QJsonObject npdValidationJson(const QString &source) {
+ const Npd::Diagram d = Npd::parse(source);
+ static const QRegularExpression rx(QStringLiteral("^line (\\d+): (.*)$"));
+ QJsonArray errors;
+ for (const QString &e : d.errors) {
+ int line = 0;
+ QString message = e;
+ const QRegularExpressionMatch m = rx.match(e);
+ if (m.hasMatch()) {
+ line = m.captured(1).toInt();
+ message = m.captured(2);
+ }
+ QJsonObject o;
+ o[QStringLiteral("line")] = line;
+ o[QStringLiteral("message")] = message;
+ errors.append(o);
+ }
+ QJsonObject r;
+ r[QStringLiteral("valid")] = d.ok();
+ r[QStringLiteral("errors")] = errors;
+ return r;
+}
+
void McpBridge::verbValidateNpd(QLocalSocket *client, int id,
const QJsonObject &args) {
QString source;
@@ -1299,26 +1537,7 @@ void McpBridge::verbValidateNpd(QLocalSocket *client, int id,
sendError(client, id, QStringLiteral("missing source or tab_index"));
return;
}
- const Npd::Diagram d = Npd::parse(source);
- static const QRegularExpression rx(QStringLiteral("^line (\\d+): (.*)$"));
- QJsonArray errors;
- for (const QString &e : d.errors) {
- int line = 0;
- QString message = e;
- const QRegularExpressionMatch m = rx.match(e);
- if (m.hasMatch()) {
- line = m.captured(1).toInt();
- message = m.captured(2);
- }
- QJsonObject o;
- o[QStringLiteral("line")] = line;
- o[QStringLiteral("message")] = message;
- errors.append(o);
- }
- QJsonObject result;
- result[QStringLiteral("valid")] = d.ok();
- result[QStringLiteral("errors")] = errors;
- sendResult(client, id, result);
+ sendResult(client, id, npdValidationJson(source));
}
// SELECT-only query. The host lambda routes SQL through the real
@@ -1344,39 +1563,50 @@ void McpBridge::verbRunSql(QLocalSocket *client, int id,
sendError(client, id, err);
return;
}
- QJsonArray rows = r.value(QLatin1String("rows")).toArray();
- bool truncated = r.value(QLatin1String("truncated")).toBool(false);
- if (rows.size() > kMaxSqlRows) { // defensive re-cap in the bridge
- QJsonArray capped;
- for (int i = 0; i < kMaxSqlRows; ++i) capped.append(rows.at(i));
- rows = capped;
- truncated = true;
- }
- // Per-cell char cap (defense in depth): a single cell must not be able to
- // exfiltrate a whole file (e.g. read_text over a huge file that slipped a
- // gate). Truncate each string cell to kMaxSqlCells and mark it.
- for (int ri = 0; ri < rows.size(); ++ri) {
- QJsonArray row = rows.at(ri).toArray();
- bool changed = false;
- for (int ci = 0; ci < row.size(); ++ci) {
- const QString cell = row.at(ci).toString();
- if (cell.size() > kMaxSqlCells) {
- row.replace(ci, cell.left(kMaxSqlCells)
- + QChar(0x2026)
- + QStringLiteral("[truncated]"));
- changed = true;
- }
- }
- if (changed) {
- rows.replace(ri, row);
- truncated = true;
- }
- }
QJsonObject result;
result[QStringLiteral("columns")] = r.value(QLatin1String("columns")).toArray();
- result[QStringLiteral("rows")] = rows;
- result[QStringLiteral("truncated")] = truncated;
+ result[QStringLiteral("rows")] = r.value(QLatin1String("rows")).toArray();
+ result[QStringLiteral("truncated")] = r.value(QLatin1String("truncated")).toBool(false);
result[QStringLiteral("engine")] = r.value(QLatin1String("engine")).toString();
+ // Defensive re-cap (row + per-cell): a single cell must not exfiltrate a
+ // whole file (e.g. read_text over a huge file that slipped a gate).
+ capSqlResultRows(result);
+ sendResult(client, id, result);
+}
+
+// ── Phase 0A read tier ─────────────────────────────────────────────────
+
+void McpBridge::verbListLanguages(QLocalSocket *client, int id) {
+ QJsonArray langs;
+ if (m_host.knownLanguages) {
+ const QStringList list = m_host.knownLanguages();
+ for (const QString &l : list) langs.append(l);
+ }
+ QJsonObject result;
+ result[QStringLiteral("languages")] = langs;
+ sendResult(client, id, result);
+}
+
+// Edition/platform/version/features from THIS binary's build state; the
+// Rust sidecar adds tool_count and tiers (it owns the tool surface).
+void McpBridge::verbGetCapabilities(QLocalSocket *client, int id) {
+ QJsonObject features;
+#ifdef NOTEPATRA_HAVE_DUCKDB
+ features[QStringLiteral("duckdb")] = true;
+#else
+ features[QStringLiteral("duckdb")] = false;
+#endif
+#ifdef NOTEPATRA_WITH_WEBENGINE
+ features[QStringLiteral("webengine")] = true;
+#else
+ features[QStringLiteral("webengine")] = false;
+#endif
+ features[QStringLiteral("noter")] = static_cast(m_host.notesRoot);
+ QJsonObject result;
+ result[QStringLiteral("edition")] = QLatin1String(editionName());
+ result[QStringLiteral("platform")] = QLatin1String(platformName());
+ result[QStringLiteral("version")] = QStringLiteral(NOTEPATRA_VERSION);
+ result[QStringLiteral("features")] = features;
sendResult(client, id, result);
}
@@ -1578,7 +1808,8 @@ void McpBridge::verbExportDiagram(QLocalSocket *client, int id,
return;
}
const QString path =
- QDir::fromNativeSeparators(args.value(QLatin1String("path")).toString());
+ QDir::cleanPath(
+ QDir::fromNativeSeparators(args.value(QLatin1String("path")).toString()));
if (path.isEmpty()) {
sendError(client, id, QStringLiteral("missing path"));
return;
@@ -1619,6 +1850,421 @@ void McpBridge::verbExportDiagram(QLocalSocket *client, int id,
});
}
+// ── Phase 1 — diagram control + Noter panel ─────────────────────────────
+
+// ACT: creates the tab even when the source is invalid .npd (the canvas
+// shows its own parse state); valid/errors ride along so the agent knows.
+void McpBridge::verbCreateDiagram(QLocalSocket *client, int id,
+ const QJsonObject &args) {
+ if (!m_host.createDiagram) {
+ sendError(client, id,
+ QStringLiteral("create_diagram not supported by host"));
+ return;
+ }
+ const QString source = args.value(QLatin1String("source")).toString();
+ if (source.size() > kDiagramSourceMaxChars) {
+ sendError(client, id, QStringLiteral("source too large"));
+ return;
+ }
+ const QString title = args.value(QLatin1String("title")).toString();
+ const int idx = m_host.createDiagram(source, title);
+ if (idx < 0) {
+ sendError(client, id, QStringLiteral("could not create diagram tab"));
+ return;
+ }
+ // Validate the tab's REAL content (covers the empty-source case too).
+ QString actual = source;
+ if (m_host.diagramSource) m_host.diagramSource(idx, &actual);
+ QJsonObject result = npdValidationJson(actual);
+ result[QStringLiteral("tab_index")] = idx;
+ sendResult(client, id, result);
+}
+
+void McpBridge::verbGetDiagramSource(QLocalSocket *client, int id,
+ const QJsonObject &args) {
+ if (!args.contains(QLatin1String("tab_index"))) {
+ sendError(client, id, QStringLiteral("missing tab_index"));
+ return;
+ }
+ const int n = m_host.tabCount ? m_host.tabCount() : 0;
+ const int idx = args.value(QLatin1String("tab_index")).toInt(-1);
+ if (idx < 0 || idx >= n) {
+ sendError(client, id,
+ QStringLiteral("tab index out of range: %1").arg(idx));
+ return;
+ }
+ QString src;
+ if (!m_host.diagramSource || !m_host.diagramSource(idx, &src)) {
+ sendError(client, id,
+ QStringLiteral("tab %1 is not a diagram (.npd) tab").arg(idx));
+ return;
+ }
+ QJsonObject result;
+ result[QStringLiteral("source")] = src;
+ sendResult(client, id, result);
+}
+
+void McpBridge::verbSetDiagramSource(QLocalSocket *client, int id,
+ const QJsonObject &args) {
+ if (!m_host.setDiagramSource) {
+ sendError(client, id,
+ QStringLiteral("set_diagram_source not supported by host"));
+ return;
+ }
+ if (!args.contains(QLatin1String("source"))) {
+ sendError(client, id, QStringLiteral("missing source"));
+ return;
+ }
+ const QString source = args.value(QLatin1String("source")).toString();
+ if (source.size() > kDiagramSourceMaxChars) {
+ sendError(client, id, QStringLiteral("source too large"));
+ return;
+ }
+ if (!args.contains(QLatin1String("tab_index"))) {
+ sendError(client, id, QStringLiteral("missing tab_index"));
+ return;
+ }
+ const int n = m_host.tabCount ? m_host.tabCount() : 0;
+ const int idx = args.value(QLatin1String("tab_index")).toInt(-1);
+ if (idx < 0 || idx >= n) {
+ sendError(client, id,
+ QStringLiteral("tab index out of range: %1").arg(idx));
+ return;
+ }
+ // Fail fast when the tab isn't a diagram — never queue a doomed approval.
+ QString probe;
+ if (!m_host.diagramSource || !m_host.diagramSource(idx, &probe)) {
+ sendError(client, id,
+ QStringLiteral("tab %1 is not a diagram (.npd) tab").arg(idx));
+ return;
+ }
+ const QString desc =
+ QStringLiteral("REPLACE the diagram source of '%1' with %2 chars "
+ "(the canvas re-renders)")
+ .arg(elideForCard(tabLabel(idx), kApprovalDescChars))
+ .arg(source.size());
+ enqueueApproval(client, id, desc,
+ elideForCard(source, kApprovalPreviewChars),
+ [this, idx, source](QString *execErr) {
+ // Re-checked at execute time: the tab may have changed while pending.
+ if (!m_host.setDiagramSource(idx, source)) {
+ *execErr = QStringLiteral("tab %1 is not a diagram (.npd) tab")
+ .arg(idx);
+ return QJsonObject();
+ }
+ QJsonObject r = npdValidationJson(source);
+ r[QStringLiteral("ok")] = true;
+ r[QStringLiteral("tab_index")] = idx;
+ return r;
+ });
+}
+
+void McpBridge::verbOpenNoter(QLocalSocket *client, int id) {
+ if (!m_host.openNoter) {
+ sendError(client, id,
+ QStringLiteral("open_noter not supported by host"));
+ return;
+ }
+ if (!m_host.openNoter()) {
+ sendError(client, id, QStringLiteral("could not open Noter"));
+ return;
+ }
+ QJsonObject result;
+ result[QStringLiteral("opened")] = true;
+ sendResult(client, id, result);
+}
+
+// ── Phase 2 — Data-analyst + Charts ─────────────────────────────────────
+
+void McpBridge::verbListConnections(QLocalSocket *client, int id) {
+ QJsonArray out = m_host.listConnections ? m_host.listConnections()
+ : QJsonArray();
+ QJsonObject result;
+ result[QStringLiteral("connections")] = out;
+ sendResult(client, id, result);
+}
+
+// READ: SELECT-only query on a SAVED named connection (what run_sql cannot
+// reach). The host lambda re-classifies + runs with allowMutation=false; the
+// bridge re-caps rows/cells with the shared helper.
+void McpBridge::verbRunQuery(QLocalSocket *client, int id,
+ const QJsonObject &args) {
+ if (!m_host.runNamedQuery) {
+ sendError(client, id,
+ QStringLiteral("run_query not supported by host"));
+ return;
+ }
+ const QString name =
+ args.value(QLatin1String("connection_name")).toString();
+ if (name.trimmed().isEmpty()) {
+ sendError(client, id, QStringLiteral("missing connection_name"));
+ return;
+ }
+ const QString sql = args.value(QLatin1String("sql")).toString();
+ if (sql.trimmed().isEmpty()) {
+ sendError(client, id, QStringLiteral("missing sql"));
+ return;
+ }
+ const int maxRows = qBound(
+ 1, args.value(QLatin1String("max_rows")).toInt(kMaxSqlRows),
+ kMaxSqlRows);
+ QString err;
+ const QJsonObject r = m_host.runNamedQuery(name, sql, maxRows, &err);
+ if (!err.isEmpty()) {
+ sendError(client, id, err);
+ return;
+ }
+ QJsonObject result;
+ result[QStringLiteral("columns")] =
+ r.value(QLatin1String("columns")).toArray();
+ result[QStringLiteral("rows")] = r.value(QLatin1String("rows")).toArray();
+ result[QStringLiteral("truncated")] =
+ r.value(QLatin1String("truncated")).toBool(false);
+ result[QStringLiteral("engine")] =
+ r.value(QLatin1String("engine")).toString();
+ capSqlResultRows(result);
+ sendResult(client, id, result);
+}
+
+void McpBridge::verbListTables(QLocalSocket *client, int id,
+ const QJsonObject &args) {
+ if (!m_host.listTables) {
+ sendError(client, id,
+ QStringLiteral("list_tables not supported by host"));
+ return;
+ }
+ const QString name =
+ args.value(QLatin1String("connection_name")).toString();
+ if (name.trimmed().isEmpty()) {
+ sendError(client, id, QStringLiteral("missing connection_name"));
+ return;
+ }
+ QString err;
+ const QJsonArray tables = m_host.listTables(name, &err);
+ if (!err.isEmpty()) {
+ sendError(client, id, err);
+ return;
+ }
+ QJsonObject result;
+ result[QStringLiteral("tables")] = tables;
+ sendResult(client, id, result);
+}
+
+void McpBridge::verbOpenDataAnalyst(QLocalSocket *client, int id) {
+ if (!m_host.openDataAnalyst) {
+ sendError(client, id,
+ QStringLiteral("open_data_analyst not supported by host"));
+ return;
+ }
+ if (!m_host.openDataAnalyst()) {
+ sendError(client, id, QStringLiteral("could not open Data Analyst"));
+ return;
+ }
+ QJsonObject result;
+ result[QStringLiteral("opened")] = true;
+ sendResult(client, id, result);
+}
+
+// ACT (no card): insert an inline chart card into the Data transcript. The
+// unset-field message IS the friendly Full/WebEngine gate (Lite leaves the
+// field unset), so gating is answered immediately with no card.
+void McpBridge::verbRenderChart(QLocalSocket *client, int id,
+ const QJsonObject &args) {
+ if (!m_host.renderChart) {
+ sendError(client, id,
+ QStringLiteral("charts require the Full edition (WebEngine)"));
+ return;
+ }
+ const QJsonValue specV = args.value(QLatin1String("spec"));
+ if (!specV.isObject() || specV.toObject().isEmpty()) {
+ sendError(client, id, QStringLiteral("missing spec (object)"));
+ return;
+ }
+ const QString title = args.value(QLatin1String("title")).toString();
+ QString err;
+ const QJsonObject r = m_host.renderChart(specV.toObject(), title, &err);
+ if (!err.isEmpty()) {
+ sendError(client, id, err);
+ return;
+ }
+ QJsonObject result;
+ result[QStringLiteral("chart_id")] = r.value(QLatin1String("chart_id"));
+ result[QStringLiteral("rendered")] = r.value(QLatin1String("rendered"));
+ sendResult(client, id, result);
+}
+
+// WRITE (card): run the read-only query, serialize to CSV/JSON, write to the
+// requested path. Containment is the human approval card (which names the
+// cleaned absolute destination) plus absolute-path + existing-parent checks —
+// same posture as export_diagram. Fail fast on every doomed case BEFORE a card.
+void McpBridge::verbExportQueryResults(QLocalSocket *client, int id,
+ const QJsonObject &args) {
+ if (!m_host.runNamedQuery) {
+ sendError(client, id,
+ QStringLiteral("export_query_results not supported by host"));
+ return;
+ }
+ const QString name =
+ args.value(QLatin1String("connection_name")).toString();
+ if (name.trimmed().isEmpty()) {
+ sendError(client, id, QStringLiteral("missing connection_name"));
+ return;
+ }
+ const QString sql = args.value(QLatin1String("sql")).toString();
+ if (sql.trimmed().isEmpty()) {
+ sendError(client, id, QStringLiteral("missing sql"));
+ return;
+ }
+ QString format = args.value(QLatin1String("format")).toString().toLower();
+ if (format.isEmpty()) format = QStringLiteral("csv");
+ if (format != QLatin1String("csv") && format != QLatin1String("json")) {
+ sendError(client, id,
+ QStringLiteral("unsupported format: %1 (csv|json)").arg(format));
+ return;
+ }
+ const int maxRows = qBound(
+ 1, args.value(QLatin1String("max_rows")).toInt(kExportRowsDefault),
+ kExportRowsMax);
+ const QString path =
+ QDir::cleanPath(
+ QDir::fromNativeSeparators(args.value(QLatin1String("path")).toString()));
+ if (path.isEmpty()) {
+ sendError(client, id, QStringLiteral("missing path"));
+ return;
+ }
+ const QFileInfo fi(path);
+ if (!fi.isAbsolute()) {
+ sendError(client, id, QStringLiteral("path must be absolute"));
+ return;
+ }
+ if (!QFileInfo(fi.absolutePath()).isDir()) {
+ sendError(client, id,
+ QStringLiteral("parent directory does not exist: %1")
+ .arg(QDir::toNativeSeparators(fi.absolutePath())));
+ return;
+ }
+ // Fail fast: a mutation SQL or an unknown connection never queues a card.
+ if (m_host.classifySqlReadOnly) {
+ QString reason;
+ if (!m_host.classifySqlReadOnly(sql, &reason)) {
+ sendError(client, id, QStringLiteral("query rejected: %1").arg(reason));
+ return;
+ }
+ }
+ if (m_host.listConnections) {
+ const QJsonArray conns = m_host.listConnections();
+ bool found = false;
+ for (const QJsonValue &c : conns) {
+ if (c.toObject().value(QLatin1String("name")).toString() == name) {
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ sendError(client, id,
+ QStringLiteral("no connection named: %1").arg(name));
+ return;
+ }
+ }
+ const bool overwrites = QFileInfo(path).isFile();
+ const QString shownPath = sanitizeForCard(QDir::toNativeSeparators(path));
+ const QString shownDest = overwrites
+ ? QStringLiteral("OVERWRITE existing file: ") + shownPath
+ : shownPath;
+ const QString desc =
+ QStringLiteral("export query results from '%1' to %2")
+ .arg(elideForCard(name, kApprovalDescChars), shownDest);
+ enqueueApproval(client, id, desc, elideForCard(sql, kApprovalPreviewChars),
+ [this, name, sql, maxRows, path, format](QString *execErr) {
+ QString e;
+ const QJsonObject r = m_host.runNamedQuery(name, sql, maxRows, &e);
+ if (!e.isEmpty()) {
+ *execErr = e;
+ return QJsonObject();
+ }
+ const QByteArray bytes = (format == QLatin1String("csv"))
+ ? sqlResultToCsv(r)
+ : sqlResultToJson(r);
+ QFile f(path);
+ if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
+ *execErr = QStringLiteral("could not write: %1")
+ .arg(QDir::toNativeSeparators(path));
+ return QJsonObject();
+ }
+ f.write(bytes);
+ f.close();
+ QJsonObject out;
+ out[QStringLiteral("ok")] = true;
+ out[QStringLiteral("path")] = QDir::toNativeSeparators(path);
+ out[QStringLiteral("rows")] = r.value(QLatin1String("rows")).toArray().size();
+ return out;
+ });
+}
+
+// WRITE (card): off-screen render + async export → file. Full/WebEngine only;
+// the unset-field message IS the friendly gate, so Lite fails fast pre-card.
+void McpBridge::verbExportChart(QLocalSocket *client, int id,
+ const QJsonObject &args) {
+ if (!m_host.exportChart) {
+ sendError(client, id,
+ QStringLiteral("charts require the Full edition (WebEngine)"));
+ return;
+ }
+ const QJsonValue specV = args.value(QLatin1String("spec"));
+ if (!specV.isObject() || specV.toObject().isEmpty()) {
+ sendError(client, id, QStringLiteral("missing spec (object)"));
+ return;
+ }
+ QString format = args.value(QLatin1String("format")).toString().toLower();
+ if (format.isEmpty()) format = QStringLiteral("png");
+ if (format != QLatin1String("png") && format != QLatin1String("svg") &&
+ format != QLatin1String("html") && format != QLatin1String("spec")) {
+ sendError(client, id,
+ QStringLiteral("unsupported format: %1 (png|svg|html|spec)")
+ .arg(format));
+ return;
+ }
+ const int scale = qBound(
+ 1, args.value(QLatin1String("scale")).toInt(kChartScaleDefault), 4);
+ const QString path =
+ QDir::cleanPath(
+ QDir::fromNativeSeparators(args.value(QLatin1String("path")).toString()));
+ if (path.isEmpty()) {
+ sendError(client, id, QStringLiteral("missing path"));
+ return;
+ }
+ const QFileInfo fi(path);
+ if (!fi.isAbsolute()) {
+ sendError(client, id, QStringLiteral("path must be absolute"));
+ return;
+ }
+ if (!QFileInfo(fi.absolutePath()).isDir()) {
+ sendError(client, id,
+ QStringLiteral("parent directory does not exist: %1")
+ .arg(QDir::toNativeSeparators(fi.absolutePath())));
+ return;
+ }
+ const bool overwrites = QFileInfo(path).isFile();
+ const QString shownPath = sanitizeForCard(QDir::toNativeSeparators(path));
+ const QString shownDest = overwrites
+ ? QStringLiteral("OVERWRITE existing file: ") + shownPath
+ : shownPath;
+ const QString desc = QStringLiteral("export a chart as %1 to %2")
+ .arg(format.toUpper(), shownDest);
+ const QJsonObject spec = specV.toObject();
+ enqueueApproval(client, id, desc, shownDest,
+ [this, spec, path, format, scale](QString *execErr) {
+ QString e;
+ if (!m_host.exportChart(spec, path, format, scale, &e)) {
+ *execErr = e.isEmpty() ? QStringLiteral("chart export failed") : e;
+ return QJsonObject();
+ }
+ QJsonObject r;
+ r[QStringLiteral("path")] = QDir::toNativeSeparators(path);
+ return r;
+ });
+}
+
void McpBridge::enqueueApproval(QLocalSocket *client, int id,
const QString &description,
const QString &preview,
diff --git a/src/mcp_bridge.h b/src/mcp_bridge.h
index 98a2df9..644f227 100644
--- a/src/mcp_bridge.h
+++ b/src/mcp_bridge.h
@@ -121,6 +121,41 @@ struct McpEditorHost {
std::function
exportDiagram;
+
+ // ── Phase 0A. Optional like everything above: unset ⇒ error/empty,
+ // never crash. ──
+ // Canonicalize an agent language token ("python"→"Python"); "" = unknown.
+ std::function resolveLanguage;
+ // Canonical Language-menu tokens (SSOT: allKnownLanguageTokens()).
+ std::function knownLanguages;
+
+ // ── Phase 1: diagram control + Noter panel. Optional like everything
+ // above: unset ⇒ clear error, never crash. ──
+ // ACT: create a Diagram tab (optionally pre-filled), focus it; → index, -1 on failure.
+ std::function createDiagram;
+ // WRITE: replace the .npd source of the DiagramEditor at tab i; false = not a diagram.
+ std::function setDiagramSource;
+ // ACT: open/focus the Noter panel tab (same path as the "+ Noter" UI).
+ std::function openNoter;
+
+ // ── Phase 2: Data-analyst + Charts. Optional: unset ⇒ clear error. ──
+ // READ: sanitized saved connections [{name,driver,database,read_only}] — never credentials.
+ std::function listConnections;
+ // READ: SELECT-only query on a SAVED connection via classifySql + runQuery(allowMutation=false).
+ std::function runNamedQuery;
+ // READ: user tables over a saved connection; *err on open failure.
+ std::function listTables;
+ // Pure static classification (DbConnections::classifySql, restrictFilesystem=true); false ⇒ *reason.
+ std::function classifySqlReadOnly;
+ // ACT: reveal the AI dock in Data Analyst mode.
+ std::function openDataAnalyst;
+ // ACT: inline chart card in the Data transcript → {chart_id,rendered}; *err on failure/Lite.
+ std::function renderChart;
+ // WRITE: off-screen render + write chart bytes to path; false + *err on failure/Lite.
+ std::function exportChart;
};
// Editor-side MCP bridge: a dedicated QLocalServer, deliberately separate from
@@ -132,12 +167,22 @@ class McpBridge : public QObject {
static QString defaultServerName();
// serverName override is for tests; production passes nothing.
+ // endpointDirForTests redirects the published endpoint file (see
+ // publishEndpoint) away from the real user config dir — tests MUST pass it
+ // whenever they pass a serverName, otherwise nothing is published at all.
explicit McpBridge(McpEditorHost host, QObject *parent = nullptr,
- const QString &serverName = QString());
+ const QString &serverName = QString(),
+ const QString &endpointDirForTests = QString());
~McpBridge() override;
bool isListening() const;
QString serverName() const { return m_serverName; }
+ // The endpoint we ACTUALLY bound. On macOS Qt rewrites a bare name into
+ // NSTemporaryDirectory() (/private/var/folders/…/T/), which is why the
+ // sidecar can never merely guess this — it has to be published.
+ QString fullServerName() const;
+ // "" when this instance published no endpoint file.
+ QString endpointFilePath() const { return m_endpointFile; }
// Test hook: shrink the human-approval timeout (production default 120 s).
void setApprovalTimeoutMs(int ms);
@@ -187,6 +232,9 @@ private slots:
const QString &sub);
void verbValidateNpd(QLocalSocket *client, int id, const QJsonObject &args);
void verbRunSql(QLocalSocket *client, int id, const QJsonObject &args);
+ // ── Phase 0A read tier ──
+ void verbListLanguages(QLocalSocket *client, int id);
+ void verbGetCapabilities(QLocalSocket *client, int id);
// ACT tier.
void verbOpenNote(QLocalSocket *client, int id, const QJsonObject &args);
// WRITE tier — human-approval-gated.
@@ -195,6 +243,19 @@ private slots:
void verbSetReminder(QLocalSocket *client, int id, const QJsonObject &args);
void verbExportDiagram(QLocalSocket *client, int id,
const QJsonObject &args);
+ // ── Phase 1 ──
+ void verbCreateDiagram(QLocalSocket *client, int id, const QJsonObject &args); // ACT
+ void verbGetDiagramSource(QLocalSocket *client, int id, const QJsonObject &args); // READ
+ void verbSetDiagramSource(QLocalSocket *client, int id, const QJsonObject &args); // WRITE (card)
+ void verbOpenNoter(QLocalSocket *client, int id); // ACT
+ // ── Phase 2: Data-analyst + Charts ──
+ void verbListConnections(QLocalSocket *client, int id); // READ
+ void verbRunQuery(QLocalSocket *client, int id, const QJsonObject &args); // READ
+ void verbListTables(QLocalSocket *client, int id, const QJsonObject &args); // READ
+ void verbOpenDataAnalyst(QLocalSocket *client, int id); // ACT
+ void verbRenderChart(QLocalSocket *client, int id, const QJsonObject &args); // ACT
+ void verbExportQueryResults(QLocalSocket *client, int id, const QJsonObject &args); // WRITE (card)
+ void verbExportChart(QLocalSocket *client, int id, const QJsonObject &args); // WRITE (card)
// One held write request: the response is sent only after the human
// decides (or the timeout / a disconnect decides for them).
@@ -224,9 +285,17 @@ private slots:
void dropClientApprovals(QLocalSocket *client);
void positionCard();
+ // Endpoint discovery: write/remove /mcp-endpoint.json describing the
+ // bound socket so the sidecar reads the truth instead of guessing it.
+ void publishEndpoint(const QString &dir);
+ void unpublishEndpoint();
+
McpEditorHost m_host;
QLocalServer *m_server = nullptr;
QString m_serverName;
+ // Absolute path of the endpoint file THIS instance wrote; empty when it
+ // published nothing (so the dtor never deletes another instance's file).
+ QString m_endpointFile;
QHash m_buffers;
// FIFO approval queue; the front entry is the one on screen when
diff --git a/test_mcp_bridge.cpp b/test_mcp_bridge.cpp
index 57ba419..de1d85e 100644
--- a/test_mcp_bridge.cpp
+++ b/test_mcp_bridge.cpp
@@ -11,6 +11,10 @@
// waitFor* on the client (that would starve the server side).
#include
+#include
+#include
+#include
+#include
#include
#include
#include
@@ -25,9 +29,12 @@
#include
#include
#include
+#include
+#include
#include
#include
+#include "dbconnections.h"
#include "mcp_bridge.h"
namespace {
@@ -41,6 +48,20 @@ struct FakeTab {
QString language = QStringLiteral("Plain Text");
bool isDiagram = false; // v0.1.119 — .npd diagram tab
};
+
+// Crash-proof progress tracker. ctest DISCARDS a crashed test's captured stdout
+// on Windows, so section progress is appended to the file named by
+// NP_MCP_PROGRESS with an fopen/fprintf/fflush/fclose per line — it survives an
+// access violation. No-op when the env var is unset (i.e. everywhere but CI).
+void npWriteProgress(const char *phase, const char *detail) {
+ const QByteArray pf = qgetenv("NP_MCP_PROGRESS");
+ if (pf.isEmpty()) return;
+ if (FILE *f = fopen(pf.constData(), "a")) {
+ fprintf(f, "%s %s\n", phase, detail ? detail : "(none)");
+ fflush(f);
+ fclose(f);
+ }
+}
} // namespace
class TestMcpBridge : public QObject {
@@ -69,6 +90,7 @@ class TestMcpBridge : public QObject {
bool m_gitFail = false; // runGit returns an error
bool m_gitHuge = false; // runGit returns > 256 KB
QString m_openedNote; // last open_note target
+ bool m_noterOpened = false; // open_noter fired
QString m_lastCreatedTitle;
QString m_lastCreatedBody;
QString m_lastCreatedPath;
@@ -80,6 +102,16 @@ class TestMcpBridge : public QObject {
QString m_exportedFormat;
int m_sqlRows = 3; // rows the fake runSql returns for a SELECT
int m_sqlCellLen = 0; // when >0, the "name" cell is this many chars
+ // ── Phase 2 fake state ──
+ QJsonArray m_connections; // sanitized saved-connection list
+ QHash m_namedRecords; // real records for run_query/list_tables
+ bool m_dataAnalystOpened = false;
+ bool m_hostHasWebEngine = false; // gated is the default posture
+ QJsonObject m_renderedSpec;
+ QString m_renderedTitle;
+ QString m_exportedChartPath;
+ QString m_exportedChartFormat;
+ int m_exportedChartScale = 0;
McpEditorHost makeHost() {
McpEditorHost h;
@@ -155,6 +187,23 @@ class TestMcpBridge : public QObject {
return kind.toUpper() + QLatin1Char(':') + text;
};
h.notesRoot = [this] { return m_notesRoot; };
+ // ── Phase 0A ──
+ h.knownLanguages = [] {
+ return QStringList{QStringLiteral("Plain Text"),
+ QStringLiteral("Python"), QStringLiteral("JSON"),
+ QStringLiteral("C++"), QStringLiteral("SQL")};
+ };
+ h.resolveLanguage = [](const QString &in) -> QString {
+ static const QStringList known = {
+ QStringLiteral("Plain Text"), QStringLiteral("Python"),
+ QStringLiteral("JSON"), QStringLiteral("C++"),
+ QStringLiteral("SQL")};
+ for (const QString &k : known)
+ if (k.compare(in, Qt::CaseInsensitive) == 0) return k;
+ if (in.compare(QStringLiteral("py"), Qt::CaseInsensitive) == 0)
+ return QStringLiteral("Python");
+ return QString();
+ };
// ── v0.1.118 write tier ──
h.approvalParent = [this]() -> QWidget * { return m_hostWindow; };
h.hasSelection = [this](int) { return !m_selection.isEmpty(); };
@@ -303,9 +352,160 @@ class TestMcpBridge : public QObject {
m_exportedFormat = format;
return true;
};
+ // ── Phase 1 ──
+ h.createDiagram = [this](const QString &src, const QString &title) {
+ m_fakeTabs.append({title.isEmpty() ? QStringLiteral("Diagram")
+ : title,
+ QString(), src, false,
+ QStringLiteral("Plain Text"), true});
+ m_currentIndex = m_fakeTabs.size() - 1;
+ return m_currentIndex;
+ };
+ h.setDiagramSource = [this](int i, const QString &src) {
+ if (i < 0 || i >= m_fakeTabs.size() || !m_fakeTabs[i].isDiagram)
+ return false;
+ m_fakeTabs[i].text = src;
+ return true;
+ };
+ h.openNoter = [this]() {
+ m_noterOpened = true;
+ return true;
+ };
+ // ── Phase 2 ──
+ h.listConnections = [this] { return m_connections; };
+ h.classifySqlReadOnly = [](const QString &sql, QString *reason) {
+ const auto v = DbConnections::classifySql(sql, /*restrict=*/true);
+ if (v.singleStatement && v.readOnly) return true;
+ if (reason) *reason = v.reason;
+ return false;
+ };
+ h.runNamedQuery = [this](const QString &name, const QString &sql,
+ int maxRows, QString *err) -> QJsonObject {
+ const auto v = DbConnections::classifySql(sql, /*restrict=*/true);
+ if (!(v.singleStatement && v.readOnly)) {
+ if (err)
+ *err = v.reason.isEmpty()
+ ? QStringLiteral("query is not read-only (SELECT only)")
+ : QStringLiteral("query rejected: %1").arg(v.reason);
+ return QJsonObject();
+ }
+ if (!m_namedRecords.contains(name)) {
+ if (err) *err = QStringLiteral("no connection named: %1").arg(name);
+ return QJsonObject();
+ }
+ const DbConnections::Record rec = m_namedRecords.value(name);
+ const DbConnections::QueryResult qr =
+ DbConnections::runQuery(rec, sql, maxRows, false, nullptr);
+ if (!qr.ok) {
+ if (err)
+ *err = qr.error.isEmpty() ? QStringLiteral("query failed")
+ : qr.error;
+ return QJsonObject();
+ }
+ QJsonArray cols;
+ for (const QString &c : qr.columns) cols.append(c);
+ QJsonArray rows;
+ for (const QVector &row : qr.rows) {
+ QJsonArray jr;
+ for (const QString &cell : row) jr.append(cell);
+ rows.append(jr);
+ }
+ QJsonObject out;
+ out[QStringLiteral("columns")] = cols;
+ out[QStringLiteral("rows")] = rows;
+ out[QStringLiteral("truncated")] = qr.truncated;
+ out[QStringLiteral("engine")] =
+ rec.driver == QLatin1String("QSQLITE") ? QStringLiteral("sqlite")
+ : QStringLiteral("odbc");
+ return out;
+ };
+ h.listTables = [this](const QString &name, QString *err) -> QJsonArray {
+ if (!m_namedRecords.contains(name)) {
+ if (err) *err = QStringLiteral("no connection named: %1").arg(name);
+ return QJsonArray();
+ }
+ bool ok = true;
+ const QStringList tables =
+ DbConnections::listTables(m_namedRecords.value(name), &ok);
+ if (!ok) {
+ if (err) *err = QStringLiteral("could not connect to: %1").arg(name);
+ return QJsonArray();
+ }
+ QJsonArray out;
+ for (const QString &t : tables) out.append(t);
+ return out;
+ };
+ h.openDataAnalyst = [this] {
+ m_dataAnalystOpened = true;
+ return true;
+ };
+ // Charts fields are set ONLY when the fake reports WebEngine — the
+ // unset-field path IS the friendly gate the bridge answers pre-card.
+ if (m_hostHasWebEngine) {
+ h.renderChart = [this](const QJsonObject &spec, const QString &title,
+ QString *) -> QJsonObject {
+ m_renderedSpec = spec;
+ m_renderedTitle = title;
+ return QJsonObject{{QStringLiteral("chart_id"),
+ QStringLiteral("test-chart-1")},
+ {QStringLiteral("rendered"), true}};
+ };
+ h.exportChart = [this](const QJsonObject &, const QString &path,
+ const QString &format, int scale,
+ QString *err) -> bool {
+ m_exportedChartPath = path;
+ m_exportedChartFormat = format;
+ m_exportedChartScale = scale;
+ QFile f(path);
+ if (!f.open(QIODevice::WriteOnly)) {
+ if (err) *err = QStringLiteral("could not write");
+ return false;
+ }
+ f.write("FAKECHART");
+ f.close();
+ return true;
+ };
+ }
return h;
}
+ // Register a real QSQLITE connection record backed by a temp sqlite file
+ // with a table `t(id INTEGER, name TEXT)` pre-filled with `rows` rows.
+ QString makeSqliteFixture(QTemporaryDir &dir, const QString &connName,
+ int rows) {
+ const QString dbPath = dir.path() + QStringLiteral("/") + connName +
+ QStringLiteral(".db");
+ {
+ QSqlDatabase db =
+ QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"),
+ QStringLiteral("fixture-") + connName);
+ db.setDatabaseName(dbPath);
+ if (db.open()) {
+ QSqlQuery q(db);
+ q.exec(QStringLiteral("CREATE TABLE t(id INTEGER, name TEXT)"));
+ for (int i = 0; i < rows; ++i)
+ q.exec(QStringLiteral("INSERT INTO t VALUES(%1, 'name%1')")
+ .arg(i));
+ db.close();
+ }
+ }
+ QSqlDatabase::removeDatabase(QStringLiteral("fixture-") + connName);
+ DbConnections::Record rec;
+ rec.name = connName;
+ rec.driver = QStringLiteral("QSQLITE");
+ rec.database = dbPath;
+ m_namedRecords.insert(connName, rec);
+ return dbPath;
+ }
+
+ // Rebuild the bridge with a host that reports WebEngine (charts enabled).
+ void useWebEngineHost() {
+ delete m_bridge;
+ m_hostHasWebEngine = true;
+ m_bridge = new McpBridge(makeHost(), this, m_name);
+ QVERIFY(m_bridge->isListening());
+ }
+
// Write a minimal Noter-convention note (notepatra-title meta wins the
// title resolution). Returns the absolute path.
QString writeNote(const QString &absPath, const QString &title,
@@ -377,11 +577,25 @@ class TestMcpBridge : public QObject {
sendLine(s, QJsonDocument(req).toJson(QJsonDocument::Compact));
}
+ // Returns the LIVE card — the visible one — never a stale predecessor.
+ //
+ // Why visibility matters: a dismissed card is hidden and then deleteLater()'d,
+ // so between those two events the object is still a child of the host window.
+ // A plain findChild