From ce69a8c989b9de78482df760eeb039761dc8f9a4 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Fri, 4 Sep 2026 10:27:32 +0300 Subject: [PATCH] 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 --- CHANGELOG.md | 6 + README.md | 2 +- docker/Dockerfile | 2 +- docker/Dockerfile.slim | 2 +- docs/cli.md | 3 - docs/configuration/storage.md | 2 +- docs/mcp.md | 29 +--- examples/docker/docker-compose.yml | 1 - haiku_rag_slim/haiku/rag/app.py | 2 +- haiku_rag_slim/haiku/rag/mcp.py | 78 +--------- tests/test_app.py | 4 - tests/test_mcp.py | 232 ++++++----------------------- 12 files changed, 61 insertions(+), 302 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8834d6de..012c59c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 3829c502..9016e322 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docker/Dockerfile b/docker/Dockerfile index 51e4f336..fa5713d1 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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"] diff --git a/docker/Dockerfile.slim b/docker/Dockerfile.slim index 529cb6c0..72844915 100644 --- a/docker/Dockerfile.slim +++ b/docker/Dockerfile.slim @@ -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"] diff --git a/docs/cli.md b/docs/cli.md index f9baf5d6..d1e083e5 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 diff --git a/docs/configuration/storage.md b/docs/configuration/storage.md index fd35edb3..baddfbe5 100644 --- a/docs/configuration/storage.md +++ b/docs/configuration/storage.md @@ -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. diff --git a/docs/mcp.md b/docs/mcp.md index 06403bb3..c3862d1a 100644 --- a/docs/mcp.md +++ b/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) diff --git a/examples/docker/docker-compose.yml b/examples/docker/docker-compose.yml index a889bf7d..45f068ac 100644 --- a/examples/docker/docker-compose.yml +++ b/examples/docker/docker-compose.yml @@ -103,7 +103,6 @@ services: "haiku-rag", "--config", "/app/haiku.rag.yaml", - "--read-only", "mcp", "--host", "0.0.0.0", diff --git a/haiku_rag_slim/haiku/rag/app.py b/haiku_rag_slim/haiku/rag/app.py index 0c71ec47..8bc934ae 100644 --- a/haiku_rag_slim/haiku/rag/app.py +++ b/haiku_rag_slim/haiku/rag/app.py @@ -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() diff --git a/haiku_rag_slim/haiku/rag/mcp.py b/haiku_rag_slim/haiku/rag/mcp.py index f5f808a3..b9142a6f 100644 --- a/haiku_rag_slim/haiku/rag/mcp.py +++ b/haiku_rag_slim/haiku/rag/mcp.py @@ -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 diff --git a/tests/test_app.py b/tests/test_app.py index ebe43f9b..cf7104da 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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() diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 7b465b45..910f2a2e 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -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