Skip to content

feat: MCP server exposing mailrag email-RAG query - #67

Merged
fmasi merged 4 commits into
mainfrom
feat/mcp-server
Jul 5, 2026
Merged

feat: MCP server exposing mailrag email-RAG query#67
fmasi merged 4 commits into
mainfrom
feat/mcp-server

Conversation

@fmasi

@fmasi fmasi commented Jul 5, 2026

Copy link
Copy Markdown
Owner

What

A new stdio MCP (Model Context Protocol) server (src/mcp_server/) that exposes mailrag's existing email-RAG query pipeline to MCP clients (Claude Desktop, Claude Code, or any MCP-capable agent). It is a thin wrapper — retrieval and answering reuse the same code paths as mailrag ask; nothing about ranking, fusion, or answering is reimplemented.

Addresses the query half of the MCP-server roadmap item (#32).

Tool surface

Tool Signature Behaviour
search_email search_email(query, top_k=5) Hybrid (bge-m3 dense+sparse, RRF) retrieval expanded into whole attributed threads via HybridSearcher.search_threads. Returns up to top_k rows {thread_id, subject, num_emails, text}. No LLM call.
answer_question answer_question(query, k=3) Full RAG path: retrieve threads then ground a single-LLM-call answer over the top-k with answer_from_threads. Returns {answer, sources:[{thread_id, subject}]}.

Launch

mailrag mcp                                             # new CLI verb (stdio)
mailrag mcp --collection work-rag --qdrant-url http://localhost:6333
python -m src.mcp_server                                # module entrypoint

Blocks on stdio until the client disconnects. Registration example for Claude clients is in docs/MCP_SERVER.md.

Config (mirrors mailrag ask)

  • Collection: --collection$MAILRAG_COLLECTION → latest onboarding manifest. Missing corpus → clear error, not a crash.
  • Qdrant URL: --qdrant-url$MAILRAG_QDRANT_URL$QDRANT_URLhttp://localhost:6333. The dedicated MAILRAG_QDRANT_URL override avoids inheriting the container-oriented QDRANT_URL on the host (the query: mailrag query fails on the host — QDRANT_URL in .env points to host.docker.internal #29 gotcha).
  • Answering LLM is the unified Settings.llm stack (usual RAG_* env vars).

Packaging

  • Adds the official mcp SDK to pyproject.toml, regenerated poetry.lock, and requirements.txt.
  • New mailrag mcp verb wired into src/cli.py.

Tests

New tests/test_mcp_server.py (18 cases, retrieval layer fully mocked — no live Qdrant/LLM): config resolution + precedence + clear-error-on-missing-corpus, searcher build/cache via injectable factory, result mapping + top_k/k truncation, invalid input, empty results, FastMCP tool registration (names + input schema), a call_tool dispatch, and the mailrag mcp CLI wiring.

Full suite: 913 passed, 1 skipped in the mailrag-test conda env.

Open questions / notes

🤖 Generated with Claude Code

fmasi and others added 3 commits July 5, 2026 19:38
Add src/mcp_server, a thin Model Context Protocol (stdio) server that wraps the
existing hybrid searcher (build_hybrid_searcher/search_threads) and grounded
answer path (answer_from_threads) as two tools:

  - search_email(query, top_k)     -> ranked, attributed email threads (no LLM)
  - answer_question(query, k)      -> grounded RAG answer + sources (one LLM call)

Config mirrors 'mailrag ask': collection resolves from --collection /
MAILRAG_COLLECTION / latest onboarding manifest; Qdrant URL from --qdrant-url /
MAILRAG_QDRANT_URL / QDRANT_URL / localhost. Missing corpus yields a clear error
rather than a crash. Launch via 'mailrag mcp' (new CLI verb) or
'python -m src.mcp_server'. Adds the official 'mcp' SDK to pyproject + lock +
requirements.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cover config resolution (collection/qdrant-url precedence + clear error when no
corpus), searcher build+cache via injectable factory, tool result mapping and
top_k/k truncation, invalid input, empty results, FastMCP tool registration
(names + input schema), a call_tool dispatch through the server, and the
'mailrag mcp' CLI wiring. No live Qdrant or LLM required.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add docs/MCP_SERVER.md (tool surface, launch, config precedence table, MCP-client
registration example) and wire it into the README quickstart, project layout,
documentation index, and roadmap (the #32 query path is now live).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread src/mcp_server/server.py
"""
if not query or not query.strip():
raise ValueError("query must be a non-empty string")
if top_k < 1:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No upper bound on top_k — a misbehaving or adversarial MCP client can pass top_k=1_000_000, which makes search_threads return everything it can and then serialises the full result set. Same issue with k at line 138.

Consider capping at a reasonable maximum (e.g. 50) and raising a ValueError rather than silently accepting huge values:

Suggested change
if top_k < 1:
if top_k < 1 or top_k > 50:
raise ValueError("top_k must be between 1 and 50")

(Choose a limit that matches what the searcher can meaningfully return; 50 is just a placeholder.)

Comment thread src/mcp_server/server.py
if not query or not query.strip():
raise ValueError("query must be a non-empty string")
if k < 1:
raise ValueError("k must be >= 1")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same missing upper-bound issue as top_k above — k=10000 would pass through to answer_from_threads with no guard. If you add a cap to search_email, add one here too.

Comment thread tests/test_mcp_server.py
Comment on lines +178 to +179
# FastMCP (this SDK version) returns (content_blocks, structured_result).
content_blocks, structured = result

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This unpacks FastMCP.call_tool's return value as (content_blocks, structured_result), which is an SDK-internal implementation detail of mcp 1.28.1 — not part of the public API. The comment acknowledges it, but the test will fail silently (wrong assertion) or loudly (unpack error) if the SDK changes its return format on upgrade.

Two options:

  1. Test the public effect instead: mock the module-level search_email function and assert it was called with the right args — that verifies dispatch without depending on FastMCP internals.
  2. Wrap the unpack in a try/except with a clear message so a future breakage points at the SDK change rather than a mystery index error.

Either way, a # mcp==1.28.1 call_tool return format version pin comment would make the assumption auditable.

Comment thread tests/test_mcp_server.py
set(by_name["answer_question"].inputSchema["properties"]),
{"query", "k"})

def test_call_tool_dispatches_into_search_email(self):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

answer_question has no call_tool dispatch test — only search_email is exercised through the FastMCP layer here. The unit tests in TestAnswerQuestion cover the function logic well, but they don't catch a broken tool registration (wrong name, missing kwarg, wrong return type in the MCP wrapper).

A minimal twin of this test for answer_question (mocking both get_searcher and answer_from_threads) would close that gap.

Bring the MCP server PR up to date with main's CI hardening (ruff format,
ruff/mypy/pip-audit gates, 85% coverage floor, CodeQL) and deps bump
(cryptography/pypdf/tornado).

Conflict resolution:
- src/cli.py: kept main's ruff-expanded _add_verb style AND #67's `mcp`
  verb (run the stdio MCP server), inserted after `ask`.
- pyproject.toml: kept BOTH main's [tool.ruff]/[tool.mypy]/pytest config
  AND #67's `mcp = "^1.28.1"` dependency.
- poetry.lock: regenerated with `poetry lock` (not hand-merged); `poetry
  check --lock` passes.
- requirements.txt / README.md: kept both sides' intent.

src/mcp_server/ type-checks cleanly under the existing lenient mypy config
(ignore_missing_imports covers the mcp SDK), so no per-module override was
needed. ruff check/format clean; full suite 913 passed, coverage 87.9%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread src/mcp_server/server.py
manifest. Raises ``ValueError`` with an actionable message when none is
available (so the MCP client sees a clear error rather than a crash).
"""
coll = collection or os.environ.get("MAILRAG_COLLECTION") or latest_manifest_collection()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: whitespace collection name passes through silently.

resolve_qdrant_url strips the resolved value and falls back to the default when blank. resolve_collection has no equivalent guard. If MAILRAG_COLLECTION (or the manifest .get("collection")) holds an all-whitespace string, the or-chain returns it as truthy, if not coll stays False, and a garbage collection name is forwarded to Qdrant — producing a confusing Collection not found rather than the intended clear ValueError.

Suggested change
coll = collection or os.environ.get("MAILRAG_COLLECTION") or latest_manifest_collection()
coll = (collection or os.environ.get("MAILRAG_COLLECTION") or latest_manifest_collection() or "").strip()

Comment thread src/mcp_server/server.py
or os.environ.get("MAILRAG_QDRANT_URL")
or os.environ.get("QDRANT_URL")
or DEFAULT_QDRANT_URL
).strip() or DEFAULT_QDRANT_URL

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The .strip() or DEFAULT_QDRANT_URL at the end acts on the entire or-chain result, not just DEFAULT_QDRANT_URL — so a whitespace env-var value correctly falls back. This is good, but it reads as if the strip only applies to the last element. A comment prevents an accidental "simplification" that drops the fallback:

Suggested change
).strip() or DEFAULT_QDRANT_URL
).strip() or DEFAULT_QDRANT_URL # strip covers all branches; blank env var falls back to default

