Make MCP failures errors on the wire
FastMCP masks unexpected exceptions and logs the traceback server-side, so paths and provider URLs never cross the transport. Expected failures raise ToolError with a message: unknown document, unknown collection, invalid filter, invalid base64, and agent failures naming only the exception type. No tool returns an empty value or an error string on failure any more. A filter is validated on its own before the read that would use it, with a filtered count on one of the selected databases: that is the query engine rejecting the filter and nothing else, so its message (columns and the statement) can be forwarded, while a ValueError raised later in the read stays masked and no database outside the selection is opened. Refs #599
This commit is contained in:
parent
88cd2b1d6c
commit
2a6d72171d
4 changed files with 271 additions and 154 deletions
|
|
@ -22,6 +22,11 @@
|
||||||
- `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`.
|
||||||
|
- MCP tools raise on failure; an empty result no longer doubles as an error.
|
||||||
|
Unknown document, unknown collection, invalid filter and invalid base64
|
||||||
|
carry a message; `ask_question` and `analyze` failures name the exception
|
||||||
|
type. Anything else is masked (`mask_error_details=True`) and logged
|
||||||
|
server-side.
|
||||||
- `haiku-rag mcp` covers the configured `lancedb.databases` set. `sources` on
|
- `haiku-rag mcp` covers the configured `lancedb.databases` set. `sources` on
|
||||||
`search_documents`, `search_documents_by_image`, `ask_question` and
|
`search_documents`, `search_documents_by_image`, `ask_question` and
|
||||||
`analyze`; `source` on `get_document`; an unknown name is a tool error.
|
`analyze`; `source` on `get_document`; an unknown name is a tool error.
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,15 @@ uri LIKE '%.pdf'
|
||||||
title = 'Q3 report'
|
title = 'Q3 report'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Errors
|
||||||
|
|
||||||
|
A failure is an MCP error, never an empty result. Expected failures carry a
|
||||||
|
message: a document id that matches nothing, a collection the server does not
|
||||||
|
cover, a filter the query engine rejects (with its message), invalid base64,
|
||||||
|
and an `ask_question` or `analyze` failure naming only the exception type.
|
||||||
|
Anything else reaches the client as `Error calling tool 'name'` and its
|
||||||
|
traceback goes to the server log.
|
||||||
|
|
||||||
### Instructions
|
### Instructions
|
||||||
|
|
||||||
The server publishes `instructions` describing the knowledge base: what it
|
The server publishes `instructions` describing the knowledge base: what it
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import AsyncExitStack, asynccontextmanager
|
from contextlib import AsyncExitStack, asynccontextmanager
|
||||||
from importlib import metadata
|
from importlib import metadata
|
||||||
|
|
@ -21,6 +22,8 @@ from haiku.rag.utils import format_citations
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from haiku.rag.client.scope import DatabaseScope
|
from haiku.rag.client.scope import DatabaseScope
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_FILTER_COLUMNS = ", ".join(DocumentMetaRecord.model_fields)
|
_FILTER_COLUMNS = ", ".join(DocumentMetaRecord.model_fields)
|
||||||
|
|
||||||
Filter = Annotated[
|
Filter = Annotated[
|
||||||
|
|
@ -47,7 +50,12 @@ def _read_only(title: str) -> ToolAnnotations:
|
||||||
def _decode_image(image_base64: str) -> bytes:
|
def _decode_image(image_base64: str) -> bytes:
|
||||||
import base64
|
import base64
|
||||||
|
|
||||||
return base64.b64decode(image_base64, validate=True)
|
try:
|
||||||
|
return base64.b64decode(image_base64, validate=True)
|
||||||
|
except ValueError as e:
|
||||||
|
# binascii.Error for characters outside the alphabet or bad padding,
|
||||||
|
# ValueError itself for non-ASCII input.
|
||||||
|
raise ToolError("Invalid base64 image") from e
|
||||||
|
|
||||||
|
|
||||||
def _decode_images(images_base64: list[str] | None) -> list[bytes] | None:
|
def _decode_images(images_base64: list[str] | None) -> list[bytes] | None:
|
||||||
|
|
@ -56,6 +64,28 @@ def _decode_images(images_base64: list[str] | None) -> list[bytes] | None:
|
||||||
return [_decode_image(b64) for b64 in images_base64]
|
return [_decode_image(b64) for b64 in images_base64]
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_filter(
|
||||||
|
rag: HaikuRAG, filter: str | None, sources: list[str] | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Evaluate a filter on its own before the read that would use it.
|
||||||
|
|
||||||
|
A filtered count on one selected database runs the same predicate on the
|
||||||
|
same table and nothing else, so a ValueError here is the query engine
|
||||||
|
rejecting the filter; its message names columns and the statement, never
|
||||||
|
a location. A ValueError raised later in the read stays masked. Only the
|
||||||
|
selection is touched: every database shares the schema, so one suffices.
|
||||||
|
"""
|
||||||
|
if filter is None:
|
||||||
|
return
|
||||||
|
selected = await rag.clients_covering(sources)
|
||||||
|
if not selected:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await selected[0].count_documents(filter=filter)
|
||||||
|
except ValueError as e:
|
||||||
|
raise ToolError(f"Invalid filter {filter!r}: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
def _instructions(scope: "DatabaseScope", config: AppConfig) -> str:
|
def _instructions(scope: "DatabaseScope", config: AppConfig) -> str:
|
||||||
"""What the server is for, naming no tools: the client has every tool's
|
"""What the server is for, naming no tools: the client has every tool's
|
||||||
description from the listing."""
|
description from the listing."""
|
||||||
|
|
@ -136,11 +166,14 @@ def _covering(scope: "DatabaseScope", config: AppConfig) -> FastMCP:
|
||||||
finally:
|
finally:
|
||||||
client = None
|
client = None
|
||||||
|
|
||||||
|
# Masking keeps paths and provider URLs out of an unexpected error's text;
|
||||||
|
# the traceback goes to the server log. A ToolError reaches the client as is.
|
||||||
mcp = FastMCP(
|
mcp = FastMCP(
|
||||||
"haiku-rag",
|
"haiku-rag",
|
||||||
instructions=_instructions(scope, config),
|
instructions=_instructions(scope, config),
|
||||||
version=metadata.version("haiku.rag-slim"),
|
version=metadata.version("haiku.rag-slim"),
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
|
mask_error_details=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool(annotations=_read_only("Search documents"))
|
@mcp.tool(annotations=_read_only("Search documents"))
|
||||||
|
|
@ -167,8 +200,9 @@ def _covering(scope: "DatabaseScope", config: AppConfig) -> FastMCP:
|
||||||
include_images: Attach the bytes of pictures in the results as
|
include_images: Attach the bytes of pictures in the results as
|
||||||
base64 PNG under `image_data`. False for a smaller response.
|
base64 PNG under `image_data`. False for a smaller response.
|
||||||
"""
|
"""
|
||||||
|
rag = await _client()
|
||||||
try:
|
try:
|
||||||
rag = await _client()
|
await _check_filter(rag, filter, sources)
|
||||||
return await rag.search(
|
return await rag.search(
|
||||||
query,
|
query,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
|
|
@ -178,8 +212,6 @@ def _covering(scope: "DatabaseScope", config: AppConfig) -> FastMCP:
|
||||||
)
|
)
|
||||||
except UnknownDatabaseError as e:
|
except UnknownDatabaseError as e:
|
||||||
raise ToolError(str(e)) from e
|
raise ToolError(str(e)) from e
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Image-as-query tool, only registered when the configured embedder
|
# Image-as-query tool, only registered when the configured embedder
|
||||||
# supports image embeddings. Probed at server-build time when no Store is
|
# supports image embeddings. Probed at server-build time when no Store is
|
||||||
|
|
@ -211,9 +243,10 @@ def _covering(scope: "DatabaseScope", config: AppConfig) -> FastMCP:
|
||||||
include_images: Attach the bytes of pictures in the results as
|
include_images: Attach the bytes of pictures in the results as
|
||||||
base64 PNG under `image_data`. False for a smaller response.
|
base64 PNG under `image_data`. False for a smaller response.
|
||||||
"""
|
"""
|
||||||
|
raw = _decode_image(image_base64)
|
||||||
|
rag = await _client()
|
||||||
try:
|
try:
|
||||||
raw = _decode_image(image_base64)
|
await _check_filter(rag, filter, sources)
|
||||||
rag = await _client()
|
|
||||||
return await rag.search(
|
return await rag.search(
|
||||||
raw,
|
raw,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
|
|
@ -223,13 +256,9 @@ def _covering(scope: "DatabaseScope", config: AppConfig) -> FastMCP:
|
||||||
)
|
)
|
||||||
except UnknownDatabaseError as e:
|
except UnknownDatabaseError as e:
|
||||||
raise ToolError(str(e)) from e
|
raise ToolError(str(e)) from e
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
|
|
||||||
@mcp.tool(annotations=_read_only("Get document"))
|
@mcp.tool(annotations=_read_only("Get document"))
|
||||||
async def get_document(
|
async def get_document(document_id: str, source: str | None = None) -> Document:
|
||||||
document_id: str, source: str | None = None
|
|
||||||
) -> Document | None:
|
|
||||||
"""Read one document whole, in reading order.
|
"""Read one document whole, in reading order.
|
||||||
|
|
||||||
Use this after a search when a passage is not enough. Returns the
|
Use this after a search when a passage is not enough. Returns the
|
||||||
|
|
@ -241,13 +270,14 @@ def _covering(scope: "DatabaseScope", config: AppConfig) -> FastMCP:
|
||||||
source: The collection holding it. Without one every collection
|
source: The collection holding it. Without one every collection
|
||||||
is asked.
|
is asked.
|
||||||
"""
|
"""
|
||||||
|
rag = await _client()
|
||||||
try:
|
try:
|
||||||
rag = await _client()
|
document = await rag.get_document_by_id(document_id, source)
|
||||||
return await rag.get_document_by_id(document_id, source)
|
|
||||||
except UnknownDatabaseError as e:
|
except UnknownDatabaseError as e:
|
||||||
raise ToolError(str(e)) from e
|
raise ToolError(str(e)) from e
|
||||||
except Exception:
|
if document is None:
|
||||||
return None
|
raise ToolError(f"No document with id {document_id!r}")
|
||||||
|
return document
|
||||||
|
|
||||||
@mcp.tool(annotations=_read_only("List documents"))
|
@mcp.tool(annotations=_read_only("List documents"))
|
||||||
async def list_documents(
|
async def list_documents(
|
||||||
|
|
@ -265,23 +295,20 @@ def _covering(scope: "DatabaseScope", config: AppConfig) -> FastMCP:
|
||||||
limit: How many documents to return.
|
limit: How many documents to return.
|
||||||
offset: How many documents to skip, for paging.
|
offset: How many documents to skip, for paging.
|
||||||
"""
|
"""
|
||||||
try:
|
rag = await _client()
|
||||||
rag = await _client()
|
await _check_filter(rag, filter)
|
||||||
documents = await rag.list_documents(limit, offset, filter)
|
documents = await rag.list_documents(limit, offset, filter)
|
||||||
|
return [
|
||||||
return [
|
DocumentInfo(
|
||||||
DocumentInfo(
|
id=doc.id,
|
||||||
id=doc.id,
|
title=doc.title or "Untitled",
|
||||||
title=doc.title or "Untitled",
|
uri=doc.uri or "",
|
||||||
uri=doc.uri or "",
|
created=doc.created_at.strftime("%Y-%m-%d"),
|
||||||
created=doc.created_at.strftime("%Y-%m-%d"),
|
source=doc.source,
|
||||||
source=doc.source,
|
metadata=doc.metadata,
|
||||||
metadata=doc.metadata,
|
)
|
||||||
)
|
for doc in documents
|
||||||
for doc in documents
|
]
|
||||||
]
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
|
|
||||||
@mcp.tool(annotations=_read_only("Ask a question"))
|
@mcp.tool(annotations=_read_only("Ask a question"))
|
||||||
async def ask_question(
|
async def ask_question(
|
||||||
|
|
@ -303,19 +330,20 @@ def _covering(scope: "DatabaseScope", config: AppConfig) -> FastMCP:
|
||||||
images_base64: Images to attach to the question, PNG or JPEG
|
images_base64: Images to attach to the question, PNG or JPEG
|
||||||
bytes as base64. Needs a vision-capable model on the server.
|
bytes as base64. Needs a vision-capable model on the server.
|
||||||
"""
|
"""
|
||||||
|
images = _decode_images(images_base64)
|
||||||
|
rag = await _client()
|
||||||
try:
|
try:
|
||||||
images = _decode_images(images_base64)
|
|
||||||
rag = await _client()
|
|
||||||
answer, citations = await rag.ask(question, images=images, sources=sources)
|
answer, citations = await rag.ask(question, images=images, sources=sources)
|
||||||
if cite and citations:
|
|
||||||
answer += "\n\n" + format_citations(
|
|
||||||
citations, include_source=rag.covers_multiple
|
|
||||||
)
|
|
||||||
return answer
|
|
||||||
except UnknownDatabaseError as e:
|
except UnknownDatabaseError as e:
|
||||||
raise ToolError(str(e)) from e
|
raise ToolError(str(e)) from e
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error answering question: {e!s}"
|
logger.exception("ask_question failed")
|
||||||
|
raise ToolError(f"ask_question failed: {type(e).__name__}") from e
|
||||||
|
if cite and citations:
|
||||||
|
answer += "\n\n" + format_citations(
|
||||||
|
citations, include_source=rag.covers_multiple
|
||||||
|
)
|
||||||
|
return answer
|
||||||
|
|
||||||
@mcp.tool(annotations=_read_only("Analyze documents"))
|
@mcp.tool(annotations=_read_only("Analyze documents"))
|
||||||
async def analyze(
|
async def analyze(
|
||||||
|
|
@ -336,16 +364,17 @@ def _covering(scope: "DatabaseScope", config: AppConfig) -> FastMCP:
|
||||||
images_base64: Images to attach to the question, PNG or JPEG
|
images_base64: Images to attach to the question, PNG or JPEG
|
||||||
bytes as base64. Needs a vision-capable model on the server.
|
bytes as base64. Needs a vision-capable model on the server.
|
||||||
"""
|
"""
|
||||||
|
images = _decode_images(images_base64)
|
||||||
|
rag = await _client()
|
||||||
try:
|
try:
|
||||||
images = _decode_images(images_base64)
|
|
||||||
rag = await _client()
|
|
||||||
result = await rag.analyze(
|
result = await rag.analyze(
|
||||||
question, filter=filter, images=images, sources=sources
|
question, filter=filter, images=images, sources=sources
|
||||||
)
|
)
|
||||||
return result.answer
|
|
||||||
except UnknownDatabaseError as e:
|
except UnknownDatabaseError as e:
|
||||||
raise ToolError(str(e)) from e
|
raise ToolError(str(e)) from e
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error running analysis capability: {e!s}"
|
logger.exception("analyze failed")
|
||||||
|
raise ToolError(f"analyze failed: {type(e).__name__}") from e
|
||||||
|
return result.answer
|
||||||
|
|
||||||
return mcp
|
return mcp
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
|
import logging
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from fastmcp.exceptions import ToolError
|
||||||
|
|
||||||
from haiku.rag.client import HaikuRAG
|
from haiku.rag.client import HaikuRAG
|
||||||
from haiku.rag.mcp import _covering as _mcp_covering
|
from haiku.rag.mcp import _covering as _mcp_covering
|
||||||
|
|
@ -87,6 +89,14 @@ async def _get_tool(mcp, name):
|
||||||
return tool.fn
|
return tool.fn
|
||||||
|
|
||||||
|
|
||||||
|
async def _call(mcp, name, **kwargs):
|
||||||
|
"""Call a tool over the wire, returning the result whether or not it errored."""
|
||||||
|
from fastmcp import Client
|
||||||
|
|
||||||
|
async with Client(mcp) as client:
|
||||||
|
return await client.call_tool(name, kwargs, raise_on_error=False)
|
||||||
|
|
||||||
|
|
||||||
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):
|
||||||
|
|
@ -184,14 +194,6 @@ class TestMCPReadTools:
|
||||||
assert "docling_document" not in serialized
|
assert "docling_document" not in serialized
|
||||||
assert "docling_version" not in serialized
|
assert "docling_version" not in serialized
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_document_not_found(self, mcp_db):
|
|
||||||
mcp = create_mcp_server(mcp_db)
|
|
||||||
get_doc = await _get_tool(mcp, "get_document")
|
|
||||||
|
|
||||||
result = await get_doc(document_id="nonexistent-id")
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
@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)
|
mcp = create_mcp_server(mcp_db)
|
||||||
|
|
@ -233,6 +235,36 @@ class TestMCPReadTools:
|
||||||
]
|
]
|
||||||
assert overview["metadata"] == {"author": "Ada"}
|
assert overview["metadata"] == {"author": "Ada"}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ask_question_appends_citations_when_requested(
|
||||||
|
self, mcp_db, monkeypatch
|
||||||
|
):
|
||||||
|
from haiku.rag.store.models.citation import Citation
|
||||||
|
|
||||||
|
citation = Citation(
|
||||||
|
chunk_id="c1",
|
||||||
|
document_id="d1",
|
||||||
|
content="cited text",
|
||||||
|
document_uri="test://ai-overview",
|
||||||
|
document_title="AI Overview",
|
||||||
|
source="alpha",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_ask(self, question, filter=None, images=None, sources=None):
|
||||||
|
return ("the answer", [citation])
|
||||||
|
|
||||||
|
monkeypatch.setattr(HaikuRAG, "ask", fake_ask)
|
||||||
|
mcp = create_mcp_server(mcp_db)
|
||||||
|
ask = await _get_tool(mcp, "ask_question")
|
||||||
|
|
||||||
|
with_cite = await ask(question="q", cite=True)
|
||||||
|
assert with_cite.startswith("the answer")
|
||||||
|
assert "AI Overview" in with_cite
|
||||||
|
# One database: its name adds nothing.
|
||||||
|
assert "alpha" not in with_cite
|
||||||
|
|
||||||
|
assert await ask(question="q", cite=False) == "the answer"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.filterwarnings("ignore:Found propagated trace context:RuntimeWarning")
|
@pytest.mark.filterwarnings("ignore:Found propagated trace context:RuntimeWarning")
|
||||||
class TestMCPDescribesItself:
|
class TestMCPDescribesItself:
|
||||||
|
|
@ -367,14 +399,28 @@ class TestMCPCoversTheConfiguredSet:
|
||||||
async def test_an_unknown_database_is_an_error_not_an_empty_result(
|
async def test_an_unknown_database_is_an_error_not_an_empty_result(
|
||||||
self, two_dbs, multimodal_embedder, tool_name, kwargs
|
self, two_dbs, multimodal_embedder, tool_name, kwargs
|
||||||
):
|
):
|
||||||
from fastmcp.exceptions import ToolError
|
|
||||||
|
|
||||||
mcp = _covering_all(two_dbs)
|
mcp = _covering_all(two_dbs)
|
||||||
tool = await _get_tool(mcp, tool_name)
|
tool = await _get_tool(mcp, tool_name)
|
||||||
|
|
||||||
with pytest.raises(ToolError, match="nope"):
|
with pytest.raises(ToolError, match="nope"):
|
||||||
await tool(**kwargs)
|
await tool(**kwargs)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_filtered_search_touches_only_the_selected_databases(self, two_dbs):
|
||||||
|
"""alpha is gone; a filtered search selecting beta must not notice."""
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
shutil.rmtree(two_dbs.lancedb.databases["alpha"])
|
||||||
|
mcp = _covering_all(two_dbs)
|
||||||
|
search = await _get_tool(mcp, "search_documents")
|
||||||
|
|
||||||
|
results = await search(
|
||||||
|
query="cats", filter="uri LIKE '%beta%'", sources=["beta"]
|
||||||
|
)
|
||||||
|
assert results
|
||||||
|
assert {r.source for r in results} == {"beta"}
|
||||||
|
assert await search(query="cats", filter="uri LIKE '%beta%'", sources=[]) == []
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_the_listing_covers_every_database(self, two_dbs):
|
async def test_the_listing_covers_every_database(self, two_dbs):
|
||||||
mcp = _covering_all(two_dbs)
|
mcp = _covering_all(two_dbs)
|
||||||
|
|
@ -495,13 +541,13 @@ class TestMCPImageQuery:
|
||||||
results = await search_by_image(
|
results = await search_by_image(
|
||||||
image_base64=base64.b64encode(png).decode("ascii"),
|
image_base64=base64.b64encode(png).decode("ascii"),
|
||||||
filter="uri LIKE 'x%'",
|
filter="uri LIKE 'x%'",
|
||||||
sources=["alpha"],
|
sources=[],
|
||||||
)
|
)
|
||||||
|
|
||||||
assert results == []
|
assert results == []
|
||||||
assert seen["query"] == png
|
assert seen["query"] == png
|
||||||
assert seen["filter"] == "uri LIKE 'x%'"
|
assert seen["filter"] == "uri LIKE 'x%'"
|
||||||
assert seen["sources"] == ["alpha"]
|
assert seen["sources"] == []
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_image_query_rejects_characters_outside_the_alphabet(
|
async def test_image_query_rejects_characters_outside_the_alphabet(
|
||||||
|
|
@ -519,36 +565,10 @@ class TestMCPImageQuery:
|
||||||
mcp = create_mcp_server(mcp_db)
|
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")
|
||||||
|
|
||||||
assert await search_by_image(image_base64="AAAA!!!!") == []
|
with pytest.raises(ToolError):
|
||||||
|
await search_by_image(image_base64="AAAA!!!!")
|
||||||
assert not searched
|
assert not searched
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_image_query_returns_empty_on_invalid_base64(
|
|
||||||
self, mcp_db, multimodal_embedder
|
|
||||||
):
|
|
||||||
"""Garbage base64 from the caller is swallowed, returning an empty
|
|
||||||
list rather than crashing the MCP server."""
|
|
||||||
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
|
|
||||||
# in search_documents_by_image rejects it.
|
|
||||||
results = await search_by_image(image_base64="!!! not base64 !!!")
|
|
||||||
assert results == []
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_image_query_returns_empty_when_the_search_raises(
|
|
||||||
self, mcp_db, multimodal_embedder, monkeypatch
|
|
||||||
):
|
|
||||||
async def boom(self, *args, **kw):
|
|
||||||
raise RuntimeError("client exploded")
|
|
||||||
|
|
||||||
monkeypatch.setattr(HaikuRAG, "search", boom)
|
|
||||||
mcp = create_mcp_server(mcp_db)
|
|
||||||
search_by_image = await _get_tool(mcp, "search_documents_by_image")
|
|
||||||
|
|
||||||
assert await search_by_image(image_base64="AAAA") == []
|
|
||||||
|
|
||||||
|
|
||||||
class TestMCPImageInput:
|
class TestMCPImageInput:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -590,14 +610,6 @@ class TestMCPImageInput:
|
||||||
assert result == "answer"
|
assert result == "answer"
|
||||||
assert captured["images"] == [jpeg]
|
assert captured["images"] == [jpeg]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_ask_question_rejects_invalid_base64(self, mcp_db):
|
|
||||||
mcp = create_mcp_server(mcp_db)
|
|
||||||
ask = await _get_tool(mcp, "ask_question")
|
|
||||||
|
|
||||||
result = await ask(question="q", images_base64=["!!! not base64 !!!"])
|
|
||||||
assert "Error" in result
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_ask_question_without_images_passes_none(self, mcp_db, monkeypatch):
|
async def test_ask_question_without_images_passes_none(self, mcp_db, monkeypatch):
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|
@ -615,78 +627,140 @@ class TestMCPImageInput:
|
||||||
assert captured["images"] is None
|
assert captured["images"] is None
|
||||||
|
|
||||||
|
|
||||||
class TestMCPToolsDegradeOnError:
|
@pytest.mark.filterwarnings("ignore:Found propagated trace context:RuntimeWarning")
|
||||||
"""Every tool swallows client failures and returns its empty value rather
|
class TestMCPErrorContract:
|
||||||
than propagating an exception to the MCP transport."""
|
"""A failure is an error on the wire, never an empty result. Expected
|
||||||
|
failures say what went wrong; anything else is masked and logged on the
|
||||||
|
server."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_an_unknown_document_is_an_error(self, mcp_db):
|
||||||
|
result = await _call(
|
||||||
|
create_mcp_server(mcp_db), "get_document", document_id="nonexistent-id"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.is_error
|
||||||
|
assert "nonexistent-id" in result.content[0].text
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"client_method,tool_name,kwargs,expected",
|
"tool_name,kwargs",
|
||||||
[
|
[("search_documents", {"query": "x"}), ("list_documents", {})],
|
||||||
("search", "search_documents", {"query": "x"}, []),
|
|
||||||
("get_document_by_id", "get_document", {"document_id": "x"}, None),
|
|
||||||
("list_documents", "list_documents", {}, []),
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
async def test_tool_returns_empty_value_when_client_raises(
|
async def test_an_invalid_filter_is_an_error_naming_the_filter(
|
||||||
self, mcp_db, monkeypatch, client_method, tool_name, kwargs, expected
|
self, mcp_db, tool_name, kwargs
|
||||||
):
|
):
|
||||||
async def boom(self, *args, **kw):
|
result = await _call(
|
||||||
raise RuntimeError("client exploded")
|
create_mcp_server(mcp_db), tool_name, filter="no_such_column = 1", **kwargs
|
||||||
|
|
||||||
monkeypatch.setattr(HaikuRAG, client_method, boom)
|
|
||||||
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)
|
|
||||||
list_docs = await _get_tool(mcp, "list_documents")
|
|
||||||
|
|
||||||
assert await list_docs(filter="no_such_column = 1") == []
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_analyze_reports_the_error(self, mcp_db, monkeypatch):
|
|
||||||
async def boom(self, question, filter=None, images=None, sources=None):
|
|
||||||
raise RuntimeError("sandbox exploded")
|
|
||||||
|
|
||||||
monkeypatch.setattr(HaikuRAG, "analyze", boom)
|
|
||||||
mcp = create_mcp_server(mcp_db)
|
|
||||||
analyze = await _get_tool(mcp, "analyze")
|
|
||||||
|
|
||||||
assert "sandbox exploded" in await analyze(question="q")
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_ask_question_appends_citations_when_requested(
|
|
||||||
self, mcp_db, monkeypatch
|
|
||||||
):
|
|
||||||
from haiku.rag.store.models.citation import Citation
|
|
||||||
|
|
||||||
citation = Citation(
|
|
||||||
chunk_id="c1",
|
|
||||||
document_id="d1",
|
|
||||||
content="cited text",
|
|
||||||
document_uri="test://ai-overview",
|
|
||||||
document_title="AI Overview",
|
|
||||||
source="alpha",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def fake_ask(self, question, filter=None, images=None, sources=None):
|
assert result.is_error
|
||||||
return ("the answer", [citation])
|
assert "no_such_column = 1" in result.content[0].text
|
||||||
|
|
||||||
monkeypatch.setattr(HaikuRAG, "ask", fake_ask)
|
@pytest.mark.asyncio
|
||||||
mcp = create_mcp_server(mcp_db)
|
@pytest.mark.parametrize("filter", [None, "title = 'AI Overview'"])
|
||||||
ask = await _get_tool(mcp, "ask_question")
|
async def test_a_value_error_from_the_read_is_not_an_invalid_filter(
|
||||||
|
self, mcp_db, monkeypatch, filter
|
||||||
|
):
|
||||||
|
"""Only the filter check translates ValueError; one raised by the read
|
||||||
|
itself, with or without a valid filter, stays masked."""
|
||||||
|
|
||||||
with_cite = await ask(question="q", cite=True)
|
async def boom(self, *args, **kw):
|
||||||
assert with_cite.startswith("the answer")
|
raise ValueError("boom at /secret/path")
|
||||||
assert "AI Overview" in with_cite
|
|
||||||
# One database: its name adds nothing.
|
|
||||||
assert "alpha" not in with_cite
|
|
||||||
|
|
||||||
assert await ask(question="q", cite=False) == "the answer"
|
monkeypatch.setattr(HaikuRAG, "search", boom)
|
||||||
|
result = await _call(
|
||||||
|
create_mcp_server(mcp_db), "search_documents", query="x", filter=filter
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.is_error
|
||||||
|
assert "filter" not in result.content[0].text
|
||||||
|
assert "/secret/path" not in result.content[0].text
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"payload", ["!!! not base64 !!!", "é"], ids=["outside_alphabet", "non_ascii"]
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"tool_name,image_param,many",
|
||||||
|
[
|
||||||
|
("search_documents_by_image", "image_base64", False),
|
||||||
|
("ask_question", "images_base64", True),
|
||||||
|
("analyze", "images_base64", True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_invalid_base64_is_an_error(
|
||||||
|
self, mcp_db, multimodal_embedder, tool_name, image_param, many, payload
|
||||||
|
):
|
||||||
|
kwargs: dict[str, object] = {"question": "q"} if many else {}
|
||||||
|
kwargs[image_param] = [payload] if many else payload
|
||||||
|
|
||||||
|
result = await _call(create_mcp_server(mcp_db), tool_name, **kwargs)
|
||||||
|
|
||||||
|
assert result.is_error
|
||||||
|
assert "base64" in result.content[0].text
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"client_method,tool_name",
|
||||||
|
[("ask", "ask_question"), ("analyze", "analyze")],
|
||||||
|
)
|
||||||
|
async def test_an_agent_failure_names_only_its_type(
|
||||||
|
self, mcp_db, monkeypatch, caplog, client_method, tool_name
|
||||||
|
):
|
||||||
|
async def boom(self, question, filter=None, images=None, sources=None):
|
||||||
|
raise RuntimeError("boom at /secret/path")
|
||||||
|
|
||||||
|
monkeypatch.setattr(HaikuRAG, client_method, boom)
|
||||||
|
with caplog.at_level(logging.ERROR, logger="haiku.rag.mcp"):
|
||||||
|
result = await _call(create_mcp_server(mcp_db), tool_name, question="q")
|
||||||
|
|
||||||
|
assert result.is_error
|
||||||
|
assert "RuntimeError" in result.content[0].text
|
||||||
|
assert "/secret/path" not in result.content[0].text
|
||||||
|
assert any(
|
||||||
|
r.exc_info and "boom at /secret/path" in str(r.exc_info[1])
|
||||||
|
for r in caplog.records
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"client_method,tool_name,kwargs",
|
||||||
|
[
|
||||||
|
("search", "search_documents", {"query": "x"}),
|
||||||
|
("search", "search_documents_by_image", {"image_base64": "AAAA"}),
|
||||||
|
("get_document_by_id", "get_document", {"document_id": "x"}),
|
||||||
|
("list_documents", "list_documents", {}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_an_unexpected_failure_is_masked_and_logged(
|
||||||
|
self,
|
||||||
|
mcp_db,
|
||||||
|
multimodal_embedder,
|
||||||
|
monkeypatch,
|
||||||
|
caplog,
|
||||||
|
client_method,
|
||||||
|
tool_name,
|
||||||
|
kwargs,
|
||||||
|
):
|
||||||
|
async def boom(self, *args, **kw):
|
||||||
|
raise RuntimeError("boom at /secret/path")
|
||||||
|
|
||||||
|
monkeypatch.setattr(HaikuRAG, client_method, boom)
|
||||||
|
# fastmcp's logger does not propagate, so listen to it directly.
|
||||||
|
fastmcp_logger = logging.getLogger("fastmcp")
|
||||||
|
fastmcp_logger.addHandler(caplog.handler)
|
||||||
|
try:
|
||||||
|
result = await _call(create_mcp_server(mcp_db), tool_name, **kwargs)
|
||||||
|
finally:
|
||||||
|
fastmcp_logger.removeHandler(caplog.handler)
|
||||||
|
|
||||||
|
assert result.is_error
|
||||||
|
assert "/secret/path" not in result.content[0].text
|
||||||
|
assert any(
|
||||||
|
r.exc_info and "boom at /secret/path" in str(r.exc_info[1])
|
||||||
|
for r in caplog.records
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestMCPClientLifetime:
|
class TestMCPClientLifetime:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue