fix: PR #813 review — shared-file context in MCP tool path + viz over-fetch cap
🟡 Important: nc_semantic_search's include_context branch did not forward accessible_owners to get_chunk_with_context, so context expansion for shared files stayed self-only, found nothing in Qdrant, and silently fell back to the plain excerpt. Forward accessible_owners (the per-file file_accessible_by_id gate still enforces access). 🟡 Performance: auth/viz_routes.py's multi-doc_type branch sorted but did not cap the candidate pool before verify-on-read, so N doc_types × limit*2 went into verification (N× the Nextcloud round-trips). Cap to limit*2 after the sort, matching server/semantic.py and the cross-app branch. Also clear the SonarCloud gate (new_duplicated_lines_density 5.1% > 3%) the ACL wiring introduced: extract the duplicated /api/v1 client-resolution + owner-expansion + verify-on-read block from unified_search/vector_search into a shared _search_with_acl helper, define a constant for the repeated "Nextcloud host not configured" literal (S1192), and reword the access_filter move_to_end comment so it isn't misread as commented-out code (S125) while adding the other-owner count to its debug log (review nits). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
8deb48e6fa
commit
350358b802
@@ -11,6 +11,7 @@ All endpoints require OAuth bearer token authentication via UnifiedTokenVerifier
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pymupdf
|
import pymupdf
|
||||||
@@ -45,6 +46,70 @@ from nextcloud_mcp_server.vector.visualization import compute_pca_coordinates
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_NEXTCLOUD_HOST_NOT_CONFIGURED = "Nextcloud host not configured"
|
||||||
|
|
||||||
|
|
||||||
|
async def _search_with_acl(
|
||||||
|
request: Request,
|
||||||
|
user_id: str,
|
||||||
|
execute: Callable[[list[str] | None], Awaitable[list]],
|
||||||
|
) -> list:
|
||||||
|
"""Resolve the caller's Nextcloud client, run ``execute(accessible_owners)``,
|
||||||
|
and verify-on-read — shared by the /api/v1 search endpoints.
|
||||||
|
|
||||||
|
The OAuth bearer only authenticates Astrolabe → MCP Server; MCP Server →
|
||||||
|
Nextcloud uses the provisioned app password. When the caller never
|
||||||
|
provisioned background sync there is no client to expand shares or verify
|
||||||
|
with, so we fall back to self-only, unverified search (the pre-ACL
|
||||||
|
behaviour) rather than 401 — keeping search working for users who haven't
|
||||||
|
opted into background indexing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The Starlette request (carries ``app.state.oauth_context``).
|
||||||
|
user_id: The authenticated caller.
|
||||||
|
execute: Coroutine that runs the search for a given owner scope
|
||||||
|
(``None`` ⇒ self-only).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The result list (verified for provisioned callers).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the Nextcloud host is not configured.
|
||||||
|
"""
|
||||||
|
oauth_ctx = request.app.state.oauth_context
|
||||||
|
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||||
|
if not nextcloud_host:
|
||||||
|
raise ValueError(_NEXTCLOUD_HOST_NOT_CONFIGURED)
|
||||||
|
|
||||||
|
try:
|
||||||
|
nc_client = await get_user_client_basic_auth(user_id, nextcloud_host)
|
||||||
|
except NotProvisionedError:
|
||||||
|
logger.debug("User %s not provisioned; self-only unverified search", user_id)
|
||||||
|
results = await execute(None)
|
||||||
|
else:
|
||||||
|
async with nc_client:
|
||||||
|
# Expand to owners who shared content with the caller (same as the
|
||||||
|
# MCP tool path) so shared documents are searchable.
|
||||||
|
accessible_owners = await list_accessible_owners(nc_client.sharing, user_id)
|
||||||
|
results = await execute(accessible_owners)
|
||||||
|
# Verify-on-read (ADR-019): drop documents the caller can no longer
|
||||||
|
# access (e.g. a revoked share). Eviction runs inline — this
|
||||||
|
# Starlette route has no FastMCP lifespan task group.
|
||||||
|
results, _dropped = await verify_search_results(nc_client, results)
|
||||||
|
|
||||||
|
# Safe to log titles now: provisioned callers passed verify-on-read;
|
||||||
|
# non-provisioned ran self-only (unverified titles are never logged — see
|
||||||
|
# the search algorithms).
|
||||||
|
if results:
|
||||||
|
logger.debug(
|
||||||
|
"Top verified results: %s",
|
||||||
|
", ".join(
|
||||||
|
f"{r.doc_type}_{r.id} (score={r.score:.3f}, title='{r.title}')"
|
||||||
|
for r in results[:5]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
async def unified_search(request: Request) -> JSONResponse:
|
async def unified_search(request: Request) -> JSONResponse:
|
||||||
"""POST /api/v1/search - Search endpoint for Nextcloud Unified Search.
|
"""POST /api/v1/search - Search endpoint for Nextcloud Unified Search.
|
||||||
@@ -192,50 +257,7 @@ async def unified_search(request: Request) -> JSONResponse:
|
|||||||
)
|
)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
# Resolve a Nextcloud client so search is ACL-aware and verify-on-read
|
all_results = await _search_with_acl(request, user_id, _execute)
|
||||||
# can confirm access. The OAuth bearer only authenticates Astrolabe →
|
|
||||||
# MCP Server; MCP Server → Nextcloud uses the provisioned app password.
|
|
||||||
# If the caller never provisioned background sync there is no client to
|
|
||||||
# expand shares or verify with — fall back to self-only, unverified
|
|
||||||
# search (the pre-ACL behaviour) rather than 401, so unified search keeps
|
|
||||||
# working for users who haven't opted into background indexing.
|
|
||||||
oauth_ctx = request.app.state.oauth_context
|
|
||||||
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
|
||||||
if not nextcloud_host:
|
|
||||||
raise ValueError("Nextcloud host not configured")
|
|
||||||
try:
|
|
||||||
nc_client = await get_user_client_basic_auth(user_id, nextcloud_host)
|
|
||||||
except NotProvisionedError:
|
|
||||||
logger.debug(
|
|
||||||
"User %s not provisioned; self-only unverified search", user_id
|
|
||||||
)
|
|
||||||
all_results = await _execute(None)
|
|
||||||
else:
|
|
||||||
async with nc_client:
|
|
||||||
# Expand to owners who shared content with the caller (same as
|
|
||||||
# the MCP tool path) so shared documents are searchable.
|
|
||||||
accessible_owners = await list_accessible_owners(
|
|
||||||
nc_client.sharing, user_id
|
|
||||||
)
|
|
||||||
all_results = await _execute(accessible_owners)
|
|
||||||
# Verify-on-read (ADR-019): drop documents the caller can no
|
|
||||||
# longer access (e.g. a revoked share) before formatting.
|
|
||||||
# Eviction runs inline — this Starlette route has no FastMCP
|
|
||||||
# lifespan task group.
|
|
||||||
all_results, _dropped = await verify_search_results(
|
|
||||||
nc_client, all_results
|
|
||||||
)
|
|
||||||
|
|
||||||
# Safe to log titles now: provisioned callers passed verify-on-read;
|
|
||||||
# non-provisioned ran self-only (unverified titles are never logged).
|
|
||||||
if all_results:
|
|
||||||
logger.debug(
|
|
||||||
"Top verified results: %s",
|
|
||||||
", ".join(
|
|
||||||
f"{r.doc_type}_{r.id} (score={r.score:.3f}, title='{r.title}')"
|
|
||||||
for r in all_results[:5]
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Sort results by score (no deduplication - show all chunks)
|
# Sort results by score (no deduplication - show all chunks)
|
||||||
sorted_results = sorted(all_results, key=lambda r: r.score, reverse=True)
|
sorted_results = sorted(all_results, key=lambda r: r.score, reverse=True)
|
||||||
@@ -440,47 +462,7 @@ async def vector_search(request: Request) -> JSONResponse:
|
|||||||
)
|
)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
# Resolve a Nextcloud client so search is ACL-aware and verify-on-read
|
all_results = await _search_with_acl(request, user_id, _execute)
|
||||||
# can confirm access (same pattern as /api/v1/search). If the caller
|
|
||||||
# never provisioned background sync there is no client to expand shares
|
|
||||||
# or verify with — fall back to self-only, unverified search (pre-ACL
|
|
||||||
# behaviour) rather than 401.
|
|
||||||
oauth_ctx = request.app.state.oauth_context
|
|
||||||
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
|
||||||
if not nextcloud_host:
|
|
||||||
raise ValueError("Nextcloud host not configured")
|
|
||||||
try:
|
|
||||||
nc_client = await get_user_client_basic_auth(user_id, nextcloud_host)
|
|
||||||
except NotProvisionedError:
|
|
||||||
logger.debug(
|
|
||||||
"User %s not provisioned; self-only unverified search", user_id
|
|
||||||
)
|
|
||||||
all_results = await _execute(None)
|
|
||||||
else:
|
|
||||||
async with nc_client:
|
|
||||||
# Expand to owners who shared content with the caller (same as
|
|
||||||
# the MCP tool path) so shared documents are searchable.
|
|
||||||
accessible_owners = await list_accessible_owners(
|
|
||||||
nc_client.sharing, user_id
|
|
||||||
)
|
|
||||||
all_results = await _execute(accessible_owners)
|
|
||||||
# Verify-on-read (ADR-019): drop now-inaccessible docs before
|
|
||||||
# formatting (inline eviction — no FastMCP lifespan task group
|
|
||||||
# on this Starlette route).
|
|
||||||
all_results, _dropped = await verify_search_results(
|
|
||||||
nc_client, all_results
|
|
||||||
)
|
|
||||||
|
|
||||||
# Safe to log titles now: provisioned callers passed verify-on-read;
|
|
||||||
# non-provisioned ran self-only (unverified titles are never logged).
|
|
||||||
if all_results:
|
|
||||||
logger.debug(
|
|
||||||
"Top verified results: %s",
|
|
||||||
", ".join(
|
|
||||||
f"{r.doc_type}_{r.id} (score={r.score:.3f}, title='{r.title}')"
|
|
||||||
for r in all_results[:5]
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Format results for PHP client
|
# Format results for PHP client
|
||||||
formatted_results = []
|
formatted_results = []
|
||||||
@@ -655,7 +637,7 @@ async def get_chunk_context(request: Request) -> JSONResponse:
|
|||||||
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||||
|
|
||||||
if not nextcloud_host:
|
if not nextcloud_host:
|
||||||
raise ValueError("Nextcloud host not configured")
|
raise ValueError(_NEXTCLOUD_HOST_NOT_CONFIGURED)
|
||||||
|
|
||||||
# Use the user's stored app password for Nextcloud calls.
|
# Use the user's stored app password for Nextcloud calls.
|
||||||
# The OAuth bearer is only used to authenticate Astrolabe → MCP Server;
|
# The OAuth bearer is only used to authenticate Astrolabe → MCP Server;
|
||||||
@@ -821,7 +803,7 @@ async def get_pdf_preview(request: Request) -> JSONResponse:
|
|||||||
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||||
|
|
||||||
if not nextcloud_host:
|
if not nextcloud_host:
|
||||||
raise ValueError("Nextcloud host not configured")
|
raise ValueError(_NEXTCLOUD_HOST_NOT_CONFIGURED)
|
||||||
|
|
||||||
# Use the user's stored app password for Nextcloud calls.
|
# Use the user's stored app password for Nextcloud calls.
|
||||||
# The OAuth bearer is only used to authenticate Astrolabe → MCP Server;
|
# The OAuth bearer is only used to authenticate Astrolabe → MCP Server;
|
||||||
|
|||||||
@@ -224,8 +224,13 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
|||||||
accessible_owners=accessible_owners,
|
accessible_owners=accessible_owners,
|
||||||
)
|
)
|
||||||
all_results.extend(unverified_results)
|
all_results.extend(unverified_results)
|
||||||
# Sort by score before verification
|
# Sort by score, then cap to the same limit*2 over-fetch budget
|
||||||
|
# as the cross-app branch and the nc_semantic_search tool path
|
||||||
|
# (server/semantic.py). Without this, N doc_types each fetched
|
||||||
|
# at limit*2 would send N*limit*2 candidates into verify-on-read,
|
||||||
|
# multiplying the Nextcloud round-trip cost (and latency) by N.
|
||||||
all_results.sort(key=lambda r: r.score, reverse=True)
|
all_results.sort(key=lambda r: r.score, reverse=True)
|
||||||
|
all_results = all_results[: limit * 2]
|
||||||
|
|
||||||
# Verify-on-read (ADR-019). Now that accessible_owners is expanded
|
# Verify-on-read (ADR-019). Now that accessible_owners is expanded
|
||||||
# via OCS shares, the result set can include OTHER users' shared
|
# via OCS shares, the result set can include OTHER users' shared
|
||||||
|
|||||||
@@ -124,10 +124,18 @@ async def list_accessible_owners(
|
|||||||
|
|
||||||
result = list(owners)
|
result = list(owners)
|
||||||
_owners_cache[user_id] = (now, result)
|
_owners_cache[user_id] = (now, result)
|
||||||
_owners_cache.move_to_end(user_id) # newest = most-recently-used
|
# Promote to the most-recently-used end. This is a no-op for a brand-new
|
||||||
|
# key (dict insertion already appends) but is needed when re-inserting an
|
||||||
|
# existing key after its TTL expired.
|
||||||
|
_owners_cache.move_to_end(user_id)
|
||||||
while len(_owners_cache) > _OWNERS_CACHE_MAXSIZE:
|
while len(_owners_cache) > _OWNERS_CACHE_MAXSIZE:
|
||||||
_owners_cache.popitem(last=False) # evict least-recently-used
|
_owners_cache.popitem(last=False) # evict the least-recently-used entry
|
||||||
logger.debug("Accessible owners for user %s: %d entries", user_id, len(result))
|
logger.debug(
|
||||||
|
"Accessible owners for user %s: %d entries (%d other owner(s))",
|
||||||
|
user_id,
|
||||||
|
len(result),
|
||||||
|
len(result) - 1,
|
||||||
|
)
|
||||||
return list(result)
|
return list(result)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -339,6 +339,12 @@ def configure_semantic_tools(mcp: FastMCP):
|
|||||||
chunk_index=result.chunk_index,
|
chunk_index=result.chunk_index,
|
||||||
total_chunks=result.total_chunks,
|
total_chunks=result.total_chunks,
|
||||||
context_chars=context_chars,
|
context_chars=context_chars,
|
||||||
|
# Forward the share-expanded owner set so context
|
||||||
|
# expansion works for shared files (the per-file
|
||||||
|
# file_accessible_by_id gate inside still enforces
|
||||||
|
# access). Without this the lookup stays self-only
|
||||||
|
# and silently falls back to the plain excerpt.
|
||||||
|
accessible_owners=accessible_owners,
|
||||||
)
|
)
|
||||||
|
|
||||||
if chunk_context:
|
if chunk_context:
|
||||||
|
|||||||
Reference in New Issue
Block a user