feat(search): support multiple folders in the semantic-search path filter

Extend the ADR-027 Phase 2 path filter from a single path_prefix to a
list of folders. The new normalize_path_prefixes() helper is the single
source of truth for trimming, dropping blanks, and de-duplicating, and
folds the legacy single path_prefix into the list for backward
compatibility.

build_base_filter_conditions() adds one MatchText to the must clause for
a single folder (unchanged shape) and OR-s multiple folders via a nested
Filter(should=[...]) so a file under any selected folder matches while
still AND-ing against the ACL/doc_type/date conditions.

path_prefixes is threaded through every search surface: the
nc_semantic_search MCP tool, the visualization API (JSON body), and the
viz route (CSV query param). The Astrolabe frontend folder picker that
produces these lists ships in a companion astrolabe PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-03 12:51:37 +02:00
co-authored by Claude Opus 4.8
parent b91af923d2
commit de6c4b360d
9 changed files with 269 additions and 50 deletions
+29 -11
View File
@@ -32,7 +32,10 @@ from nextcloud_mcp_server.models.semantic import (
from nextcloud_mcp_server.observability.metrics import (
instrument_tool,
)
from nextcloud_mcp_server.search.access_filter import list_accessible_owners
from nextcloud_mcp_server.search.access_filter import (
list_accessible_owners,
normalize_path_prefixes,
)
from nextcloud_mcp_server.search.bm25_hybrid import BM25HybridSearchAlgorithm
from nextcloud_mcp_server.search.context import get_chunk_with_context
from nextcloud_mcp_server.search.verification import verify_search_results
@@ -88,6 +91,7 @@ def configure_semantic_tools(mcp: FastMCP):
str | None,
Field(
description=(
"Deprecated single-folder filter; prefer path_prefixes. "
"Restrict to files under this folder/path "
"(e.g. '/Projects/Reports'). Matches the file_path of "
"indexed files only, so setting it implicitly limits "
@@ -95,6 +99,18 @@ def configure_semantic_tools(mcp: FastMCP):
),
),
] = None,
path_prefixes: Annotated[
list[str] | None,
Field(
description=(
"Restrict to files under any of these folders/paths "
"(e.g. ['/Projects/Reports', '/Shared/Specs']). Folders are "
"OR-ed together. Matches the file_path of indexed files "
"only, so setting it implicitly limits results to files. "
"None or empty = no path filter."
),
),
] = None,
) -> SemanticSearchResponse:
"""
Search Nextcloud content using BM25 hybrid search with cross-app support.
@@ -127,9 +143,12 @@ def configure_semantic_tools(mcp: FastMCP):
modified_before: Only return documents whose last-modified time is at or before this
instant. Same formats as modified_after. None = no upper bound (default). Must be
>= modified_after when both are supplied.
path_prefix: Restrict to files under this folder/path (e.g. "/Projects/Reports").
Matches the file_path of indexed files only — setting it implicitly limits results
to files. None = no path filter (default).
path_prefix: Deprecated single-folder filter; prefer path_prefixes. Restrict to files
under this folder/path (e.g. "/Projects/Reports"). Folded into path_prefixes.
path_prefixes: Restrict to files under any of these folders/paths (OR-ed), e.g.
["/Projects/Reports", "/Shared/Specs"]. Matches the file_path of indexed files
only — setting it implicitly limits results to files. None/empty = no path filter
(default).
Returns:
SemanticSearchResponse with matching documents ranked by fusion scores.
@@ -200,11 +219,10 @@ def configure_semantic_tools(mcp: FastMCP):
)
)
# Treat a blank/whitespace path_prefix as "no filter" so an empty UI
# field doesn't filter out every result (ADR-027 Phase 2).
path_prefix = path_prefix.strip() if path_prefix else None
if not path_prefix:
path_prefix = None
# Merge the legacy single path_prefix and the path_prefixes list into one
# cleaned list, dropping blank/whitespace entries so an empty UI field
# doesn't filter out every result (ADR-027 Phase 2).
folder_prefixes = normalize_path_prefixes(path_prefix, path_prefixes)
# Expand the caller's identity to every owner whose content they
# have read access to via Nextcloud shares. Lets a user find files
@@ -252,7 +270,7 @@ def configure_semantic_tools(mcp: FastMCP):
accessible_owners=accessible_owners,
modified_after=modified_after_ts,
modified_before=modified_before_ts,
path_prefix=path_prefix,
path_prefixes=folder_prefixes,
)
all_results.extend(unverified_results)
else:
@@ -280,7 +298,7 @@ def configure_semantic_tools(mcp: FastMCP):
accessible_owners=accessible_owners,
modified_after=modified_after_ts,
modified_before=modified_before_ts,
path_prefix=path_prefix,
path_prefixes=folder_prefixes,
)
all_results.extend(unverified_results)