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:
2026-04-29 06:27:32 -04:00
parent f2af28d96d
commit 5527cf6bdb
8 changed files with 360 additions and 38 deletions

View File

@@ -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:

View 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}",
)