docs: architecture map (initial 5-slice generation, 105 pages)
Five-slice navigable map of the entire codebase under
docs/architecture-map/. Each slice is a self-contained
breadcrumbed sub-tree:
01-frontend (16) Svelte/SvelteKit UI
02-tauri-runtime (26) src-tauri commands + lifecycle
03-audio-transcription (16) audio + transcription crates
04-llm-formatting-mcp (19) llm, ai-formatting, mcp, cloud
05-core-storage-hotkey-build core, storage, hotkey, workspace,
(26) CI, dev glue
Plus master README.md and data-flow-end-to-end.md tracing
audio bytes from microphone to FTS5 search to MCP read.
Generated by 5 parallel subagents on 2026/05/09 against
HEAD 3c47000. Each page has YAML frontmatter, file:line code
refs, sibling cross-links, plain-English summaries.
Aggregated debt surfaced (full lists in master README):
RB-08 macOS power assertion, schema head drift v14 vs v15,
VAD blocked on ort version conflict, streaming primitives
not wired into live.rs, no prompt versioning, MCP has no
auth, cloud-providers in-memory keystore, SettingsPage
2 484 LOC, commands/live.rs 1 737 LOC, dual theme system,
brand rename to Lumenote pending across the codebase.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
161
docs/architecture-map/04-llm-formatting-mcp/mcp-server.md
Normal file
161
docs/architecture-map/04-llm-formatting-mcp/mcp-server.md
Normal file
@@ -0,0 +1,161 @@
|
||||
---
|
||||
name: MCP server entry and stdio protocol
|
||||
type: architecture-map-page
|
||||
slice: 04-llm-formatting-mcp
|
||||
last_verified: 2026/05/09
|
||||
---
|
||||
|
||||
# MCP server entry and stdio protocol
|
||||
|
||||
> **Where you are:** [Architecture map](../README.md) → [LLM, Formatting, MCP](README.md) → MCP server
|
||||
|
||||
**Plain English summary.** `magnotia-mcp` is a standalone binary that speaks JSON-RPC 2.0 over stdio. It opens Magnotia's SQLite store read-only and exposes four tools to any MCP-capable client (Claude desktop, Cline, Goose, etc). One stdin line per request, one stdout line per response, stderr for logs. No auth — stdio access is the trust boundary.
|
||||
|
||||
## At a glance
|
||||
|
||||
- Crate: `magnotia-mcp`
|
||||
- Paths:
|
||||
- `crates/mcp/src/main.rs` — 53 LOC binary entry
|
||||
- `crates/mcp/src/lib.rs` — 531 LOC dispatcher and tool implementations
|
||||
- Public surface (from `lib.rs`):
|
||||
- `pub const PROTOCOL_VERSION: &str = "2024-11-05"` (`:14`)
|
||||
- `pub const SERVER_NAME: &str = "magnotia-mcp"` (`:15`)
|
||||
- `pub const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION")` (`:16`)
|
||||
- `pub struct JsonRpcRequest` (`:18`)
|
||||
- `pub struct JsonRpcResponse` (`:28`)
|
||||
- `pub struct JsonRpcError` (`:38`)
|
||||
- `pub async fn handle_message(pool: &SqlitePool, raw: Value) -> Option<JsonRpcResponse>` (`:49`)
|
||||
- `pub fn parse_error_response(detail: &str) -> JsonRpcResponse` (`:362`)
|
||||
- External deps that matter: `sqlx 0.8` with `runtime-tokio` and `sqlite` features (no migrations from this binary), `serde_json`, `tokio` (`current_thread` flavor), `anyhow`, `magnotia-storage` for the read-only init and the data accessors.
|
||||
- Tauri command that calls this: n/a — the binary is invoked by external MCP clients, never by Tauri.
|
||||
|
||||
## What's in here
|
||||
|
||||
### `main.rs` — stdio loop (`crates/mcp/src/main.rs:1`)
|
||||
|
||||
`#[tokio::main(flavor = "current_thread")]`. Single-thread Tokio runtime — no parallelism inside the binary; one client at a time over a pipe.
|
||||
|
||||
Steps:
|
||||
|
||||
1. **Resolve database path.** `magnotia_storage::database_path()` — slice 5 owns the path computation. Logged to stderr.
|
||||
2. **Open read-only.** `magnotia_storage::init_readonly(&db_path).await?`. The init call sets the SQLite connection's URI to the read-only mode at the connection level, so this binary cannot write regardless of which tools the dispatcher exposes. Migrations are deliberately skipped — only the main app owns the schema.
|
||||
3. **Stdin loop.** Buffered line reader. For each non-empty line:
|
||||
- Parse as `serde_json::Value`.
|
||||
- On parse error: log to stderr, build a `parse_error_response` (code -32700, id `null`), write to stdout. Previously this branch logged-and-continued, dropping the response, which left clients in silence; the 2026-04-22 review flagged it as a MAJOR (`d25b095 fix(cr-2026-04-22): MCP stdio replies with parse-error on malformed JSON`).
|
||||
- On parse success: dispatch via `handle_message`. If `None` (notification — no `id`), continue without writing. Otherwise serialise the response and write a single line.
|
||||
4. **Flush after every response.** Pipe-buffering would otherwise stall the client.
|
||||
|
||||
### `lib.rs` — dispatcher
|
||||
|
||||
#### `handle_message` (`crates/mcp/src/lib.rs:49`)
|
||||
|
||||
The single dispatch function. Takes the parsed JSON and the SQLite pool.
|
||||
|
||||
Steps:
|
||||
|
||||
1. **Deserialise into `JsonRpcRequest`.** Shape mismatch (e.g. wrong types) becomes a parse error response with `id: Value::Null` and code -32700.
|
||||
2. **Notification check.** A request with no `id` field is a JSON-RPC notification. Return `None`. MCP clients send `notifications/initialized` after the initialise handshake; this is the only notification we expect, but the protocol allows others.
|
||||
3. **Method dispatch.** A `match` on `request.method.as_str()`:
|
||||
- `"initialize"` → `initialize_result()` (`:89`).
|
||||
- `"tools/list"` → `tools_list_result()` (`:103`).
|
||||
- `"tools/call"` → `call_tool(pool, request.params).await`.
|
||||
- `"ping"` → `Ok(json!({}))`. MCP clients sometimes send a ping; we reply with an empty object.
|
||||
- any other → `Err(error(-32601, "Method not found: {other}"))`.
|
||||
4. **Response wrapping.** `Ok(result)` and `Err(err)` both produce a `JsonRpcResponse` with `jsonrpc: "2.0"` and the request's `id`.
|
||||
|
||||
#### `initialize_result` (`crates/mcp/src/lib.rs:89`)
|
||||
|
||||
Returns the protocol-mandated handshake:
|
||||
|
||||
```json
|
||||
{
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": { "tools": {} },
|
||||
"serverInfo": { "name": "magnotia-mcp", "version": "0.1.0" },
|
||||
"instructions": "Read-only access to Magnotia's local transcript history and task list. All data stays on the user's machine."
|
||||
}
|
||||
```
|
||||
|
||||
The `instructions` field is what an MCP host shows the user as "what does this server do".
|
||||
|
||||
#### `tools_list_result` (`crates/mcp/src/lib.rs:103`)
|
||||
|
||||
Returns the four tool schemas. See [`mcp-tools.md`](mcp-tools.md) for the full tool detail. Tool names are stable: `list_transcripts`, `get_transcript`, `search_transcripts`, `list_tasks`. Each tool advertises a JSON Schema for its `inputSchema`.
|
||||
|
||||
#### `call_tool` (`crates/mcp/src/lib.rs:168`)
|
||||
|
||||
Inner-deserialise the params shape `{ name: String, arguments: Value }`. Failure → -32602 Invalid params. Then a `match` on `name`:
|
||||
|
||||
- `"list_transcripts"` → `list_transcripts_tool(pool, arguments)`
|
||||
- `"get_transcript"` → `get_transcript_tool(pool, arguments)`
|
||||
- `"search_transcripts"` → `search_transcripts_tool(pool, arguments)`
|
||||
- `"list_tasks"` → `list_tasks_tool(pool)`
|
||||
- otherwise → -32602 "Unknown tool: ..."
|
||||
|
||||
Each tool function returns `Result<Value, JsonRpcError>` shaped as `{ content: [{ type: "text", text: <pretty-printed JSON string> }] }` per MCP's standard tool-output convention.
|
||||
|
||||
### Error codes used
|
||||
|
||||
| Code | Where | Meaning |
|
||||
|---|---|---|
|
||||
| -32700 | `parse_error_response` | JSON parse failure |
|
||||
| -32601 | `handle_message` other branch | Method not found |
|
||||
| -32602 | `call_tool` invalid params, `*_tool` invalid arguments, unknown tool | Invalid params |
|
||||
| -32603 | every `*_tool` DB error path | Internal error (DB error) |
|
||||
| -32000 | `get_transcript_tool` not-found | Server error reserved range; "Transcript {id} not found" |
|
||||
|
||||
### `parse_error_response` (`crates/mcp/src/lib.rs:362`)
|
||||
|
||||
Public helper called from `main.rs`'s parse-error branch. Emits a JSON-RPC 2.0 Parse Error with code -32700 and `id: Value::Null`. The 2026-04-22 review fix.
|
||||
|
||||
### Tests (`crates/mcp/src/lib.rs:366`)
|
||||
|
||||
- `initialize_returns_server_info` (`:370`) — handshake works without DB.
|
||||
- `notification_without_id_produces_no_response` (`:388`) — notifications are silent.
|
||||
- `tools_list_advertises_four_tools` (`:401`) — tool name list is exact.
|
||||
- `parse_error_response_has_jsonrpc_2_0_shape` (`:432`) — the parse-error helper.
|
||||
- `list_transcripts_accepts_omitted_arguments` (`:446`) — regression for the review-of-review (`a5bc45e`): `arguments` omitted entirely (Value::Null) must not error.
|
||||
- `list_transcripts_rejects_malformed_params_with_invalid_arguments` (`:476`) — regression for the original review (`8400128`): a malformed `arguments` shape returns -32602, not silent default.
|
||||
- `unknown_method_returns_method_not_found_error` (`:502`) — -32601 for unknown methods.
|
||||
- `preview_truncates_at_boundary` and `preview_keeps_short_text_intact` (`:517`, `:526`) — helper coverage.
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
external MCP client
|
||||
↓ stdin (newline-delimited JSON-RPC 2.0)
|
||||
magnotia-mcp main.rs loop
|
||||
→ serde_json::from_str → Value
|
||||
(or parse_error_response on failure)
|
||||
→ magnotia_mcp::handle_message(&pool, raw)
|
||||
→ JsonRpcRequest deserialise (or -32700 on shape mismatch)
|
||||
→ notification check (None)
|
||||
→ method dispatch:
|
||||
initialize → server info
|
||||
tools/list → tool schemas
|
||||
tools/call → call_tool → list/get/search/list_tasks tool
|
||||
ping → {}
|
||||
other → -32601
|
||||
→ response or None
|
||||
→ serde_json::to_string + "\n" + flush
|
||||
↓ stdout
|
||||
external MCP client
|
||||
```
|
||||
|
||||
The SQLite pool is opened once at startup and shared across the whole loop. `init_readonly` from `magnotia-storage` configures the connection with `mode=ro`, so a misbehaving tool implementation cannot write.
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- **No auth, no transport-level scoping.** Anyone with stdio access to this binary has read access to every transcript and task in the user's local store. Magnotia's threat model treats stdio as a trust boundary; cloud / HTTP transport would need an auth layer. Tracked in the slice README.
|
||||
- **Read-only at the connection level, not just at the tool layer.** Even if a future contributor adds a write-shaped tool by mistake, the SQLite connection rejects it. Defense in depth.
|
||||
- **`current_thread` Tokio runtime.** No internal parallelism. A request that takes 5 seconds blocks the next request. Acceptable because (a) MCP is a single-client protocol over stdio and (b) every read tool is a quick SQLite query. Worth knowing if a future tool ever makes a network call (it should not).
|
||||
- **Logs go to stderr deliberately.** Stdout is the JSON-RPC channel; mixing logs in would corrupt the stream. Every log uses `eprintln!`; this is enforced by convention only — there is no `tracing` setup gating the writers.
|
||||
- **Migrations are skipped.** The binary's comment at `crates/mcp/src/main.rs:18` makes this explicit. The main app is the single migration writer. If the main app has not been run yet (no DB exists), `init_readonly` will fail and the binary exits.
|
||||
- **Notifications are dropped silently.** The MCP spec sends `notifications/initialized` after the handshake. We accept it (no error) but do not act on it; the connection is already "ready" by the time we see it.
|
||||
- **Empty stdin lines are skipped.** A keepalive that emits a blank line does not produce a parse error.
|
||||
|
||||
## See also
|
||||
|
||||
- [MCP tools](mcp-tools.md)
|
||||
- [Slice README — MCP auth gap](README.md)
|
||||
- Slice 5 (forthcoming) — `magnotia-storage::init_readonly`, `database_path`, `list_transcripts`, `get_transcript`, `search_transcripts`, `list_tasks`
|
||||
Reference in New Issue
Block a user