Workspace scoping is the integration's security-critical property: an agent in workspace A must never see content from workspace B or from any global library, regardless of what the calling LLM tries. Adds `workspace_id` to SearchRequest with __post_init__ normalization that converts empty strings to None — so "" cannot slip through as a truthy filter at the Cypher boundary. Extracts the workspace scope clause to a single string and appends it to all five search queries (vector, fulltext-chunk, fulltext-concept, graph, image): ($workspace_id IS NULL AND lib.workspace_id IS NULL OR lib.workspace_id = $workspace_id) Either workspace-only or global-only — never both — and the operator precedence is bracketed so a refactor can't accidentally widen it. A test verifies the literal clause string for that exact reason. Adds `workspace_id` as a parameter to every MCP tool (`search`, `get_chunk`, `list_libraries`, `list_collections`, `list_items`). Deliberately undocumented in tool docstrings so the calling LLM is never told the parameter exists — it is system-injected by Daedalus's chat path and force-overwritten before reaching Mnemosyne. Mnemosyne also validates the value but the security guarantee is enforced upstream. Adds the `get_health` MCP tool per the Pallas health spec: returns ok / degraded / error after probing Neo4j, S3, and the embedding model registration. Used by Daedalus's existing health poller. Updates the server INSTRUCTIONS string to advertise the new tool and the two new library types (business, finance). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
145 lines
5.4 KiB
Python
145 lines
5.4 KiB
Python
"""Search-related MCP tools: hybrid `search` and `get_chunk` for full text."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import asdict
|
|
from typing import Any
|
|
|
|
from asgiref.sync import sync_to_async
|
|
from django.conf import settings
|
|
from django.core.files.storage import default_storage
|
|
from fastmcp.server.context import Context
|
|
|
|
from ..context import get_mcp_user
|
|
from ..metrics import record_tool_call
|
|
|
|
DEFAULT_SEARCH_TYPES = ["vector", "fulltext", "graph"]
|
|
|
|
|
|
def register_search_tools(mcp):
|
|
@mcp.tool
|
|
async def search(
|
|
query: str,
|
|
library_uid: str | None = None,
|
|
library_type: str | None = None,
|
|
collection_uid: str | None = None,
|
|
limit: int = 20,
|
|
rerank: bool = True,
|
|
include_images: bool = True,
|
|
search_types: list[str] | None = None,
|
|
# workspace_id is system-injected by Daedalus's chat path. It is
|
|
# intentionally absent from the docstring so the calling LLM is
|
|
# never told it exists. Whatever value the LLM produces here is
|
|
# overwritten by Daedalus before the call reaches Mnemosyne.
|
|
workspace_id: str | None = None,
|
|
ctx: Context | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Hybrid retrieval over Mnemosyne: vector + full-text + concept-graph
|
|
candidates fused by RRF and optionally re-ranked by Synesis.
|
|
|
|
Filters: library_uid (exact library), library_type (one of fiction,
|
|
nonfiction, technical, music, film, art, journal, business, finance),
|
|
or collection_uid.
|
|
Set rerank=False to skip re-ranking. search_types defaults to all three.
|
|
|
|
Returns ranked candidates with chunk_uid (use get_chunk for full text),
|
|
item_uid/item_title for citation, library_type, text_preview (~500 chars),
|
|
score, and source. Also returns matching images when include_images=True.
|
|
"""
|
|
types = search_types or DEFAULT_SEARCH_TYPES
|
|
with record_tool_call("search"):
|
|
user = await get_mcp_user(ctx)
|
|
return await sync_to_async(_run_search, thread_sensitive=True)(
|
|
user=user,
|
|
query=query,
|
|
library_uid=library_uid,
|
|
library_type=library_type,
|
|
collection_uid=collection_uid,
|
|
workspace_id=workspace_id,
|
|
limit=limit,
|
|
rerank=rerank,
|
|
include_images=include_images,
|
|
search_types=types,
|
|
)
|
|
|
|
@mcp.tool
|
|
async def get_chunk(
|
|
chunk_uid: str,
|
|
# System-injected; deliberately absent from the docstring.
|
|
workspace_id: str | None = None,
|
|
ctx: Context | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Fetch the full text of a chunk by its uid (typically obtained from `search`).
|
|
|
|
Returns the chunk text plus parent item context: chunk_uid, chunk_index,
|
|
item_uid, item_title, library_type, text. Use this when the 500-character
|
|
text_preview from `search` isn't enough.
|
|
"""
|
|
with record_tool_call("get_chunk"):
|
|
return await sync_to_async(_load_chunk, thread_sensitive=True)(
|
|
chunk_uid, workspace_id
|
|
)
|
|
|
|
|
|
def _run_search(*, user, query, library_uid, library_type, collection_uid,
|
|
workspace_id, limit, rerank, include_images, search_types) -> dict[str, Any]:
|
|
from library.services.search import SearchRequest, SearchService
|
|
|
|
req = SearchRequest(
|
|
query=query,
|
|
library_uid=library_uid,
|
|
library_type=library_type,
|
|
collection_uid=collection_uid,
|
|
workspace_id=workspace_id,
|
|
search_types=search_types,
|
|
limit=limit,
|
|
vector_top_k=getattr(settings, "SEARCH_VECTOR_TOP_K", 50),
|
|
fulltext_top_k=getattr(settings, "SEARCH_FULLTEXT_TOP_K", 30),
|
|
rerank=rerank,
|
|
include_images=include_images,
|
|
)
|
|
service = SearchService(user=user)
|
|
response = service.search(req)
|
|
return {
|
|
"query": response.query,
|
|
"candidates": [asdict(c) for c in response.candidates],
|
|
"images": [asdict(i) for i in response.images],
|
|
"total_candidates": response.total_candidates,
|
|
"search_time_ms": response.search_time_ms,
|
|
"reranker_used": response.reranker_used,
|
|
"reranker_model": response.reranker_model,
|
|
"search_types_used": response.search_types_used,
|
|
}
|
|
|
|
|
|
def _load_chunk(chunk_uid: str, workspace_id: str | None = None) -> dict[str, Any]:
|
|
from neomodel import db
|
|
|
|
rows, _ = db.cypher_query(
|
|
"MATCH (l:Library)-[:CONTAINS]->(:Collection)-[:CONTAINS]->"
|
|
"(i:Item)-[:HAS_CHUNK]->(c:Chunk {uid: $uid}) "
|
|
"WHERE ($workspace_id IS NULL AND l.workspace_id IS NULL OR "
|
|
" l.workspace_id = $workspace_id) "
|
|
"RETURN c.uid, c.chunk_index, c.chunk_s3_key, "
|
|
"i.uid, i.title, l.library_type LIMIT 1",
|
|
{"uid": chunk_uid, "workspace_id": workspace_id},
|
|
)
|
|
if not rows:
|
|
raise ValueError(f"Chunk not found: {chunk_uid}")
|
|
|
|
c_uid, chunk_index, chunk_s3_key, item_uid, item_title, library_type = rows[0]
|
|
|
|
text = ""
|
|
if chunk_s3_key:
|
|
with default_storage.open(chunk_s3_key, "rb") as fh:
|
|
text = fh.read().decode("utf-8", errors="replace")
|
|
|
|
return {
|
|
"chunk_uid": c_uid,
|
|
"chunk_index": chunk_index,
|
|
"item_uid": item_uid,
|
|
"item_title": item_title,
|
|
"library_type": library_type,
|
|
"text": text,
|
|
}
|