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:
parent
7eeddd1a7c
commit
ce69a8c989
12 changed files with 61 additions and 302 deletions
|
|
@ -16,6 +16,12 @@
|
|||
- `processing.conversion_options.picture_description.model` defaults to
|
||||
`enable_thinking: false`, and the field now reaches the VLM: docling's
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -40,4 +40,4 @@ EXPOSE 8001 8765
|
|||
|
||||
# Default command: read-only MCP server. The companion ingester service is
|
||||
# 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"]
|
||||
|
|
|
|||
|
|
@ -39,4 +39,4 @@ EXPOSE 8001 8765
|
|||
|
||||
# Default command: read-only MCP server. The companion ingester service is
|
||||
# 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"]
|
||||
|
|
|
|||
|
|
@ -477,9 +477,6 @@ haiku-rag mcp --port 9000
|
|||
|
||||
# Bind to all interfaces (containers, trusted LAN)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
||||
- **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.
|
||||
|
||||
|
|
|
|||
29
docs/mcp.md
29
docs/mcp.md
|
|
@ -18,16 +18,14 @@ haiku-rag mcp --host 0.0.0.0 --port 8001
|
|||
|
||||
# stdio transport (for Claude Desktop)
|
||||
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
|
||||
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.
|
||||
|
||||
**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
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
### Document Management
|
||||
|
||||
- **`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
|
||||
### Documents
|
||||
|
||||
- **`get_document`** - Retrieve a document by 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
|
||||
- `filter` (optional): SQL WHERE clause for filtering
|
||||
|
||||
- **`delete_document`** - Delete a document by ID
|
||||
- `document_id` (required): The document ID
|
||||
|
||||
### Search
|
||||
|
||||
- **`search_documents`** - Search using hybrid search (vector + full-text)
|
||||
|
|
|
|||
|
|
@ -103,7 +103,6 @@ services:
|
|||
"haiku-rag",
|
||||
"--config",
|
||||
"/app/haiku.rag.yaml",
|
||||
"--read-only",
|
||||
"mcp",
|
||||
"--host",
|
||||
"0.0.0.0",
|
||||
|
|
|
|||
|
|
@ -934,7 +934,7 @@ class HaikuRAGApp:
|
|||
# The resolved scope: a path overrides a configured URI, and a derived
|
||||
# single-database configuration drops the name results and citations
|
||||
# carry.
|
||||
server = _mcp_server_covering(self.scope, self.config, self.read_only)
|
||||
server = _mcp_server_covering(self.scope, self.config)
|
||||
try:
|
||||
if transport == "stdio":
|
||||
await server.run_stdio_async()
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import asyncio
|
|||
from collections.abc import AsyncIterator
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
|
|
@ -27,7 +27,6 @@ def _decode_images(images_base64: list[str] | None) -> list[bytes] | None:
|
|||
def create_mcp_server(
|
||||
db_path: Path | None = None,
|
||||
config: AppConfig | None = None,
|
||||
read_only: bool = False,
|
||||
) -> FastMCP:
|
||||
"""Create an MCP server over one database.
|
||||
|
||||
|
|
@ -36,17 +35,14 @@ def create_mcp_server(
|
|||
None to serve the database the configuration places. Beside
|
||||
`lancedb.databases` a path raises `AmbiguousDatabaseError`.
|
||||
config: Configuration to use.
|
||||
read_only: If True, write tools (add_document_*, delete_document) are not registered.
|
||||
"""
|
||||
from haiku.rag.client.scope import DatabaseScope
|
||||
|
||||
config = config if config is not None else get_config()
|
||||
return _covering(
|
||||
DatabaseScope.resolve(config, database_path=db_path), config, read_only
|
||||
)
|
||||
return _covering(DatabaseScope.resolve(config, database_path=db_path), config)
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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:
|
||||
if client is None:
|
||||
client = await stack.enter_async_context(
|
||||
HaikuRAG._covering(scope, config, read_only=read_only)
|
||||
HaikuRAG._covering(scope, config, read_only=True)
|
||||
)
|
||||
return client
|
||||
|
||||
|
|
@ -97,72 +93,6 @@ def _covering(scope: "DatabaseScope", config: AppConfig, read_only: bool) -> Fas
|
|||
|
||||
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()
|
||||
async def search_documents(
|
||||
query: str, limit: int | None = None, include_images: bool = True
|
||||
|
|
|
|||
|
|
@ -31,10 +31,6 @@ def client():
|
|||
@pytest.fixture
|
||||
def app(tmp_path, client, monkeypatch):
|
||||
class StubHaikuRAG:
|
||||
# run_mcp passes db_path positionally; every other caller uses kwargs.
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def _covering(cls, *args, **kwargs):
|
||||
return cls()
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ async def _get_tool(mcp, name):
|
|||
class TestMCPReadTools:
|
||||
@pytest.mark.asyncio
|
||||
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")
|
||||
|
||||
results = await search(query="artificial intelligence")
|
||||
|
|
@ -64,7 +64,7 @@ class TestMCPReadTools:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
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")
|
||||
|
||||
results = await search(query="artificial intelligence", limit=1)
|
||||
|
|
@ -93,7 +93,7 @@ class TestMCPReadTools:
|
|||
)
|
||||
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:
|
||||
result = await client.call_tool(
|
||||
"search_documents", {"query": "artificial intelligence"}
|
||||
|
|
@ -107,7 +107,7 @@ class TestMCPReadTools:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
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")
|
||||
|
||||
# First get the ID via list
|
||||
|
|
@ -122,7 +122,7 @@ class TestMCPReadTools:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
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")
|
||||
|
||||
list_docs = await _get_tool(mcp, "list_documents")
|
||||
|
|
@ -136,7 +136,7 @@ class TestMCPReadTools:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
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")
|
||||
|
||||
result = await get_doc(document_id="nonexistent-id")
|
||||
|
|
@ -144,7 +144,7 @@ class TestMCPReadTools:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
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")
|
||||
|
||||
results = await list_docs()
|
||||
|
|
@ -153,7 +153,7 @@ class TestMCPReadTools:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
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")
|
||||
|
||||
results = await list_docs(limit=1)
|
||||
|
|
@ -161,7 +161,7 @@ class TestMCPReadTools:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
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")
|
||||
|
||||
results = await list_docs(filter="title = 'AI Overview'")
|
||||
|
|
@ -169,66 +169,18 @@ class TestMCPReadTools:
|
|||
assert results[0].title == "AI Overview"
|
||||
|
||||
|
||||
class TestMCPWriteTools:
|
||||
class TestMCPToolSet:
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_tools_registered_when_not_read_only(self, temp_db_path):
|
||||
async with HaikuRAG(temp_db_path, create=True):
|
||||
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
|
||||
async def test_the_server_registers_read_tools_only(self, mcp_db):
|
||||
mcp = create_mcp_server(mcp_db)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_tools_not_registered_when_read_only(self, temp_db_path):
|
||||
async with HaikuRAG(temp_db_path, create=True):
|
||||
pass
|
||||
mcp = create_mcp_server(temp_db_path, read_only=True)
|
||||
tools = await mcp.list_tools()
|
||||
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
|
||||
assert {t.name for t in await mcp.list_tools()} == {
|
||||
"search_documents",
|
||||
"get_document",
|
||||
"list_documents",
|
||||
"ask_question",
|
||||
"analyze",
|
||||
}
|
||||
|
||||
|
||||
class TestMCPImageQuery:
|
||||
|
|
@ -237,7 +189,7 @@ class TestMCPImageQuery:
|
|||
@pytest.mark.asyncio
|
||||
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."""
|
||||
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()}
|
||||
assert "search_documents_by_image" not in names
|
||||
|
||||
|
|
@ -264,7 +216,7 @@ class TestMCPImageQuery:
|
|||
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()}
|
||||
assert "search_documents_by_image" in names
|
||||
|
||||
|
|
@ -296,7 +248,7 @@ class TestMCPImageQuery:
|
|||
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")
|
||||
|
||||
# Not valid base64 (contains non-base64 chars) — the strict decoder
|
||||
|
|
@ -317,7 +269,7 @@ class TestMCPImageInput:
|
|||
return ("answer", [])
|
||||
|
||||
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")
|
||||
|
||||
png = b"fake image bytes"
|
||||
|
|
@ -337,7 +289,7 @@ class TestMCPImageInput:
|
|||
return SimpleNamespace(answer="answer")
|
||||
|
||||
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")
|
||||
|
||||
jpeg = b"fake jpeg bytes"
|
||||
|
|
@ -347,7 +299,7 @@ class TestMCPImageInput:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
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")
|
||||
|
||||
result = await ask(question="q", images_base64=["!!! not base64 !!!"])
|
||||
|
|
@ -362,7 +314,7 @@ class TestMCPImageInput:
|
|||
return ("answer", [])
|
||||
|
||||
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")
|
||||
|
||||
result = await ask(question="q")
|
||||
|
|
@ -370,70 +322,6 @@ class TestMCPImageInput:
|
|||
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:
|
||||
"""Every tool swallows client failures and returns its empty value rather
|
||||
than propagating an exception to the MCP transport."""
|
||||
|
|
@ -442,20 +330,6 @@ class TestMCPToolsDegradeOnError:
|
|||
@pytest.mark.parametrize(
|
||||
"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"}, []),
|
||||
("get_document_by_id", "get_document", {"document_id": "x"}, None),
|
||||
("list_documents", "list_documents", {}, []),
|
||||
|
|
@ -468,14 +342,14 @@ class TestMCPToolsDegradeOnError:
|
|||
raise RuntimeError("client exploded")
|
||||
|
||||
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)
|
||||
|
||||
assert await tool(**kwargs) == expected
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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")
|
||||
|
||||
assert await list_docs(filter="no_such_column = 1") == []
|
||||
|
|
@ -486,7 +360,7 @@ class TestMCPToolsDegradeOnError:
|
|||
raise RuntimeError("sandbox exploded")
|
||||
|
||||
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")
|
||||
|
||||
assert "sandbox exploded" in await analyze(question="q")
|
||||
|
|
@ -509,7 +383,7 @@ class TestMCPToolsDegradeOnError:
|
|||
return ("the answer", [citation])
|
||||
|
||||
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")
|
||||
|
||||
with_cite = await ask(question="q", cite=True)
|
||||
|
|
@ -534,7 +408,7 @@ class TestMCPClientLifetime:
|
|||
|
||||
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")
|
||||
list_docs = await _get_tool(mcp, "list_documents")
|
||||
await search(query="artificial intelligence")
|
||||
|
|
@ -559,7 +433,7 @@ class TestMCPClientLifetime:
|
|||
|
||||
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")
|
||||
|
||||
results = await asyncio.gather(*(list_docs() for _ in range(5)))
|
||||
|
|
@ -567,18 +441,6 @@ class TestMCPClientLifetime:
|
|||
assert opens == 1
|
||||
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
|
||||
async def test_lifespan_opens_and_closes_once(self, mcp_db, monkeypatch):
|
||||
from haiku.rag.store.engine import Store
|
||||
|
|
@ -593,7 +455,7 @@ class TestMCPClientLifetime:
|
|||
|
||||
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() combines provider lifespans only.
|
||||
async with mcp._lifespan_manager():
|
||||
|
|
@ -624,7 +486,7 @@ class TestMCPClientLifetime:
|
|||
)
|
||||
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():
|
||||
search = await _get_tool(mcp, "search_documents")
|
||||
results = await search(query="artificial intelligence")
|
||||
|
|
@ -649,7 +511,7 @@ class TestMCPClientLifetime:
|
|||
)
|
||||
|
||||
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):
|
||||
"""It resolves the same scope, so it reaches the same refusal."""
|
||||
|
|
@ -696,7 +558,7 @@ class TestMCPClientLifetime:
|
|||
async def run_stdio_async(self):
|
||||
return None
|
||||
|
||||
def fake_covering(scope, config, read_only):
|
||||
def fake_covering(scope, config):
|
||||
seen.update(scope=scope, config=config)
|
||||
return _Server()
|
||||
|
||||
|
|
@ -715,7 +577,7 @@ class TestMCPClientLifetime:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
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):
|
||||
async with mcp._lifespan_manager():
|
||||
|
|
@ -737,7 +599,7 @@ class TestMCPClientLifetime:
|
|||
|
||||
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")
|
||||
|
||||
async with mcp._lifespan_manager():
|
||||
|
|
@ -750,23 +612,13 @@ class TestMCPClientLifetime:
|
|||
assert len(results) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_dim_drift_starts_read_only_but_not_writable(self, mcp_db):
|
||||
"""Validation is unchanged: same-dimension identity drift warns in
|
||||
read-only mode and raises in writable mode. The MCP server no longer
|
||||
opts out of it for deletion."""
|
||||
async def test_same_dim_drift_starts(self, mcp_db):
|
||||
"""Same-dimension identity drift warns on a read-only open and raises
|
||||
on a writable one; the server starts, so it opened read-only."""
|
||||
from haiku.rag.config import get_config
|
||||
from haiku.rag.store.exceptions import ConfigMismatchError
|
||||
|
||||
drifted = get_config().model_copy(deep=True)
|
||||
drifted.embeddings.model.name = "a-different-model"
|
||||
|
||||
async with create_mcp_server(
|
||||
mcp_db, config=drifted, read_only=True
|
||||
)._lifespan_manager():
|
||||
async with create_mcp_server(mcp_db, config=drifted)._lifespan_manager():
|
||||
pass
|
||||
|
||||
with pytest.raises(ConfigMismatchError):
|
||||
async with create_mcp_server(
|
||||
mcp_db, config=drifted, read_only=False
|
||||
)._lifespan_manager():
|
||||
pass
|
||||
|
|
|
|||
Loading…
Reference in a new issue