Remove the MCP write tools; the server opens read-only

add_document_from_file, add_document_from_url, add_document_from_text
and delete_document are gone. create_mcp_server and _covering lose
read_only; the client always opens with read_only=True, so the
--read-only flag before `mcp` is redundant and the docs, Dockerfiles and
compose example drop it.

Ingestion is `haiku-rag add`/`add-src`/`delete` and haiku-ingester. No
known consumer used the write tools; one stdio server per client window
made multi-writer the accidental default, and streamable HTTP has no auth.

The test_app stub drops a comment and __init__ that described run_mcp
constructing the client positionally; every path goes through _covering.

Refs #599
This commit is contained in:
Yiorgis Gozadinos 2026-09-04 10:27:32 +03:00
parent 7eeddd1a7c
commit ce69a8c989
No known key found for this signature in database
12 changed files with 61 additions and 302 deletions

View file

@ -16,6 +16,12 @@
- `processing.conversion_options.picture_description.model` defaults to - `processing.conversion_options.picture_description.model` defaults to
`enable_thinking: false`, and the field now reaches the VLM: docling's `enable_thinking: false`, and the field now reaches the VLM: docling's
picture-description request carries `reasoning_effort` in `params`. picture-description request carries `reasoning_effort` in `params`.
### Removed
- MCP write tools `add_document_from_file`, `add_document_from_url`,
`add_document_from_text` and `delete_document`. The server opens the
database read-only; ingest with `haiku-rag add`, `add-src`, `delete` or
`haiku-ingester`. `create_mcp_server` loses `read_only`.
## [0.82.1] - 2026-09-03 ## [0.82.1] - 2026-09-03

View file

@ -129,7 +129,7 @@ Add to your Claude Desktop configuration:
} }
``` ```
Provides tools for document management, search, QA, and analysis directly in your AI assistant. Provides search, document, QA, and analysis tools directly in your AI assistant.
## Examples ## Examples

View file

@ -40,4 +40,4 @@ EXPOSE 8001 8765
# Default command: read-only MCP server. The companion ingester service is # Default command: read-only MCP server. The companion ingester service is
# launched via docker-compose against the same image. # launched via docker-compose against the same image.
CMD ["haiku-rag", "--config", "/app/haiku.rag.yaml", "--read-only", "mcp", "--port", "8001"] CMD ["haiku-rag", "--config", "/app/haiku.rag.yaml", "mcp", "--port", "8001"]

View file

@ -39,4 +39,4 @@ EXPOSE 8001 8765
# Default command: read-only MCP server. The companion ingester service is # Default command: read-only MCP server. The companion ingester service is
# launched via docker-compose against the same image. # launched via docker-compose against the same image.
CMD ["haiku-rag", "--config", "/app/haiku.rag.yaml", "--read-only", "mcp", "--port", "8001"] CMD ["haiku-rag", "--config", "/app/haiku.rag.yaml", "mcp", "--port", "8001"]

View file

@ -477,9 +477,6 @@ haiku-rag mcp --port 9000
# Bind to all interfaces (containers, trusted LAN) # Bind to all interfaces (containers, trusted LAN)
haiku-rag mcp --host 0.0.0.0 haiku-rag mcp --host 0.0.0.0
# Read-only mode (no write tools)
haiku-rag --read-only mcp
``` ```
See [MCP](mcp.md) for details. For continuous document ingestion See [MCP](mcp.md) for details. For continuous document ingestion

View file

@ -196,7 +196,7 @@ writing process per database URI, any number of read-only consumers.
The recommended layout for production is "different buckets, same account, separate IAM roles per process": The recommended layout for production is "different buckets, same account, separate IAM roles per process":
- **Ingestion process** — IAM role with `s3:Get/List` on the documents bucket and `s3:Get/Put/Delete` on the LanceDB bucket. Runs `haiku-ingester serve` (with `ingester.sources[type=s3]` pointing at the documents bucket). Exactly one such process per LanceDB URI. - **Ingestion process** — IAM role with `s3:Get/List` on the documents bucket and `s3:Get/Put/Delete` on the LanceDB bucket. Runs `haiku-ingester serve` (with `ingester.sources[type=s3]` pointing at the documents bucket). Exactly one such process per LanceDB URI.
- **Consumer processes** (1..N) — IAM role with `s3:Get/List` on the LanceDB bucket only. Run `haiku-rag --read-only mcp`, the chat TUI, etc. They never see the documents bucket. - **Consumer processes** (1..N) — IAM role with `s3:Get/List` on the LanceDB bucket only. Run `haiku-rag mcp`, the chat TUI, etc. They never see the documents bucket.
Each process picks up its own credentials from the AWS default chain (env vars, IAM instance role, AWS profile), so no credentials are hard-coded in the configuration files. Each process picks up its own credentials from the AWS default chain (env vars, IAM instance role, AWS profile), so no credentials are hard-coded in the configuration files.

