feat(search,mcp): workspace-scope search and add get_health MCP tool
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>
This commit is contained in:
@@ -26,15 +26,32 @@ from .fusion import ImageSearchResult, SearchCandidate, reciprocal_rank_fusion
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Workspace scoping clause appended to every search Cypher query.
|
||||
#
|
||||
# A request with workspace_id set returns ONLY that workspace's content.
|
||||
# A request with workspace_id null returns ONLY global content (libraries
|
||||
# with no workspace_id). There is no third mode.
|
||||
_WORKSPACE_SCOPE_CLAUSE = (
|
||||
" AND ($workspace_id IS NULL AND lib.workspace_id IS NULL OR "
|
||||
"lib.workspace_id = $workspace_id)"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchRequest:
|
||||
"""Parameters for a search query."""
|
||||
"""Parameters for a search query.
|
||||
|
||||
Scope is single-mode: a request is either workspace-scoped (workspace_id
|
||||
set) or global (workspace_id is None). There is no parameter combination
|
||||
that returns both workspace and global content in one call.
|
||||
"""
|
||||
|
||||
query: str
|
||||
query_image: Optional[bytes] = None
|
||||
library_uid: Optional[str] = None
|
||||
library_type: Optional[str] = None
|
||||
collection_uid: Optional[str] = None
|
||||
workspace_id: Optional[str] = None
|
||||
search_types: list[str] = field(
|
||||
default_factory=lambda: ["vector", "fulltext", "graph"]
|
||||
)
|
||||
@@ -45,6 +62,18 @@ class SearchRequest:
|
||||
rerank: bool = True
|
||||
include_images: bool = True
|
||||
|
||||
def __post_init__(self):
|
||||
# Normalize empty strings to None so "" doesn't slip through as
|
||||
# truthy at the Cypher boundary.
|
||||
if self.workspace_id == "":
|
||||
self.workspace_id = None
|
||||
if self.library_uid == "":
|
||||
self.library_uid = None
|
||||
if self.library_type == "":
|
||||
self.library_type = None
|
||||
if self.collection_uid == "":
|
||||
self.collection_uid = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchResponse:
|
||||
@@ -243,7 +272,8 @@ class SearchService:
|
||||
top_k = request.vector_top_k
|
||||
|
||||
# Build Cypher with optional filtering
|
||||
cypher = """
|
||||
cypher = (
|
||||
"""
|
||||
CALL db.index.vector.queryNodes('chunk_embedding_index', $top_k, $query_vector)
|
||||
YIELD node AS chunk, score
|
||||
MATCH (item:Item)-[:HAS_CHUNK]->(chunk)
|
||||
@@ -251,13 +281,17 @@ class SearchService:
|
||||
WHERE ($library_uid IS NULL OR lib.uid = $library_uid)
|
||||
AND ($library_type IS NULL OR lib.library_type = $library_type)
|
||||
AND ($collection_uid IS NULL OR col.uid = $collection_uid)
|
||||
"""
|
||||
+ _WORKSPACE_SCOPE_CLAUSE
|
||||
+ """
|
||||
RETURN chunk.uid AS chunk_uid, chunk.text_preview AS text_preview,
|
||||
chunk.chunk_s3_key AS chunk_s3_key, chunk.chunk_index AS chunk_index,
|
||||
item.uid AS item_uid, item.title AS item_title,
|
||||
lib.library_type AS library_type, score
|
||||
ORDER BY score DESC
|
||||
LIMIT $top_k
|
||||
"""
|
||||
"""
|
||||
)
|
||||
|
||||
params = {
|
||||
"top_k": top_k,
|
||||
@@ -265,6 +299,7 @@ class SearchService:
|
||||
"library_uid": request.library_uid,
|
||||
"library_type": request.library_type,
|
||||
"collection_uid": request.collection_uid,
|
||||
"workspace_id": request.workspace_id,
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -348,7 +383,8 @@ class SearchService:
|
||||
candidates: dict[str, SearchCandidate],
|
||||
):
|
||||
"""Search chunk_text_fulltext index and add to candidates dict."""
|
||||
cypher = """
|
||||
cypher = (
|
||||
"""
|
||||
CALL db.index.fulltext.queryNodes('chunk_text_fulltext', $query)
|
||||
YIELD node AS chunk, score
|
||||
MATCH (item:Item)-[:HAS_CHUNK]->(chunk)
|
||||
@@ -356,13 +392,17 @@ class SearchService:
|
||||
WHERE ($library_uid IS NULL OR lib.uid = $library_uid)
|
||||
AND ($library_type IS NULL OR lib.library_type = $library_type)
|
||||
AND ($collection_uid IS NULL OR col.uid = $collection_uid)
|
||||
"""
|
||||
+ _WORKSPACE_SCOPE_CLAUSE
|
||||
+ """
|
||||
RETURN chunk.uid AS chunk_uid, chunk.text_preview AS text_preview,
|
||||
chunk.chunk_s3_key AS chunk_s3_key, chunk.chunk_index AS chunk_index,
|
||||
item.uid AS item_uid, item.title AS item_title,
|
||||
lib.library_type AS library_type, score
|
||||
ORDER BY score DESC
|
||||
LIMIT $top_k
|
||||
"""
|
||||
"""
|
||||
)
|
||||
|
||||
params = {
|
||||
"query": request.query,
|
||||
@@ -370,6 +410,7 @@ class SearchService:
|
||||
"library_uid": request.library_uid,
|
||||
"library_type": request.library_type,
|
||||
"collection_uid": request.collection_uid,
|
||||
"workspace_id": request.workspace_id,
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -402,7 +443,8 @@ class SearchService:
|
||||
candidates: dict[str, SearchCandidate],
|
||||
):
|
||||
"""Search concept_name_fulltext and traverse to chunks."""
|
||||
cypher = """
|
||||
cypher = (
|
||||
"""
|
||||
CALL db.index.fulltext.queryNodes('concept_name_fulltext', $query)
|
||||
YIELD node AS concept, score AS concept_score
|
||||
MATCH (chunk:Chunk)-[:MENTIONS]->(concept)
|
||||
@@ -410,6 +452,9 @@ class SearchService:
|
||||
MATCH (lib:Library)-[:CONTAINS]->(:Collection)-[:CONTAINS]->(item)
|
||||
WHERE ($library_uid IS NULL OR lib.uid = $library_uid)
|
||||
AND ($library_type IS NULL OR lib.library_type = $library_type)
|
||||
"""
|
||||
+ _WORKSPACE_SCOPE_CLAUSE
|
||||
+ """
|
||||
RETURN chunk.uid AS chunk_uid, chunk.text_preview AS text_preview,
|
||||
chunk.chunk_s3_key AS chunk_s3_key, chunk.chunk_index AS chunk_index,
|
||||
item.uid AS item_uid, item.title AS item_title,
|
||||
@@ -417,13 +462,15 @@ class SearchService:
|
||||
concept_score * 0.8 AS score
|
||||
ORDER BY score DESC
|
||||
LIMIT $top_k
|
||||
"""
|
||||
"""
|
||||
)
|
||||
|
||||
params = {
|
||||
"query": request.query,
|
||||
"top_k": top_k,
|
||||
"library_uid": request.library_uid,
|
||||
"library_type": request.library_type,
|
||||
"workspace_id": request.workspace_id,
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -465,7 +512,8 @@ class SearchService:
|
||||
"""
|
||||
start = time.time()
|
||||
|
||||
cypher = """
|
||||
cypher = (
|
||||
"""
|
||||
CALL db.index.fulltext.queryNodes('concept_name_fulltext', $query)
|
||||
YIELD node AS concept, score AS concept_score
|
||||
WITH concept, concept_score
|
||||
@@ -476,6 +524,9 @@ class SearchService:
|
||||
MATCH (lib:Library)-[:CONTAINS]->(:Collection)-[:CONTAINS]->(item)
|
||||
WHERE ($library_uid IS NULL OR lib.uid = $library_uid)
|
||||
AND ($library_type IS NULL OR lib.library_type = $library_type)
|
||||
"""
|
||||
+ _WORKSPACE_SCOPE_CLAUSE
|
||||
+ """
|
||||
WITH chunk, item, lib,
|
||||
max(concept_score) AS score,
|
||||
collect(DISTINCT concept.name)[..5] AS concept_names
|
||||
@@ -486,13 +537,15 @@ class SearchService:
|
||||
score, concept_names
|
||||
ORDER BY score DESC
|
||||
LIMIT $limit
|
||||
"""
|
||||
"""
|
||||
)
|
||||
|
||||
params = {
|
||||
"query": request.query,
|
||||
"limit": request.fulltext_top_k,
|
||||
"library_uid": request.library_uid,
|
||||
"library_type": request.library_type,
|
||||
"workspace_id": request.workspace_id,
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -550,7 +603,8 @@ class SearchService:
|
||||
"""
|
||||
start = time.time()
|
||||
|
||||
cypher = """
|
||||
cypher = (
|
||||
"""
|
||||
CALL db.index.vector.queryNodes('image_embedding_index', $top_k, $query_vector)
|
||||
YIELD node AS emb_node, score
|
||||
MATCH (img:Image)-[:HAS_EMBEDDING]->(emb_node)
|
||||
@@ -558,19 +612,24 @@ class SearchService:
|
||||
MATCH (lib:Library)-[:CONTAINS]->(:Collection)-[:CONTAINS]->(item)
|
||||
WHERE ($library_uid IS NULL OR lib.uid = $library_uid)
|
||||
AND ($library_type IS NULL OR lib.library_type = $library_type)
|
||||
"""
|
||||
+ _WORKSPACE_SCOPE_CLAUSE
|
||||
+ """
|
||||
RETURN img.uid AS image_uid, img.image_type AS image_type,
|
||||
img.description AS description, img.s3_key AS s3_key,
|
||||
item.uid AS item_uid, item.title AS item_title,
|
||||
score
|
||||
ORDER BY score DESC
|
||||
LIMIT 10
|
||||
"""
|
||||
"""
|
||||
)
|
||||
|
||||
params = {
|
||||
"top_k": 10,
|
||||
"query_vector": query_vector,
|
||||
"library_uid": request.library_uid,
|
||||
"library_type": request.library_type,
|
||||
"workspace_id": request.workspace_id,
|
||||
}
|
||||
|
||||
try:
|
||||
|
||||
71
mnemosyne/library/tests/test_search_scoping.py
Normal file
71
mnemosyne/library/tests/test_search_scoping.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
Tests for workspace scoping in SearchRequest and the Cypher scope clause.
|
||||
|
||||
These exercise the dataclass-level normalization and the construction
|
||||
of Cypher parameter dicts. The actual Cypher execution against Neo4j
|
||||
is validated by the manual end-to-end test plan.
|
||||
"""
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from library.services.search import _WORKSPACE_SCOPE_CLAUSE, SearchRequest
|
||||
|
||||
|
||||
class SearchRequestScopingTests(TestCase):
|
||||
"""SearchRequest workspace_id behavior."""
|
||||
|
||||
def test_default_workspace_id_is_none(self):
|
||||
req = SearchRequest(query="hello")
|
||||
self.assertIsNone(req.workspace_id)
|
||||
|
||||
def test_explicit_workspace_id_preserved(self):
|
||||
req = SearchRequest(query="hello", workspace_id="ws_abc")
|
||||
self.assertEqual(req.workspace_id, "ws_abc")
|
||||
|
||||
def test_empty_string_workspace_id_normalized_to_none(self):
|
||||
"""Empty strings must NOT slip through as a truthy filter at the Cypher boundary."""
|
||||
req = SearchRequest(query="hello", workspace_id="")
|
||||
self.assertIsNone(req.workspace_id)
|
||||
|
||||
def test_empty_string_library_uid_normalized_to_none(self):
|
||||
req = SearchRequest(query="hello", library_uid="")
|
||||
self.assertIsNone(req.library_uid)
|
||||
|
||||
def test_empty_string_library_type_normalized_to_none(self):
|
||||
req = SearchRequest(query="hello", library_type="")
|
||||
self.assertIsNone(req.library_type)
|
||||
|
||||
def test_empty_string_collection_uid_normalized_to_none(self):
|
||||
req = SearchRequest(query="hello", collection_uid="")
|
||||
self.assertIsNone(req.collection_uid)
|
||||
|
||||
|
||||
class WorkspaceScopeClauseTests(TestCase):
|
||||
"""Sanity checks on the Cypher snippet itself.
|
||||
|
||||
The clause must produce two distinct, non-overlapping result sets:
|
||||
1. workspace_id IS NULL → only global libraries (lib.workspace_id IS NULL)
|
||||
2. workspace_id = X → only libraries with workspace_id = X
|
||||
|
||||
A "leaks both" bug would be a Cypher OR that fails to bracket properly.
|
||||
Verifying the literal string here is a cheap regression guard against
|
||||
refactors that accidentally change the operator precedence.
|
||||
"""
|
||||
|
||||
def test_clause_references_lib_workspace_id(self):
|
||||
self.assertIn("lib.workspace_id", _WORKSPACE_SCOPE_CLAUSE)
|
||||
|
||||
def test_clause_references_workspace_id_param(self):
|
||||
self.assertIn("$workspace_id", _WORKSPACE_SCOPE_CLAUSE)
|
||||
|
||||
def test_clause_handles_both_modes(self):
|
||||
"""Both 'IS NULL' and '=' branches must be present."""
|
||||
self.assertIn("IS NULL", _WORKSPACE_SCOPE_CLAUSE)
|
||||
self.assertIn("=", _WORKSPACE_SCOPE_CLAUSE)
|
||||
|
||||
def test_clause_starts_with_AND_so_it_appends_safely(self):
|
||||
"""The clause is appended to existing WHERE filters."""
|
||||
self.assertTrue(
|
||||
_WORKSPACE_SCOPE_CLAUSE.lstrip().startswith("AND"),
|
||||
f"Clause must start with AND: {_WORKSPACE_SCOPE_CLAUSE!r}",
|
||||
)
|
||||
Reference in New Issue
Block a user