Comment thread src/mcp_server/server.py
Comment on lines +144 to +147
"""Construct the ``FastMCP`` server with the mailrag tools registered.

Imported lazily so importing this module (e.g. for unit tests of the pure
query functions) does not require the ``mcp`` SDK at import time.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring says "importing this module … does not require the mcp SDK at import time," but the module already imports answer_from_threads, latest_manifest_collection, and build_hybrid_searcher at the top level. What's actually lazy is just the mcp.server.fastmcp import inside this function — tighten the claim so readers (and future refactors) aren't misled:

Defers the mcp.server.fastmcp import so test modules that only exercise the pure query helpers don't require the mcp SDK installed.

Comment thread tests/test_mcp_server.py
Comment on lines +183 to +193
def test_call_tool_dispatches_into_search_email(self):
srv = server.build_server()
searcher = _FakeSearcher(_threads())
with mock.patch("src.mcp_server.server.get_searcher", return_value=searcher):
result = asyncio.run(srv.call_tool("search_email", {"query": "invoices", "top_k": 1}))
# FastMCP (this SDK version) returns (content_blocks, structured_result).
content_blocks, structured = result
rows = structured["result"]
self.assertEqual(rows[0]["thread_id"], "t1")
# The text content mirrors the same payload.
self.assertIn("t1", content_blocks[0].text)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test unpacks the call_tool return as (content_blocks, structured) and accesses structured["result"] — coupling to an internal FastMCP return shape that is not part of the public MCP protocol contract. If the SDK bumps from 1.28.x to a 1.29.x (within the ^1.28.1 pin) and changes this tuple layout, the test will fail with an unhelpful ValueError: not enough values to unpack rather than a meaningful assertion message.

Consider asserting only on the observable MCP-protocol output (e.g. the content blocks' text), or at minimum add a comment that this shape is version-specific so a future breakage is immediately diagnosable.

Comment thread tests/test_mcp_server.py
Comment on lines +197 to +212
def test_mcp_verb_invokes_serve_and_sets_env(self):
from src import cli

with (
mock.patch("src.mcp_server.server.serve") as serve,
mock.patch.dict("os.environ", {}, clear=True),
):
rc = cli.main(
["mcp", "--collection", "work-rag", "--qdrant-url", "http://localhost:6333"]
)
self.assertEqual(rc, 0)
serve.assert_called_once()
import os

self.assertEqual(os.environ["MAILRAG_COLLECTION"], "work-rag")
self.assertEqual(os.environ["MAILRAG_QDRANT_URL"], "http://localhost:6333")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test: mailrag mcp with no flags (the most common invocation).

This test only covers the path where both --collection and --qdrant-url are supplied. There's no coverage for running without flags, where collection resolution falls through to $MAILRAG_COLLECTION → manifest. A minimal addition:

def test_mcp_verb_no_flags_still_calls_serve(self):
    from src import cli
    with (
        mock.patch("src.mcp_server.server.serve") as serve,
        mock.patch.dict("os.environ", {"MAILRAG_COLLECTION": "my-col"}, clear=False),
    ):
        rc = cli.main(["mcp"])
    self.assertEqual(rc, 0)
    serve.assert_called_once()
    # env var must not be overwritten when flag was absent
    self.assertEqual(os.environ["MAILRAG_COLLECTION"], "my-col")

@claude

claude Bot commented Jul 5, 2026

Copy link
Copy Markdown

Review summary

Clean, well-scoped addition. The thin-wrapper design is correct — no retrieval logic is reimplemented, the injectable-factory pattern makes tests fast, config precedence mirrors mailrag ask, and the 18-case test file covers the happy path, edge cases, and invalid inputs thoroughly. One correctness bug and a few smaller issues worth addressing before merge.


Correctness

resolve_collection doesn't strip whitespace (server.py:49) — the one real bug. resolve_qdrant_url defensively strips its resolved value and falls back to the default when blank; resolve_collection doesn't. If MAILRAG_COLLECTION is set to a whitespace string, or if latest_manifest_collection returns one (possible if a manifest has "collection": ""), the value passes through as truthy and arrives at Qdrant as a garbage collection name — producing an opaque Collection not found instead of the intended clear ValueError. See inline suggestion.


Tests

  • test_call_tool_dispatches_into_search_email (test_mcp_server.py:183–193) — tuple-unpacks the call_tool return as (content_blocks, structured) and accesses structured["result"]. This is FastMCP-internal, not an MCP-protocol guarantee; a minor SDK bump within the ^1.28.1 pin could break it with a cryptic ValueError: not enough values to unpack. See inline note.
  • test_qdrant_url_precedence — tests four precedence levels in one method; if scenario 1 fails the rest don't run. Minor, but splitting into four methods makes failures self-describing.
  • Missing: mailrag mcp with no flags — the most common real-world invocation goes untested (collection from env/manifest, URL defaulting). See inline suggestion for a minimal addition.

Dependency footprint

mcp >= 1.28.1 pulls in a full HTTP/SSE server stack — starlette, uvicorn, sse-starlette, python-multipart, pyjwt[crypto] — because the SDK bundles both transport modes. mailrag only uses stdio. This is ~10 new production packages for a local CLI tool with no network exposure. Not a blocker, but worth tracking; the MCP SDK currently has no stdio-only slim install.


Nits (no action required)

  • from typing import List, Optional — with Python >= 3.11 (as declared in pyproject.toml) you can use list[dict] and str | None directly.
  • build_server docstring (server.py:144–147) — says the whole module import is lazy; actually only the mcp.server.fastmcp import is deferred. See inline.
  • if __name__ == "__main__": in __main__.py is always True and can be removed.

@fmasi
fmasi merged commit a6ee5c6 into main Jul 5, 2026
19 checks passed
@fmasi
fmasi deleted the feat/mcp-server branch July 19, 2026 19:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant