Files
tolaria/docs/ARCHITECTURE.md
lucaronin 2bd9cd91dd docs: update architecture with full MCP server tool surface and auto-registration
- Document all 14 MCP tools with params
- Add auto-registration flow (Claude Code + Cursor configs)
- Document dual WebSocket bridge (tool port 9710, UI port 9711)
- Add Rust MCP module details (spawn, register, upsert)
- Update startup sequence with MCP initialization steps
- Add register_mcp_tools to Tauri IPC commands table

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:18:21 +01:00

446 lines
23 KiB
Markdown

# Architecture
Laputa is a personal knowledge and life management desktop app. It reads a vault of markdown files with YAML frontmatter and presents them in a four-panel UI inspired by Bear Notes.
## Tech Stack
| Layer | Technology | Version |
|-------|-----------|---------|
| Desktop shell | Tauri v2 | 2.10.0 |
| Frontend | React + TypeScript | React 19, TS 5.9 |
| Editor | BlockNote | 0.46.2 |
| Styling | Tailwind CSS v4 + CSS variables | 4.1.18 |
| UI primitives | Radix UI + shadcn/ui | - |
| Icons | Phosphor Icons + Lucide | - |
| Build | Vite | 7.3.1 |
| Backend language | Rust (edition 2021) | 1.77.2 |
| Frontmatter parsing | gray_matter | 0.2 |
| AI | Anthropic Claude API (Haiku 3.5 default) | - |
| MCP | @modelcontextprotocol/sdk | 1.0 |
| Tests | Vitest (unit), Playwright (E2E), cargo test (Rust) | - |
| Package manager | pnpm | - |
## System Overview
```
┌─────────────────────────────────────────────────────────────┐
│ Tauri v2 Window │
│ │
│ ┌─────────────────── React Frontend ───────────────────┐ │
│ │ │ │
│ │ App.tsx (orchestrator) │ │
│ │ ├── Sidebar (navigation + filters) │ │
│ │ ├── NoteList (filtered note list) │ │
│ │ ├── Editor (BlockNote + tabs + diff) │ │
│ │ │ ├── Inspector (metadata + relationships) │ │
│ │ │ └── AIChatPanel (AI assistant + context) │ │
│ │ ├── StatusBar (footer info) │ │
│ │ └── Modals (QuickOpen, CreateNote, CommitDialog) │ │
│ │ │ │
│ └──────────────┬──────────┬──────────────────────────┘ │
│ │ │ │
│ Tauri IPC│ Vite Proxy / WS │
│ ┌──────────────▼────┐ ┌──▼───────────────────────────┐ │
│ │ Rust Backend │ │ External Services │ │
│ │ lib.rs → 10 cmds │ │ Anthropic API (Claude) │ │
│ │ vault/ │ │ MCP Server (ws://9710) │ │
│ │ frontmatter.rs │ │ │ │
│ │ git.rs │ └──────────────────────────────┘ │
│ │ ai_chat.rs │ │
│ └───────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
## Four-Panel Layout
```
┌────────┬─────────────┬─────────────────────────┬────────────┐
│Sidebar │ Note List │ Editor │ Inspector │
│(250px) │ (300px) │ (flex-1) │ (280px) │
│ │ │ │ OR │
│ All │ [Search] │ [Tab Bar] │ AI Chat │
│ Favs │ [Type Pill] │ [Breadcrumb Bar] │ │
│ │ │ │ Context │
│Projects│ Note 1 │ # My Note │ Messages │
│Experim.│ Note 2 │ │ Actions │
│Respons.│ Note 3 │ Content here... │ Input │
│Procedu.│ ... │ │ │
│People │ │ │ │
│Events │ │ │ │
│Topics │ │ │ │
├────────┴─────────────┴─────────────────────────┴────────────┤
│ StatusBar: v0.4.2 │ main │ Synced 2m ago │ 3 pending notes│
└─────────────────────────────────────────────────────────────┘
```
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Favorites) and collapsible section groups (Projects, Experiments, Responsibilities, etc.)
- **Note List** (200-500px, resizable): Filtered list of notes matching the sidebar selection. Shows snippets, modified dates, relationship groups, and orange dot indicators for uncommitted modified notes.
- **Editor** (flex, fills remaining space): Tab bar (with orange modified dots on dirty tabs), breadcrumb bar with word count and modified indicator, BlockNote editor with wikilink support. Can toggle to diff view for modified files. Decomposed into focused subcomponents: `Editor` (orchestrator), `EditorContent` (breadcrumb + editor/diff views), `EditorRightPanel` (inspector/AI toggle), `SingleEditorView` (BlockNote + suggestions), with hooks `useDiffMode` and `useEditorFocus`.
- **Inspector / AI Chat** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, backlinks, git history) and AI Chat panel. The Sparkle icon in the breadcrumb bar toggles between them.
Panels are separated by `ResizeHandle` components that support drag-to-resize.
## AI Chat System
### Architecture
The AI chat feature has three layers:
1. **Frontend** (`AIChatPanel` + `useAIChat` hook) — UI and state management
2. **API Proxy** (Vite middleware in dev, Rust `ai_chat` command in Tauri) — routes to Anthropic
3. **MCP Server** (`mcp-server/`) — vault operation tools for AI assistants
### Data Flow
```
User types message in AIChatPanel
→ useAIChat.sendMessage(text)
→ buildSystemPrompt(contextNotes, allContent, model)
→ Assembles selected notes as system context
→ Estimates tokens, truncates if needed
→ streamChat(messages, systemPrompt, model, callbacks)
→ POST /api/ai/chat (Vite proxy → Anthropic API)
→ SSE stream parsed, chunks dispatched to onChunk callback
→ UI updates in real-time as tokens arrive
→ On completion: message added to conversation history
```
### Context Picker
The context picker controls which notes are sent to the AI as context:
- **Current note** is auto-added when the panel opens
- **Add button** opens a search dropdown to select additional notes
- **Token estimation** shows approximate context size (~4 chars/token)
- **Truncation** kicks in when context exceeds 60% of model limit (108k tokens)
- Context pills show selected notes with remove buttons
### API Key Management
- Stored in `localStorage` under key `laputa:anthropic-api-key`
- Configurable via the key icon in the AI Chat header
- When no key is set, falls back to mock responses for testing
### Models
| Model | ID | Use case |
|-------|----|----------|
| Haiku 3.5 | `claude-3-5-haiku-20241022` | Fast, cheap — default |
| Sonnet 4 | `claude-sonnet-4-20250514` | Balanced |
| Opus 4 | `claude-opus-4-20250514` | Most capable |
### MCP Server
The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistants (Claude Code, Cursor, or any MCP-compatible client).
#### Tool Surface (14 tools)
| Tool | Params | Description |
|------|--------|-------------|
| `open_note` | `path` | Open and read a note by relative path |
| `read_note` | `path` | Read note content (alias for `open_note`) |
| `create_note` | `path, title, [is_a]` | Create new note with title and optional type frontmatter |
| `search_notes` | `query, [limit]` | Search notes by title or content substring |
| `append_to_note` | `path, text` | Append text to end of existing note |
| `edit_note_frontmatter` | `path, patch` | Merge key-value patch into YAML frontmatter |
| `delete_note` | `path` | Delete a note file from the vault |
| `link_notes` | `source_path, property, target_title` | Add a target to an array property in frontmatter |
| `list_notes` | `[type_filter], [sort]` | List all notes, optionally filtered by type |
| `vault_context` | — | Get vault summary: entity types + 20 recent notes |
| `ui_open_note` | `path` | Open a note in the Laputa UI editor |
| `ui_open_tab` | `path` | Open a note in a new UI tab |
| `ui_highlight` | `element, [path]` | Highlight a UI element (editor, tab, properties, notelist) |
| `ui_set_filter` | `type` | Set the sidebar filter to a specific type |
#### Transports
- **stdio** — standard MCP transport for Claude Code / Cursor (`node mcp-server/index.js`)
- **WebSocket** — live bridge for Laputa app integration:
- Port **9710**: Tool bridge — AI/Claude clients call vault tools here
- Port **9711**: UI bridge — Frontend listens for UI action broadcasts from MCP tools
#### Auto-Registration
On app startup, Laputa automatically registers itself as an MCP server in:
- `~/.claude/mcp.json` (Claude Code)
- `~/.cursor/mcp.json` (Cursor)
The registration is non-destructive (additive — preserves other MCP servers) and uses `upsert` semantics. The entry points to `mcp-server/index.js` with the active vault path as `VAULT_PATH` env var.
Registration also runs from the frontend via the `useMcpRegistration` hook and `register_mcp_tools` Tauri command, ensuring the config stays up-to-date when the vault path changes.
#### Architecture
```
┌─────────────────────────────────────────────────────┐
│ MCP Server (Node.js) │
│ │
│ index.js ─── stdio transport ──→ Claude Code │
│ │ Cursor │
│ ├── vault.js (9 vault operations) │
│ │ ├── findMarkdownFiles ├── deleteNote │
│ │ ├── readNote ├── linkNotes │
│ │ ├── createNote ├── listNotes │
│ │ ├── searchNotes ├── vaultContext │
│ │ ├── appendToNote │
│ │ └── editNoteFrontmatter │
│ │ │
│ └── ws-bridge.js │
│ ├── port 9710: tool bridge ←→ AI clients │
│ └── port 9711: UI bridge ←→ Frontend │
│ │
│ Spawned by Tauri (mcp.rs) on app startup │
│ Auto-registered in ~/.claude/mcp.json │
└─────────────────────────────────────────────────────┘
```
### WebSocket Bridge
The WebSocket bridge enables real-time vault operations from both the frontend and external AI clients:
```
Frontend (useMcpBridge) ←→ ws://localhost:9710 ←→ ws-bridge.js ←→ vault.js
MCP stdio tools ←→ ws://localhost:9711 ←→ Frontend UI actions
```
**Tool bridge protocol** (port 9710):
- Request: `{ "id": "req-1", "tool": "search_notes", "args": { "query": "test" } }`
- Response: `{ "id": "req-1", "result": { ... } }`
- Error: `{ "id": "req-1", "error": "message" }`
**UI bridge protocol** (port 9711):
- Broadcast: `{ "type": "ui_action", "action": "open_note", "path": "..." }`
### Rust MCP Module
`src-tauri/src/mcp.rs` manages the MCP server lifecycle:
| Function | Purpose |
|----------|---------|
| `spawn_ws_bridge(vault_path)` | Spawns `ws-bridge.js` as child process with VAULT_PATH env |
| `register_mcp(vault_path)` | Writes Laputa entry to Claude Code and Cursor MCP configs |
| `upsert_mcp_config(path, entry)` | Atomic config file update (create/merge, preserves others) |
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is killed on app exit via `RunEvent::Exit` handler.
### Rust Backend (Tauri)
The `ai_chat` Tauri command (`src-tauri/src/ai_chat.rs`) provides a non-streaming alternative:
- Uses `reqwest` to call the Anthropic Messages API directly
- API key from `ANTHROPIC_API_KEY` environment variable
- Returns full response (not streamed)
- Used in production Tauri builds where Vite proxy is unavailable
### Files
| File | Purpose |
|------|---------|
| `src/components/AIChatPanel.tsx` | Main UI: context bar, messages, input, quick actions |
| `src/hooks/useAIChat.ts` | Chat state: messages, streaming, send/retry/clear |
| `src/hooks/useMcpBridge.ts` | WebSocket client for MCP vault tool calls |
| `src/hooks/useMcpRegistration.ts` | Auto-registers Laputa MCP on vault load |
| `src/utils/ai-chat.ts` | API client, token estimation, context builder |
| `src-tauri/src/ai_chat.rs` | Rust Anthropic API client (non-streaming) |
| `src-tauri/src/mcp.rs` | MCP server spawning + config registration |
| `mcp-server/index.js` | MCP server entry (stdio transport, 14 tools) |
| `mcp-server/vault.js` | Vault file operations (9 functions) |
| `mcp-server/ws-bridge.js` | WebSocket bridge server (tool + UI bridges) |
| `mcp-server/test.js` | 26 unit tests for all vault.js functions |
## Data Flow
### Startup Sequence
```
1. Tauri setup:
a. run_startup_tasks() → purge trash, migrate frontmatter, register MCP config
b. spawn_ws_bridge() → start MCP WebSocket bridge (ports 9710, 9711)
2. App mounts
3. useVaultLoader fires:
a. isTauri() ? invoke('list_vault') : mockInvoke('list_vault')
→ VaultEntry[] stored in state
b. Load all content (mock mode) or on-demand (Tauri mode)
c. invoke('get_modified_files') → ModifiedFile[] stored in state
d. useMcpRegistration → invoke('register_mcp_tools') → ensures MCP config current
4. User clicks note in NoteList
4. useNoteActions.handleSelectNote:
a. invoke('get_note_content') → raw markdown string
b. Add tab { entry, content } to tabs state
c. Set activeTabPath
5. Editor renders BlockNoteTab:
a. splitFrontmatter(content) → [yaml, body]
b. preProcessWikilinks(body) → replaces [[target]] with tokens
c. editor.tryParseMarkdownToBlocks(preprocessed)
d. injectWikilinks(blocks) → replaces tokens with wikilink nodes
e. editor.replaceBlocks()
6. Inspector renders frontmatter parsed from content
```
### Frontmatter Edit Flow
```
User edits property in Inspector
→ handleUpdateFrontmatter(path, key, value)
→ Tauri: invoke('update_frontmatter') → Rust reads file, modifies YAML, writes back
→ Mock: updateMockFrontmatter() → client-side YAML manipulation
→ Update tab content in state
→ Update allContent for backlink recalculation
→ Toast: "Property updated"
```
### Git Flow
```
User clicks Commit button → CommitDialog opens
→ handleCommitPush(message)
→ invoke('git_commit') → git add -A && git commit -m "..."
→ invoke('git_push') → git push
→ Reload modified files
→ Toast: "Committed and pushed"
```
## Vault Module Structure
The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| File | Purpose | CodeScene Health |
|------|---------|-----------------|
| `mod.rs` | Core types (`VaultEntry`, `Frontmatter`), `parse_md_file`, `scan_vault`, relationship extraction | 10.0 |
| `parsing.rs` | Text processing: snippet extraction, markdown stripping, ISO date parsing, `extract_title` | 9.68 |
| `cache.rs` | Git-based incremental vault caching (`scan_vault_cached`), git helpers | 9.68 |
| `trash.rs` | `purge_trash` — deletes trashed notes older than 30 days | 9.38 |
| `rename.rs` | `rename_note` — renames files and updates wikilinks across the vault | 9.68 |
| `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames | 10.0 |
Public API (re-exported from `mod.rs`): `scan_vault_cached`, `save_image`, `rename_note`, `RenameResult`, `purge_trash`, `get_note_content`, `parse_md_file`, `VaultEntry`.
## Tauri IPC Commands
All commands are defined in `src-tauri/src/lib.rs` and registered via `tauri::generate_handler![]`.
| Command | Params | Returns | Backend function |
|---------|--------|---------|-----------------|
| `list_vault` | `path` | `Vec<VaultEntry>` | `vault::scan_vault()` |
| `get_note_content` | `path` | `String` | `vault::get_note_content()` |
| `update_frontmatter` | `path, key, value` | `String` (updated content) | `frontmatter::with_frontmatter()` |
| `delete_frontmatter_property` | `path, key` | `String` (updated content) | `frontmatter::with_frontmatter()` |
| `get_file_history` | `vault_path, path` | `Vec<GitCommit>` | `git::get_file_history()` |
| `get_modified_files` | `vault_path` | `Vec<ModifiedFile>` | `git::get_modified_files()` |
| `get_file_diff` | `vault_path, path` | `String` (unified diff) | `git::get_file_diff()` |
| `git_commit` | `vault_path, message` | `String` | `git::git_commit()` |
| `git_push` | `vault_path` | `String` | `git::git_push()` |
| `ai_chat` | `request: AiChatRequest` | `AiChatResponse` | `ai_chat::send_chat()` |
| `register_mcp_tools` | `vault_path` | `String` ("registered" or "updated") | `mcp::register_mcp()` |
All commands return `Result<T, String>`. Errors are serialized as JSON error objects to the frontend.
## Mock Layer
When running outside Tauri (browser at `localhost:5201`), `src/mock-tauri.ts` provides a transparent mock layer:
```typescript
// In hooks, the pattern is always:
if (isTauri()) {
result = await invoke<T>('command_name', { args })
} else {
result = await mockInvoke<T>('command_name', { args })
}
```
The mock layer includes:
- **15 sample entries** across all entity types (Project, Responsibility, Procedure, Experiment, Note, Person, Event, Topic, Essay)
- **Full markdown content** with realistic frontmatter for each entry
- **Mock git history, modified files, and diff output**
- **Mock AI chat responses** with context-aware answers (summarize, expand, grammar)
- `addMockEntry()` and `updateMockContent()` for runtime updates
This means the entire UI can be developed and tested in Chrome without the Rust backend.
## State Management
No Redux or global context. State lives in the root `App.tsx` and custom hooks:
| State owner | State | Purpose |
|-------------|-------|---------|
| `App.tsx` | `selection`, panel widths, dialog visibility, toast, `showAIChat` | UI state |
| `useVaultLoader` | `entries`, `allContent`, `modifiedFiles` | Vault data |
| `useNoteActions` | `tabs`, `activeTabPath` | Open tabs and note operations |
| `useAIChat` | `messages`, `isStreaming`, `streamingContent` | AI conversation state |
| `useMcpBridge` | `connected`, tool methods | MCP WebSocket connection |
Data flows unidirectionally: `App` passes data and callbacks as props to child components. No child-to-child communication — everything goes through `App`.
## Keyboard Shortcuts
| Shortcut | Action |
|----------|--------|
| Cmd+P | Open Quick Open palette |
| Cmd+N | Open Create Note dialog |
| Cmd+S | Show "Saved" toast |
| Cmd+W | Close active tab |
| `[[` in editor | Open wikilink suggestion menu |
## Auto-Release & In-App Updates
### Release Pipeline
Every push to `main` triggers `.github/workflows/release.yml`:
```
push to main
→ version job: compute 0.YYYYMMDD.RUN_NUMBER
→ build job (matrix: aarch64 + x86_64):
→ pnpm install, stamp version, pnpm build, tauri build --target <arch>
→ upload .app, .tar.gz + .sig, .dmg as artifacts
→ release job:
→ download both arch artifacts
→ lipo aarch64 + x86_64 → universal binary
→ create universal .dmg + signed updater tarball
→ generate latest.json (per-arch + universal platform entries)
→ publish GitHub Release with all assets + auto-generated notes
→ pages job:
→ fetch all releases via gh api
→ build static HTML release history page
→ deploy to gh-pages via peaceiris/actions-gh-pages
```
### Versioning
Format: `0.YYYYMMDD.GITHUB_RUN_NUMBER` (e.g. `0.20260223.42`). The `0.` prefix keeps it SemVer-compatible while making it clear these are date-based auto-releases. The version is stamped into both `tauri.conf.json` and `Cargo.toml` dynamically in the workflow.
### Universal Binary
macOS builds produce both `aarch64-apple-darwin` and `x86_64-apple-darwin` in parallel. The release job merges them with `lipo` — copying the arm64 `.app` as the base and replacing only the main executable with a universal fat binary. The per-arch updater tarballs are also uploaded so the Tauri updater downloads only the relevant architecture (smaller download).
### Updater Endpoint
The Tauri updater plugin is configured to fetch:
```
https://github.com/refactoringhq/laputa-app/releases/latest/download/latest.json
```
This JSON manifest contains `version`, `pub_date`, `notes`, and per-platform entries (`darwin-aarch64`, `darwin-x86_64`) with `url` and `signature` fields. The updater compares the manifest version against the running app version, downloads the matching platform artifact, verifies the signature, and installs it.
### In-App Update UI
```
App startup (3s delay)
→ useUpdater.check()
→ idle (no update) → no UI shown
→ available → UpdateBanner: "Laputa X.Y.Z is available" + Release Notes + Update Now + X
→ user clicks Update Now → downloading → progress bar
→ download complete → ready → "Restart to apply" + Restart Now button
→ user clicks Restart → relaunch()
→ network error / 404 → fail silently, no UI
```
| Component | File | Purpose |
|-----------|------|---------|
| `useUpdater` | `src/hooks/useUpdater.ts` | State machine: idle → available → downloading → ready → error |
| `UpdateBanner` | `src/components/UpdateBanner.tsx` | Top-of-app notification bar |
| `restartApp` | `src/hooks/useUpdater.ts` | Calls `@tauri-apps/plugin-process` relaunch |
### GitHub Pages
Release history site at `https://refactoringhq.github.io/laputa-app/`. Auto-updated by the workflow after each release. The page loads `releases.json` (deployed alongside) and renders each release with date, notes, and `.dmg` download links. Linked from the in-app "Release Notes" button.