Skip to main content
Status:✏️ DRAFT
Date:📅 unknown
Domain:📦 MCP Server

SPEC-0003 Design: MCP Server

Architecture

internal/cli/mcp.go ──▶ mcp.NewServer(store, llmClient, opts)
│ registerTools() (modelcontextprotocol/go-sdk)
├── list_conversations ─┐
├── get_conversation │
├── search_messages ────┤──▶ internal/store (read-only)
├── semantic_search │ + llm.Client.Embed (query only)
├── get_context │
├── list_media │
└── list_links ─────────┘
transports: StdioTransport (default) | StreamableHTTPHandler (--http)

Key design decisions

Official Go MCP SDK (ADR-0004)

The server uses github.com/modelcontextprotocol/go-sdk, the canonical spec-tracking implementation, over third-party alternatives. Its typed AddTool[In, Out] infers JSON Schema from Go structs and validates inputs/outputs, keeping tool definitions declarative and self-documenting, and it provides both stdio and streamable-HTTP transports out of the box. The cost is a Go 1.25 toolchain floor, accepted for a single-maintainer project.

Thin adapter over the store

Every tool calls the same store methods the web UI uses (SearchMessages, SemanticSearch, ConversationTranscript, GetContext, ListAttachments, ListLinks, GetConversationByID). The MCP layer adds only shaping: it maps store rows into citation-faithful result structs (messageHit, mediaInfo, linkInfo) and resolves a conversation name to an id (erroring on an unknown name). This guarantees keyword/semantic/media behavior cannot diverge between the model's view and the human's view.

Citation-faithful result shapes

A shared messageHit carries message_id, hash, conversation, source, sender, timestamp, optional score, and the text (snippet for keyword hits, body for semantic hits). Three converters (hitFromSearch, hitFromScored, hitFromView) produce it from the store's three result types, all normalizing the owner sender to Me. The coordinates let the consuming model cite precisely and a human jump to the message in the web UI (/c/{id}/at/{message_id}). The server never returns a passage without coordinates.

Hybrid fusion and graceful degradation

search_messages runs the always-available keyword half and a best-effort vector half (only when an llm.Client and embed model are configured and the query embeds successfully), then fuses with RRF (fuseResults, SPEC-0002). A failed vector half is logged and the tool returns keyword-only results — the keyword half is the offline floor. semantic_search is pure-vector and returns an explicit error when no embedding model is configured (rather than silently empty results).

Read-only and minimal egress (ADR-0004)

No tool mutates the store or archive. The only network call any tool makes is embedQueryllm.Client.Embed to embed the search query, via the same local-by- default endpoint as the rest of msgbrowse. A future sqlite-vec backend (📝 ADR-0002) changes only store.SemanticSearch; the tools are unaffected.

Transports and stderr logging

RunStdio is the default; RunHTTP wraps the server in a StreamableHTTPHandler bound to 127.0.0.1:8788 by default with a read-header timeout and graceful shutdown on context cancel. Under stdio, stdout is the JSON-RPC channel, so the CLI wires logging to slog's stderr writer; the package doc and CLI help both call this out.

Journal tools deferred (ADR-0004)

get_journal_day and on_this_day are intentionally absent until the journal generation that produces their data exists (Slice 6), so no tool is backed by data that does not yet exist.

Trade-offs

  • Conversation filtering is by name (resolved to id), so an unknown name errors rather than silently returning nothing — clearer for the calling model.
  • The HTTP transport has no authentication; it binds loopback by default and exposing it elsewhere is the operator's explicit choice (mirrors the web UI).