diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0e5eea9..526620d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -62,4 +62,4 @@ jobs: NEXTCLOUD_USERNAME: "admin" NEXTCLOUD_PASSWORD: "admin" run: | - uv run pytest -v --browser firefox -k oauth + uv run pytest -v --browser firefox diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index c694bef..91afaa6 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -1,35 +1,30 @@ -import click import logging import os -import uvicorn from collections.abc import AsyncIterator -from contextlib import asynccontextmanager, AsyncExitStack +from contextlib import AsyncExitStack, asynccontextmanager from dataclasses import dataclass +import click +import uvicorn +from mcp.server.auth.settings import AuthSettings +from mcp.server.fastmcp import Context, FastMCP from pydantic import AnyHttpUrl from starlette.applications import Starlette from starlette.routing import Mount -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.auth.settings import AuthSettings - -from nextcloud_mcp_server.config import setup_logging +from nextcloud_mcp_server.auth import NextcloudTokenVerifier, load_or_register_client from nextcloud_mcp_server.client import NextcloudClient +from nextcloud_mcp_server.config import setup_logging from nextcloud_mcp_server.context import get_client as get_nextcloud_client -from nextcloud_mcp_server.auth import ( - NextcloudTokenVerifier, - load_or_register_client, -) from nextcloud_mcp_server.server import ( configure_calendar_tools, configure_contacts_tools, + configure_deck_tools, configure_notes_tools, configure_tables_tools, configure_webdav_tools, - configure_deck_tools, ) - logger = logging.getLogger(__name__) diff --git a/nextcloud_mcp_server/auth/context_helper.py b/nextcloud_mcp_server/auth/context_helper.py index 6e0c0f2..986e1be 100644 --- a/nextcloud_mcp_server/auth/context_helper.py +++ b/nextcloud_mcp_server/auth/context_helper.py @@ -2,8 +2,8 @@ import logging -from mcp.server.fastmcp import Context from mcp.server.auth.provider import AccessToken +from mcp.server.fastmcp import Context from ..client import NextcloudClient diff --git a/nextcloud_mcp_server/client/__init__.py b/nextcloud_mcp_server/client/__init__.py index 621a379..4a2a4c6 100644 --- a/nextcloud_mcp_server/client/__init__.py +++ b/nextcloud_mcp_server/client/__init__.py @@ -2,13 +2,13 @@ import logging import os from httpx import ( + AsyncBaseTransport, AsyncClient, + AsyncHTTPTransport, Auth, BasicAuth, Request, Response, - AsyncBaseTransport, - AsyncHTTPTransport, ) from ..controllers.notes_search import NotesSearchController diff --git a/nextcloud_mcp_server/client/base.py b/nextcloud_mcp_server/client/base.py index 3dbabdf..fe298d5 100644 --- a/nextcloud_mcp_server/client/base.py +++ b/nextcloud_mcp_server/client/base.py @@ -1,11 +1,11 @@ """Base client for Nextcloud operations with shared authentication.""" import logging -from abc import ABC - -from functools import wraps import time -from httpx import HTTPStatusError, codes, RequestError, AsyncClient +from abc import ABC +from functools import wraps + +from httpx import AsyncClient, HTTPStatusError, RequestError, codes logger = logging.getLogger(__name__) diff --git a/nextcloud_mcp_server/client/contacts.py b/nextcloud_mcp_server/client/contacts.py index 460a884..042a84a 100644 --- a/nextcloud_mcp_server/client/contacts.py +++ b/nextcloud_mcp_server/client/contacts.py @@ -1,10 +1,12 @@ """CardDAV client for NextCloud contacts operations.""" import logging -from .base import BaseNextcloudClient import xml.etree.ElementTree as ET + from pythonvCard4.vcard import Contact +from .base import BaseNextcloudClient + logger = logging.getLogger(__name__) diff --git a/nextcloud_mcp_server/client/deck.py b/nextcloud_mcp_server/client/deck.py index eab85b2..6f1acf9 100644 --- a/nextcloud_mcp_server/client/deck.py +++ b/nextcloud_mcp_server/client/deck.py @@ -1,16 +1,16 @@ -from typing import List, Optional, Dict, Any +from typing import Any, Dict, List, Optional from nextcloud_mcp_server.client.base import BaseNextcloudClient from nextcloud_mcp_server.models.deck import ( - DeckBoard, - DeckStack, - DeckCard, - DeckLabel, DeckACL, DeckAttachment, + DeckBoard, + DeckCard, DeckComment, - DeckSession, DeckConfig, + DeckLabel, + DeckSession, + DeckStack, ) diff --git a/nextcloud_mcp_server/models/__init__.py b/nextcloud_mcp_server/models/__init__.py index 6845df9..55bf208 100644 --- a/nextcloud_mcp_server/models/__init__.py +++ b/nextcloud_mcp_server/models/__init__.py @@ -1,41 +1,25 @@ """Pydantic models for structured MCP server responses.""" # Base models -from .base import ( - BaseResponse, - IdResponse, - StatusResponse, -) - -# Notes models -from .notes import ( - Note, - NoteSearchResult, - NotesSettings, - CreateNoteResponse, - UpdateNoteResponse, - DeleteNoteResponse, - AppendContentResponse, - SearchNotesResponse, -) +from .base import BaseResponse, IdResponse, StatusResponse # Calendar models from .calendar import ( + AvailabilitySlot, + BulkOperationResponse, + BulkOperationResult, Calendar, CalendarEvent, CalendarEventSummary, CreateEventResponse, - UpdateEventResponse, - DeleteEventResponse, - ListEventsResponse, - ListCalendarsResponse, - AvailabilitySlot, - FindAvailabilityResponse, - BulkOperationResult, - BulkOperationResponse, CreateMeetingResponse, - UpcomingEventsResponse, + DeleteEventResponse, + FindAvailabilityResponse, + ListCalendarsResponse, + ListEventsResponse, ManageCalendarResponse, + UpcomingEventsResponse, + UpdateEventResponse, ) # Contacts models @@ -43,38 +27,50 @@ from .contacts import ( AddressBook, Contact, ContactField, + CreateAddressBookResponse, + CreateContactResponse, + DeleteAddressBookResponse, + DeleteContactResponse, ListAddressBooksResponse, ListContactsResponse, - CreateContactResponse, UpdateContactResponse, - DeleteContactResponse, - CreateAddressBookResponse, - DeleteAddressBookResponse, +) + +# Notes models +from .notes import ( + AppendContentResponse, + CreateNoteResponse, + DeleteNoteResponse, + Note, + NoteSearchResult, + NotesSettings, + SearchNotesResponse, + UpdateNoteResponse, ) # Tables models from .tables import ( + CreateRowResponse, + DeleteRowResponse, + GetSchemaResponse, + ListTablesResponse, + ReadTableResponse, Table, TableColumn, TableRow, - TableView, TableSchema, - ListTablesResponse, - GetSchemaResponse, - ReadTableResponse, - CreateRowResponse, + TableView, UpdateRowResponse, - DeleteRowResponse, ) # WebDAV models from .webdav import ( - FileInfo, - DirectoryListing, - ReadFileResponse, - WriteFileResponse, CreateDirectoryResponse, DeleteResourceResponse, + DirectoryListing, + FileInfo, + ReadFileResponse, + WriteFileResponse, ) __all__ = [ diff --git a/nextcloud_mcp_server/models/deck.py b/nextcloud_mcp_server/models/deck.py index d46a3d2..b636ddd 100644 --- a/nextcloud_mcp_server/models/deck.py +++ b/nextcloud_mcp_server/models/deck.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import List, Optional, Dict, Any, Union +from typing import Any, Dict, List, Optional, Union from pydantic import BaseModel, Field, field_validator diff --git a/nextcloud_mcp_server/server/__init__.py b/nextcloud_mcp_server/server/__init__.py index 9f806bb..7b3b980 100644 --- a/nextcloud_mcp_server/server/__init__.py +++ b/nextcloud_mcp_server/server/__init__.py @@ -1,9 +1,9 @@ from .calendar import configure_calendar_tools +from .contacts import configure_contacts_tools +from .deck import configure_deck_tools from .notes import configure_notes_tools from .tables import configure_tables_tools from .webdav import configure_webdav_tools -from .contacts import configure_contacts_tools -from .deck import configure_deck_tools __all__ = [ "configure_calendar_tools", diff --git a/nextcloud_mcp_server/server/calendar.py b/nextcloud_mcp_server/server/calendar.py index bf5af43..07a70e3 100644 --- a/nextcloud_mcp_server/server/calendar.py +++ b/nextcloud_mcp_server/server/calendar.py @@ -5,10 +5,7 @@ from typing import Optional from mcp.server.fastmcp import Context, FastMCP from nextcloud_mcp_server.context import get_client -from nextcloud_mcp_server.models.calendar import ( - Calendar, - ListCalendarsResponse, -) +from nextcloud_mcp_server.models.calendar import Calendar, ListCalendarsResponse logger = logging.getLogger(__name__) diff --git a/nextcloud_mcp_server/server/deck.py b/nextcloud_mcp_server/server/deck.py index 0b0eb87..a79ba4f 100644 --- a/nextcloud_mcp_server/server/deck.py +++ b/nextcloud_mcp_server/server/deck.py @@ -5,17 +5,17 @@ from mcp.server.fastmcp import Context, FastMCP from nextcloud_mcp_server.context import get_client from nextcloud_mcp_server.models.deck import ( + CardOperationResponse, + CreateBoardResponse, + CreateCardResponse, + CreateLabelResponse, + CreateStackResponse, DeckBoard, - DeckStack, DeckCard, DeckLabel, - CreateBoardResponse, - CreateStackResponse, - StackOperationResponse, - CreateCardResponse, - CardOperationResponse, - CreateLabelResponse, + DeckStack, LabelOperationResponse, + StackOperationResponse, ) logger = logging.getLogger(__name__) diff --git a/nextcloud_mcp_server/server/notes.py b/nextcloud_mcp_server/server/notes.py index aad9e8e..a241633 100644 --- a/nextcloud_mcp_server/server/notes.py +++ b/nextcloud_mcp_server/server/notes.py @@ -1,20 +1,20 @@ import logging + from httpx import HTTPStatusError +from mcp.server.fastmcp import Context, FastMCP from mcp.shared.exceptions import McpError from mcp.types import ErrorData -from mcp.server.fastmcp import Context, FastMCP - from nextcloud_mcp_server.context import get_client from nextcloud_mcp_server.models.notes import ( - Note, - NotesSettings, - CreateNoteResponse, - UpdateNoteResponse, - DeleteNoteResponse, AppendContentResponse, - SearchNotesResponse, + CreateNoteResponse, + DeleteNoteResponse, + Note, NoteSearchResult, + NotesSettings, + SearchNotesResponse, + UpdateNoteResponse, ) logger = logging.getLogger(__name__) diff --git a/tests/conftest.py b/tests/conftest.py index 1a5dbe2..2f42fe5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -572,109 +572,6 @@ async def temporary_board_with_card( logger.error(f"Unexpected error deleting temporary card {card.id}: {e}") -async def get_oauth_token(nextcloud_url: str, username: str, password: str) -> str: - """ - Get an OAuth access token from Nextcloud OIDC using Client Credentials flow. - - This is a helper function for testing only - it bypasses the normal OAuth flow - to directly obtain a token for automated testing. - - Args: - nextcloud_url: Nextcloud base URL - username: Nextcloud username - password: Nextcloud password - - Returns: - Access token string - - Raises: - Exception: If token acquisition fails - """ - from nextcloud_mcp_server.auth.client_registration import load_or_register_client - - logger.info(f"Getting OAuth token for testing from {nextcloud_url}") - - # Perform OIDC discovery - async with httpx.AsyncClient() as http_client: - discovery_url = f"{nextcloud_url}/.well-known/openid-configuration" - logger.debug(f"Fetching OIDC discovery from: {discovery_url}") - - discovery_response = await http_client.get(discovery_url) - if discovery_response.status_code != 200: - raise Exception(f"OIDC discovery failed: {discovery_response.status_code}") - - oidc_config = discovery_response.json() - token_endpoint = oidc_config.get("token_endpoint") - registration_endpoint = oidc_config.get("registration_endpoint") - - if not token_endpoint or not registration_endpoint: - raise Exception("OIDC discovery missing required endpoints") - - logger.debug(f"Token endpoint: {token_endpoint}") - logger.debug(f"Registration endpoint: {registration_endpoint}") - - # Get or register an OAuth client - client_info = await load_or_register_client( - nextcloud_url=nextcloud_url, - registration_endpoint=registration_endpoint, - storage_path=".nextcloud_oauth_test_client.json", - redirect_uris=["http://localhost:8000/oauth/callback"], - ) - - # Use client credentials to get a token via client_credentials grant - token_response = await http_client.post( - token_endpoint, - data={ - "grant_type": "client_credentials", - "client_id": client_info.client_id, - "client_secret": client_info.client_secret, - "scope": "openid profile email", - }, - ) - - if token_response.status_code != 200: - logger.error(f"Failed to get OAuth token: {token_response.text}") - raise Exception(f"Token request failed: {token_response.status_code}") - - token_data = token_response.json() - access_token = token_data.get("access_token") - - if not access_token: - raise Exception("No access_token in response") - - logger.info("Successfully obtained OAuth access token for testing") - return access_token - - -@pytest.fixture(scope="session") -async def oauth_token() -> str: - """ - Fixture to obtain an OAuth access token for integration tests. - - This uses the Resource Owner Password flow to get a token without - requiring interactive browser authentication. - """ - nextcloud_host = os.getenv("NEXTCLOUD_HOST") - username = os.getenv("NEXTCLOUD_USERNAME") - password = os.getenv("NEXTCLOUD_PASSWORD") - - if not all([nextcloud_host, username, password]): - pytest.skip( - "OAuth token fixture requires NEXTCLOUD_HOST, USERNAME, and PASSWORD" - ) - - # Wait for Nextcloud to be ready - if not await wait_for_nextcloud(nextcloud_host): - pytest.fail(f"Nextcloud server at {nextcloud_host} is not ready") - - try: - token = await get_oauth_token(nextcloud_host, username, password) - return token - except Exception as e: - logger.error(f"Failed to obtain OAuth token: {e}") - pytest.skip(f"Could not obtain OAuth token for testing: {e}") - - @pytest.fixture(scope="session") async def nc_oauth_client_interactive( interactive_oauth_token: str, @@ -771,9 +668,9 @@ def oauth_callback_server(): # Skip interactive tests in CI environments if os.getenv("GITHUB_ACTIONS"): pytest.skip("Skipping interactive OAuth tests in GitHub Actions CI") - from http.server import BaseHTTPRequestHandler, HTTPServer import threading - from urllib.parse import urlparse, parse_qs + from http.server import BaseHTTPRequestHandler, HTTPServer + from urllib.parse import parse_qs, urlparse # Use a mutable container to share state across threads auth_state = {"code": None} @@ -843,6 +740,10 @@ def oauth_callback_server(): @pytest.fixture(scope="session") +@pytest.mark.skipif( + "GITHUB_ACTIONS" in os.environ, + reason="Unable to access interactive browser in GitHub Actions", +) async def interactive_oauth_token(oauth_callback_server) -> str: """ Fixture to obtain an OAuth access token for integration tests. @@ -852,12 +753,9 @@ async def interactive_oauth_token(oauth_callback_server) -> str: Automatically skips when running in GitHub Actions CI. """ - # Skip interactive tests in CI environments - if os.getenv("GITHUB_ACTIONS"): - pytest.skip("Skipping interactive OAuth tests in GitHub Actions CI") - import webbrowser import time + import webbrowser from nextcloud_mcp_server.auth.client_registration import load_or_register_client @@ -948,7 +846,7 @@ async def playwright_oauth_token(browser) -> str: - Browser fixture provided by pytest-playwright-asyncio - See: https://playwright.dev/python/docs/test-runners """ - from urllib.parse import urlparse, parse_qs + from urllib.parse import parse_qs, urlparse nextcloud_host = os.getenv("NEXTCLOUD_HOST") username = os.getenv("NEXTCLOUD_USERNAME") diff --git a/tests/integration/test_deck_api.py b/tests/integration/test_deck_api.py index c9b2f86..f1ce5d3 100644 --- a/tests/integration/test_deck_api.py +++ b/tests/integration/test_deck_api.py @@ -5,7 +5,7 @@ import pytest from httpx import HTTPStatusError from nextcloud_mcp_server.client import NextcloudClient -from nextcloud_mcp_server.models.deck import DeckStack, DeckCard, DeckLabel +from nextcloud_mcp_server.models.deck import DeckCard, DeckLabel, DeckStack logger = logging.getLogger(__name__) pytestmark = pytest.mark.integration diff --git a/tests/integration/test_error_propagation.py b/tests/integration/test_error_propagation.py index 8cf6667..4812538 100644 --- a/tests/integration/test_error_propagation.py +++ b/tests/integration/test_error_propagation.py @@ -1,9 +1,9 @@ """Test error propagation in the MCP server for various error scenarios.""" import logging -from mcp import ClientSession import pytest +from mcp import ClientSession logger = logging.getLogger(__name__) diff --git a/tests/integration/test_field_preservation.py b/tests/integration/test_field_preservation.py index 62bb473..93bae35 100644 --- a/tests/integration/test_field_preservation.py +++ b/tests/integration/test_field_preservation.py @@ -5,10 +5,11 @@ are present in calendar events and contacts during round-trip operations. """ import logging -import pytest import uuid from datetime import datetime, timedelta +import pytest + logger = logging.getLogger(__name__) diff --git a/tests/integration/test_oauth_interactive.py b/tests/integration/test_oauth_interactive.py index 27a947a..3652769 100644 --- a/tests/integration/test_oauth_interactive.py +++ b/tests/integration/test_oauth_interactive.py @@ -9,35 +9,28 @@ logger = logging.getLogger(__name__) pytestmark = [pytest.mark.integration, pytest.mark.oauth] -class TestOAuthInteractive: - """Test interactive OAuth authentication with manual browser login.""" +async def test_oauth_client_with_interactive_flow(nc_oauth_client_interactive): + """Test that OAuth client created via interactive flow can access Nextcloud APIs.""" + # Test 1: Check capabilities + capabilities = await nc_oauth_client_interactive.capabilities() + assert capabilities is not None + logger.info("OAuth client (interactive) successfully fetched capabilities") - async def test_oauth_client_with_interactive_flow( - self, nc_oauth_client_interactive - ): - """Test that OAuth client created via interactive flow can access Nextcloud APIs.""" - # Test 1: Check capabilities - capabilities = await nc_oauth_client_interactive.capabilities() - assert capabilities is not None - logger.info("OAuth client (interactive) successfully fetched capabilities") + # Test 2: List notes + notes = await nc_oauth_client_interactive.notes.get_all_notes() + assert isinstance(notes, list) + logger.info(f"OAuth client (interactive) successfully listed {len(notes)} notes") - # Test 2: List notes - notes = await nc_oauth_client_interactive.notes.get_all_notes() - assert isinstance(notes, list) - logger.info( - f"OAuth client (interactive) successfully listed {len(notes)} notes" - ) + # Test 3: Create and delete a note + test_note = await nc_oauth_client_interactive.notes.create_note( + title="OAuth Interactive Test Note", + content="This note was created during OAuth interactive testing", + ) + assert test_note is not None + assert test_note.get("id") is not None + note_id = test_note["id"] + logger.info(f"OAuth client (interactive) successfully created note {note_id}") - # Test 3: Create and delete a note - test_note = await nc_oauth_client_interactive.notes.create_note( - title="OAuth Interactive Test Note", - content="This note was created during OAuth interactive testing", - ) - assert test_note is not None - assert test_note.get("id") is not None - note_id = test_note["id"] - logger.info(f"OAuth client (interactive) successfully created note {note_id}") - - # Clean up - await nc_oauth_client_interactive.notes.delete_note(note_id=note_id) - logger.info(f"OAuth client (interactive) successfully deleted note {note_id}") + # Clean up + await nc_oauth_client_interactive.notes.delete_note(note_id=note_id) + logger.info(f"OAuth client (interactive) successfully deleted note {note_id}") diff --git a/tests/test_models.py b/tests/test_models.py index 0f7bf0d..2157617 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,9 +1,9 @@ """Unit tests for Pydantic models and serialization.""" -from datetime import datetime, timezone import json import logging import re +from datetime import datetime, timezone from nextcloud_mcp_server.models.base import BaseResponse