View file

@ -18,16 +18,14 @@ haiku-rag mcp --host 0.0.0.0 --port 8001
# stdio transport (for Claude Desktop) # stdio transport (for Claude Desktop)
haiku-rag mcp --stdio haiku-rag mcp --stdio
# Read-only mode (excludes write tools)
haiku-rag --read-only mcp --stdio
``` ```
`--host` defaults to `127.0.0.1` (loopback only). Bind to `0.0.0.0` only `--host` defaults to `127.0.0.1` (loopback only). Bind to `0.0.0.0` only
when you want the MCP server reachable from outside the local machine — when you want the MCP server reachable from outside the local machine —
e.g. inside a Docker container with port mapping, or on a trusted LAN. e.g. inside a Docker container with port mapping, or on a trusted LAN.
**Read-only mode:** When `--read-only` is specified, write tools (`add_document_from_file`, `add_document_from_url`, `add_document_from_text`, `delete_document`) are not registered. Only search and query tools remain available. The server opens the database read-only. Ingestion goes through the CLI
(`haiku-rag add`, `add-src`, `delete`) or [`haiku-ingester`](ingester.md).
## Claude Desktop Integration ## Claude Desktop Integration
@ -57,27 +55,11 @@ With a custom database path:
} }
``` ```
After restarting Claude Desktop, you can ask Claude to search your documents, add new content, or answer questions using your knowledge base. After restarting Claude Desktop, you can ask Claude to search your documents or answer questions using your knowledge base.
## Available Tools ## Available Tools
### Document Management ### Documents
- **`add_document_from_file`** - Add documents from local file paths
- `file_path` (required): Path to the file
- `metadata` (optional): Key-value metadata
- `title` (optional): Human-readable title
- **`add_document_from_url`** - Add documents from URLs
- `url` (required): URL to fetch
- `metadata` (optional): Key-value metadata
- `title` (optional): Human-readable title
- **`add_document_from_text`** - Add documents from raw text content
- `content` (required): Text content
- `uri` (optional): URI identifier
- `metadata` (optional): Key-value metadata
- `title` (optional): Human-readable title
- **`get_document`** - Retrieve a document by ID - **`get_document`** - Retrieve a document by ID
- `document_id` (required): The document ID - `document_id` (required): The document ID
@ -87,9 +69,6 @@ After restarting Claude Desktop, you can ask Claude to search your documents, ad
- `offset` (optional): Number to skip - `offset` (optional): Number to skip
- `filter` (optional): SQL WHERE clause for filtering - `filter` (optional): SQL WHERE clause for filtering
- **`delete_document`** - Delete a document by ID
- `document_id` (required): The document ID
### Search ### Search
- **`search_documents`** - Search using hybrid search (vector + full-text) - **`search_documents`** - Search using hybrid search (vector + full-text)

View file

@ -103,7 +103,6 @@ services:
"haiku-rag", "haiku-rag",
"--config", "--config",
"/app/haiku.rag.yaml", "/app/haiku.rag.yaml",
"--read-only",
"mcp", "mcp",
"--host", "--host",
"0.0.0.0", "0.0.0.0",

View file

@ -934,7 +934,7 @@ class HaikuRAGApp:
# The resolved scope: a path overrides a configured URI, and a derived # The resolved scope: a path overrides a configured URI, and a derived
# single-database configuration drops the name results and citations # single-database configuration drops the name results and citations
# carry. # carry.
server = _mcp_server_covering(self.scope, self.config, self.read_only) server = _mcp_server_covering(self.scope, self.config)
try: try:
if transport == "stdio": if transport == "stdio":
await server.run_stdio_async() await server.run_stdio_async()

View file

