From 7ea314a42242f587b32fe9bf7b429b2023805a82 Mon Sep 17 00:00:00 2001 From: Daniel Elskamp Date: Wed, 5 Aug 2026 20:24:10 +0200 Subject: [PATCH 01/18] docs: add MCP server overhaul design spec Markdown-native, group-aware notes API with full note lifecycle (create/append/update/archive/trash/restore, create/list groups), JSON responses, and a Rust Markdown<->HTML converter. Co-Authored-By: Claude Opus 4.8 --- .../2026-08-05-mcp-server-overhaul-design.md | 245 ++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-05-mcp-server-overhaul-design.md diff --git a/docs/superpowers/specs/2026-08-05-mcp-server-overhaul-design.md b/docs/superpowers/specs/2026-08-05-mcp-server-overhaul-design.md new file mode 100644 index 0000000..ec90a58 --- /dev/null +++ b/docs/superpowers/specs/2026-08-05-mcp-server-overhaul-design.md @@ -0,0 +1,245 @@ +# MCP Server Overhaul — Design + +**Date:** 2026-08-05 +**Status:** Approved (design), pending implementation plan + +## Problem + +Notefix ships an HTTP JSON-RPC MCP server (`src-tauri/src/mcp.rs`) that lets +local desktop AI clients (e.g. Claude Desktop) work with notes. Today it is too +thin and lossy: + +1. **Markdown gets mangled on write.** Notes are stored as Tiptap HTML. The MCP + server accepts plain text and converts it with a crude `text_to_html()` that + just wraps each line in `

`. When a client sends Markdown + (`# Heading`, `**bold**`, `- [ ] task`), the syntax is preserved literally as + paragraph text and shows up escaped/unformatted in the app. +2. **No group information.** `list_notes`/`search_notes` return only `id` + title; + the client can't see which folder (group) a note belongs to, and `create_note` + can't place a note into a group. +3. **No content-type signal.** Responses don't declare what format the content is + in, so the client has to guess. +4. **Lossy reads.** `get_note` flattens HTML to plain text, destroying formatting. +5. **Fragile output format.** `list_notes`/`search_notes` return tab-separated + `"{id}\t{title}"` lines that the client must split by hand — brittle and not + extensible. +6. **Incomplete lifecycle.** Only `create_note` and `append_note` exist. No way to + edit (full replace), move, archive, trash, restore, or list archived/trashed + notes; no way to create or discover groups. + +## Goals + +Turn the MCP server into a **Markdown-native, group-aware notes API** with a full +note lifecycle: + +- Content crosses the MCP boundary as **Markdown** in both directions, converted + to/from Tiptap HTML in Rust. +- Every list/get/search response is **JSON** (returned in the tool's text block). +- Groups (folders) are first-class: discoverable, targetable by id or name, + returned on every note. +- Full lifecycle tools: create/append/update/archive/trash/restore notes; + create/list groups; status-filtered listing. + +## Non-goals (YAGNI) + +- Setting `pinned` / `color` / `dueAt` on create. +- Tags (no such field exists in the model). +- Reconstructing custom link-preview node cards from MCP input. +- A persisted per-note "type" field (we use delivery-format metadata instead — no + DB migration). +- Mobile considerations: the MCP server is desktop-only and already hidden on + mobile; nothing changes there. + +## Background / current state + +- **Storage:** SQLite via `rusqlite`. Notes stored as Tiptap **HTML** in + `notes.content`. No `content_type`/`format` column exists, and none is added. +- **Note model** (`src-tauri/src/storage.rs`, `src/types.ts`): `id`, `content` + (HTML), `updatedAt`, `pinned`, `archived`, `color`, `dueAt`, `folderId`, + `position`, `deletedAt`, `dirty`. +- **Groups = Folders** (`src-tauri/src/folders.rs`): `id`, `name`, `parentId` + (hierarchical), `position`, `icon`, `color`, `sort`. A note's group membership is + `notes.folder_id` (nullable; `NULL` = ungrouped/top-level). +- **Status is derived from fields**, not a single column: + - `deletedAt != NULL` → **trashed** (soft-delete tombstone) + - else `archived == true` → **archived** + - else → **active** +- **Frontend markdown** (`src/markdown.ts`): `markdownToHtml` (marked + GFM + + `fixTaskLists`/`restoreLinkPreviews`) and `htmlToMarkdown` (Turndown + GFM). The + MCP server is Rust and **cannot** reuse this JS; it gets its own Rust converter. +- **Write gating:** an existing `allow_write` setting already gates + `create_note`/`append_note` (`Err("writing disabled")` when off). All new write + tools respect it. + +## Architecture + +### New module: `src-tauri/src/mdconv.rs` + +Isolated, unit-tested Markdown↔HTML conversion, replacing the crude +`text_to_html`/`html_to_text` helpers currently inline in `mcp.rs`. + +- **`md_to_html(md: &str) -> String`** — comrak with GFM extensions + (tables, strikethrough, task list items, autolinks). +- **`html_to_md(html: &str) -> String`** — HTML → Markdown (via a Rust + html-to-markdown crate; candidate: `htmd`). Used by `get_note`. +- **Task-list fidelity (hard requirement):** `- [ ]` / `- [x]` must round-trip to + Notefix's Tiptap task-list structure + ``, + mirroring the frontend's `fixTaskLists`. comrak's default checkbox output + (``) does **not** match Tiptap and must be post-processed + into the `data-type` structure. Symmetrically, `html_to_md` must recognise the + Tiptap task-list structure and emit `- [ ]` / `- [x]`. +- **`title_of(md_or_html)`** — first non-empty text line, used for the `title` + field (matches today's behaviour). + +**Contract & interface:** `mdconv` depends only on the conversion crates and knows +nothing about SQLite or MCP. Inputs are strings; outputs are strings. It can be +tested entirely in isolation. + +### Content format at the boundary + +- Reads return **Markdown** by default; `contentType` in the response echoes the + format (`"markdown"`). +- Every **write** tool accepts an optional `format: "markdown" | "html" | "text"` + (default `"markdown"`): + - `markdown` → `md_to_html` before storing. + - `html` → stored as-is (trusted passthrough for advanced clients). + - `text` → literal text, wrapped safely (each line → `

`), for clients that + explicitly want no Markdown interpretation. +- `get_note` accepts an optional `format` to request `html` or `text` instead of + markdown; `contentType` in the response reflects what was returned. + +### Response shape (JSON in the text block) + +Note summary object (used by `list_notes`, `search_notes`): + +```json +{ + "id": "uuid", + "title": "First line of the note", + "group": { "id": "uuid", "name": "Work", "path": "Work/Projects" }, + "contentType": "markdown", + "status": "active", + "updatedAt": 1234567890 +} +``` + +- `group` is `null` for ungrouped notes. `path` is the folder name chain from root + joined by `/`. +- `status` is one of `"active" | "archived" | "trashed"` (derived as above). +- `search_notes` items additionally include `"snippet": "…"`. + +`get_note` returns a **full** object: the summary fields **plus** `content` +(markdown by default), `pinned` (bool), and `dueAt` (number | null). + +`list_groups` returns an array of `{ "id", "name", "parentId": "uuid"|null, "path" }`. + +Write tools return a small JSON confirmation, e.g. `create_note` → +`{ "id": "…", "group": { "id":"…", "name":"…" } | null, "status": "active" }`. +`archive_note`/`delete_note`/`restore_note` → `{ "id": "…", "status": "…" }`. +`create_group` → `{ "id": "…", "name": "…", "parentId": "…"|null, "path": "…" }`. + +All JSON is returned as the tool result's `content[0].text` (a JSON string), the +same channel used today. Errors keep the existing shape +(`content: [{type:text,text:msg}], isError:true`). + +### Group targeting (create_note / update_note) + +A note can be placed/moved into a group via **either**: + +- `groupId` — resolved exactly; error `"group not found"` if unknown. +- `groupName` — resolved **case-insensitively** against folder names. If exactly + one match → use it. If none → error `"group not found"` (no accidental folder + creation — use `create_group` first). If multiple → error listing the candidate + ids, e.g. `"ambiguous group name 'Work': a1b2…, c3d4…"`. + +Passing neither leaves the note ungrouped (`folder_id = NULL`). Passing both is an +error (`"specify groupId or groupName, not both"`). + +## Tools (final set) + +### Reads + +| Tool | Input | Returns | +|------|-------|---------| +| `list_notes` | `status?` = `active`(default)`\|archived\|trashed\|all`, `groupId?` | JSON array of note summaries | +| `get_note` | `id` (req), `format?` | Full note JSON | +| `search_notes` | `query` (req), `status?` (default `active`), `groupId?` | JSON array of summaries + `snippet` | +| `list_groups` | — | JSON array of group objects | + +### Writes (all gated by `allow_write`) + +| Tool | Input | Effect / Returns | +|------|-------|------------------| +| `create_note` | `content` (req), `format?`, `groupId?`, `groupName?` | New note; returns id + group + status | +| `append_note` | `id` (req), `text` (req), `format?` | Appends converted content; returns `{id,status}` | +| `update_note` | `id` (req), `content?`, `format?`, `groupId?`, `groupName?` | Full-replace content and/or move to group; returns `{id,group,status}` | +| `create_group` | `name` (req), `parentId?` | New folder; returns group object | +| `archive_note` | `id` (req) | Sets `archived=true`; returns `{id,status:"archived"}` | +| `delete_note` | `id` (req) | Soft-delete (sets `deletedAt`) → trash; returns `{id,status:"trashed"}` | +| `restore_note` | `id` (req) | Returns note to active: clears `deletedAt` if trashed, sets `archived=false` if archived; returns `{id,status:"active"}` | + +`update_note` with no `content` and only a group param performs a move-only. With +`content` present it replaces the whole note body. + +All tool `description` and `inputSchema` strings are rewritten to be explicit, +notably stating **"content is Markdown (GFM)"** on `create_note`/`append_note`/ +`update_note` so clients format correctly. + +## Data flow examples + +**Create a task note in a group:** +1. Client → `create_note { content: "# Groceries\n- [ ] milk\n- [x] eggs", groupName: "Home" }`. +2. Server resolves `groupName` "Home" → folder id. +3. `md_to_html` converts (task list → Tiptap `data-type` structure). +4. Note saved with `folder_id` set, UUID minted, `notes-changed` event emitted. +5. Returns `{ "id":"…", "group":{"id":"…","name":"Home","path":"Home"}, "status":"active" }`. + +**Read it back:** +1. Client → `get_note { id }`. +2. `html_to_md` converts stored HTML back to Markdown (checkboxes → `- [ ]`/`- [x]`). +3. Returns full JSON with `content` in Markdown and `contentType: "markdown"`. + +## Error handling + +- Unknown note id → `"note not found"`. +- Unknown/ambiguous group → messages above. +- Writes while `allow_write` is off → existing `"writing disabled"`. +- Conflicting/invalid params → explicit messages (`groupId`+`groupName`, etc.). +- All errors returned via the existing `isError:true` text-content shape; the RPC + envelope is unchanged. + +## Testing + +Rust unit tests (isolated where possible): + +- **`mdconv` round-trips:** headings, bold/italic, ordered/unordered lists, + **task lists** (checked & unchecked), inline code, code blocks, tables, links. + Assert Tiptap task-list structure specifically. +- **Group resolution:** by id, by name (case-insensitive), not-found, ambiguous + (multiple folders same name), both-params error. +- **Status filtering:** `list_notes` returns the right set for + active/archived/trashed/all, and honours `groupId`. +- **Write gating:** each write tool refuses when `allow_write` is off. +- **JSON shape:** responses parse as JSON and carry the documented fields + (`group`, `contentType`, `status`). + +Project-wide gates stay green: `npx tsc --noEmit` and `npx vitest run` (i18n key +parity). No frontend changes are expected; verify regardless. + +## Risks + +- **Task-list conversion fidelity** is the main technical risk (comrak output vs + Tiptap's `data-type` structure, both directions). Covered by dedicated tests; if + a chosen crate can't be coaxed into the right structure, post-process the HTML. +- **Breaking contract change** (tab-separated → JSON, plain text → Markdown). + Acceptable: the server only talks to the user's own local AI clients; no external + consumers. +- **New Rust dependencies** (comrak + an html-to-markdown crate). Adds build + weight; both are mature, widely-used crates. + +## Out-of-scope / future + +- Exposing note metadata (pinned/color/dueAt) as writable via MCP. +- MCP `structuredContent` + `outputSchema` (chose JSON-in-text for universal client + support; can be layered on later without breaking the text payload). From 4ff6ae5813be784550e5756a9fcec95b6f5626bd Mon Sep 17 00:00:00 2001 From: Daniel Elskamp Date: Wed, 5 Aug 2026 20:38:59 +0200 Subject: [PATCH 02/18] docs: add MCP server overhaul implementation plan 12 TDD tasks: mdconv module (markdown<->html with Tiptap task-list fidelity), NoteStore trait redesign, JSON read tools, markdown-aware write tools, group + lifecycle tools, tool_defs rewrite, verification. Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-08-05-mcp-server-overhaul.md | 1630 +++++++++++++++++ 1 file changed, 1630 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-05-mcp-server-overhaul.md diff --git a/docs/superpowers/plans/2026-08-05-mcp-server-overhaul.md b/docs/superpowers/plans/2026-08-05-mcp-server-overhaul.md new file mode 100644 index 0000000..3da6a85 --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-mcp-server-overhaul.md @@ -0,0 +1,1630 @@ +# MCP Server Overhaul Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn Notefix's MCP server into a Markdown-native, group-aware notes API with a full note lifecycle (create/append/update/archive/trash/restore, create/list groups), JSON responses, and a Rust Markdown↔HTML converter that preserves Tiptap task lists. + +**Architecture:** Three layers inside `src-tauri/src/`. (1) A new pure `mdconv` module does Markdown↔HTML conversion with no knowledge of storage or MCP. (2) A redesigned `NoteStore` trait is the test seam: a real `StoreAccess` impl backed by SQLite + a `Fake` in-memory impl for tests. (3) The `call_tool` handler in `mcp.rs` orchestrates conversion, group resolution, status filtering, and JSON building on top of the trait. Content crosses the MCP boundary as Markdown; every list/get/search response is a JSON string in the tool's text block. + +**Tech Stack:** Rust (edition 2021), axum JSON-RPC, rusqlite (SQLite), serde_json. New crates: `comrak` (Markdown→HTML, GFM) and `htmd` (HTML→Markdown). Frontend unchanged (TypeScript/React/Tiptap). + +## Global Constraints + +- Rust edition **2021**; crate is `notefix_lib` under `src-tauri/`. +- Rust tests run with: `cd src-tauri && cargo test`. A single test: `cargo test `. +- Formatting/lint gates: `cd src-tauri && cargo fmt` and `cargo clippy --all-targets -- -D warnings` must pass. +- Frontend gates must stay green: from repo root `npx tsc --noEmit` and `npx vitest run`. +- The MCP server is **desktop-only** and already hidden on mobile — do not add mobile UI or gating; no frontend changes are expected in this plan. +- Notes are stored as **Tiptap HTML** in SQLite. There is **no** `content_type`/`format`/`isMarkdown` column and this plan adds none (no DB migration). +- Tiptap task-list HTML format (canonical round-trip target, matches `src/markdown.ts`): + `

