--- name: MCP tools (list, get, search, list_tasks) type: architecture-map-page slice: 04-llm-formatting-mcp last_verified: 2026/05/09 --- # MCP tools > **Where you are:** [Architecture map](../README.md) → [LLM, Formatting, MCP](README.md) → MCP tools **Plain English summary.** Four read-only tools surface Lumotia's transcripts and tasks to MCP clients. Each tool is a thin SQLite query over a `sqlx` pool with the result reshaped into the MCP "text content" envelope. Every tool path forbids writes at the connection level. ## At a glance - Crate: `lumotia-mcp` - Path: `crates/mcp/src/lib.rs:103-321` (registration and four tool functions) - LOC: 218 across the four tool functions and the `tools_list_result` - Public surface: tools are exposed via `handle_message`'s `tools/call` branch. No tool function is `pub`. - External deps that matter: `lumotia_storage::{list_transcripts, get_transcript, search_transcripts, list_tasks}`. All four return typed `Vec` / `Option` / `Vec` from `lumotia-storage` (slice 5). - Tauri command that calls this: n/a — the tools are remote-callable from any MCP client. The Tauri app uses `lumotia_storage` directly without going through MCP. ## What's in here ### `list_transcripts` (`crates/mcp/src/lib.rs:188`) Returns a paginated list of recent transcripts, most recent first. - **Tool schema:** `{ limit?: integer (1–200, default 20) }`. - **Argument handling:** `args.is_null()` short-circuits to `Args::default()` so a client that sends `tools/call` without an `arguments` field gets defaults instead of -32602. This is the regression from review-of-review (`a5bc45e fix(cr-2026-04-22): list_transcripts accepts omitted arguments`). A malformed shape (e.g. `{"limit": "twenty"}`) still returns -32602 (`8400128 fix(cr-2026-04-22): list_transcripts tool returns -32602 on malformed params`). - **Limit clamp:** `args.limit.unwrap_or(20).clamp(1, 200)`. - **DB call:** `lumotia_storage::list_transcripts(pool, limit)`. - **Per-row JSON:** ```json { "id": "...", "title": "...", "createdAt": "...", "source": "...", "duration": , "starred": , "language": "...", "preview": "" } ``` - **Output envelope:** `{ "content": [{ "type": "text", "text": "" }] }`. ### `get_transcript` (`crates/mcp/src/lib.rs:234`) Returns the full text and metadata of a single transcript. - **Tool schema:** `{ id: string (required) }`. UUID from `list_transcripts` or `search_transcripts`. - **DB call:** `lumotia_storage::get_transcript(pool, &args.id)`. - **Not-found:** returns -32000 "Transcript {id} not found". Reserved server-error range; chosen because the client supplied a valid ID shape but no row matches. - **Per-row JSON:** ```json { "id": "...", "title": "...", "text": "", "createdAt": "...", "source": "...", "duration": , "engine": "", "modelId": "", "language": "...", "starred": , "manualTags": "<...>", "template": "<...>" } ``` Note: `manualTags` and `template` are pass-through strings from the DB row; their internal shape is owned by slice 5. ### `search_transcripts` (`crates/mcp/src/lib.rs:265`) Full-text search across all transcripts. - **Tool schema:** `{ query: string (required), limit?: integer (1–100, default 20) }`. The description note "FTS5 syntax supported" is exposed to the MCP client so it can advise the user (or LLM) on phrase queries. - **Limit clamp:** `args.limit.unwrap_or(20).clamp(1, 100)`. Tighter than `list_transcripts` because search results are typically narrower. - **DB call:** `lumotia_storage::search_transcripts(pool, &args.query, limit)`. Slice 5 owns the SQLite FTS5 binding. - **Per-row JSON:** ```json { "id": "...", "title": "...", "createdAt": "...", "preview": "", "source": "..." } ``` Search results omit `duration`, `starred`, `language` from the list-summary shape — they are not load-bearing for "did this transcript match my query?" and the MCP client should call `get_transcript` for a matched ID anyway. ### `list_tasks` (`crates/mcp/src/lib.rs:298`) Returns every task (open and completed). No paging arguments — the working assumption is that task lists stay small enough to return fully. - **Tool schema:** `{}`. - **DB call:** `lumotia_storage::list_tasks(pool)`. - **Per-row JSON:** ```json { "id": "...", "text": "...", "bucket": "", "done": , "doneAt": "", "createdAt": "...", "parentTaskId": "" } ``` ### Helpers - `text_content(text: String)` (`crates/mcp/src/lib.rs:323`) — wraps a string in the MCP `{ content: [{ type: "text", text }] }` envelope. - `preview(text: &str, limit: usize)` (`crates/mcp/src/lib.rs:329`) — char-aware truncation with a trailing ellipsis. Uses `chars().count()` and `chars().take(limit)` so multi-byte sequences are not split. Non-truncating short input is just returned trimmed. - `error(code, message)` (`crates/mcp/src/lib.rs:339`) — `JsonRpcError` builder with `data: None`. - `error_response(id, code, message)` (`crates/mcp/src/lib.rs:347`) — wraps `error` in a full `JsonRpcResponse` with no result. ## Data flow ``` client tools/call request → call_tool: deserialise { name, arguments } (or -32602 invalid params) → match name: list_transcripts → list_transcripts_tool(pool, args) get_transcript → get_transcript_tool(pool, args) search_transcripts → search_transcripts_tool(pool, args) list_tasks → list_tasks_tool(pool) other → -32602 "Unknown tool" → each tool: - deserialise its own args struct (or -32602 invalid arguments) - clamp / sanitise limits - call lumotia_storage::* (or -32603 DB error) - shape rows into JSON Value - serde_json::to_string_pretty - wrap in text_content envelope → returns Result → bubbles back to handle_message → JsonRpcResponse ``` ## Watch-outs - **All four tools serialise rows server-side as pretty-printed JSON inside a text payload.** This is the MCP `text` content convention; clients (or the LLM behind them) get a string they have to parse again. The double-serialise is part of the protocol — do not "optimise" by emitting structured content unless every consuming MCP client supports it. - **Read-only enforced at the connection.** Even if a future tool function calls a write-shaped storage helper by mistake, SQLite rejects. The contract is doubly belt-and-braces: the API surface is a curated subset of `lumotia_storage`, and the connection is `mode=ro`. - **`get_transcript` not-found is -32000, not -32601 or -32602.** -32000 is the JSON-RPC reserved server-error range. The choice is deliberate: the client's request was well-formed, the resource just does not exist. -32602 would be misleading (the params were valid in shape). - **`search_transcripts` exposes raw FTS5 syntax.** A malformed FTS5 query (e.g. unbalanced parens) bubbles up from `lumotia_storage::search_transcripts` as a `-32603 DB error`. The error message includes the underlying SQLite error text, which is informative but exposes implementation detail. Acceptable for a local-first read-only server; a remote-facing one might want to sanitise. - **`list_tasks` returns everything.** No pagination, no limit. A user with thousands of tasks would get a megabyte of JSON. The working assumption is that task counts stay in the low hundreds; if that ever stops being true, add a `limit` argument matching `list_transcripts`'s shape. - **`preview` is char-count-bounded, not byte-count-bounded.** A 240-emoji preview takes more bytes than a 240-ASCII preview but the count is the same. This is the desired behaviour for "first 240 visible characters". - **`engine` and `modelId` fields in `get_transcript` are pass-through.** A transcript created by an engine no longer in the registry would surface a stale identifier; the MCP server does not normalise. ## See also - [MCP server entry and stdio protocol](mcp-server.md) - Slice 5 (forthcoming) — schema and storage accessors - [Slice README](README.md)