@ -2,7 +2,7 @@ import asyncio
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import AsyncExitStack, asynccontextmanager from contextlib import AsyncExitStack, asynccontextmanager
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING
from fastmcp import FastMCP from fastmcp import FastMCP
@ -27,7 +27,6 @@ def _decode_images(images_base64: list[str] | None) -> list[bytes] | None:
def create_mcp_server( def create_mcp_server(
db_path: Path | None = None, db_path: Path | None = None,
config: AppConfig | None = None, config: AppConfig | None = None,
read_only: bool = False,
) -> FastMCP: ) -> FastMCP:
"""Create an MCP server over one database. """Create an MCP server over one database.
@ -36,17 +35,14 @@ def create_mcp_server(
None to serve the database the configuration places. Beside None to serve the database the configuration places. Beside
`lancedb.databases` a path raises `AmbiguousDatabaseError`. `lancedb.databases` a path raises `AmbiguousDatabaseError`.
config: Configuration to use. config: Configuration to use.
read_only: If True, write tools (add_document_*, delete_document) are not registered.
""" """
from haiku.rag.client.scope import DatabaseScope from haiku.rag.client.scope import DatabaseScope
config = config if config is not None else get_config() config = config if config is not None else get_config()
return _covering( return _covering(DatabaseScope.resolve(config, database_path=db_path), config)
DatabaseScope.resolve(config, database_path=db_path), config, read_only
)
def _covering(scope: "DatabaseScope", config: AppConfig, read_only: bool) -> FastMCP: def _covering(scope: "DatabaseScope", config: AppConfig) -> FastMCP:
"""An MCP server over databases someone already resolved. """An MCP server over databases someone already resolved.
Internal, as ``HaikuRAG._covering`` is: the public factory takes a path and Internal, as ``HaikuRAG._covering`` is: the public factory takes a path and
@ -76,7 +72,7 @@ def _covering(scope: "DatabaseScope", config: AppConfig, read_only: bool) -> Fas
async with client_lock: async with client_lock:
if client is None: if client is None:
client = await stack.enter_async_context( client = await stack.enter_async_context(
HaikuRAG._covering(scope, config, read_only=read_only) HaikuRAG._covering(scope, config, read_only=True)
) )
return client return client
@ -97,72 +93,6 @@ def _covering(scope: "DatabaseScope", config: AppConfig, read_only: bool) -> Fas
mcp = FastMCP("haiku-rag", lifespan=lifespan) mcp = FastMCP("haiku-rag", lifespan=lifespan)
# Write tools - only registered when not in read-only mode
if not read_only:
@mcp.tool()
async def add_document_from_file(
file_path: str,
metadata: dict[str, Any] | None = None,
title: str | None = None,
) -> str | None:
"""Add a document to the RAG system from a file path."""
try:
rag = await _client()
result = await rag.create_document_from_source(
Path(file_path), title=title, metadata=metadata or {}
)
# Handle both single document and list of documents (directories)
if isinstance(result, list):
return result[0].id if result else None
return result.id
except Exception:
return None
@mcp.tool()
async def add_document_from_url(
url: str, metadata: dict[str, Any] | None = None, title: str | None = None
) -> str | None:
"""Add a document to the RAG system from a URL."""
try:
rag = await _client()
result = await rag.create_document_from_source(
url, title=title, metadata=metadata or {}
)
# Handle both single document and list of documents
if isinstance(result, list):
return result[0].id if result else None
return result.id
except Exception:
return None
@mcp.tool()
async def add_document_from_text(
content: str,
uri: str | None = None,
metadata: dict[str, Any] | None = None,
title: str | None = None,
) -> str | None:
"""Add a document to the RAG system from text content."""
try:
rag = await _client()
document = await rag.create_document(
content, uri, title=title, metadata=metadata or {}
)
return document.id
except Exception:
return None
@mcp.tool()
async def delete_document(document_id: str) -> bool:
"""Delete a document by its ID."""
try:
rag = await _client()
return await rag.delete_document(document_id)
except Exception:
return False
# Read tools - always registered
@mcp.tool() @mcp.tool()
async def search_documents( async def search_documents(
query: str, limit: int | None = None, include_images: bool = True query: str, limit: int | None = None, include_images: bool = True

View file

@ -31,10 +31,6 @@ def client():
@pytest.fixture @pytest.fixture
def app(tmp_path, client, monkeypatch): def app(tmp_path, client, monkeypatch):
class StubHaikuRAG: class StubHaikuRAG:
# run_mcp passes db_path positionally; every other caller uses kwargs.
def __init__(self, *args, **kwargs):
pass
@classmethod @classmethod
def _covering(cls, *args, **kwargs): def _covering(cls, *args, **kwargs):
return cls() return cls()

View file

@ -55,7 +55,7 @@ async def _get_tool(mcp, name):
class TestMCPReadTools: class TestMCPReadTools:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_search_documents(self, mcp_db): async def test_search_documents(self, mcp_db):
mcp = create_mcp_server(mcp_db, read_only=True) mcp = create_mcp_server(mcp_db)
search = await _get_tool(mcp, "search_documents") search = await _get_tool(mcp, "search_documents")
results = await search(query="artificial intelligence") results = await search(query="artificial intelligence")
@ -64,7 +64,7 @@ class TestMCPReadTools:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_search_documents_with_limit(self, mcp_db): async def test_search_documents_with_limit(self, mcp_db):
mcp = create_mcp_server(mcp_db, read_only=True) mcp = create_mcp_server(mcp_db)
search = await _get_tool(mcp, "search_documents") search = await _get_tool(mcp, "search_documents")
results = await search(query="artificial intelligence", limit=1) results = await search(query="artificial intelligence", limit=1)
@ -93,7 +93,7 @@ class TestMCPReadTools:
) )
await rag.store.chunks_table.optimize() await rag.store.chunks_table.optimize()
mcp = create_mcp_server(mcp_db, read_only=True) mcp = create_mcp_server(mcp_db)
async with Client(mcp) as client: async with Client(mcp) as client:
result = await client.call_tool( result = await client.call_tool(
"search_documents", {"query": "artificial intelligence"} "search_documents", {"query": "artificial intelligence"}
@ -107,7 +107,7 @@ class TestMCPReadTools:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_document(self, mcp_db): async def test_get_document(self, mcp_db):
mcp = create_mcp_server(mcp_db, read_only=True) mcp = create_mcp_server(mcp_db)
get_doc = await _get_tool(mcp, "get_document") get_doc = await _get_tool(mcp, "get_document")
# First get the ID via list # First get the ID via list
@ -122,7 +122,7 @@ class TestMCPReadTools:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_document_excludes_docling_fields(self, mcp_db): async def test_get_document_excludes_docling_fields(self, mcp_db):
mcp = create_mcp_server(mcp_db, read_only=True) mcp = create_mcp_server(mcp_db)
get_doc = await _get_tool(mcp, "get_document") get_doc = await _get_tool(mcp, "get_document")
list_docs = await _get_tool(mcp, "list_documents") list_docs = await _get_tool(mcp, "list_documents")
@ -136,7 +136,7 @@ class TestMCPReadTools:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_document_not_found(self, mcp_db): async def test_get_document_not_found(self, mcp_db):
mcp = create_mcp_server(mcp_db, read_only=True) mcp = create_mcp_server(mcp_db)
get_doc = await _get_tool(mcp, "get_document") get_doc = await _get_tool(mcp, "get_document")
result = await get_doc(document_id="nonexistent-id") result = await get_doc(document_id="nonexistent-id")
@ -144,7 +144,7 @@ class TestMCPReadTools:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_documents(self, mcp_db): async def test_list_documents(self, mcp_db):
mcp = create_mcp_server(mcp_db, read_only=True) mcp = create_mcp_server(mcp_db)
list_docs = await _get_tool(mcp, "list_documents") list_docs = await _get_tool(mcp, "list_documents")
results = await list_docs() results = await list_docs()
@ -153,7 +153,7 @@ class TestMCPReadTools:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_documents_with_limit(self, mcp_db): async def test_list_documents_with_limit(self, mcp_db):
mcp = create_mcp_server(mcp_db, read_only=True) mcp = create_mcp_server(mcp_db)
list_docs = await _get_tool(mcp, "list_documents") list_docs = await _get_tool(mcp, "list_documents")
results = await list_docs(limit=1) results = await list_docs(limit=1)
@ -161,7 +161,7 @@ class TestMCPReadTools:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_documents_with_filter(self, mcp_db): async def test_list_documents_with_filter(self, mcp_db):
mcp = create_mcp_server(mcp_db, read_only=True) mcp = create_mcp_server(mcp_db)
list_docs = await _get_tool(mcp, "list_documents") list_docs = await _get_tool(mcp, "list_documents")
results = await list_docs(filter="title = 'AI Overview'") results = await list_docs(filter="title = 'AI Overview'")
@ -169,66 +169,18 @@ class TestMCPReadTools:
assert results[0].title == "AI Overview" assert results[0].title == "AI Overview"
class TestMCPWriteTools: class TestMCPToolSet:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_write_tools_registered_when_not_read_only(self, temp_db_path): async def test_the_server_registers_read_tools_only(self, mcp_db):
async with HaikuRAG(temp_db_path, create=True): mcp = create_mcp_server(mcp_db)
pass
mcp = create_mcp_server(temp_db_path, read_only=False)
tools = await mcp.list_tools()
tool_names = [t.name for t in tools]
assert "add_document_from_text" in tool_names
assert "add_document_from_file" in tool_names
assert "add_document_from_url" in tool_names
assert "delete_document" in tool_names
@pytest.mark.asyncio assert {t.name for t in await mcp.list_tools()} == {
async def test_write_tools_not_registered_when_read_only(self, temp_db_path): "search_documents",
async with HaikuRAG(temp_db_path, create=True): "get_document",
pass "list_documents",
mcp = create_mcp_server(temp_db_path, read_only=True) "ask_question",
tools = await mcp.list_tools() "analyze",
tool_names = [t.name for t in tools] }
assert "add_document_from_text" not in tool_names
assert "delete_document" not in tool_names
@pytest.mark.asyncio
async def test_add_document_from_text(self, temp_db_path):
async with HaikuRAG(temp_db_path, create=True):
pass
mcp = create_mcp_server(temp_db_path, read_only=False)
add_text = await _get_tool(mcp, "add_document_from_text")
doc_id = await add_text(content="Test content for MCP", title="MCP Test Doc")
assert doc_id is not None
get_doc = await _get_tool(mcp, "get_document")
doc = await get_doc(document_id=doc_id)
assert doc.title == "MCP Test Doc"
assert doc.content == "Test content for MCP"
@pytest.mark.asyncio
async def test_delete_document(self, mcp_db):
mcp = create_mcp_server(mcp_db, read_only=False)
list_docs = await _get_tool(mcp, "list_documents")
delete_doc = await _get_tool(mcp, "delete_document")
docs = await list_docs()
assert len(docs) == 2
result = await delete_doc(document_id=docs[0].id)
assert result is True
docs_after = await list_docs()
assert len(docs_after) == 1
@pytest.mark.asyncio
async def test_delete_document_not_found(self, mcp_db):
mcp = create_mcp_server(mcp_db, read_only=False)
delete_doc = await _get_tool(mcp, "delete_document")
result = await delete_doc(document_id="nonexistent-id")
assert result is False
class TestMCPImageQuery: class TestMCPImageQuery:
@ -237,7 +189,7 @@ class TestMCPImageQuery:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_image_query_tool_absent_for_text_only_embedder(self, mcp_db): async def test_image_query_tool_absent_for_text_only_embedder(self, mcp_db):
"""Default text-only embedder must not expose the image-query tool.""" """Default text-only embedder must not expose the image-query tool."""
mcp = create_mcp_server(mcp_db, read_only=True) mcp = create_mcp_server(mcp_db)
names = {t.name for t in await mcp.list_tools()} names = {t.name for t in await mcp.list_tools()}
assert "search_documents_by_image" not in names assert "search_documents_by_image" not in names
@ -264,7 +216,7 @@ class TestMCPImageQuery:
lambda *a, **kw: StubMultimodal(), lambda *a, **kw: StubMultimodal(),
) )
mcp = create_mcp_server(mcp_db, read_only=True) mcp = create_mcp_server(mcp_db)
names = {t.name for t in await mcp.list_tools()} names = {t.name for t in await mcp.list_tools()}
assert "search_documents_by_image" in names assert "search_documents_by_image" in names
@ -296,7 +248,7 @@ class TestMCPImageQuery:
lambda *a, **kw: StubMultimodal(), lambda *a, **kw: StubMultimodal(),
) )
mcp = create_mcp_server(mcp_db, read_only=True) mcp = create_mcp_server(mcp_db)
search_by_image = await _get_tool(mcp, "search_documents_by_image") search_by_image = await _get_tool(mcp, "search_documents_by_image")
# Not valid base64 (contains non-base64 chars) — the strict decoder # Not valid base64 (contains non-base64 chars) — the strict decoder
@ -317,7 +269,7 @@ class TestMCPImageInput:
return ("answer", []) return ("answer", [])
monkeypatch.setattr(HaikuRAG, "ask", fake_ask) monkeypatch.setattr(HaikuRAG, "ask", fake_ask)
mcp = create_mcp_server(mcp_db, read_only=True) mcp = create_mcp_server(mcp_db)
ask = await _get_tool(mcp, "ask_question") ask = await _get_tool(mcp, "ask_question")
png = b"fake image bytes" png = b"fake image bytes"
@ -337,7 +289,7 @@ class TestMCPImageInput:
return SimpleNamespace(answer="answer") return SimpleNamespace(answer="answer")
monkeypatch.setattr(HaikuRAG, "analyze", fake_analyze) monkeypatch.setattr(HaikuRAG, "analyze", fake_analyze)
mcp = create_mcp_server(mcp_db, read_only=True) mcp = create_mcp_server(mcp_db)
analyze = await _get_tool(mcp, "analyze") analyze = await _get_tool(mcp, "analyze")
jpeg = b"fake jpeg bytes" jpeg = b"fake jpeg bytes"
@ -347,7 +299,7 @@ class TestMCPImageInput:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_ask_question_rejects_invalid_base64(self, mcp_db): async def test_ask_question_rejects_invalid_base64(self, mcp_db):
mcp = create_mcp_server(mcp_db, read_only=True) mcp = create_mcp_server(mcp_db)
ask = await _get_tool(mcp, "ask_question") ask = await _get_tool(mcp, "ask_question")
result = await ask(question="q", images_base64=["!!! not base64 !!!"]) result = await ask(question="q", images_base64=["!!! not base64 !!!"])
@ -362,7 +314,7 @@ class TestMCPImageInput:
return ("answer", []) return ("answer", [])
monkeypatch.setattr(HaikuRAG, "ask", fake_ask) monkeypatch.setattr(HaikuRAG, "ask", fake_ask)
mcp = create_mcp_server(mcp_db, read_only=True) mcp = create_mcp_server(mcp_db)
ask = await _get_tool(mcp, "ask_question") ask = await _get_tool(mcp, "ask_question")
result = await ask(question="q") result = await ask(question="q")
@ -370,70 +322,6 @@ class TestMCPImageInput:
assert captured["images"] is None assert captured["images"] is None
class TestMCPFileAndUrlIngestion:
@pytest.mark.asyncio
async def test_add_document_from_file(self, temp_db_path, tmp_path):
async with HaikuRAG(temp_db_path, create=True):
pass
source = tmp_path / "note.txt"
source.write_text("Ingested from a file path.")
mcp = create_mcp_server(temp_db_path, read_only=False)
add_file = await _get_tool(mcp, "add_document_from_file")
doc_id = await add_file(file_path=str(source), title="File Doc")
assert doc_id is not None
get_doc = await _get_tool(mcp, "get_document")
doc = await get_doc(document_id=doc_id)
assert doc.title == "File Doc"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"tool_name,kwargs",
[
("add_document_from_file", {"file_path": "/tmp/x.txt"}),
("add_document_from_url", {"url": "https://example.com/x.txt"}),
],
)
@pytest.mark.parametrize(
"results,expected",
[
(
[Document(id="first", content="a"), Document(id="second", content="b")],
"first",
),
([], None),
],
ids=["directory_reports_first_id", "empty_directory_reports_none"],
)
async def test_add_tools_handle_multi_document_sources(
self, mcp_db, monkeypatch, tool_name, kwargs, results, expected
):
"""A source resolving to several documents reports the first id."""
async def fake_from_source(self, source, title=None, metadata=None, **kw):
return results
monkeypatch.setattr(HaikuRAG, "create_document_from_source", fake_from_source)
mcp = create_mcp_server(mcp_db, read_only=False)
add = await _get_tool(mcp, tool_name)
assert await add(**kwargs) == expected
@pytest.mark.asyncio
async def test_add_document_from_url(self, mcp_db, monkeypatch):
async def fake_from_source(self, source, title=None, metadata=None, **kwargs):
assert source == "https://example.com/doc.txt"
return Document(id="url-doc", content="fetched")
monkeypatch.setattr(HaikuRAG, "create_document_from_source", fake_from_source)
mcp = create_mcp_server(mcp_db, read_only=False)
add_url = await _get_tool(mcp, "add_document_from_url")
assert await add_url(url="https://example.com/doc.txt") == "url-doc"
class TestMCPToolsDegradeOnError: class TestMCPToolsDegradeOnError:
"""Every tool swallows client failures and returns its empty value rather """Every tool swallows client failures and returns its empty value rather
than propagating an exception to the MCP transport.""" than propagating an exception to the MCP transport."""
@ -442,20 +330,6 @@ class TestMCPToolsDegradeOnError:
@pytest.mark.parametrize( @pytest.mark.parametrize(
"client_method,tool_name,kwargs,expected", "client_method,tool_name,kwargs,expected",
[ [
(
"create_document_from_source",
"add_document_from_file",
{"file_path": "/tmp/x.txt"},
None,
),
(
"create_document_from_source",
"add_document_from_url",
{"url": "https://example.com/x"},
None,
),
("create_document", "add_document_from_text", {"content": "x"}, None),
("delete_document", "delete_document", {"document_id": "x"}, False),
("search", "search_documents", {"query": "x"}, []), ("search", "search_documents", {"query": "x"}, []),
("get_document_by_id", "get_document", {"document_id": "x"}, None), ("get_document_by_id", "get_document", {"document_id": "x"}, None),
("list_documents", "list_documents", {}, []), ("list_documents", "list_documents", {}, []),
@ -468,14 +342,14 @@ class TestMCPToolsDegradeOnError:
raise RuntimeError("client exploded") raise RuntimeError("client exploded")
monkeypatch.setattr(HaikuRAG, client_method, boom) monkeypatch.setattr(HaikuRAG, client_method, boom)
mcp = create_mcp_server(mcp_db, read_only=False) mcp = create_mcp_server(mcp_db)
tool = await _get_tool(mcp, tool_name) tool = await _get_tool(mcp, tool_name)
assert await tool(**kwargs) == expected assert await tool(**kwargs) == expected
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_documents_returns_empty_for_invalid_filter(self, mcp_db): async def test_list_documents_returns_empty_for_invalid_filter(self, mcp_db):
mcp = create_mcp_server(mcp_db, read_only=True) mcp = create_mcp_server(mcp_db)
list_docs = await _get_tool(mcp, "list_documents") list_docs = await _get_tool(mcp, "list_documents")
assert await list_docs(filter="no_such_column = 1") == [] assert await list_docs(filter="no_such_column = 1") == []
@ -486,7 +360,7 @@ class TestMCPToolsDegradeOnError:
raise RuntimeError("sandbox exploded") raise RuntimeError("sandbox exploded")
monkeypatch.setattr(HaikuRAG, "analyze", boom) monkeypatch.setattr(HaikuRAG, "analyze", boom)
mcp = create_mcp_server(mcp_db, read_only=True) mcp = create_mcp_server(mcp_db)
analyze = await _get_tool(mcp, "analyze") analyze = await _get_tool(mcp, "analyze")
assert "sandbox exploded" in await analyze(question="q") assert "sandbox exploded" in await analyze(question="q")
@ -509,7 +383,7 @@ class TestMCPToolsDegradeOnError:
return ("the answer", [citation]) return ("the answer", [citation])
monkeypatch.setattr(HaikuRAG, "ask", fake_ask) monkeypatch.setattr(HaikuRAG, "ask", fake_ask)
mcp = create_mcp_server(mcp_db, read_only=True) mcp = create_mcp_server(mcp_db)
ask = await _get_tool(mcp, "ask_question") ask = await _get_tool(mcp, "ask_question")
with_cite = await ask(question="q", cite=True) with_cite = await ask(question="q", cite=True)
@ -534,7 +408,7 @@ class TestMCPClientLifetime:
monkeypatch.setattr(Store, "_initialize", counted) monkeypatch.setattr(Store, "_initialize", counted)
mcp = create_mcp_server(mcp_db, read_only=True) mcp = create_mcp_server(mcp_db)
search = await _get_tool(mcp, "search_documents") search = await _get_tool(mcp, "search_documents")
list_docs = await _get_tool(mcp, "list_documents") list_docs = await _get_tool(mcp, "list_documents")
await search(query="artificial intelligence") await search(query="artificial intelligence")
@ -559,7 +433,7 @@ class TestMCPClientLifetime:
monkeypatch.setattr(Store, "_initialize", counted) monkeypatch.setattr(Store, "_initialize", counted)
mcp = create_mcp_server(mcp_db, read_only=True) mcp = create_mcp_server(mcp_db)
list_docs = await _get_tool(mcp, "list_documents") list_docs = await _get_tool(mcp, "list_documents")
results = await asyncio.gather(*(list_docs() for _ in range(5))) results = await asyncio.gather(*(list_docs() for _ in range(5)))
@ -567,18 +441,6 @@ class TestMCPClientLifetime:
assert opens == 1 assert opens == 1
assert all(len(r) == 2 for r in results) assert all(len(r) == 2 for r in results)
@pytest.mark.asyncio
async def test_a_write_is_visible_to_the_next_read(self, mcp_db):
"""One connection sees its own writes, whatever the consistency interval."""
mcp = create_mcp_server(mcp_db, read_only=False)
list_docs = await _get_tool(mcp, "list_documents")
delete_doc = await _get_tool(mcp, "delete_document")
docs = await list_docs()
assert await delete_doc(document_id=docs[0].id) is True
assert len(await list_docs()) == len(docs) - 1
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_lifespan_opens_and_closes_once(self, mcp_db, monkeypatch): async def test_lifespan_opens_and_closes_once(self, mcp_db, monkeypatch):
from haiku.rag.store.engine import Store from haiku.rag.store.engine import Store
@ -593,7 +455,7 @@ class TestMCPClientLifetime:
monkeypatch.setattr(Store, "_initialize", counted) monkeypatch.setattr(Store, "_initialize", counted)
mcp = create_mcp_server(mcp_db, read_only=True) mcp = create_mcp_server(mcp_db)
# _lifespan_manager is what every transport enters; the public # _lifespan_manager is what every transport enters; the public
# lifespan() combines provider lifespans only. # lifespan() combines provider lifespans only.
async with mcp._lifespan_manager(): async with mcp._lifespan_manager():
@ -624,7 +486,7 @@ class TestMCPClientLifetime:
) )
scope = DatabaseScope.resolve(config, database_name="alpha") scope = DatabaseScope.resolve(config, database_name="alpha")
mcp = _mcp_covering(scope, config, read_only=True) mcp = _mcp_covering(scope, config)
async with mcp._lifespan_manager(): async with mcp._lifespan_manager():
search = await _get_tool(mcp, "search_documents") search = await _get_tool(mcp, "search_documents")
results = await search(query="artificial intelligence") results = await search(query="artificial intelligence")
@ -649,7 +511,7 @@ class TestMCPClientLifetime:
) )
with pytest.raises(AmbiguousDatabaseError, match="alpha, beta"): with pytest.raises(AmbiguousDatabaseError, match="alpha, beta"):
_mcp_covering(DatabaseScope.resolve(config), config, read_only=True) _mcp_covering(DatabaseScope.resolve(config), config)
def test_the_public_factory_refuses_a_configured_set_too(self, tmp_path): def test_the_public_factory_refuses_a_configured_set_too(self, tmp_path):
"""It resolves the same scope, so it reaches the same refusal.""" """It resolves the same scope, so it reaches the same refusal."""
@ -696,7 +558,7 @@ class TestMCPClientLifetime:
async def run_stdio_async(self): async def run_stdio_async(self):
return None return None
def fake_covering(scope, config, read_only): def fake_covering(scope, config):
seen.update(scope=scope, config=config) seen.update(scope=scope, config=config)
return _Server() return _Server()
@ -715,7 +577,7 @@ class TestMCPClientLifetime:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_startup_fails_when_the_database_cannot_open(self, tmp_path): async def test_startup_fails_when_the_database_cannot_open(self, tmp_path):
mcp = create_mcp_server(tmp_path / "does-not-exist.lancedb", read_only=True) mcp = create_mcp_server(tmp_path / "does-not-exist.lancedb")
with pytest.raises(FileNotFoundError): with pytest.raises(FileNotFoundError):
async with mcp._lifespan_manager(): async with mcp._lifespan_manager():
@ -737,7 +599,7 @@ class TestMCPClientLifetime:
monkeypatch.setattr(Store, "_initialize", counted) monkeypatch.setattr(Store, "_initialize", counted)
mcp = create_mcp_server(mcp_db, read_only=True) mcp = create_mcp_server(mcp_db)
search = await _get_tool(mcp, "search_documents") search = await _get_tool(mcp, "search_documents")
async with mcp._lifespan_manager(): async with mcp._lifespan_manager():
@ -750,23 +612,13 @@ class TestMCPClientLifetime:
assert len(results) > 0 assert len(results) > 0
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_same_dim_drift_starts_read_only_but_not_writable(self, mcp_db): async def test_same_dim_drift_starts(self, mcp_db):
"""Validation is unchanged: same-dimension identity drift warns in """Same-dimension identity drift warns on a read-only open and raises
read-only mode and raises in writable mode. The MCP server no longer on a writable one; the server starts, so it opened read-only."""
opts out of it for deletion."""
from haiku.rag.config import get_config from haiku.rag.config import get_config
from haiku.rag.store.exceptions import ConfigMismatchError
drifted = get_config().model_copy(deep=True) drifted = get_config().model_copy(deep=True)
drifted.embeddings.model.name = "a-different-model" drifted.embeddings.model.name = "a-different-model"
async with create_mcp_server( async with create_mcp_server(mcp_db, config=drifted)._lifespan_manager():
mcp_db, config=drifted, read_only=True
)._lifespan_manager():
pass pass
with pytest.raises(ConfigMismatchError):
async with create_mcp_server(
mcp_db, config=drifted, read_only=False
)._lifespan_manager():
pass