From 331abdd00a6123a6613e2e8089062540f9c58098 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Sun, 22 Mar 2026 13:18:49 +0100 Subject: [PATCH 1/3] fix: expose public status endpoints in all modes and enable vector sync (#637) Make /api/v1/status and /api/v1/vector-sync/status available in all non-Smithery deployment modes so Astrolabe can show server status even in single-user BasicAuth mode. Previously these were only mounted when OAuth or multi-user BasicAuth with offline access was enabled. - Split management API routes into public (Tier 1) and authenticated (Tier 2+) - Enable semantic search with in-memory Qdrant for single-user docker service - Update astrolabe submodule with admin settings fix Co-Authored-By: Claude Opus 4.6 (1M context) --- docker-compose.yml | 4 ++-- nextcloud_mcp_server/app.py | 20 ++++++++++++++------ third_party/astrolabe | 2 +- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 52cc8206..a10b970c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -90,7 +90,7 @@ services: - NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080 # Semantic search configuration (ADR-007, ADR-021) - #- ENABLE_SEMANTIC_SEARCH=true + - ENABLE_SEMANTIC_SEARCH=true - VECTOR_SYNC_SCAN_INTERVAL=60 - VECTOR_SYNC_PROCESSOR_WORKERS=1 @@ -100,7 +100,7 @@ services: # 1. Network mode: Set QDRANT_URL=http://qdrant:6333 (requires qdrant service) # 2. In-memory mode: Set QDRANT_LOCATION=:memory: (default if nothing set) # 3. Persistent local: Set QDRANT_LOCATION=/app/data/qdrant (stored in mcp-data volume) - #- QDRANT_LOCATION=/app/data/qdrant # In-memory mode used if not set + - QDRANT_LOCATION=":memory:" #- QDRANT_URL=http://qdrant:6333 # Uncomment for network mode #- QDRANT_API_KEY=${QDRANT_API_KEY:-my_secret_api_key} # Only for network mode diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index 8a8523ca..5d602eaf 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -2187,11 +2187,9 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = logger.info("Test webhook endpoint enabled: /webhooks/nextcloud") # Add management API endpoints for Nextcloud PHP app - # Available in: OAuth modes OR multi-user BasicAuth with offline access (for Astrolabe integration) - enable_management_apis = oauth_enabled or ( - settings.enable_multi_user_basic_auth and settings.enable_offline_access - ) - if enable_management_apis: + # Tier 1: Public endpoints (no auth required) - available in all non-Smithery modes + # These let Astrolabe show basic server status even in single-user BasicAuth mode + if deployment_mode != DeploymentMode.SMITHERY_STATELESS: routes.append(Route("/api/v1/status", get_server_status, methods=["GET"])) routes.append( Route( @@ -2200,6 +2198,16 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = methods=["GET"], ) ) + logger.info( + "Public management API endpoints enabled: /api/v1/status, /api/v1/vector-sync/status" + ) + + # Tier 2+: Authenticated management endpoints (OAuth required) + # Available in: OAuth modes OR multi-user BasicAuth with offline access + enable_authenticated_management_apis = oauth_enabled or ( + settings.enable_multi_user_basic_auth and settings.enable_offline_access + ) + if enable_authenticated_management_apis: routes.append( Route( "/api/v1/users/{user_id}/session", @@ -2270,7 +2278,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = ) routes.append(Route("/api/v1/scopes", list_supported_scopes, methods=["GET"])) logger.info( - "Management API endpoints enabled: /api/v1/status, /api/v1/vector-sync/status, " + "Authenticated management API endpoints enabled: " "/api/v1/users/{user_id}/session, /api/v1/users/{user_id}/revoke, " "/api/v1/users/{user_id}/app-password, /api/v1/users/{user_id}/access, " "/api/v1/users/{user_id}/scopes, /api/v1/scopes, " diff --git a/third_party/astrolabe b/third_party/astrolabe index af53f1c0..41d4a409 160000 --- a/third_party/astrolabe +++ b/third_party/astrolabe @@ -1 +1 @@ -Subproject commit af53f1c02fbdb6db07c20e8f068069d994e14030 +Subproject commit 41d4a409c526f3db173885636b637c0f050932da From 322e92276eb2d80c2e6cb24a7a154257ec96b30a Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Sun, 22 Mar 2026 16:59:36 +0100 Subject: [PATCH 2/3] fix: reduce vector sync scan interval to 5s for single-user service The test_semantic_search_answer_successful_sampling test creates a note and waits 30s for indexing, but the scanner only ran every 60s. Aligning with the CI overlay's 5s interval ensures new notes are indexed in time. Co-Authored-By: Claude Opus 4.6 (1M context) --- docker-compose.yml | 2 +- third_party/astrolabe | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index a10b970c..3f6af113 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -91,7 +91,7 @@ services: # Semantic search configuration (ADR-007, ADR-021) - ENABLE_SEMANTIC_SEARCH=true - - VECTOR_SYNC_SCAN_INTERVAL=60 + - VECTOR_SYNC_SCAN_INTERVAL=5 - VECTOR_SYNC_PROCESSOR_WORKERS=1 #- LOG_FORMAT=json diff --git a/third_party/astrolabe b/third_party/astrolabe index 41d4a409..7e747a10 160000 --- a/third_party/astrolabe +++ b/third_party/astrolabe @@ -1 +1 @@ -Subproject commit 41d4a409c526f3db173885636b637c0f050932da +Subproject commit 7e747a1070632ed038fc09ccf0c59e941102ea26 From 96839662bc304d7db76541bd7fe77e04b64f56ca Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Sun, 22 Mar 2026 18:10:45 +0100 Subject: [PATCH 3/3] fix: increase vector sync wait timeout to prevent sampling test timeouts in CI Extract reusable wait_for_vector_sync() helper with 90s max_wait (up from 30s) to handle slow single-worker processing in CI. Increase processor workers to 2 for the mcp service to parallelize note indexing. Co-Authored-By: Claude Opus 4.6 (1M context) --- docker-compose.yml | 2 +- tests/integration/test_sampling.py | 140 ++++++++++++----------------- third_party/astrolabe | 2 +- 3 files changed, 57 insertions(+), 87 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 3f6af113..a1830ba0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -92,7 +92,7 @@ services: # Semantic search configuration (ADR-007, ADR-021) - ENABLE_SEMANTIC_SEARCH=true - VECTOR_SYNC_SCAN_INTERVAL=5 - - VECTOR_SYNC_PROCESSOR_WORKERS=1 + - VECTOR_SYNC_PROCESSOR_WORKERS=2 #- LOG_FORMAT=json diff --git a/tests/integration/test_sampling.py b/tests/integration/test_sampling.py index fed46c2e..07850d1e 100644 --- a/tests/integration/test_sampling.py +++ b/tests/integration/test_sampling.py @@ -23,6 +23,56 @@ from mcp.types import CreateMessageResult, TextContent pytestmark = pytest.mark.integration +async def wait_for_vector_sync( + nc_mcp_client, + *, + initial_indexed_count: int | None = None, + max_wait: int = 90, + wait_interval: int = 1, +) -> dict: + """Wait for vector sync to complete, returning final status. + + Args: + nc_mcp_client: MCP client to poll status with. + initial_indexed_count: If set, wait until indexed_count exceeds this + value and pending_count reaches 0. Otherwise wait for idle with + no pending work. + max_wait: Maximum seconds to wait before failing. + wait_interval: Seconds between status polls. + + Returns: + The last status dict from nc_get_vector_sync_status. + """ + waited = 0 + status_data: dict = {} + while waited < max_wait: + sync_status = await nc_mcp_client.call_tool( + "nc_get_vector_sync_status", arguments={} + ) + status_data = json.loads(sync_status.content[0].text) + + if initial_indexed_count is not None: + # Wait for new document(s) to be indexed + if ( + status_data["indexed_count"] > initial_indexed_count + and status_data["pending_count"] == 0 + ): + break + else: + # Wait for all pending work to complete + if status_data["status"] == "idle" and status_data["pending_count"] == 0: + break + + await anyio.sleep(wait_interval) + waited += wait_interval + + assert waited < max_wait, ( + f"Vector sync did not complete within {max_wait} seconds. " + f"Last status: {status_data}" + ) + return status_data + + async def require_vector_sync_tools(nc_mcp_client): """Skip test if vector sync tools are not available.""" tools = await nc_mcp_client.list_tools() @@ -93,37 +143,8 @@ Avoid blocking operations in async code.""", print(f"Created note ID: {_note['id']}") # Wait for vector indexing to complete - max_wait = 30 # Maximum 30 seconds - wait_interval = 1 # Check every 1 second - waited = 0 - - while waited < max_wait: - sync_status = await nc_mcp_client.call_tool( - "nc_get_vector_sync_status", arguments={} - ) - status_data = json.loads(sync_status.content[0].text) - - print( - f"Sync status at {waited}s: indexed={status_data['indexed_count']}, pending={status_data['pending_count']}, status={status_data['status']}" - ) - - # Check if indexed count increased (new note was indexed) - if ( - status_data["indexed_count"] > initial_indexed_count - and status_data["pending_count"] == 0 - ): - # Sync complete and new document indexed - print( - f"✓ Sync complete: {status_data['indexed_count']} documents indexed (was {initial_indexed_count})" - ) - break - - await anyio.sleep(wait_interval) - waited += wait_interval - - # Verify sync completed - assert waited < max_wait, ( - f"Vector sync did not complete within {max_wait} seconds. Last status: {status_data}" + status_data = await wait_for_vector_sync( + nc_mcp_client, initial_indexed_count=initial_indexed_count ) assert status_data["indexed_count"] > initial_indexed_count, ( f"New note was not indexed (count stayed at {initial_indexed_count})" @@ -247,24 +268,7 @@ async def test_semantic_search_answer_with_limit(nc_mcp_client, temporary_note_f ) # Wait for vector indexing to complete - - max_wait = 30 - wait_interval = 1 - waited = 0 - - while waited < max_wait: - sync_status = await nc_mcp_client.call_tool( - "nc_get_vector_sync_status", arguments={} - ) - status_data = json.loads(sync_status.content[0].text) - - if status_data["status"] == "idle" and status_data["pending_count"] == 0: - break - - await anyio.sleep(wait_interval) - waited += wait_interval - - assert waited < max_wait, f"Vector sync did not complete within {max_wait} seconds" + await wait_for_vector_sync(nc_mcp_client) call_result = await nc_mcp_client.call_tool( "nc_semantic_search_answer", @@ -305,24 +309,7 @@ async def test_semantic_search_answer_score_threshold( ) # Wait for vector indexing to complete - - max_wait = 30 - wait_interval = 1 - waited = 0 - - while waited < max_wait: - sync_status = await nc_mcp_client.call_tool( - "nc_get_vector_sync_status", arguments={} - ) - status_data = json.loads(sync_status.content[0].text) - - if status_data["status"] == "idle" and status_data["pending_count"] == 0: - break - - await anyio.sleep(wait_interval) - waited += wait_interval - - assert waited < max_wait, f"Vector sync did not complete within {max_wait} seconds" + await wait_for_vector_sync(nc_mcp_client) # Query with exact match call_result = await nc_mcp_client.call_tool( @@ -369,24 +356,7 @@ async def test_semantic_search_answer_max_tokens(nc_mcp_client, temporary_note_f ) # Wait for vector indexing to complete - - max_wait = 30 - wait_interval = 1 - waited = 0 - - while waited < max_wait: - sync_status = await nc_mcp_client.call_tool( - "nc_get_vector_sync_status", arguments={} - ) - status_data = json.loads(sync_status.content[0].text) - - if status_data["status"] == "idle" and status_data["pending_count"] == 0: - break - - await anyio.sleep(wait_interval) - waited += wait_interval - - assert waited < max_wait, f"Vector sync did not complete within {max_wait} seconds" + await wait_for_vector_sync(nc_mcp_client) call_result = await nc_mcp_client.call_tool( "nc_semantic_search_answer", diff --git a/third_party/astrolabe b/third_party/astrolabe index 7e747a10..d245ded7 160000 --- a/third_party/astrolabe +++ b/third_party/astrolabe @@ -1 +1 @@ -Subproject commit 7e747a1070632ed038fc09ccf0c59e941102ea26 +Subproject commit d245ded7f8dc57f9add06f5d7d5ee90c19d38bc3