Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a26a470af6 | |||
| 71ace47197 | |||
| 30d3d9f0cf | |||
| 48a4182ef9 | |||
| 13dd709fc2 | |||
| dd66d4bbbc | |||
| 663e66af81 | |||
| 9c17bbfe9c | |||
| 052db2cf56 | |||
| 056414752e | |||
| b841407f07 | |||
| 555c26526e |
@@ -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,6 +1,6 @@
|
|||||||
[tool.commitizen]
|
[tool.commitizen]
|
||||||
name = "cz_conventional_commits"
|
name = "cz_conventional_commits"
|
||||||
version = "0.55.2"
|
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
|
||||||
|
|||||||
@@ -14,6 +14,42 @@ 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)
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **auth**: Decouple BasicAuth and OAuth authentication strategies
|
||||||
|
|
||||||
## nextcloud-mcp-server-0.55.2 (2025-12-22)
|
## nextcloud-mcp-server-0.55.2 (2025-12-22)
|
||||||
|
|
||||||
### Fix
|
### Fix
|
||||||
|
|||||||
@@ -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.55.2
|
version: 0.56.2
|
||||||
appVersion: "0.60.0"
|
appVersion: "0.60.2"
|
||||||
keywords:
|
keywords:
|
||||||
- nextcloud
|
- nextcloud
|
||||||
- mcp
|
- mcp
|
||||||
|
|||||||
+3
-1
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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})"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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,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":
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,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 +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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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"}
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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__)
|
||||||
|
|
||||||
|
|||||||
@@ -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,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,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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Vendored
+1
-1
@@ -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
|
||||||
|
|||||||
Vendored
+29
@@ -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
@@ -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
@@ -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',
|
||||||
|
|||||||
Vendored
+1
-1
@@ -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",
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
Reference in New Issue
Block a user