Compare commits

...

11 Commits

Author SHA1 Message Date
Chris Coutinho a26a470af6 fix(deck): Always preserve fields in update_card for partial updates
The Deck PUT API is a full replacement, not a partial update.
Previously, title and description were conditionally sent, causing:
- 400 errors when title not provided (it's required)
- Description being cleared when not explicitly set

Now all required fields (title, type, owner) and description are
always included in the payload using current card values when not
explicitly provided. This matches the existing pattern for type/owner.

Also simplified owner extraction since DeckCard.validate_owner
already ensures it's always a string.

Fixes #452

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 23:30:01 -06:00
Chris Coutinho 71ace47197 test: Define expected partial update behavior for DeckClient.update_card
Refactor tests to assert what SHOULD happen (partial updates preserve
unchanged fields) rather than documenting current buggy behavior.

Tests will fail until fix is implemented in client or upstream.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 22:28:23 -06:00
Chris Coutinho 30d3d9f0cf test: Add integration tests documenting DeckClient.update_card bugs
Tests document current behavior of update_card method:
- Updating without title fails (400) - title required but conditionally sent
- Updating with title clears description - PUT is full replacement

Related: #452

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 11:52:57 -06:00
Chris Coutinho 48a4182ef9 fix(astrolabe): Fix revoke access button HTTP method mismatch
The "Revoke Access" button in Astrolabe personal settings was failing
with "Unable to connect to server" error in multi-user basic auth mode.

Root cause: The JavaScript sends a POST request but the route was
configured to accept DELETE. Changed the route to:
- Use POST method (matching the JavaScript fetch call)
- Use /api/v1/background-sync/credentials/revoke path (avoiding
  conflict with storeAppPassword which uses POST on the base URL)

Added integration test that verifies the complete revoke flow:
enable background sync → click revoke → verify credentials deleted.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 22:49:53 -06:00
Chris Coutinho 13dd709fc2 bump: version 0.56.1 → 0.56.2 2025-12-29 12:18:18 -06:00
github-actions[bot] dd66d4bbbc bump: version 0.60.1 → 0.60.2 2025-12-29 18:15:01 +00:00
Chris Coutinho 663e66af81 fix(oauth): Enable browser OAuth routes for Management API in hybrid mode
The /oauth/login route was returning 404 in multi-user BasicAuth mode with
offline access enabled. This was because browser OAuth routes were gated
by `oauth_enabled` (only True for MCP OAuth modes), not by
`oauth_provisioning_available` which correctly includes hybrid mode.

The Management API (admin UI, webhook management) requires OAuth
authentication regardless of how MCP tools authenticate. These are
independent security concerns:
- MCP Tools: BasicAuth (waiting for upstream Nextcloud OAuth patches)
- Management API: OAuth (for admin UI, webhook management, vector sync)

Changes:
- Gate browser OAuth routes by oauth_provisioning_available instead of
  oauth_enabled
- Add follow_redirects=True to OIDC discovery HTTP clients

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 12:14:26 -06:00
Chris Coutinho 9c17bbfe9c bump: version 0.56.0 → 0.56.1 2025-12-26 10:33:20 -06:00
github-actions[bot] 052db2cf56 bump: version 0.60.0 → 0.60.1 2025-12-26 16:05:51 +00:00
Chris Coutinho 056414752e fix(mcp): Move all imports to the top of modules 2025-12-26 10:05:27 -06:00
github-actions[bot] b841407f07 bump: version 0.6.0 → 0.7.0 2025-12-26 15:17:32 +00:00
55 changed files with 624 additions and 187 deletions
+12
View File
@@ -5,6 +5,18 @@ All notable changes to the Nextcloud MCP Server will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [PEP 440](https://peps.python.org/pep-0440/). and this project adheres to [PEP 440](https://peps.python.org/pep-0440/).
## v0.60.2 (2025-12-29)
### Fix
- **oauth**: Enable browser OAuth routes for Management API in hybrid mode
## v0.60.1 (2025-12-26)
### Fix
- **mcp**: Move all imports to the top of modules
## v0.60.0 (2025-12-26) ## v0.60.0 (2025-12-26)
### Feat ### Feat
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.commitizen] [tool.commitizen]
name = "cz_conventional_commits" name = "cz_conventional_commits"
version = "0.56.0" version = "0.56.2"
tag_format = "nextcloud-mcp-server-$version" tag_format = "nextcloud-mcp-server-$version"
version_scheme = "semver" version_scheme = "semver"
update_changelog_on_bump = true update_changelog_on_bump = true
+12
View File
@@ -14,6 +14,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Configurable resource limits - Configurable resource limits
- Grafana dashboard annotations - Grafana dashboard annotations
## nextcloud-mcp-server-0.56.2 (2025-12-29)
### Fix
- **oauth**: Enable browser OAuth routes for Management API in hybrid mode
## nextcloud-mcp-server-0.56.1 (2025-12-26)
### Fix
- **mcp**: Move all imports to the top of modules
## nextcloud-mcp-server-0.56.0 (2025-12-26) ## nextcloud-mcp-server-0.56.0 (2025-12-26)
### Feat ### Feat
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: nextcloud-mcp-server name: nextcloud-mcp-server
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
type: application type: application
version: 0.56.0 version: 0.56.2
appVersion: "0.60.0" appVersion: "0.60.2"
keywords: keywords:
- nextcloud - nextcloud
- mcp - mcp
+3 -1
View File
@@ -8,6 +8,8 @@ services:
command: --transaction-isolation=READ-COMMITTED command: --transaction-isolation=READ-COMMITTED
volumes: volumes:
- db:/var/lib/mysql - db:/var/lib/mysql
ports:
- 127.0.0.1:3306:3306
environment: environment:
- MYSQL_ROOT_PASSWORD=password - MYSQL_ROOT_PASSWORD=password
- MYSQL_PASSWORD=password - MYSQL_PASSWORD=password
@@ -24,7 +26,7 @@ services:
image: docker.io/library/nextcloud:32.0.3@sha256:53231a9fb9233af2c15bfe70fc03ebe639fd53243fa42a9369884b1e0008deae image: docker.io/library/nextcloud:32.0.3@sha256:53231a9fb9233af2c15bfe70fc03ebe639fd53243fa42a9369884b1e0008deae
restart: always restart: always
ports: ports:
- 0.0.0.0:8080:80 - 127.0.0.1:8080:80
depends_on: depends_on:
- redis - redis
- db - db
+1 -8
View File
@@ -15,6 +15,7 @@ import time
from importlib.metadata import version from importlib.metadata import version
from typing import Any from typing import Any
import httpx
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
@@ -530,8 +531,6 @@ async def get_installed_apps(request: Request) -> JSONResponse:
) )
try: try:
import httpx
# Get Bearer token from request # Get Bearer token from request
token = extract_bearer_token(request) token = extract_bearer_token(request)
if not token: if not token:
@@ -602,8 +601,6 @@ async def list_webhooks(request: Request) -> JSONResponse:
) )
try: try:
import httpx
from nextcloud_mcp_server.client.webhooks import WebhooksClient from nextcloud_mcp_server.client.webhooks import WebhooksClient
# Get Bearer token from request # Get Bearer token from request
@@ -669,8 +666,6 @@ async def create_webhook(request: Request) -> JSONResponse:
) )
try: try:
import httpx
from nextcloud_mcp_server.client.webhooks import WebhooksClient from nextcloud_mcp_server.client.webhooks import WebhooksClient
# Parse request body # Parse request body
@@ -747,8 +742,6 @@ async def delete_webhook(request: Request) -> JSONResponse:
) )
try: try:
import httpx
from nextcloud_mcp_server.client.webhooks import WebhooksClient from nextcloud_mcp_server.client.webhooks import WebhooksClient
# Get webhook_id from path parameter # Get webhook_id from path parameter
+19 -22
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import base64
import json
import logging import logging
import os import os
import time import time
@@ -11,13 +13,13 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional, cast from typing import TYPE_CHECKING, Optional, cast
from urllib.parse import urlparse from urllib.parse import urlparse
import anyio
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
if TYPE_CHECKING: if TYPE_CHECKING:
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
import anyio
import click import click
import httpx import httpx
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
@@ -384,8 +386,6 @@ class BasicAuthMiddleware:
if auth_header.startswith(b"Basic "): if auth_header.startswith(b"Basic "):
try: try:
import base64
# Decode base64(username:password) # Decode base64(username:password)
encoded = auth_header[6:] # Skip "Basic " encoded = auth_header[6:] # Skip "Basic "
decoded = base64.b64decode(encoded).decode("utf-8") decoded = base64.b64decode(encoded).decode("utf-8")
@@ -690,7 +690,7 @@ async def setup_oauth_config():
logger.info(f"Performing OIDC discovery: {discovery_url}") logger.info(f"Performing OIDC discovery: {discovery_url}")
# Perform OIDC discovery # Perform OIDC discovery
async with httpx.AsyncClient() as client: async with httpx.AsyncClient(follow_redirects=True) as client:
response = await client.get(discovery_url) response = await client.get(discovery_url)
response.raise_for_status() response.raise_for_status()
discovery = response.json() discovery = response.json()
@@ -994,7 +994,9 @@ async def setup_oauth_config_for_multi_user_basic(
# Perform OIDC discovery # Perform OIDC discovery
try: try:
async with httpx.AsyncClient(timeout=30.0) as http_client: async with httpx.AsyncClient(
timeout=30.0, follow_redirects=True
) as http_client:
response = await http_client.get(discovery_url) response = await http_client.get(discovery_url)
response.raise_for_status() response.raise_for_status()
discovery = response.json() discovery = response.json()
@@ -1200,8 +1202,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
"OAuth credentials not configured - attempting Dynamic Client Registration..." "OAuth credentials not configured - attempting Dynamic Client Registration..."
) )
import anyio
async def setup_multi_user_basic_dcr(): async def setup_multi_user_basic_dcr():
"""Setup DCR for multi-user BasicAuth background operations.""" """Setup DCR for multi-user BasicAuth background operations."""
# Construct registration endpoint directly from nextcloud_host # Construct registration endpoint directly from nextcloud_host
@@ -1288,7 +1288,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
if mode in (AuthMode.OAUTH_SINGLE_AUDIENCE, AuthMode.OAUTH_TOKEN_EXCHANGE): if mode in (AuthMode.OAUTH_SINGLE_AUDIENCE, AuthMode.OAUTH_TOKEN_EXCHANGE):
logger.info("Configuring MCP server for OAuth mode") logger.info("Configuring MCP server for OAuth mode")
# Asynchronously get the OAuth configuration # Asynchronously get the OAuth configuration
import anyio
( (
nextcloud_host, nextcloud_host,
@@ -1626,7 +1625,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# Start background vector sync tasks (ADR-007) # Start background vector sync tasks (ADR-007)
# Scanner runs at server-level (once), not per-session # Scanner runs at server-level (once), not per-session
import anyio as anyio_module
# Re-use settings from outer scope (already validated) # Re-use settings from outer scope (already validated)
# Note: enable_offline_access_for_sync, encryption_key, and refresh_token_storage # Note: enable_offline_access_for_sync, encryption_key, and refresh_token_storage
@@ -1666,11 +1664,11 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
) from e ) from e
# Initialize shared state # Initialize shared state
send_stream, receive_stream = anyio_module.create_memory_object_stream( send_stream, receive_stream = anyio.create_memory_object_stream(
max_buffer_size=settings.vector_sync_queue_max_size max_buffer_size=settings.vector_sync_queue_max_size
) )
shutdown_event = anyio_module.Event() shutdown_event = anyio.Event()
scanner_wake_event = anyio_module.Event() scanner_wake_event = anyio.Event()
# Store in app state for access from routes (ADR-007) # Store in app state for access from routes (ADR-007)
app.state.document_send_stream = send_stream app.state.document_send_stream = send_stream
@@ -1697,7 +1695,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
break break
# Start background tasks using anyio TaskGroup # Start background tasks using anyio TaskGroup
async with anyio_module.create_task_group() as tg: async with anyio.create_task_group() as tg:
# Start scanner task # Start scanner task
await tg.start( await tg.start(
scanner_task, scanner_task,
@@ -1828,11 +1826,11 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
) from e ) from e
# Initialize shared state # Initialize shared state
send_stream, receive_stream = anyio_module.create_memory_object_stream( send_stream, receive_stream = anyio.create_memory_object_stream(
max_buffer_size=settings.vector_sync_queue_max_size max_buffer_size=settings.vector_sync_queue_max_size
) )
shutdown_event = anyio_module.Event() shutdown_event = anyio.Event()
scanner_wake_event = anyio_module.Event() scanner_wake_event = anyio.Event()
# User state tracking for user manager # User state tracking for user manager
user_states: dict = {} user_states: dict = {}
@@ -1869,7 +1867,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
use_basic_auth = not oauth_enabled use_basic_auth = not oauth_enabled
# Start background tasks using anyio TaskGroup # Start background tasks using anyio TaskGroup
async with anyio_module.create_task_group() as tg: async with anyio.create_task_group() as tg:
# Start user manager task (supervises per-user scanners) # Start user manager task (supervises per-user scanners)
await tg.start( await tg.start(
user_manager_task, user_manager_task,
@@ -2076,7 +2074,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
This is a temporary endpoint for testing webhook schemas and payloads. This is a temporary endpoint for testing webhook schemas and payloads.
It logs the full payload and returns 200 OK immediately. It logs the full payload and returns 200 OK immediately.
""" """
import json
try: try:
payload = await request.json() payload = await request.json()
@@ -2313,8 +2310,10 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
routes.append(Route("/oauth/authorize", oauth_authorize, methods=["GET"])) routes.append(Route("/oauth/authorize", oauth_authorize, methods=["GET"]))
logger.info("OAuth login routes enabled: /oauth/authorize (Flow 1)") logger.info("OAuth login routes enabled: /oauth/authorize (Flow 1)")
# Add browser OAuth login routes (OAuth mode only) # Add browser OAuth login routes for Management API access
if oauth_enabled: # Available in OAuth modes AND multi-user BasicAuth with offline access
# (hybrid mode). Separate from MCP tool auth - Management API uses OAuth
if oauth_provisioning_available:
from nextcloud_mcp_server.auth.browser_oauth_routes import ( from nextcloud_mcp_server.auth.browser_oauth_routes import (
oauth_login, oauth_login,
oauth_login_callback, oauth_login_callback,
@@ -2467,8 +2466,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# Starlette caches the body internally, so it's safe to read here # Starlette caches the body internally, so it's safe to read here
body = await request.body() body = await request.body()
try: try:
import json
data = json.loads(body) data = json.loads(body)
# Check if this is an initialize request # Check if this is an initialize request
if data.get("method") == "initialize": if data.get("method") == "initialize":
@@ -8,6 +8,7 @@ import hashlib
import logging import logging
import os import os
import secrets import secrets
import time
from base64 import urlsafe_b64encode from base64 import urlsafe_b64encode
from urllib.parse import urlencode from urllib.parse import urlencode
@@ -381,8 +382,6 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
refresh_expires_in = token_data.get("refresh_expires_in") refresh_expires_in = token_data.get("refresh_expires_in")
refresh_expires_at = None refresh_expires_at = None
if refresh_expires_in: if refresh_expires_in:
import time
refresh_expires_at = int(time.time()) + refresh_expires_in refresh_expires_at = int(time.time()) + refresh_expires_in
logger.info( logger.info(
f"Refresh token expires in {refresh_expires_in}s (at timestamp {refresh_expires_at})" f"Refresh token expires in {refresh_expires_in}s (at timestamp {refresh_expires_at})"
+1 -1
View File
@@ -8,6 +8,7 @@ Handles OAuth flows with Keycloak as the identity provider, including:
- Integration with RefreshTokenStorage - Integration with RefreshTokenStorage
""" """
import base64
import hashlib import hashlib
import logging import logging
import os import os
@@ -155,7 +156,6 @@ class KeycloakOAuthClient:
Returns: Returns:
Tuple of (code_verifier, code_challenge) Tuple of (code_verifier, code_challenge)
""" """
import base64
# Generate code verifier (43-128 characters) # Generate code verifier (43-128 characters)
code_verifier = secrets.token_urlsafe(32) code_verifier = secrets.token_urlsafe(32)
+1 -2
View File
@@ -23,6 +23,7 @@ import hashlib
import logging import logging
import os import os
import secrets import secrets
import time
from base64 import urlsafe_b64encode from base64 import urlsafe_b64encode
from urllib.parse import urlencode from urllib.parse import urlencode
@@ -521,8 +522,6 @@ async def oauth_callback_nextcloud(request: Request):
refresh_expires_in = token_data.get("refresh_expires_in") refresh_expires_in = token_data.get("refresh_expires_in")
refresh_expires_at = None refresh_expires_at = None
if refresh_expires_in: if refresh_expires_in:
import time
refresh_expires_at = int(time.time()) + refresh_expires_in refresh_expires_at = int(time.time()) + refresh_expires_in
logger.info(f" refresh_expires_in: {refresh_expires_in}s") logger.info(f" refresh_expires_in: {refresh_expires_in}s")
logger.info(f" refresh_expires_at: {refresh_expires_at}") logger.info(f" refresh_expires_at: {refresh_expires_at}")
@@ -9,6 +9,7 @@ import functools
import logging import logging
from typing import Callable from typing import Callable
import jwt
from mcp.server.fastmcp import Context from mcp.server.fastmcp import Context
from mcp.shared.exceptions import McpError from mcp.shared.exceptions import McpError
from mcp.types import ErrorData from mcp.types import ErrorData
@@ -78,8 +79,6 @@ def require_provisioning(func: Callable) -> Callable:
user_id = None user_id = None
if hasattr(ctx, "authorization") and ctx.authorization: if hasattr(ctx, "authorization") and ctx.authorization:
try: try:
import jwt
token = ctx.authorization.token token = ctx.authorization.token
payload = jwt.decode(token, options={"verify_signature": False}) payload = jwt.decode(token, options={"verify_signature": False})
user_id = payload.get("sub") user_id = payload.get("sub")
@@ -163,8 +162,6 @@ def require_provisioning_or_suggest(func: Callable) -> Callable:
# Get user_id from authorization token # Get user_id from authorization token
user_id = None user_id = None
if hasattr(ctx, "authorization") and ctx.authorization: if hasattr(ctx, "authorization") and ctx.authorization:
import jwt
token = ctx.authorization.token token = ctx.authorization.token
payload = jwt.decode(token, options={"verify_signature": False}) payload = jwt.decode(token, options={"verify_signature": False})
user_id = payload.get("sub") user_id = payload.get("sub")
+1 -1
View File
@@ -28,6 +28,7 @@ Sensitive data (tokens, secrets) is encrypted at rest using Fernet symmetric enc
import json import json
import logging import logging
import os import os
import socket
import time import time
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
@@ -830,7 +831,6 @@ class RefreshTokenStorage:
resource_id: Resource identifier resource_id: Resource identifier
auth_method: Authentication method used auth_method: Authentication method used
""" """
import socket
hostname = socket.gethostname() hostname = socket.gethostname()
timestamp = int(time.time()) timestamp = int(time.time())
+1 -2
View File
@@ -9,6 +9,7 @@ For OAuth mode: Requires browser-based OAuth login to establish session.
import logging import logging
import os import os
import traceback
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -385,8 +386,6 @@ async def _get_user_info(request: Request) -> dict[str, Any]:
return user_context return user_context
except Exception as e: except Exception as e:
import traceback
logger.error(f"Error retrieving user info: {e}") logger.error(f"Error retrieving user info: {e}")
logger.error(f"Traceback: {traceback.format_exc()}") logger.error(f"Traceback: {traceback.format_exc()}")
return { return {
+1 -2
View File
@@ -15,6 +15,7 @@ import logging
import time import time
from pathlib import Path from pathlib import Path
import anyio
import numpy as np import numpy as np
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from starlette.authentication import requires from starlette.authentication import requires
@@ -396,8 +397,6 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
coords = pca.fit_transform(vectors) coords = pca.fit_transform(vectors)
return coords, pca return coords, pca
import anyio
with trace_operation( with trace_operation(
"vector_viz.pca_compute", "vector_viz.pca_compute",
attributes={ attributes={
+15 -20
View File
@@ -285,28 +285,23 @@ class DeckClient(BaseNextcloudClient):
archived: Optional[bool] = None, archived: Optional[bool] = None,
done: Optional[str] = None, done: Optional[str] = None,
) -> None: ) -> None:
# First, get the current card to use existing values for required fields # Deck PUT API is a full replacement - all required fields must be sent.
# Fetch current card to preserve values for fields not being updated.
current_card = await self.get_card(board_id, stack_id, card_id) current_card = await self.get_card(board_id, stack_id, card_id)
json_data = {} # Build payload with required fields always included
if title is not None: json_data = {
json_data["title"] = title # Title is required by the API
if description is not None: "title": title if title is not None else current_card.title,
json_data["description"] = description # Type is required by the API
# Type is required by the API, use provided or keep current "type": type if type is not None else current_card.type,
json_data["type"] = type if type is not None else current_card.type # Owner is required by the API (model validator ensures it's a string)
# Owner is required by the API, use provided or keep current "owner": owner if owner is not None else current_card.owner,
json_data["owner"] = ( # Description must be sent to preserve it (PUT clears omitted fields)
owner "description": description
if owner is not None if description is not None
else ( else (current_card.description or ""),
current_card.owner }
if isinstance(current_card.owner, str)
else current_card.owner.uid
if hasattr(current_card.owner, "uid")
else current_card.owner.primaryKey
)
)
if order is not None: if order is not None:
json_data["order"] = order json_data["order"] = order
if duedate is not None: if duedate is not None:
+1 -1
View File
@@ -1,6 +1,7 @@
import logging import logging
import logging.config import logging.config
import os import os
import socket
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
from typing import Any, Optional from typing import Any, Optional
@@ -337,7 +338,6 @@ class Settings:
Returns: Returns:
Collection name string Collection name string
""" """
import socket
# Use explicit override if user configured non-default value # Use explicit override if user configured non-default value
if self.qdrant_collection != "nextcloud_content": if self.qdrant_collection != "nextcloud_content":
+1 -2
View File
@@ -9,6 +9,7 @@ See ADR-020 for detailed architecture and deployment mode documentation.
""" """
import logging import logging
import os
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
@@ -240,8 +241,6 @@ def detect_auth_mode(settings: Settings) -> AuthMode:
Raises: Raises:
ValueError: If explicit deployment_mode is invalid or conflicts with detected mode ValueError: If explicit deployment_mode is invalid or conflicts with detected mode
""" """
import logging
import os
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -6,6 +6,8 @@ import tempfile
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import Any, Optional from typing import Any, Optional
import anyio
# NOTE: Do NOT call pymupdf.layout.activate() here! # NOTE: Do NOT call pymupdf.layout.activate() here!
# It changes the behavior of pymupdf4llm.to_markdown() when page_chunks=True, # It changes the behavior of pymupdf4llm.to_markdown() when page_chunks=True,
# causing it to return a string instead of a list[dict]. # causing it to return a string instead of a list[dict].
@@ -95,7 +97,6 @@ class PyMuPDFProcessor(DocumentProcessor):
Raises: Raises:
ProcessorError: If PDF processing fails ProcessorError: If PDF processing fails
""" """
import anyio
try: try:
if progress_callback: if progress_callback:
@@ -3,6 +3,7 @@
import logging import logging
from typing import Any from typing import Any
import anyio
from fastembed import SparseTextEmbedding from fastembed import SparseTextEmbedding
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -67,7 +68,6 @@ class BM25SparseEmbeddingProvider:
Returns: Returns:
Dictionary with 'indices' and 'values' keys for Qdrant sparse vector Dictionary with 'indices' and 'values' keys for Qdrant sparse vector
""" """
import anyio
# Run CPU-bound BM25 encoding in thread pool # Run CPU-bound BM25 encoding in thread pool
return await anyio.to_thread.run_sync(lambda: self.encode(text)) # type: ignore[attr-defined] return await anyio.to_thread.run_sync(lambda: self.encode(text)) # type: ignore[attr-defined]
@@ -82,7 +82,6 @@ class BM25SparseEmbeddingProvider:
Returns: Returns:
List of dictionaries with 'indices' and 'values' for each text List of dictionaries with 'indices' and 'values' for each text
""" """
import anyio
# Run CPU-bound BM25 encoding in thread pool to avoid blocking event loop # Run CPU-bound BM25 encoding in thread pool to avoid blocking event loop
sparse_embeddings = await anyio.to_thread.run_sync( # type: ignore[attr-defined] sparse_embeddings = await anyio.to_thread.run_sync( # type: ignore[attr-defined]
+1 -1
View File
@@ -6,6 +6,7 @@ provides CLI integration.
""" """
import logging import logging
import sqlite3
from pathlib import Path from pathlib import Path
from alembic.config import Config from alembic.config import Config
@@ -98,7 +99,6 @@ def get_current_revision(database_path: str | Path | None = None) -> str | None:
Returns: Returns:
Current revision ID or None if not versioned Current revision ID or None if not versioned
""" """
import sqlite3
if database_path is None: if database_path is None:
database_path = "/app/data/tokens.db" database_path = "/app/data/tokens.db"
@@ -14,7 +14,9 @@ and resource usage. Metrics are organized by category:
- External Dependency Health Metrics - External Dependency Health Metrics
""" """
import functools
import logging import logging
import time
from prometheus_client import ( from prometheus_client import (
Counter, Counter,
@@ -423,8 +425,6 @@ def instrument_tool(func):
Returns: Returns:
Wrapped function with metrics and tracing instrumentation Wrapped function with metrics and tracing instrumentation
""" """
import functools
import time
from nextcloud_mcp_server.observability.tracing import trace_operation from nextcloud_mcp_server.observability.tracing import trace_operation
+7 -7
View File
@@ -1,9 +1,16 @@
"""Base interfaces and data structures for search algorithms.""" """Base interfaces and data structures for search algorithms."""
import logging
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Protocol, runtime_checkable from typing import Any, Protocol, runtime_checkable
from qdrant_client.models import FieldCondition, Filter, MatchValue
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
@runtime_checkable @runtime_checkable
class NextcloudClientProtocol(Protocol): class NextcloudClientProtocol(Protocol):
@@ -78,13 +85,6 @@ async def get_indexed_doc_types(user_id: str) -> set[str]:
>>> if "note" in types: >>> if "note" in types:
... # Search notes ... # Search notes
""" """
import logging
from qdrant_client.models import FieldCondition, Filter, MatchValue
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
settings = get_settings() settings = get_settings()
+3 -2
View File
@@ -7,6 +7,9 @@ position markers for better visualization and understanding of search results.
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
import pymupdf
import pymupdf4llm
from nextcloud_mcp_server.client import NextcloudClient from nextcloud_mcp_server.client import NextcloudClient
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -549,8 +552,6 @@ async def _fetch_document_text(
# Extract text from PDF using PyMuPDF # Extract text from PDF using PyMuPDF
# IMPORTANT: Use pymupdf4llm.to_markdown() to match indexing extraction # IMPORTANT: Use pymupdf4llm.to_markdown() to match indexing extraction
# This ensures character offsets align between indexed chunks and retrieval # This ensures character offsets align between indexed chunks and retrieval
import pymupdf
import pymupdf4llm
logger.debug(f"Extracting text from PDF: {file_path}") logger.debug(f"Extracting text from PDF: {file_path}")
pdf_doc = pymupdf.open(stream=file_content, filetype="pdf") pdf_doc = pymupdf.open(stream=file_content, filetype="pdf")
@@ -10,6 +10,9 @@ varies between indexing and rendering.
import logging import logging
import re import re
import shutil
import tempfile
from pathlib import Path
from typing import Optional from typing import Optional
import pymupdf import pymupdf
@@ -77,8 +80,6 @@ class PDFHighlighter:
Tuple of (full_text, page_boundaries) where page_boundaries is a list of: Tuple of (full_text, page_boundaries) where page_boundaries is a list of:
{"page": 1, "start_offset": 0, "end_offset": 1234} {"page": 1, "start_offset": 0, "end_offset": 1234}
""" """
import tempfile
from pathlib import Path
page_boundaries = [] page_boundaries = []
text_parts = [] text_parts = []
@@ -110,7 +111,6 @@ class PDFHighlighter:
full_text = "".join(text_parts) full_text = "".join(text_parts)
# Clean up temp directory and extracted images # Clean up temp directory and extracted images
import shutil
try: try:
shutil.rmtree(temp_dir) shutil.rmtree(temp_dir)
@@ -590,8 +590,6 @@ class PDFHighlighter:
Returns: Returns:
Tuple of (png_bytes, page_number, highlight_count) or None if failed Tuple of (png_bytes, page_number, highlight_count) or None if failed
""" """
import tempfile
from pathlib import Path
temp_pdf_path = None temp_pdf_path = None
try: try:
+1 -2
View File
@@ -12,6 +12,7 @@ from typing import Optional
from urllib.parse import urlencode from urllib.parse import urlencode
import httpx import httpx
import jwt
from mcp.server.auth.middleware.auth_context import get_access_token from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import AccessToken from mcp.server.auth.provider import AccessToken
from mcp.server.fastmcp import Context from mcp.server.fastmcp import Context
@@ -53,8 +54,6 @@ async def extract_user_id_from_token(ctx: Context) -> str:
# Try JWT decode first # Try JWT decode first
if is_jwt: if is_jwt:
try: try:
import jwt
payload = jwt.decode(token, options={"verify_signature": False}) payload = jwt.decode(token, options={"verify_signature": False})
user_id = payload.get("sub", "unknown") user_id = payload.get("sub", "unknown")
logger.info(f" ✓ JWT decode successful: user_id={user_id}") logger.info(f" ✓ JWT decode successful: user_id={user_id}")
+1 -1
View File
@@ -1,6 +1,7 @@
"""Semantic search MCP tools using vector database.""" """Semantic search MCP tools using vector database."""
import logging import logging
import os
import anyio import anyio
from httpx import RequestError from httpx import RequestError
@@ -656,7 +657,6 @@ def configure_semantic_tools(mcp: FastMCP):
This is useful for determining when vector indexing is complete This is useful for determining when vector indexing is complete
after creating or updating content across all indexed apps. after creating or updating content across all indexed apps.
""" """
import os
# Check if vector sync is enabled # Check if vector sync is enabled
vector_sync_enabled = ( vector_sync_enabled = (
+1 -3
View File
@@ -1,3 +1,4 @@
import base64
import logging import logging
from mcp.server.fastmcp import Context, FastMCP from mcp.server.fastmcp import Context, FastMCP
@@ -120,7 +121,6 @@ def configure_webdav_tools(mcp: FastMCP):
pass pass
# For binary files, return metadata and base64 encoded content # For binary files, return metadata and base64 encoded content
import base64
return { return {
"path": path, "path": path,
@@ -156,8 +156,6 @@ def configure_webdav_tools(mcp: FastMCP):
# Handle base64 encoded content # Handle base64 encoded content
if content_type and "base64" in content_type.lower(): if content_type and "base64" in content_type.lower():
import base64
content_bytes = base64.b64decode(content) content_bytes = base64.b64decode(content)
content_type = content_type.replace(";base64", "") content_type = content_type.replace(";base64", "")
else: else:
@@ -3,6 +3,7 @@
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
import anyio
from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_text_splitters import RecursiveCharacterTextSplitter
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -68,7 +69,6 @@ class DocumentChunker:
Returns: Returns:
List of chunks with their character positions in the original content List of chunks with their character positions in the original content
""" """
import anyio
# Handle empty content - return single empty chunk for backward compatibility # Handle empty content - return single empty chunk for backward compatibility
if not content: if not content:
@@ -1,6 +1,7 @@
"""HTML to Markdown conversion utilities for vector sync.""" """HTML to Markdown conversion utilities for vector sync."""
import logging import logging
import re
from markdownify import markdownify as md from markdownify import markdownify as md
@@ -43,7 +44,6 @@ def html_to_markdown(html_content: str | None) -> str:
except Exception as e: except Exception as e:
logger.warning(f"Failed to convert HTML to Markdown: {e}") logger.warning(f"Failed to convert HTML to Markdown: {e}")
# Fallback: strip all HTML tags as a last resort # Fallback: strip all HTML tags as a last resort
import re
text = re.sub(r"<[^>]+>", " ", html_content) text = re.sub(r"<[^>]+>", " ", html_content)
return " ".join(text.split()) # Normalize whitespace return " ".join(text.split()) # Normalize whitespace
+1 -2
View File
@@ -3,6 +3,7 @@
Processes documents from stream: fetches content, generates embeddings, stores in Qdrant. Processes documents from stream: fetches content, generates embeddings, stores in Qdrant.
""" """
import base64
import logging import logging
import time import time
import uuid import uuid
@@ -585,8 +586,6 @@ async def _index_document(
"vector_sync.pdf_size": len(content_bytes), "vector_sync.pdf_size": len(content_bytes),
}, },
): ):
import base64
from nextcloud_mcp_server.search.pdf_highlighter import PDFHighlighter from nextcloud_mcp_server.search.pdf_highlighter import PDFHighlighter
# Build chunk data for batch processing # Build chunk data for batch processing
+1 -1
View File
@@ -5,6 +5,7 @@ Periodically scans enabled users' content and queues changed documents for proce
import logging import logging
import os import os
import random
import time import time
from dataclasses import dataclass from dataclasses import dataclass
@@ -167,7 +168,6 @@ async def scan_user_documents(
nc_client: Authenticated Nextcloud client nc_client: Authenticated Nextcloud client
initial_sync: If True, send all documents (first-time sync) initial_sync: If True, send all documents (first-time sync)
""" """
import random
scan_id = random.randint(1000, 9999) scan_id = random.randint(1000, 9999)
logger.info( logger.info(
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "nextcloud-mcp-server" name = "nextcloud-mcp-server"
version = "0.60.0" version = "0.60.2"
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data" description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
authors = [ authors = [
{name = "Chris Coutinho", email = "chris@coutinho.io"} {name = "Chris Coutinho", email = "chris@coutinho.io"}
+3 -2
View File
@@ -1,3 +1,5 @@
import json
import httpx import httpx
# ============================================================================ # ============================================================================
@@ -22,14 +24,13 @@ def create_mock_response(
Returns: Returns:
Mock httpx.Response object Mock httpx.Response object
""" """
import json as json_module
if headers is None: if headers is None:
headers = {} headers = {}
# If json_data is provided, serialize it to content # If json_data is provided, serialize it to content
if json_data is not None: if json_data is not None:
content = json_module.dumps(json_data).encode("utf-8") content = json.dumps(json_data).encode("utf-8")
headers.setdefault("content-type", "application/json") headers.setdefault("content-type", "application/json")
if content is None: if content is None:
@@ -0,0 +1,194 @@
"""
Integration tests for DeckClient.update_card API behavior.
These tests define the EXPECTED behavior for partial card updates:
- Only fields explicitly passed should be modified
- All other fields should be preserved unchanged
Related issues:
- nextcloud-mcp-server #452: DeckClient.update_card partial update bugs
- deck #3127: REST API Docs: missing parameter in "update cards"
- deck #4106: Provide a working example of API usage to update a cards details
"""
import pytest
pytestmark = [pytest.mark.integration]
@pytest.fixture
async def deck_test_card(nc_client):
"""Create a board, stack, and card for testing, cleanup after."""
board = await nc_client.deck.create_board("Test Update Card API", "FF0000")
stack = await nc_client.deck.create_stack(board.id, "Test Stack", 1)
card = await nc_client.deck.create_card(
board.id,
stack.id,
"Original Title",
type="plain",
description="Original description",
)
yield {
"board_id": board.id,
"stack_id": stack.id,
"card_id": card.id,
"card": card,
}
# Cleanup
await nc_client.deck.delete_board(board.id)
class TestDeckClientUpdateCard:
"""
Test DeckClient.update_card() partial update behavior.
Expected: Only explicitly provided fields are updated, all others preserved.
"""
async def test_update_title_only_preserves_description(
self, nc_client, deck_test_card
):
"""Updating only the title should preserve the description."""
await nc_client.deck.update_card(
board_id=deck_test_card["board_id"],
stack_id=deck_test_card["stack_id"],
card_id=deck_test_card["card_id"],
title="New Title",
)
updated = await nc_client.deck.get_card(
deck_test_card["board_id"],
deck_test_card["stack_id"],
deck_test_card["card_id"],
)
assert updated.title == "New Title"
assert updated.description == "Original description"
async def test_update_description_only(self, nc_client, deck_test_card):
"""Updating only the description should work and preserve other fields."""
await nc_client.deck.update_card(
board_id=deck_test_card["board_id"],
stack_id=deck_test_card["stack_id"],
card_id=deck_test_card["card_id"],
description="New description only",
)
updated = await nc_client.deck.get_card(
deck_test_card["board_id"],
deck_test_card["stack_id"],
deck_test_card["card_id"],
)
assert updated.title == "Original Title"
assert updated.description == "New description only"
async def test_update_title_and_description(self, nc_client, deck_test_card):
"""Updating title and description together should work."""
await nc_client.deck.update_card(
board_id=deck_test_card["board_id"],
stack_id=deck_test_card["stack_id"],
card_id=deck_test_card["card_id"],
title="New Title",
description="New description",
)
updated = await nc_client.deck.get_card(
deck_test_card["board_id"],
deck_test_card["stack_id"],
deck_test_card["card_id"],
)
assert updated.title == "New Title"
assert updated.description == "New description"
async def test_update_duedate_only(self, nc_client, deck_test_card):
"""Updating only the duedate should work and preserve other fields."""
await nc_client.deck.update_card(
board_id=deck_test_card["board_id"],
stack_id=deck_test_card["stack_id"],
card_id=deck_test_card["card_id"],
duedate="2025-12-31T23:59:59+00:00",
)
updated = await nc_client.deck.get_card(
deck_test_card["board_id"],
deck_test_card["stack_id"],
deck_test_card["card_id"],
)
assert updated.title == "Original Title"
assert updated.description == "Original description"
assert updated.duedate is not None
async def test_update_archived_only(self, nc_client, deck_test_card):
"""Updating only the archived status should work and preserve other fields."""
await nc_client.deck.update_card(
board_id=deck_test_card["board_id"],
stack_id=deck_test_card["stack_id"],
card_id=deck_test_card["card_id"],
archived=True,
)
updated = await nc_client.deck.get_card(
deck_test_card["board_id"],
deck_test_card["stack_id"],
deck_test_card["card_id"],
)
assert updated.title == "Original Title"
assert updated.description == "Original description"
assert updated.archived is True
async def test_update_order_only(self, nc_client, deck_test_card):
"""Updating only the order should work and preserve other fields."""
await nc_client.deck.update_card(
board_id=deck_test_card["board_id"],
stack_id=deck_test_card["stack_id"],
card_id=deck_test_card["card_id"],
order=99,
)
updated = await nc_client.deck.get_card(
deck_test_card["board_id"],
deck_test_card["stack_id"],
deck_test_card["card_id"],
)
assert updated.title == "Original Title"
assert updated.description == "Original description"
assert updated.order == 99
async def test_update_preserves_type(self, nc_client, deck_test_card):
"""Type should be preserved when not explicitly changed."""
original = deck_test_card["card"]
await nc_client.deck.update_card(
board_id=deck_test_card["board_id"],
stack_id=deck_test_card["stack_id"],
card_id=deck_test_card["card_id"],
title="Changed Title",
)
updated = await nc_client.deck.get_card(
deck_test_card["board_id"],
deck_test_card["stack_id"],
deck_test_card["card_id"],
)
assert updated.type == original.type
assert updated.description == "Original description"
async def test_update_preserves_owner(self, nc_client, deck_test_card):
"""Owner should be preserved when not explicitly changed."""
original = deck_test_card["card"]
await nc_client.deck.update_card(
board_id=deck_test_card["board_id"],
stack_id=deck_test_card["stack_id"],
card_id=deck_test_card["card_id"],
title="Changed Title",
)
updated = await nc_client.deck.get_card(
deck_test_card["board_id"],
deck_test_card["stack_id"],
deck_test_card["card_id"],
)
assert updated.owner == original.owner
assert updated.description == "Original description"
+10 -25
View File
@@ -1,7 +1,17 @@
import base64
import hashlib
import json
import logging import logging
import os import os
import re
import secrets
import subprocess
import threading
import time
import uuid import uuid
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Any, AsyncGenerator from typing import Any, AsyncGenerator
from urllib.parse import parse_qs, quote, urlparse
import anyio import anyio
import httpx import httpx
@@ -257,7 +267,6 @@ async def nc_mcp_basic_auth_client(
Uses anyio pytest plugin for proper async fixture handling. Uses anyio pytest plugin for proper async fixture handling.
""" """
import base64
credentials = base64.b64encode(b"admin:admin").decode("utf-8") credentials = base64.b64encode(b"admin:admin").decode("utf-8")
auth_header = f"Basic {credentials}" auth_header = f"Basic {credentials}"
@@ -342,7 +351,6 @@ async def nc_mcp_oauth_client_with_elicitation(
logger.info(f" Schema: {params.schema}") logger.info(f" Schema: {params.schema}")
# Extract OAuth URL from elicitation message # Extract OAuth URL from elicitation message
import re
url_pattern = r"https?://[^\s]+" url_pattern = r"https?://[^\s]+"
urls = re.findall(url_pattern, params.message) urls = re.findall(url_pattern, params.message)
@@ -1108,10 +1116,6 @@ def oauth_callback_server():
# "OAuth tests with browser automation not supported in GitHub Actions CI" # "OAuth tests with browser automation not supported in GitHub Actions CI"
# ) # )
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlparse
# Use a dict to store auth codes keyed by state parameter # Use a dict to store auth codes keyed by state parameter
# This allows multiple concurrent OAuth flows # This allows multiple concurrent OAuth flows
auth_states = {} auth_states = {}
@@ -1758,9 +1762,6 @@ async def playwright_oauth_token(
- Browser fixture provided by pytest-playwright-asyncio - Browser fixture provided by pytest-playwright-asyncio
- See: https://playwright.dev/python/docs/test-runners - See: https://playwright.dev/python/docs/test-runners
""" """
import secrets
import time
from urllib.parse import quote
nextcloud_host = os.getenv("NEXTCLOUD_HOST") nextcloud_host = os.getenv("NEXTCLOUD_HOST")
username = os.getenv("NEXTCLOUD_USERNAME") username = os.getenv("NEXTCLOUD_USERNAME")
@@ -2047,9 +2048,6 @@ async def _get_oauth_token_with_scopes(
Returns: Returns:
OAuth access token string with requested scopes OAuth access token string with requested scopes
""" """
import secrets
import time
from urllib.parse import quote
nextcloud_host = os.getenv("NEXTCLOUD_HOST") nextcloud_host = os.getenv("NEXTCLOUD_HOST")
username = os.getenv("NEXTCLOUD_USERNAME") username = os.getenv("NEXTCLOUD_USERNAME")
@@ -2417,9 +2415,6 @@ async def _get_oauth_token_for_user(
Returns: Returns:
OAuth access token string OAuth access token string
""" """
import secrets
import time
from urllib.parse import quote
nextcloud_host = os.getenv("NEXTCLOUD_HOST") nextcloud_host = os.getenv("NEXTCLOUD_HOST")
@@ -2560,7 +2555,6 @@ async def all_oauth_tokens(
Now uses the real callback server with state parameters for reliable Now uses the real callback server with state parameters for reliable
concurrent token acquisition without race conditions. concurrent token acquisition without race conditions.
""" """
import time
# Get auth_states dict from callback server # Get auth_states dict from callback server
auth_states, callback_url = oauth_callback_server auth_states, callback_url = oauth_callback_server
@@ -2711,7 +2705,6 @@ async def test_user(nc_client: NextcloudClient):
user_config = test_user user_config = test_user
await nc_client.users.create_user(**user_config) await nc_client.users.create_user(**user_config)
""" """
import uuid
# Generate unique user ID to avoid conflicts # Generate unique user ID to avoid conflicts
userid = f"testuser_{uuid.uuid4().hex[:8]}" userid = f"testuser_{uuid.uuid4().hex[:8]}"
@@ -2747,7 +2740,6 @@ async def test_group(nc_client: NextcloudClient):
Returns the group ID. Returns the group ID.
""" """
import uuid
# Generate unique group ID to avoid conflicts # Generate unique group ID to avoid conflicts
groupid = f"testgroup_{uuid.uuid4().hex[:8]}" groupid = f"testgroup_{uuid.uuid4().hex[:8]}"
@@ -2882,11 +2874,6 @@ async def _get_keycloak_oauth_token(
Returns: Returns:
OAuth access token string from Keycloak OAuth access token string from Keycloak
""" """
import base64
import hashlib
import secrets
import time
from urllib.parse import quote
# Get auth_states dict from callback server # Get auth_states dict from callback server
auth_states, _ = oauth_callback_server auth_states, _ = oauth_callback_server
@@ -3252,8 +3239,6 @@ async def configure_astrolabe_for_mcp_server(nc_client):
- mcp_server_public_url: Public URL for OAuth token audience validation - mcp_server_public_url: Public URL for OAuth token audience validation
- client_id: Optional OAuth client ID (default: "nextcloudMcpServerUIPublicClient") - client_id: Optional OAuth client ID (default: "nextcloudMcpServerUIPublicClient")
""" """
import json
import subprocess
async def _configure( async def _configure(
mcp_server_internal_url: str, mcp_server_internal_url: str,
@@ -1,6 +1,7 @@
"""Integration tests for document processing with progress notifications.""" """Integration tests for document processing with progress notifications."""
import io import io
import os
import pytest import pytest
from PIL import Image from PIL import Image
@@ -13,7 +14,6 @@ class TestDocumentProcessingProgress:
async def test_unstructured_processor_with_progress_callback(self, nc_client): async def test_unstructured_processor_with_progress_callback(self, nc_client):
"""Test that UnstructuredProcessor calls progress callback during processing.""" """Test that UnstructuredProcessor calls progress callback during processing."""
import os
# Skip if unstructured is not enabled # Skip if unstructured is not enabled
if os.getenv("ENABLE_UNSTRUCTURED", "false").lower() != "true": if os.getenv("ENABLE_UNSTRUCTURED", "false").lower() != "true":
@@ -71,7 +71,6 @@ class TestDocumentProcessingProgress:
self, nc_mcp_client, nc_client self, nc_mcp_client, nc_client
): ):
"""Test that reading a document via WebDAV MCP tool sends progress notifications.""" """Test that reading a document via WebDAV MCP tool sends progress notifications."""
import os
# Skip if document processing is not enabled # Skip if document processing is not enabled
if os.getenv("ENABLE_DOCUMENT_PROCESSING", "false").lower() != "true": if os.getenv("ENABLE_DOCUMENT_PROCESSING", "false").lower() != "true":
@@ -110,7 +109,6 @@ class TestDocumentProcessingProgress:
async def test_progress_callback_not_required(self, nc_client): async def test_progress_callback_not_required(self, nc_client):
"""Test that processing works without progress callback (backward compatibility).""" """Test that processing works without progress callback (backward compatibility)."""
import os
if os.getenv("ENABLE_UNSTRUCTURED", "false").lower() != "true": if os.getenv("ENABLE_UNSTRUCTURED", "false").lower() != "true":
pytest.skip("Unstructured processor not enabled") pytest.skip("Unstructured processor not enabled")
@@ -13,6 +13,8 @@ app password entry → background sync activation → database verification.
""" """
import logging import logging
import re
import subprocess
import anyio import anyio
import pytest import pytest
@@ -151,7 +153,6 @@ async def generate_app_password(
) )
# Validate password format before returning # Validate password format before returning
import re
if not re.match( if not re.match(
r"^[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}$", r"^[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}$",
@@ -350,7 +351,6 @@ async def verify_app_password_created(username: str) -> bool:
# Query the database to check for background sync credentials # Query the database to check for background sync credentials
# Astrolabe stores app passwords in oc_preferences, not oc_authtoken # Astrolabe stores app passwords in oc_preferences, not oc_authtoken
import subprocess
query = f""" query = f"""
SELECT userid, configkey, configvalue SELECT userid, configkey, configvalue
@@ -559,3 +559,259 @@ async def test_multi_user_astrolabe_background_sync_enablement(
logger.info( logger.info(
f"\n✓ All {len(test_users)} users successfully enabled background sync via app passwords!" f"\n✓ All {len(test_users)} users successfully enabled background sync via app passwords!"
) )
async def revoke_background_sync_access(page: Page, username: str) -> bool:
"""Revoke background sync access by clicking the Revoke Access button.
Args:
page: Playwright page instance (must be authenticated)
username: Username (for logging)
Returns:
True if revocation was successful
"""
logger.info(f"Revoking background sync access for {username}...")
nextcloud_url = "http://localhost:8080"
# Set up network request and console listeners
network_requests = []
network_responses = []
console_messages = []
def log_request(req):
network_requests.append(f"{req.method} {req.url}")
def log_response(resp):
response_info = f"{resp.status} {resp.url}"
network_responses.append(response_info)
logger.info(f"Response: {response_info}")
def log_console(msg):
console_messages.append(f"[{msg.type}] {msg.text}")
page.on("request", log_request)
page.on("response", log_response)
page.on("console", log_console)
# Navigate to Astrolabe settings
await page.goto(
f"{nextcloud_url}/settings/user/astrolabe", wait_until="networkidle"
)
# Wait for page to load
await anyio.sleep(1)
# Check if "Active" badge is visible (indicating background sync is enabled)
try:
active_text = page.get_by_text("Active", exact=True)
if not await active_text.is_visible(timeout=2000):
logger.warning(
f"Background sync not active for {username}, nothing to revoke"
)
return False
except Exception:
logger.warning(f"Could not find Active badge for {username}")
return False
# Find the "Revoke Access" button
revoke_button = page.get_by_role("button", name="Revoke Access")
try:
await revoke_button.wait_for(timeout=5000, state="visible")
logger.info("Found Revoke Access button")
except Exception:
screenshot_path = f"/tmp/astrolabe_no_revoke_button_{username}.png"
await page.screenshot(path=screenshot_path)
raise ValueError(
f"Could not find Revoke Access button for {username}. Screenshot: {screenshot_path}"
)
# Set up dialog handler for confirmation dialog
page.once("dialog", lambda dialog: dialog.accept())
# Click the Revoke Access button
await revoke_button.click()
logger.info("Clicked Revoke Access button")
# Wait for the request to complete and page to reload
await page.wait_for_load_state("networkidle", timeout=15000)
await anyio.sleep(2)
# Log network requests after clicking
logger.info(f"Network requests after Revoke for {username}:")
for req in network_requests[-10:]:
logger.info(f" {req}")
# Log network responses
logger.info(f"Network responses after Revoke for {username}:")
for resp in network_responses[-10:]:
logger.info(f" {resp}")
# Check specifically for the revoke POST response
revoke_responses = [r for r in network_responses if "credentials/revoke" in r]
if revoke_responses:
logger.info(f"Revoke endpoint response: {revoke_responses[-1]}")
if "200" not in revoke_responses[-1]:
logger.error(f"Revoke POST did not return 200 OK: {revoke_responses[-1]}")
return False
else:
logger.warning("No response found for credentials/revoke endpoint!")
# Take screenshot for debugging
screenshot_path = f"/tmp/astrolabe_revoke_no_response_{username}.png"
await page.screenshot(path=screenshot_path)
return False
# Log any console messages
if console_messages:
logger.info(f"Console messages for {username}:")
for msg in console_messages:
logger.info(f" {msg}")
# Check for error notifications (toast messages)
try:
error_toast = page.locator(".toastify.toast-error, .toast-error")
if await error_toast.count() > 0:
error_text = await error_toast.first.text_content()
logger.error(f"Error notification for {username}: {error_text}")
return False
except Exception:
pass
# Verify "Active" badge is no longer visible
try:
active_text = page.get_by_text("Active", exact=True)
if await active_text.is_visible(timeout=2000):
logger.error(f"Active badge still visible for {username} after revoke!")
screenshot_path = f"/tmp/astrolabe_revoke_still_active_{username}.png"
await page.screenshot(path=screenshot_path)
return False
except Exception:
pass
logger.info(f"✓ Background sync access revoked for {username}")
return True
async def verify_app_password_deleted(username: str) -> bool:
"""Verify that background sync app password was deleted for the user.
Args:
username: Nextcloud username
Returns:
True if background sync credentials no longer exist
"""
logger.info(f"Verifying background sync credentials deleted for {username}...")
query = f"""
SELECT userid, configkey, configvalue
FROM oc_preferences
WHERE userid = '{username}'
AND appid = 'astrolabe'
AND configkey IN ('background_sync_password', 'background_sync_type', 'background_sync_provisioned_at')
ORDER BY configkey;
"""
try:
result = subprocess.run(
[
"docker",
"compose",
"exec",
"-T",
"db",
"mariadb",
"-u",
"root",
"-ppassword",
"nextcloud",
"-e",
query,
],
capture_output=True,
text=True,
timeout=10,
)
output = result.stdout
logger.debug(f"Background sync credentials query result:\n{output}")
# After deletion, we should NOT see background_sync_password
if "background_sync_password" not in output:
logger.info(f"✓ Background sync credentials deleted for {username}")
return True
else:
logger.warning(f"Background sync credentials still exist for {username}")
return False
except Exception as e:
logger.error(f"Error checking background sync credentials for {username}: {e}")
return False
@pytest.mark.integration
@pytest.mark.oauth
async def test_revoke_background_sync_access(
browser,
nc_client,
test_users_setup,
configure_astrolabe_for_mcp_server,
):
"""Test that users can revoke background sync access via the Revoke Access button.
This test verifies:
1. User enables background sync via app password
2. User clicks "Revoke Access" button
3. Confirmation dialog is handled
4. POST request is sent to /api/v1/background-sync/credentials/revoke
5. "Active" badge disappears from settings page
6. Background sync credentials are deleted from database
This tests the fix for the issue where POST requests to the revoke endpoint
were returning errors due to HTTP method mismatch (was DELETE, now POST).
"""
# Configure Astrolabe to point to the mcp-multi-user-basic server
logger.info("Configuring Astrolabe for mcp-multi-user-basic server...")
await configure_astrolabe_for_mcp_server(
mcp_server_internal_url="http://mcp-multi-user-basic:8000",
mcp_server_public_url="http://localhost:8003",
)
# Test with a single user for this specific test
username = "alice"
user_config = test_users_setup[username]
password = user_config["password"]
# Create new browser context
context = await browser.new_context(ignore_https_errors=True)
page = await context.new_page()
try:
# Step 1: Login to Nextcloud
await login_to_nextcloud(page, username, password)
# Step 2: Generate app password and enable background sync
app_password = await generate_app_password(page, username)
await enable_background_sync_via_app_password(page, username, app_password)
# Step 3: Verify background sync is enabled
assert await verify_app_password_created(username), (
f"Background sync not enabled for {username}"
)
# Step 4: Revoke background sync access
revoke_success = await revoke_background_sync_access(page, username)
assert revoke_success, f"Failed to revoke background sync access for {username}"
# Step 5: Verify credentials are deleted from database
credentials_deleted = await verify_app_password_deleted(username)
assert credentials_deleted, (
f"Background sync credentials not deleted for {username}"
)
logger.info(f"\n✓ Successfully revoked background sync access for {username}!")
finally:
await context.close()
+5 -8
View File
@@ -16,6 +16,7 @@ vector database with indexed test data.
import json import json
from unittest.mock import MagicMock from unittest.mock import MagicMock
import anyio
import pytest import pytest
from mcp.types import CreateMessageResult, TextContent from mcp.types import CreateMessageResult, TextContent
@@ -67,7 +68,6 @@ async def test_semantic_search_answer_successful_sampling(
await require_vector_sync_tools(nc_mcp_client) await require_vector_sync_tools(nc_mcp_client)
# Get initial indexed count before creating note # Get initial indexed count before creating note
import asyncio
initial_sync = await nc_mcp_client.call_tool( initial_sync = await nc_mcp_client.call_tool(
"nc_get_vector_sync_status", arguments={} "nc_get_vector_sync_status", arguments={}
@@ -118,7 +118,7 @@ Avoid blocking operations in async code.""",
) )
break break
await asyncio.sleep(wait_interval) await anyio.sleep(wait_interval)
waited += wait_interval waited += wait_interval
# Verify sync completed # Verify sync completed
@@ -247,7 +247,6 @@ async def test_semantic_search_answer_with_limit(nc_mcp_client, temporary_note_f
) )
# Wait for vector indexing to complete # Wait for vector indexing to complete
import asyncio
max_wait = 30 max_wait = 30
wait_interval = 1 wait_interval = 1
@@ -262,7 +261,7 @@ async def test_semantic_search_answer_with_limit(nc_mcp_client, temporary_note_f
if status_data["status"] == "idle" and status_data["pending_count"] == 0: if status_data["status"] == "idle" and status_data["pending_count"] == 0:
break break
await asyncio.sleep(wait_interval) await anyio.sleep(wait_interval)
waited += wait_interval waited += wait_interval
assert waited < max_wait, f"Vector sync did not complete within {max_wait} seconds" assert waited < max_wait, f"Vector sync did not complete within {max_wait} seconds"
@@ -306,7 +305,6 @@ async def test_semantic_search_answer_score_threshold(
) )
# Wait for vector indexing to complete # Wait for vector indexing to complete
import asyncio
max_wait = 30 max_wait = 30
wait_interval = 1 wait_interval = 1
@@ -321,7 +319,7 @@ async def test_semantic_search_answer_score_threshold(
if status_data["status"] == "idle" and status_data["pending_count"] == 0: if status_data["status"] == "idle" and status_data["pending_count"] == 0:
break break
await asyncio.sleep(wait_interval) await anyio.sleep(wait_interval)
waited += wait_interval waited += wait_interval
assert waited < max_wait, f"Vector sync did not complete within {max_wait} seconds" assert waited < max_wait, f"Vector sync did not complete within {max_wait} seconds"
@@ -371,7 +369,6 @@ async def test_semantic_search_answer_max_tokens(nc_mcp_client, temporary_note_f
) )
# Wait for vector indexing to complete # Wait for vector indexing to complete
import asyncio
max_wait = 30 max_wait = 30
wait_interval = 1 wait_interval = 1
@@ -386,7 +383,7 @@ async def test_semantic_search_answer_max_tokens(nc_mcp_client, temporary_note_f
if status_data["status"] == "idle" and status_data["pending_count"] == 0: if status_data["status"] == "idle" and status_data["pending_count"] == 0:
break break
await asyncio.sleep(wait_interval) await anyio.sleep(wait_interval)
waited += wait_interval waited += wait_interval
assert waited < max_wait, f"Vector sync did not complete within {max_wait} seconds" assert waited < max_wait, f"Vector sync did not complete within {max_wait} seconds"
+1 -2
View File
@@ -10,6 +10,7 @@ Uses SimpleEmbeddingProvider for deterministic, in-process embeddings
without requiring external services like Ollama. without requiring external services like Ollama.
""" """
import math
import tempfile import tempfile
from pathlib import Path from pathlib import Path
@@ -147,7 +148,6 @@ async def test_simple_embedding_provider_deterministic(simple_embedding_provider
assert len(embedding1) == 384 assert len(embedding1) == 384
# Should be normalized (unit length) # Should be normalized (unit length)
import math
norm = math.sqrt(sum(x * x for x in embedding1)) norm = math.sqrt(sum(x * x for x in embedding1))
assert abs(norm - 1.0) < 1e-6 assert abs(norm - 1.0) < 1e-6
@@ -340,7 +340,6 @@ async def test_batch_embedding(simple_embedding_provider: SimpleEmbeddingProvide
assert all(len(emb) == 384 for emb in embeddings) assert all(len(emb) == 384 for emb in embeddings)
# Each should be normalized # Each should be normalized
import math
for emb in embeddings: for emb in embeddings:
norm = math.sqrt(sum(x * x for x in emb)) norm = math.sqrt(sum(x * x for x in emb))
+1 -2
View File
@@ -6,6 +6,7 @@ workflow completion rates, and cross-user operation latencies.
""" """
import statistics import statistics
import time
from collections import Counter, defaultdict from collections import Counter, defaultdict
from typing import Any from typing import Any
@@ -44,13 +45,11 @@ class OAuthBenchmarkMetrics:
def start(self): def start(self):
"""Mark the start of the benchmark.""" """Mark the start of the benchmark."""
import time
self.start_time = time.time() self.start_time = time.time()
def stop(self): def stop(self):
"""Mark the end of the benchmark.""" """Mark the end of the benchmark."""
import time
self.end_time = time.time() self.end_time = time.time()
+4 -4
View File
@@ -5,8 +5,12 @@ Manages multiple OAuth-authenticated users for realistic multi-user load testing
""" """
import logging import logging
import secrets
import string
import time
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from urllib.parse import quote
import anyio import anyio
import httpx import httpx
@@ -333,8 +337,6 @@ class OAuthUserPool:
TimeoutError: If callback not received within timeout TimeoutError: If callback not received within timeout
ValueError: If token exchange fails ValueError: If token exchange fails
""" """
import time
from urllib.parse import quote
logger.info(f"Starting Playwright OAuth flow for {username}...") logger.info(f"Starting Playwright OAuth flow for {username}...")
logger.debug(f"Using state: {state[:16]}...") logger.debug(f"Using state: {state[:16]}...")
@@ -478,8 +480,6 @@ class UserSessionWrapper:
def generate_secure_password(length: int = 20) -> str: def generate_secure_password(length: int = 20) -> str:
"""Generate a secure random password.""" """Generate a secure random password."""
import secrets
import string
alphabet = string.ascii_letters + string.digits + "!@#$%^&*()" alphabet = string.ascii_letters + string.digits + "!@#$%^&*()"
return "".join(secrets.choice(alphabet) for _ in range(length)) return "".join(secrets.choice(alphabet) for _ in range(length))
+1 -4
View File
@@ -4,6 +4,7 @@ Workload definitions for load testing the MCP server.
Defines realistic operation mixes and individual operation functions. Defines realistic operation mixes and individual operation functions.
""" """
import json
import logging import logging
import random import random
import time import time
@@ -91,8 +92,6 @@ class WorkloadOperations:
if result and len(result.content) > 0: if result and len(result.content) > 0:
content = result.content[0] content = result.content[0]
if hasattr(content, "text"): if hasattr(content, "text"):
import json
note_data = json.loads(content.text) note_data = json.loads(content.text)
note_id = note_data.get("id") note_id = note_data.get("id")
if note_id: if note_id:
@@ -222,8 +221,6 @@ class MixedWorkload:
"nc_notes_get_note", {"note_id": note_id} "nc_notes_get_note", {"note_id": note_id}
) )
if get_result and len(get_result.content) > 0: if get_result and len(get_result.content) > 0:
import json
note_data = json.loads(get_result.content[0].text) note_data = json.loads(get_result.content[0].text)
etag = note_data.get("etag", "") etag = note_data.get("etag", "")
self._warmup_note_ids.append((note_id, etag)) self._warmup_note_ids.append((note_id, etag))
+1 -1
View File
@@ -18,6 +18,7 @@ Usage:
import asyncio import asyncio
import logging import logging
import os import os
import re
import sys import sys
# Add parent directory to path # Add parent directory to path
@@ -127,7 +128,6 @@ async def main():
) )
# Extract requesttoken from HTML # Extract requesttoken from HTML
import re
token_match = re.search(r'data-requesttoken="([^"]+)"', settings_response.text) token_match = re.search(r'data-requesttoken="([^"]+)"', settings_response.text)
if token_match: if token_match:
+1 -1
View File
@@ -17,6 +17,7 @@ Architecture:
MCP Client Keycloak DCR Keycloak OAuth MCP Server Nextcloud APIs MCP Client Keycloak DCR Keycloak OAuth MCP Server Nextcloud APIs
""" """
import json
import logging import logging
import os import os
import secrets import secrets
@@ -623,7 +624,6 @@ async def test_keycloak_dcr_architecture():
} }
logger.info("Keycloak DCR Architecture:") logger.info("Keycloak DCR Architecture:")
import json
logger.info(json.dumps(architecture, indent=2)) logger.info(json.dumps(architecture, indent=2))
@@ -11,13 +11,15 @@ Note: Tests use JWT OAuth tokens because scopes are embedded in the token payloa
enabling efficient scope-based tool filtering without additional API calls. enabling efficient scope-based tool filtering without additional API calls.
""" """
import logging
import httpx
import pytest import pytest
@pytest.mark.integration @pytest.mark.integration
async def test_prm_endpoint(): async def test_prm_endpoint():
"""Test that the Protected Resource Metadata endpoint returns correct data.""" """Test that the Protected Resource Metadata endpoint returns correct data."""
import httpx
# Test the PRM endpoint directly (RFC 9728 - path includes /mcp resource) # Test the PRM endpoint directly (RFC 9728 - path includes /mcp resource)
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
@@ -60,7 +62,6 @@ async def test_basicauth_shows_all_tools(nc_mcp_client):
@pytest.mark.integration @pytest.mark.integration
async def test_read_only_token_filters_write_tools(nc_mcp_oauth_client_read_only): async def test_read_only_token_filters_write_tools(nc_mcp_oauth_client_read_only):
"""Test that a token with only read scopes filters out write tools.""" """Test that a token with only read scopes filters out write tools."""
import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -109,7 +110,6 @@ async def test_read_only_token_filters_write_tools(nc_mcp_oauth_client_read_only
@pytest.mark.integration @pytest.mark.integration
async def test_write_only_token_filters_read_tools(nc_mcp_oauth_client_write_only): async def test_write_only_token_filters_read_tools(nc_mcp_oauth_client_write_only):
"""Test that a token with only write scopes filters out read tools.""" """Test that a token with only write scopes filters out read tools."""
import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -158,7 +158,6 @@ async def test_write_only_token_filters_read_tools(nc_mcp_oauth_client_write_onl
@pytest.mark.integration @pytest.mark.integration
async def test_full_access_token_shows_all_tools(nc_mcp_oauth_client_full_access): async def test_full_access_token_shows_all_tools(nc_mcp_oauth_client_full_access):
"""Test that a token with both read and write scopes scopes can see all tools.""" """Test that a token with both read and write scopes scopes can see all tools."""
import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -402,7 +401,6 @@ async def test_jwt_with_no_custom_scopes_returns_zero_tools(
- OAuth provisioning tools (requiring only 'openid') remain visible - OAuth provisioning tools (requiring only 'openid') remain visible
so users can provision Nextcloud access after authentication so users can provision Nextcloud access after authentication
""" """
import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -442,7 +440,6 @@ async def test_jwt_consent_scenarios_read_only(nc_mcp_oauth_client_read_only):
Simulates user granting only read permission during OAuth consent. Simulates user granting only read permission during OAuth consent.
Expected: Should see read tools but not write tools. Expected: Should see read tools but not write tools.
""" """
import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -480,7 +477,6 @@ async def test_jwt_consent_scenarios_write_only(nc_mcp_oauth_client_write_only):
Simulates user granting only write permission during OAuth consent. Simulates user granting only write permission during OAuth consent.
Expected: Should see write tools but not read-only tools. Expected: Should see write tools but not read-only tools.
""" """
import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -518,7 +514,6 @@ async def test_jwt_consent_scenarios_full_access(nc_mcp_oauth_client_full_access
Simulates user granting both permissions during OAuth consent. Simulates user granting both permissions during OAuth consent.
Expected: Should see all 90+ tools (both read and write). Expected: Should see all 90+ tools (both read and write).
""" """
import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
+2 -3
View File
@@ -6,10 +6,12 @@ Tests the critical token exchange pattern that separates:
""" """
import os import os
import tempfile
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import jwt import jwt
import pytest import pytest
from cryptography.fernet import Fernet
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
@@ -21,9 +23,6 @@ pytestmark = pytest.mark.unit
@pytest.fixture @pytest.fixture
async def token_storage(): async def token_storage():
"""Create test token storage.""" """Create test token storage."""
import tempfile
from cryptography.fernet import Fernet
# Generate valid Fernet key # Generate valid Fernet key
encryption_key = Fernet.generate_key() encryption_key = Fernet.generate_key()
+1 -8
View File
@@ -1,5 +1,6 @@
"""Integration tests for Calendar VTODO (task) MCP tools.""" """Integration tests for Calendar VTODO (task) MCP tools."""
import json
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -41,7 +42,6 @@ async def test_mcp_todo_complete_workflow(
# Extract UID from the result # Extract UID from the result
result_data = create_result.content[0].text result_data = create_result.content[0].text
import json
result_json = json.loads(result_data) result_json = json.loads(result_data)
todo_uid = result_json["uid"] todo_uid = result_json["uid"]
@@ -156,7 +156,6 @@ async def test_mcp_list_todos_with_filters(
{"calendar_name": calendar_name, "status": "NEEDS-ACTION"}, {"calendar_name": calendar_name, "status": "NEEDS-ACTION"},
) )
assert result.isError is False assert result.isError is False
import json
data = json.loads(result.content[0].text) data = json.loads(result.content[0].text)
needs_action_todos = [t for t in data["todos"] if t["uid"] in created_uids] needs_action_todos = [t for t in data["todos"] if t["uid"] in created_uids]
@@ -253,8 +252,6 @@ async def test_mcp_search_todos_across_calendars(
) )
assert search_result.isError is False assert search_result.isError is False
import json
data = json.loads(search_result.content[0].text) data = json.loads(search_result.content[0].text)
assert "todos" in data assert "todos" in data
@@ -388,8 +385,6 @@ async def test_mcp_todo_with_dates(
) )
assert create_result.isError is False assert create_result.isError is False
import json
result_data = json.loads(create_result.content[0].text) result_data = json.loads(create_result.content[0].text)
todo_uid = result_data["uid"] todo_uid = result_data["uid"]
@@ -432,8 +427,6 @@ async def test_mcp_todo_categories(
) )
assert create_result.isError is False assert create_result.isError is False
import json
result_data = json.loads(create_result.content[0].text) result_data = json.loads(create_result.content[0].text)
todo_uid = result_data["uid"] todo_uid = result_data["uid"]
+1 -4
View File
@@ -1,5 +1,6 @@
"""Tests for configuration validation.""" """Tests for configuration validation."""
import logging
import os import os
from unittest.mock import patch from unittest.mock import patch
@@ -48,7 +49,6 @@ class TestQdrantConfigValidation:
def test_api_key_warning_in_local_mode(self, caplog): def test_api_key_warning_in_local_mode(self, caplog):
"""Test that API key in local mode triggers warning.""" """Test that API key in local mode triggers warning."""
import logging
caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.config") caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.config")
Settings( Settings(
@@ -59,7 +59,6 @@ class TestQdrantConfigValidation:
def test_api_key_no_warning_in_network_mode(self, caplog): def test_api_key_no_warning_in_network_mode(self, caplog):
"""Test that API key in network mode doesn't trigger warning.""" """Test that API key in network mode doesn't trigger warning."""
import logging
caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.config") caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.config")
Settings( Settings(
@@ -206,7 +205,6 @@ class TestChunkConfigValidation:
def test_small_chunk_size_warning(self, caplog): def test_small_chunk_size_warning(self, caplog):
"""Test that chunk size < 512 triggers warning.""" """Test that chunk size < 512 triggers warning."""
import logging
caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.config") caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.config")
Settings( Settings(
@@ -221,7 +219,6 @@ class TestChunkConfigValidation:
def test_reasonable_chunk_size_no_warning(self, caplog): def test_reasonable_chunk_size_no_warning(self, caplog):
"""Test that chunk size >= 512 doesn't trigger warning.""" """Test that chunk size >= 512 doesn't trigger warning."""
import logging
caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.config") caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.config")
Settings( Settings(
+1 -1
View File
@@ -8,6 +8,7 @@ APIs use OAuth.
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest import pytest
from nextcloud_mcp_server.app import setup_oauth_config_for_multi_user_basic from nextcloud_mcp_server.app import setup_oauth_config_for_multi_user_basic
@@ -207,7 +208,6 @@ class TestSetupOAuthConfigForMultiUserBasic:
self, hybrid_auth_settings, mocker self, hybrid_auth_settings, mocker
): ):
"""Test handling of OIDC discovery HTTP errors.""" """Test handling of OIDC discovery HTTP errors."""
import httpx
# Create a mock response with a status error # Create a mock response with a status error
mock_response = MagicMock() mock_response = MagicMock()
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.commitizen] [tool.commitizen]
name = "cz_conventional_commits" name = "cz_conventional_commits"
version = "0.6.0" version = "0.7.0"
tag_format = "astrolabe-v$version" tag_format = "astrolabe-v$version"
version_scheme = "semver" version_scheme = "semver"
update_changelog_on_bump = true update_changelog_on_bump = true
+29
View File
@@ -25,6 +25,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Requires external MCP server deployment - Requires external MCP server deployment
- See documentation for setup: https://github.com/cbcoutinho/nextcloud-mcp-server - See documentation for setup: https://github.com/cbcoutinho/nextcloud-mcp-server
## astrolabe-v0.7.0 (2025-12-26)
### Feat
- Remove URL rewriting in favor of proper nextcloud config
- **helm**: migrate to new environment variable naming convention
- Migrate to vue 3
- **astrolabe**: upgrade to Vue 3 and @nextcloud/vue 9
- **helm**: add support for multi-user BasicAuth mode
### Fix
- **tests**: Add singleton reset fixture to prevent anyio.WouldBlock errors
- **tests**: Fix integration test failures in qdrant, sampling, and rag tests
- **auth**: Skip issuer validation for management API tokens
- Use settings.enable_offline_access for env var consolidation
- Add required config.py attributes
- **docker**: remove overwritehost to fix container-to-container DCR
- **deps**: update dependency @nextcloud/vue to v9
- **deps**: update dependency vue to v3
- **helm**: set OIDC client env vars when using existingSecret
- **helm**: trigger chart release workflow on helm chart tags
- **helm**: address PR #447 reviewer feedback
- **helm**: include MCP server version bumps in changelog pattern
### Refactor
- **auth**: Decouple BasicAuth and OAuth authentication strategies
## astrolabe-v0.6.0 (2025-12-22) ## astrolabe-v0.6.0 (2025-12-22)
### Feat ### Feat
+1 -1
View File
@@ -29,7 +29,7 @@ Astrolabe connects to a semantic search service that understands the meaning of
See [documentation](https://github.com/cbcoutinho/nextcloud-mcp-server) for configuration details. See [documentation](https://github.com/cbcoutinho/nextcloud-mcp-server) for configuration details.
]]></description> ]]></description>
<version>0.6.0</version> <version>0.7.0</version>
<licence>agpl</licence> <licence>agpl</licence>
<author homepage="https://github.com/cbcoutinho">Chris Coutinho</author> <author homepage="https://github.com/cbcoutinho">Chris Coutinho</author>
<namespace>Astrolabe</namespace> <namespace>Astrolabe</namespace>
+2 -2
View File
@@ -47,8 +47,8 @@ return [
], ],
[ [
'name' => 'credentials#deleteCredentials', 'name' => 'credentials#deleteCredentials',
'url' => '/api/v1/background-sync/credentials', 'url' => '/api/v1/background-sync/credentials/revoke',
'verb' => 'DELETE', 'verb' => 'POST',
], ],
[ [
'name' => 'credentials#getStatus', 'name' => 'credentials#getStatus',
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "astrolabe", "name": "astrolabe",
"version": "0.6.0", "version": "0.7.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"engines": { "engines": {
"node": "^22.0.0", "node": "^22.0.0",
Generated
+1 -1
View File
@@ -1988,7 +1988,7 @@ wheels = [
[[package]] [[package]]
name = "nextcloud-mcp-server" name = "nextcloud-mcp-server"
version = "0.60.0" version = "0.60.2"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiosqlite" }, { name = "aiosqlite" },