---
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<TranscriptRow>` / `Option<TranscriptRow>` / `Vec<TaskRow>` 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": <seconds>,
    "starred": <bool>,
    "language": "...",
    "preview": "<first 240 chars + …>"
  }
  ```
- **Output envelope:** `{ "content": [{ "type": "text", "text": "<pretty-printed JSON array>" }] }`.

### `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": "<full transcript>",
    "createdAt": "...",
    "source": "...",
    "duration": <seconds>,
    "engine": "<whisper | parakeet | ...>",
    "modelId": "<asr model identifier>",
    "language": "...",
    "starred": <bool>,
    "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": "<first 240 chars + …>",
    "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": "<inbox | today | week | someday | done>",
    "done": <bool>,
    "doneAt": "<RFC3339 timestamp or null>",
    "createdAt": "...",
    "parentTaskId": "<UUID of parent if subtask, else null>"
  }
  ```

### 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<Value, JsonRpcError>
  → 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)