`. +- Note status is **derived**, never a column: `deleted_at.is_some()` → `"trashed"`; else `archived` → `"archived"`; else `"active"`. +- `Store::save_note` upserts, but on an **existing** row its `ON CONFLICT` updates only `content`, `updated_at`, `dirty`. To change folder/archived/deleted you MUST use the dedicated methods (`set_folder`, `set_archived`, `trash_note`, `restore_note`). Never try to move/archive/trash a note by mutating a `Note` and calling `save_note`. +- All JSON responses are returned as the tool result's `content[0].text` (a serialized JSON string), preserving the existing `{ "content": [{ "type": "text", "text": … }] }` envelope. Errors keep the existing `isError: true` text-content shape. +- Response field names are **camelCase** (matches serde `rename_all = "camelCase"` and the TS types). + +--- + +## File Structure + +- **Create** `src-tauri/src/mdconv.rs` — pure Markdown↔HTML conversion + helpers. One responsibility: format conversion. No storage/MCP imports. +- **Modify** `src-tauri/src/mcp.rs` — redesigned `NoteStore` trait, rewritten `call_tool` handler, JSON builders, `StoreAccess` impl, `tool_defs`, resources handlers, and `#[cfg(test)]` module (`Fake` + handler tests). +- **Modify** `src-tauri/src/lib.rs` — add `mod mdconv;` (single line; verify `apply(...)` call is unaffected). +- **Modify** `src-tauri/Cargo.toml` — add `comrak` and `htmd` dependencies. +- **Unchanged** `storage.rs`, `folders.rs`, `migrate.rs`, `commands.rs` (we consume their existing APIs), and all of `src/` (frontend). + +--- + +## Task 1: Add Markdown conversion dependencies + +**Files:** +- Modify: `src-tauri/Cargo.toml:20-43` (`[dependencies]`) + +**Interfaces:** +- Produces: `comrak` and `htmd` crates available to the build. + +- [ ] **Step 1: Add the crates** + +Run from `src-tauri/`: + +```bash +cargo add comrak htmd +``` + +This appends latest-compatible versions under `[dependencies]` and updates `Cargo.lock`. + +- [ ] **Step 2: Verify the project still builds** + +Run: `cd src-tauri && cargo build` +Expected: builds successfully (downloads `comrak`/`htmd` and transitive deps). + +- [ ] **Step 3: Commit** + +```bash +git add src-tauri/Cargo.toml src-tauri/Cargo.lock +git commit -m "build: add comrak + htmd for MCP markdown conversion" +``` + +--- + +## Task 2: `mdconv` — core Markdown → HTML + +**Files:** +- Create: `src-tauri/src/mdconv.rs` +- Modify: `src-tauri/src/lib.rs` (add `mod mdconv;` next to the other `mod` declarations) + +**Interfaces:** +- Produces: `pub fn md_to_html(md: &str) -> String` — GFM Markdown → HTML. Task-list post-processing is added in Task 3; this task covers headings, emphasis, lists, code, blockquotes, tables, links, and hard line breaks. + +- [ ] **Step 1: Register the module** + +In `src-tauri/src/lib.rs`, add alongside the existing module declarations: + +```rust +mod mdconv; +``` + +- [ ] **Step 2: Write the failing tests** + +Create `src-tauri/src/mdconv.rs` with: + +```rust +//! Pure Markdown <-> HTML conversion for the MCP boundary. Notes are stored as +//! Tiptap HTML; MCP speaks Markdown. Task-list handling mirrors `src/markdown.ts` +//! so notes created via MCP are indistinguishable from app-created ones. + +use comrak::{markdown_to_html, ComrakOptions}; + +/// Markdown (GFM) -> HTML. Hard line breaks on (matches the frontend's +/// `marked` `breaks: true`), tables/strikethrough/autolinks/task items enabled. +pub fn md_to_html(md: &str) -> String { + let mut opts = ComrakOptions::default(); + opts.extension.table = true; + opts.extension.strikethrough = true; + opts.extension.tasklist = true; + opts.extension.autolink = true; + opts.render.hardbreaks = true; + let html = markdown_to_html(md, &opts); + html.trim().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn headings_and_emphasis() { + let h = md_to_html("# Title\n\nsome **bold** and *italic*"); + assert!(h.contains("

Title

"), "got: {h}"); + assert!(h.contains("bold"), "got: {h}"); + assert!(h.contains("italic"), "got: {h}"); + } + + #[test] + fn bullet_and_ordered_lists() { + let h = md_to_html("- a\n- b"); + assert!(h.contains("