From 59633017b074ef6ccd8c5dbace9007a1836a16b5 Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Thu, 11 Sep 2025 14:15:39 +0000 Subject: [PATCH 001/102] fix(deps): update dependency mcp to >=1.13,<1.14 --- pyproject.toml | 2 +- uv.lock | 28 ++++++++++++++++++++++++---- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 60408aa..2109ae6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ authors = [ readme = "README.md" requires-python = ">=3.11" dependencies = [ - "mcp[cli] (>=1.10,<1.11)", + "mcp[cli] (>=1.13,<1.14)", "httpx (>=0.28.1,<0.29.0)", "pillow (>=11.2.1,<12.0.0)", "icalendar (>=6.0.0,<7.0.0)", diff --git a/uv.lock b/uv.lock index 2562887..5d9e81c 100644 --- a/uv.lock +++ b/uv.lock @@ -469,7 +469,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.10.1" +version = "1.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -479,13 +479,14 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-settings" }, { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, { name = "sse-starlette" }, { name = "starlette" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7c/68/63045305f29ff680a9cd5be360c755270109e6b76f696ea6824547ddbc30/mcp-1.10.1.tar.gz", hash = "sha256:aaa0957d8307feeff180da2d9d359f2b801f35c0c67f1882136239055ef034c2", size = 392969, upload-time = "2025-06-27T12:03:08.982Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/3c/82c400c2d50afdac4fbefb5b4031fd327e2ad1f23ccef8eee13c5909aa48/mcp-1.13.1.tar.gz", hash = "sha256:165306a8fd7991dc80334edd2de07798175a56461043b7ae907b279794a834c5", size = 438198, upload-time = "2025-08-22T09:22:16.061Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/3f/435a5b3d10ae242a9d6c2b33175551173c3c61fe637dc893be05c4ed0aaf/mcp-1.10.1-py3-none-any.whl", hash = "sha256:4d08301aefe906dce0fa482289db55ce1db831e3e67212e65b5e23ad8454b3c5", size = 150878, upload-time = "2025-06-27T12:03:07.328Z" }, + { url = "https://files.pythonhosted.org/packages/19/3f/d085c7f49ade6d273b185d61ec9405e672b6433f710ea64a90135a8dd445/mcp-1.13.1-py3-none-any.whl", hash = "sha256:c314e7c8bd477a23ba3ef472ee5a32880316c42d03e06dcfa31a1cc7a73b65df", size = 161494, upload-time = "2025-08-22T09:22:14.705Z" }, ] [package.optional-dependencies] @@ -532,7 +533,7 @@ requires-dist = [ { name = "click", specifier = ">=8.1.8" }, { name = "httpx", specifier = ">=0.28.1,<0.29.0" }, { name = "icalendar", specifier = ">=6.0.0,<7.0.0" }, - { name = "mcp", extras = ["cli"], specifier = ">=1.10,<1.11" }, + { name = "mcp", extras = ["cli"], specifier = ">=1.13,<1.14" }, { name = "pillow", specifier = ">=11.2.1,<12.0.0" }, { name = "pydantic", specifier = ">=2.11.4" }, { name = "pythonvcard4", specifier = ">=0.2.0" }, @@ -858,6 +859,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/2f/ee10d88bbe12e4e9e06f81589d999687038e5cd5fec6c05aed57c50aede6/pythonvcard4-0.2.0-py3-none-any.whl", hash = "sha256:dce31355dd50aee537f8883de86f301510e407bc1755a68ec8d5055b64f5c660", size = 5890, upload-time = "2025-04-26T23:18:48.2Z" }, ] +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + [[package]] name = "pyyaml" version = "6.0.2" From e7c4eb0842cc1993dc62236ae55682e26bb379b8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 11 Sep 2025 14:21:48 +0000 Subject: [PATCH 002/102] =?UTF-8?q?bump:=20version=200.11.0=20=E2=86=92=20?= =?UTF-8?q?0.11.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0444373..d255ff3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## v0.11.1 (2025-09-11) + +### Fix + +- **deps**: update dependency mcp to >=1.13,<1.14 + ## v0.11.0 (2025-09-11) ### Feat diff --git a/pyproject.toml b/pyproject.toml index 2109ae6..bdc1e4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nextcloud-mcp-server" -version = "0.11.0" +version = "0.11.1" description = "" authors = [ {name = "Chris Coutinho",email = "chris@coutinho.io"} diff --git a/uv.lock b/uv.lock index 5d9e81c..a70619a 100644 --- a/uv.lock +++ b/uv.lock @@ -506,7 +506,7 @@ wheels = [ [[package]] name = "nextcloud-mcp-server" -version = "0.11.0" +version = "0.11.1" source = { editable = "." } dependencies = [ { name = "click" }, From c1c5a61952742a8dfa2c81a17afe6c88f984cbc6 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Thu, 11 Sep 2025 16:59:23 +0200 Subject: [PATCH 003/102] feat(server): Add support for `streamable-http` transport type --- docker-compose.yml | 2 +- nextcloud_mcp_server/app.py | 16 +++++++++++++--- tests/conftest.py | 20 ++++++++++---------- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index caa7204..9cef690 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -46,7 +46,7 @@ services: mcp: build: . - command: ["--host", "0.0.0.0"] + command: ["--host", "0.0.0.0", "--transport", "streamable-http"] ports: - 8000:8000 environment: diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index c75c752..08dee2e 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -2,7 +2,7 @@ import click import logging import uvicorn from collections.abc import AsyncIterator -from contextlib import asynccontextmanager +from contextlib import asynccontextmanager, AsyncExitStack from dataclasses import dataclass from starlette.applications import Starlette @@ -83,9 +83,19 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): f"Unknown app: {app_name}. Available apps: {list(available_apps.keys())}" ) - mcp_app = mcp.sse_app() if transport == "sse" else mcp.streamable_http_app() + if transport == "sse": + mcp_app = mcp.sse_app() + lifespan = None + else: + mcp_app = mcp.streamable_http_app() - app = Starlette(routes=[Mount("/", app=mcp_app)]) + @asynccontextmanager + async def lifespan(app: Starlette): + async with AsyncExitStack() as stack: + await stack.enter_async_context(mcp.session_manager.run()) + yield + + app = Starlette(routes=[Mount("/", app=mcp_app)], lifespan=lifespan) return app diff --git a/tests/conftest.py b/tests/conftest.py index 155099f..296736f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,7 +6,7 @@ from typing import Any, AsyncGenerator import pytest from httpx import HTTPStatusError from mcp import ClientSession -from mcp.client.sse import sse_client +from mcp.client.streamable_http import streamablehttp_client from nextcloud_mcp_server.client import NextcloudClient @@ -39,18 +39,18 @@ async def nc_client() -> AsyncGenerator[NextcloudClient, Any]: await client.close() -@pytest.fixture +@pytest.fixture(scope="session") async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]: """ - Fixture to create an MCP client session for integration tests. + Fixture to create an MCP client session for integration tests using streamable-http. """ - logger.info("Creating SSE client") - sse_context = sse_client(url="http://127.0.0.1:8000/sse") + logger.info("Creating Streamable HTTP client") + streamable_context = streamablehttp_client("http://127.0.0.1:8000/mcp") session_context = None try: - read, write = await sse_context.__aenter__() - session_context = ClientSession(read, write) + read_stream, write_stream, _ = await streamable_context.__aenter__() + session_context = ClientSession(read_stream, write_stream) session = await session_context.__aenter__() await session.initialize() logger.info("MCP client session initialized successfully") @@ -71,14 +71,14 @@ async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]: logger.warning(f"Error closing session: {e}") try: - await sse_context.__aexit__(None, None, None) + await streamable_context.__aexit__(None, None, None) except RuntimeError as e: if "cancel scope" in str(e): logger.debug(f"Ignoring cancel scope teardown issue: {e}") else: - logger.warning(f"Error closing SSE client: {e}") + logger.warning(f"Error closing streamable HTTP client: {e}") except Exception as e: - logger.warning(f"Error closing SSE client: {e}") + logger.warning(f"Error closing streamable HTTP client: {e}") @pytest.fixture From 84106a059e6add2d3df5d889e7cfa5144f884306 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 11 Sep 2025 15:02:22 +0000 Subject: [PATCH 004/102] =?UTF-8?q?bump:=20version=200.11.1=20=E2=86=92=20?= =?UTF-8?q?0.12.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d255ff3..6cdfc88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## v0.12.0 (2025-09-11) + +### Feat + +- **server**: Add support for `streamable-http` transport type + ## v0.11.1 (2025-09-11) ### Fix diff --git a/pyproject.toml b/pyproject.toml index bdc1e4e..f03fbf5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nextcloud-mcp-server" -version = "0.11.1" +version = "0.12.0" description = "" authors = [ {name = "Chris Coutinho",email = "chris@coutinho.io"} diff --git a/uv.lock b/uv.lock index a70619a..64cdbc7 100644 --- a/uv.lock +++ b/uv.lock @@ -506,7 +506,7 @@ wheels = [ [[package]] name = "nextcloud-mcp-server" -version = "0.11.1" +version = "0.12.0" source = { editable = "." } dependencies = [ { name = "click" }, From 2cd91ceee7027473234a74468bfe7fcb50bb5c97 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Thu, 11 Sep 2025 17:10:58 +0200 Subject: [PATCH 005/102] chore: Update README and help text --- README.md | 36 +++++++++++++++++++++++++++++++++--- nextcloud_mcp_server/app.py | 12 +++++++++--- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 09ba408..d6806dc 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,6 @@ included? Feel free to open an issue, or contribute via a pull-request. | Tool | Description | |------|-------------| -| `nc_get_note` | Get a specific note by ID | | `nc_notes_create_note` | Create a new note with title, content, and category | | `nc_notes_update_note` | Update an existing note by ID | | `nc_notes_append_content` | Append content to an existing note with a clear separator | @@ -67,9 +66,7 @@ included? Feel free to open an issue, or contribute via a pull-request. | Tool | Description | |------|-------------| -| `deck_list_boards` | List all Nextcloud Deck boards with optional details and filtering | | `deck_create_board` | Create a new Deck board with title and color | -| `deck_list_stacks` | List all stacks in a board | | `deck_create_stack` | Create a new stack in a board | | `deck_update_stack` | Update stack title and order | | `deck_delete_stack` | Delete a stack and all its cards | @@ -405,6 +402,39 @@ NEXTCLOUD_PASSWORD=your_nextcloud_app_password_or_login_password * `NEXTCLOUD_USERNAME`: Your Nextcloud username. * `NEXTCLOUD_PASSWORD`: **Important:** It is highly recommended to use a dedicated Nextcloud App Password for security. You can generate one in your Nextcloud Security settings. Alternatively, you can use your regular login password, but this is less secure. +## Transport Types + +The server supports two transport types for MCP communication: + +### Streamable HTTP (Recommended) +The streamable-http transport is the recommended and modern transport type that provides improved streaming capabilities: + +```bash +# Use streamable-http transport (recommended) +uv run python -m nextcloud_mcp_server.app --transport streamable-http +``` + +### SSE (Server-Sent Events) - Deprecated +⚠️ **Deprecated**: SSE transport is deprecated and will be removed in a future version. Please migrate to `streamable-http`. + +```bash +# SSE transport (deprecated - for backwards compatibility only) +uv run python -m nextcloud_mcp_server.app --transport sse +``` + +#### Docker Usage with Transports +```bash +# Using streamable-http transport (recommended) +docker run -p 127.0.0.1:8000:8000 --env-file .env --rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest \ + --host 0.0.0.0 --transport streamable-http + +# Using SSE transport (deprecated) +docker run -p 127.0.0.1:8000:8000 --env-file .env --rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest \ + --host 0.0.0.0 --transport sse +``` + +**Note:** When using MCP clients, ensure your client supports the transport type you've configured on the server. Most modern MCP clients support streamable-http. + ## Running the Server ### Locally diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index 08dee2e..4e919c3 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -101,16 +101,22 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): @click.command() -@click.option("--host", "-h", default="127.0.0.1") -@click.option("--port", "-p", type=int, default=8000) +@click.option("--host", "-h", default="127.0.0.1", show_default=True) +@click.option("--port", "-p", type=int, default=8000, show_default=True) @click.option("--workers", "-w", type=int, default=None) @click.option("--reload", "-r", is_flag=True) @click.option( "--log-level", + default="info", + show_default=True, type=click.Choice(["critical", "error", "warning", "info", "debug", "trace"]), ) @click.option( - "--transport", "-t", default="sse", type=click.Choice(["sse", "streamable-http"]) + "--transport", + "-t", + default="sse", + show_default=True, + type=click.Choice(["sse", "streamable-http"]), ) @click.option( "--enable-app", From b3cd2ace348f270824afabe40c2205993001164d Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Thu, 11 Sep 2025 17:28:13 +0200 Subject: [PATCH 006/102] chore: Update README.md, move docs to directory --- README.md | 344 +++--------------------------------- docs/calendar.md | 93 ++++++++++ docs/calender.md | 17 ++ docs/contacts.md | 12 ++ docs/deck.md | 108 +++++++++++ docs/notes.md | 19 ++ docs/table.md | 12 ++ docs/webdav.md | 62 +++++++ nextcloud_mcp_server/app.py | 1 + 9 files changed, 350 insertions(+), 318 deletions(-) create mode 100644 docs/calendar.md create mode 100644 docs/calender.md create mode 100644 docs/contacts.md create mode 100644 docs/deck.md create mode 100644 docs/notes.md create mode 100644 docs/table.md create mode 100644 docs/webdav.md diff --git a/README.md b/README.md index d6806dc..f491f64 100644 --- a/README.md +++ b/README.md @@ -23,91 +23,7 @@ The server provides integration with multiple Nextcloud apps, enabling LLMs to i Is there a Nextcloud app not present in this list that you'd like to be included? Feel free to open an issue, or contribute via a pull-request. -## Available Tools - -### Notes Tools - -| Tool | Description | -|------|-------------| -| `nc_notes_create_note` | Create a new note with title, content, and category | -| `nc_notes_update_note` | Update an existing note by ID | -| `nc_notes_append_content` | Append content to an existing note with a clear separator | -| `nc_notes_delete_note` | Delete a note by ID | -| `nc_notes_search_notes` | Search notes by title or content | - -### Calendar Tools - -| Tool | Description | -|------|-------------| -| `nc_calendar_list_calendars` | List all available calendars for the user | -| `nc_calendar_create_event` | Create a comprehensive calendar event with full feature support (recurring, reminders, attendees, etc.) | -| `nc_calendar_list_events` | **Enhanced:** List events with advanced filtering (min attendees, duration, categories, status, search across all calendars) | -| `nc_calendar_get_event` | Get detailed information about a specific event | -| `nc_calendar_update_event` | Update any aspect of an existing event | -| `nc_calendar_delete_event` | Delete a calendar event | -| `nc_calendar_create_meeting` | Quick meeting creation with smart defaults | -| `nc_calendar_get_upcoming_events` | Get upcoming events in the next N days | -| `nc_calendar_find_availability` | **New:** Intelligent availability finder - find free time slots for meetings with attendee conflict detection | -| `nc_calendar_bulk_operations` | **New:** Bulk update, delete, or move events matching filter criteria | -| `nc_calendar_manage_calendar` | **New:** Create, delete, and manage calendar properties | - -### Contacts Tools - -| Tool | Description | -|------|-------------| -| `nc_contacts_list_addressbooks` | List all available addressbooks for the user | -| `nc_contacts_list_contacts` | List all contacts in a specific addressbook | -| `nc_contacts_create_addressbook` | Create a new addressbook | -| `nc_contacts_delete_addressbook` | Delete an addressbook | -| `nc_contacts_create_contact` | Create a new contact in an addressbook | -| `nc_contacts_delete_contact` | Delete a contact from an addressbook | - -### Deck Tools - -| Tool | Description | -|------|-------------| -| `deck_create_board` | Create a new Deck board with title and color | -| `deck_create_stack` | Create a new stack in a board | -| `deck_update_stack` | Update stack title and order | -| `deck_delete_stack` | Delete a stack and all its cards | -| `deck_create_card` | Create a new card in a stack with full options (title, description, due date, etc.) | -| `deck_update_card` | Update any aspect of a card (title, description, owner, order, etc.) | -| `deck_delete_card` | Delete a card | -| `deck_archive_card` | Archive a card | -| `deck_unarchive_card` | Unarchive a card | -| `deck_reorder_card` | Move/reorder cards within or between stacks | -| `deck_create_label` | Create a new label in a board | -| `deck_update_label` | Update label title and color | -| `deck_delete_label` | Delete a label | -| `deck_assign_label_to_card` | Assign a label to a card | -| `deck_remove_label_from_card` | Remove a label from a card | -| `deck_assign_user_to_card` | Assign a user to a card | -| `deck_unassign_user_from_card` | Remove a user assignment from a card | - -### Tables Tools - -| Tool | Description | -|------|-------------| -| `nc_tables_list_tables` | List all tables available to the user | -| `nc_tables_get_schema` | Get the schema/structure of a specific table including columns and views | -| `nc_tables_read_table` | Read rows from a table with optional pagination | -| `nc_tables_insert_row` | Insert a new row into a table | -| `nc_tables_update_row` | Update an existing row in a table | -| `nc_tables_delete_row` | Delete a row from a table | - -### WebDAV File System Tools - -| Tool | Description | -|------|-------------| -| `nc_webdav_list_directory` | List files and directories in any NextCloud path | -| `nc_webdav_read_file` | Read file content (text files decoded, binary as base64) | -| `nc_webdav_write_file` | Create or update files in NextCloud | -| `nc_webdav_create_directory` | Create new directories | -| `nc_webdav_delete_resource` | Delete files or directories | -| `nc_webdav_move_resource` | Move or rename files and directories | -| `nc_webdav_copy_resource` | Copy files and directories | - -## Available Resources +## Available Tools & Resources Resources provide read-only access to data for browsing and discovery. Unlike tools, resources are automatically listed by MCP clients and enable LLMs to explore your Nextcloud data structure. @@ -118,17 +34,6 @@ Resources provide read-only access to data for browsing and discovery. Unlike to | `notes://settings` | Access Notes app settings | | `nc://Notes/{note_id}/attachments/{attachment_filename}` | Access attachments for notes | -### Deck Resources -| Resource | Description | -|----------|-------------| -| `nc://Deck/boards` | List all deck boards | -| `nc://Deck/boards/{board_id}` | Get details of a specific board | -| `nc://Deck/boards/{board_id}/stacks` | List all stacks in a board | -| `nc://Deck/boards/{board_id}/stacks/{stack_id}` | Get details of a specific stack | -| `nc://Deck/boards/{board_id}/stacks/{stack_id}/cards` | List all cards in a stack | -| `nc://Deck/boards/{board_id}/stacks/{stack_id}/cards/{card_id}` | Get details of a specific card | -| `nc://Deck/boards/{board_id}/labels` | List all labels in a board | -| `nc://Deck/boards/{board_id}/labels/{label_id}` | Get details of a specific label | ### Tools vs Resources @@ -144,231 +49,12 @@ Resources provide read-only access to data for browsing and discovery. Unlike to - Raw data format for exploration - Examples: `nc://Deck/boards/{board_id}`, `nc://Deck/boards/{board_id}/stacks` -### WebDAV File System Access - -The server provides complete file system access to your NextCloud instance, enabling you to: - -- Browse any directory structure -- Read and write files of any type -- Create and delete directories -- Manage your NextCloud files directly through LLM interactions - -**Usage Examples:** - -```python -# List files in root directory -await nc_webdav_list_directory("") - -# Browse a specific folder -await nc_webdav_list_directory("Documents/Projects") - -# Read a text file -content = await nc_webdav_read_file("Documents/readme.txt") - -# Create a new directory -await nc_webdav_create_directory("NewProject/docs") - -# Write content to a file -await nc_webdav_write_file("NewProject/docs/notes.md", "# My Notes\n\nContent here...") - -# Delete a file or directory -await nc_webdav_delete_resource("old_file.txt") - -# Move or rename a file -await nc_webdav_move_resource("document.txt", "new_name.txt") - -# Move a file to another directory -await nc_webdav_move_resource("document.txt", "Archive/document.txt") - -# Move a directory -await nc_webdav_move_resource("Projects/OldProject", "Projects/NewProject") - -# Copy a file -await nc_webdav_copy_resource("document.txt", "document_copy.txt") - -# Copy a file to another directory -await nc_webdav_copy_resource("document.txt", "Backup/document.txt") - -# Copy a directory -await nc_webdav_copy_resource("Projects/ProjectA", "Projects/ProjectA_Backup") -``` - -### Deck Project Management - -The server provides complete Nextcloud Deck integration, enabling you to manage projects, tasks, and workflows: - -- Create and manage boards, stacks, and cards -- Organize tasks with labels and user assignments -- Archive/unarchive cards and reorder within or between stacks -- Full CRUD operations on all Deck entities -- Browse project structure through hierarchical resources - -**Usage Examples:** - -```python -# Create a new project board -await deck_create_board(title="Website Redesign", color="1976D2") - -# Create workflow stacks -await deck_create_stack(board_id=1, title="To Do", order=1) -await deck_create_stack(board_id=1, title="In Progress", order=2) -await deck_create_stack(board_id=1, title="Done", order=3) - -# Create task cards with details -await deck_create_card( - board_id=1, - stack_id=1, - title="Design new homepage", - description="Create mockups for the new homepage layout", - type="plain", - order=1, - duedate="2025-08-15T17:00:00" -) - -# Create and assign labels for organization -await deck_create_label(board_id=1, title="High Priority", color="F44336") -await deck_create_label(board_id=1, title="UI/UX", color="9C27B0") - -# Assign labels and users to cards -await deck_assign_label_to_card(board_id=1, stack_id=1, card_id=1, label_id=1) -await deck_assign_user_to_card(board_id=1, stack_id=1, card_id=1, user_id="designer") - -# Move cards through workflow -await deck_reorder_card( - board_id=1, - stack_id=1, # From "To Do" - card_id=1, - order=1, - target_stack_id=2 # To "In Progress" -) - -# Update task progress -await deck_update_card( - board_id=1, - stack_id=2, - card_id=1, - description="Homepage mockups completed, starting development", - order=1 -) - -# Complete tasks -await deck_reorder_card( - board_id=1, - stack_id=2, # From "In Progress" - card_id=1, - order=1, - target_stack_id=3 # To "Done" -) - -# Archive completed cards -await deck_archive_card(board_id=1, stack_id=3, card_id=1) -``` - -### Calendar Integration - -The server provides comprehensive calendar integration through CalDAV, enabling you to: - -- List all available calendars -- Create, read, update, and delete calendar events -- Handle recurring events with RRULE support -- Manage event reminders and notifications -- Support all-day and timed events -- Handle attendees and meeting invitations -- Organize events with categories and priorities - -**Usage Examples:** - -```python -# List available calendars -calendars = await nc_calendar_list_calendars() - -# Create a simple event -await nc_calendar_create_event( - calendar_name="personal", - title="Team Meeting", - start_datetime="2025-07-28T14:00:00", - end_datetime="2025-07-28T15:00:00", - description="Weekly team sync", - location="Conference Room A" -) - -# Create a recurring weekly meeting -await nc_calendar_create_event( - calendar_name="work", - title="Weekly Standup", - start_datetime="2025-07-28T09:00:00", - end_datetime="2025-07-28T09:30:00", - recurring=True, - recurrence_rule="FREQ=WEEKLY;BYDAY=MO" -) - -# Quick meeting creation -await nc_calendar_create_meeting( - title="Client Call", - date="2025-07-28", - time="15:00", - duration_minutes=60, - attendees="client@example.com,colleague@company.com" -) - -# Get upcoming events -events = await nc_calendar_get_upcoming_events(days_ahead=7) - -# Advanced search - find all meetings with 5+ attendees lasting 2+ hours -long_meetings = await nc_calendar_list_events( - calendar_name="", # Search all calendars - search_all_calendars=True, - start_date="2025-07-01", - end_date="2025-07-31", - min_attendees=5, - min_duration_minutes=120, - title_contains="meeting" -) - -# Find availability for a 1-hour meeting with specific attendees -availability = await nc_calendar_find_availability( - duration_minutes=60, - attendees="sarah@company.com,mike@company.com", - date_range_start="2025-07-28", - date_range_end="2025-08-04", - business_hours_only=True, - exclude_weekends=True, - preferred_times="09:00-12:00,14:00-17:00" -) - -# Bulk update all team meetings to new location -bulk_result = await nc_calendar_bulk_operations( - operation="update", - title_contains="team meeting", - start_date="2025-08-01", - end_date="2025-08-31", - new_location="Conference Room B", - new_reminder_minutes=15 -) - -# Create a new project calendar -new_calendar = await nc_calendar_manage_calendar( - action="create", - calendar_name="project-alpha", - display_name="Project Alpha Calendar", - description="Calendar for Project Alpha team", - color="#FF5722" -) -``` - -### Note Attachments - -This server supports adding and retrieving note attachments via WebDAV. Please note the following behavior regarding attachments: - -* When a note is deleted, its attachments remain in the system. This matches the behavior of the official Nextcloud Notes app. -* Orphaned attachments (attachments whose parent notes have been deleted) may accumulate over time. -* WebDAV permissions must be properly configured for attachment operations to work correctly. ## Installation ### Prerequisites -* Python 3.8+ +* Python 3.11+ * Access to a Nextcloud instance ### Local Installation @@ -383,6 +69,27 @@ This server supports adding and retrieving note attachments via WebDAV. Please n uv sync ``` +3. Run the CLI --help command to see all available options + ```bash + $ uv run python -m nextcloud_mcp_server.app --help + Usage: python -m nextcloud_mcp_server.app [OPTIONS] + + Options: + -h, --host TEXT [default: 127.0.0.1] + -p, --port INTEGER [default: 8000] + -w, --workers INTEGER + -r, --reload + -l, --log-level [critical|error|warning|info|debug|trace] + [default: info] + -t, --transport [sse|streamable-http] + [default: sse] + -e, --enable-app [notes|tables|webdav|calendar|contacts|deck] + Enable specific Nextcloud app APIs. Can be + specified multiple times. If not specified, + all apps are enabled. + --help Show this message and exit. + ``` + ### Docker A pre-built Docker image is available: `ghcr.io/cbcoutinho/nextcloud-mcp-server` @@ -407,7 +114,7 @@ NEXTCLOUD_PASSWORD=your_nextcloud_app_password_or_login_password The server supports two transport types for MCP communication: ### Streamable HTTP (Recommended) -The streamable-http transport is the recommended and modern transport type that provides improved streaming capabilities: +The `streamable-http` transport is the recommended and modern transport type that provides improved streaming capabilities: ```bash # Use streamable-http transport (recommended) @@ -415,7 +122,8 @@ uv run python -m nextcloud_mcp_server.app --transport streamable-http ``` ### SSE (Server-Sent Events) - Deprecated -⚠️ **Deprecated**: SSE transport is deprecated and will be removed in a future version. Please migrate to `streamable-http`. +> [!WARNING] +> ⚠️ **Deprecated**: SSE transport is deprecated and will be removed in a future version of the MCP spec. SSE will be supported for the foreseable future, but users are encouraged to switch to the new transport type. Please migrate to `streamable-http`. ```bash # SSE transport (deprecated - for backwards compatibility only) diff --git a/docs/calendar.md b/docs/calendar.md new file mode 100644 index 0000000..1cf3e52 --- /dev/null +++ b/docs/calendar.md @@ -0,0 +1,93 @@ +# Calendar App + +### Calendar Integration + +The server provides comprehensive calendar integration through CalDAV, enabling you to: + +- List all available calendars +- Create, read, update, and delete calendar events +- Handle recurring events with RRULE support +- Manage event reminders and notifications +- Support all-day and timed events +- Handle attendees and meeting invitations +- Organize events with categories and priorities + +**Usage Examples:** + +```python +# List available calendars +calendars = await nc_calendar_list_calendars() + +# Create a simple event +await nc_calendar_create_event( + calendar_name="personal", + title="Team Meeting", + start_datetime="2025-07-28T14:00:00", + end_datetime="2025-07-28T15:00:00", + description="Weekly team sync", + location="Conference Room A" +) + +# Create a recurring weekly meeting +await nc_calendar_create_event( + calendar_name="work", + title="Weekly Standup", + start_datetime="2025-07-28T09:00:00", + end_datetime="2025-07-28T09:30:00", + recurring=True, + recurrence_rule="FREQ=WEEKLY;BYDAY=MO" +) + +# Quick meeting creation +await nc_calendar_create_meeting( + title="Client Call", + date="2025-07-28", + time="15:00", + duration_minutes=60, + attendees="client@example.com,colleague@company.com" +) + +# Get upcoming events +events = await nc_calendar_get_upcoming_events(days_ahead=7) + +# Advanced search - find all meetings with 5+ attendees lasting 2+ hours +long_meetings = await nc_calendar_list_events( + calendar_name="", # Search all calendars + search_all_calendars=True, + start_date="2025-07-01", + end_date="2025-07-31", + min_attendees=5, + min_duration_minutes=120, + title_contains="meeting" +) + +# Find availability for a 1-hour meeting with specific attendees +availability = await nc_calendar_find_availability( + duration_minutes=60, + attendees="sarah@company.com,mike@company.com", + date_range_start="2025-07-28", + date_range_end="2025-08-04", + business_hours_only=True, + exclude_weekends=True, + preferred_times="09:00-12:00,14:00-17:00" +) + +# Bulk update all team meetings to new location +bulk_result = await nc_calendar_bulk_operations( + operation="update", + title_contains="team meeting", + start_date="2025-08-01", + end_date="2025-08-31", + new_location="Conference Room B", + new_reminder_minutes=15 +) + +# Create a new project calendar +new_calendar = await nc_calendar_manage_calendar( + action="create", + calendar_name="project-alpha", + display_name="Project Alpha Calendar", + description="Calendar for Project Alpha team", + color="#FF5722" +) +``` diff --git a/docs/calender.md b/docs/calender.md new file mode 100644 index 0000000..9fa3121 --- /dev/null +++ b/docs/calender.md @@ -0,0 +1,17 @@ +# Calendar App + +### Calendar Tools + +| Tool | Description | +|------|-------------| +| `nc_calendar_list_calendars` | List all available calendars for the user | +| `nc_calendar_create_event` | Create a comprehensive calendar event with full feature support (recurring, reminders, attendees, etc.) | +| `nc_calendar_list_events` | **Enhanced:** List events with advanced filtering (min attendees, duration, categories, status, search across all calendars) | +| `nc_calendar_get_event` | Get detailed information about a specific event | +| `nc_calendar_update_event` | Update any aspect of an existing event | +| `nc_calendar_delete_event` | Delete a calendar event | +| `nc_calendar_create_meeting` | Quick meeting creation with smart defaults | +| `nc_calendar_get_upcoming_events` | Get upcoming events in the next N days | +| `nc_calendar_find_availability` | **New:** Intelligent availability finder - find free time slots for meetings with attendee conflict detection | +| `nc_calendar_bulk_operations` | **New:** Bulk update, delete, or move events matching filter criteria | +| `nc_calendar_manage_calendar` | **New:** Create, delete, and manage calendar properties | diff --git a/docs/contacts.md b/docs/contacts.md new file mode 100644 index 0000000..70023d8 --- /dev/null +++ b/docs/contacts.md @@ -0,0 +1,12 @@ +# Contacts App + +### Contacts Tools + +| Tool | Description | +|------|-------------| +| `nc_contacts_list_addressbooks` | List all available addressbooks for the user | +| `nc_contacts_list_contacts` | List all contacts in a specific addressbook | +| `nc_contacts_create_addressbook` | Create a new addressbook | +| `nc_contacts_delete_addressbook` | Delete an addressbook | +| `nc_contacts_create_contact` | Create a new contact in an addressbook | +| `nc_contacts_delete_contact` | Delete a contact from an addressbook | diff --git a/docs/deck.md b/docs/deck.md new file mode 100644 index 0000000..51a51bf --- /dev/null +++ b/docs/deck.md @@ -0,0 +1,108 @@ +# Deck App + +### Deck Tools + +| Tool | Description | +|------|-------------| +| `deck_create_board` | Create a new Deck board with title and color | +| `deck_create_stack` | Create a new stack in a board | +| `deck_update_stack` | Update stack title and order | +| `deck_delete_stack` | Delete a stack and all its cards | +| `deck_create_card` | Create a new card in a stack with full options (title, description, due date, etc.) | +| `deck_update_card` | Update any aspect of a card (title, description, owner, order, etc.) | +| `deck_delete_card` | Delete a card | +| `deck_archive_card` | Archive a card | +| `deck_unarchive_card` | Unarchive a card | +| `deck_reorder_card` | Move/reorder cards within or between stacks | +| `deck_create_label` | Create a new label in a board | +| `deck_update_label` | Update label title and color | +| `deck_delete_label` | Delete a label | +| `deck_assign_label_to_card` | Assign a label to a card | +| `deck_remove_label_from_card` | Remove a label from a card | +| `deck_assign_user_to_card` | Assign a user to a card | +| `deck_unassign_user_from_card` | Remove a user assignment from a card | + +### Deck Resources +| Resource | Description | +|----------|-------------| +| `nc://Deck/boards` | List all deck boards | +| `nc://Deck/boards/{board_id}` | Get details of a specific board | +| `nc://Deck/boards/{board_id}/stacks` | List all stacks in a board | +| `nc://Deck/boards/{board_id}/stacks/{stack_id}` | Get details of a specific stack | +| `nc://Deck/boards/{board_id}/stacks/{stack_id}/cards` | List all cards in a stack | +| `nc://Deck/boards/{board_id}/stacks/{stack_id}/cards/{card_id}` | Get details of a specific card | +| `nc://Deck/boards/{board_id}/labels` | List all labels in a board | +| `nc://Deck/boards/{board_id}/labels/{label_id}` | Get details of a specific label | + + + +### Deck Project Management + +The server provides complete Nextcloud Deck integration, enabling you to manage projects, tasks, and workflows: + +- Create and manage boards, stacks, and cards +- Organize tasks with labels and user assignments +- Archive/unarchive cards and reorder within or between stacks +- Full CRUD operations on all Deck entities +- Browse project structure through hierarchical resources + +**Usage Examples:** + +```python +# Create a new project board +await deck_create_board(title="Website Redesign", color="1976D2") + +# Create workflow stacks +await deck_create_stack(board_id=1, title="To Do", order=1) +await deck_create_stack(board_id=1, title="In Progress", order=2) +await deck_create_stack(board_id=1, title="Done", order=3) + +# Create task cards with details +await deck_create_card( + board_id=1, + stack_id=1, + title="Design new homepage", + description="Create mockups for the new homepage layout", + type="plain", + order=1, + duedate="2025-08-15T17:00:00" +) + +# Create and assign labels for organization +await deck_create_label(board_id=1, title="High Priority", color="F44336") +await deck_create_label(board_id=1, title="UI/UX", color="9C27B0") + +# Assign labels and users to cards +await deck_assign_label_to_card(board_id=1, stack_id=1, card_id=1, label_id=1) +await deck_assign_user_to_card(board_id=1, stack_id=1, card_id=1, user_id="designer") + +# Move cards through workflow +await deck_reorder_card( + board_id=1, + stack_id=1, # From "To Do" + card_id=1, + order=1, + target_stack_id=2 # To "In Progress" +) + +# Update task progress +await deck_update_card( + board_id=1, + stack_id=2, + card_id=1, + description="Homepage mockups completed, starting development", + order=1 +) + +# Complete tasks +await deck_reorder_card( + board_id=1, + stack_id=2, # From "In Progress" + card_id=1, + order=1, + target_stack_id=3 # To "Done" +) + +# Archive completed cards +await deck_archive_card(board_id=1, stack_id=3, card_id=1) +``` diff --git a/docs/notes.md b/docs/notes.md new file mode 100644 index 0000000..f7fa2c1 --- /dev/null +++ b/docs/notes.md @@ -0,0 +1,19 @@ +# Notes App + +### Notes Tools + +| Tool | Description | +|------|-------------| +| `nc_notes_create_note` | Create a new note with title, content, and category | +| `nc_notes_update_note` | Update an existing note by ID | +| `nc_notes_append_content` | Append content to an existing note with a clear separator | +| `nc_notes_delete_note` | Delete a note by ID | +| `nc_notes_search_notes` | Search notes by title or content | + +### Note Attachments + +This server supports adding and retrieving note attachments via WebDAV. Please note the following behavior regarding attachments: + +* When a note is deleted, its attachments remain in the system. This matches the behavior of the official Nextcloud Notes app. +* Orphaned attachments (attachments whose parent notes have been deleted) may accumulate over time. +* WebDAV permissions must be properly configured for attachment operations to work correctly. diff --git a/docs/table.md b/docs/table.md new file mode 100644 index 0000000..e702cdc --- /dev/null +++ b/docs/table.md @@ -0,0 +1,12 @@ +# Tables App + +### Tables Tools + +| Tool | Description | +|------|-------------| +| `nc_tables_list_tables` | List all tables available to the user | +| `nc_tables_get_schema` | Get the schema/structure of a specific table including columns and views | +| `nc_tables_read_table` | Read rows from a table with optional pagination | +| `nc_tables_insert_row` | Insert a new row into a table | +| `nc_tables_update_row` | Update an existing row in a table | +| `nc_tables_delete_row` | Delete a row from a table | diff --git a/docs/webdav.md b/docs/webdav.md new file mode 100644 index 0000000..8009029 --- /dev/null +++ b/docs/webdav.md @@ -0,0 +1,62 @@ +# WebDAV support + +### WebDAV File System Tools + +| Tool | Description | +|------|-------------| +| `nc_webdav_list_directory` | List files and directories in any NextCloud path | +| `nc_webdav_read_file` | Read file content (text files decoded, binary as base64) | +| `nc_webdav_write_file` | Create or update files in NextCloud | +| `nc_webdav_create_directory` | Create new directories | +| `nc_webdav_delete_resource` | Delete files or directories | +| `nc_webdav_move_resource` | Move or rename files and directories | +| `nc_webdav_copy_resource` | Copy files and directories | + +### WebDAV File System Access + +The server provides complete file system access to your NextCloud instance, enabling you to: + +- Browse any directory structure +- Read and write files of any type +- Create and delete directories +- Manage your NextCloud files directly through LLM interactions + +**Usage Examples:** + +```python +# List files in root directory +await nc_webdav_list_directory("") + +# Browse a specific folder +await nc_webdav_list_directory("Documents/Projects") + +# Read a text file +content = await nc_webdav_read_file("Documents/readme.txt") + +# Create a new directory +await nc_webdav_create_directory("NewProject/docs") + +# Write content to a file +await nc_webdav_write_file("NewProject/docs/notes.md", "# My Notes\n\nContent here...") + +# Delete a file or directory +await nc_webdav_delete_resource("old_file.txt") + +# Move or rename a file +await nc_webdav_move_resource("document.txt", "new_name.txt") + +# Move a file to another directory +await nc_webdav_move_resource("document.txt", "Archive/document.txt") + +# Move a directory +await nc_webdav_move_resource("Projects/OldProject", "Projects/NewProject") + +# Copy a file +await nc_webdav_copy_resource("document.txt", "document_copy.txt") + +# Copy a file to another directory +await nc_webdav_copy_resource("document.txt", "Backup/document.txt") + +# Copy a directory +await nc_webdav_copy_resource("Projects/ProjectA", "Projects/ProjectA_Backup") +``` diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index 4e919c3..6bb1371 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -107,6 +107,7 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): @click.option("--reload", "-r", is_flag=True) @click.option( "--log-level", + "-l", default="info", show_default=True, type=click.Choice(["critical", "error", "warning", "info", "debug", "trace"]), From 9711d1d1616e551207bf9bed40ae2bd8f6b881b3 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Thu, 11 Sep 2025 17:31:00 +0200 Subject: [PATCH 007/102] docs: fix duplicate --- docs/calendar.md | 16 ++++++++++++++++ docs/calender.md | 17 ----------------- 2 files changed, 16 insertions(+), 17 deletions(-) delete mode 100644 docs/calender.md diff --git a/docs/calendar.md b/docs/calendar.md index 1cf3e52..c049495 100644 --- a/docs/calendar.md +++ b/docs/calendar.md @@ -1,5 +1,21 @@ # Calendar App +### Calendar Tools + +| Tool | Description | +|------|-------------| +| `nc_calendar_list_calendars` | List all available calendars for the user | +| `nc_calendar_create_event` | Create a comprehensive calendar event with full feature support (recurring, reminders, attendees, etc.) | +| `nc_calendar_list_events` | **Enhanced:** List events with advanced filtering (min attendees, duration, categories, status, search across all calendars) | +| `nc_calendar_get_event` | Get detailed information about a specific event | +| `nc_calendar_update_event` | Update any aspect of an existing event | +| `nc_calendar_delete_event` | Delete a calendar event | +| `nc_calendar_create_meeting` | Quick meeting creation with smart defaults | +| `nc_calendar_get_upcoming_events` | Get upcoming events in the next N days | +| `nc_calendar_find_availability` | **New:** Intelligent availability finder - find free time slots for meetings with attendee conflict detection | +| `nc_calendar_bulk_operations` | **New:** Bulk update, delete, or move events matching filter criteria | +| `nc_calendar_manage_calendar` | **New:** Create, delete, and manage calendar properties | + ### Calendar Integration The server provides comprehensive calendar integration through CalDAV, enabling you to: diff --git a/docs/calender.md b/docs/calender.md deleted file mode 100644 index 9fa3121..0000000 --- a/docs/calender.md +++ /dev/null @@ -1,17 +0,0 @@ -# Calendar App - -### Calendar Tools - -| Tool | Description | -|------|-------------| -| `nc_calendar_list_calendars` | List all available calendars for the user | -| `nc_calendar_create_event` | Create a comprehensive calendar event with full feature support (recurring, reminders, attendees, etc.) | -| `nc_calendar_list_events` | **Enhanced:** List events with advanced filtering (min attendees, duration, categories, status, search across all calendars) | -| `nc_calendar_get_event` | Get detailed information about a specific event | -| `nc_calendar_update_event` | Update any aspect of an existing event | -| `nc_calendar_delete_event` | Delete a calendar event | -| `nc_calendar_create_meeting` | Quick meeting creation with smart defaults | -| `nc_calendar_get_upcoming_events` | Get upcoming events in the next N days | -| `nc_calendar_find_availability` | **New:** Intelligent availability finder - find free time slots for meetings with attendee conflict detection | -| `nc_calendar_bulk_operations` | **New:** Bulk update, delete, or move events matching filter criteria | -| `nc_calendar_manage_calendar` | **New:** Create, delete, and manage calendar properties | From 5bdf8400987dff82397d7d0eb8a708be10da6f10 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Thu, 11 Sep 2025 17:36:00 +0200 Subject: [PATCH 008/102] chore: Update docker-compose.yml --- docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 9cef690..cf92037 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,7 +28,7 @@ services: #- command: chown -R www-data:www-data /var/www/html && while ! nc -z db 3306; do sleep 1; echo sleeping; done #user: root ports: - - 8080:80 + - 127.0.0.1:8080:80 depends_on: - redis - db @@ -48,7 +48,7 @@ services: build: . command: ["--host", "0.0.0.0", "--transport", "streamable-http"] ports: - - 8000:8000 + - 127.0.0.1:8000:8000 environment: - NEXTCLOUD_HOST=http://app:80 - NEXTCLOUD_USERNAME=admin From 06042357f8a37b125b27d8b4ab4224c52986fdcc Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Thu, 11 Sep 2025 17:44:45 +0200 Subject: [PATCH 009/102] fix(docker): Provide --host 0.0.0.0 in default docker image --- Dockerfile | 2 +- README.md | 21 ++++++++++----------- docker-compose.yml | 2 +- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/Dockerfile b/Dockerfile index 09ce181..11ef0ab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,4 +6,4 @@ COPY . . RUN uv sync --locked --no-dev -ENTRYPOINT ["/app/.venv/bin/python", "-m", "nextcloud_mcp_server.app"] +ENTRYPOINT ["/app/.venv/bin/python", "-m", "nextcloud_mcp_server.app", "--host", "0.0.0.0"] diff --git a/README.md b/README.md index f491f64..2ff0180 100644 --- a/README.md +++ b/README.md @@ -131,14 +131,14 @@ uv run python -m nextcloud_mcp_server.app --transport sse ``` #### Docker Usage with Transports + ```bash +# Using SSE transport (default - deprecated) +docker run -p 127.0.0.1:8000:8000 --env-file .env --rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest + # Using streamable-http transport (recommended) docker run -p 127.0.0.1:8000:8000 --env-file .env --rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest \ - --host 0.0.0.0 --transport streamable-http - -# Using SSE transport (deprecated) -docker run -p 127.0.0.1:8000:8000 --env-file .env --rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest \ - --host 0.0.0.0 --transport sse + --transport streamable-http ``` **Note:** When using MCP clients, ensure your client supports the transport type you've configured on the server. Most modern MCP clients support streamable-http. @@ -154,8 +154,8 @@ Ensure your environment variables are loaded, then run the server. You have seve # Load environment variables from your .env file export $(grep -v '^#' .env | xargs) -# Or run the app module directly with custom options -uv run python -m nextcloud_mcp_server.app --host 0.0.0.0 --port 8080 --log-level info --reload +# Run the app module directly with custom options +uv run python -m nextcloud_mcp_server.app --host 0.0.0.0 --port 8080 --log-level info # Enable only specific Nextcloud app APIs uv run python -m nextcloud_mcp_server.app --enable-app notes --enable-app calendar @@ -217,16 +217,15 @@ Mount your environment file when running the container: ```bash # Run with all apps enabled (default) -docker run -p 127.0.0.1:8000:8000 --env-file .env --rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest \ - --host 0.0.0.0 +docker run -p 127.0.0.1:8000:8000 --env-file .env --rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest # Run with only specific apps enabled docker run -p 127.0.0.1:8000:8000 --env-file .env --rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest \ - --host 0.0.0.0 --enable-app notes --enable-app calendar + --enable-app notes --enable-app calendar # Run with only WebDAV docker run -p 127.0.0.1:8000:8000 --env-file .env --rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest \ - --host 0.0.0.0 --enable-app webdav + --enable-app webdav ``` This will start the server and expose it on port 8000 of your local machine. diff --git a/docker-compose.yml b/docker-compose.yml index cf92037..697ec65 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -46,7 +46,7 @@ services: mcp: build: . - command: ["--host", "0.0.0.0", "--transport", "streamable-http"] + command: ["--transport", "streamable-http"] ports: - 127.0.0.1:8000:8000 environment: From fad2cd8dcb77f910d32650a640e075935c2e0f6d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 11 Sep 2025 15:45:22 +0000 Subject: [PATCH 010/102] =?UTF-8?q?bump:=20version=200.12.0=20=E2=86=92=20?= =?UTF-8?q?0.12.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cdfc88..aa9d6ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## v0.12.1 (2025-09-11) + +### Fix + +- **docker**: Provide --host 0.0.0.0 in default docker image + ## v0.12.0 (2025-09-11) ### Feat diff --git a/pyproject.toml b/pyproject.toml index f03fbf5..46e5853 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nextcloud-mcp-server" -version = "0.12.0" +version = "0.12.1" description = "" authors = [ {name = "Chris Coutinho",email = "chris@coutinho.io"} diff --git a/uv.lock b/uv.lock index 64cdbc7..42b0972 100644 --- a/uv.lock +++ b/uv.lock @@ -506,7 +506,7 @@ wheels = [ [[package]] name = "nextcloud-mcp-server" -version = "0.12.0" +version = "0.12.1" source = { editable = "." } dependencies = [ { name = "click" }, From 4ea6ce347737c6f50dee737d1ce1d04af696f8d8 Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Fri, 12 Sep 2025 16:05:34 +0000 Subject: [PATCH 011/102] chore(deps): update nextcloud:31.0.8 docker digest to 92bc503 --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 697ec65..ddcc815 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,7 +21,7 @@ services: restart: always app: - image: nextcloud:31.0.8@sha256:c3329db9d0d0d79b1fe6433b54b81c28acaefecfe96a400be202b7da80f6b8ca + image: nextcloud:31.0.8@sha256:92bc503ea0c19789f402b0469ecfb8df1ffea81e2bf90a45bba39063a626aa00 #user: www-data:www-data restart: always #post_start: From ffbb86df57bdfe7d95bb3dbde60e03f6a365d865 Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Sat, 13 Sep 2025 09:02:50 +0000 Subject: [PATCH 012/102] chore(deps): lock file maintenance --- uv.lock | 564 ++++++++++++++++++++++++++++++++++---------------------- 1 file changed, 348 insertions(+), 216 deletions(-) diff --git a/uv.lock b/uv.lock index 42b0972..06f6558 100644 --- a/uv.lock +++ b/uv.lock @@ -13,16 +13,16 @@ wheels = [ [[package]] name = "anyio" -version = "4.9.0" +version = "4.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "sniffio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, + { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, ] [[package]] @@ -54,71 +54,76 @@ wheels = [ [[package]] name = "certifi" -version = "2025.4.26" +version = "2025.8.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705, upload-time = "2025-04-26T02:12:29.51Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload-time = "2025-04-26T02:12:27.662Z" }, + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, ] [[package]] name = "charset-normalizer" -version = "3.4.2" +version = "3.4.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" } +sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/85/4c40d00dcc6284a1c1ad5de5e0996b06f39d8232f1031cd23c2f5c07ee86/charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2", size = 198794, upload-time = "2025-05-02T08:32:11.945Z" }, - { url = "https://files.pythonhosted.org/packages/41/d9/7a6c0b9db952598e97e93cbdfcb91bacd89b9b88c7c983250a77c008703c/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645", size = 142846, upload-time = "2025-05-02T08:32:13.946Z" }, - { url = "https://files.pythonhosted.org/packages/66/82/a37989cda2ace7e37f36c1a8ed16c58cf48965a79c2142713244bf945c89/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd", size = 153350, upload-time = "2025-05-02T08:32:15.873Z" }, - { url = "https://files.pythonhosted.org/packages/df/68/a576b31b694d07b53807269d05ec3f6f1093e9545e8607121995ba7a8313/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8", size = 145657, upload-time = "2025-05-02T08:32:17.283Z" }, - { url = "https://files.pythonhosted.org/packages/92/9b/ad67f03d74554bed3aefd56fe836e1623a50780f7c998d00ca128924a499/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f", size = 147260, upload-time = "2025-05-02T08:32:18.807Z" }, - { url = "https://files.pythonhosted.org/packages/a6/e6/8aebae25e328160b20e31a7e9929b1578bbdc7f42e66f46595a432f8539e/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7", size = 149164, upload-time = "2025-05-02T08:32:20.333Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f2/b3c2f07dbcc248805f10e67a0262c93308cfa149a4cd3d1fe01f593e5fd2/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9", size = 144571, upload-time = "2025-05-02T08:32:21.86Z" }, - { url = "https://files.pythonhosted.org/packages/60/5b/c3f3a94bc345bc211622ea59b4bed9ae63c00920e2e8f11824aa5708e8b7/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544", size = 151952, upload-time = "2025-05-02T08:32:23.434Z" }, - { url = "https://files.pythonhosted.org/packages/e2/4d/ff460c8b474122334c2fa394a3f99a04cf11c646da895f81402ae54f5c42/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82", size = 155959, upload-time = "2025-05-02T08:32:24.993Z" }, - { url = "https://files.pythonhosted.org/packages/a2/2b/b964c6a2fda88611a1fe3d4c400d39c66a42d6c169c924818c848f922415/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0", size = 153030, upload-time = "2025-05-02T08:32:26.435Z" }, - { url = "https://files.pythonhosted.org/packages/59/2e/d3b9811db26a5ebf444bc0fa4f4be5aa6d76fc6e1c0fd537b16c14e849b6/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5", size = 148015, upload-time = "2025-05-02T08:32:28.376Z" }, - { url = "https://files.pythonhosted.org/packages/90/07/c5fd7c11eafd561bb51220d600a788f1c8d77c5eef37ee49454cc5c35575/charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a", size = 98106, upload-time = "2025-05-02T08:32:30.281Z" }, - { url = "https://files.pythonhosted.org/packages/a8/05/5e33dbef7e2f773d672b6d79f10ec633d4a71cd96db6673625838a4fd532/charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28", size = 105402, upload-time = "2025-05-02T08:32:32.191Z" }, - { url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936, upload-time = "2025-05-02T08:32:33.712Z" }, - { url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790, upload-time = "2025-05-02T08:32:35.768Z" }, - { url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924, upload-time = "2025-05-02T08:32:37.284Z" }, - { url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626, upload-time = "2025-05-02T08:32:38.803Z" }, - { url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567, upload-time = "2025-05-02T08:32:40.251Z" }, - { url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957, upload-time = "2025-05-02T08:32:41.705Z" }, - { url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408, upload-time = "2025-05-02T08:32:43.709Z" }, - { url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399, upload-time = "2025-05-02T08:32:46.197Z" }, - { url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815, upload-time = "2025-05-02T08:32:48.105Z" }, - { url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537, upload-time = "2025-05-02T08:32:49.719Z" }, - { url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565, upload-time = "2025-05-02T08:32:51.404Z" }, - { url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357, upload-time = "2025-05-02T08:32:53.079Z" }, - { url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776, upload-time = "2025-05-02T08:32:54.573Z" }, - { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" }, - { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" }, - { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" }, - { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" }, - { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" }, - { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" }, - { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" }, - { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" }, - { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" }, - { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" }, - { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" }, - { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" }, - { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" }, - { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483, upload-time = "2025-08-09T07:55:53.12Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520, upload-time = "2025-08-09T07:55:54.712Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876, upload-time = "2025-08-09T07:55:56.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", size = 156083, upload-time = "2025-08-09T07:55:57.582Z" }, + { url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", size = 150295, upload-time = "2025-08-09T07:55:59.147Z" }, + { url = "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", size = 148379, upload-time = "2025-08-09T07:56:00.364Z" }, + { url = "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", size = 160018, upload-time = "2025-08-09T07:56:01.678Z" }, + { url = "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", size = 157430, upload-time = "2025-08-09T07:56:02.87Z" }, + { url = "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", size = 151600, upload-time = "2025-08-09T07:56:04.089Z" }, + { url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616, upload-time = "2025-08-09T07:56:05.658Z" }, + { url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108, upload-time = "2025-08-09T07:56:07.176Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" }, + { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" }, + { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" }, + { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" }, + { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" }, + { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" }, + { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, + { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, + { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, + { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, + { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, + { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, + { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, + { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, + { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" }, + { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" }, + { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" }, + { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, ] [[package]] name = "click" -version = "8.1.8" +version = "8.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } +sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, + { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, ] [[package]] @@ -132,73 +137,100 @@ wheels = [ [[package]] name = "commitizen" -version = "4.8.2" +version = "4.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "argcomplete" }, { name = "charset-normalizer" }, { name = "colorama" }, { name = "decli" }, + { name = "deprecated" }, { name = "jinja2" }, { name = "packaging" }, + { name = "prompt-toolkit" }, { name = "pyyaml" }, { name = "questionary" }, { name = "termcolor" }, { name = "tomlkit" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/64/15/c2fe85c0224886109b5061419acea2e20539be1b4bff619a16d7295fe0f2/commitizen-4.8.2.tar.gz", hash = "sha256:4fc73126c7300f715f11b85242550677722c57767b579100e869ccd45143e2c5", size = 53235, upload-time = "2025-05-22T03:16:39.915Z" } +sdist = { url = "https://files.pythonhosted.org/packages/77/19/927ac5b0eabb9451e2d5bb45b30813915c9a1260713b5b68eeb31358ea23/commitizen-4.9.1.tar.gz", hash = "sha256:b076b24657718f7a35b1068f2083bd39b4065d250164a1398d1dac235c51753b", size = 56610, upload-time = "2025-09-10T14:19:33.746Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/40/2b81df1b3ec24c41004512feba0884895b84748775d21642690120539a30/commitizen-4.8.2-py3-none-any.whl", hash = "sha256:86cae0bd8e1da889389d828b30a5acb79b62f9290f9274b127ee9d8c189eb16c", size = 76074, upload-time = "2025-05-22T03:16:38.431Z" }, + { url = "https://files.pythonhosted.org/packages/cf/49/577035b841442fe031b017027c3d99278b46104d227f0353c69dbbe55148/commitizen-4.9.1-py3-none-any.whl", hash = "sha256:4241b2ecae97b8109af8e587c36bc3b805a09b9a311084d159098e12d6ead497", size = 80624, upload-time = "2025-09-10T14:19:32.102Z" }, ] [[package]] name = "coverage" -version = "7.8.0" +version = "7.10.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/4f/2251e65033ed2ce1e68f00f91a0294e0f80c80ae8c3ebbe2f12828c4cd53/coverage-7.8.0.tar.gz", hash = "sha256:7a3d62b3b03b4b6fd41a085f3574874cf946cb4604d2b4d3e8dca8cd570ca501", size = 811872, upload-time = "2025-03-30T20:36:45.376Z" } +sdist = { url = "https://files.pythonhosted.org/packages/14/70/025b179c993f019105b79575ac6edb5e084fb0f0e63f15cdebef4e454fb5/coverage-7.10.6.tar.gz", hash = "sha256:f644a3ae5933a552a29dbb9aa2f90c677a875f80ebea028e5a52a4f429044b90", size = 823736, upload-time = "2025-08-29T15:35:16.668Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/77/074d201adb8383addae5784cb8e2dac60bb62bfdf28b2b10f3a3af2fda47/coverage-7.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7ac22a0bb2c7c49f441f7a6d46c9c80d96e56f5a8bc6972529ed43c8b694e27", size = 211493, upload-time = "2025-03-30T20:35:12.286Z" }, - { url = "https://files.pythonhosted.org/packages/a9/89/7a8efe585750fe59b48d09f871f0e0c028a7b10722b2172dfe021fa2fdd4/coverage-7.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf13d564d310c156d1c8e53877baf2993fb3073b2fc9f69790ca6a732eb4bfea", size = 211921, upload-time = "2025-03-30T20:35:14.18Z" }, - { url = "https://files.pythonhosted.org/packages/e9/ef/96a90c31d08a3f40c49dbe897df4f1fd51fb6583821a1a1c5ee30cc8f680/coverage-7.8.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5761c70c017c1b0d21b0815a920ffb94a670c8d5d409d9b38857874c21f70d7", size = 244556, upload-time = "2025-03-30T20:35:15.616Z" }, - { url = "https://files.pythonhosted.org/packages/89/97/dcd5c2ce72cee9d7b0ee8c89162c24972fb987a111b92d1a3d1d19100c61/coverage-7.8.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5ff52d790c7e1628241ffbcaeb33e07d14b007b6eb00a19320c7b8a7024c040", size = 242245, upload-time = "2025-03-30T20:35:18.648Z" }, - { url = "https://files.pythonhosted.org/packages/b2/7b/b63cbb44096141ed435843bbb251558c8e05cc835c8da31ca6ffb26d44c0/coverage-7.8.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d39fc4817fd67b3915256af5dda75fd4ee10621a3d484524487e33416c6f3543", size = 244032, upload-time = "2025-03-30T20:35:20.131Z" }, - { url = "https://files.pythonhosted.org/packages/97/e3/7fa8c2c00a1ef530c2a42fa5df25a6971391f92739d83d67a4ee6dcf7a02/coverage-7.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b44674870709017e4b4036e3d0d6c17f06a0e6d4436422e0ad29b882c40697d2", size = 243679, upload-time = "2025-03-30T20:35:21.636Z" }, - { url = "https://files.pythonhosted.org/packages/4f/b3/e0a59d8df9150c8a0c0841d55d6568f0a9195692136c44f3d21f1842c8f6/coverage-7.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8f99eb72bf27cbb167b636eb1726f590c00e1ad375002230607a844d9e9a2318", size = 241852, upload-time = "2025-03-30T20:35:23.525Z" }, - { url = "https://files.pythonhosted.org/packages/9b/82/db347ccd57bcef150c173df2ade97976a8367a3be7160e303e43dd0c795f/coverage-7.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b571bf5341ba8c6bc02e0baeaf3b061ab993bf372d982ae509807e7f112554e9", size = 242389, upload-time = "2025-03-30T20:35:25.09Z" }, - { url = "https://files.pythonhosted.org/packages/21/f6/3f7d7879ceb03923195d9ff294456241ed05815281f5254bc16ef71d6a20/coverage-7.8.0-cp311-cp311-win32.whl", hash = "sha256:e75a2ad7b647fd8046d58c3132d7eaf31b12d8a53c0e4b21fa9c4d23d6ee6d3c", size = 213997, upload-time = "2025-03-30T20:35:26.914Z" }, - { url = "https://files.pythonhosted.org/packages/28/87/021189643e18ecf045dbe1e2071b2747901f229df302de01c998eeadf146/coverage-7.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:3043ba1c88b2139126fc72cb48574b90e2e0546d4c78b5299317f61b7f718b78", size = 214911, upload-time = "2025-03-30T20:35:28.498Z" }, - { url = "https://files.pythonhosted.org/packages/aa/12/4792669473297f7973518bec373a955e267deb4339286f882439b8535b39/coverage-7.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bbb5cc845a0292e0c520656d19d7ce40e18d0e19b22cb3e0409135a575bf79fc", size = 211684, upload-time = "2025-03-30T20:35:29.959Z" }, - { url = "https://files.pythonhosted.org/packages/be/e1/2a4ec273894000ebedd789e8f2fc3813fcaf486074f87fd1c5b2cb1c0a2b/coverage-7.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4dfd9a93db9e78666d178d4f08a5408aa3f2474ad4d0e0378ed5f2ef71640cb6", size = 211935, upload-time = "2025-03-30T20:35:31.912Z" }, - { url = "https://files.pythonhosted.org/packages/f8/3a/7b14f6e4372786709a361729164125f6b7caf4024ce02e596c4a69bccb89/coverage-7.8.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f017a61399f13aa6d1039f75cd467be388d157cd81f1a119b9d9a68ba6f2830d", size = 245994, upload-time = "2025-03-30T20:35:33.455Z" }, - { url = "https://files.pythonhosted.org/packages/54/80/039cc7f1f81dcbd01ea796d36d3797e60c106077e31fd1f526b85337d6a1/coverage-7.8.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0915742f4c82208ebf47a2b154a5334155ed9ef9fe6190674b8a46c2fb89cb05", size = 242885, upload-time = "2025-03-30T20:35:35.354Z" }, - { url = "https://files.pythonhosted.org/packages/10/e0/dc8355f992b6cc2f9dcd5ef6242b62a3f73264893bc09fbb08bfcab18eb4/coverage-7.8.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a40fcf208e021eb14b0fac6bdb045c0e0cab53105f93ba0d03fd934c956143a", size = 245142, upload-time = "2025-03-30T20:35:37.121Z" }, - { url = "https://files.pythonhosted.org/packages/43/1b/33e313b22cf50f652becb94c6e7dae25d8f02e52e44db37a82de9ac357e8/coverage-7.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a1f406a8e0995d654b2ad87c62caf6befa767885301f3b8f6f73e6f3c31ec3a6", size = 244906, upload-time = "2025-03-30T20:35:39.07Z" }, - { url = "https://files.pythonhosted.org/packages/05/08/c0a8048e942e7f918764ccc99503e2bccffba1c42568693ce6955860365e/coverage-7.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:77af0f6447a582fdc7de5e06fa3757a3ef87769fbb0fdbdeba78c23049140a47", size = 243124, upload-time = "2025-03-30T20:35:40.598Z" }, - { url = "https://files.pythonhosted.org/packages/5b/62/ea625b30623083c2aad645c9a6288ad9fc83d570f9adb913a2abdba562dd/coverage-7.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f2d32f95922927186c6dbc8bc60df0d186b6edb828d299ab10898ef3f40052fe", size = 244317, upload-time = "2025-03-30T20:35:42.204Z" }, - { url = "https://files.pythonhosted.org/packages/62/cb/3871f13ee1130a6c8f020e2f71d9ed269e1e2124aa3374d2180ee451cee9/coverage-7.8.0-cp312-cp312-win32.whl", hash = "sha256:769773614e676f9d8e8a0980dd7740f09a6ea386d0f383db6821df07d0f08545", size = 214170, upload-time = "2025-03-30T20:35:44.216Z" }, - { url = "https://files.pythonhosted.org/packages/88/26/69fe1193ab0bfa1eb7a7c0149a066123611baba029ebb448500abd8143f9/coverage-7.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:e5d2b9be5b0693cf21eb4ce0ec8d211efb43966f6657807f6859aab3814f946b", size = 214969, upload-time = "2025-03-30T20:35:45.797Z" }, - { url = "https://files.pythonhosted.org/packages/f3/21/87e9b97b568e223f3438d93072479c2f36cc9b3f6b9f7094b9d50232acc0/coverage-7.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ac46d0c2dd5820ce93943a501ac5f6548ea81594777ca585bf002aa8854cacd", size = 211708, upload-time = "2025-03-30T20:35:47.417Z" }, - { url = "https://files.pythonhosted.org/packages/75/be/882d08b28a0d19c9c4c2e8a1c6ebe1f79c9c839eb46d4fca3bd3b34562b9/coverage-7.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:771eb7587a0563ca5bb6f622b9ed7f9d07bd08900f7589b4febff05f469bea00", size = 211981, upload-time = "2025-03-30T20:35:49.002Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1d/ce99612ebd58082fbe3f8c66f6d8d5694976c76a0d474503fa70633ec77f/coverage-7.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42421e04069fb2cbcbca5a696c4050b84a43b05392679d4068acbe65449b5c64", size = 245495, upload-time = "2025-03-30T20:35:51.073Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8d/6115abe97df98db6b2bd76aae395fcc941d039a7acd25f741312ced9a78f/coverage-7.8.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:554fec1199d93ab30adaa751db68acec2b41c5602ac944bb19187cb9a41a8067", size = 242538, upload-time = "2025-03-30T20:35:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/cb/74/2f8cc196643b15bc096d60e073691dadb3dca48418f08bc78dd6e899383e/coverage-7.8.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aaeb00761f985007b38cf463b1d160a14a22c34eb3f6a39d9ad6fc27cb73008", size = 244561, upload-time = "2025-03-30T20:35:54.658Z" }, - { url = "https://files.pythonhosted.org/packages/22/70/c10c77cd77970ac965734fe3419f2c98665f6e982744a9bfb0e749d298f4/coverage-7.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:581a40c7b94921fffd6457ffe532259813fc68eb2bdda60fa8cc343414ce3733", size = 244633, upload-time = "2025-03-30T20:35:56.221Z" }, - { url = "https://files.pythonhosted.org/packages/38/5a/4f7569d946a07c952688debee18c2bb9ab24f88027e3d71fd25dbc2f9dca/coverage-7.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f319bae0321bc838e205bf9e5bc28f0a3165f30c203b610f17ab5552cff90323", size = 242712, upload-time = "2025-03-30T20:35:57.801Z" }, - { url = "https://files.pythonhosted.org/packages/bb/a1/03a43b33f50475a632a91ea8c127f7e35e53786dbe6781c25f19fd5a65f8/coverage-7.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04bfec25a8ef1c5f41f5e7e5c842f6b615599ca8ba8391ec33a9290d9d2db3a3", size = 244000, upload-time = "2025-03-30T20:35:59.378Z" }, - { url = "https://files.pythonhosted.org/packages/6a/89/ab6c43b1788a3128e4d1b7b54214548dcad75a621f9d277b14d16a80d8a1/coverage-7.8.0-cp313-cp313-win32.whl", hash = "sha256:dd19608788b50eed889e13a5d71d832edc34fc9dfce606f66e8f9f917eef910d", size = 214195, upload-time = "2025-03-30T20:36:01.005Z" }, - { url = "https://files.pythonhosted.org/packages/12/12/6bf5f9a8b063d116bac536a7fb594fc35cb04981654cccb4bbfea5dcdfa0/coverage-7.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:a9abbccd778d98e9c7e85038e35e91e67f5b520776781d9a1e2ee9d400869487", size = 214998, upload-time = "2025-03-30T20:36:03.006Z" }, - { url = "https://files.pythonhosted.org/packages/2a/e6/1e9df74ef7a1c983a9c7443dac8aac37a46f1939ae3499424622e72a6f78/coverage-7.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:18c5ae6d061ad5b3e7eef4363fb27a0576012a7447af48be6c75b88494c6cf25", size = 212541, upload-time = "2025-03-30T20:36:04.638Z" }, - { url = "https://files.pythonhosted.org/packages/04/51/c32174edb7ee49744e2e81c4b1414ac9df3dacfcb5b5f273b7f285ad43f6/coverage-7.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:95aa6ae391a22bbbce1b77ddac846c98c5473de0372ba5c463480043a07bff42", size = 212767, upload-time = "2025-03-30T20:36:06.503Z" }, - { url = "https://files.pythonhosted.org/packages/e9/8f/f454cbdb5212f13f29d4a7983db69169f1937e869a5142bce983ded52162/coverage-7.8.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e013b07ba1c748dacc2a80e69a46286ff145935f260eb8c72df7185bf048f502", size = 256997, upload-time = "2025-03-30T20:36:08.137Z" }, - { url = "https://files.pythonhosted.org/packages/e6/74/2bf9e78b321216d6ee90a81e5c22f912fc428442c830c4077b4a071db66f/coverage-7.8.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d766a4f0e5aa1ba056ec3496243150698dc0481902e2b8559314368717be82b1", size = 252708, upload-time = "2025-03-30T20:36:09.781Z" }, - { url = "https://files.pythonhosted.org/packages/92/4d/50d7eb1e9a6062bee6e2f92e78b0998848a972e9afad349b6cdde6fa9e32/coverage-7.8.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad80e6b4a0c3cb6f10f29ae4c60e991f424e6b14219d46f1e7d442b938ee68a4", size = 255046, upload-time = "2025-03-30T20:36:11.409Z" }, - { url = "https://files.pythonhosted.org/packages/40/9e/71fb4e7402a07c4198ab44fc564d09d7d0ffca46a9fb7b0a7b929e7641bd/coverage-7.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b87eb6fc9e1bb8f98892a2458781348fa37e6925f35bb6ceb9d4afd54ba36c73", size = 256139, upload-time = "2025-03-30T20:36:13.86Z" }, - { url = "https://files.pythonhosted.org/packages/49/1a/78d37f7a42b5beff027e807c2843185961fdae7fe23aad5a4837c93f9d25/coverage-7.8.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d1ba00ae33be84066cfbe7361d4e04dec78445b2b88bdb734d0d1cbab916025a", size = 254307, upload-time = "2025-03-30T20:36:16.074Z" }, - { url = "https://files.pythonhosted.org/packages/58/e9/8fb8e0ff6bef5e170ee19d59ca694f9001b2ec085dc99b4f65c128bb3f9a/coverage-7.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f3c38e4e5ccbdc9198aecc766cedbb134b2d89bf64533973678dfcf07effd883", size = 255116, upload-time = "2025-03-30T20:36:18.033Z" }, - { url = "https://files.pythonhosted.org/packages/56/b0/d968ecdbe6fe0a863de7169bbe9e8a476868959f3af24981f6a10d2b6924/coverage-7.8.0-cp313-cp313t-win32.whl", hash = "sha256:379fe315e206b14e21db5240f89dc0774bdd3e25c3c58c2c733c99eca96f1ada", size = 214909, upload-time = "2025-03-30T20:36:19.644Z" }, - { url = "https://files.pythonhosted.org/packages/87/e9/d6b7ef9fecf42dfb418d93544af47c940aa83056c49e6021a564aafbc91f/coverage-7.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2e4b6b87bb0c846a9315e3ab4be2d52fac905100565f4b92f02c445c8799e257", size = 216068, upload-time = "2025-03-30T20:36:21.282Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f1/1da77bb4c920aa30e82fa9b6ea065da3467977c2e5e032e38e66f1c57ffd/coverage-7.8.0-pp39.pp310.pp311-none-any.whl", hash = "sha256:b8194fb8e50d556d5849753de991d390c5a1edeeba50f68e3a9253fbd8bf8ccd", size = 203443, upload-time = "2025-03-30T20:36:41.959Z" }, - { url = "https://files.pythonhosted.org/packages/59/f1/4da7717f0063a222db253e7121bd6a56f6fb1ba439dcc36659088793347c/coverage-7.8.0-py3-none-any.whl", hash = "sha256:dbf364b4c5e7bae9250528167dfe40219b62e2d573c854d74be213e1e52069f7", size = 203435, upload-time = "2025-03-30T20:36:43.61Z" }, + { url = "https://files.pythonhosted.org/packages/d4/16/2bea27e212c4980753d6d563a0803c150edeaaddb0771a50d2afc410a261/coverage-7.10.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c706db3cabb7ceef779de68270150665e710b46d56372455cd741184f3868d8f", size = 217129, upload-time = "2025-08-29T15:33:13.575Z" }, + { url = "https://files.pythonhosted.org/packages/2a/51/e7159e068831ab37e31aac0969d47b8c5ee25b7d307b51e310ec34869315/coverage-7.10.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8e0c38dc289e0508ef68ec95834cb5d2e96fdbe792eaccaa1bccac3966bbadcc", size = 217532, upload-time = "2025-08-29T15:33:14.872Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c0/246ccbea53d6099325d25cd208df94ea435cd55f0db38099dd721efc7a1f/coverage-7.10.6-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:752a3005a1ded28f2f3a6e8787e24f28d6abe176ca64677bcd8d53d6fe2ec08a", size = 247931, upload-time = "2025-08-29T15:33:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fb/7435ef8ab9b2594a6e3f58505cc30e98ae8b33265d844007737946c59389/coverage-7.10.6-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:689920ecfd60f992cafca4f5477d55720466ad2c7fa29bb56ac8d44a1ac2b47a", size = 249864, upload-time = "2025-08-29T15:33:17.434Z" }, + { url = "https://files.pythonhosted.org/packages/51/f8/d9d64e8da7bcddb094d511154824038833c81e3a039020a9d6539bf303e9/coverage-7.10.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec98435796d2624d6905820a42f82149ee9fc4f2d45c2c5bc5a44481cc50db62", size = 251969, upload-time = "2025-08-29T15:33:18.822Z" }, + { url = "https://files.pythonhosted.org/packages/43/28/c43ba0ef19f446d6463c751315140d8f2a521e04c3e79e5c5fe211bfa430/coverage-7.10.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b37201ce4a458c7a758ecc4efa92fa8ed783c66e0fa3c42ae19fc454a0792153", size = 249659, upload-time = "2025-08-29T15:33:20.407Z" }, + { url = "https://files.pythonhosted.org/packages/79/3e/53635bd0b72beaacf265784508a0b386defc9ab7fad99ff95f79ce9db555/coverage-7.10.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2904271c80898663c810a6b067920a61dd8d38341244a3605bd31ab55250dad5", size = 247714, upload-time = "2025-08-29T15:33:21.751Z" }, + { url = "https://files.pythonhosted.org/packages/4c/55/0964aa87126624e8c159e32b0bc4e84edef78c89a1a4b924d28dd8265625/coverage-7.10.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5aea98383463d6e1fa4e95416d8de66f2d0cb588774ee20ae1b28df826bcb619", size = 248351, upload-time = "2025-08-29T15:33:23.105Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ab/6cfa9dc518c6c8e14a691c54e53a9433ba67336c760607e299bfcf520cb1/coverage-7.10.6-cp311-cp311-win32.whl", hash = "sha256:e3fb1fa01d3598002777dd259c0c2e6d9d5e10e7222976fc8e03992f972a2cba", size = 219562, upload-time = "2025-08-29T15:33:24.717Z" }, + { url = "https://files.pythonhosted.org/packages/5b/18/99b25346690cbc55922e7cfef06d755d4abee803ef335baff0014268eff4/coverage-7.10.6-cp311-cp311-win_amd64.whl", hash = "sha256:f35ed9d945bece26553d5b4c8630453169672bea0050a564456eb88bdffd927e", size = 220453, upload-time = "2025-08-29T15:33:26.482Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ed/81d86648a07ccb124a5cf1f1a7788712b8d7216b593562683cd5c9b0d2c1/coverage-7.10.6-cp311-cp311-win_arm64.whl", hash = "sha256:99e1a305c7765631d74b98bf7dbf54eeea931f975e80f115437d23848ee8c27c", size = 219127, upload-time = "2025-08-29T15:33:27.777Z" }, + { url = "https://files.pythonhosted.org/packages/26/06/263f3305c97ad78aab066d116b52250dd316e74fcc20c197b61e07eb391a/coverage-7.10.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5b2dd6059938063a2c9fee1af729d4f2af28fd1a545e9b7652861f0d752ebcea", size = 217324, upload-time = "2025-08-29T15:33:29.06Z" }, + { url = "https://files.pythonhosted.org/packages/e9/60/1e1ded9a4fe80d843d7d53b3e395c1db3ff32d6c301e501f393b2e6c1c1f/coverage-7.10.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:388d80e56191bf846c485c14ae2bc8898aa3124d9d35903fef7d907780477634", size = 217560, upload-time = "2025-08-29T15:33:30.748Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/52136173c14e26dfed8b106ed725811bb53c30b896d04d28d74cb64318b3/coverage-7.10.6-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:90cb5b1a4670662719591aa92d0095bb41714970c0b065b02a2610172dbf0af6", size = 249053, upload-time = "2025-08-29T15:33:32.041Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1d/ae25a7dc58fcce8b172d42ffe5313fc267afe61c97fa872b80ee72d9515a/coverage-7.10.6-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:961834e2f2b863a0e14260a9a273aff07ff7818ab6e66d2addf5628590c628f9", size = 251802, upload-time = "2025-08-29T15:33:33.625Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7a/1f561d47743710fe996957ed7c124b421320f150f1d38523d8d9102d3e2a/coverage-7.10.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf9a19f5012dab774628491659646335b1928cfc931bf8d97b0d5918dd58033c", size = 252935, upload-time = "2025-08-29T15:33:34.909Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ad/8b97cd5d28aecdfde792dcbf646bac141167a5cacae2cd775998b45fabb5/coverage-7.10.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:99c4283e2a0e147b9c9cc6bc9c96124de9419d6044837e9799763a0e29a7321a", size = 250855, upload-time = "2025-08-29T15:33:36.922Z" }, + { url = "https://files.pythonhosted.org/packages/33/6a/95c32b558d9a61858ff9d79580d3877df3eb5bc9eed0941b1f187c89e143/coverage-7.10.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:282b1b20f45df57cc508c1e033403f02283adfb67d4c9c35a90281d81e5c52c5", size = 248974, upload-time = "2025-08-29T15:33:38.175Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9c/8ce95dee640a38e760d5b747c10913e7a06554704d60b41e73fdea6a1ffd/coverage-7.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cdbe264f11afd69841bd8c0d83ca10b5b32853263ee62e6ac6a0ab63895f972", size = 250409, upload-time = "2025-08-29T15:33:39.447Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/7a55b0bdde78a98e2eb2356771fd2dcddb96579e8342bb52aa5bc52e96f0/coverage-7.10.6-cp312-cp312-win32.whl", hash = "sha256:a517feaf3a0a3eca1ee985d8373135cfdedfbba3882a5eab4362bda7c7cf518d", size = 219724, upload-time = "2025-08-29T15:33:41.172Z" }, + { url = "https://files.pythonhosted.org/packages/36/4a/32b185b8b8e327802c9efce3d3108d2fe2d9d31f153a0f7ecfd59c773705/coverage-7.10.6-cp312-cp312-win_amd64.whl", hash = "sha256:856986eadf41f52b214176d894a7de05331117f6035a28ac0016c0f63d887629", size = 220536, upload-time = "2025-08-29T15:33:42.524Z" }, + { url = "https://files.pythonhosted.org/packages/08/3a/d5d8dc703e4998038c3099eaf77adddb00536a3cec08c8dcd556a36a3eb4/coverage-7.10.6-cp312-cp312-win_arm64.whl", hash = "sha256:acf36b8268785aad739443fa2780c16260ee3fa09d12b3a70f772ef100939d80", size = 219171, upload-time = "2025-08-29T15:33:43.974Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e7/917e5953ea29a28c1057729c1d5af9084ab6d9c66217523fd0e10f14d8f6/coverage-7.10.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ffea0575345e9ee0144dfe5701aa17f3ba546f8c3bb48db62ae101afb740e7d6", size = 217351, upload-time = "2025-08-29T15:33:45.438Z" }, + { url = "https://files.pythonhosted.org/packages/eb/86/2e161b93a4f11d0ea93f9bebb6a53f113d5d6e416d7561ca41bb0a29996b/coverage-7.10.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:95d91d7317cde40a1c249d6b7382750b7e6d86fad9d8eaf4fa3f8f44cf171e80", size = 217600, upload-time = "2025-08-29T15:33:47.269Z" }, + { url = "https://files.pythonhosted.org/packages/0e/66/d03348fdd8df262b3a7fb4ee5727e6e4936e39e2f3a842e803196946f200/coverage-7.10.6-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e23dd5408fe71a356b41baa82892772a4cefcf758f2ca3383d2aa39e1b7a003", size = 248600, upload-time = "2025-08-29T15:33:48.953Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/508420fb47d09d904d962f123221bc249f64b5e56aa93d5f5f7603be475f/coverage-7.10.6-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f3f56e4cb573755e96a16501a98bf211f100463d70275759e73f3cbc00d4f27", size = 251206, upload-time = "2025-08-29T15:33:50.697Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1f/9020135734184f439da85c70ea78194c2730e56c2d18aee6e8ff1719d50d/coverage-7.10.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db4a1d897bbbe7339946ffa2fe60c10cc81c43fab8b062d3fcb84188688174a4", size = 252478, upload-time = "2025-08-29T15:33:52.303Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a4/3d228f3942bb5a2051fde28c136eea23a761177dc4ff4ef54533164ce255/coverage-7.10.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d8fd7879082953c156d5b13c74aa6cca37f6a6f4747b39538504c3f9c63d043d", size = 250637, upload-time = "2025-08-29T15:33:53.67Z" }, + { url = "https://files.pythonhosted.org/packages/36/e3/293dce8cdb9a83de971637afc59b7190faad60603b40e32635cbd15fbf61/coverage-7.10.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:28395ca3f71cd103b8c116333fa9db867f3a3e1ad6a084aa3725ae002b6583bc", size = 248529, upload-time = "2025-08-29T15:33:55.022Z" }, + { url = "https://files.pythonhosted.org/packages/90/26/64eecfa214e80dd1d101e420cab2901827de0e49631d666543d0e53cf597/coverage-7.10.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:61c950fc33d29c91b9e18540e1aed7d9f6787cc870a3e4032493bbbe641d12fc", size = 250143, upload-time = "2025-08-29T15:33:56.386Z" }, + { url = "https://files.pythonhosted.org/packages/3e/70/bd80588338f65ea5b0d97e424b820fb4068b9cfb9597fbd91963086e004b/coverage-7.10.6-cp313-cp313-win32.whl", hash = "sha256:160c00a5e6b6bdf4e5984b0ef21fc860bc94416c41b7df4d63f536d17c38902e", size = 219770, upload-time = "2025-08-29T15:33:58.063Z" }, + { url = "https://files.pythonhosted.org/packages/a7/14/0b831122305abcc1060c008f6c97bbdc0a913ab47d65070a01dc50293c2b/coverage-7.10.6-cp313-cp313-win_amd64.whl", hash = "sha256:628055297f3e2aa181464c3808402887643405573eb3d9de060d81531fa79d32", size = 220566, upload-time = "2025-08-29T15:33:59.766Z" }, + { url = "https://files.pythonhosted.org/packages/83/c6/81a83778c1f83f1a4a168ed6673eeedc205afb562d8500175292ca64b94e/coverage-7.10.6-cp313-cp313-win_arm64.whl", hash = "sha256:df4ec1f8540b0bcbe26ca7dd0f541847cc8a108b35596f9f91f59f0c060bfdd2", size = 219195, upload-time = "2025-08-29T15:34:01.191Z" }, + { url = "https://files.pythonhosted.org/packages/d7/1c/ccccf4bf116f9517275fa85047495515add43e41dfe8e0bef6e333c6b344/coverage-7.10.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c9a8b7a34a4de3ed987f636f71881cd3b8339f61118b1aa311fbda12741bff0b", size = 218059, upload-time = "2025-08-29T15:34:02.91Z" }, + { url = "https://files.pythonhosted.org/packages/92/97/8a3ceff833d27c7492af4f39d5da6761e9ff624831db9e9f25b3886ddbca/coverage-7.10.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8dd5af36092430c2b075cee966719898f2ae87b636cefb85a653f1d0ba5d5393", size = 218287, upload-time = "2025-08-29T15:34:05.106Z" }, + { url = "https://files.pythonhosted.org/packages/92/d8/50b4a32580cf41ff0423777a2791aaf3269ab60c840b62009aec12d3970d/coverage-7.10.6-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0353b0f0850d49ada66fdd7d0c7cdb0f86b900bb9e367024fd14a60cecc1e27", size = 259625, upload-time = "2025-08-29T15:34:06.575Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7e/6a7df5a6fb440a0179d94a348eb6616ed4745e7df26bf2a02bc4db72c421/coverage-7.10.6-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d6b9ae13d5d3e8aeca9ca94198aa7b3ebbc5acfada557d724f2a1f03d2c0b0df", size = 261801, upload-time = "2025-08-29T15:34:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/3a/4c/a270a414f4ed5d196b9d3d67922968e768cd971d1b251e1b4f75e9362f75/coverage-7.10.6-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:675824a363cc05781b1527b39dc2587b8984965834a748177ee3c37b64ffeafb", size = 264027, upload-time = "2025-08-29T15:34:09.806Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8b/3210d663d594926c12f373c5370bf1e7c5c3a427519a8afa65b561b9a55c/coverage-7.10.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:692d70ea725f471a547c305f0d0fc6a73480c62fb0da726370c088ab21aed282", size = 261576, upload-time = "2025-08-29T15:34:11.585Z" }, + { url = "https://files.pythonhosted.org/packages/72/d0/e1961eff67e9e1dba3fc5eb7a4caf726b35a5b03776892da8d79ec895775/coverage-7.10.6-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:851430a9a361c7a8484a36126d1d0ff8d529d97385eacc8dfdc9bfc8c2d2cbe4", size = 259341, upload-time = "2025-08-29T15:34:13.159Z" }, + { url = "https://files.pythonhosted.org/packages/3a/06/d6478d152cd189b33eac691cba27a40704990ba95de49771285f34a5861e/coverage-7.10.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d9369a23186d189b2fc95cc08b8160ba242057e887d766864f7adf3c46b2df21", size = 260468, upload-time = "2025-08-29T15:34:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/ed/73/737440247c914a332f0b47f7598535b29965bf305e19bbc22d4c39615d2b/coverage-7.10.6-cp313-cp313t-win32.whl", hash = "sha256:92be86fcb125e9bda0da7806afd29a3fd33fdf58fba5d60318399adf40bf37d0", size = 220429, upload-time = "2025-08-29T15:34:16.394Z" }, + { url = "https://files.pythonhosted.org/packages/bd/76/b92d3214740f2357ef4a27c75a526eb6c28f79c402e9f20a922c295c05e2/coverage-7.10.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6b3039e2ca459a70c79523d39347d83b73f2f06af5624905eba7ec34d64d80b5", size = 221493, upload-time = "2025-08-29T15:34:17.835Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8e/6dcb29c599c8a1f654ec6cb68d76644fe635513af16e932d2d4ad1e5ac6e/coverage-7.10.6-cp313-cp313t-win_arm64.whl", hash = "sha256:3fb99d0786fe17b228eab663d16bee2288e8724d26a199c29325aac4b0319b9b", size = 219757, upload-time = "2025-08-29T15:34:19.248Z" }, + { url = "https://files.pythonhosted.org/packages/d3/aa/76cf0b5ec00619ef208da4689281d48b57f2c7fde883d14bf9441b74d59f/coverage-7.10.6-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6008a021907be8c4c02f37cdc3ffb258493bdebfeaf9a839f9e71dfdc47b018e", size = 217331, upload-time = "2025-08-29T15:34:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/65/91/8e41b8c7c505d398d7730206f3cbb4a875a35ca1041efc518051bfce0f6b/coverage-7.10.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5e75e37f23eb144e78940b40395b42f2321951206a4f50e23cfd6e8a198d3ceb", size = 217607, upload-time = "2025-08-29T15:34:22.433Z" }, + { url = "https://files.pythonhosted.org/packages/87/7f/f718e732a423d442e6616580a951b8d1ec3575ea48bcd0e2228386805e79/coverage-7.10.6-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0f7cb359a448e043c576f0da00aa8bfd796a01b06aa610ca453d4dde09cc1034", size = 248663, upload-time = "2025-08-29T15:34:24.425Z" }, + { url = "https://files.pythonhosted.org/packages/e6/52/c1106120e6d801ac03e12b5285e971e758e925b6f82ee9b86db3aa10045d/coverage-7.10.6-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c68018e4fc4e14b5668f1353b41ccf4bc83ba355f0e1b3836861c6f042d89ac1", size = 251197, upload-time = "2025-08-29T15:34:25.906Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ec/3a8645b1bb40e36acde9c0609f08942852a4af91a937fe2c129a38f2d3f5/coverage-7.10.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd4b2b0707fc55afa160cd5fc33b27ccbf75ca11d81f4ec9863d5793fc6df56a", size = 252551, upload-time = "2025-08-29T15:34:27.337Z" }, + { url = "https://files.pythonhosted.org/packages/a1/70/09ecb68eeb1155b28a1d16525fd3a9b65fbe75337311a99830df935d62b6/coverage-7.10.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cec13817a651f8804a86e4f79d815b3b28472c910e099e4d5a0e8a3b6a1d4cb", size = 250553, upload-time = "2025-08-29T15:34:29.065Z" }, + { url = "https://files.pythonhosted.org/packages/c6/80/47df374b893fa812e953b5bc93dcb1427a7b3d7a1a7d2db33043d17f74b9/coverage-7.10.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f2a6a8e06bbda06f78739f40bfb56c45d14eb8249d0f0ea6d4b3d48e1f7c695d", size = 248486, upload-time = "2025-08-29T15:34:30.897Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/9f98640979ecee1b0d1a7164b589de720ddf8100d1747d9bbdb84be0c0fb/coverage-7.10.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:081b98395ced0d9bcf60ada7661a0b75f36b78b9d7e39ea0790bb4ed8da14747", size = 249981, upload-time = "2025-08-29T15:34:32.365Z" }, + { url = "https://files.pythonhosted.org/packages/1f/55/eeb6603371e6629037f47bd25bef300387257ed53a3c5fdb159b7ac8c651/coverage-7.10.6-cp314-cp314-win32.whl", hash = "sha256:6937347c5d7d069ee776b2bf4e1212f912a9f1f141a429c475e6089462fcecc5", size = 220054, upload-time = "2025-08-29T15:34:34.124Z" }, + { url = "https://files.pythonhosted.org/packages/15/d1/a0912b7611bc35412e919a2cd59ae98e7ea3b475e562668040a43fb27897/coverage-7.10.6-cp314-cp314-win_amd64.whl", hash = "sha256:adec1d980fa07e60b6ef865f9e5410ba760e4e1d26f60f7e5772c73b9a5b0713", size = 220851, upload-time = "2025-08-29T15:34:35.651Z" }, + { url = "https://files.pythonhosted.org/packages/ef/2d/11880bb8ef80a45338e0b3e0725e4c2d73ffbb4822c29d987078224fd6a5/coverage-7.10.6-cp314-cp314-win_arm64.whl", hash = "sha256:a80f7aef9535442bdcf562e5a0d5a5538ce8abe6bb209cfbf170c462ac2c2a32", size = 219429, upload-time = "2025-08-29T15:34:37.16Z" }, + { url = "https://files.pythonhosted.org/packages/83/c0/1f00caad775c03a700146f55536ecd097a881ff08d310a58b353a1421be0/coverage-7.10.6-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0de434f4fbbe5af4fa7989521c655c8c779afb61c53ab561b64dcee6149e4c65", size = 218080, upload-time = "2025-08-29T15:34:38.919Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c4/b1c5d2bd7cc412cbeb035e257fd06ed4e3e139ac871d16a07434e145d18d/coverage-7.10.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6e31b8155150c57e5ac43ccd289d079eb3f825187d7c66e755a055d2c85794c6", size = 218293, upload-time = "2025-08-29T15:34:40.425Z" }, + { url = "https://files.pythonhosted.org/packages/3f/07/4468d37c94724bf6ec354e4ec2f205fda194343e3e85fd2e59cec57e6a54/coverage-7.10.6-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:98cede73eb83c31e2118ae8d379c12e3e42736903a8afcca92a7218e1f2903b0", size = 259800, upload-time = "2025-08-29T15:34:41.996Z" }, + { url = "https://files.pythonhosted.org/packages/82/d8/f8fb351be5fee31690cd8da768fd62f1cfab33c31d9f7baba6cd8960f6b8/coverage-7.10.6-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f863c08f4ff6b64fa8045b1e3da480f5374779ef187f07b82e0538c68cb4ff8e", size = 261965, upload-time = "2025-08-29T15:34:43.61Z" }, + { url = "https://files.pythonhosted.org/packages/e8/70/65d4d7cfc75c5c6eb2fed3ee5cdf420fd8ae09c4808723a89a81d5b1b9c3/coverage-7.10.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b38261034fda87be356f2c3f42221fdb4171c3ce7658066ae449241485390d5", size = 264220, upload-time = "2025-08-29T15:34:45.387Z" }, + { url = "https://files.pythonhosted.org/packages/98/3c/069df106d19024324cde10e4ec379fe2fb978017d25e97ebee23002fbadf/coverage-7.10.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e93b1476b79eae849dc3872faeb0bf7948fd9ea34869590bc16a2a00b9c82a7", size = 261660, upload-time = "2025-08-29T15:34:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8a/2974d53904080c5dc91af798b3a54a4ccb99a45595cc0dcec6eb9616a57d/coverage-7.10.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ff8a991f70f4c0cf53088abf1e3886edcc87d53004c7bb94e78650b4d3dac3b5", size = 259417, upload-time = "2025-08-29T15:34:48.779Z" }, + { url = "https://files.pythonhosted.org/packages/30/38/9616a6b49c686394b318974d7f6e08f38b8af2270ce7488e879888d1e5db/coverage-7.10.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ac765b026c9f33044419cbba1da913cfb82cca1b60598ac1c7a5ed6aac4621a0", size = 260567, upload-time = "2025-08-29T15:34:50.718Z" }, + { url = "https://files.pythonhosted.org/packages/76/16/3ed2d6312b371a8cf804abf4e14895b70e4c3491c6e53536d63fd0958a8d/coverage-7.10.6-cp314-cp314t-win32.whl", hash = "sha256:441c357d55f4936875636ef2cfb3bee36e466dcf50df9afbd398ce79dba1ebb7", size = 220831, upload-time = "2025-08-29T15:34:52.653Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e5/d38d0cb830abede2adb8b147770d2a3d0e7fecc7228245b9b1ae6c24930a/coverage-7.10.6-cp314-cp314t-win_amd64.whl", hash = "sha256:073711de3181b2e204e4870ac83a7c4853115b42e9cd4d145f2231e12d670930", size = 221950, upload-time = "2025-08-29T15:34:54.212Z" }, + { url = "https://files.pythonhosted.org/packages/f4/51/e48e550f6279349895b0ffcd6d2a690e3131ba3a7f4eafccc141966d4dea/coverage-7.10.6-cp314-cp314t-win_arm64.whl", hash = "sha256:137921f2bac5559334ba66122b753db6dc5d1cf01eb7b64eb412bb0d064ef35b", size = 219969, upload-time = "2025-08-29T15:34:55.83Z" }, + { url = "https://files.pythonhosted.org/packages/44/0c/50db5379b615854b5cf89146f8f5bd1d5a9693d7f3a987e269693521c404/coverage-7.10.6-py3-none-any.whl", hash = "sha256:92c4ecf6bf11b2e85fd4d8204814dc26e6a19f0c9d938c207c5cb0eadfcabbe3", size = 208986, upload-time = "2025-08-29T15:35:14.506Z" }, ] [package.optional-dependencies] @@ -208,11 +240,11 @@ toml = [ [[package]] name = "decli" -version = "0.6.2" +version = "0.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3d/a0/a4658f93ecb589f479037b164dc13c68d108b50bf6594e54c820749f97ac/decli-0.6.2.tar.gz", hash = "sha256:36f71eb55fd0093895efb4f416ec32b7f6e00147dda448e3365cf73ceab42d6f", size = 7424, upload-time = "2024-04-28T17:41:05.963Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/59/d4ffff1dee2c8f6f2dd8f87010962e60f7b7847504d765c91ede5a466730/decli-0.6.3.tar.gz", hash = "sha256:87f9d39361adf7f16b9ca6e3b614badf7519da13092f2db3c80ca223c53c7656", size = 7564, upload-time = "2025-06-01T15:23:41.25Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/70/3ea48dc9e958d7d66c44c9944809181f1ca79aaef25703c023b5092d34ff/decli-0.6.2-py3-none-any.whl", hash = "sha256:2fc84106ce9a8f523ed501ca543bdb7e416c064917c12a59ebdc7f311a97b7ed", size = 7854, upload-time = "2024-04-28T17:41:04.663Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fa/ec878c28bc7f65b77e7e17af3522c9948a9711b9fa7fc4c5e3140a7e3578/decli-0.6.3-py3-none-any.whl", hash = "sha256:5152347c7bb8e3114ad65db719e5709b28d7f7f45bdb709f70167925e55640f3", size = 7989, upload-time = "2025-06-01T15:23:40.228Z" }, ] [[package]] @@ -225,12 +257,24 @@ wheels = [ ] [[package]] -name = "executing" -version = "2.2.0" +name = "deprecated" +version = "1.2.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/91/50/a9d80c47ff289c611ff12e63f7c5d13942c65d68125160cefd768c73e6e4/executing-2.2.0.tar.gz", hash = "sha256:5d108c028108fe2551d1a7b2e8b713341e2cb4fc0aa7dcf966fa4327a5226755", size = 978693, upload-time = "2025-01-22T15:41:29.403Z" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/97/06afe62762c9a8a86af0cfb7bfdab22a43ad17138b07af5b1a58442690a2/deprecated-1.2.18.tar.gz", hash = "sha256:422b6f6d859da6f2ef57857761bfb392480502a64c3028ca9bbe86085d72115d", size = 2928744, upload-time = "2025-01-27T10:46:25.7Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/8f/c4d9bafc34ad7ad5d8dc16dd1347ee0e507a52c3adb6bfa8887e1c6a26ba/executing-2.2.0-py2.py3-none-any.whl", hash = "sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa", size = 26702, upload-time = "2025-01-22T15:41:25.929Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c6/ac0b6c1e2d138f1002bcf799d330bd6d85084fece321e662a14223794041/Deprecated-1.2.18-py2.py3-none-any.whl", hash = "sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec", size = 9998, upload-time = "2025-01-27T10:46:09.186Z" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, ] [[package]] @@ -312,7 +356,7 @@ wheels = [ [[package]] name = "ipython" -version = "9.2.0" +version = "9.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -327,9 +371,9 @@ dependencies = [ { name = "traitlets" }, { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/02/63a84444a7409b3c0acd1de9ffe524660e0e5d82ee473e78b45e5bfb64a4/ipython-9.2.0.tar.gz", hash = "sha256:62a9373dbc12f28f9feaf4700d052195bf89806279fc8ca11f3f54017d04751b", size = 4424394, upload-time = "2025-04-25T17:55:40.498Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/71/a86262bf5a68bf211bcc71fe302af7e05f18a2852fdc610a854d20d085e6/ipython-9.5.0.tar.gz", hash = "sha256:129c44b941fe6d9b82d36fc7a7c18127ddb1d6f02f78f867f402e2e3adde3113", size = 4389137, upload-time = "2025-08-29T12:15:21.519Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/ce/5e897ee51b7d26ab4e47e5105e7368d40ce6cfae2367acdf3165396d50be/ipython-9.2.0-py3-none-any.whl", hash = "sha256:fef5e33c4a1ae0759e0bba5917c9db4eb8c53fee917b6a526bd973e1ca5159f6", size = 604277, upload-time = "2025-04-25T17:55:37.625Z" }, + { url = "https://files.pythonhosted.org/packages/08/2a/5628a99d04acb2d2f2e749cdf4ea571d2575e898df0528a090948018b726/ipython-9.5.0-py3-none-any.whl", hash = "sha256:88369ffa1d5817d609120daa523a6da06d02518e582347c29f8451732a9c5e72", size = 612426, upload-time = "2025-08-29T12:15:18.866Z" }, ] [[package]] @@ -560,11 +604,11 @@ wheels = [ [[package]] name = "parso" -version = "0.8.4" +version = "0.8.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/66/94/68e2e17afaa9169cf6412ab0f28623903be73d1b32e208d9e8e541bb086d/parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d", size = 400609, upload-time = "2024-04-05T09:43:55.897Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/de/53e0bcf53d13e005bd8c92e7855142494f41171b34c2536b86187474184d/parso-0.8.5.tar.gz", hash = "sha256:034d7354a9a018bdce352f48b2a8a450f05e9d6ee85db84764e9b6bd96dafe5a", size = 401205, upload-time = "2025-08-23T15:15:28.028Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650, upload-time = "2024-04-05T09:43:53.299Z" }, + { url = "https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl", hash = "sha256:646204b5ee239c396d040b90f9e272e9a8017c630092bf59980beb62fd033887", size = 106668, upload-time = "2025-08-23T15:15:25.663Z" }, ] [[package]] @@ -581,61 +625,86 @@ wheels = [ [[package]] name = "pillow" -version = "11.2.1" +version = "11.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/cb/bb5c01fcd2a69335b86c22142b2bccfc3464087efb7fd382eee5ffc7fdf7/pillow-11.2.1.tar.gz", hash = "sha256:a64dd61998416367b7ef979b73d3a85853ba9bec4c2925f74e588879a58716b6", size = 47026707, upload-time = "2025-04-12T17:50:03.289Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/08/3fbf4b98924c73037a8e8b4c2c774784805e0fb4ebca6c5bb60795c40125/pillow-11.2.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:35ca289f712ccfc699508c4658a1d14652e8033e9b69839edf83cbdd0ba39e70", size = 3198450, upload-time = "2025-04-12T17:47:37.135Z" }, - { url = "https://files.pythonhosted.org/packages/84/92/6505b1af3d2849d5e714fc75ba9e69b7255c05ee42383a35a4d58f576b16/pillow-11.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0409af9f829f87a2dfb7e259f78f317a5351f2045158be321fd135973fff7bf", size = 3030550, upload-time = "2025-04-12T17:47:39.345Z" }, - { url = "https://files.pythonhosted.org/packages/3c/8c/ac2f99d2a70ff966bc7eb13dacacfaab57c0549b2ffb351b6537c7840b12/pillow-11.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4e5c5edee874dce4f653dbe59db7c73a600119fbea8d31f53423586ee2aafd7", size = 4415018, upload-time = "2025-04-12T17:47:41.128Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e3/0a58b5d838687f40891fff9cbaf8669f90c96b64dc8f91f87894413856c6/pillow-11.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b93a07e76d13bff9444f1a029e0af2964e654bfc2e2c2d46bfd080df5ad5f3d8", size = 4498006, upload-time = "2025-04-12T17:47:42.912Z" }, - { url = "https://files.pythonhosted.org/packages/21/f5/6ba14718135f08fbfa33308efe027dd02b781d3f1d5c471444a395933aac/pillow-11.2.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e6def7eed9e7fa90fde255afaf08060dc4b343bbe524a8f69bdd2a2f0018f600", size = 4517773, upload-time = "2025-04-12T17:47:44.611Z" }, - { url = "https://files.pythonhosted.org/packages/20/f2/805ad600fc59ebe4f1ba6129cd3a75fb0da126975c8579b8f57abeb61e80/pillow-11.2.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:8f4f3724c068be008c08257207210c138d5f3731af6c155a81c2b09a9eb3a788", size = 4607069, upload-time = "2025-04-12T17:47:46.46Z" }, - { url = "https://files.pythonhosted.org/packages/71/6b/4ef8a288b4bb2e0180cba13ca0a519fa27aa982875882392b65131401099/pillow-11.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a0a6709b47019dff32e678bc12c63008311b82b9327613f534e496dacaefb71e", size = 4583460, upload-time = "2025-04-12T17:47:49.255Z" }, - { url = "https://files.pythonhosted.org/packages/62/ae/f29c705a09cbc9e2a456590816e5c234382ae5d32584f451c3eb41a62062/pillow-11.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f6b0c664ccb879109ee3ca702a9272d877f4fcd21e5eb63c26422fd6e415365e", size = 4661304, upload-time = "2025-04-12T17:47:51.067Z" }, - { url = "https://files.pythonhosted.org/packages/6e/1a/c8217b6f2f73794a5e219fbad087701f412337ae6dbb956db37d69a9bc43/pillow-11.2.1-cp311-cp311-win32.whl", hash = "sha256:cc5d875d56e49f112b6def6813c4e3d3036d269c008bf8aef72cd08d20ca6df6", size = 2331809, upload-time = "2025-04-12T17:47:54.425Z" }, - { url = "https://files.pythonhosted.org/packages/e2/72/25a8f40170dc262e86e90f37cb72cb3de5e307f75bf4b02535a61afcd519/pillow-11.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:0f5c7eda47bf8e3c8a283762cab94e496ba977a420868cb819159980b6709193", size = 2676338, upload-time = "2025-04-12T17:47:56.535Z" }, - { url = "https://files.pythonhosted.org/packages/06/9e/76825e39efee61efea258b479391ca77d64dbd9e5804e4ad0fa453b4ba55/pillow-11.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:4d375eb838755f2528ac8cbc926c3e31cc49ca4ad0cf79cff48b20e30634a4a7", size = 2414918, upload-time = "2025-04-12T17:47:58.217Z" }, - { url = "https://files.pythonhosted.org/packages/c7/40/052610b15a1b8961f52537cc8326ca6a881408bc2bdad0d852edeb6ed33b/pillow-11.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:78afba22027b4accef10dbd5eed84425930ba41b3ea0a86fa8d20baaf19d807f", size = 3190185, upload-time = "2025-04-12T17:48:00.417Z" }, - { url = "https://files.pythonhosted.org/packages/e5/7e/b86dbd35a5f938632093dc40d1682874c33dcfe832558fc80ca56bfcb774/pillow-11.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78092232a4ab376a35d68c4e6d5e00dfd73454bd12b230420025fbe178ee3b0b", size = 3030306, upload-time = "2025-04-12T17:48:02.391Z" }, - { url = "https://files.pythonhosted.org/packages/a4/5c/467a161f9ed53e5eab51a42923c33051bf8d1a2af4626ac04f5166e58e0c/pillow-11.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25a5f306095c6780c52e6bbb6109624b95c5b18e40aab1c3041da3e9e0cd3e2d", size = 4416121, upload-time = "2025-04-12T17:48:04.554Z" }, - { url = "https://files.pythonhosted.org/packages/62/73/972b7742e38ae0e2ac76ab137ca6005dcf877480da0d9d61d93b613065b4/pillow-11.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c7b29dbd4281923a2bfe562acb734cee96bbb129e96e6972d315ed9f232bef4", size = 4501707, upload-time = "2025-04-12T17:48:06.831Z" }, - { url = "https://files.pythonhosted.org/packages/e4/3a/427e4cb0b9e177efbc1a84798ed20498c4f233abde003c06d2650a6d60cb/pillow-11.2.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3e645b020f3209a0181a418bffe7b4a93171eef6c4ef6cc20980b30bebf17b7d", size = 4522921, upload-time = "2025-04-12T17:48:09.229Z" }, - { url = "https://files.pythonhosted.org/packages/fe/7c/d8b1330458e4d2f3f45d9508796d7caf0c0d3764c00c823d10f6f1a3b76d/pillow-11.2.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b2dbea1012ccb784a65349f57bbc93730b96e85b42e9bf7b01ef40443db720b4", size = 4612523, upload-time = "2025-04-12T17:48:11.631Z" }, - { url = "https://files.pythonhosted.org/packages/b3/2f/65738384e0b1acf451de5a573d8153fe84103772d139e1e0bdf1596be2ea/pillow-11.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:da3104c57bbd72948d75f6a9389e6727d2ab6333c3617f0a89d72d4940aa0443", size = 4587836, upload-time = "2025-04-12T17:48:13.592Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c5/e795c9f2ddf3debb2dedd0df889f2fe4b053308bb59a3cc02a0cd144d641/pillow-11.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:598174aef4589af795f66f9caab87ba4ff860ce08cd5bb447c6fc553ffee603c", size = 4669390, upload-time = "2025-04-12T17:48:15.938Z" }, - { url = "https://files.pythonhosted.org/packages/96/ae/ca0099a3995976a9fce2f423166f7bff9b12244afdc7520f6ed38911539a/pillow-11.2.1-cp312-cp312-win32.whl", hash = "sha256:1d535df14716e7f8776b9e7fee118576d65572b4aad3ed639be9e4fa88a1cad3", size = 2332309, upload-time = "2025-04-12T17:48:17.885Z" }, - { url = "https://files.pythonhosted.org/packages/7c/18/24bff2ad716257fc03da964c5e8f05d9790a779a8895d6566e493ccf0189/pillow-11.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:14e33b28bf17c7a38eede290f77db7c664e4eb01f7869e37fa98a5aa95978941", size = 2676768, upload-time = "2025-04-12T17:48:19.655Z" }, - { url = "https://files.pythonhosted.org/packages/da/bb/e8d656c9543276517ee40184aaa39dcb41e683bca121022f9323ae11b39d/pillow-11.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:21e1470ac9e5739ff880c211fc3af01e3ae505859392bf65458c224d0bf283eb", size = 2415087, upload-time = "2025-04-12T17:48:21.991Z" }, - { url = "https://files.pythonhosted.org/packages/36/9c/447528ee3776e7ab8897fe33697a7ff3f0475bb490c5ac1456a03dc57956/pillow-11.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fdec757fea0b793056419bca3e9932eb2b0ceec90ef4813ea4c1e072c389eb28", size = 3190098, upload-time = "2025-04-12T17:48:23.915Z" }, - { url = "https://files.pythonhosted.org/packages/b5/09/29d5cd052f7566a63e5b506fac9c60526e9ecc553825551333e1e18a4858/pillow-11.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0e130705d568e2f43a17bcbe74d90958e8a16263868a12c3e0d9c8162690830", size = 3030166, upload-time = "2025-04-12T17:48:25.738Z" }, - { url = "https://files.pythonhosted.org/packages/71/5d/446ee132ad35e7600652133f9c2840b4799bbd8e4adba881284860da0a36/pillow-11.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bdb5e09068332578214cadd9c05e3d64d99e0e87591be22a324bdbc18925be0", size = 4408674, upload-time = "2025-04-12T17:48:27.908Z" }, - { url = "https://files.pythonhosted.org/packages/69/5f/cbe509c0ddf91cc3a03bbacf40e5c2339c4912d16458fcb797bb47bcb269/pillow-11.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d189ba1bebfbc0c0e529159631ec72bb9e9bc041f01ec6d3233d6d82eb823bc1", size = 4496005, upload-time = "2025-04-12T17:48:29.888Z" }, - { url = "https://files.pythonhosted.org/packages/f9/b3/dd4338d8fb8a5f312021f2977fb8198a1184893f9b00b02b75d565c33b51/pillow-11.2.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:191955c55d8a712fab8934a42bfefbf99dd0b5875078240943f913bb66d46d9f", size = 4518707, upload-time = "2025-04-12T17:48:31.874Z" }, - { url = "https://files.pythonhosted.org/packages/13/eb/2552ecebc0b887f539111c2cd241f538b8ff5891b8903dfe672e997529be/pillow-11.2.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:ad275964d52e2243430472fc5d2c2334b4fc3ff9c16cb0a19254e25efa03a155", size = 4610008, upload-time = "2025-04-12T17:48:34.422Z" }, - { url = "https://files.pythonhosted.org/packages/72/d1/924ce51bea494cb6e7959522d69d7b1c7e74f6821d84c63c3dc430cbbf3b/pillow-11.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:750f96efe0597382660d8b53e90dd1dd44568a8edb51cb7f9d5d918b80d4de14", size = 4585420, upload-time = "2025-04-12T17:48:37.641Z" }, - { url = "https://files.pythonhosted.org/packages/43/ab/8f81312d255d713b99ca37479a4cb4b0f48195e530cdc1611990eb8fd04b/pillow-11.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fe15238d3798788d00716637b3d4e7bb6bde18b26e5d08335a96e88564a36b6b", size = 4667655, upload-time = "2025-04-12T17:48:39.652Z" }, - { url = "https://files.pythonhosted.org/packages/94/86/8f2e9d2dc3d308dfd137a07fe1cc478df0a23d42a6c4093b087e738e4827/pillow-11.2.1-cp313-cp313-win32.whl", hash = "sha256:3fe735ced9a607fee4f481423a9c36701a39719252a9bb251679635f99d0f7d2", size = 2332329, upload-time = "2025-04-12T17:48:41.765Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ec/1179083b8d6067a613e4d595359b5fdea65d0a3b7ad623fee906e1b3c4d2/pillow-11.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:74ee3d7ecb3f3c05459ba95eed5efa28d6092d751ce9bf20e3e253a4e497e691", size = 2676388, upload-time = "2025-04-12T17:48:43.625Z" }, - { url = "https://files.pythonhosted.org/packages/23/f1/2fc1e1e294de897df39fa8622d829b8828ddad938b0eaea256d65b84dd72/pillow-11.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:5119225c622403afb4b44bad4c1ca6c1f98eed79db8d3bc6e4e160fc6339d66c", size = 2414950, upload-time = "2025-04-12T17:48:45.475Z" }, - { url = "https://files.pythonhosted.org/packages/c4/3e/c328c48b3f0ead7bab765a84b4977acb29f101d10e4ef57a5e3400447c03/pillow-11.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8ce2e8411c7aaef53e6bb29fe98f28cd4fbd9a1d9be2eeea434331aac0536b22", size = 3192759, upload-time = "2025-04-12T17:48:47.866Z" }, - { url = "https://files.pythonhosted.org/packages/18/0e/1c68532d833fc8b9f404d3a642991441d9058eccd5606eab31617f29b6d4/pillow-11.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ee66787e095127116d91dea2143db65c7bb1e232f617aa5957c0d9d2a3f23a7", size = 3033284, upload-time = "2025-04-12T17:48:50.189Z" }, - { url = "https://files.pythonhosted.org/packages/b7/cb/6faf3fb1e7705fd2db74e070f3bf6f88693601b0ed8e81049a8266de4754/pillow-11.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9622e3b6c1d8b551b6e6f21873bdcc55762b4b2126633014cea1803368a9aa16", size = 4445826, upload-time = "2025-04-12T17:48:52.346Z" }, - { url = "https://files.pythonhosted.org/packages/07/94/8be03d50b70ca47fb434a358919d6a8d6580f282bbb7af7e4aa40103461d/pillow-11.2.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63b5dff3a68f371ea06025a1a6966c9a1e1ee452fc8020c2cd0ea41b83e9037b", size = 4527329, upload-time = "2025-04-12T17:48:54.403Z" }, - { url = "https://files.pythonhosted.org/packages/fd/a4/bfe78777076dc405e3bd2080bc32da5ab3945b5a25dc5d8acaa9de64a162/pillow-11.2.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:31df6e2d3d8fc99f993fd253e97fae451a8db2e7207acf97859732273e108406", size = 4549049, upload-time = "2025-04-12T17:48:56.383Z" }, - { url = "https://files.pythonhosted.org/packages/65/4d/eaf9068dc687c24979e977ce5677e253624bd8b616b286f543f0c1b91662/pillow-11.2.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:062b7a42d672c45a70fa1f8b43d1d38ff76b63421cbbe7f88146b39e8a558d91", size = 4635408, upload-time = "2025-04-12T17:48:58.782Z" }, - { url = "https://files.pythonhosted.org/packages/1d/26/0fd443365d9c63bc79feb219f97d935cd4b93af28353cba78d8e77b61719/pillow-11.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4eb92eca2711ef8be42fd3f67533765d9fd043b8c80db204f16c8ea62ee1a751", size = 4614863, upload-time = "2025-04-12T17:49:00.709Z" }, - { url = "https://files.pythonhosted.org/packages/49/65/dca4d2506be482c2c6641cacdba5c602bc76d8ceb618fd37de855653a419/pillow-11.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f91ebf30830a48c825590aede79376cb40f110b387c17ee9bd59932c961044f9", size = 4692938, upload-time = "2025-04-12T17:49:02.946Z" }, - { url = "https://files.pythonhosted.org/packages/b3/92/1ca0c3f09233bd7decf8f7105a1c4e3162fb9142128c74adad0fb361b7eb/pillow-11.2.1-cp313-cp313t-win32.whl", hash = "sha256:e0b55f27f584ed623221cfe995c912c61606be8513bfa0e07d2c674b4516d9dd", size = 2335774, upload-time = "2025-04-12T17:49:04.889Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ac/77525347cb43b83ae905ffe257bbe2cc6fd23acb9796639a1f56aa59d191/pillow-11.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:36d6b82164c39ce5482f649b437382c0fb2395eabc1e2b1702a6deb8ad647d6e", size = 2681895, upload-time = "2025-04-12T17:49:06.635Z" }, - { url = "https://files.pythonhosted.org/packages/67/32/32dc030cfa91ca0fc52baebbba2e009bb001122a1daa8b6a79ad830b38d3/pillow-11.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:225c832a13326e34f212d2072982bb1adb210e0cc0b153e688743018c94a2681", size = 2417234, upload-time = "2025-04-12T17:49:08.399Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ad/2613c04633c7257d9481ab21d6b5364b59fc5d75faafd7cb8693523945a3/pillow-11.2.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:80f1df8dbe9572b4b7abdfa17eb5d78dd620b1d55d9e25f834efdbee872d3aed", size = 3181734, upload-time = "2025-04-12T17:49:46.789Z" }, - { url = "https://files.pythonhosted.org/packages/a4/fd/dcdda4471ed667de57bb5405bb42d751e6cfdd4011a12c248b455c778e03/pillow-11.2.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ea926cfbc3957090becbcbbb65ad177161a2ff2ad578b5a6ec9bb1e1cd78753c", size = 2999841, upload-time = "2025-04-12T17:49:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/ac/89/8a2536e95e77432833f0db6fd72a8d310c8e4272a04461fb833eb021bf94/pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:738db0e0941ca0376804d4de6a782c005245264edaa253ffce24e5a15cbdc7bd", size = 3437470, upload-time = "2025-04-12T17:49:50.831Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8f/abd47b73c60712f88e9eda32baced7bfc3e9bd6a7619bb64b93acff28c3e/pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db98ab6565c69082ec9b0d4e40dd9f6181dab0dd236d26f7a50b8b9bfbd5076", size = 3460013, upload-time = "2025-04-12T17:49:53.278Z" }, - { url = "https://files.pythonhosted.org/packages/f6/20/5c0a0aa83b213b7a07ec01e71a3d6ea2cf4ad1d2c686cc0168173b6089e7/pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:036e53f4170e270ddb8797d4c590e6dd14d28e15c7da375c18978045f7e6c37b", size = 3527165, upload-time = "2025-04-12T17:49:55.164Z" }, - { url = "https://files.pythonhosted.org/packages/58/0e/2abab98a72202d91146abc839e10c14f7cf36166f12838ea0c4db3ca6ecb/pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:14f73f7c291279bd65fda51ee87affd7c1e097709f7fdd0188957a16c264601f", size = 3571586, upload-time = "2025-04-12T17:49:57.171Z" }, - { url = "https://files.pythonhosted.org/packages/21/2c/5e05f58658cf49b6667762cca03d6e7d85cededde2caf2ab37b81f80e574/pillow-11.2.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:208653868d5c9ecc2b327f9b9ef34e0e42a4cdd172c2988fd81d62d2bc9bc044", size = 2674751, upload-time = "2025-04-12T17:49:59.628Z" }, + { url = "https://files.pythonhosted.org/packages/db/26/77f8ed17ca4ffd60e1dcd220a6ec6d71210ba398cfa33a13a1cd614c5613/pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722", size = 5316531, upload-time = "2025-07-01T09:13:59.203Z" }, + { url = "https://files.pythonhosted.org/packages/cb/39/ee475903197ce709322a17a866892efb560f57900d9af2e55f86db51b0a5/pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288", size = 4686560, upload-time = "2025-07-01T09:14:01.101Z" }, + { url = "https://files.pythonhosted.org/packages/d5/90/442068a160fd179938ba55ec8c97050a612426fae5ec0a764e345839f76d/pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d", size = 5870978, upload-time = "2025-07-03T13:09:55.638Z" }, + { url = "https://files.pythonhosted.org/packages/13/92/dcdd147ab02daf405387f0218dcf792dc6dd5b14d2573d40b4caeef01059/pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494", size = 7641168, upload-time = "2025-07-03T13:10:00.37Z" }, + { url = "https://files.pythonhosted.org/packages/6e/db/839d6ba7fd38b51af641aa904e2960e7a5644d60ec754c046b7d2aee00e5/pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58", size = 5973053, upload-time = "2025-07-01T09:14:04.491Z" }, + { url = "https://files.pythonhosted.org/packages/f2/2f/d7675ecae6c43e9f12aa8d58b6012683b20b6edfbdac7abcb4e6af7a3784/pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f", size = 6640273, upload-time = "2025-07-01T09:14:06.235Z" }, + { url = "https://files.pythonhosted.org/packages/45/ad/931694675ede172e15b2ff03c8144a0ddaea1d87adb72bb07655eaffb654/pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e", size = 6082043, upload-time = "2025-07-01T09:14:07.978Z" }, + { url = "https://files.pythonhosted.org/packages/3a/04/ba8f2b11fc80d2dd462d7abec16351b45ec99cbbaea4387648a44190351a/pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94", size = 6715516, upload-time = "2025-07-01T09:14:10.233Z" }, + { url = "https://files.pythonhosted.org/packages/48/59/8cd06d7f3944cc7d892e8533c56b0acb68399f640786313275faec1e3b6f/pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0", size = 6274768, upload-time = "2025-07-01T09:14:11.921Z" }, + { url = "https://files.pythonhosted.org/packages/f1/cc/29c0f5d64ab8eae20f3232da8f8571660aa0ab4b8f1331da5c2f5f9a938e/pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac", size = 6986055, upload-time = "2025-07-01T09:14:13.623Z" }, + { url = "https://files.pythonhosted.org/packages/c6/df/90bd886fabd544c25addd63e5ca6932c86f2b701d5da6c7839387a076b4a/pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd", size = 2423079, upload-time = "2025-07-01T09:14:15.268Z" }, + { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" }, + { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" }, + { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" }, + { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" }, + { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" }, + { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" }, + { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" }, + { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" }, + { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" }, + { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" }, + { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" }, + { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" }, + { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" }, + { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" }, + { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" }, + { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" }, + { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" }, + { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e3/6fa84033758276fb31da12e5fb66ad747ae83b93c67af17f8c6ff4cc8f34/pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6", size = 5270566, upload-time = "2025-07-01T09:16:19.801Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ee/e8d2e1ab4892970b561e1ba96cbd59c0d28cf66737fc44abb2aec3795a4e/pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438", size = 4654618, upload-time = "2025-07-01T09:16:21.818Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6d/17f80f4e1f0761f02160fc433abd4109fa1548dcfdca46cfdadaf9efa565/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3", size = 4874248, upload-time = "2025-07-03T13:11:20.738Z" }, + { url = "https://files.pythonhosted.org/packages/de/5f/c22340acd61cef960130585bbe2120e2fd8434c214802f07e8c03596b17e/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c", size = 6583963, upload-time = "2025-07-03T13:11:26.283Z" }, + { url = "https://files.pythonhosted.org/packages/31/5e/03966aedfbfcbb4d5f8aa042452d3361f325b963ebbadddac05b122e47dd/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361", size = 4957170, upload-time = "2025-07-01T09:16:23.762Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2d/e082982aacc927fc2cab48e1e731bdb1643a1406acace8bed0900a61464e/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7", size = 5581505, upload-time = "2025-07-01T09:16:25.593Z" }, + { url = "https://files.pythonhosted.org/packages/34/e7/ae39f538fd6844e982063c3a5e4598b8ced43b9633baa3a85ef33af8c05c/pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8", size = 6984598, upload-time = "2025-07-01T09:16:27.732Z" }, ] [[package]] @@ -679,7 +748,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.11.4" +version = "2.11.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -687,9 +756,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/ab/5250d56ad03884ab5efd07f734203943c8a8ab40d551e208af81d0257bf2/pydantic-2.11.4.tar.gz", hash = "sha256:32738d19d63a226a52eed76645a98ee07c1f410ee41d93b4afbfa85ed8111c2d", size = 786540, upload-time = "2025-04-29T20:38:55.02Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/c0/1ff9b59cf6f54494cf3ae2b95f4e9cf3a4b10ca352dade98a94d30e8a62e/pydantic-2.11.8.tar.gz", hash = "sha256:3d080f4a3ac6bde98e959ba552124d46be9f565b7be67769e49fcb286bae1bfb", size = 788452, upload-time = "2025-09-13T01:39:32.577Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/12/46b65f3534d099349e38ef6ec98b1a5a81f42536d17e0ba382c28c67ba67/pydantic-2.11.4-py3-none-any.whl", hash = "sha256:d9615eaa9ac5a063471da949c8fc16376a84afb5024688b3ff885693506764eb", size = 443900, upload-time = "2025-04-29T20:38:52.724Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/bdc01f684aff7070253fe32fb9c60788e70de6b8ec527eaebf96bdf1a76b/pydantic-2.11.8-py3-none-any.whl", hash = "sha256:830ec4cccc3cf21be1ef5aec1d3348a179c92a61a7dab0e59837f9cc9fa93351", size = 444830, upload-time = "2025-09-13T01:39:30.095Z" }, ] [[package]] @@ -773,51 +842,54 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.1" +version = "2.19.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload-time = "2025-01-06T17:26:30.443Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] [[package]] name = "pytest" -version = "8.3.5" +version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, + { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, ] [[package]] name = "pytest-asyncio" -version = "1.0.0" +version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d0/d4/14f53324cb1a6381bef29d698987625d80052bb33932d8e7cbf9b337b17c/pytest_asyncio-1.0.0.tar.gz", hash = "sha256:d15463d13f4456e1ead2594520216b225a16f781e144f8fdf6c5bb4667c48b3f", size = 46960, upload-time = "2025-05-26T04:54:40.484Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/30/05/ce271016e351fddc8399e546f6e23761967ee09c8c568bbfbecb0c150171/pytest_asyncio-1.0.0-py3-none-any.whl", hash = "sha256:4f024da9f1ef945e680dc68610b52550e36590a67fd31bb3b4943979a1f90ef3", size = 15976, upload-time = "2025-05-26T04:54:39.035Z" }, + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, ] [[package]] name = "pytest-cov" -version = "6.1.1" +version = "7.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/25/69/5f1e57f6c5a39f81411b550027bf72842c4567ff5fd572bed1edc9e4b5d9/pytest_cov-6.1.1.tar.gz", hash = "sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a", size = 66857, upload-time = "2025-04-05T14:07:51.592Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/28/d0/def53b4a790cfb21483016430ed828f64830dd981ebe1089971cd10cab25/pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde", size = 23841, upload-time = "2025-04-05T14:07:49.641Z" }, + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, ] [[package]] @@ -915,14 +987,14 @@ wheels = [ [[package]] name = "questionary" -version = "2.1.0" +version = "2.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "prompt-toolkit" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a8/b8/d16eb579277f3de9e56e5ad25280fab52fc5774117fb70362e8c2e016559/questionary-2.1.0.tar.gz", hash = "sha256:6302cdd645b19667d8f6e6634774e9538bfcd1aad9be287e743d96cacaf95587", size = 26775, upload-time = "2024-12-29T11:49:17.802Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/3f/11dd4cd4f39e05128bfd20138faea57bec56f9ffba6185d276e3107ba5b2/questionary-2.1.0-py3-none-any.whl", hash = "sha256:44174d237b68bc828e4878c763a9ad6790ee61990e0ae72927694ead57bab8ec", size = 36747, upload-time = "2024-12-29T11:49:16.734Z" }, + { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, ] [[package]] @@ -1062,27 +1134,28 @@ wheels = [ [[package]] name = "ruff" -version = "0.11.13" +version = "0.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ed/da/9c6f995903b4d9474b39da91d2d626659af3ff1eeb43e9ae7c119349dba6/ruff-0.11.13.tar.gz", hash = "sha256:26fa247dc68d1d4e72c179e08889a25ac0c7ba4d78aecfc835d49cbfd60bf514", size = 4282054, upload-time = "2025-06-05T21:00:15.721Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/1a/1f4b722862840295bcaba8c9e5261572347509548faaa99b2d57ee7bfe6a/ruff-0.13.0.tar.gz", hash = "sha256:5b4b1ee7eb35afae128ab94459b13b2baaed282b1fb0f472a73c82c996c8ae60", size = 5372863, upload-time = "2025-09-10T16:25:37.917Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/ce/a11d381192966e0b4290842cc8d4fac7dc9214ddf627c11c1afff87da29b/ruff-0.11.13-py3-none-linux_armv6l.whl", hash = "sha256:4bdfbf1240533f40042ec00c9e09a3aade6f8c10b6414cf11b519488d2635d46", size = 10292516, upload-time = "2025-06-05T20:59:32.944Z" }, - { url = "https://files.pythonhosted.org/packages/78/db/87c3b59b0d4e753e40b6a3b4a2642dfd1dcaefbff121ddc64d6c8b47ba00/ruff-0.11.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aef9c9ed1b5ca28bb15c7eac83b8670cf3b20b478195bd49c8d756ba0a36cf48", size = 11106083, upload-time = "2025-06-05T20:59:37.03Z" }, - { url = "https://files.pythonhosted.org/packages/77/79/d8cec175856ff810a19825d09ce700265f905c643c69f45d2b737e4a470a/ruff-0.11.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53b15a9dfdce029c842e9a5aebc3855e9ab7771395979ff85b7c1dedb53ddc2b", size = 10436024, upload-time = "2025-06-05T20:59:39.741Z" }, - { url = "https://files.pythonhosted.org/packages/8b/5b/f6d94f2980fa1ee854b41568368a2e1252681b9238ab2895e133d303538f/ruff-0.11.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab153241400789138d13f362c43f7edecc0edfffce2afa6a68434000ecd8f69a", size = 10646324, upload-time = "2025-06-05T20:59:42.185Z" }, - { url = "https://files.pythonhosted.org/packages/6c/9c/b4c2acf24ea4426016d511dfdc787f4ce1ceb835f3c5fbdbcb32b1c63bda/ruff-0.11.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c51f93029d54a910d3d24f7dd0bb909e31b6cd989a5e4ac513f4eb41629f0dc", size = 10174416, upload-time = "2025-06-05T20:59:44.319Z" }, - { url = "https://files.pythonhosted.org/packages/f3/10/e2e62f77c65ede8cd032c2ca39c41f48feabedb6e282bfd6073d81bb671d/ruff-0.11.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1808b3ed53e1a777c2ef733aca9051dc9bf7c99b26ece15cb59a0320fbdbd629", size = 11724197, upload-time = "2025-06-05T20:59:46.935Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f0/466fe8469b85c561e081d798c45f8a1d21e0b4a5ef795a1d7f1a9a9ec182/ruff-0.11.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d28ce58b5ecf0f43c1b71edffabe6ed7f245d5336b17805803312ec9bc665933", size = 12511615, upload-time = "2025-06-05T20:59:49.534Z" }, - { url = "https://files.pythonhosted.org/packages/17/0e/cefe778b46dbd0cbcb03a839946c8f80a06f7968eb298aa4d1a4293f3448/ruff-0.11.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55e4bc3a77842da33c16d55b32c6cac1ec5fb0fbec9c8c513bdce76c4f922165", size = 12117080, upload-time = "2025-06-05T20:59:51.654Z" }, - { url = "https://files.pythonhosted.org/packages/5d/2c/caaeda564cbe103bed145ea557cb86795b18651b0f6b3ff6a10e84e5a33f/ruff-0.11.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:633bf2c6f35678c56ec73189ba6fa19ff1c5e4807a78bf60ef487b9dd272cc71", size = 11326315, upload-time = "2025-06-05T20:59:54.469Z" }, - { url = "https://files.pythonhosted.org/packages/75/f0/782e7d681d660eda8c536962920c41309e6dd4ebcea9a2714ed5127d44bd/ruff-0.11.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ffbc82d70424b275b089166310448051afdc6e914fdab90e08df66c43bb5ca9", size = 11555640, upload-time = "2025-06-05T20:59:56.986Z" }, - { url = "https://files.pythonhosted.org/packages/5d/d4/3d580c616316c7f07fb3c99dbecfe01fbaea7b6fd9a82b801e72e5de742a/ruff-0.11.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a9ddd3ec62a9a89578c85842b836e4ac832d4a2e0bfaad3b02243f930ceafcc", size = 10507364, upload-time = "2025-06-05T20:59:59.154Z" }, - { url = "https://files.pythonhosted.org/packages/5a/dc/195e6f17d7b3ea6b12dc4f3e9de575db7983db187c378d44606e5d503319/ruff-0.11.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d237a496e0778d719efb05058c64d28b757c77824e04ffe8796c7436e26712b7", size = 10141462, upload-time = "2025-06-05T21:00:01.481Z" }, - { url = "https://files.pythonhosted.org/packages/f4/8e/39a094af6967faa57ecdeacb91bedfb232474ff8c3d20f16a5514e6b3534/ruff-0.11.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26816a218ca6ef02142343fd24c70f7cd8c5aa6c203bca284407adf675984432", size = 11121028, upload-time = "2025-06-05T21:00:04.06Z" }, - { url = "https://files.pythonhosted.org/packages/5a/c0/b0b508193b0e8a1654ec683ebab18d309861f8bd64e3a2f9648b80d392cb/ruff-0.11.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:51c3f95abd9331dc5b87c47ac7f376db5616041173826dfd556cfe3d4977f492", size = 11602992, upload-time = "2025-06-05T21:00:06.249Z" }, - { url = "https://files.pythonhosted.org/packages/7c/91/263e33ab93ab09ca06ce4f8f8547a858cc198072f873ebc9be7466790bae/ruff-0.11.13-py3-none-win32.whl", hash = "sha256:96c27935418e4e8e77a26bb05962817f28b8ef3843a6c6cc49d8783b5507f250", size = 10474944, upload-time = "2025-06-05T21:00:08.459Z" }, - { url = "https://files.pythonhosted.org/packages/46/f4/7c27734ac2073aae8efb0119cae6931b6fb48017adf048fdf85c19337afc/ruff-0.11.13-py3-none-win_amd64.whl", hash = "sha256:29c3189895a8a6a657b7af4e97d330c8a3afd2c9c8f46c81e2fc5a31866517e3", size = 11548669, upload-time = "2025-06-05T21:00:11.147Z" }, - { url = "https://files.pythonhosted.org/packages/ec/bf/b273dd11673fed8a6bd46032c0ea2a04b2ac9bfa9c628756a5856ba113b0/ruff-0.11.13-py3-none-win_arm64.whl", hash = "sha256:b4385285e9179d608ff1d2fb9922062663c658605819a6876d8beef0c30b7f3b", size = 10683928, upload-time = "2025-06-05T21:00:13.758Z" }, + { url = "https://files.pythonhosted.org/packages/ac/fe/6f87b419dbe166fd30a991390221f14c5b68946f389ea07913e1719741e0/ruff-0.13.0-py3-none-linux_armv6l.whl", hash = "sha256:137f3d65d58ee828ae136a12d1dc33d992773d8f7644bc6b82714570f31b2004", size = 12187826, upload-time = "2025-09-10T16:24:39.5Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/c92296b1fc36d2499e12b74a3fdb230f77af7bdf048fad7b0a62e94ed56a/ruff-0.13.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:21ae48151b66e71fd111b7d79f9ad358814ed58c339631450c66a4be33cc28b9", size = 12933428, upload-time = "2025-09-10T16:24:43.866Z" }, + { url = "https://files.pythonhosted.org/packages/44/cf/40bc7221a949470307d9c35b4ef5810c294e6cfa3caafb57d882731a9f42/ruff-0.13.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:64de45f4ca5441209e41742d527944635a05a6e7c05798904f39c85bafa819e3", size = 12095543, upload-time = "2025-09-10T16:24:46.638Z" }, + { url = "https://files.pythonhosted.org/packages/f1/03/8b5ff2a211efb68c63a1d03d157e924997ada87d01bebffbd13a0f3fcdeb/ruff-0.13.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b2c653ae9b9d46e0ef62fc6fbf5b979bda20a0b1d2b22f8f7eb0cde9f4963b8", size = 12312489, upload-time = "2025-09-10T16:24:49.556Z" }, + { url = "https://files.pythonhosted.org/packages/37/fc/2336ef6d5e9c8d8ea8305c5f91e767d795cd4fc171a6d97ef38a5302dadc/ruff-0.13.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4cec632534332062bc9eb5884a267b689085a1afea9801bf94e3ba7498a2d207", size = 11991631, upload-time = "2025-09-10T16:24:53.439Z" }, + { url = "https://files.pythonhosted.org/packages/39/7f/f6d574d100fca83d32637d7f5541bea2f5e473c40020bbc7fc4a4d5b7294/ruff-0.13.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dcd628101d9f7d122e120ac7c17e0a0f468b19bc925501dbe03c1cb7f5415b24", size = 13720602, upload-time = "2025-09-10T16:24:56.392Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c8/a8a5b81d8729b5d1f663348d11e2a9d65a7a9bd3c399763b1a51c72be1ce/ruff-0.13.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:afe37db8e1466acb173bb2a39ca92df00570e0fd7c94c72d87b51b21bb63efea", size = 14697751, upload-time = "2025-09-10T16:24:59.89Z" }, + { url = "https://files.pythonhosted.org/packages/57/f5/183ec292272ce7ec5e882aea74937f7288e88ecb500198b832c24debc6d3/ruff-0.13.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0f96a8d90bb258d7d3358b372905fe7333aaacf6c39e2408b9f8ba181f4b6ef2", size = 14095317, upload-time = "2025-09-10T16:25:03.025Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8d/7f9771c971724701af7926c14dab31754e7b303d127b0d3f01116faef456/ruff-0.13.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94b5e3d883e4f924c5298e3f2ee0f3085819c14f68d1e5b6715597681433f153", size = 13144418, upload-time = "2025-09-10T16:25:06.272Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a6/7985ad1778e60922d4bef546688cd8a25822c58873e9ff30189cfe5dc4ab/ruff-0.13.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03447f3d18479df3d24917a92d768a89f873a7181a064858ea90a804a7538991", size = 13370843, upload-time = "2025-09-10T16:25:09.965Z" }, + { url = "https://files.pythonhosted.org/packages/64/1c/bafdd5a7a05a50cc51d9f5711da704942d8dd62df3d8c70c311e98ce9f8a/ruff-0.13.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fbc6b1934eb1c0033da427c805e27d164bb713f8e273a024a7e86176d7f462cf", size = 13321891, upload-time = "2025-09-10T16:25:12.969Z" }, + { url = "https://files.pythonhosted.org/packages/bc/3e/7817f989cb9725ef7e8d2cee74186bf90555279e119de50c750c4b7a72fe/ruff-0.13.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a8ab6a3e03665d39d4a25ee199d207a488724f022db0e1fe4002968abdb8001b", size = 12119119, upload-time = "2025-09-10T16:25:16.621Z" }, + { url = "https://files.pythonhosted.org/packages/58/07/9df080742e8d1080e60c426dce6e96a8faf9a371e2ce22eef662e3839c95/ruff-0.13.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d2a5c62f8ccc6dd2fe259917482de7275cecc86141ee10432727c4816235bc41", size = 11961594, upload-time = "2025-09-10T16:25:19.49Z" }, + { url = "https://files.pythonhosted.org/packages/6a/f4/ae1185349197d26a2316840cb4d6c3fba61d4ac36ed728bf0228b222d71f/ruff-0.13.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b7b85ca27aeeb1ab421bc787009831cffe6048faae08ad80867edab9f2760945", size = 12933377, upload-time = "2025-09-10T16:25:22.371Z" }, + { url = "https://files.pythonhosted.org/packages/b6/39/e776c10a3b349fc8209a905bfb327831d7516f6058339a613a8d2aaecacd/ruff-0.13.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:79ea0c44a3032af768cabfd9616e44c24303af49d633b43e3a5096e009ebe823", size = 13418555, upload-time = "2025-09-10T16:25:25.681Z" }, + { url = "https://files.pythonhosted.org/packages/46/09/dca8df3d48e8b3f4202bf20b1658898e74b6442ac835bfe2c1816d926697/ruff-0.13.0-py3-none-win32.whl", hash = "sha256:4e473e8f0e6a04e4113f2e1de12a5039579892329ecc49958424e5568ef4f768", size = 12141613, upload-time = "2025-09-10T16:25:28.664Z" }, + { url = "https://files.pythonhosted.org/packages/61/21/0647eb71ed99b888ad50e44d8ec65d7148babc0e242d531a499a0bbcda5f/ruff-0.13.0-py3-none-win_amd64.whl", hash = "sha256:48e5c25c7a3713eea9ce755995767f4dcd1b0b9599b638b12946e892123d1efb", size = 13258250, upload-time = "2025-09-10T16:25:31.773Z" }, + { url = "https://files.pythonhosted.org/packages/e1/a3/03216a6a86c706df54422612981fb0f9041dbb452c3401501d4a22b942c9/ruff-0.13.0-py3-none-win_arm64.whl", hash = "sha256:ab80525317b1e1d38614addec8ac954f1b3e662de9d59114ecbf771d00cf613e", size = 12312357, upload-time = "2025-09-10T16:25:35.595Z" }, ] [[package]] @@ -1140,24 +1213,24 @@ wheels = [ [[package]] name = "starlette" -version = "0.47.3" +version = "0.48.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/15/b9/cc3017f9a9c9b6e27c5106cc10cc7904653c3eec0729793aec10479dd669/starlette-0.47.3.tar.gz", hash = "sha256:6bc94f839cc176c4858894f1f8908f0ab79dfec1a6b8402f6da9be26ebea52e9", size = 2584144, upload-time = "2025-08-24T13:36:42.122Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/a5/d6f429d43394057b67a6b5bbe6eae2f77a6bf7459d961fdb224bf206eee6/starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46", size = 2652949, upload-time = "2025-09-13T08:41:05.699Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/fd/901cfa59aaa5b30a99e16876f11abe38b59a1a2c51ffb3d7142bb6089069/starlette-0.47.3-py3-none-any.whl", hash = "sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51", size = 72991, upload-time = "2025-08-24T13:36:40.887Z" }, + { url = "https://files.pythonhosted.org/packages/be/72/2db2f49247d0a18b4f1bb9a5a39a0162869acf235f3a96418363947b3d46/starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659", size = 73736, upload-time = "2025-09-13T08:41:03.869Z" }, ] [[package]] name = "termcolor" -version = "2.5.0" +version = "3.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/37/72/88311445fd44c455c7d553e61f95412cf89054308a1aa2434ab835075fc5/termcolor-2.5.0.tar.gz", hash = "sha256:998d8d27da6d48442e8e1f016119076b690d962507531df4890fcd2db2ef8a6f", size = 13057, upload-time = "2024-10-06T19:50:04.115Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/6c/3d75c196ac07ac8749600b60b03f4f6094d54e132c4d94ebac6ee0e0add0/termcolor-3.1.0.tar.gz", hash = "sha256:6a6dd7fbee581909eeec6a756cff1d7f7c376063b14e4a298dc4980309e55970", size = 14324, upload-time = "2025-04-30T11:37:53.791Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/be/df630c387a0a054815d60be6a97eb4e8f17385d5d6fe660e1c02750062b4/termcolor-2.5.0-py3-none-any.whl", hash = "sha256:37b17b5fc1e604945c2642c872a3764b5d547a48009871aea3edd3afa180afb8", size = 7755, upload-time = "2024-10-06T19:50:02.097Z" }, + { url = "https://files.pythonhosted.org/packages/4f/bd/de8d508070629b6d84a30d01d57e4a65c69aa7f5abe7560b8fad3b50ea59/termcolor-3.1.0-py3-none-any.whl", hash = "sha256:591dd26b5c2ce03b9e43f391264626557873ce1d379019786f99b0c2bee140aa", size = 7684, upload-time = "2025-04-30T11:37:52.382Z" }, ] [[package]] @@ -1201,11 +1274,11 @@ wheels = [ [[package]] name = "tomlkit" -version = "0.13.2" +version = "0.13.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b1/09/a439bec5888f00a54b8b9f05fa94d7f901d6735ef4e55dcec9bc37b5d8fa/tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79", size = 192885, upload-time = "2024-08-14T08:19:41.488Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/b6/a447b5e4ec71e13871be01ba81f5dfc9d0af7e473da256ff46bc0e24026f/tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde", size = 37955, upload-time = "2024-08-14T08:19:40.05Z" }, + { url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" }, ] [[package]] @@ -1234,23 +1307,23 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.13.2" +version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]] name = "typing-inspection" -version = "0.4.0" +version = "0.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122", size = 76222, upload-time = "2025-02-25T17:27:59.638Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125, upload-time = "2025-02-25T17:27:57.754Z" }, + { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, ] [[package]] @@ -1283,3 +1356,62 @@ sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc wheels = [ { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166, upload-time = "2024-01-06T02:10:55.763Z" }, ] + +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" }, + { url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" }, + { url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8f/a32a99fc03e4b37e31b57cb9cefc65050ea08147a8ce12f288616b05ef54/wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311", size = 82376, upload-time = "2025-08-12T05:52:32.134Z" }, + { url = "https://files.pythonhosted.org/packages/31/57/4930cb8d9d70d59c27ee1332a318c20291749b4fba31f113c2f8ac49a72e/wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1", size = 83604, upload-time = "2025-08-12T05:52:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/1afd48de81d63dd66e01b263a6fbb86e1b5053b419b9b33d13e1f6d0f7d0/wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5", size = 82782, upload-time = "2025-08-12T05:52:12.626Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d7/4ad5327612173b144998232f98a85bb24b60c352afb73bc48e3e0d2bdc4e/wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2", size = 82076, upload-time = "2025-08-12T05:52:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/59/e0adfc831674a65694f18ea6dc821f9fcb9ec82c2ce7e3d73a88ba2e8718/wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89", size = 36457, upload-time = "2025-08-12T05:53:03.936Z" }, + { url = "https://files.pythonhosted.org/packages/83/88/16b7231ba49861b6f75fc309b11012ede4d6b0a9c90969d9e0db8d991aeb/wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77", size = 38745, upload-time = "2025-08-12T05:53:02.885Z" }, + { url = "https://files.pythonhosted.org/packages/9a/1e/c4d4f3398ec073012c51d1c8d87f715f56765444e1a4b11e5180577b7e6e/wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a", size = 36806, upload-time = "2025-08-12T05:52:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, + { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, + { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +] From e7b37312a70cfe6ae65d3724fd4837bfc28ab54f Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Sun, 14 Sep 2025 16:03:58 +0000 Subject: [PATCH 013/102] chore(deps): update astral-sh/setup-uv digest to b75a909 --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c821334..0b7f76d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Install the latest version of uv - uses: astral-sh/setup-uv@557e51de59eb14aaaba2ed9621916900a91d50c6 # v6 + uses: astral-sh/setup-uv@b75a909f75acd358c2196fb9a5f1299a9a8868a4 # v6 - name: Check format run: | uv run --frozen ruff format --diff @@ -31,7 +31,7 @@ jobs: with: compose-file: "./docker-compose.yml" - name: Install the latest version of uv - uses: astral-sh/setup-uv@557e51de59eb14aaaba2ed9621916900a91d50c6 # v6 + uses: astral-sh/setup-uv@b75a909f75acd358c2196fb9a5f1299a9a8868a4 # v6 - name: Wait for service to be ready run: | From cfd03a761b1c389bb519b194abe7efb141de434d Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Sun, 14 Sep 2025 20:42:14 +0200 Subject: [PATCH 014/102] ci: pin --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0b7f76d..247f436 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Install the latest version of uv - uses: astral-sh/setup-uv@b75a909f75acd358c2196fb9a5f1299a9a8868a4 # v6 + uses: astral-sh/setup-uv@b75a909f75acd358c2196fb9a5f1299a9a8868a4 # v6.7.0 - name: Check format run: | uv run --frozen ruff format --diff @@ -31,7 +31,7 @@ jobs: with: compose-file: "./docker-compose.yml" - name: Install the latest version of uv - uses: astral-sh/setup-uv@b75a909f75acd358c2196fb9a5f1299a9a8868a4 # v6 + uses: astral-sh/setup-uv@b75a909f75acd358c2196fb9a5f1299a9a8868a4 # v6.7.0 - name: Wait for service to be ready run: | From 948e7a4d919c81a9d6649655b40f8f65b1e98a48 Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Mon, 15 Sep 2025 22:07:01 +0000 Subject: [PATCH 015/102] chore(deps): update nextcloud docker tag to v31.0.9 --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index ddcc815..b0cffc5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,7 +21,7 @@ services: restart: always app: - image: nextcloud:31.0.8@sha256:92bc503ea0c19789f402b0469ecfb8df1ffea81e2bf90a45bba39063a626aa00 + image: nextcloud:31.0.9@sha256:4f432f8d5c6c44febb729749aaf1211187977634fb6e77a8aab7843984efdfce #user: www-data:www-data restart: always #post_start: From 0600cea87bfdf58efbe0535c2a5d34655816df4d Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Tue, 16 Sep 2025 04:05:11 +0000 Subject: [PATCH 016/102] chore(deps): update mariadb:lts docker digest to 851a602 --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index b0cffc5..6f7e286 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ services: # https://hub.docker.com/_/mariadb db: # Note: Check the recommend version here: https://docs.nextcloud.com/server/latest/admin_manual/installation/system_requirements.html#server - image: mariadb:lts@sha256:ec5d50f32359ff020b93cce6834f9bf89147c34aea0e90c952ccf556c94a4fb8 + image: mariadb:lts@sha256:851a6020c97b9eae7736b6fb275800601d64635222054d3a1b1b3c4abdfa117a restart: always command: --transaction-isolation=READ-COMMITTED volumes: From aafac732c6fc09ebf58d6eb5c7ef635fd5d4acb4 Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Wed, 17 Sep 2025 04:04:06 +0000 Subject: [PATCH 017/102] chore(deps): update nextcloud:31.0.9 docker digest to 11f1580 --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 6f7e286..b3fba7d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,7 +21,7 @@ services: restart: always app: - image: nextcloud:31.0.9@sha256:4f432f8d5c6c44febb729749aaf1211187977634fb6e77a8aab7843984efdfce + image: nextcloud:31.0.9@sha256:11f158050216614d585886600445e0a1b75ee224d6a6dddc5eba996cc9499fa6 #user: www-data:www-data restart: always #post_start: From 0a307b87ae99b4f53db6e28e9297c8381ee29474 Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Wed, 17 Sep 2025 22:06:28 +0000 Subject: [PATCH 018/102] chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.18 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 11ef0ab..d808fde 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/astral-sh/uv:0.8.17-python3.11-alpine@sha256:2a2cae80b7d3b3b3c7f94ec3ed91e9b3ca2524a7a429824fbbadd9954fa5d6b6 +FROM ghcr.io/astral-sh/uv:0.8.18-python3.11-alpine@sha256:368af158c1cf243aa7c518ca5d4772a1133f78df9100544868d58abe7a258ff8 WORKDIR /app From 8d4303a6249ba0c893c832caf38c68bd4324e519 Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Fri, 19 Sep 2025 22:07:37 +0000 Subject: [PATCH 019/102] chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.19 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index d808fde..84bd013 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/astral-sh/uv:0.8.18-python3.11-alpine@sha256:368af158c1cf243aa7c518ca5d4772a1133f78df9100544868d58abe7a258ff8 +FROM ghcr.io/astral-sh/uv:0.8.19-python3.11-alpine@sha256:f55e8bf10a21798bee13afc9d12f6923e32d5557528d3368a6e7248aae201e84 WORKDIR /app From 71da6200997d7d4bde01561867bb1682de561149 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Sat, 20 Sep 2025 22:22:06 +0200 Subject: [PATCH 020/102] refactor: Add `http` to --transport option --- .dockerignore | 3 +-- Dockerfile | 2 +- nextcloud_mcp_server/app.py | 4 ++-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.dockerignore b/.dockerignore index 0f229a0..88f7234 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,8 +1,7 @@ * !pyproject.toml -!poetry.lock !README.md !uv.lock -!nextcloud_mcp_server/ +!nextcloud_mcp_server/**/*.py diff --git a/Dockerfile b/Dockerfile index 84bd013..446f710 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,4 +6,4 @@ COPY . . RUN uv sync --locked --no-dev -ENTRYPOINT ["/app/.venv/bin/python", "-m", "nextcloud_mcp_server.app", "--host", "0.0.0.0"] +ENTRYPOINT ["/app/.venv/bin/nextcloud-mcp-server", "--host", "0.0.0.0"] diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index 6bb1371..380e31b 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -86,7 +86,7 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): if transport == "sse": mcp_app = mcp.sse_app() lifespan = None - else: + elif transport in ("http", "streamable-http"): mcp_app = mcp.streamable_http_app() @asynccontextmanager @@ -117,7 +117,7 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): "-t", default="sse", show_default=True, - type=click.Choice(["sse", "streamable-http"]), + type=click.Choice(["sse", "streamable-http", "http"]), ) @click.option( "--enable-app", From 22811f29f60f5d04594ab904e55665e203c079c8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 20 Sep 2025 20:34:35 +0000 Subject: [PATCH 021/102] =?UTF-8?q?bump:=20version=200.12.1=20=E2=86=92=20?= =?UTF-8?q?0.12.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa9d6ba..18bec38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## v0.12.2 (2025-09-20) + +### Refactor + +- Add `http` to --transport option + ## v0.12.1 (2025-09-11) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 46e5853..bc50c7f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nextcloud-mcp-server" -version = "0.12.1" +version = "0.12.2" description = "" authors = [ {name = "Chris Coutinho",email = "chris@coutinho.io"} diff --git a/uv.lock b/uv.lock index 06f6558..972a2fb 100644 --- a/uv.lock +++ b/uv.lock @@ -550,7 +550,7 @@ wheels = [ [[package]] name = "nextcloud-mcp-server" -version = "0.12.1" +version = "0.12.2" source = { editable = "." } dependencies = [ { name = "click" }, From 67617d7fccc0c4e1dd0ef34827c0f28308115d1b Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Tue, 23 Sep 2025 04:07:43 +0000 Subject: [PATCH 022/102] chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.20 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 446f710..80cbf3d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/astral-sh/uv:0.8.19-python3.11-alpine@sha256:f55e8bf10a21798bee13afc9d12f6923e32d5557528d3368a6e7248aae201e84 +FROM ghcr.io/astral-sh/uv:0.8.20-python3.11-alpine@sha256:8c24c223d63cb6b997101852cb7bc767b349918652e05e3cba20c93c899cb3d5 WORKDIR /app From 1a37a6c1fe1584b28f8b08fb3df602364d20f80f Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Tue, 23 Sep 2025 22:07:49 +0000 Subject: [PATCH 023/102] chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.22 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 80cbf3d..5939b97 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/astral-sh/uv:0.8.20-python3.11-alpine@sha256:8c24c223d63cb6b997101852cb7bc767b349918652e05e3cba20c93c899cb3d5 +FROM ghcr.io/astral-sh/uv:0.8.22-python3.11-alpine@sha256:a8d5f7079a3223380ec060fefe48afe45b4c4622d631ce0e495593ac9a38f546 WORKDIR /app From cc9650b077dbc15a0fba892a84d4df400566c62d Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Wed, 24 Sep 2025 00:13:24 +0200 Subject: [PATCH 024/102] refactor: Add tools for all resources to enable tool-only workflows --- nextcloud_mcp_server/app.py | 3 + nextcloud_mcp_server/server/deck.py | 92 ++++++++++++++++++++- nextcloud_mcp_server/server/notes.py | 69 +++++++++++++++- tests/integration/test_error_propagation.py | 15 ++-- tests/integration/test_mcp.py | 11 ++- 5 files changed, 176 insertions(+), 14 deletions(-) diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index 380e31b..9e182a9 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -56,6 +56,9 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): ctx: Context = ( mcp.get_context() ) # https://github.com/modelcontextprotocol/python-sdk/issues/244 + await ctx.warning( + "This resource is deprecated and will be removed in a future version" + ) client: NextcloudClient = ctx.request_context.lifespan_context.client return await client.capabilities() diff --git a/nextcloud_mcp_server/server/deck.py b/nextcloud_mcp_server/server/deck.py index 034a430..8d2ddad 100644 --- a/nextcloud_mcp_server/server/deck.py +++ b/nextcloud_mcp_server/server/deck.py @@ -5,6 +5,10 @@ from mcp.server.fastmcp import Context, FastMCP from nextcloud_mcp_server.client import NextcloudClient from nextcloud_mcp_server.models.deck import ( + DeckBoard, + DeckStack, + DeckCard, + DeckLabel, CreateBoardResponse, CreateStackResponse, StackOperationResponse, @@ -25,6 +29,7 @@ def configure_deck_tools(mcp: FastMCP): async def deck_boards_resource(): """List all Nextcloud Deck boards""" ctx: Context = mcp.get_context() + await ctx.warning("This message is deprecated, use the deck_get_board instead") client: NextcloudClient = ctx.request_context.lifespan_context.client boards = await client.deck.get_boards() return [board.model_dump() for board in boards] @@ -33,6 +38,9 @@ def configure_deck_tools(mcp: FastMCP): async def deck_board_resource(board_id: int): """Get details of a specific Nextcloud Deck board""" ctx: Context = mcp.get_context() + await ctx.warning( + "This resource is deprecated, use the deck_get_board tool instead" + ) client: NextcloudClient = ctx.request_context.lifespan_context.client board = await client.deck.get_board(board_id) return board.model_dump() @@ -41,6 +49,9 @@ def configure_deck_tools(mcp: FastMCP): async def deck_stacks_resource(board_id: int): """List all stacks in a Nextcloud Deck board""" ctx: Context = mcp.get_context() + await ctx.warning( + "This resource is deprecated, use the deck_get_stacks tool instead" + ) client: NextcloudClient = ctx.request_context.lifespan_context.client stacks = await client.deck.get_stacks(board_id) return [stack.model_dump() for stack in stacks] @@ -49,6 +60,9 @@ def configure_deck_tools(mcp: FastMCP): async def deck_stack_resource(board_id: int, stack_id: int): """Get details of a specific Nextcloud Deck stack""" ctx: Context = mcp.get_context() + await ctx.warning( + "This resource is deprecated, use the deck_get_stack tool instead" + ) client: NextcloudClient = ctx.request_context.lifespan_context.client stack = await client.deck.get_stack(board_id, stack_id) return stack.model_dump() @@ -57,6 +71,9 @@ def configure_deck_tools(mcp: FastMCP): async def deck_cards_resource(board_id: int, stack_id: int): """List all cards in a Nextcloud Deck stack""" ctx: Context = mcp.get_context() + await ctx.warning( + "This resource is deprecated, use the deck_get_cards tool instead" + ) client: NextcloudClient = ctx.request_context.lifespan_context.client stack = await client.deck.get_stack(board_id, stack_id) if stack.cards: @@ -67,6 +84,9 @@ def configure_deck_tools(mcp: FastMCP): async def deck_card_resource(board_id: int, stack_id: int, card_id: int): """Get details of a specific Nextcloud Deck card""" ctx: Context = mcp.get_context() + await ctx.warning( + "This resource is deprecated, use the deck_get_card tool instead" + ) client: NextcloudClient = ctx.request_context.lifespan_context.client card = await client.deck.get_card(board_id, stack_id, card_id) return card.model_dump() @@ -75,6 +95,9 @@ def configure_deck_tools(mcp: FastMCP): async def deck_labels_resource(board_id: int): """List all labels in a Nextcloud Deck board""" ctx: Context = mcp.get_context() + await ctx.warning( + "This resource is deprecated, use the deck_get_labels tool instead" + ) client: NextcloudClient = ctx.request_context.lifespan_context.client board = await client.deck.get_board(board_id) return [label.model_dump() for label in board.labels] @@ -83,11 +106,78 @@ def configure_deck_tools(mcp: FastMCP): async def deck_label_resource(board_id: int, label_id: int): """Get details of a specific Nextcloud Deck label""" ctx: Context = mcp.get_context() + await ctx.warning( + "This resource is deprecated, use the deck_get_label tool instead" + ) client: NextcloudClient = ctx.request_context.lifespan_context.client label = await client.deck.get_label(board_id, label_id) return label.model_dump() - # Tools + # Read Tools (converted from resources) + + @mcp.tool() + async def deck_get_boards(ctx: Context) -> list[DeckBoard]: + """Get all Nextcloud Deck boards""" + client: NextcloudClient = ctx.request_context.lifespan_context.client + boards = await client.deck.get_boards() + return boards + + @mcp.tool() + async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard: + """Get details of a specific Nextcloud Deck board""" + client: NextcloudClient = ctx.request_context.lifespan_context.client + board = await client.deck.get_board(board_id) + return board + + @mcp.tool() + async def deck_get_stacks(ctx: Context, board_id: int) -> list[DeckStack]: + """Get all stacks in a Nextcloud Deck board""" + client: NextcloudClient = ctx.request_context.lifespan_context.client + stacks = await client.deck.get_stacks(board_id) + return stacks + + @mcp.tool() + async def deck_get_stack(ctx: Context, board_id: int, stack_id: int) -> DeckStack: + """Get details of a specific Nextcloud Deck stack""" + client: NextcloudClient = ctx.request_context.lifespan_context.client + stack = await client.deck.get_stack(board_id, stack_id) + return stack + + @mcp.tool() + async def deck_get_cards( + ctx: Context, board_id: int, stack_id: int + ) -> list[DeckCard]: + """Get all cards in a Nextcloud Deck stack""" + client: NextcloudClient = ctx.request_context.lifespan_context.client + stack = await client.deck.get_stack(board_id, stack_id) + if stack.cards: + return stack.cards + return [] + + @mcp.tool() + async def deck_get_card( + ctx: Context, board_id: int, stack_id: int, card_id: int + ) -> DeckCard: + """Get details of a specific Nextcloud Deck card""" + client: NextcloudClient = ctx.request_context.lifespan_context.client + card = await client.deck.get_card(board_id, stack_id, card_id) + return card + + @mcp.tool() + async def deck_get_labels(ctx: Context, board_id: int) -> list[DeckLabel]: + """Get all labels in a Nextcloud Deck board""" + client: NextcloudClient = ctx.request_context.lifespan_context.client + board = await client.deck.get_board(board_id) + return board.labels + + @mcp.tool() + async def deck_get_label(ctx: Context, board_id: int, label_id: int) -> DeckLabel: + """Get details of a specific Nextcloud Deck label""" + client: NextcloudClient = ctx.request_context.lifespan_context.client + label = await client.deck.get_label(board_id, label_id) + return label + + # Create/Update/Delete Tools @mcp.tool() async def deck_create_board( diff --git a/nextcloud_mcp_server/server/notes.py b/nextcloud_mcp_server/server/notes.py index cd21d45..607dee6 100644 --- a/nextcloud_mcp_server/server/notes.py +++ b/nextcloud_mcp_server/server/notes.py @@ -27,12 +27,15 @@ def configure_notes_tools(mcp: FastMCP): ctx: Context = ( mcp.get_context() ) # https://github.com/modelcontextprotocol/python-sdk/issues/244 + await ctx.warning( + "This resource is deprecated and will be removed in a future version" + ) client: NextcloudClient = ctx.request_context.lifespan_context.client settings_data = await client.notes.get_settings() return NotesSettings(**settings_data) @mcp.resource("nc://Notes/{note_id}/attachments/{attachment_filename}") - async def nc_notes_get_attachment(note_id: int, attachment_filename: str): + async def nc_notes_get_attachment_resource(note_id: int, attachment_filename: str): """Get a specific attachment from a note""" ctx: Context = mcp.get_context() client: NextcloudClient = ctx.request_context.lifespan_context.client @@ -53,7 +56,7 @@ def configure_notes_tools(mcp: FastMCP): } @mcp.resource("nc://Notes/{note_id}") - async def nc_get_note(note_id: int): + async def nc_get_note_resource(note_id: int): """Get user note using note id""" ctx: Context = mcp.get_context() @@ -129,7 +132,7 @@ def configure_notes_tools(mcp: FastMCP): """Update an existing note's title, content, or category. REQUIRED: etag parameter must be provided to prevent overwriting concurrent changes. - Get the current ETag by first retrieving the note using nc://Notes/{note_id} resource. + Get the current ETag by first retrieving the note using nc_notes_get_note tool. If the note has been modified by someone else since you retrieved it, the update will fail with a 412 error.""" logger.info("Updating note %s", note_id) @@ -258,6 +261,66 @@ def configure_notes_tools(mcp: FastMCP): ) ) + @mcp.tool() + async def nc_notes_get_note(note_id: int, ctx: Context) -> Note: + """Get a specific note by its ID""" + client: NextcloudClient = ctx.request_context.lifespan_context.client + try: + note_data = await client.notes.get_note(note_id) + return Note(**note_data) + except HTTPStatusError as e: + if e.response.status_code == 404: + raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found")) + elif e.response.status_code == 403: + raise McpError( + ErrorData(code=-1, message=f"Access denied to note {note_id}") + ) + else: + raise McpError( + ErrorData( + code=-1, + message=f"Failed to retrieve note {note_id}: {e.response.reason_phrase}", + ) + ) + + @mcp.tool() + async def nc_notes_get_attachment( + note_id: int, attachment_filename: str, ctx: Context + ) -> dict[str, str]: + """Get a specific attachment from a note""" + client: NextcloudClient = ctx.request_context.lifespan_context.client + try: + content, mime_type = await client.webdav.get_note_attachment( + note_id=note_id, filename=attachment_filename + ) + return { + "uri": f"nc://Notes/{note_id}/attachments/{attachment_filename}", + "mimeType": mime_type, + "data": content, + } + except HTTPStatusError as e: + if e.response.status_code == 404: + raise McpError( + ErrorData( + code=-1, + message=f"Attachment {attachment_filename} not found for note {note_id}", + ) + ) + elif e.response.status_code == 403: + raise McpError( + ErrorData( + code=-1, + message=f"Access denied to attachment {attachment_filename} for note {note_id}", + ) + ) + else: + raise McpError( + ErrorData( + code=-1, + message=f"Failed to retrieve attachment: {e.response.reason_phrase}", + ) + ) + @mcp.tool() async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse: """Delete a note permanently""" diff --git a/tests/integration/test_error_propagation.py b/tests/integration/test_error_propagation.py index 623d5ab..8cf6667 100644 --- a/tests/integration/test_error_propagation.py +++ b/tests/integration/test_error_propagation.py @@ -2,7 +2,6 @@ import logging from mcp import ClientSession -from mcp.shared.exceptions import McpError import pytest @@ -10,11 +9,15 @@ logger = logging.getLogger(__name__) @pytest.mark.integration -async def test_missing_note_resource_error(nc_mcp_client: ClientSession): - """Test that accessing a non-existent note resource returns proper error.""" - # Try to get a non-existent note via resource - should raise McpError with improved message - with pytest.raises(McpError, match=r"Note 999999 not found"): - await nc_mcp_client.read_resource("nc://Notes/999999") +async def test_missing_note_tool_error(nc_mcp_client: ClientSession): + """Test that accessing a non-existent note via tool returns proper error.""" + # Try to get a non-existent note via tool - should return error response + response = await nc_mcp_client.call_tool("nc_notes_get_note", {"note_id": 999999}) + + # Should return error response (not raise exception) for tools + assert response is not None + assert response.isError is True + assert "Note 999999 not found" in response.content[0].text @pytest.mark.integration diff --git a/tests/integration/test_mcp.py b/tests/integration/test_mcp.py index ea65c7d..c3074fe 100644 --- a/tests/integration/test_mcp.py +++ b/tests/integration/test_mcp.py @@ -68,7 +68,8 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession): template_uris.append(template.uriTemplate) # Verify expected resource templates - expected_templates = ["nc://Notes/{note_id}/attachments/{attachment_filename}"] + # Note: Notes attachments are now handled via tools, not resource templates + expected_templates = [] for expected_template in expected_templates: assert expected_template in template_uris, ( @@ -140,9 +141,11 @@ async def test_mcp_notes_crud_workflow( # 3. Read note via MCP logger.info(f"Reading note via MCP: {note_id}") - read_result = await nc_mcp_client.read_resource(f"nc://Notes/{note_id}") - assert len(read_result.contents) == 1, "Expected exactly one content item" - read_note_data = json.loads(read_result.contents[0].text) + read_result = await nc_mcp_client.call_tool( + "nc_notes_get_note", {"note_id": note_id} + ) + read_note_data = read_result.content[0].text + read_note_data = json.loads(read_note_data) assert read_note_data["title"] == test_title assert read_note_data["content"] == test_content From 79e6250377835e7a39503a76e310649e8d47b392 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Wed, 24 Sep 2025 00:17:57 +0200 Subject: [PATCH 025/102] update deprecated log warnings --- nextcloud_mcp_server/app.py | 3 --- nextcloud_mcp_server/server/notes.py | 3 --- 2 files changed, 6 deletions(-) diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index 9e182a9..380e31b 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -56,9 +56,6 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): ctx: Context = ( mcp.get_context() ) # https://github.com/modelcontextprotocol/python-sdk/issues/244 - await ctx.warning( - "This resource is deprecated and will be removed in a future version" - ) client: NextcloudClient = ctx.request_context.lifespan_context.client return await client.capabilities() diff --git a/nextcloud_mcp_server/server/notes.py b/nextcloud_mcp_server/server/notes.py index 607dee6..37ab74a 100644 --- a/nextcloud_mcp_server/server/notes.py +++ b/nextcloud_mcp_server/server/notes.py @@ -27,9 +27,6 @@ def configure_notes_tools(mcp: FastMCP): ctx: Context = ( mcp.get_context() ) # https://github.com/modelcontextprotocol/python-sdk/issues/244 - await ctx.warning( - "This resource is deprecated and will be removed in a future version" - ) client: NextcloudClient = ctx.request_context.lifespan_context.client settings_data = await client.notes.get_settings() return NotesSettings(**settings_data) From 93b109e5b9c3b2277bf300dc261dd6fb804caad3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 23 Sep 2025 22:22:36 +0000 Subject: [PATCH 026/102] =?UTF-8?q?bump:=20version=200.12.2=20=E2=86=92=20?= =?UTF-8?q?0.12.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18bec38..3d5c7ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## v0.12.3 (2025-09-23) + +### Refactor + +- Add tools for all resources to enable tool-only workflows + ## v0.12.2 (2025-09-20) ### Refactor diff --git a/pyproject.toml b/pyproject.toml index bc50c7f..2acee85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nextcloud-mcp-server" -version = "0.12.2" +version = "0.12.3" description = "" authors = [ {name = "Chris Coutinho",email = "chris@coutinho.io"} diff --git a/uv.lock b/uv.lock index 972a2fb..5f98a10 100644 --- a/uv.lock +++ b/uv.lock @@ -550,7 +550,7 @@ wheels = [ [[package]] name = "nextcloud-mcp-server" -version = "0.12.2" +version = "0.12.3" source = { editable = "." } dependencies = [ { name = "click" }, From 4bdf67b04299f76900e071ba2052b448cbb2090c Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Thu, 25 Sep 2025 16:07:30 +0000 Subject: [PATCH 027/102] fix(deps): update dependency mcp to >=1.15,<1.16 --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2acee85..c4bbdf5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ authors = [ readme = "README.md" requires-python = ">=3.11" dependencies = [ - "mcp[cli] (>=1.13,<1.14)", + "mcp[cli] (>=1.15,<1.16)", "httpx (>=0.28.1,<0.29.0)", "pillow (>=11.2.1,<12.0.0)", "icalendar (>=6.0.0,<7.0.0)", diff --git a/uv.lock b/uv.lock index 5f98a10..d9b1f08 100644 --- a/uv.lock +++ b/uv.lock @@ -513,7 +513,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.13.1" +version = "1.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -528,9 +528,9 @@ dependencies = [ { name = "starlette" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/3c/82c400c2d50afdac4fbefb5b4031fd327e2ad1f23ccef8eee13c5909aa48/mcp-1.13.1.tar.gz", hash = "sha256:165306a8fd7991dc80334edd2de07798175a56461043b7ae907b279794a834c5", size = 438198, upload-time = "2025-08-22T09:22:16.061Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/9e/e65114795f359f314d7061f4fcb50dfe60026b01b52ad0b986b4631bf8bb/mcp-1.15.0.tar.gz", hash = "sha256:5bda1f4d383cf539d3c035b3505a3de94b20dbd7e4e8b4bd071e14634eeb2d72", size = 469622, upload-time = "2025-09-25T15:39:51.995Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/3f/d085c7f49ade6d273b185d61ec9405e672b6433f710ea64a90135a8dd445/mcp-1.13.1-py3-none-any.whl", hash = "sha256:c314e7c8bd477a23ba3ef472ee5a32880316c42d03e06dcfa31a1cc7a73b65df", size = 161494, upload-time = "2025-08-22T09:22:14.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/82/4d0df23d5ff5bb982a59ad597bc7cb9920f2650278ccefb8e0d85c5ce3d4/mcp-1.15.0-py3-none-any.whl", hash = "sha256:314614c8addc67b663d6c3e4054db0a5c3dedc416c24ef8ce954e203fdc2333d", size = 166963, upload-time = "2025-09-25T15:39:50.538Z" }, ] [package.optional-dependencies] @@ -577,7 +577,7 @@ requires-dist = [ { name = "click", specifier = ">=8.1.8" }, { name = "httpx", specifier = ">=0.28.1,<0.29.0" }, { name = "icalendar", specifier = ">=6.0.0,<7.0.0" }, - { name = "mcp", extras = ["cli"], specifier = ">=1.13,<1.14" }, + { name = "mcp", extras = ["cli"], specifier = ">=1.15,<1.16" }, { name = "pillow", specifier = ">=11.2.1,<12.0.0" }, { name = "pydantic", specifier = ">=2.11.4" }, { name = "pythonvcard4", specifier = ">=0.2.0" }, From 144c08c3393f43b09702f0cb21d0ac002d01655d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 25 Sep 2025 16:17:59 +0000 Subject: [PATCH 028/102] =?UTF-8?q?bump:=20version=200.12.3=20=E2=86=92=20?= =?UTF-8?q?0.12.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d5c7ae..7a50d2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## v0.12.4 (2025-09-25) + +### Fix + +- **deps**: update dependency mcp to >=1.15,<1.16 + ## v0.12.3 (2025-09-23) ### Refactor diff --git a/pyproject.toml b/pyproject.toml index c4bbdf5..e224fa1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nextcloud-mcp-server" -version = "0.12.3" +version = "0.12.4" description = "" authors = [ {name = "Chris Coutinho",email = "chris@coutinho.io"} diff --git a/uv.lock b/uv.lock index d9b1f08..6833466 100644 --- a/uv.lock +++ b/uv.lock @@ -550,7 +550,7 @@ wheels = [ [[package]] name = "nextcloud-mcp-server" -version = "0.12.3" +version = "0.12.4" source = { editable = "." } dependencies = [ { name = "click" }, From 290ad2edc2ff7d33e89a54565ffe9d591ac23667 Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Sat, 27 Sep 2025 10:05:24 +0000 Subject: [PATCH 029/102] chore(deps): update nextcloud:31.0.9 docker digest to 875511f --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index b3fba7d..10a572f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,7 +21,7 @@ services: restart: always app: - image: nextcloud:31.0.9@sha256:11f158050216614d585886600445e0a1b75ee224d6a6dddc5eba996cc9499fa6 + image: nextcloud:31.0.9@sha256:875511fe8d307d7b353b9d754080a40cce3291819af1ed03862d541ba4d5c6a6 #user: www-data:www-data restart: always #post_start: From 3f8312e6f3561424f3fc371cd97186bed0553a3d Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Sun, 28 Sep 2025 22:05:34 +0000 Subject: [PATCH 030/102] chore(deps): update nextcloud:31.0.9 docker digest to 88fe398 --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 10a572f..e3b592d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,7 +21,7 @@ services: restart: always app: - image: nextcloud:31.0.9@sha256:875511fe8d307d7b353b9d754080a40cce3291819af1ed03862d541ba4d5c6a6 + image: nextcloud:31.0.9@sha256:88fe398340a896eeebfe0a4ba847998ff2c8fbb3d72de354ac1f08bc7b44db18 #user: www-data:www-data restart: always #post_start: From 55945c6c0f8accd1befceaa867c8f628f0ef73a1 Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Mon, 29 Sep 2025 04:12:15 +0000 Subject: [PATCH 031/102] chore(deps): lock file maintenance --- uv.lock | 420 ++++++++++++++++++++++++++++++++------------------------ 1 file changed, 239 insertions(+), 181 deletions(-) diff --git a/uv.lock b/uv.lock index 6833466..f30df55 100644 --- a/uv.lock +++ b/uv.lock @@ -13,16 +13,16 @@ wheels = [ [[package]] name = "anyio" -version = "4.10.0" +version = "4.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "sniffio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, ] [[package]] @@ -116,14 +116,14 @@ wheels = [ [[package]] name = "click" -version = "8.2.1" +version = "8.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, + { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, ] [[package]] @@ -160,77 +160,89 @@ wheels = [ [[package]] name = "coverage" -version = "7.10.6" +version = "7.10.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/14/70/025b179c993f019105b79575ac6edb5e084fb0f0e63f15cdebef4e454fb5/coverage-7.10.6.tar.gz", hash = "sha256:f644a3ae5933a552a29dbb9aa2f90c677a875f80ebea028e5a52a4f429044b90", size = 823736, upload-time = "2025-08-29T15:35:16.668Z" } +sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/16/2bea27e212c4980753d6d563a0803c150edeaaddb0771a50d2afc410a261/coverage-7.10.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c706db3cabb7ceef779de68270150665e710b46d56372455cd741184f3868d8f", size = 217129, upload-time = "2025-08-29T15:33:13.575Z" }, - { url = "https://files.pythonhosted.org/packages/2a/51/e7159e068831ab37e31aac0969d47b8c5ee25b7d307b51e310ec34869315/coverage-7.10.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8e0c38dc289e0508ef68ec95834cb5d2e96fdbe792eaccaa1bccac3966bbadcc", size = 217532, upload-time = "2025-08-29T15:33:14.872Z" }, - { url = "https://files.pythonhosted.org/packages/e7/c0/246ccbea53d6099325d25cd208df94ea435cd55f0db38099dd721efc7a1f/coverage-7.10.6-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:752a3005a1ded28f2f3a6e8787e24f28d6abe176ca64677bcd8d53d6fe2ec08a", size = 247931, upload-time = "2025-08-29T15:33:16.142Z" }, - { url = "https://files.pythonhosted.org/packages/7d/fb/7435ef8ab9b2594a6e3f58505cc30e98ae8b33265d844007737946c59389/coverage-7.10.6-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:689920ecfd60f992cafca4f5477d55720466ad2c7fa29bb56ac8d44a1ac2b47a", size = 249864, upload-time = "2025-08-29T15:33:17.434Z" }, - { url = "https://files.pythonhosted.org/packages/51/f8/d9d64e8da7bcddb094d511154824038833c81e3a039020a9d6539bf303e9/coverage-7.10.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec98435796d2624d6905820a42f82149ee9fc4f2d45c2c5bc5a44481cc50db62", size = 251969, upload-time = "2025-08-29T15:33:18.822Z" }, - { url = "https://files.pythonhosted.org/packages/43/28/c43ba0ef19f446d6463c751315140d8f2a521e04c3e79e5c5fe211bfa430/coverage-7.10.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b37201ce4a458c7a758ecc4efa92fa8ed783c66e0fa3c42ae19fc454a0792153", size = 249659, upload-time = "2025-08-29T15:33:20.407Z" }, - { url = "https://files.pythonhosted.org/packages/79/3e/53635bd0b72beaacf265784508a0b386defc9ab7fad99ff95f79ce9db555/coverage-7.10.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2904271c80898663c810a6b067920a61dd8d38341244a3605bd31ab55250dad5", size = 247714, upload-time = "2025-08-29T15:33:21.751Z" }, - { url = "https://files.pythonhosted.org/packages/4c/55/0964aa87126624e8c159e32b0bc4e84edef78c89a1a4b924d28dd8265625/coverage-7.10.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5aea98383463d6e1fa4e95416d8de66f2d0cb588774ee20ae1b28df826bcb619", size = 248351, upload-time = "2025-08-29T15:33:23.105Z" }, - { url = "https://files.pythonhosted.org/packages/eb/ab/6cfa9dc518c6c8e14a691c54e53a9433ba67336c760607e299bfcf520cb1/coverage-7.10.6-cp311-cp311-win32.whl", hash = "sha256:e3fb1fa01d3598002777dd259c0c2e6d9d5e10e7222976fc8e03992f972a2cba", size = 219562, upload-time = "2025-08-29T15:33:24.717Z" }, - { url = "https://files.pythonhosted.org/packages/5b/18/99b25346690cbc55922e7cfef06d755d4abee803ef335baff0014268eff4/coverage-7.10.6-cp311-cp311-win_amd64.whl", hash = "sha256:f35ed9d945bece26553d5b4c8630453169672bea0050a564456eb88bdffd927e", size = 220453, upload-time = "2025-08-29T15:33:26.482Z" }, - { url = "https://files.pythonhosted.org/packages/d8/ed/81d86648a07ccb124a5cf1f1a7788712b8d7216b593562683cd5c9b0d2c1/coverage-7.10.6-cp311-cp311-win_arm64.whl", hash = "sha256:99e1a305c7765631d74b98bf7dbf54eeea931f975e80f115437d23848ee8c27c", size = 219127, upload-time = "2025-08-29T15:33:27.777Z" }, - { url = "https://files.pythonhosted.org/packages/26/06/263f3305c97ad78aab066d116b52250dd316e74fcc20c197b61e07eb391a/coverage-7.10.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5b2dd6059938063a2c9fee1af729d4f2af28fd1a545e9b7652861f0d752ebcea", size = 217324, upload-time = "2025-08-29T15:33:29.06Z" }, - { url = "https://files.pythonhosted.org/packages/e9/60/1e1ded9a4fe80d843d7d53b3e395c1db3ff32d6c301e501f393b2e6c1c1f/coverage-7.10.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:388d80e56191bf846c485c14ae2bc8898aa3124d9d35903fef7d907780477634", size = 217560, upload-time = "2025-08-29T15:33:30.748Z" }, - { url = "https://files.pythonhosted.org/packages/b8/25/52136173c14e26dfed8b106ed725811bb53c30b896d04d28d74cb64318b3/coverage-7.10.6-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:90cb5b1a4670662719591aa92d0095bb41714970c0b065b02a2610172dbf0af6", size = 249053, upload-time = "2025-08-29T15:33:32.041Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1d/ae25a7dc58fcce8b172d42ffe5313fc267afe61c97fa872b80ee72d9515a/coverage-7.10.6-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:961834e2f2b863a0e14260a9a273aff07ff7818ab6e66d2addf5628590c628f9", size = 251802, upload-time = "2025-08-29T15:33:33.625Z" }, - { url = "https://files.pythonhosted.org/packages/f5/7a/1f561d47743710fe996957ed7c124b421320f150f1d38523d8d9102d3e2a/coverage-7.10.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf9a19f5012dab774628491659646335b1928cfc931bf8d97b0d5918dd58033c", size = 252935, upload-time = "2025-08-29T15:33:34.909Z" }, - { url = "https://files.pythonhosted.org/packages/6c/ad/8b97cd5d28aecdfde792dcbf646bac141167a5cacae2cd775998b45fabb5/coverage-7.10.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:99c4283e2a0e147b9c9cc6bc9c96124de9419d6044837e9799763a0e29a7321a", size = 250855, upload-time = "2025-08-29T15:33:36.922Z" }, - { url = "https://files.pythonhosted.org/packages/33/6a/95c32b558d9a61858ff9d79580d3877df3eb5bc9eed0941b1f187c89e143/coverage-7.10.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:282b1b20f45df57cc508c1e033403f02283adfb67d4c9c35a90281d81e5c52c5", size = 248974, upload-time = "2025-08-29T15:33:38.175Z" }, - { url = "https://files.pythonhosted.org/packages/0d/9c/8ce95dee640a38e760d5b747c10913e7a06554704d60b41e73fdea6a1ffd/coverage-7.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cdbe264f11afd69841bd8c0d83ca10b5b32853263ee62e6ac6a0ab63895f972", size = 250409, upload-time = "2025-08-29T15:33:39.447Z" }, - { url = "https://files.pythonhosted.org/packages/04/12/7a55b0bdde78a98e2eb2356771fd2dcddb96579e8342bb52aa5bc52e96f0/coverage-7.10.6-cp312-cp312-win32.whl", hash = "sha256:a517feaf3a0a3eca1ee985d8373135cfdedfbba3882a5eab4362bda7c7cf518d", size = 219724, upload-time = "2025-08-29T15:33:41.172Z" }, - { url = "https://files.pythonhosted.org/packages/36/4a/32b185b8b8e327802c9efce3d3108d2fe2d9d31f153a0f7ecfd59c773705/coverage-7.10.6-cp312-cp312-win_amd64.whl", hash = "sha256:856986eadf41f52b214176d894a7de05331117f6035a28ac0016c0f63d887629", size = 220536, upload-time = "2025-08-29T15:33:42.524Z" }, - { url = "https://files.pythonhosted.org/packages/08/3a/d5d8dc703e4998038c3099eaf77adddb00536a3cec08c8dcd556a36a3eb4/coverage-7.10.6-cp312-cp312-win_arm64.whl", hash = "sha256:acf36b8268785aad739443fa2780c16260ee3fa09d12b3a70f772ef100939d80", size = 219171, upload-time = "2025-08-29T15:33:43.974Z" }, - { url = "https://files.pythonhosted.org/packages/bd/e7/917e5953ea29a28c1057729c1d5af9084ab6d9c66217523fd0e10f14d8f6/coverage-7.10.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ffea0575345e9ee0144dfe5701aa17f3ba546f8c3bb48db62ae101afb740e7d6", size = 217351, upload-time = "2025-08-29T15:33:45.438Z" }, - { url = "https://files.pythonhosted.org/packages/eb/86/2e161b93a4f11d0ea93f9bebb6a53f113d5d6e416d7561ca41bb0a29996b/coverage-7.10.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:95d91d7317cde40a1c249d6b7382750b7e6d86fad9d8eaf4fa3f8f44cf171e80", size = 217600, upload-time = "2025-08-29T15:33:47.269Z" }, - { url = "https://files.pythonhosted.org/packages/0e/66/d03348fdd8df262b3a7fb4ee5727e6e4936e39e2f3a842e803196946f200/coverage-7.10.6-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e23dd5408fe71a356b41baa82892772a4cefcf758f2ca3383d2aa39e1b7a003", size = 248600, upload-time = "2025-08-29T15:33:48.953Z" }, - { url = "https://files.pythonhosted.org/packages/73/dd/508420fb47d09d904d962f123221bc249f64b5e56aa93d5f5f7603be475f/coverage-7.10.6-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f3f56e4cb573755e96a16501a98bf211f100463d70275759e73f3cbc00d4f27", size = 251206, upload-time = "2025-08-29T15:33:50.697Z" }, - { url = "https://files.pythonhosted.org/packages/e9/1f/9020135734184f439da85c70ea78194c2730e56c2d18aee6e8ff1719d50d/coverage-7.10.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db4a1d897bbbe7339946ffa2fe60c10cc81c43fab8b062d3fcb84188688174a4", size = 252478, upload-time = "2025-08-29T15:33:52.303Z" }, - { url = "https://files.pythonhosted.org/packages/a4/a4/3d228f3942bb5a2051fde28c136eea23a761177dc4ff4ef54533164ce255/coverage-7.10.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d8fd7879082953c156d5b13c74aa6cca37f6a6f4747b39538504c3f9c63d043d", size = 250637, upload-time = "2025-08-29T15:33:53.67Z" }, - { url = "https://files.pythonhosted.org/packages/36/e3/293dce8cdb9a83de971637afc59b7190faad60603b40e32635cbd15fbf61/coverage-7.10.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:28395ca3f71cd103b8c116333fa9db867f3a3e1ad6a084aa3725ae002b6583bc", size = 248529, upload-time = "2025-08-29T15:33:55.022Z" }, - { url = "https://files.pythonhosted.org/packages/90/26/64eecfa214e80dd1d101e420cab2901827de0e49631d666543d0e53cf597/coverage-7.10.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:61c950fc33d29c91b9e18540e1aed7d9f6787cc870a3e4032493bbbe641d12fc", size = 250143, upload-time = "2025-08-29T15:33:56.386Z" }, - { url = "https://files.pythonhosted.org/packages/3e/70/bd80588338f65ea5b0d97e424b820fb4068b9cfb9597fbd91963086e004b/coverage-7.10.6-cp313-cp313-win32.whl", hash = "sha256:160c00a5e6b6bdf4e5984b0ef21fc860bc94416c41b7df4d63f536d17c38902e", size = 219770, upload-time = "2025-08-29T15:33:58.063Z" }, - { url = "https://files.pythonhosted.org/packages/a7/14/0b831122305abcc1060c008f6c97bbdc0a913ab47d65070a01dc50293c2b/coverage-7.10.6-cp313-cp313-win_amd64.whl", hash = "sha256:628055297f3e2aa181464c3808402887643405573eb3d9de060d81531fa79d32", size = 220566, upload-time = "2025-08-29T15:33:59.766Z" }, - { url = "https://files.pythonhosted.org/packages/83/c6/81a83778c1f83f1a4a168ed6673eeedc205afb562d8500175292ca64b94e/coverage-7.10.6-cp313-cp313-win_arm64.whl", hash = "sha256:df4ec1f8540b0bcbe26ca7dd0f541847cc8a108b35596f9f91f59f0c060bfdd2", size = 219195, upload-time = "2025-08-29T15:34:01.191Z" }, - { url = "https://files.pythonhosted.org/packages/d7/1c/ccccf4bf116f9517275fa85047495515add43e41dfe8e0bef6e333c6b344/coverage-7.10.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c9a8b7a34a4de3ed987f636f71881cd3b8339f61118b1aa311fbda12741bff0b", size = 218059, upload-time = "2025-08-29T15:34:02.91Z" }, - { url = "https://files.pythonhosted.org/packages/92/97/8a3ceff833d27c7492af4f39d5da6761e9ff624831db9e9f25b3886ddbca/coverage-7.10.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8dd5af36092430c2b075cee966719898f2ae87b636cefb85a653f1d0ba5d5393", size = 218287, upload-time = "2025-08-29T15:34:05.106Z" }, - { url = "https://files.pythonhosted.org/packages/92/d8/50b4a32580cf41ff0423777a2791aaf3269ab60c840b62009aec12d3970d/coverage-7.10.6-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0353b0f0850d49ada66fdd7d0c7cdb0f86b900bb9e367024fd14a60cecc1e27", size = 259625, upload-time = "2025-08-29T15:34:06.575Z" }, - { url = "https://files.pythonhosted.org/packages/7e/7e/6a7df5a6fb440a0179d94a348eb6616ed4745e7df26bf2a02bc4db72c421/coverage-7.10.6-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d6b9ae13d5d3e8aeca9ca94198aa7b3ebbc5acfada557d724f2a1f03d2c0b0df", size = 261801, upload-time = "2025-08-29T15:34:08.006Z" }, - { url = "https://files.pythonhosted.org/packages/3a/4c/a270a414f4ed5d196b9d3d67922968e768cd971d1b251e1b4f75e9362f75/coverage-7.10.6-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:675824a363cc05781b1527b39dc2587b8984965834a748177ee3c37b64ffeafb", size = 264027, upload-time = "2025-08-29T15:34:09.806Z" }, - { url = "https://files.pythonhosted.org/packages/9c/8b/3210d663d594926c12f373c5370bf1e7c5c3a427519a8afa65b561b9a55c/coverage-7.10.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:692d70ea725f471a547c305f0d0fc6a73480c62fb0da726370c088ab21aed282", size = 261576, upload-time = "2025-08-29T15:34:11.585Z" }, - { url = "https://files.pythonhosted.org/packages/72/d0/e1961eff67e9e1dba3fc5eb7a4caf726b35a5b03776892da8d79ec895775/coverage-7.10.6-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:851430a9a361c7a8484a36126d1d0ff8d529d97385eacc8dfdc9bfc8c2d2cbe4", size = 259341, upload-time = "2025-08-29T15:34:13.159Z" }, - { url = "https://files.pythonhosted.org/packages/3a/06/d6478d152cd189b33eac691cba27a40704990ba95de49771285f34a5861e/coverage-7.10.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d9369a23186d189b2fc95cc08b8160ba242057e887d766864f7adf3c46b2df21", size = 260468, upload-time = "2025-08-29T15:34:14.571Z" }, - { url = "https://files.pythonhosted.org/packages/ed/73/737440247c914a332f0b47f7598535b29965bf305e19bbc22d4c39615d2b/coverage-7.10.6-cp313-cp313t-win32.whl", hash = "sha256:92be86fcb125e9bda0da7806afd29a3fd33fdf58fba5d60318399adf40bf37d0", size = 220429, upload-time = "2025-08-29T15:34:16.394Z" }, - { url = "https://files.pythonhosted.org/packages/bd/76/b92d3214740f2357ef4a27c75a526eb6c28f79c402e9f20a922c295c05e2/coverage-7.10.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6b3039e2ca459a70c79523d39347d83b73f2f06af5624905eba7ec34d64d80b5", size = 221493, upload-time = "2025-08-29T15:34:17.835Z" }, - { url = "https://files.pythonhosted.org/packages/fc/8e/6dcb29c599c8a1f654ec6cb68d76644fe635513af16e932d2d4ad1e5ac6e/coverage-7.10.6-cp313-cp313t-win_arm64.whl", hash = "sha256:3fb99d0786fe17b228eab663d16bee2288e8724d26a199c29325aac4b0319b9b", size = 219757, upload-time = "2025-08-29T15:34:19.248Z" }, - { url = "https://files.pythonhosted.org/packages/d3/aa/76cf0b5ec00619ef208da4689281d48b57f2c7fde883d14bf9441b74d59f/coverage-7.10.6-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6008a021907be8c4c02f37cdc3ffb258493bdebfeaf9a839f9e71dfdc47b018e", size = 217331, upload-time = "2025-08-29T15:34:20.846Z" }, - { url = "https://files.pythonhosted.org/packages/65/91/8e41b8c7c505d398d7730206f3cbb4a875a35ca1041efc518051bfce0f6b/coverage-7.10.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5e75e37f23eb144e78940b40395b42f2321951206a4f50e23cfd6e8a198d3ceb", size = 217607, upload-time = "2025-08-29T15:34:22.433Z" }, - { url = "https://files.pythonhosted.org/packages/87/7f/f718e732a423d442e6616580a951b8d1ec3575ea48bcd0e2228386805e79/coverage-7.10.6-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0f7cb359a448e043c576f0da00aa8bfd796a01b06aa610ca453d4dde09cc1034", size = 248663, upload-time = "2025-08-29T15:34:24.425Z" }, - { url = "https://files.pythonhosted.org/packages/e6/52/c1106120e6d801ac03e12b5285e971e758e925b6f82ee9b86db3aa10045d/coverage-7.10.6-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c68018e4fc4e14b5668f1353b41ccf4bc83ba355f0e1b3836861c6f042d89ac1", size = 251197, upload-time = "2025-08-29T15:34:25.906Z" }, - { url = "https://files.pythonhosted.org/packages/3d/ec/3a8645b1bb40e36acde9c0609f08942852a4af91a937fe2c129a38f2d3f5/coverage-7.10.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd4b2b0707fc55afa160cd5fc33b27ccbf75ca11d81f4ec9863d5793fc6df56a", size = 252551, upload-time = "2025-08-29T15:34:27.337Z" }, - { url = "https://files.pythonhosted.org/packages/a1/70/09ecb68eeb1155b28a1d16525fd3a9b65fbe75337311a99830df935d62b6/coverage-7.10.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cec13817a651f8804a86e4f79d815b3b28472c910e099e4d5a0e8a3b6a1d4cb", size = 250553, upload-time = "2025-08-29T15:34:29.065Z" }, - { url = "https://files.pythonhosted.org/packages/c6/80/47df374b893fa812e953b5bc93dcb1427a7b3d7a1a7d2db33043d17f74b9/coverage-7.10.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f2a6a8e06bbda06f78739f40bfb56c45d14eb8249d0f0ea6d4b3d48e1f7c695d", size = 248486, upload-time = "2025-08-29T15:34:30.897Z" }, - { url = "https://files.pythonhosted.org/packages/4a/65/9f98640979ecee1b0d1a7164b589de720ddf8100d1747d9bbdb84be0c0fb/coverage-7.10.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:081b98395ced0d9bcf60ada7661a0b75f36b78b9d7e39ea0790bb4ed8da14747", size = 249981, upload-time = "2025-08-29T15:34:32.365Z" }, - { url = "https://files.pythonhosted.org/packages/1f/55/eeb6603371e6629037f47bd25bef300387257ed53a3c5fdb159b7ac8c651/coverage-7.10.6-cp314-cp314-win32.whl", hash = "sha256:6937347c5d7d069ee776b2bf4e1212f912a9f1f141a429c475e6089462fcecc5", size = 220054, upload-time = "2025-08-29T15:34:34.124Z" }, - { url = "https://files.pythonhosted.org/packages/15/d1/a0912b7611bc35412e919a2cd59ae98e7ea3b475e562668040a43fb27897/coverage-7.10.6-cp314-cp314-win_amd64.whl", hash = "sha256:adec1d980fa07e60b6ef865f9e5410ba760e4e1d26f60f7e5772c73b9a5b0713", size = 220851, upload-time = "2025-08-29T15:34:35.651Z" }, - { url = "https://files.pythonhosted.org/packages/ef/2d/11880bb8ef80a45338e0b3e0725e4c2d73ffbb4822c29d987078224fd6a5/coverage-7.10.6-cp314-cp314-win_arm64.whl", hash = "sha256:a80f7aef9535442bdcf562e5a0d5a5538ce8abe6bb209cfbf170c462ac2c2a32", size = 219429, upload-time = "2025-08-29T15:34:37.16Z" }, - { url = "https://files.pythonhosted.org/packages/83/c0/1f00caad775c03a700146f55536ecd097a881ff08d310a58b353a1421be0/coverage-7.10.6-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0de434f4fbbe5af4fa7989521c655c8c779afb61c53ab561b64dcee6149e4c65", size = 218080, upload-time = "2025-08-29T15:34:38.919Z" }, - { url = "https://files.pythonhosted.org/packages/a9/c4/b1c5d2bd7cc412cbeb035e257fd06ed4e3e139ac871d16a07434e145d18d/coverage-7.10.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6e31b8155150c57e5ac43ccd289d079eb3f825187d7c66e755a055d2c85794c6", size = 218293, upload-time = "2025-08-29T15:34:40.425Z" }, - { url = "https://files.pythonhosted.org/packages/3f/07/4468d37c94724bf6ec354e4ec2f205fda194343e3e85fd2e59cec57e6a54/coverage-7.10.6-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:98cede73eb83c31e2118ae8d379c12e3e42736903a8afcca92a7218e1f2903b0", size = 259800, upload-time = "2025-08-29T15:34:41.996Z" }, - { url = "https://files.pythonhosted.org/packages/82/d8/f8fb351be5fee31690cd8da768fd62f1cfab33c31d9f7baba6cd8960f6b8/coverage-7.10.6-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f863c08f4ff6b64fa8045b1e3da480f5374779ef187f07b82e0538c68cb4ff8e", size = 261965, upload-time = "2025-08-29T15:34:43.61Z" }, - { url = "https://files.pythonhosted.org/packages/e8/70/65d4d7cfc75c5c6eb2fed3ee5cdf420fd8ae09c4808723a89a81d5b1b9c3/coverage-7.10.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b38261034fda87be356f2c3f42221fdb4171c3ce7658066ae449241485390d5", size = 264220, upload-time = "2025-08-29T15:34:45.387Z" }, - { url = "https://files.pythonhosted.org/packages/98/3c/069df106d19024324cde10e4ec379fe2fb978017d25e97ebee23002fbadf/coverage-7.10.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e93b1476b79eae849dc3872faeb0bf7948fd9ea34869590bc16a2a00b9c82a7", size = 261660, upload-time = "2025-08-29T15:34:47.288Z" }, - { url = "https://files.pythonhosted.org/packages/fc/8a/2974d53904080c5dc91af798b3a54a4ccb99a45595cc0dcec6eb9616a57d/coverage-7.10.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ff8a991f70f4c0cf53088abf1e3886edcc87d53004c7bb94e78650b4d3dac3b5", size = 259417, upload-time = "2025-08-29T15:34:48.779Z" }, - { url = "https://files.pythonhosted.org/packages/30/38/9616a6b49c686394b318974d7f6e08f38b8af2270ce7488e879888d1e5db/coverage-7.10.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ac765b026c9f33044419cbba1da913cfb82cca1b60598ac1c7a5ed6aac4621a0", size = 260567, upload-time = "2025-08-29T15:34:50.718Z" }, - { url = "https://files.pythonhosted.org/packages/76/16/3ed2d6312b371a8cf804abf4e14895b70e4c3491c6e53536d63fd0958a8d/coverage-7.10.6-cp314-cp314t-win32.whl", hash = "sha256:441c357d55f4936875636ef2cfb3bee36e466dcf50df9afbd398ce79dba1ebb7", size = 220831, upload-time = "2025-08-29T15:34:52.653Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e5/d38d0cb830abede2adb8b147770d2a3d0e7fecc7228245b9b1ae6c24930a/coverage-7.10.6-cp314-cp314t-win_amd64.whl", hash = "sha256:073711de3181b2e204e4870ac83a7c4853115b42e9cd4d145f2231e12d670930", size = 221950, upload-time = "2025-08-29T15:34:54.212Z" }, - { url = "https://files.pythonhosted.org/packages/f4/51/e48e550f6279349895b0ffcd6d2a690e3131ba3a7f4eafccc141966d4dea/coverage-7.10.6-cp314-cp314t-win_arm64.whl", hash = "sha256:137921f2bac5559334ba66122b753db6dc5d1cf01eb7b64eb412bb0d064ef35b", size = 219969, upload-time = "2025-08-29T15:34:55.83Z" }, - { url = "https://files.pythonhosted.org/packages/44/0c/50db5379b615854b5cf89146f8f5bd1d5a9693d7f3a987e269693521c404/coverage-7.10.6-py3-none-any.whl", hash = "sha256:92c4ecf6bf11b2e85fd4d8204814dc26e6a19f0c9d938c207c5cb0eadfcabbe3", size = 208986, upload-time = "2025-08-29T15:35:14.506Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5d/c1a17867b0456f2e9ce2d8d4708a4c3a089947d0bec9c66cdf60c9e7739f/coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59", size = 218102, upload-time = "2025-09-21T20:01:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/54/f0/514dcf4b4e3698b9a9077f084429681bf3aad2b4a72578f89d7f643eb506/coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a", size = 218505, upload-time = "2025-09-21T20:01:17.788Z" }, + { url = "https://files.pythonhosted.org/packages/20/f6/9626b81d17e2a4b25c63ac1b425ff307ecdeef03d67c9a147673ae40dc36/coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699", size = 248898, upload-time = "2025-09-21T20:01:19.488Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ef/bd8e719c2f7417ba03239052e099b76ea1130ac0cbb183ee1fcaa58aaff3/coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d", size = 250831, upload-time = "2025-09-21T20:01:20.817Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b6/bf054de41ec948b151ae2b79a55c107f5760979538f5fb80c195f2517718/coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e", size = 252937, upload-time = "2025-09-21T20:01:22.171Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e5/3860756aa6f9318227443c6ce4ed7bf9e70bb7f1447a0353f45ac5c7974b/coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23", size = 249021, upload-time = "2025-09-21T20:01:23.907Z" }, + { url = "https://files.pythonhosted.org/packages/26/0f/bd08bd042854f7fd07b45808927ebcce99a7ed0f2f412d11629883517ac2/coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab", size = 250626, upload-time = "2025-09-21T20:01:25.721Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a7/4777b14de4abcc2e80c6b1d430f5d51eb18ed1d75fca56cbce5f2db9b36e/coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82", size = 248682, upload-time = "2025-09-21T20:01:27.105Z" }, + { url = "https://files.pythonhosted.org/packages/34/72/17d082b00b53cd45679bad682fac058b87f011fd8b9fe31d77f5f8d3a4e4/coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2", size = 248402, upload-time = "2025-09-21T20:01:28.629Z" }, + { url = "https://files.pythonhosted.org/packages/81/7a/92367572eb5bdd6a84bfa278cc7e97db192f9f45b28c94a9ca1a921c3577/coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61", size = 249320, upload-time = "2025-09-21T20:01:30.004Z" }, + { url = "https://files.pythonhosted.org/packages/2f/88/a23cc185f6a805dfc4fdf14a94016835eeb85e22ac3a0e66d5e89acd6462/coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14", size = 220536, upload-time = "2025-09-21T20:01:32.184Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ef/0b510a399dfca17cec7bc2f05ad8bd78cf55f15c8bc9a73ab20c5c913c2e/coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2", size = 221425, upload-time = "2025-09-21T20:01:33.557Z" }, + { url = "https://files.pythonhosted.org/packages/51/7f/023657f301a276e4ba1850f82749bc136f5a7e8768060c2e5d9744a22951/coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a", size = 220103, upload-time = "2025-09-21T20:01:34.929Z" }, + { url = "https://files.pythonhosted.org/packages/13/e4/eb12450f71b542a53972d19117ea5a5cea1cab3ac9e31b0b5d498df1bd5a/coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417", size = 218290, upload-time = "2025-09-21T20:01:36.455Z" }, + { url = "https://files.pythonhosted.org/packages/37/66/593f9be12fc19fb36711f19a5371af79a718537204d16ea1d36f16bd78d2/coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973", size = 218515, upload-time = "2025-09-21T20:01:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/4c49f7ae09cafdacc73fbc30949ffe77359635c168f4e9ff33c9ebb07838/coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c", size = 250020, upload-time = "2025-09-21T20:01:39.617Z" }, + { url = "https://files.pythonhosted.org/packages/a6/90/a64aaacab3b37a17aaedd83e8000142561a29eb262cede42d94a67f7556b/coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7", size = 252769, upload-time = "2025-09-21T20:01:41.341Z" }, + { url = "https://files.pythonhosted.org/packages/98/2e/2dda59afd6103b342e096f246ebc5f87a3363b5412609946c120f4e7750d/coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6", size = 253901, upload-time = "2025-09-21T20:01:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/dc/8d8119c9051d50f3119bb4a75f29f1e4a6ab9415cd1fa8bf22fcc3fb3b5f/coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59", size = 250413, upload-time = "2025-09-21T20:01:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/98/b3/edaff9c5d79ee4d4b6d3fe046f2b1d799850425695b789d491a64225d493/coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b", size = 251820, upload-time = "2025-09-21T20:01:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/11/25/9a0728564bb05863f7e513e5a594fe5ffef091b325437f5430e8cfb0d530/coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a", size = 249941, upload-time = "2025-09-21T20:01:47.296Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fd/ca2650443bfbef5b0e74373aac4df67b08180d2f184b482c41499668e258/coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb", size = 249519, upload-time = "2025-09-21T20:01:48.73Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/f692f125fb4299b6f963b0745124998ebb8e73ecdfce4ceceb06a8c6bec5/coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1", size = 251375, upload-time = "2025-09-21T20:01:50.529Z" }, + { url = "https://files.pythonhosted.org/packages/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699, upload-time = "2025-09-21T20:01:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f3/3bf7905288b45b075918d372498f1cf845b5b579b723c8fd17168018d5f5/coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba", size = 221512, upload-time = "2025-09-21T20:01:53.481Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147, upload-time = "2025-09-21T20:01:55.2Z" }, + { url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" }, + { url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" }, + { url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" }, + { url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" }, + { url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" }, + { url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" }, + { url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" }, + { url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" }, + { url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" }, + { url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" }, + { url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" }, + { url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" }, + { url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" }, + { url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" }, + { url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" }, + { url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" }, + { url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" }, + { url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" }, + { url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" }, + { url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" }, + { url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" }, + { url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" }, + { url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" }, + { url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" }, + { url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" }, + { url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" }, + { url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" }, + { url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" }, ] [package.optional-dependencies] @@ -453,50 +465,76 @@ wheels = [ [[package]] name = "markupsafe" -version = "3.0.2" +version = "3.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353, upload-time = "2024-10-18T15:21:02.187Z" }, - { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392, upload-time = "2024-10-18T15:21:02.941Z" }, - { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984, upload-time = "2024-10-18T15:21:03.953Z" }, - { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120, upload-time = "2024-10-18T15:21:06.495Z" }, - { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032, upload-time = "2024-10-18T15:21:07.295Z" }, - { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057, upload-time = "2024-10-18T15:21:08.073Z" }, - { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359, upload-time = "2024-10-18T15:21:09.318Z" }, - { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306, upload-time = "2024-10-18T15:21:10.185Z" }, - { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094, upload-time = "2024-10-18T15:21:11.005Z" }, - { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521, upload-time = "2024-10-18T15:21:12.911Z" }, - { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" }, - { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" }, - { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" }, - { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" }, - { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" }, - { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" }, - { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" }, - { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" }, - { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, - { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, - { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, - { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, - { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, - { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, - { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, - { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, - { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, - { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, - { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, - { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, - { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, - { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, - { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, - { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, - { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, - { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] [[package]] @@ -748,7 +786,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.11.8" +version = "2.11.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -756,9 +794,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ac/c0/1ff9b59cf6f54494cf3ae2b95f4e9cf3a4b10ca352dade98a94d30e8a62e/pydantic-2.11.8.tar.gz", hash = "sha256:3d080f4a3ac6bde98e959ba552124d46be9f565b7be67769e49fcb286bae1bfb", size = 788452, upload-time = "2025-09-13T01:39:32.577Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/5d/09a551ba512d7ca404d785072700d3f6727a02f6f3c24ecfd081c7cf0aa8/pydantic-2.11.9.tar.gz", hash = "sha256:6b8ffda597a14812a7975c90b82a8a2e777d9257aba3453f973acd3c032a18e2", size = 788495, upload-time = "2025-09-13T11:26:39.325Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/49/bdc01f684aff7070253fe32fb9c60788e70de6b8ec527eaebf96bdf1a76b/pydantic-2.11.8-py3-none-any.whl", hash = "sha256:830ec4cccc3cf21be1ef5aec1d3348a179c92a61a7dab0e59837f9cc9fa93351", size = 444830, upload-time = "2025-09-13T01:39:30.095Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2", size = 444855, upload-time = "2025-09-13T11:26:36.909Z" }, ] [[package]] @@ -828,16 +866,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.10.1" +version = "2.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583, upload-time = "2025-06-24T13:26:46.841Z" } +sdist = { url = "https://files.pythonhosted.org/packages/20/c5/dbbc27b814c71676593d1c3f718e6cd7d4f00652cefa24b75f7aa3efb25e/pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180", size = 188394, upload-time = "2025-09-24T14:19:11.764Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" }, + { url = "https://files.pythonhosted.org/packages/83/d6/887a1ff844e64aa823fb4905978d882a633cfe295c32eacad582b78a7d8b/pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c", size = 48608, upload-time = "2025-09-24T14:19:10.015Z" }, ] [[package]] @@ -952,37 +990,57 @@ wheels = [ [[package]] name = "pyyaml" -version = "6.0.2" +version = "6.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" }, - { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" }, - { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" }, - { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload-time = "2024-08-06T20:32:08.338Z" }, - { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload-time = "2024-08-06T20:32:14.124Z" }, - { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload-time = "2024-08-06T20:32:16.17Z" }, - { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload-time = "2024-08-06T20:32:18.555Z" }, - { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload-time = "2024-08-06T20:32:19.889Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload-time = "2024-08-06T20:32:21.273Z" }, - { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, - { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, - { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, - { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, - { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, - { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, - { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, - { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, - { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, - { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, - { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, - { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, - { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, - { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, - { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, - { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, - { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] [[package]] @@ -1134,28 +1192,28 @@ wheels = [ [[package]] name = "ruff" -version = "0.13.0" +version = "0.13.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6e/1a/1f4b722862840295bcaba8c9e5261572347509548faaa99b2d57ee7bfe6a/ruff-0.13.0.tar.gz", hash = "sha256:5b4b1ee7eb35afae128ab94459b13b2baaed282b1fb0f472a73c82c996c8ae60", size = 5372863, upload-time = "2025-09-10T16:25:37.917Z" } +sdist = { url = "https://files.pythonhosted.org/packages/02/df/8d7d8c515d33adfc540e2edf6c6021ea1c5a58a678d8cfce9fae59aabcab/ruff-0.13.2.tar.gz", hash = "sha256:cb12fffd32fb16d32cef4ed16d8c7cdc27ed7c944eaa98d99d01ab7ab0b710ff", size = 5416417, upload-time = "2025-09-25T14:54:09.936Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ac/fe/6f87b419dbe166fd30a991390221f14c5b68946f389ea07913e1719741e0/ruff-0.13.0-py3-none-linux_armv6l.whl", hash = "sha256:137f3d65d58ee828ae136a12d1dc33d992773d8f7644bc6b82714570f31b2004", size = 12187826, upload-time = "2025-09-10T16:24:39.5Z" }, - { url = "https://files.pythonhosted.org/packages/e4/25/c92296b1fc36d2499e12b74a3fdb230f77af7bdf048fad7b0a62e94ed56a/ruff-0.13.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:21ae48151b66e71fd111b7d79f9ad358814ed58c339631450c66a4be33cc28b9", size = 12933428, upload-time = "2025-09-10T16:24:43.866Z" }, - { url = "https://files.pythonhosted.org/packages/44/cf/40bc7221a949470307d9c35b4ef5810c294e6cfa3caafb57d882731a9f42/ruff-0.13.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:64de45f4ca5441209e41742d527944635a05a6e7c05798904f39c85bafa819e3", size = 12095543, upload-time = "2025-09-10T16:24:46.638Z" }, - { url = "https://files.pythonhosted.org/packages/f1/03/8b5ff2a211efb68c63a1d03d157e924997ada87d01bebffbd13a0f3fcdeb/ruff-0.13.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b2c653ae9b9d46e0ef62fc6fbf5b979bda20a0b1d2b22f8f7eb0cde9f4963b8", size = 12312489, upload-time = "2025-09-10T16:24:49.556Z" }, - { url = "https://files.pythonhosted.org/packages/37/fc/2336ef6d5e9c8d8ea8305c5f91e767d795cd4fc171a6d97ef38a5302dadc/ruff-0.13.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4cec632534332062bc9eb5884a267b689085a1afea9801bf94e3ba7498a2d207", size = 11991631, upload-time = "2025-09-10T16:24:53.439Z" }, - { url = "https://files.pythonhosted.org/packages/39/7f/f6d574d100fca83d32637d7f5541bea2f5e473c40020bbc7fc4a4d5b7294/ruff-0.13.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dcd628101d9f7d122e120ac7c17e0a0f468b19bc925501dbe03c1cb7f5415b24", size = 13720602, upload-time = "2025-09-10T16:24:56.392Z" }, - { url = "https://files.pythonhosted.org/packages/fd/c8/a8a5b81d8729b5d1f663348d11e2a9d65a7a9bd3c399763b1a51c72be1ce/ruff-0.13.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:afe37db8e1466acb173bb2a39ca92df00570e0fd7c94c72d87b51b21bb63efea", size = 14697751, upload-time = "2025-09-10T16:24:59.89Z" }, - { url = "https://files.pythonhosted.org/packages/57/f5/183ec292272ce7ec5e882aea74937f7288e88ecb500198b832c24debc6d3/ruff-0.13.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0f96a8d90bb258d7d3358b372905fe7333aaacf6c39e2408b9f8ba181f4b6ef2", size = 14095317, upload-time = "2025-09-10T16:25:03.025Z" }, - { url = "https://files.pythonhosted.org/packages/9f/8d/7f9771c971724701af7926c14dab31754e7b303d127b0d3f01116faef456/ruff-0.13.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94b5e3d883e4f924c5298e3f2ee0f3085819c14f68d1e5b6715597681433f153", size = 13144418, upload-time = "2025-09-10T16:25:06.272Z" }, - { url = "https://files.pythonhosted.org/packages/a8/a6/7985ad1778e60922d4bef546688cd8a25822c58873e9ff30189cfe5dc4ab/ruff-0.13.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03447f3d18479df3d24917a92d768a89f873a7181a064858ea90a804a7538991", size = 13370843, upload-time = "2025-09-10T16:25:09.965Z" }, - { url = "https://files.pythonhosted.org/packages/64/1c/bafdd5a7a05a50cc51d9f5711da704942d8dd62df3d8c70c311e98ce9f8a/ruff-0.13.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fbc6b1934eb1c0033da427c805e27d164bb713f8e273a024a7e86176d7f462cf", size = 13321891, upload-time = "2025-09-10T16:25:12.969Z" }, - { url = "https://files.pythonhosted.org/packages/bc/3e/7817f989cb9725ef7e8d2cee74186bf90555279e119de50c750c4b7a72fe/ruff-0.13.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a8ab6a3e03665d39d4a25ee199d207a488724f022db0e1fe4002968abdb8001b", size = 12119119, upload-time = "2025-09-10T16:25:16.621Z" }, - { url = "https://files.pythonhosted.org/packages/58/07/9df080742e8d1080e60c426dce6e96a8faf9a371e2ce22eef662e3839c95/ruff-0.13.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d2a5c62f8ccc6dd2fe259917482de7275cecc86141ee10432727c4816235bc41", size = 11961594, upload-time = "2025-09-10T16:25:19.49Z" }, - { url = "https://files.pythonhosted.org/packages/6a/f4/ae1185349197d26a2316840cb4d6c3fba61d4ac36ed728bf0228b222d71f/ruff-0.13.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b7b85ca27aeeb1ab421bc787009831cffe6048faae08ad80867edab9f2760945", size = 12933377, upload-time = "2025-09-10T16:25:22.371Z" }, - { url = "https://files.pythonhosted.org/packages/b6/39/e776c10a3b349fc8209a905bfb327831d7516f6058339a613a8d2aaecacd/ruff-0.13.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:79ea0c44a3032af768cabfd9616e44c24303af49d633b43e3a5096e009ebe823", size = 13418555, upload-time = "2025-09-10T16:25:25.681Z" }, - { url = "https://files.pythonhosted.org/packages/46/09/dca8df3d48e8b3f4202bf20b1658898e74b6442ac835bfe2c1816d926697/ruff-0.13.0-py3-none-win32.whl", hash = "sha256:4e473e8f0e6a04e4113f2e1de12a5039579892329ecc49958424e5568ef4f768", size = 12141613, upload-time = "2025-09-10T16:25:28.664Z" }, - { url = "https://files.pythonhosted.org/packages/61/21/0647eb71ed99b888ad50e44d8ec65d7148babc0e242d531a499a0bbcda5f/ruff-0.13.0-py3-none-win_amd64.whl", hash = "sha256:48e5c25c7a3713eea9ce755995767f4dcd1b0b9599b638b12946e892123d1efb", size = 13258250, upload-time = "2025-09-10T16:25:31.773Z" }, - { url = "https://files.pythonhosted.org/packages/e1/a3/03216a6a86c706df54422612981fb0f9041dbb452c3401501d4a22b942c9/ruff-0.13.0-py3-none-win_arm64.whl", hash = "sha256:ab80525317b1e1d38614addec8ac954f1b3e662de9d59114ecbf771d00cf613e", size = 12312357, upload-time = "2025-09-10T16:25:35.595Z" }, + { url = "https://files.pythonhosted.org/packages/6e/84/5716a7fa4758e41bf70e603e13637c42cfb9dbf7ceb07180211b9bbf75ef/ruff-0.13.2-py3-none-linux_armv6l.whl", hash = "sha256:3796345842b55f033a78285e4f1641078f902020d8450cade03aad01bffd81c3", size = 12343254, upload-time = "2025-09-25T14:53:27.784Z" }, + { url = "https://files.pythonhosted.org/packages/9b/77/c7042582401bb9ac8eff25360e9335e901d7a1c0749a2b28ba4ecb239991/ruff-0.13.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ff7e4dda12e683e9709ac89e2dd436abf31a4d8a8fc3d89656231ed808e231d2", size = 13040891, upload-time = "2025-09-25T14:53:31.38Z" }, + { url = "https://files.pythonhosted.org/packages/c6/15/125a7f76eb295cb34d19c6778e3a82ace33730ad4e6f28d3427e134a02e0/ruff-0.13.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c75e9d2a2fafd1fdd895d0e7e24b44355984affdde1c412a6f6d3f6e16b22d46", size = 12243588, upload-time = "2025-09-25T14:53:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/9e/eb/0093ae04a70f81f8be7fd7ed6456e926b65d238fc122311293d033fdf91e/ruff-0.13.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cceac74e7bbc53ed7d15d1042ffe7b6577bf294611ad90393bf9b2a0f0ec7cb6", size = 12491359, upload-time = "2025-09-25T14:53:35.892Z" }, + { url = "https://files.pythonhosted.org/packages/43/fe/72b525948a6956f07dad4a6f122336b6a05f2e3fd27471cea612349fedb9/ruff-0.13.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6ae3f469b5465ba6d9721383ae9d49310c19b452a161b57507764d7ef15f4b07", size = 12162486, upload-time = "2025-09-25T14:53:38.171Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e3/0fac422bbbfb2ea838023e0d9fcf1f30183d83ab2482800e2cb892d02dfe/ruff-0.13.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f8f9e3cd6714358238cd6626b9d43026ed19c0c018376ac1ef3c3a04ffb42d8", size = 13871203, upload-time = "2025-09-25T14:53:41.943Z" }, + { url = "https://files.pythonhosted.org/packages/6b/82/b721c8e3ec5df6d83ba0e45dcf00892c4f98b325256c42c38ef136496cbf/ruff-0.13.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c6ed79584a8f6cbe2e5d7dbacf7cc1ee29cbdb5df1172e77fbdadc8bb85a1f89", size = 14929635, upload-time = "2025-09-25T14:53:43.953Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a0/ad56faf6daa507b83079a1ad7a11694b87d61e6bf01c66bd82b466f21821/ruff-0.13.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aed130b2fde049cea2019f55deb939103123cdd191105f97a0599a3e753d61b0", size = 14338783, upload-time = "2025-09-25T14:53:46.205Z" }, + { url = "https://files.pythonhosted.org/packages/47/77/ad1d9156db8f99cd01ee7e29d74b34050e8075a8438e589121fcd25c4b08/ruff-0.13.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1887c230c2c9d65ed1b4e4cfe4d255577ea28b718ae226c348ae68df958191aa", size = 13355322, upload-time = "2025-09-25T14:53:48.164Z" }, + { url = "https://files.pythonhosted.org/packages/64/8b/e87cfca2be6f8b9f41f0bb12dc48c6455e2d66df46fe61bb441a226f1089/ruff-0.13.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5bcb10276b69b3cfea3a102ca119ffe5c6ba3901e20e60cf9efb53fa417633c3", size = 13354427, upload-time = "2025-09-25T14:53:50.486Z" }, + { url = "https://files.pythonhosted.org/packages/7f/df/bf382f3fbead082a575edb860897287f42b1b3c694bafa16bc9904c11ed3/ruff-0.13.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:afa721017aa55a555b2ff7944816587f1cb813c2c0a882d158f59b832da1660d", size = 13537637, upload-time = "2025-09-25T14:53:52.887Z" }, + { url = "https://files.pythonhosted.org/packages/51/70/1fb7a7c8a6fc8bd15636288a46e209e81913b87988f26e1913d0851e54f4/ruff-0.13.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1dbc875cf3720c64b3990fef8939334e74cb0ca65b8dbc61d1f439201a38101b", size = 12340025, upload-time = "2025-09-25T14:53:54.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/27/1e5b3f1c23ca5dd4106d9d580e5c13d9acb70288bff614b3d7b638378cc9/ruff-0.13.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5b939a1b2a960e9742e9a347e5bbc9b3c3d2c716f86c6ae273d9cbd64f193f22", size = 12133449, upload-time = "2025-09-25T14:53:57.089Z" }, + { url = "https://files.pythonhosted.org/packages/2d/09/b92a5ccee289f11ab128df57d5911224197d8d55ef3bd2043534ff72ca54/ruff-0.13.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:50e2d52acb8de3804fc5f6e2fa3ae9bdc6812410a9e46837e673ad1f90a18736", size = 13051369, upload-time = "2025-09-25T14:53:59.124Z" }, + { url = "https://files.pythonhosted.org/packages/89/99/26c9d1c7d8150f45e346dc045cc49f23e961efceb4a70c47dea0960dea9a/ruff-0.13.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3196bc13ab2110c176b9a4ae5ff7ab676faaa1964b330a1383ba20e1e19645f2", size = 13523644, upload-time = "2025-09-25T14:54:01.622Z" }, + { url = "https://files.pythonhosted.org/packages/f7/00/e7f1501e81e8ec290e79527827af1d88f541d8d26151751b46108978dade/ruff-0.13.2-py3-none-win32.whl", hash = "sha256:7c2a0b7c1e87795fec3404a485096bcd790216c7c146a922d121d8b9c8f1aaac", size = 12245990, upload-time = "2025-09-25T14:54:03.647Z" }, + { url = "https://files.pythonhosted.org/packages/ee/bd/d9f33a73de84fafd0146c6fba4f497c4565fe8fa8b46874b8e438869abc2/ruff-0.13.2-py3-none-win_amd64.whl", hash = "sha256:17d95fb32218357c89355f6f6f9a804133e404fc1f65694372e02a557edf8585", size = 13324004, upload-time = "2025-09-25T14:54:06.05Z" }, + { url = "https://files.pythonhosted.org/packages/c3/12/28fa2f597a605884deb0f65c1b1ae05111051b2a7030f5d8a4ff7f4599ba/ruff-0.13.2-py3-none-win_arm64.whl", hash = "sha256:da711b14c530412c827219312b7d7fbb4877fb31150083add7e8c5336549cea7", size = 12484437, upload-time = "2025-09-25T14:54:08.022Z" }, ] [[package]] @@ -1292,7 +1350,7 @@ wheels = [ [[package]] name = "typer" -version = "0.17.4" +version = "0.19.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -1300,9 +1358,9 @@ dependencies = [ { name = "shellingham" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/92/e8/2a73ccf9874ec4c7638f172efc8972ceab13a0e3480b389d6ed822f7a822/typer-0.17.4.tar.gz", hash = "sha256:b77dc07d849312fd2bb5e7f20a7af8985c7ec360c45b051ed5412f64d8dc1580", size = 103734, upload-time = "2025-09-05T18:14:40.746Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/ca/950278884e2ca20547ff3eb109478c6baf6b8cf219318e6bc4f666fad8e8/typer-0.19.2.tar.gz", hash = "sha256:9ad824308ded0ad06cc716434705f691d4ee0bfd0fb081839d2e426860e7fdca", size = 104755, upload-time = "2025-09-23T09:47:48.256Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/72/6b3e70d32e89a5cbb6a4513726c1ae8762165b027af569289e19ec08edd8/typer-0.17.4-py3-none-any.whl", hash = "sha256:015534a6edaa450e7007eba705d5c18c3349dcea50a6ad79a5ed530967575824", size = 46643, upload-time = "2025-09-05T18:14:39.166Z" }, + { url = "https://files.pythonhosted.org/packages/00/22/35617eee79080a5d071d0f14ad698d325ee6b3bf824fc0467c03b30e7fa8/typer-0.19.2-py3-none-any.whl", hash = "sha256:755e7e19670ffad8283db353267cb81ef252f595aa6834a0d1ca9312d9326cb9", size = 46748, upload-time = "2025-09-23T09:47:46.777Z" }, ] [[package]] @@ -1337,24 +1395,24 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.35.0" +version = "0.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" } +sdist = { url = "https://files.pythonhosted.org/packages/71/57/1616c8274c3442d802621abf5deb230771c7a0fec9414cb6763900eb3868/uvicorn-0.37.0.tar.gz", hash = "sha256:4115c8add6d3fd536c8ee77f0e14a7fd2ebba939fed9b02583a97f80648f9e13", size = 80367, upload-time = "2025-09-23T13:33:47.486Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" }, + { url = "https://files.pythonhosted.org/packages/85/cd/584a2ceb5532af99dd09e50919e3615ba99aa127e9850eafe5f31ddfdb9a/uvicorn-0.37.0-py3-none-any.whl", hash = "sha256:913b2b88672343739927ce381ff9e2ad62541f9f8289664fa1d1d3803fa2ce6c", size = 67976, upload-time = "2025-09-23T13:33:45.842Z" }, ] [[package]] name = "wcwidth" -version = "0.2.13" +version = "0.2.14" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301, upload-time = "2024-01-06T02:10:57.829Z" } +sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166, upload-time = "2024-01-06T02:10:55.763Z" }, + { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, ] [[package]] From 0e0bfd9f9813c3edee2a67e43e9f7f878cb41661 Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Mon, 29 Sep 2025 16:06:20 +0000 Subject: [PATCH 032/102] chore(deps): update docker/login-action digest to 5e57cd1 --- .github/workflows/docker-build-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-build-publish.yml b/.github/workflows/docker-build-publish.yml index 9e28ab8..fa40f37 100644 --- a/.github/workflows/docker-build-publish.yml +++ b/.github/workflows/docker-build-publish.yml @@ -37,7 +37,7 @@ jobs: - name: Log in to GitHub Container Registry if: github.event_name != 'pull_request' - uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3 + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3 with: registry: ghcr.io username: ${{ github.actor }} From 7e3c2c97741ecce59be675012951a51094196768 Mon Sep 17 00:00:00 2001 From: Chris Coutinho <12901868+cbcoutinho@users.noreply.github.com> Date: Mon, 29 Sep 2025 18:20:56 +0200 Subject: [PATCH 033/102] chore: Update README.md --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 2ff0180..ab0f419 100644 --- a/README.md +++ b/README.md @@ -248,6 +248,10 @@ You can then connect to and interact with the server's tools and resources throu Contributions are welcome! Please feel free to submit issues or pull requests on the [GitHub repository](https://github.com/cbcoutinho/nextcloud-mcp-server). +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=cbcoutinho/nextcloud-mcp-server&type=Date)](https://www.star-history.com/#cbcoutinho/nextcloud-mcp-server&Date) + ## License This project is licensed under the AGPL-3.0 License. See the [LICENSE](./LICENSE) file for details. From 593c84345ead825e32732f59f5b77ec110dde702 Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Tue, 30 Sep 2025 16:11:04 +0000 Subject: [PATCH 034/102] chore(deps): update astral-sh/setup-uv action to v6.8.0 --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 247f436..5b6bc89 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Install the latest version of uv - uses: astral-sh/setup-uv@b75a909f75acd358c2196fb9a5f1299a9a8868a4 # v6.7.0 + uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 - name: Check format run: | uv run --frozen ruff format --diff @@ -31,7 +31,7 @@ jobs: with: compose-file: "./docker-compose.yml" - name: Install the latest version of uv - uses: astral-sh/setup-uv@b75a909f75acd358c2196fb9a5f1299a9a8868a4 # v6.7.0 + uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 - name: Wait for service to be ready run: | From 7c677205bba34774c8b15955c2ae05798e1cb8f8 Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Tue, 30 Sep 2025 22:11:11 +0000 Subject: [PATCH 035/102] chore(deps): update hoverkraft-tech/compose-action action to v2.4.0 --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5b6bc89..00f8e26 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,7 +27,7 @@ jobs: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Run docker compose - uses: hoverkraft-tech/compose-action@40041ff1b97dbf152cd2361138c2b03fa29139df # v2.3.0 + uses: hoverkraft-tech/compose-action@b716db5b717cb9b81e391fe638e5aceaa2299e43 # v2.4.0 with: compose-file: "./docker-compose.yml" - name: Install the latest version of uv From 1a2a1f065f8a767188804743692aa0491212c17a Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Tue, 30 Sep 2025 22:11:16 +0000 Subject: [PATCH 036/102] chore(deps): update nextcloud docker tag to v32 --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index e3b592d..96d7bc2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,7 +21,7 @@ services: restart: always app: - image: nextcloud:31.0.9@sha256:88fe398340a896eeebfe0a4ba847998ff2c8fbb3d72de354ac1f08bc7b44db18 + image: nextcloud:32.0.0@sha256:54f7a04123643af5de16c40e57fbe7cf4dca162af432412780c78b328d5b83f1 #user: www-data:www-data restart: always #post_start: From e6dc14c31f8f4166fc0d68aeb82beaca1f50766d Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Wed, 1 Oct 2025 16:04:54 +0000 Subject: [PATCH 037/102] chore(deps): replace nextcloud docker tag with docker.io/library/nextcloud 32.0.0 --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 96d7bc2..6eb3672 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,7 +21,7 @@ services: restart: always app: - image: nextcloud:32.0.0@sha256:54f7a04123643af5de16c40e57fbe7cf4dca162af432412780c78b328d5b83f1 + image: docker.io/library/nextcloud:32.0.0@sha256:5413182349f878ed178874d72d028e6140eca7b9a8a8fe04ae3072adce9f23d8 #user: www-data:www-data restart: always #post_start: From eda675325301b933a182cbbde87a983d3e420d47 Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Wed, 1 Oct 2025 22:06:55 +0000 Subject: [PATCH 038/102] chore(deps): update docker.io/library/nextcloud:32.0.0 docker digest to f4d0a4a --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 6eb3672..52ca969 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,7 +21,7 @@ services: restart: always app: - image: docker.io/library/nextcloud:32.0.0@sha256:5413182349f878ed178874d72d028e6140eca7b9a8a8fe04ae3072adce9f23d8 + image: docker.io/library/nextcloud:32.0.0@sha256:f4d0a4a74e93780db5e2130cdf08caa4cd856f6db4da34802d542d57443a9a63 #user: www-data:www-data restart: always #post_start: From 9be03ef0de0bb8839d14f1af58fcaacea93dae50 Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Thu, 2 Oct 2025 04:04:12 +0000 Subject: [PATCH 039/102] chore(deps): update mariadb:lts docker digest to 24264e9 --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 6eb3672..f3c939e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ services: # https://hub.docker.com/_/mariadb db: # Note: Check the recommend version here: https://docs.nextcloud.com/server/latest/admin_manual/installation/system_requirements.html#server - image: mariadb:lts@sha256:851a6020c97b9eae7736b6fb275800601d64635222054d3a1b1b3c4abdfa117a + image: mariadb:lts@sha256:24264e922918396ac17c6f8b7b196c0e6df246728e4ca4b6d8a10217202ec824 restart: always command: --transaction-isolation=READ-COMMITTED volumes: From 0faa32fd103469372497bb717407a3d2fefdc5e2 Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Thu, 2 Oct 2025 10:04:52 +0000 Subject: [PATCH 040/102] chore(deps): replace mariadb docker tag with docker.io/library/mariadb lts --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 693cb88..2aad985 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ services: # https://hub.docker.com/_/mariadb db: # Note: Check the recommend version here: https://docs.nextcloud.com/server/latest/admin_manual/installation/system_requirements.html#server - image: mariadb:lts@sha256:24264e922918396ac17c6f8b7b196c0e6df246728e4ca4b6d8a10217202ec824 + image: docker.io/library/mariadb:lts@sha256:4b1e7958f820681b1b93b9cf9ea05a76e0785da12699a1fb1d05e0eb2137e56b restart: always command: --transaction-isolation=READ-COMMITTED volumes: From b10fba067874acf794e24ad0f75f196303f70031 Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Thu, 2 Oct 2025 22:10:23 +0000 Subject: [PATCH 041/102] fix(deps): update dependency mcp to >=1.16,<1.17 --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e224fa1..cbb13b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ authors = [ readme = "README.md" requires-python = ">=3.11" dependencies = [ - "mcp[cli] (>=1.15,<1.16)", + "mcp[cli] (>=1.16,<1.17)", "httpx (>=0.28.1,<0.29.0)", "pillow (>=11.2.1,<12.0.0)", "icalendar (>=6.0.0,<7.0.0)", diff --git a/uv.lock b/uv.lock index f30df55..ca07984 100644 --- a/uv.lock +++ b/uv.lock @@ -551,7 +551,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.15.0" +version = "1.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -566,9 +566,9 @@ dependencies = [ { name = "starlette" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0c/9e/e65114795f359f314d7061f4fcb50dfe60026b01b52ad0b986b4631bf8bb/mcp-1.15.0.tar.gz", hash = "sha256:5bda1f4d383cf539d3c035b3505a3de94b20dbd7e4e8b4bd071e14634eeb2d72", size = 469622, upload-time = "2025-09-25T15:39:51.995Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/a1/b1f328da3b153683d2ec34f849b4b6eac2790fb240e3aef06ff2fab3df9d/mcp-1.16.0.tar.gz", hash = "sha256:39b8ca25460c578ee2cdad33feeea122694cfdf73eef58bee76c42f6ef0589df", size = 472918, upload-time = "2025-10-02T16:58:20.631Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/82/4d0df23d5ff5bb982a59ad597bc7cb9920f2650278ccefb8e0d85c5ce3d4/mcp-1.15.0-py3-none-any.whl", hash = "sha256:314614c8addc67b663d6c3e4054db0a5c3dedc416c24ef8ce954e203fdc2333d", size = 166963, upload-time = "2025-09-25T15:39:50.538Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0e/7cebc88e17daf94ebe28c95633af595ccb2864dc2ee7abd75542d98495cc/mcp-1.16.0-py3-none-any.whl", hash = "sha256:ec917be9a5d31b09ba331e1768aa576e0af45470d657a0319996a20a57d7d633", size = 167266, upload-time = "2025-10-02T16:58:19.039Z" }, ] [package.optional-dependencies] @@ -615,7 +615,7 @@ requires-dist = [ { name = "click", specifier = ">=8.1.8" }, { name = "httpx", specifier = ">=0.28.1,<0.29.0" }, { name = "icalendar", specifier = ">=6.0.0,<7.0.0" }, - { name = "mcp", extras = ["cli"], specifier = ">=1.15,<1.16" }, + { name = "mcp", extras = ["cli"], specifier = ">=1.16,<1.17" }, { name = "pillow", specifier = ">=11.2.1,<12.0.0" }, { name = "pydantic", specifier = ">=2.11.4" }, { name = "pythonvcard4", specifier = ">=0.2.0" }, From b60da5759781d2f2fd8f90f230c76ca6d87be6dd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 3 Oct 2025 06:20:51 +0000 Subject: [PATCH 042/102] =?UTF-8?q?bump:=20version=200.12.4=20=E2=86=92=20?= =?UTF-8?q?0.12.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a50d2c..bce2691 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## v0.12.5 (2025-10-03) + +### Fix + +- **deps**: update dependency mcp to >=1.16,<1.17 + ## v0.12.4 (2025-09-25) ### Fix diff --git a/pyproject.toml b/pyproject.toml index cbb13b3..0ac87fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nextcloud-mcp-server" -version = "0.12.4" +version = "0.12.5" description = "" authors = [ {name = "Chris Coutinho",email = "chris@coutinho.io"} diff --git a/uv.lock b/uv.lock index ca07984..00c2cfe 100644 --- a/uv.lock +++ b/uv.lock @@ -588,7 +588,7 @@ wheels = [ [[package]] name = "nextcloud-mcp-server" -version = "0.12.4" +version = "0.12.5" source = { editable = "." } dependencies = [ { name = "click" }, From 7463234ccbf44c2d1d84cfd78b1d3e2c4f9b3f88 Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Fri, 3 Oct 2025 22:07:16 +0000 Subject: [PATCH 043/102] chore(deps): replace redis docker tag with docker.io/library/redis alpine --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 2aad985..566663d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,7 @@ services: # Note: Redis is an external service. You can find more information about the configuration here: # https://hub.docker.com/_/redis redis: - image: redis:alpine@sha256:987c376c727652f99625c7d205a1cba3cb2c53b92b0b62aade2bd48ee1593232 + image: docker.io/library/redis:alpine@sha256:1c78f5e7512cc8b22b0edc95c20e7abd9e1fd832e5dfd5c3c6b59ce82fb238d0 restart: always app: From 1cf783d0624fbc24d9aa01f48f29735de3673f75 Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Fri, 3 Oct 2025 22:07:27 +0000 Subject: [PATCH 044/102] chore(deps): update softprops/action-gh-release action to v2.3.4 --- .github/workflows/bump-version.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index 73852bc..0d523a3 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -25,7 +25,7 @@ jobs: github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} changelog_increment_filename: body.md - name: Release - uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3 + uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4 with: body_path: "body.md" tag_name: v${{ env.REVISION }} From 5f3ff60531b44ab9da1d374ca491c23930ec1e14 Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Sat, 4 Oct 2025 16:07:30 +0000 Subject: [PATCH 045/102] chore(deps): update docker.io/library/nextcloud:32.0.0 docker digest to 3e70e4d --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 566663d..824d584 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,7 +21,7 @@ services: restart: always app: - image: docker.io/library/nextcloud:32.0.0@sha256:f4d0a4a74e93780db5e2130cdf08caa4cd856f6db4da34802d542d57443a9a63 + image: docker.io/library/nextcloud:32.0.0@sha256:3e70e4dfe882ef44738fdc30d9896fb07c12febb27c4a1177e3d63dc0004a0b4 #user: www-data:www-data restart: always #post_start: From aead059eaa9e61f436be40369ae2b61a1c5f2379 Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Sat, 4 Oct 2025 22:05:42 +0000 Subject: [PATCH 046/102] chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.23 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 5939b97..2b8a860 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/astral-sh/uv:0.8.22-python3.11-alpine@sha256:a8d5f7079a3223380ec060fefe48afe45b4c4622d631ce0e495593ac9a38f546 +FROM ghcr.io/astral-sh/uv:0.8.23-python3.11-alpine@sha256:e2079eb6524d4b2afdfe8dadae1e7340813d751447356b37d0ed7386f60d6c40 WORKDIR /app From fb2632e0445ef15256771301d8b689b647809c49 Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 04:06:48 +0000 Subject: [PATCH 047/102] chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.24 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 2b8a860..77c1626 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/astral-sh/uv:0.8.23-python3.11-alpine@sha256:e2079eb6524d4b2afdfe8dadae1e7340813d751447356b37d0ed7386f60d6c40 +FROM ghcr.io/astral-sh/uv:0.8.24-python3.11-alpine@sha256:50de0388f8c809e9b5ad8b0bed917f02db04a8b8bdf2a810e302e7a133c68273 WORKDIR /app From 431644fff6c1ff3068571784e0545b118eee49ef Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 04:06:56 +0000 Subject: [PATCH 048/102] chore(deps): update softprops/action-gh-release action to v2.4.0 --- .github/workflows/bump-version.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index 0d523a3..bb3104b 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -25,7 +25,7 @@ jobs: github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} changelog_increment_filename: body.md - name: Release - uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4 + uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2.4.0 with: body_path: "body.md" tag_name: v${{ env.REVISION }} From 0d98d9dfa0c6b2c6fbd5d1421f78170a280c822b Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 22:09:38 +0000 Subject: [PATCH 049/102] chore(deps): update astral-sh/setup-uv action to v7 --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 00f8e26..6c28d4c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Install the latest version of uv - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 + uses: astral-sh/setup-uv@eb1897b8dc4b5d5bfe39a428a8f2304605e0983c # v7.0.0 - name: Check format run: | uv run --frozen ruff format --diff @@ -31,7 +31,7 @@ jobs: with: compose-file: "./docker-compose.yml" - name: Install the latest version of uv - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 + uses: astral-sh/setup-uv@eb1897b8dc4b5d5bfe39a428a8f2304605e0983c # v7.0.0 - name: Wait for service to be ready run: | From 0f7f5171a411873297c9a92a93b6e4aa40734213 Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Wed, 8 Oct 2025 04:06:44 +0000 Subject: [PATCH 050/102] chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.0 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 77c1626..ca1851e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/astral-sh/uv:0.8.24-python3.11-alpine@sha256:50de0388f8c809e9b5ad8b0bed917f02db04a8b8bdf2a810e302e7a133c68273 +FROM ghcr.io/astral-sh/uv:0.9.0-python3.11-alpine@sha256:8d304012855f0ef78c67ed1970fa5744adcd9514c967ec3263a520b8d18d7344 WORKDIR /app From 1402da0ac001d165012e68f5a712e30e526858f0 Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Thu, 9 Oct 2025 04:06:45 +0000 Subject: [PATCH 051/102] chore(deps): update docker.io/library/redis:alpine docker digest to 0ea5184 --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 824d584..07937f4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,7 @@ services: # Note: Redis is an external service. You can find more information about the configuration here: # https://hub.docker.com/_/redis redis: - image: docker.io/library/redis:alpine@sha256:1c78f5e7512cc8b22b0edc95c20e7abd9e1fd832e5dfd5c3c6b59ce82fb238d0 + image: docker.io/library/redis:alpine@sha256:0ea5184d8a7bcc7cf48248364790d427b1e99d981212b10ab3312cb42fbed44b restart: always app: From 900d1bb462957d74b11b1ae301f0ce5452660ed9 Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Thu, 9 Oct 2025 10:13:48 +0000 Subject: [PATCH 052/102] chore(deps): update docker.io/library/redis:alpine docker digest to b4ab73c --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 07937f4..464975c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,7 @@ services: # Note: Redis is an external service. You can find more information about the configuration here: # https://hub.docker.com/_/redis redis: - image: docker.io/library/redis:alpine@sha256:0ea5184d8a7bcc7cf48248364790d427b1e99d981212b10ab3312cb42fbed44b + image: docker.io/library/redis:alpine@sha256:b4ab73ca1f4e12c803845e561c07ccc365d2be7c9b048459e679b8ce73da4056 restart: always app: From e1f17c33865cafae50db80e62af305e6d9f60104 Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Thu, 9 Oct 2025 22:14:27 +0000 Subject: [PATCH 053/102] chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.1 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index ca1851e..7cc3c84 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/astral-sh/uv:0.9.0-python3.11-alpine@sha256:8d304012855f0ef78c67ed1970fa5744adcd9514c967ec3263a520b8d18d7344 +FROM ghcr.io/astral-sh/uv:0.9.1-python3.11-alpine@sha256:c916d811124ace1edc7a7fe1f541ff48ca5a1a72ebe2b968ce49653cb2d9e82a WORKDIR /app From 391f4189348b065e08a41a0f6f40f6c8b281c853 Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Fri, 10 Oct 2025 04:06:36 +0000 Subject: [PATCH 054/102] chore(deps): update docker.io/library/mariadb:lts docker digest to ae61197 --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 464975c..2090648 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ services: # https://hub.docker.com/_/mariadb db: # Note: Check the recommend version here: https://docs.nextcloud.com/server/latest/admin_manual/installation/system_requirements.html#server - image: docker.io/library/mariadb:lts@sha256:4b1e7958f820681b1b93b9cf9ea05a76e0785da12699a1fb1d05e0eb2137e56b + image: docker.io/library/mariadb:lts@sha256:ae6119716edac6998ae85508431b3d2e666530ddf4e94c61a10710caec9b0f71 restart: always command: --transaction-isolation=READ-COMMITTED volumes: From 3340a63f86d1057b87330cb7e6f95bd96e6223ff Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Fri, 10 Oct 2025 16:08:58 +0000 Subject: [PATCH 055/102] fix(deps): update dependency mcp to >=1.17,<1.18 --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0ac87fb..d4e7470 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ authors = [ readme = "README.md" requires-python = ">=3.11" dependencies = [ - "mcp[cli] (>=1.16,<1.17)", + "mcp[cli] (>=1.17,<1.18)", "httpx (>=0.28.1,<0.29.0)", "pillow (>=11.2.1,<12.0.0)", "icalendar (>=6.0.0,<7.0.0)", diff --git a/uv.lock b/uv.lock index 00c2cfe..0f32b82 100644 --- a/uv.lock +++ b/uv.lock @@ -551,7 +551,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.16.0" +version = "1.17.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -566,9 +566,9 @@ dependencies = [ { name = "starlette" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/a1/b1f328da3b153683d2ec34f849b4b6eac2790fb240e3aef06ff2fab3df9d/mcp-1.16.0.tar.gz", hash = "sha256:39b8ca25460c578ee2cdad33feeea122694cfdf73eef58bee76c42f6ef0589df", size = 472918, upload-time = "2025-10-02T16:58:20.631Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/79/5724a540df19e192e8606c543cdcf162de8eb435077520cca150f7365ec0/mcp-1.17.0.tar.gz", hash = "sha256:1b57fabf3203240ccc48e39859faf3ae1ccb0b571ff798bbedae800c73c6df90", size = 477951, upload-time = "2025-10-10T12:16:44.519Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/0e/7cebc88e17daf94ebe28c95633af595ccb2864dc2ee7abd75542d98495cc/mcp-1.16.0-py3-none-any.whl", hash = "sha256:ec917be9a5d31b09ba331e1768aa576e0af45470d657a0319996a20a57d7d633", size = 167266, upload-time = "2025-10-02T16:58:19.039Z" }, + { url = "https://files.pythonhosted.org/packages/1c/72/3751feae343a5ad07959df713907b5c3fbaed269d697a14b0c449080cf2e/mcp-1.17.0-py3-none-any.whl", hash = "sha256:0660ef275cada7a545af154db3082f176cf1d2681d5e35ae63e014faf0a35d40", size = 167737, upload-time = "2025-10-10T12:16:42.863Z" }, ] [package.optional-dependencies] @@ -615,7 +615,7 @@ requires-dist = [ { name = "click", specifier = ">=8.1.8" }, { name = "httpx", specifier = ">=0.28.1,<0.29.0" }, { name = "icalendar", specifier = ">=6.0.0,<7.0.0" }, - { name = "mcp", extras = ["cli"], specifier = ">=1.16,<1.17" }, + { name = "mcp", extras = ["cli"], specifier = ">=1.17,<1.18" }, { name = "pillow", specifier = ">=11.2.1,<12.0.0" }, { name = "pydantic", specifier = ">=2.11.4" }, { name = "pythonvcard4", specifier = ">=0.2.0" }, From f16af39b97787b7cafce7a72c5f4fdeae60e3996 Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Fri, 10 Oct 2025 22:09:45 +0000 Subject: [PATCH 056/102] chore(deps): update docker.io/library/redis:alpine docker digest to 59b6e69 --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 2090648..4322ae3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,7 @@ services: # Note: Redis is an external service. You can find more information about the configuration here: # https://hub.docker.com/_/redis redis: - image: docker.io/library/redis:alpine@sha256:b4ab73ca1f4e12c803845e561c07ccc365d2be7c9b048459e679b8ce73da4056 + image: docker.io/library/redis:alpine@sha256:59b6e694653476de2c992937ebe1c64182af4728e54bb49e9b7a6c26614d8933 restart: always app: From 7695fbca0cb2c23459bb5b8cb059854606ebb3d9 Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Fri, 10 Oct 2025 22:09:50 +0000 Subject: [PATCH 057/102] chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.2 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 7cc3c84..f435026 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/astral-sh/uv:0.9.1-python3.11-alpine@sha256:c916d811124ace1edc7a7fe1f541ff48ca5a1a72ebe2b968ce49653cb2d9e82a +FROM ghcr.io/astral-sh/uv:0.9.2-python3.11-alpine@sha256:59c7cb3e4a4fe9ccff6a5bf0d952a0b1b0101adda48e305c02beea3c22256208 WORKDIR /app From 34daaa380e8ec17a2fc43f117d2cf174d84fa9b7 Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Sat, 11 Oct 2025 16:05:14 +0000 Subject: [PATCH 058/102] chore(deps): update softprops/action-gh-release action to v2.4.1 --- .github/workflows/bump-version.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index bb3104b..fb2a286 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -25,7 +25,7 @@ jobs: github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} changelog_increment_filename: body.md - name: Release - uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2.4.0 + uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1 with: body_path: "body.md" tag_name: v${{ env.REVISION }} From b19eb37ee28766ba522926903aa60ad07a836808 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 11 Oct 2025 16:31:34 +0000 Subject: [PATCH 059/102] =?UTF-8?q?bump:=20version=200.12.5=20=E2=86=92=20?= =?UTF-8?q?0.12.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bce2691..a00de2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## v0.12.6 (2025-10-11) + +### Fix + +- **deps**: update dependency mcp to >=1.17,<1.18 + ## v0.12.5 (2025-10-03) ### Fix diff --git a/pyproject.toml b/pyproject.toml index d4e7470..3cc71d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nextcloud-mcp-server" -version = "0.12.5" +version = "0.12.6" description = "" authors = [ {name = "Chris Coutinho",email = "chris@coutinho.io"} diff --git a/uv.lock b/uv.lock index 0f32b82..48451ba 100644 --- a/uv.lock +++ b/uv.lock @@ -588,7 +588,7 @@ wheels = [ [[package]] name = "nextcloud-mcp-server" -version = "0.12.5" +version = "0.12.6" source = { editable = "." } dependencies = [ { name = "click" }, From 55f326aa9a920ada1cbde47636d82d2fefd14e27 Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Sun, 12 Oct 2025 22:06:03 +0000 Subject: [PATCH 060/102] chore(deps): update astral-sh/setup-uv action to v7.1.0 --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6c28d4c..f69b118 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Install the latest version of uv - uses: astral-sh/setup-uv@eb1897b8dc4b5d5bfe39a428a8f2304605e0983c # v7.0.0 + uses: astral-sh/setup-uv@3259c6206f993105e3a61b142c2d97bf4b9ef83d # v7.1.0 - name: Check format run: | uv run --frozen ruff format --diff @@ -31,7 +31,7 @@ jobs: with: compose-file: "./docker-compose.yml" - name: Install the latest version of uv - uses: astral-sh/setup-uv@eb1897b8dc4b5d5bfe39a428a8f2304605e0983c # v7.0.0 + uses: astral-sh/setup-uv@3259c6206f993105e3a61b142c2d97bf4b9ef83d # v7.1.0 - name: Wait for service to be ready run: | From bad04573b59646384fde49252b4080fdc951289e Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Mon, 13 Oct 2025 22:08:43 +0000 Subject: [PATCH 061/102] chore(deps): update hoverkraft-tech/compose-action action to v2.4.1 --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f69b118..2543d69 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,7 +27,7 @@ jobs: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Run docker compose - uses: hoverkraft-tech/compose-action@b716db5b717cb9b81e391fe638e5aceaa2299e43 # v2.4.0 + uses: hoverkraft-tech/compose-action@3846bcd61da338e9eaaf83e7ed0234a12b099b72 # v2.4.1 with: compose-file: "./docker-compose.yml" - name: Install the latest version of uv From 4d7e4b9a4b3adea3a3e4b76ef63de299f517c20f Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 13 Oct 2025 18:07:46 +0200 Subject: [PATCH 062/102] feat(server): Experimental support for OAuth2/OIDC authentication --- OAUTH_IMPLEMENTATION_PLAN.md | 742 ++++++++++++++++++ OAUTH_TESTING.md | 121 +++ .../post-installation/install-oidc-app.sh | 13 + docker-compose.yml | 18 + env.sample | 20 + nextcloud_mcp_server/app.py | 407 +++++++++- nextcloud_mcp_server/auth/__init__.py | 14 + nextcloud_mcp_server/auth/bearer_auth.py | 34 + .../auth/client_registration.py | 260 ++++++ nextcloud_mcp_server/auth/context_helper.py | 54 ++ nextcloud_mcp_server/auth/token_verifier.py | 207 +++++ nextcloud_mcp_server/client/__init__.py | 17 + nextcloud_mcp_server/context.py | 51 ++ nextcloud_mcp_server/server/calendar.py | 24 +- nextcloud_mcp_server/server/contacts.py | 16 +- nextcloud_mcp_server/server/deck.py | 68 +- nextcloud_mcp_server/server/notes.py | 22 +- nextcloud_mcp_server/server/tables.py | 14 +- nextcloud_mcp_server/server/webdav.py | 16 +- scripts/test_oauth_tools.py | 94 +++ scripts/verify_oidc.py | 290 +++++++ tests/conftest.py | 236 +++++- tests/integration/test_oauth.py | 126 +++ 23 files changed, 2767 insertions(+), 97 deletions(-) create mode 100644 OAUTH_IMPLEMENTATION_PLAN.md create mode 100644 OAUTH_TESTING.md create mode 100755 app-hooks/post-installation/install-oidc-app.sh create mode 100644 nextcloud_mcp_server/auth/__init__.py create mode 100644 nextcloud_mcp_server/auth/bearer_auth.py create mode 100644 nextcloud_mcp_server/auth/client_registration.py create mode 100644 nextcloud_mcp_server/auth/context_helper.py create mode 100644 nextcloud_mcp_server/auth/token_verifier.py create mode 100644 nextcloud_mcp_server/context.py create mode 100644 scripts/test_oauth_tools.py create mode 100755 scripts/verify_oidc.py create mode 100644 tests/integration/test_oauth.py diff --git a/OAUTH_IMPLEMENTATION_PLAN.md b/OAUTH_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..e6c82b4 --- /dev/null +++ b/OAUTH_IMPLEMENTATION_PLAN.md @@ -0,0 +1,742 @@ +# OAuth2/OIDC Implementation Plan for Nextcloud MCP Server + +## Executive Summary +Upgrade the Nextcloud MCP server to support OAuth2/OIDC authentication using Nextcloud's OIDC app as the Authorization Server, eliminating the need for baked-in credentials in server deployment. + +**Status**: ✅ Research Complete - Implementation Ready + +## Research Findings Summary + +### ✅ Verified Nextcloud OIDC Capabilities +- **Token Format**: Opaque tokens by default, **RFC 9068 JWT access tokens available** (must be enabled per-client) +- **Discovery**: Full OpenID Connect discovery available at `/.well-known/openid-configuration` +- **JWKS**: Available at `/apps/oidc/jwks` for JWT signature validation +- **Dynamic Registration**: Supported via `/apps/oidc/register` (must be enabled by admin) +- **Introspection**: ❌ NOT available - must use **userinfo endpoint** for token validation +- **Userinfo**: Available at `/apps/oidc/userinfo` - validates token and returns user claims +- **Scopes**: `openid`, `profile`, `email`, `roles`, `groups` +- **User Claims**: `sub`, `preferred_username` (both contain Nextcloud username) + +### 🔑 Key Implementation Decisions +1. **Primary Token Validation**: Use **userinfo endpoint** (not introspection) +2. **JWT Support**: Optional - enables local validation if client configured for RFC 9068 +3. **User Context**: Extract username from `sub` or `preferred_username` claim via userinfo +4. **Dynamic Registration**: Primary deployment method (zero-config) +5. **Token Lifetime**: Access tokens default to 3600s, clients default to 3600s (both configurable) + +## Architecture Overview + +### Server Role: Resource Server (RS) - RFC 9728 +The MCP server acts as a **Resource Server** that: +- Validates OAuth tokens issued by Nextcloud OIDC app (Authorization Server) +- Protects MCP tools/resources with OAuth authentication +- Uses validated tokens to make Nextcloud API calls on behalf of authenticated users + +### Authentication Flow +``` +1. Client connects to MCP Server (RS) +2. MCP Server provides RFC 9728 metadata pointing to Nextcloud OIDC (AS) +3. Client performs OAuth flow with Nextcloud OIDC +4. Client presents access token to MCP Server +5. MCP Server validates token via userinfo endpoint (or JWT if configured) +6. MCP Server extracts username from claims +7. MCP Server uses token to call Nextcloud APIs with user context +``` + +## Key Design Decisions + +### 1. Dynamic Client Registration (PRIMARY APPROACH) +**Use Nextcloud OIDC's Dynamic Client Registration for zero-config deployment** + +**Benefits:** +- No manual client setup required +- MCP server auto-registers on first startup +- Automatic credential generation +- Self-healing if client expires +- Better developer/deployment experience + +**Implementation:** +```python +# Startup sequence: +1. Check for existing client credentials (file/env) +2. If none found, POST to /apps/oidc/register +3. Store client_id and client_secret persistently +4. Use credentials for OAuth flow +5. Auto re-register if client expires (3600s default) +``` + +**Nextcloud OIDC Requirements:** +- Admin must enable "Dynamic Client Registration" in OIDC app settings +- Rate limiting via BruteForce protection +- Max 100 dynamic clients per instance +- Clients expire after 1 hour (configurable via occ) + +### 2. Token Validation Strategy: Userinfo Endpoint (PRIMARY) + +**✅ VERIFIED IMPLEMENTATION: Userinfo Endpoint Validation** + +Nextcloud OIDC **does NOT provide** a token introspection endpoint. Token validation must use: + +**Primary: Userinfo Endpoint Validation** +- Call `/apps/oidc/userinfo` with Bearer token +- Nextcloud validates token internally (checks expiration, client, etc.) +- Returns user claims if valid: `sub`, `preferred_username`, `email`, `roles`, `groups` +- HTTP 400/401 if token invalid +- Cache results with TTL matching token expiration (3600s default) + +**Implementation Pattern**: +```python +async def verify_token(self, token: str) -> AccessToken | None: + # Call userinfo endpoint + response = await client.get( + f"{nextcloud_host}/apps/oidc/userinfo", + headers={"Authorization": f"Bearer {token}"} + ) + + if response.status_code == 200: + claims = response.json() + return AccessToken( + token=token, + client_id="", # Not available from userinfo + scopes=["openid", "profile"], # From original request + expires_at=calculate_expiry() # 3600s from now + ) + return None # Invalid token +``` + +**Optional: JWT Validation (Performance Optimization)** +- Available if client configured with "JWT Access Tokens (RFC 9068)" enabled +- Fetch JWKS from `/apps/oidc/jwks` +- Validate JWT signatures locally (no network call) +- Cache JWKS with refresh mechanism +- Falls back to userinfo if JWT validation fails + +**Trade-offs**: +- Userinfo: Simpler, always works, network call per validation +- JWT: Faster, no network call, requires per-client configuration + +### 3. Dual-Mode Authentication (Backward Compatibility) +Support both authentication modes: + +**Mode 1: OAuth2/OIDC (NEW)** +- Environment: `NEXTCLOUD_HOST` + optional `NEXTCLOUD_OIDC_CLIENT_ID/SECRET` +- Auto-registers if no client credentials provided +- Per-request client creation with bearer token + +**Mode 2: Basic Auth (LEGACY)** +- Environment: `NEXTCLOUD_HOST` + `NEXTCLOUD_USERNAME` + `NEXTCLOUD_PASSWORD` +- Current implementation preserved +- Single client in lifespan context + +### 4. HTTP Client Architecture + +**✅ REVISED: Context-aware Client Retrieval** + +Instead of per-request client creation, use a helper that extracts user context: + +```python +# Helper function to get client from MCP context +async def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient: + """Extract authenticated user context and create NextcloudClient.""" + # MCP SDK provides AccessToken from TokenVerifier + access_token: AccessToken = ctx.request_context.session.access_token + + # Extract username from cached userinfo claims + # (stored during token verification) + username = access_token.scopes[0] # Or from custom metadata + + # Create client with bearer token + return NextcloudClient.from_token( + base_url=base_url, + token=access_token.token, + username=username + ) + +# In tool implementations: +@mcp.tool() +async def nc_notes_create(title: str, content: str): + ctx = mcp.get_context() + + if oauth_mode: + client = await get_client_from_context(ctx, nextcloud_host) + else: + # Legacy: use lifespan client + client = ctx.request_context.lifespan_context.client + + return await client.notes.create_note(title, content) +``` + +**Key Pattern**: +- Token verification caches userinfo claims +- Helper retrieves username from cached data (no additional API call) +- Client uses bearer token for Nextcloud API calls + +### 5. User Context Extraction + +**✅ VERIFIED: Userinfo Endpoint Response** + +From Nextcloud OIDC userinfo endpoint response: +- **Username**: `sub` AND `preferred_username` (both contain Nextcloud username) +- **Scopes**: Determined by scopes requested during OAuth flow +- **Groups/Roles**: Available via `roles` or `groups` scope +- **Profile**: `name`, `email`, `picture`, etc. (if `profile` scope requested) + +**Implementation**: +```python +# During token verification: +userinfo = await fetch_userinfo(token) +# { +# "sub": "username", +# "preferred_username": "username", +# "email": "user@example.com", +# "roles": ["group1", "group2"], # if 'roles' scope +# "groups": ["group1", "group2"] # if 'groups' scope +# } + +username = userinfo["sub"] # or userinfo["preferred_username"] +``` + +**Storage Strategy**: +- Cache userinfo in AccessToken metadata +- Use MCP SDK's built-in token caching +- TTL matches access token expiration (3600s default) + +## Implementation Components + +### New Modules + +#### 1. `nextcloud_mcp_server/auth/__init__.py` +Exports: `NextcloudTokenVerifier`, `BearerAuth`, `register_client` + +#### 2. `nextcloud_mcp_server/auth/token_verifier.py` +```python +class NextcloudTokenVerifier(TokenVerifier): + """ + Validates access tokens using Nextcloud OIDC userinfo endpoint. + + Primary method: Userinfo endpoint validation (always works) + Optional: JWT validation if client configured for RFC 9068 + """ + + def __init__( + self, + nextcloud_host: str, + userinfo_uri: str, + jwks_uri: str | None = None, + enable_jwt_validation: bool = False + ): + self.nextcloud_host = nextcloud_host + self.userinfo_uri = userinfo_uri + self.jwks_uri = jwks_uri + self.enable_jwt_validation = enable_jwt_validation + + # Cache for validated tokens: token -> (userinfo, expiry) + self._token_cache: dict[str, tuple[dict, float]] = {} + + # JWKS cache (if JWT validation enabled) + self._jwks: dict | None = None + self._jwks_expires: float = 0 + + self._client = httpx.AsyncClient() + + async def verify_token(self, token: str) -> AccessToken | None: + """ + Verify token using userinfo endpoint (primary) or JWT validation (optional). + + Returns AccessToken with userinfo cached in metadata. + """ + # Check cache first + if token in self._token_cache: + userinfo, expiry = self._token_cache[token] + if time.time() < expiry: + return self._create_access_token(token, userinfo) + + # Try JWT validation first if enabled + if self.enable_jwt_validation and self.jwks_uri: + access_token = await self._verify_jwt(token) + if access_token: + return access_token + + # Fall back to (or use primary) userinfo validation + return await self._verify_via_userinfo(token) + + async def _verify_via_userinfo(self, token: str) -> AccessToken | None: + """Validate token by calling userinfo endpoint.""" + try: + response = await self._client.get( + self.userinfo_uri, + headers={"Authorization": f"Bearer {token}"}, + timeout=5.0 + ) + + if response.status_code == 200: + userinfo = response.json() + + # Cache for 3600s (default token lifetime) + # TODO: Get actual expiry from token if JWT + expiry = time.time() + 3600 + self._token_cache[token] = (userinfo, expiry) + + return self._create_access_token(token, userinfo) + + except Exception as e: + logger.warning(f"Userinfo validation failed: {e}") + + return None + + async def _verify_jwt(self, token: str) -> AccessToken | None: + """Validate JWT token locally using JWKS (optional optimization).""" + try: + # Fetch JWKS if not cached + if not self._jwks or time.time() > self._jwks_expires: + await self._refresh_jwks() + + # Decode and validate JWT + claims = jwt.decode( + token, + self._jwks, + algorithms=["RS256", "HS256"], + issuer=self.nextcloud_host, + options={"verify_aud": False} # Nextcloud may not include aud + ) + + # Extract userinfo from JWT claims + userinfo = { + "sub": claims.get("sub"), + "preferred_username": claims.get("preferred_username"), + "email": claims.get("email"), + "roles": claims.get("roles", []), + "groups": claims.get("groups", []) + } + + # Cache + expiry = claims.get("exp", time.time() + 3600) + self._token_cache[token] = (userinfo, expiry) + + return self._create_access_token(token, userinfo) + + except Exception as e: + logger.debug(f"JWT validation failed, falling back to userinfo: {e}") + return None + + def _create_access_token(self, token: str, userinfo: dict) -> AccessToken: + """Create AccessToken with userinfo in metadata.""" + username = userinfo.get("sub") or userinfo.get("preferred_username") + + return AccessToken( + token=token, + client_id="", # Not available from userinfo + scopes=["openid", "profile", "email"], # TODO: Track actual scopes + expires_at=int(time.time() + 3600), # TODO: Get from JWT exp claim + # Store username in scopes[0] as workaround for MCP SDK limitation + # Or use custom AccessToken subclass with username field + ) + + async def _refresh_jwks(self): + """Fetch JWKS from Nextcloud OIDC.""" + response = await self._client.get(self.jwks_uri) + response.raise_for_status() + self._jwks = response.json() + self._jwks_expires = time.time() + 3600 # Cache for 1 hour + + async def close(self): + """Cleanup resources.""" + await self._client.aclose() +``` + +#### 3. `nextcloud_mcp_server/auth/client_registration.py` +```python +async def register_client( + nextcloud_url: str, + client_name: str = "Nextcloud MCP Server", + redirect_uris: list[str] = None +) -> dict: + """Register MCP server as OAuth client with Nextcloud OIDC""" + # POST to /apps/oidc/register + # Return client_id, client_secret, expires_at + +async def load_or_register_client(storage_path: str) -> dict: + """Load existing client or register new one""" + # Check storage file + # Validate expiration + # Re-register if expired + # Persist credentials +``` + +#### 4. `nextcloud_mcp_server/auth/bearer_auth.py` +```python +class BearerAuth(httpx.Auth): + """Bearer token authentication for httpx""" + + def __init__(self, token: str): + self.token = token + + def auth_flow(self, request): + request.headers["Authorization"] = f"Bearer {self.token}" + yield request +``` + +### Modified Files + +#### 1. `nextcloud_mcp_server/app.py` +```python +# Add OAuth configuration +from nextcloud_mcp_server.auth import NextcloudTokenVerifier, register_client + +# In get_app(): +if oauth_enabled: + # Load or register client + client_info = await load_or_register_client(storage_path) + + # Create token verifier + token_verifier = NextcloudTokenVerifier( + jwks_uri=f"{nextcloud_host}/apps/oidc/jwks", + issuer=f"{nextcloud_host}" + ) + + # Configure FastMCP with OAuth + mcp = FastMCP( + "Nextcloud MCP", + token_verifier=token_verifier, + auth=AuthSettings( + issuer_url=nextcloud_host, + resource_server_url=mcp_server_url, + required_scopes=["openid", "profile"] + ), + lifespan=app_lifespan_oauth # Don't create client in lifespan + ) +else: + # Legacy BasicAuth mode + mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_basic) +``` + +#### 2. `nextcloud_mcp_server/client/__init__.py` +```python +class NextcloudClient: + def __init__(self, base_url: str, username: str, auth: Auth | None = None): + # Accept either BasicAuth or BearerAuth + self._client = AsyncClient(base_url=base_url, auth=auth, ...) + + @classmethod + def from_env(cls): + """Legacy: Create from username/password env vars""" + return cls(base_url, username, auth=BasicAuth(username, password)) + + @classmethod + def from_token(cls, base_url: str, token: str, username: str): + """OAuth: Create from bearer token""" + return cls(base_url, username, auth=BearerAuth(token)) +``` + +#### 3. `nextcloud_mcp_server/server/notes.py` (and other tool modules) +```python +from nextcloud_mcp_server.auth import get_client_from_context + +@mcp.tool() +async def nc_notes_create(title: str, content: str): + ctx: Context = mcp.get_context() + + # OAuth mode: Get client from request context + if oauth_enabled: + client = get_client_from_context(ctx) + else: + # Legacy mode: Use lifespan client + client = ctx.request_context.lifespan_context.client + + return await client.notes.create_note(...) +``` + +#### 4. `nextcloud_mcp_server/config.py` +```python +class NextcloudConfig: + # Common + host: str + + # OAuth mode + oauth_enabled: bool = False + oidc_client_id: str | None = None + oidc_client_secret: str | None = None + client_storage_path: str = ".nextcloud_oauth_client.json" + mcp_server_url: str = "http://localhost:8000/mcp" + required_scopes: list[str] = ["openid", "profile", "email"] + + # Legacy mode + username: str | None = None + password: str | None = None + + @classmethod + def from_env(cls): + oauth_enabled = not ( + os.getenv("NEXTCLOUD_USERNAME") and + os.getenv("NEXTCLOUD_PASSWORD") + ) + return cls(oauth_enabled=oauth_enabled, ...) +``` + +### Configuration Files + +#### Updated `env.sample` +```bash +# Nextcloud Instance +NEXTCLOUD_HOST=https://nextcloud.example.com + +# ===== AUTHENTICATION MODE ===== +# Choose ONE of the following: + +# Option 1: OAuth2/OIDC (RECOMMENDED) +# - Requires Nextcloud OIDC app installed +# - Enable "Dynamic Client Registration" in OIDC app settings +# - Leave NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD empty +# - Optional: Pre-register client and provide credentials +NEXTCLOUD_OIDC_CLIENT_ID= +NEXTCLOUD_OIDC_CLIENT_SECRET= +NEXTCLOUD_OIDC_CLIENT_STORAGE=.nextcloud_oauth_client.json +NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000/mcp + +# Option 2: Basic Authentication (LEGACY - Will be deprecated) +# - Requires username and password +# - Less secure - credentials stored in environment +# - Use only for backward compatibility +NEXTCLOUD_USERNAME= +NEXTCLOUD_PASSWORD= +``` + +## Dependencies + +### New Python Dependencies +```toml +# pyproject.toml additions: +dependencies = [ + # ... existing ... + "PyJWT[crypto]>=2.8.0", # JWT validation + "cryptography>=41.0.0", # JWKS key handling (if not present) +] +``` + +## Nextcloud OIDC Setup + +### Administrator Setup (One-time) +1. Install Nextcloud OIDC app from App Store +2. Navigate to Settings → OIDC +3. Enable "Dynamic Client Registration" +4. (Optional) Configure token expiration times via CLI: + ```bash + php occ config:app:set oidc expire_time --value "3600" + php occ config:app:set oidc refresh_expire_time --value "86400" + ``` + +### MCP Server Deployment (Zero-config) +1. Set `NEXTCLOUD_HOST` environment variable +2. Set `NEXTCLOUD_MCP_SERVER_URL` (if not localhost:8000) +3. Start MCP server → Auto-registers on first run +4. Client credentials stored in `.nextcloud_oauth_client.json` + +### Alternative: Pre-registered Client +```bash +# Create client via CLI +php occ oidc:create \ + --name="Nextcloud MCP Server" \ + --type=confidential \ + --redirect-uri="http://localhost:8000/oauth/callback" + +# Set credentials in environment +NEXTCLOUD_OIDC_CLIENT_ID= +NEXTCLOUD_OIDC_CLIENT_SECRET= +``` + +## Testing Strategy + +### Unit Tests +- Token validation with mocked JWKS +- JWT claim extraction +- Client registration flow +- Bearer auth implementation + +### Integration Tests +- Dynamic client registration against test Nextcloud +- OAuth flow end-to-end +- Token-based API calls +- Client expiration and re-registration +- Dual-mode authentication (OAuth + BasicAuth) + +### Test Fixtures +```python +# tests/conftest.py additions: +@pytest.fixture +def mock_oidc_server(): + """Mock Nextcloud OIDC endpoints""" + # Mock /apps/oidc/openid-configuration + # Mock /apps/oidc/jwks + # Mock /apps/oidc/register + # Mock /apps/oidc/token + +@pytest.fixture +async def oauth_nc_client(mock_oidc_server): + """NextcloudClient with OAuth token""" + token = generate_test_jwt() + return NextcloudClient.from_token(base_url, token, "testuser") +``` + +## Migration Path + +### Phase 1: Implementation (Week 1-2) +- [ ] Implement token verifier with JWT validation +- [ ] Implement dynamic client registration +- [ ] Add BearerAuth for httpx +- [ ] Modify NextcloudClient for dual-mode auth +- [ ] Update app.py with OAuth configuration +- [ ] Add configuration management + +### Phase 2: Testing (Week 2-3) +- [ ] Unit tests for all auth components +- [ ] Integration tests with test Nextcloud instance +- [ ] End-to-end OAuth flow testing +- [ ] Backward compatibility testing + +### Phase 3: Documentation (Week 3) +- [ ] Update README.md with OAuth setup +- [ ] Update CLAUDE.md with architecture changes +- [ ] Add OAuth troubleshooting guide +- [ ] Document OIDC app configuration +- [ ] Add migration guide for existing deployments + +### Phase 4: Deployment (Week 4) +- [ ] Release with both modes supported +- [ ] Monitor for issues +- [ ] Deprecation notice for BasicAuth +- [ ] Plan BasicAuth removal timeline (6+ months) + +## Security Considerations + +### Token Security +- Store client secrets securely (file permissions, secret managers) +- Validate JWT signatures against trusted JWKS +- Verify token claims (issuer, audience, expiration) +- Implement token refresh logic +- Rate limit token validation failures + +### Client Registration Security +- Nextcloud OIDC provides BruteForce protection +- Dynamic clients limited to 100 per instance +- Clients expire after 1 hour (configurable) +- Admin must explicitly enable dynamic registration + +### API Security +- Bearer tokens used for Nextcloud API calls +- Token scopes control access levels +- User context preserved in all API operations +- No credential storage in MCP server + +## Performance Considerations + +### JWT Validation Performance +- JWKS caching with TTL (e.g., 1 hour) +- Key rotation handling via JWKS refresh +- Local validation (no network call per request) +- Async validation to avoid blocking + +### Client Creation +- OAuth mode: Per-request client creation (lightweight) +- BasicAuth mode: Single client in lifespan (current) +- Connection pooling maintained in both modes + +## Future Enhancements + +### Scope-based Authorization +- Define custom Nextcloud scopes for MCP operations +- Map MCP tools to required scopes +- Fine-grained permission control + +### Multi-tenant Support +- Support multiple Nextcloud instances +- Per-user client registration +- Tenant isolation + +### Token Introspection Fallback +- Implement RFC 7662 introspection +- Use if JWT validation fails +- Support for opaque tokens + +### Admin Controls +- MCP server admin UI for OAuth config +- Client credential rotation +- Usage monitoring and logging + +## Decisions Made (Post-Research) + +1. **✅ Token Validation Method**: Userinfo endpoint (primary), JWT optional + - Nextcloud OIDC does NOT provide introspection endpoint + - Userinfo endpoint validates token AND returns user claims + - JWT validation available as performance optimization if client configured + +2. **✅ Client expiration handling**: Auto re-register with logging + - Clients expire after 3600s by default + - Check expiry on startup and periodically + - Auto-register with backoff on failure + +3. **✅ Scope requirements**: `["openid", "profile", "email"]` + - Sufficient for basic user identification + - Optional: Add `"roles"` or `"groups"` for group-based authorization + +4. **✅ Token caching**: In-memory with 3600s TTL + - Cache userinfo response (includes all needed claims) + - Use token string as cache key + - TTL matches default access token lifetime + +5. **✅ Client storage**: JSON file with 0600 permissions + - Default: `.nextcloud_oauth_client.json` + - Configurable via env var + - Contains: client_id, client_secret, issued_at + +6. **✅ Username extraction**: From `sub` or `preferred_username` claim + - Both contain Nextcloud username (verified) + - Retrieved during token validation + - Cached with token + +7. **✅ BasicAuth deprecation**: 12 months after OAuth stable release + - Phase 1: OAuth + BasicAuth (6 months) + - Phase 2: OAuth only, deprecation warnings (6 months) + - Phase 3: Remove BasicAuth + +## Key Changes from Original Plan + +### 1. Token Validation +**Original**: JWT validation with JWKS (primary), introspection (fallback) +**Updated**: Userinfo endpoint (primary), JWT validation (optional optimization) +- Reason: Nextcloud OIDC has no introspection endpoint + +### 2. User Context Extraction +**Original**: Extract username from JWT claims +**Updated**: Fetch from userinfo endpoint during validation +- Reason: Opaque tokens by default, userinfo always works + +### 3. Token Caching Strategy +**Original**: MCP SDK handles all caching +**Updated**: Custom cache in TokenVerifier for userinfo responses +- Reason: Need to cache username separately from AccessToken + +### 4. JWT Support +**Original**: Required for all deployments +**Updated**: Optional performance optimization +- Reason: Requires per-client configuration in Nextcloud OIDC +- Default: Opaque tokens validated via userinfo + +## References + +- [MCP Python SDK OAuth Documentation](https://github.com/modelcontextprotocol/python-sdk) +- [MCP RFC 9728 Protected Resource Metadata](https://www.rfc-editor.org/rfc/rfc9728.html) +- [Nextcloud OIDC App Repository](https://github.com/H2CK/oidc) +- [OpenID Connect Dynamic Client Registration](https://openid.net/specs/openid-connect-registration-1_0.html) +- [RFC 9068 JWT Access Tokens](https://www.rfc-editor.org/rfc/rfc9068.html) +- [MCP Simple Auth Example](~/Software/python-sdk/examples/servers/simple-auth/) + +## Success Criteria + +✅ MCP server can authenticate via Nextcloud OIDC with zero manual client setup +✅ Dynamic client registration works automatically on first run +✅ JWT tokens validated locally without per-request network calls +✅ Backward compatibility maintained with BasicAuth mode +✅ All existing tests pass in both auth modes +✅ Documentation complete for OAuth setup and migration +✅ Security review passed (token handling, credential storage) +✅ Performance benchmarks meet targets (< 10ms token validation overhead) diff --git a/OAUTH_TESTING.md b/OAUTH_TESTING.md new file mode 100644 index 0000000..d601866 --- /dev/null +++ b/OAUTH_TESTING.md @@ -0,0 +1,121 @@ +# OAuth Testing Setup + +This document describes the automated OAuth testing infrastructure for the Nextcloud MCP server. + +## Overview + +We've created a comprehensive testing setup that includes: + +1. **OIDC App Configuration** - Nextcloud OIDC app automatically installed and configured with dynamic client registration +2. **Dual MCP Services** - Two MCP server instances running in Docker: + - `mcp` (port 8000) - BasicAuth mode (username/password) + - `mcp-oauth` (port 8001) - OAuth mode (dynamic client registration) +3. **Test Fixtures** - Pytest fixtures for OAuth client testing +4. **Integration Tests** - OAuth-specific integration tests + +## Docker Compose Setup + +The `docker-compose.yml` includes: + +```yaml +services: + app: # Nextcloud with OIDC app enabled + mcp: # BasicAuth MCP server (port 8000) + mcp-oauth: # OAuth MCP server (port 8001) +``` + +## OIDC Configuration + +The OIDC app is configured automatically via `app-hooks/post-installation/install-oidc-app.sh`: + +- **Dynamic Client Registration**: Enabled +- **Config Key**: `dynamic_client_registration` (not `allow_dynamic_client_registration`) +- **Registration Endpoint**: `http://localhost:8080/apps/oidc/register` + +### Important: Config Key Fix + +The correct OIDC config key is `dynamic_client_registration`. The initial implementation used `allow_dynamic_client_registration` which was incorrect and caused the registration endpoint to not appear in the OIDC discovery document. + +## Test Fixtures + +Located in `tests/conftest.py`: + +### `oauth_token` +Session-scoped fixture that obtains an OAuth access token. + +**Current Limitation**: Nextcloud OIDC only supports `authorization_code` and `refresh_token` grant types, not the `password` grant type. This means we cannot automatically obtain tokens for testing without implementing a full browser-based OAuth flow. + +### `nc_oauth_client` +Session-scoped NextcloudClient configured with OAuth bearer token authentication. + +**Status**: Implemented but currently skipped due to token acquisition limitation. + +### `nc_mcp_oauth_client` +Session-scoped MCP client that connects to the OAuth-enabled MCP server on port 8001. + +**Status**: Implemented but marked as skip - requires full OAuth authorization flow implementation in MCP SDK. + +## Current Test Status + +### ✅ Working +- OIDC app installation and configuration +- Dynamic client registration +- OAuth infrastructure (BearerAuth, TokenVerifier, client registration) +- Docker compose dual-mode setup + +### ⚠️ Limitations +- **No automated token acquisition**: Nextcloud OIDC doesn't support the Resource Owner Password Credentials grant, which means we cannot programmatically get tokens for testing without browser interaction +- **Manual testing only**: OAuth functionality must be tested manually using a browser-based OAuth flow +- **MCP OAuth server untested**: The OAuth MCP server requires the full OAuth authorization flow to be implemented in the MCP Python SDK + +## Manual Testing OAuth + +To manually test OAuth functionality: + +1. Start the docker-compose environment: + ```bash + docker-compose up -d + ``` + +2. The OAuth MCP server runs on port 8001 and will: + - Automatically register a client via dynamic registration + - Store client credentials in `/app/.oauth/` volume + - Display OAuth configuration on startup + +3. To test OAuth with a real client: + - Use the authorization endpoint: `http://localhost:8080/apps/oidc/authorize` + - Implement the authorization code flow + - Exchange code for token at: `http://localhost:8080/apps/oidc/token` + +## Future Work + +To enable automated OAuth testing, one of these approaches is needed: + +1. **Mock OIDC Server**: Create a test OIDC server that supports password grant +2. **Browser Automation**: Use Selenium/Playwright to automate the OAuth flow +3. **Test-Only Password Grant**: Patch Nextcloud OIDC to support password grant in test mode +4. **Pre-generated Tokens**: Manually generate long-lived tokens and use them in tests + +## Running Tests + +```bash +# Run all tests (OAuth tests will be skipped) +uv run pytest tests/integration/test_oauth.py -v + +# Run only the invalid token test (this one works) +uv run pytest tests/integration/test_oauth.py::TestOAuthTokenValidation::test_invalid_token_fails -v +``` + +## Files Modified + +- `tests/conftest.py` - Added OAuth fixtures and token acquisition logic +- `tests/integration/test_oauth.py` - OAuth-specific integration tests +- `docker-compose.yml` - Added `mcp-oauth` service +- `app-hooks/post-installation/install-oidc-app.sh` - OIDC installation and configuration +- `nextcloud_mcp_server/client/__init__.py` - Added `from_token()` classmethod + +## Notes + +- The `from_token()` method was added to NextcloudClient to support OAuth authentication +- All OAuth infrastructure is in place and functional +- The main limitation is automated token acquisition for testing, not the OAuth implementation itself diff --git a/app-hooks/post-installation/install-oidc-app.sh b/app-hooks/post-installation/install-oidc-app.sh new file mode 100755 index 0000000..a09f708 --- /dev/null +++ b/app-hooks/post-installation/install-oidc-app.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -e + +echo "Installing and configuring OIDC app for testing..." + +# Enable the OIDC app +php /var/www/html/occ app:enable oidc + +# Configure OIDC for testing with dynamic client registration enabled +# Note: The correct config key is 'dynamic_client_registration', not 'allow_dynamic_client_registration' +php /var/www/html/occ config:app:set oidc dynamic_client_registration --value='true' + +echo "OIDC app installed and configured successfully" diff --git a/docker-compose.yml b/docker-compose.yml index 4322ae3..966d13b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,6 +47,8 @@ services: mcp: build: . command: ["--transport", "streamable-http"] + depends_on: + - app ports: - 127.0.0.1:8000:8000 environment: @@ -56,6 +58,22 @@ services: #volumes: #- ./nextcloud_mcp_server:/app/nextcloud_mcp_server:ro + mcp-oauth: + build: . + command: ["--transport", "streamable-http", "--oauth", "--port", "8001"] + depends_on: + - app + ports: + - 127.0.0.1:8001:8001 + environment: + - NEXTCLOUD_HOST=http://app:80 + # No USERNAME/PASSWORD - will use OAuth + volumes: + - oauth-client-storage:/app/.oauth + #volumes: + #- ./nextcloud_mcp_server:/app/nextcloud_mcp_server:ro + volumes: nextcloud: db: + oauth-client-storage: diff --git a/env.sample b/env.sample index 0c2c1ed..cc29540 100644 --- a/env.sample +++ b/env.sample @@ -1,3 +1,23 @@ +# Nextcloud Instance NEXTCLOUD_HOST= + +# ===== AUTHENTICATION MODE ===== +# Choose ONE of the following: + +# Option 1: OAuth2/OIDC (RECOMMENDED - More Secure) +# - Requires Nextcloud OIDC app installed and configured +# - Admin must enable "Dynamic Client Registration" in OIDC app settings +# - Leave NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD empty to use OAuth mode +# - Optional: Pre-register client and provide credentials (otherwise auto-registers) +NEXTCLOUD_OIDC_CLIENT_ID= +NEXTCLOUD_OIDC_CLIENT_SECRET= +NEXTCLOUD_OIDC_CLIENT_STORAGE=.nextcloud_oauth_client.json +NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000 + +# Option 2: Basic Authentication (LEGACY - Less Secure) +# - Requires username and password +# - Credentials stored in environment variables +# - Use only for backward compatibility or if OAuth unavailable +# - If these are set, OAuth mode is disabled NEXTCLOUD_USERNAME= NEXTCLOUD_PASSWORD= diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index 380e31b..f63ad08 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -1,17 +1,25 @@ import click import logging +import os import uvicorn from collections.abc import AsyncIterator from contextlib import asynccontextmanager, AsyncExitStack from dataclasses import dataclass +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.client import NextcloudClient +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, @@ -27,36 +35,266 @@ logger = logging.getLogger(__name__) @dataclass class AppContext: + """Application context for BasicAuth mode.""" + client: NextcloudClient +@dataclass +class OAuthAppContext: + """Application context for OAuth mode.""" + + nextcloud_host: str + token_verifier: NextcloudTokenVerifier + + +def is_oauth_mode() -> bool: + """ + Determine if OAuth mode should be used. + + OAuth mode is enabled when: + - NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD are NOT set + - Or explicitly enabled via configuration + + Returns: + True if OAuth mode, False if BasicAuth mode + """ + username = os.getenv("NEXTCLOUD_USERNAME") + password = os.getenv("NEXTCLOUD_PASSWORD") + + # If both username and password are set, use BasicAuth + if username and password: + logger.info( + "BasicAuth mode detected (NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD set)" + ) + return False + + logger.info("OAuth mode detected (NEXTCLOUD_USERNAME/PASSWORD not set)") + return True + + @asynccontextmanager -async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: - """Manage application lifecycle with type-safe context""" - # Initialize on startup - logging.info("Creating Nextcloud client") +async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]: + """ + Manage application lifecycle for BasicAuth mode. + + Creates a single Nextcloud client with basic authentication + that is shared across all requests. + """ + logger.info("Starting MCP server in BasicAuth mode") + logger.info("Creating Nextcloud client with BasicAuth") + client = NextcloudClient.from_env() - logging.info("Client initialization wait complete.") + logger.info("Client initialization complete") + try: yield AppContext(client=client) finally: - # Cleanup on shutdown + logger.info("Shutting down BasicAuth mode") await client.close() +@asynccontextmanager +async def app_lifespan_oauth(server: FastMCP) -> AsyncIterator[OAuthAppContext]: + """ + Manage application lifecycle for OAuth mode. + + Initializes OAuth client registration and token verifier. + Does NOT create a Nextcloud client - clients are created per-request. + """ + logger.info("Starting MCP server in OAuth mode") + + nextcloud_host = os.getenv("NEXTCLOUD_HOST") + if not nextcloud_host: + raise ValueError("NEXTCLOUD_HOST environment variable is required") + + nextcloud_host = nextcloud_host.rstrip("/") + + # Get OAuth discovery endpoint + discovery_url = f"{nextcloud_host}/.well-known/openid-configuration" + + try: + # Fetch OIDC discovery + import httpx + + async with httpx.AsyncClient() as client: + response = await client.get(discovery_url) + response.raise_for_status() + discovery = response.json() + + logger.info(f"OIDC discovery successful: {discovery_url}") + + # Extract endpoints + userinfo_uri = discovery["userinfo_endpoint"] + registration_endpoint = discovery.get("registration_endpoint") + + logger.info(f"Userinfo endpoint: {userinfo_uri}") + + # Handle client registration + client_id = os.getenv("NEXTCLOUD_OIDC_CLIENT_ID") + client_secret = os.getenv("NEXTCLOUD_OIDC_CLIENT_SECRET") + storage_path = os.getenv( + "NEXTCLOUD_OIDC_CLIENT_STORAGE", ".nextcloud_oauth_client.json" + ) + + if client_id and client_secret: + logger.info("Using pre-configured OAuth client credentials") + elif registration_endpoint: + logger.info("Dynamic client registration available") + mcp_server_url = os.getenv( + "NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000" + ) + redirect_uris = [f"{mcp_server_url}/oauth/callback"] + + # Load or register client + client_info = await load_or_register_client( + nextcloud_url=nextcloud_host, + registration_endpoint=registration_endpoint, + storage_path=storage_path, + client_name="Nextcloud MCP Server", + redirect_uris=redirect_uris, + ) + + logger.info(f"OAuth client ready: {client_info.client_id[:16]}...") + else: + raise ValueError( + "OAuth mode requires either:\n" + "1. NEXTCLOUD_OIDC_CLIENT_ID and NEXTCLOUD_OIDC_CLIENT_SECRET, OR\n" + "2. Dynamic client registration enabled on Nextcloud OIDC app" + ) + + # Create token verifier + token_verifier = NextcloudTokenVerifier( + nextcloud_host=nextcloud_host, userinfo_uri=userinfo_uri + ) + + logger.info("OAuth initialization complete") + + try: + yield OAuthAppContext( + nextcloud_host=nextcloud_host, token_verifier=token_verifier + ) + finally: + logger.info("Shutting down OAuth mode") + await token_verifier.close() + + except Exception as e: + logger.error(f"Failed to initialize OAuth mode: {e}") + raise + + +async def setup_oauth_config(): + """ + Setup OAuth configuration by performing OIDC discovery and client registration. + + This is done synchronously before FastMCP initialization because FastMCP + requires token_verifier at construction time. + + Returns: + Tuple of (nextcloud_host, token_verifier, auth_settings) + """ + nextcloud_host = os.getenv("NEXTCLOUD_HOST") + if not nextcloud_host: + raise ValueError( + "NEXTCLOUD_HOST environment variable is required for OAuth mode" + ) + + nextcloud_host = nextcloud_host.rstrip("/") + discovery_url = f"{nextcloud_host}/.well-known/openid-configuration" + + logger.info(f"Performing OIDC discovery: {discovery_url}") + + # Fetch OIDC discovery + import httpx + + async with httpx.AsyncClient() as client: + response = await client.get(discovery_url) + response.raise_for_status() + discovery = response.json() + + logger.info("OIDC discovery successful") + + # Extract endpoints + issuer = discovery["issuer"] + userinfo_uri = discovery["userinfo_endpoint"] + registration_endpoint = discovery.get("registration_endpoint") + + # Handle client registration + client_id = os.getenv("NEXTCLOUD_OIDC_CLIENT_ID") + client_secret = os.getenv("NEXTCLOUD_OIDC_CLIENT_SECRET") + + if client_id and client_secret: + logger.info("Using pre-configured OAuth client credentials") + elif registration_endpoint: + logger.info("Dynamic client registration available") + storage_path = os.getenv( + "NEXTCLOUD_OIDC_CLIENT_STORAGE", ".nextcloud_oauth_client.json" + ) + mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000") + redirect_uris = [f"{mcp_server_url}/oauth/callback"] + + # Load or register client + client_info = await load_or_register_client( + nextcloud_url=nextcloud_host, + registration_endpoint=registration_endpoint, + storage_path=storage_path, + client_name="Nextcloud MCP Server", + redirect_uris=redirect_uris, + ) + + logger.info(f"OAuth client ready: {client_info.client_id[:16]}...") + else: + raise ValueError( + "OAuth mode requires either:\n" + "1. NEXTCLOUD_OIDC_CLIENT_ID and NEXTCLOUD_OIDC_CLIENT_SECRET, OR\n" + "2. Dynamic client registration enabled on Nextcloud OIDC app" + ) + + # Create token verifier + token_verifier = NextcloudTokenVerifier( + nextcloud_host=nextcloud_host, userinfo_uri=userinfo_uri + ) + + # Create auth settings + mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000") + auth_settings = AuthSettings( + issuer_url=AnyHttpUrl(issuer), + resource_server_url=AnyHttpUrl(mcp_server_url), + required_scopes=["openid", "profile"], + ) + + logger.info("OAuth configuration complete") + + return nextcloud_host, token_verifier, auth_settings + + def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): setup_logging() - # Create an MCP server - mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan) + # Determine authentication mode + oauth_enabled = is_oauth_mode() + + # WARNING: This is a synchronous function but OAuth setup requires async + # For now, OAuth configuration will be handled differently + # We'll need to restructure this or use a factory pattern + + if oauth_enabled: + logger.info("Configuring MCP server for OAuth mode") + logger.warning( + "OAuth mode requires async initialization - use factory pattern or separate setup" + ) + # For now, fall back to a simplified OAuth setup + # TODO: This needs to be restructured to support async initialization + mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_oauth) + else: + logger.info("Configuring MCP server for BasicAuth mode") + mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_basic) @mcp.resource("nc://capabilities") async def nc_get_capabilities(): """Get the Nextcloud Host capabilities""" - ctx: Context = ( - mcp.get_context() - ) # https://github.com/modelcontextprotocol/python-sdk/issues/244 - client: NextcloudClient = ctx.request_context.lifespan_context.client + ctx: Context = mcp.get_context() + client = get_nextcloud_client(ctx) return await client.capabilities() # Define available apps and their configuration functions @@ -101,16 +339,23 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): @click.command() -@click.option("--host", "-h", default="127.0.0.1", show_default=True) -@click.option("--port", "-p", type=int, default=8000, show_default=True) -@click.option("--workers", "-w", type=int, default=None) -@click.option("--reload", "-r", is_flag=True) +@click.option( + "--host", "-h", default="127.0.0.1", show_default=True, help="Server host" +) +@click.option( + "--port", "-p", type=int, default=8000, show_default=True, help="Server port" +) +@click.option( + "--workers", "-w", type=int, default=None, help="Number of worker processes" +) +@click.option("--reload", "-r", is_flag=True, help="Enable auto-reload") @click.option( "--log-level", "-l", default="info", show_default=True, type=click.Choice(["critical", "error", "warning", "info", "debug", "trace"]), + help="Logging level", ) @click.option( "--transport", @@ -118,6 +363,7 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): default="sse", show_default=True, type=click.Choice(["sse", "streamable-http", "http"]), + help="MCP transport protocol", ) @click.option( "--enable-app", @@ -126,6 +372,35 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): type=click.Choice(["notes", "tables", "webdav", "calendar", "contacts", "deck"]), help="Enable specific Nextcloud app APIs. Can be specified multiple times. If not specified, all apps are enabled.", ) +@click.option( + "--oauth/--no-oauth", + default=None, + help="Force OAuth mode (if enabled) or BasicAuth mode (if disabled). By default, auto-detected based on environment variables.", +) +@click.option( + "--oauth-client-id", + envvar="NEXTCLOUD_OIDC_CLIENT_ID", + help="OAuth client ID (can also use NEXTCLOUD_OIDC_CLIENT_ID env var)", +) +@click.option( + "--oauth-client-secret", + envvar="NEXTCLOUD_OIDC_CLIENT_SECRET", + help="OAuth client secret (can also use NEXTCLOUD_OIDC_CLIENT_SECRET env var)", +) +@click.option( + "--oauth-storage-path", + envvar="NEXTCLOUD_OIDC_CLIENT_STORAGE", + default=".nextcloud_oauth_client.json", + show_default=True, + help="Path to store OAuth client credentials (can also use NEXTCLOUD_OIDC_CLIENT_STORAGE env var)", +) +@click.option( + "--mcp-server-url", + envvar="NEXTCLOUD_MCP_SERVER_URL", + default="http://localhost:8000", + show_default=True, + help="MCP server URL for OAuth callbacks (can also use NEXTCLOUD_MCP_SERVER_URL env var)", +) def run( host: str, port: int, @@ -134,7 +409,107 @@ def run( log_level: str, transport: str, enable_app: tuple[str, ...], + oauth: bool | None, + oauth_client_id: str | None, + oauth_client_secret: str | None, + oauth_storage_path: str, + mcp_server_url: str, ): + """ + Run the Nextcloud MCP server. + + \b + Authentication Modes: + - BasicAuth: Set NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD + - OAuth: Leave USERNAME/PASSWORD unset (requires OIDC app enabled) + + \b + Examples: + # BasicAuth mode (legacy) + $ nextcloud-mcp-server --host 0.0.0.0 --port 8000 + + # OAuth mode with auto-registration + $ nextcloud-mcp-server --oauth + + # OAuth mode with pre-configured client + $ nextcloud-mcp-server --oauth --oauth-client-id=xxx --oauth-client-secret=yyy + """ + # Set OAuth env vars from CLI options if provided + if oauth_client_id: + os.environ["NEXTCLOUD_OIDC_CLIENT_ID"] = oauth_client_id + if oauth_client_secret: + os.environ["NEXTCLOUD_OIDC_CLIENT_SECRET"] = oauth_client_secret + if oauth_storage_path: + os.environ["NEXTCLOUD_OIDC_CLIENT_STORAGE"] = oauth_storage_path + if mcp_server_url: + os.environ["NEXTCLOUD_MCP_SERVER_URL"] = mcp_server_url + + # Force OAuth mode if explicitly requested + if oauth is True: + # Clear username/password to force OAuth mode + if "NEXTCLOUD_USERNAME" in os.environ: + click.echo( + "Warning: --oauth flag set, ignoring NEXTCLOUD_USERNAME", err=True + ) + del os.environ["NEXTCLOUD_USERNAME"] + if "NEXTCLOUD_PASSWORD" in os.environ: + click.echo( + "Warning: --oauth flag set, ignoring NEXTCLOUD_PASSWORD", err=True + ) + del os.environ["NEXTCLOUD_PASSWORD"] + + # Validate OAuth configuration + nextcloud_host = os.getenv("NEXTCLOUD_HOST") + if not nextcloud_host: + raise click.ClickException( + "OAuth mode requires NEXTCLOUD_HOST environment variable to be set" + ) + + # Check if we have client credentials OR if dynamic registration is possible + has_client_creds = os.getenv("NEXTCLOUD_OIDC_CLIENT_ID") and os.getenv( + "NEXTCLOUD_OIDC_CLIENT_SECRET" + ) + + if not has_client_creds: + # No client credentials - will attempt dynamic registration + # Show helpful message before server starts + click.echo("", err=True) + click.echo("OAuth Configuration:", err=True) + click.echo(" Mode: Dynamic Client Registration", err=True) + click.echo(" Host: " + nextcloud_host, err=True) + click.echo( + " Storage: " + + os.getenv( + "NEXTCLOUD_OIDC_CLIENT_STORAGE", ".nextcloud_oauth_client.json" + ), + err=True, + ) + click.echo("", err=True) + click.echo( + "Note: Make sure 'Dynamic Client Registration' is enabled", err=True + ) + click.echo(" in your Nextcloud OIDC app settings.", err=True) + click.echo("", err=True) + else: + click.echo("", err=True) + click.echo("OAuth Configuration:", err=True) + click.echo(" Mode: Pre-configured Client", err=True) + click.echo(" Host: " + nextcloud_host, err=True) + click.echo( + " Client ID: " + + os.getenv("NEXTCLOUD_OIDC_CLIENT_ID", "")[:16] + + "...", + err=True, + ) + click.echo("", err=True) + + elif oauth is False: + # Force BasicAuth mode - verify credentials exist + if not os.getenv("NEXTCLOUD_USERNAME") or not os.getenv("NEXTCLOUD_PASSWORD"): + raise click.ClickException( + "--no-oauth flag set but NEXTCLOUD_USERNAME or NEXTCLOUD_PASSWORD not set" + ) + enabled_apps = list(enable_app) if enable_app else None if reload or workers: diff --git a/nextcloud_mcp_server/auth/__init__.py b/nextcloud_mcp_server/auth/__init__.py new file mode 100644 index 0000000..722064b --- /dev/null +++ b/nextcloud_mcp_server/auth/__init__.py @@ -0,0 +1,14 @@ +"""OAuth authentication components for Nextcloud MCP server.""" + +from .bearer_auth import BearerAuth +from .client_registration import load_or_register_client, register_client +from .context_helper import get_client_from_context +from .token_verifier import NextcloudTokenVerifier + +__all__ = [ + "BearerAuth", + "NextcloudTokenVerifier", + "register_client", + "load_or_register_client", + "get_client_from_context", +] diff --git a/nextcloud_mcp_server/auth/bearer_auth.py b/nextcloud_mcp_server/auth/bearer_auth.py new file mode 100644 index 0000000..7489b24 --- /dev/null +++ b/nextcloud_mcp_server/auth/bearer_auth.py @@ -0,0 +1,34 @@ +"""Bearer token authentication for httpx.""" + +from httpx import Auth, Request + + +class BearerAuth(Auth): + """ + Bearer token authentication flow for httpx. + + This auth class adds the Authorization: Bearer header + to all outgoing requests. + """ + + def __init__(self, token: str): + """ + Initialize bearer authentication. + + Args: + token: The bearer token to use for authentication + """ + self.token = token + + def auth_flow(self, request: Request): + """ + Add Authorization header to the request. + + Args: + request: The outgoing HTTP request + + Yields: + The modified request with Authorization header + """ + request.headers["Authorization"] = f"Bearer {self.token}" + yield request diff --git a/nextcloud_mcp_server/auth/client_registration.py b/nextcloud_mcp_server/auth/client_registration.py new file mode 100644 index 0000000..7ae9d28 --- /dev/null +++ b/nextcloud_mcp_server/auth/client_registration.py @@ -0,0 +1,260 @@ +"""Dynamic client registration for Nextcloud OIDC.""" + +import json +import logging +import os +import time +from pathlib import Path +from typing import Any + +import httpx + +logger = logging.getLogger(__name__) + + +class ClientInfo: + """Client registration information.""" + + def __init__( + self, + client_id: str, + client_secret: str, + client_id_issued_at: int, + client_secret_expires_at: int, + redirect_uris: list[str], + ): + self.client_id = client_id + self.client_secret = client_secret + self.client_id_issued_at = client_id_issued_at + self.client_secret_expires_at = client_secret_expires_at + self.redirect_uris = redirect_uris + + @property + def is_expired(self) -> bool: + """Check if the client has expired.""" + return time.time() >= self.client_secret_expires_at + + @property + def expires_soon(self) -> bool: + """Check if client expires within 5 minutes.""" + return time.time() >= (self.client_secret_expires_at - 300) + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for storage.""" + return { + "client_id": self.client_id, + "client_secret": self.client_secret, + "client_id_issued_at": self.client_id_issued_at, + "client_secret_expires_at": self.client_secret_expires_at, + "redirect_uris": self.redirect_uris, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "ClientInfo": + """Create from dictionary.""" + return cls( + client_id=data["client_id"], + client_secret=data["client_secret"], + client_id_issued_at=data["client_id_issued_at"], + client_secret_expires_at=data["client_secret_expires_at"], + redirect_uris=data["redirect_uris"], + ) + + +async def register_client( + nextcloud_url: str, + registration_endpoint: str, + client_name: str = "Nextcloud MCP Server", + redirect_uris: list[str] | None = None, + scopes: str = "openid profile email", +) -> ClientInfo: + """ + Register a new OAuth client with Nextcloud OIDC using dynamic client registration. + + Args: + nextcloud_url: Base URL of the Nextcloud instance + registration_endpoint: Full URL to the registration endpoint + client_name: Name of the client application + redirect_uris: List of redirect URIs (default: http://localhost:8000/oauth/callback) + scopes: Space-separated list of scopes to request + + Returns: + ClientInfo with registration details + + Raises: + httpx.HTTPStatusError: If registration fails + ValueError: If response is invalid + """ + if redirect_uris is None: + redirect_uris = ["http://localhost:8000/oauth/callback"] + + client_metadata = { + "client_name": client_name, + "redirect_uris": redirect_uris, + "token_endpoint_auth_method": "client_secret_post", + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "scope": scopes, + } + + logger.info(f"Registering OAuth client with Nextcloud: {client_name}") + logger.debug(f"Registration endpoint: {registration_endpoint}") + + async with httpx.AsyncClient(timeout=30.0) as client: + try: + response = await client.post( + registration_endpoint, + json=client_metadata, + headers={"Content-Type": "application/json"}, + ) + response.raise_for_status() + + client_info = response.json() + logger.info( + f"Successfully registered client: {client_info.get('client_id')}" + ) + logger.info( + f"Client expires at: {client_info.get('client_secret_expires_at')} " + f"(in {client_info.get('client_secret_expires_at', 0) - int(time.time())} seconds)" + ) + + return ClientInfo( + client_id=client_info["client_id"], + client_secret=client_info["client_secret"], + client_id_issued_at=client_info.get( + "client_id_issued_at", int(time.time()) + ), + client_secret_expires_at=client_info.get( + "client_secret_expires_at", int(time.time()) + 3600 + ), + redirect_uris=client_info.get("redirect_uris", redirect_uris), + ) + + except httpx.HTTPStatusError as e: + logger.error(f"Failed to register client: HTTP {e.response.status_code}") + logger.error(f"Response: {e.response.text}") + raise + except KeyError as e: + logger.error(f"Invalid response from registration endpoint: missing {e}") + raise ValueError(f"Invalid registration response: missing {e}") + + +def load_client_from_file(storage_path: Path) -> ClientInfo | None: + """ + Load client credentials from storage file. + + Args: + storage_path: Path to the JSON file containing client credentials + + Returns: + ClientInfo if file exists and is valid, None otherwise + """ + if not storage_path.exists(): + logger.debug(f"Client storage file not found: {storage_path}") + return None + + try: + with open(storage_path, "r") as f: + data = json.load(f) + + client_info = ClientInfo.from_dict(data) + + if client_info.is_expired: + logger.warning( + f"Stored client has expired (expired at {client_info.client_secret_expires_at})" + ) + return None + + logger.info(f"Loaded client from storage: {client_info.client_id[:16]}...") + if client_info.expires_soon: + logger.warning("Client expires soon (within 5 minutes)") + + return client_info + + except (json.JSONDecodeError, KeyError, ValueError) as e: + logger.error(f"Failed to load client from file: {e}") + return None + + +def save_client_to_file(client_info: ClientInfo, storage_path: Path): + """ + Save client credentials to storage file. + + Args: + client_info: Client information to save + storage_path: Path to save the JSON file + + Raises: + OSError: If file cannot be written + """ + try: + # Create directory if it doesn't exist + storage_path.parent.mkdir(parents=True, exist_ok=True) + + # Write client info + with open(storage_path, "w") as f: + json.dump(client_info.to_dict(), f, indent=2) + + # Set restrictive permissions (owner read/write only) + os.chmod(storage_path, 0o600) + + logger.info(f"Saved client credentials to {storage_path}") + + except OSError as e: + logger.error(f"Failed to save client credentials: {e}") + raise + + +async def load_or_register_client( + nextcloud_url: str, + registration_endpoint: str, + storage_path: str | Path, + client_name: str = "Nextcloud MCP Server", + redirect_uris: list[str] | None = None, + force_register: bool = False, +) -> ClientInfo: + """ + Load client from storage or register a new one if not found/expired. + + This function: + 1. Checks for existing client credentials in storage + 2. Validates the credentials are not expired + 3. Registers a new client if needed + 4. Saves the new client credentials + + Args: + nextcloud_url: Base URL of the Nextcloud instance + registration_endpoint: Full URL to the registration endpoint + storage_path: Path to store client credentials + client_name: Name of the client application + redirect_uris: List of redirect URIs + force_register: Force registration even if valid credentials exist + + Returns: + ClientInfo with valid credentials + + Raises: + httpx.HTTPStatusError: If registration fails + ValueError: If response is invalid + """ + storage_path = Path(storage_path) + + # Try to load existing client unless forced to register + if not force_register: + client_info = load_client_from_file(storage_path) + if client_info: + return client_info + + # Register new client + logger.info("Registering new OAuth client...") + client_info = await register_client( + nextcloud_url=nextcloud_url, + registration_endpoint=registration_endpoint, + client_name=client_name, + redirect_uris=redirect_uris, + ) + + # Save to storage + save_client_to_file(client_info, storage_path) + + return client_info diff --git a/nextcloud_mcp_server/auth/context_helper.py b/nextcloud_mcp_server/auth/context_helper.py new file mode 100644 index 0000000..1c160ce --- /dev/null +++ b/nextcloud_mcp_server/auth/context_helper.py @@ -0,0 +1,54 @@ +"""Helper functions for extracting OAuth context from MCP requests.""" + +import logging + +from mcp.server.fastmcp import Context +from mcp.server.auth.provider import AccessToken + +from ..client import NextcloudClient + +logger = logging.getLogger(__name__) + + +def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient: + """ + Extract authenticated user context from MCP request and create NextcloudClient. + + This function retrieves the OAuth access token from the MCP context, + extracts the username from the token's resource field (where we stored it + during token verification), and creates a NextcloudClient with bearer auth. + + Args: + ctx: MCP request context containing session info + base_url: Nextcloud base URL + + Returns: + NextcloudClient configured with bearer token auth + + Raises: + AttributeError: If context doesn't contain expected OAuth session data + ValueError: If username cannot be extracted from token + """ + try: + # Get AccessToken from MCP session (set by TokenVerifier) + access_token: AccessToken = ctx.request_context.session.access_token + + # Extract username from resource field (RFC 8707) + # We stored the username here during token verification + username = access_token.resource + + if not username: + logger.error("No username found in access token resource field") + raise ValueError("Username not available in OAuth token context") + + logger.debug(f"Creating OAuth NextcloudClient for user: {username}") + + # Create client with bearer token + return NextcloudClient.from_token( + base_url=base_url, token=access_token.token, username=username + ) + + except AttributeError as e: + logger.error(f"Failed to extract OAuth context: {e}") + logger.error("This may indicate the server is not running in OAuth mode") + raise diff --git a/nextcloud_mcp_server/auth/token_verifier.py b/nextcloud_mcp_server/auth/token_verifier.py new file mode 100644 index 0000000..afa4ac8 --- /dev/null +++ b/nextcloud_mcp_server/auth/token_verifier.py @@ -0,0 +1,207 @@ +"""Token verification using Nextcloud OIDC userinfo endpoint.""" + +import logging +import time +from typing import Any + +import httpx +from mcp.server.auth.provider import AccessToken, TokenVerifier + +logger = logging.getLogger(__name__) + + +class NextcloudTokenVerifier(TokenVerifier): + """ + Validates access tokens using Nextcloud OIDC userinfo endpoint. + + This verifier: + 1. Calls the userinfo endpoint with the bearer token + 2. Caches successful responses to avoid repeated API calls + 3. Extracts username from the 'sub' or 'preferred_username' claim + 4. Optionally supports JWT validation for performance (future enhancement) + + The userinfo endpoint validates the token and returns user claims if valid, + or returns HTTP 400/401 if the token is invalid or expired. + """ + + def __init__( + self, + nextcloud_host: str, + userinfo_uri: str, + cache_ttl: int = 3600, + ): + """ + Initialize the token verifier. + + Args: + nextcloud_host: Base URL of the Nextcloud instance (e.g., https://cloud.example.com) + userinfo_uri: Full URL to the userinfo endpoint + cache_ttl: Time-to-live for cached tokens in seconds (default: 3600) + """ + self.nextcloud_host = nextcloud_host.rstrip("/") + self.userinfo_uri = userinfo_uri + self.cache_ttl = cache_ttl + + # Cache: token -> (userinfo, expiry_timestamp) + self._token_cache: dict[str, tuple[dict[str, Any], float]] = {} + + # HTTP client for userinfo requests + self._client = httpx.AsyncClient(timeout=10.0) + + async def verify_token(self, token: str) -> AccessToken | None: + """ + Verify a bearer token by calling the userinfo endpoint. + + This method: + 1. Checks the cache first for recent validations + 2. Calls the userinfo endpoint if not cached + 3. Returns AccessToken with username stored in metadata + + Args: + token: The bearer token to verify + + Returns: + AccessToken if valid, None if invalid or expired + """ + # Check cache first + cached = self._get_cached_token(token) + if cached: + logger.debug("Token found in cache") + return cached + + # Validate via userinfo endpoint + try: + return await self._verify_via_userinfo(token) + except Exception as e: + logger.warning(f"Token verification failed: {e}") + return None + + async def _verify_via_userinfo(self, token: str) -> AccessToken | None: + """ + Validate token by calling the userinfo endpoint. + + Args: + token: The bearer token to verify + + Returns: + AccessToken if valid, None otherwise + """ + try: + response = await self._client.get( + self.userinfo_uri, headers={"Authorization": f"Bearer {token}"} + ) + + if response.status_code == 200: + userinfo = response.json() + logger.debug( + f"Token validated successfully for user: {userinfo.get('sub')}" + ) + + # Cache the result + expiry = time.time() + self.cache_ttl + self._token_cache[token] = (userinfo, expiry) + + # Create AccessToken with username in resource field (workaround for MCP SDK) + username = userinfo.get("sub") or userinfo.get("preferred_username") + if not username: + logger.error("No username found in userinfo response") + return None + + return AccessToken( + token=token, + client_id="", # Not available from userinfo + scopes=self._extract_scopes(userinfo), + expires_at=int(expiry), + resource=username, # Store username in resource field (RFC 8707) + ) + + elif response.status_code in (400, 401, 403): + logger.info(f"Token validation failed: HTTP {response.status_code}") + return None + else: + logger.warning( + f"Unexpected response from userinfo: {response.status_code}" + ) + return None + + except httpx.TimeoutException: + logger.error("Timeout while validating token via userinfo endpoint") + return None + except httpx.RequestError as e: + logger.error(f"Network error while validating token: {e}") + return None + except Exception as e: + logger.error(f"Unexpected error during token validation: {e}") + return None + + def _get_cached_token(self, token: str) -> AccessToken | None: + """ + Retrieve a token from cache if not expired. + + Args: + token: The bearer token to look up + + Returns: + AccessToken if cached and valid, None otherwise + """ + if token not in self._token_cache: + return None + + userinfo, expiry = self._token_cache[token] + + # Check if expired + if time.time() >= expiry: + logger.debug("Cached token expired, removing from cache") + del self._token_cache[token] + return None + + # Return cached AccessToken + username = userinfo.get("sub") or userinfo.get("preferred_username") + return AccessToken( + token=token, + client_id="", + scopes=self._extract_scopes(userinfo), + expires_at=int(expiry), + resource=username, + ) + + def _extract_scopes(self, userinfo: dict[str, Any]) -> list[str]: + """ + Extract scopes from userinfo response. + + Since the userinfo response doesn't include the original scopes, + we infer them from the claims present in the response. + + Args: + userinfo: The userinfo response dictionary + + Returns: + List of inferred scopes + """ + scopes = ["openid"] # Always present + + if "email" in userinfo: + scopes.append("email") + + if any( + key in userinfo for key in ["name", "given_name", "family_name", "picture"] + ): + scopes.append("profile") + + if "roles" in userinfo: + scopes.append("roles") + + if "groups" in userinfo: + scopes.append("groups") + + return scopes + + def clear_cache(self): + """Clear the token cache.""" + self._token_cache.clear() + logger.debug("Token cache cleared") + + async def close(self): + """Cleanup resources.""" + await self._client.aclose() + logger.debug("Token verifier closed") diff --git a/nextcloud_mcp_server/client/__init__.py b/nextcloud_mcp_server/client/__init__.py index b6879c6..621a379 100644 --- a/nextcloud_mcp_server/client/__init__.py +++ b/nextcloud_mcp_server/client/__init__.py @@ -85,6 +85,23 @@ class NextcloudClient: # Pass username to constructor return cls(base_url=host, username=username, auth=BasicAuth(username, password)) + @classmethod + def from_token(cls, base_url: str, token: str, username: str): + """Create NextcloudClient with OAuth bearer token. + + Args: + base_url: Nextcloud base URL + token: OAuth access token + username: Nextcloud username + + Returns: + NextcloudClient configured with bearer token authentication + """ + from ..auth import BearerAuth + + logger.info(f"Creating NC Client for user '{username}' using OAuth token") + return cls(base_url=base_url, username=username, auth=BearerAuth(token)) + async def capabilities(self): response = await self._client.get( "/ocs/v2.php/cloud/capabilities", diff --git a/nextcloud_mcp_server/context.py b/nextcloud_mcp_server/context.py new file mode 100644 index 0000000..fad2bcc --- /dev/null +++ b/nextcloud_mcp_server/context.py @@ -0,0 +1,51 @@ +"""Helper functions for accessing context in MCP tools.""" + +from mcp.server.fastmcp import Context + +from nextcloud_mcp_server.client import NextcloudClient + + +def get_client(ctx: Context) -> NextcloudClient: + """ + Get the appropriate Nextcloud client based on authentication mode. + + In BasicAuth mode, returns the shared client from lifespan context. + In OAuth mode, creates a new client per-request using the OAuth context. + + This function automatically detects the authentication mode by checking + the type of the lifespan context. + + Args: + ctx: MCP request context + + Returns: + NextcloudClient configured for the current authentication mode + + Raises: + AttributeError: If context doesn't contain expected data + + Example: + ```python + @mcp.tool() + async def my_tool(ctx: Context): + client = get_client(ctx) + return await client.capabilities() + ``` + """ + lifespan_ctx = ctx.request_context.lifespan_context + + # Try BasicAuth mode first (has 'client' attribute) + if hasattr(lifespan_ctx, "client"): + return lifespan_ctx.client + + # OAuth mode (has 'nextcloud_host' attribute) + if hasattr(lifespan_ctx, "nextcloud_host"): + from nextcloud_mcp_server.auth import get_client_from_context + + return get_client_from_context(ctx, lifespan_ctx.nextcloud_host) + + # Unknown context type + raise AttributeError( + f"Lifespan context does not have 'client' or 'nextcloud_host' attribute. " + f"Type: {type(lifespan_ctx)}" + ) diff --git a/nextcloud_mcp_server/server/calendar.py b/nextcloud_mcp_server/server/calendar.py index c68c73d..bf5af43 100644 --- a/nextcloud_mcp_server/server/calendar.py +++ b/nextcloud_mcp_server/server/calendar.py @@ -4,7 +4,7 @@ from typing import Optional from mcp.server.fastmcp import Context, FastMCP -from nextcloud_mcp_server.client import NextcloudClient +from nextcloud_mcp_server.context import get_client from nextcloud_mcp_server.models.calendar import ( Calendar, ListCalendarsResponse, @@ -18,7 +18,7 @@ def configure_calendar_tools(mcp: FastMCP): @mcp.tool() async def nc_calendar_list_calendars(ctx: Context) -> ListCalendarsResponse: """List all available calendars for the user""" - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) calendars_data = await client.calendar.list_calendars() calendars = [Calendar(**cal_data) for cal_data in calendars_data] @@ -74,7 +74,7 @@ def configure_calendar_tools(mcp: FastMCP): Returns: Dict with event creation result """ - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) event_data = { "title": title, @@ -133,7 +133,7 @@ def configure_calendar_tools(mcp: FastMCP): Returns: List of events matching the filters """ - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) # Convert YYYY-MM-DD format dates to datetime objects start_datetime = None @@ -207,7 +207,7 @@ def configure_calendar_tools(mcp: FastMCP): ctx: Context, ): """Get detailed information about a specific event""" - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) event_data, etag = await client.calendar.get_event(calendar_name, event_uid) return event_data @@ -240,7 +240,7 @@ def configure_calendar_tools(mcp: FastMCP): etag: str = "", ): """Update any aspect of an existing event""" - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) # Build update data with only non-None values event_data = {} @@ -290,7 +290,7 @@ def configure_calendar_tools(mcp: FastMCP): ctx: Context, ): """Delete a calendar event""" - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) return await client.calendar.delete_event(calendar_name, event_uid) @mcp.tool() @@ -332,7 +332,7 @@ def configure_calendar_tools(mcp: FastMCP): Returns: Dict with meeting creation result """ - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) # Combine date and time for start_datetime start_datetime = f"{date}T{time}:00" @@ -366,7 +366,7 @@ def configure_calendar_tools(mcp: FastMCP): limit: int = 10, ): """Get upcoming events in next N days""" - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) now = dt.datetime.now() end_datetime = now + dt.timedelta(days=days_ahead) @@ -435,7 +435,7 @@ def configure_calendar_tools(mcp: FastMCP): Returns: List of available time slots with start/end times and duration """ - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) # Parse attendees attendee_list = [] @@ -536,7 +536,7 @@ def configure_calendar_tools(mcp: FastMCP): Returns: Summary of operation results including counts and details """ - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) if operation not in ["update", "delete", "move"]: raise ValueError("Operation must be 'update', 'delete', or 'move'") @@ -758,7 +758,7 @@ def configure_calendar_tools(mcp: FastMCP): Returns: Result of the calendar management operation """ - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) if action == "list": return await client.calendar.list_calendars() diff --git a/nextcloud_mcp_server/server/contacts.py b/nextcloud_mcp_server/server/contacts.py index 78a63ef..b6d2871 100644 --- a/nextcloud_mcp_server/server/contacts.py +++ b/nextcloud_mcp_server/server/contacts.py @@ -2,7 +2,7 @@ import logging from mcp.server.fastmcp import Context, FastMCP -from nextcloud_mcp_server.client import NextcloudClient +from nextcloud_mcp_server.context import get_client logger = logging.getLogger(__name__) @@ -12,13 +12,13 @@ def configure_contacts_tools(mcp: FastMCP): @mcp.tool() async def nc_contacts_list_addressbooks(ctx: Context): """List all addressbooks for the user.""" - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) return await client.contacts.list_addressbooks() @mcp.tool() async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str): """List all contacts in the specified addressbook.""" - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) return await client.contacts.list_contacts(addressbook=addressbook) @mcp.tool() @@ -31,7 +31,7 @@ def configure_contacts_tools(mcp: FastMCP): name: The name of the addressbook. display_name: The display name of the addressbook. """ - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) return await client.contacts.create_addressbook( name=name, display_name=display_name ) @@ -39,7 +39,7 @@ def configure_contacts_tools(mcp: FastMCP): @mcp.tool() async def nc_contacts_delete_addressbook(ctx: Context, *, name: str): """Delete an addressbook.""" - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) return await client.contacts.delete_addressbook(name=name) @mcp.tool() @@ -53,7 +53,7 @@ def configure_contacts_tools(mcp: FastMCP): uid: The unique ID for the contact. contact_data: A dictionary with the contact's details, e.g. {"fn": "John Doe", "email": "john.doe@example.com"}. """ - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) return await client.contacts.create_contact( addressbook=addressbook, uid=uid, contact_data=contact_data ) @@ -61,7 +61,7 @@ def configure_contacts_tools(mcp: FastMCP): @mcp.tool() async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str): """Delete a contact.""" - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) return await client.contacts.delete_contact(addressbook=addressbook, uid=uid) @mcp.tool() @@ -76,7 +76,7 @@ def configure_contacts_tools(mcp: FastMCP): contact_data: A dictionary with the contact's updated details, e.g. {"fn": "Jane Doe", "email": "jane.doe@example.com"}. etag: Optional ETag for optimistic concurrency control. """ - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) return await client.contacts.update_contact( addressbook=addressbook, uid=uid, contact_data=contact_data, etag=etag ) diff --git a/nextcloud_mcp_server/server/deck.py b/nextcloud_mcp_server/server/deck.py index 8d2ddad..0b0eb87 100644 --- a/nextcloud_mcp_server/server/deck.py +++ b/nextcloud_mcp_server/server/deck.py @@ -3,7 +3,7 @@ from typing import Optional from mcp.server.fastmcp import Context, FastMCP -from nextcloud_mcp_server.client import NextcloudClient +from nextcloud_mcp_server.context import get_client from nextcloud_mcp_server.models.deck import ( DeckBoard, DeckStack, @@ -30,7 +30,7 @@ def configure_deck_tools(mcp: FastMCP): """List all Nextcloud Deck boards""" ctx: Context = mcp.get_context() await ctx.warning("This message is deprecated, use the deck_get_board instead") - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) boards = await client.deck.get_boards() return [board.model_dump() for board in boards] @@ -41,7 +41,7 @@ def configure_deck_tools(mcp: FastMCP): await ctx.warning( "This resource is deprecated, use the deck_get_board tool instead" ) - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) board = await client.deck.get_board(board_id) return board.model_dump() @@ -52,7 +52,7 @@ def configure_deck_tools(mcp: FastMCP): await ctx.warning( "This resource is deprecated, use the deck_get_stacks tool instead" ) - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) stacks = await client.deck.get_stacks(board_id) return [stack.model_dump() for stack in stacks] @@ -63,7 +63,7 @@ def configure_deck_tools(mcp: FastMCP): await ctx.warning( "This resource is deprecated, use the deck_get_stack tool instead" ) - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) stack = await client.deck.get_stack(board_id, stack_id) return stack.model_dump() @@ -74,7 +74,7 @@ def configure_deck_tools(mcp: FastMCP): await ctx.warning( "This resource is deprecated, use the deck_get_cards tool instead" ) - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) stack = await client.deck.get_stack(board_id, stack_id) if stack.cards: return [card.model_dump() for card in stack.cards] @@ -87,7 +87,7 @@ def configure_deck_tools(mcp: FastMCP): await ctx.warning( "This resource is deprecated, use the deck_get_card tool instead" ) - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) card = await client.deck.get_card(board_id, stack_id, card_id) return card.model_dump() @@ -98,7 +98,7 @@ def configure_deck_tools(mcp: FastMCP): await ctx.warning( "This resource is deprecated, use the deck_get_labels tool instead" ) - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) board = await client.deck.get_board(board_id) return [label.model_dump() for label in board.labels] @@ -109,7 +109,7 @@ def configure_deck_tools(mcp: FastMCP): await ctx.warning( "This resource is deprecated, use the deck_get_label tool instead" ) - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) label = await client.deck.get_label(board_id, label_id) return label.model_dump() @@ -118,28 +118,28 @@ def configure_deck_tools(mcp: FastMCP): @mcp.tool() async def deck_get_boards(ctx: Context) -> list[DeckBoard]: """Get all Nextcloud Deck boards""" - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) boards = await client.deck.get_boards() return boards @mcp.tool() async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard: """Get details of a specific Nextcloud Deck board""" - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) board = await client.deck.get_board(board_id) return board @mcp.tool() async def deck_get_stacks(ctx: Context, board_id: int) -> list[DeckStack]: """Get all stacks in a Nextcloud Deck board""" - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) stacks = await client.deck.get_stacks(board_id) return stacks @mcp.tool() async def deck_get_stack(ctx: Context, board_id: int, stack_id: int) -> DeckStack: """Get details of a specific Nextcloud Deck stack""" - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) stack = await client.deck.get_stack(board_id, stack_id) return stack @@ -148,7 +148,7 @@ def configure_deck_tools(mcp: FastMCP): ctx: Context, board_id: int, stack_id: int ) -> list[DeckCard]: """Get all cards in a Nextcloud Deck stack""" - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) stack = await client.deck.get_stack(board_id, stack_id) if stack.cards: return stack.cards @@ -159,21 +159,21 @@ def configure_deck_tools(mcp: FastMCP): ctx: Context, board_id: int, stack_id: int, card_id: int ) -> DeckCard: """Get details of a specific Nextcloud Deck card""" - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) card = await client.deck.get_card(board_id, stack_id, card_id) return card @mcp.tool() async def deck_get_labels(ctx: Context, board_id: int) -> list[DeckLabel]: """Get all labels in a Nextcloud Deck board""" - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) board = await client.deck.get_board(board_id) return board.labels @mcp.tool() async def deck_get_label(ctx: Context, board_id: int, label_id: int) -> DeckLabel: """Get details of a specific Nextcloud Deck label""" - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) label = await client.deck.get_label(board_id, label_id) return label @@ -189,7 +189,7 @@ def configure_deck_tools(mcp: FastMCP): title: The title of the new board color: The hexadecimal color of the new board (e.g. FF0000) """ - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) board = await client.deck.create_board(title, color) return CreateBoardResponse(id=board.id, title=board.title, color=board.color) @@ -206,7 +206,7 @@ def configure_deck_tools(mcp: FastMCP): title: The title of the new stack order: Order for sorting the stacks """ - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) stack = await client.deck.create_stack(board_id, title, order) return CreateStackResponse(id=stack.id, title=stack.title, order=stack.order) @@ -226,7 +226,7 @@ def configure_deck_tools(mcp: FastMCP): title: New title for the stack order: New order for the stack """ - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) await client.deck.update_stack(board_id, stack_id, title, order) return StackOperationResponse( success=True, @@ -245,7 +245,7 @@ def configure_deck_tools(mcp: FastMCP): board_id: The ID of the board stack_id: The ID of the stack """ - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) await client.deck.delete_stack(board_id, stack_id) return StackOperationResponse( success=True, @@ -277,7 +277,7 @@ def configure_deck_tools(mcp: FastMCP): description: Description of the card duedate: Due date of the card (ISO-8601 format) """ - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) card = await client.deck.create_card( board_id, stack_id, title, type, order, description, duedate ) @@ -318,7 +318,7 @@ def configure_deck_tools(mcp: FastMCP): archived: Whether the card should be archived done: Completion date for the card (ISO-8601 format) """ - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) await client.deck.update_card( board_id, stack_id, @@ -351,7 +351,7 @@ def configure_deck_tools(mcp: FastMCP): stack_id: The ID of the stack card_id: The ID of the card """ - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) await client.deck.delete_card(board_id, stack_id, card_id) return CardOperationResponse( success=True, @@ -372,7 +372,7 @@ def configure_deck_tools(mcp: FastMCP): stack_id: The ID of the stack card_id: The ID of the card """ - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) await client.deck.archive_card(board_id, stack_id, card_id) return CardOperationResponse( success=True, @@ -393,7 +393,7 @@ def configure_deck_tools(mcp: FastMCP): stack_id: The ID of the stack card_id: The ID of the card """ - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) await client.deck.unarchive_card(board_id, stack_id, card_id) return CardOperationResponse( success=True, @@ -421,7 +421,7 @@ def configure_deck_tools(mcp: FastMCP): order: New position in the target stack target_stack_id: The ID of the target stack """ - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) await client.deck.reorder_card( board_id, stack_id, card_id, order, target_stack_id ) @@ -445,7 +445,7 @@ def configure_deck_tools(mcp: FastMCP): title: The title of the new label color: The color of the new label (hex format without #) """ - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) label = await client.deck.create_label(board_id, title, color) return CreateLabelResponse(id=label.id, title=label.title, color=label.color) @@ -465,7 +465,7 @@ def configure_deck_tools(mcp: FastMCP): title: New title for the label color: New color for the label (hex format without #) """ - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) await client.deck.update_label(board_id, label_id, title, color) return LabelOperationResponse( success=True, @@ -484,7 +484,7 @@ def configure_deck_tools(mcp: FastMCP): board_id: The ID of the board label_id: The ID of the label """ - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) await client.deck.delete_label(board_id, label_id) return LabelOperationResponse( success=True, @@ -506,7 +506,7 @@ def configure_deck_tools(mcp: FastMCP): card_id: The ID of the card label_id: The ID of the label to assign """ - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) await client.deck.assign_label_to_card(board_id, stack_id, card_id, label_id) return CardOperationResponse( success=True, @@ -528,7 +528,7 @@ def configure_deck_tools(mcp: FastMCP): card_id: The ID of the card label_id: The ID of the label to remove """ - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) await client.deck.remove_label_from_card(board_id, stack_id, card_id, label_id) return CardOperationResponse( success=True, @@ -551,7 +551,7 @@ def configure_deck_tools(mcp: FastMCP): card_id: The ID of the card user_id: The user ID to assign """ - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) await client.deck.assign_user_to_card(board_id, stack_id, card_id, user_id) return CardOperationResponse( success=True, @@ -573,7 +573,7 @@ def configure_deck_tools(mcp: FastMCP): card_id: The ID of the card user_id: The user ID to unassign """ - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) await client.deck.unassign_user_from_card(board_id, stack_id, card_id, user_id) return CardOperationResponse( success=True, diff --git a/nextcloud_mcp_server/server/notes.py b/nextcloud_mcp_server/server/notes.py index 37ab74a..aad9e8e 100644 --- a/nextcloud_mcp_server/server/notes.py +++ b/nextcloud_mcp_server/server/notes.py @@ -5,7 +5,7 @@ from mcp.types import ErrorData from mcp.server.fastmcp import Context, FastMCP -from nextcloud_mcp_server.client import NextcloudClient +from nextcloud_mcp_server.context import get_client from nextcloud_mcp_server.models.notes import ( Note, NotesSettings, @@ -27,7 +27,7 @@ def configure_notes_tools(mcp: FastMCP): ctx: Context = ( mcp.get_context() ) # https://github.com/modelcontextprotocol/python-sdk/issues/244 - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) settings_data = await client.notes.get_settings() return NotesSettings(**settings_data) @@ -35,7 +35,7 @@ def configure_notes_tools(mcp: FastMCP): async def nc_notes_get_attachment_resource(note_id: int, attachment_filename: str): """Get a specific attachment from a note""" ctx: Context = mcp.get_context() - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) # Assuming a method get_note_attachment exists in the client # This method should return the raw content and determine the mime type content, mime_type = await client.webdav.get_note_attachment( @@ -57,7 +57,7 @@ def configure_notes_tools(mcp: FastMCP): """Get user note using note id""" ctx: Context = mcp.get_context() - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) try: note_data = await client.notes.get_note(note_id) return Note(**note_data) @@ -81,7 +81,7 @@ def configure_notes_tools(mcp: FastMCP): title: str, content: str, category: str, ctx: Context ) -> CreateNoteResponse: """Create a new note""" - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) try: note_data = await client.notes.create_note( title=title, @@ -133,7 +133,7 @@ def configure_notes_tools(mcp: FastMCP): If the note has been modified by someone else since you retrieved it, the update will fail with a 412 error.""" logger.info("Updating note %s", note_id) - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) try: note_data = await client.notes.update( note_id=note_id, @@ -183,7 +183,7 @@ def configure_notes_tools(mcp: FastMCP): between the note and what will be appended.""" logger.info("Appending content to note %s", note_id) - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) try: note_data = await client.notes.append_content( note_id=note_id, content=content @@ -220,7 +220,7 @@ def configure_notes_tools(mcp: FastMCP): @mcp.tool() async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse: """Search notes by title or content, returning only id, title, and category.""" - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) try: search_results_raw = await client.notes_search_notes(query=query) @@ -261,7 +261,7 @@ def configure_notes_tools(mcp: FastMCP): @mcp.tool() async def nc_notes_get_note(note_id: int, ctx: Context) -> Note: """Get a specific note by its ID""" - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) try: note_data = await client.notes.get_note(note_id) return Note(**note_data) @@ -285,7 +285,7 @@ def configure_notes_tools(mcp: FastMCP): note_id: int, attachment_filename: str, ctx: Context ) -> dict[str, str]: """Get a specific attachment from a note""" - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) try: content, mime_type = await client.webdav.get_note_attachment( note_id=note_id, filename=attachment_filename @@ -322,7 +322,7 @@ def configure_notes_tools(mcp: FastMCP): async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse: """Delete a note permanently""" logger.info("Deleting note %s", note_id) - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) try: await client.notes.delete_note(note_id) return DeleteNoteResponse( diff --git a/nextcloud_mcp_server/server/tables.py b/nextcloud_mcp_server/server/tables.py index f9f7699..90f985a 100644 --- a/nextcloud_mcp_server/server/tables.py +++ b/nextcloud_mcp_server/server/tables.py @@ -2,7 +2,7 @@ import logging from mcp.server.fastmcp import Context, FastMCP -from nextcloud_mcp_server.client import NextcloudClient +from nextcloud_mcp_server.context import get_client logger = logging.getLogger(__name__) @@ -12,13 +12,13 @@ def configure_tables_tools(mcp: FastMCP): @mcp.tool() async def nc_tables_list_tables(ctx: Context): """List all tables available to the user""" - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) return await client.tables.list_tables() @mcp.tool() async def nc_tables_get_schema(table_id: int, ctx: Context): """Get the schema/structure of a specific table including columns and views""" - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) return await client.tables.get_table_schema(table_id) @mcp.tool() @@ -29,7 +29,7 @@ def configure_tables_tools(mcp: FastMCP): offset: int | None = None, ): """Read rows from a table with optional pagination""" - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) return await client.tables.get_table_rows(table_id, limit, offset) @mcp.tool() @@ -38,7 +38,7 @@ def configure_tables_tools(mcp: FastMCP): Data should be a dictionary mapping column IDs to values, e.g. {1: "text", 2: 42} """ - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) return await client.tables.create_row(table_id, data) @mcp.tool() @@ -47,11 +47,11 @@ def configure_tables_tools(mcp: FastMCP): Data should be a dictionary mapping column IDs to new values, e.g. {1: "new text", 2: 99} """ - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) return await client.tables.update_row(row_id, data) @mcp.tool() async def nc_tables_delete_row(row_id: int, ctx: Context): """Delete a row from a table""" - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) return await client.tables.delete_row(row_id) diff --git a/nextcloud_mcp_server/server/webdav.py b/nextcloud_mcp_server/server/webdav.py index 6fa6db6..6241ef6 100644 --- a/nextcloud_mcp_server/server/webdav.py +++ b/nextcloud_mcp_server/server/webdav.py @@ -2,7 +2,7 @@ import logging from mcp.server.fastmcp import Context, FastMCP -from nextcloud_mcp_server.client import NextcloudClient +from nextcloud_mcp_server.context import get_client logger = logging.getLogger(__name__) @@ -26,7 +26,7 @@ def configure_webdav_tools(mcp: FastMCP): # List a specific folder await nc_webdav_list_directory("Documents/Projects") """ - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) return await client.webdav.list_directory(path) @mcp.tool() @@ -49,7 +49,7 @@ def configure_webdav_tools(mcp: FastMCP): result = await nc_webdav_read_file("Images/photo.jpg") logger.info(result['encoding']) # 'base64' """ - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) content, content_type = await client.webdav.read_file(path) # For text files, decode content for easier viewing @@ -97,7 +97,7 @@ def configure_webdav_tools(mcp: FastMCP): # Write binary data (base64 encoded) await nc_webdav_write_file("files/data.bin", base64_content, "application/octet-stream;base64") """ - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) # Handle base64 encoded content if content_type and "base64" in content_type.lower(): @@ -127,7 +127,7 @@ def configure_webdav_tools(mcp: FastMCP): # Create nested directories (parent must exist) await nc_webdav_create_directory("Projects/MyApp/docs") """ - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) return await client.webdav.create_directory(path) @mcp.tool() @@ -147,7 +147,7 @@ def configure_webdav_tools(mcp: FastMCP): # Delete a directory (will delete all contents) await nc_webdav_delete_resource("temp_folder") """ - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) return await client.webdav.delete_resource(path) @mcp.tool() @@ -177,7 +177,7 @@ def configure_webdav_tools(mcp: FastMCP): # Move and overwrite if destination exists await nc_webdav_move_resource("document.txt", "Archive/document.txt", overwrite=True) """ - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) return await client.webdav.move_resource( source_path, destination_path, overwrite ) @@ -209,7 +209,7 @@ def configure_webdav_tools(mcp: FastMCP): # Copy and overwrite if destination exists await nc_webdav_copy_resource("document.txt", "Backup/document.txt", overwrite=True) """ - client: NextcloudClient = ctx.request_context.lifespan_context.client + client = get_client(ctx) return await client.webdav.copy_resource( source_path, destination_path, overwrite ) diff --git a/scripts/test_oauth_tools.py b/scripts/test_oauth_tools.py new file mode 100644 index 0000000..994cd52 --- /dev/null +++ b/scripts/test_oauth_tools.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Test script to verify OAuth MCP tools work correctly. + +This script connects to the OAuth MCP server and tests tool execution. +Note: This currently requires a valid OAuth token, which must be obtained +through the browser-based OAuth flow. +""" + +import asyncio +import sys + +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + + +async def test_oauth_mcp_tools(): + """Test OAuth MCP server tools.""" + print("Connecting to OAuth MCP server on port 8001...") + + streamable_context = streamablehttp_client("http://127.0.0.1:8001/mcp") + session_context = None + + try: + read_stream, write_stream, _ = await streamable_context.__aenter__() + session_context = ClientSession(read_stream, write_stream) + session = await session_context.__aenter__() + + print("Initializing session...") + await session.initialize() + print("✓ Session initialized successfully") + + # List available tools + print("\nListing available tools...") + result = await session.list_tools() + print(f"✓ Found {len(result.tools)} tools") + + for tool in result.tools[:5]: # Show first 5 + print(f" - {tool.name}: {tool.description}") + + if len(result.tools) > 5: + print(f" ... and {len(result.tools) - 5} more") + + # Try to call a simple tool + print("\nTesting tool execution...") + print("Note: Tool execution will fail without a valid OAuth token") + print(" (OAuth token must be obtained through browser flow)") + + try: + # Try to list tables (this will fail without OAuth token) + response = await session.call_tool("nc_tables_list_tables", {}) + print(f"✓ Tool executed successfully: {response}") + except Exception as e: + print(f"✗ Tool execution failed (expected without OAuth token): {e}") + print("\nTo use OAuth tools, you need to:") + print(" 1. Implement the browser-based OAuth authorization flow") + print(" 2. Obtain an access token from Nextcloud OIDC") + print(" 3. Include the token in the Authorization header") + + return True + + except Exception as e: + print(f"✗ Error: {e}") + import traceback + + traceback.print_exc() + return False + + finally: + # Clean up + if session_context is not None: + try: + await session_context.__aexit__(None, None, None) + except Exception: + pass + + try: + await streamable_context.__aexit__(None, None, None) + except Exception: + pass + + +if __name__ == "__main__": + print("OAuth MCP Server Tool Test") + print("=" * 50) + + success = asyncio.run(test_oauth_mcp_tools()) + + print("\n" + "=" * 50) + if success: + print("✓ Test completed (tools accessible)") + sys.exit(0) + else: + print("✗ Test failed") + sys.exit(1) diff --git a/scripts/verify_oidc.py b/scripts/verify_oidc.py new file mode 100755 index 0000000..fff4c5e --- /dev/null +++ b/scripts/verify_oidc.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +""" +Verification script for Nextcloud OIDC implementation. + +This script tests the OIDC endpoints to understand token format and capabilities. +Usage: python scripts/verify_oidc.py +""" + +import asyncio +import json +import sys + +import httpx + + +class NextcloudOIDCVerifier: + """Verify Nextcloud OIDC implementation details.""" + + def __init__(self, base_url: str): + self.base_url = base_url.rstrip("/") + self.client = httpx.AsyncClient(follow_redirects=True, timeout=30.0) + + async def close(self): + await self.client.aclose() + + async def get_discovery(self) -> dict: + """Fetch OIDC discovery document.""" + print(f"\n{'=' * 60}") + print("1. OIDC Discovery Endpoint") + print(f"{'=' * 60}") + + url = f"{self.base_url}/.well-known/openid-configuration" + print(f"URL: {url}") + + try: + response = await self.client.get(url) + response.raise_for_status() + discovery = response.json() + + print("\n✓ Discovery endpoint successful") + print(f"\nIssuer: {discovery.get('issuer')}") + print(f"Authorization endpoint: {discovery.get('authorization_endpoint')}") + print(f"Token endpoint: {discovery.get('token_endpoint')}") + print(f"Userinfo endpoint: {discovery.get('userinfo_endpoint')}") + print(f"JWKS URI: {discovery.get('jwks_uri')}") + print( + f"Registration endpoint: {discovery.get('registration_endpoint', 'NOT AVAILABLE')}" + ) + + print( + f"\nSupported scopes: {', '.join(discovery.get('scopes_supported', []))}" + ) + print( + f"Response types: {', '.join(discovery.get('response_types_supported', []))}" + ) + print( + f"Grant types: {', '.join(discovery.get('grant_types_supported', []))}" + ) + + return discovery + + except httpx.HTTPStatusError as e: + print(f"\n✗ Discovery failed: HTTP {e.response.status_code}") + print(f"Response: {e.response.text}") + sys.exit(1) + except Exception as e: + print(f"\n✗ Discovery failed: {e}") + sys.exit(1) + + async def get_jwks(self, jwks_uri: str) -> dict: + """Fetch JWKS to check if JWT tokens are supported.""" + print(f"\n{'=' * 60}") + print("2. JWKS Endpoint (JWT Support)") + print(f"{'=' * 60}") + + print(f"URL: {jwks_uri}") + + try: + response = await self.client.get(jwks_uri) + response.raise_for_status() + jwks = response.json() + + print("\n✓ JWKS endpoint successful") + print(f"Number of keys: {len(jwks.get('keys', []))}") + + for idx, key in enumerate(jwks.get("keys", []), 1): + print(f"\nKey {idx}:") + print(f" - Key type: {key.get('kty')}") + print(f" - Algorithm: {key.get('alg')}") + print(f" - Use: {key.get('use', 'N/A')}") + print(f" - Key ID: {key.get('kid', 'N/A')}") + + return jwks + + except Exception as e: + print(f"\n✗ JWKS failed: {e}") + return {} + + async def test_dynamic_registration( + self, registration_endpoint: str | None + ) -> dict | None: + """Test dynamic client registration.""" + print(f"\n{'=' * 60}") + print("3. Dynamic Client Registration") + print(f"{'=' * 60}") + + if not registration_endpoint: + print("✗ Dynamic registration not available (not in discovery)") + return None + + print(f"URL: {registration_endpoint}") + + client_metadata = { + "client_name": "Nextcloud MCP Server Test", + "redirect_uris": ["http://localhost:8000/oauth/callback"], + "token_endpoint_auth_method": "client_secret_post", + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "scope": "openid profile email roles groups", + } + + print("\nRegistration payload:") + print(json.dumps(client_metadata, indent=2)) + + try: + response = await self.client.post( + registration_endpoint, + json=client_metadata, + headers={"Content-Type": "application/json"}, + ) + response.raise_for_status() + client_info = response.json() + + print("\n✓ Dynamic registration successful") + print(f"\nClient ID: {client_info.get('client_id')}") + print(f"Client Secret: {client_info.get('client_secret', 'N/A')[:20]}...") + print( + f"Client ID issued at: {client_info.get('client_id_issued_at', 'N/A')}" + ) + print( + f"Client secret expires at: {client_info.get('client_secret_expires_at', 'Never')}" + ) + + # Save for later use + with open("/tmp/nextcloud_oidc_client.json", "w") as f: + json.dump(client_info, f, indent=2) + print("\n✓ Client credentials saved to /tmp/nextcloud_oidc_client.json") + + return client_info + + except httpx.HTTPStatusError as e: + print(f"\n✗ Dynamic registration failed: HTTP {e.response.status_code}") + print(f"Response: {e.response.text}") + return None + except Exception as e: + print(f"\n✗ Dynamic registration failed: {e}") + return None + + async def check_introspection_endpoint(self, discovery: dict) -> bool: + """Check if token introspection endpoint exists.""" + print(f"\n{'=' * 60}") + print("4. Token Introspection Endpoint") + print(f"{'=' * 60}") + + introspection_endpoint = discovery.get("introspection_endpoint") + + if introspection_endpoint: + print(f"URL: {introspection_endpoint}") + print("✓ Introspection endpoint available") + return True + else: + print("✗ Introspection endpoint NOT available") + print("Note: Will need to use userinfo endpoint for token validation") + return False + + def print_summary( + self, discovery: dict, jwks_available: bool, registration_available: bool + ): + """Print implementation summary.""" + print(f"\n{'=' * 60}") + print("IMPLEMENTATION SUMMARY") + print(f"{'=' * 60}") + + print("\n📋 Nextcloud OIDC Capabilities:") + print(" ✓ Discovery endpoint: Available") + print( + f" {'✓' if jwks_available else '✗'} JWKS endpoint: {'Available' if jwks_available else 'Not Available'}" + ) + print( + f" {'✓' if registration_available else '✗'} Dynamic registration: {'Available' if registration_available else 'Not Available'}" + ) + print(f" {'✗'} Token introspection: Not Available (use userinfo)") + + print("\n🔑 Token Format:") + if jwks_available: + print(" ✓ JWT access tokens: SUPPORTED (RFC 9068)") + print(" - Must be enabled per-client in OIDC settings") + print(" - Default: Opaque tokens") + else: + print(" - Opaque tokens only") + + print("\n🔐 Authentication Strategy:") + print(" Primary: Userinfo endpoint validation") + print(" Alternative: JWT validation (if enabled per-client)") + + print("\n📦 Required Scopes:") + scopes = discovery.get("scopes_supported", []) + print(f" Available: {', '.join(scopes)}") + print(" Recommended for MCP: openid profile email") + + print("\n👤 User Context Extraction:") + print(" - Username: 'sub' or 'preferred_username' claim") + print(" - From: JWT claims OR userinfo endpoint") + print(" - Groups: Available via 'roles' or 'groups' scope") + + print("\n⚙️ Configuration Requirements:") + if registration_available: + print(" ✓ Dynamic registration enabled - zero-config deployment possible") + print(" - Clients expire after 3600s (1 hour)") + print(" - Max 100 dynamic clients per instance") + print(" - BruteForce protection enabled") + else: + print(" ✗ Dynamic registration disabled - manual client setup required") + print(" Admin must create client via: occ oidc:create") + + print("\n📝 Endpoints:") + print(f" Authorization: {discovery.get('authorization_endpoint')}") + print(f" Token: {discovery.get('token_endpoint')}") + print(f" Userinfo: {discovery.get('userinfo_endpoint')}") + print(f" JWKS: {discovery.get('jwks_uri')}") + + +async def main(): + """Run verification tests.""" + print("=" * 60) + print("Nextcloud OIDC Verification Script") + print("=" * 60) + + # Get Nextcloud URL + nextcloud_url = input( + "\nEnter Nextcloud URL (e.g., https://cloud.coutinho.io): " + ).strip() + if not nextcloud_url: + nextcloud_url = "https://cloud.coutinho.io" + + verifier = NextcloudOIDCVerifier(nextcloud_url) + + try: + # 1. Get discovery document + discovery = await verifier.get_discovery() + + # 2. Check JWKS + jwks_uri = discovery.get("jwks_uri") + jwks_available = False + if jwks_uri: + jwks = await verifier.get_jwks(jwks_uri) + jwks_available = len(jwks.get("keys", [])) > 0 + + # 3. Test dynamic registration + registration_endpoint = discovery.get("registration_endpoint") + if registration_endpoint: + print("\nTest dynamic registration? (y/n): ", end="") + test_reg = input().strip().lower() + if test_reg == "y": + client_info = await verifier.test_dynamic_registration( + registration_endpoint + ) + registration_available = client_info is not None + else: + registration_available = True + print("Skipping dynamic registration test") + else: + registration_available = False + + # 4. Check introspection + await verifier.check_introspection_endpoint(discovery) + + # 5. Print summary + verifier.print_summary(discovery, jwks_available, registration_available) + + print(f"\n{'=' * 60}") + print("Verification complete!") + print(f"{'=' * 60}\n") + + finally: + await verifier.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/conftest.py b/tests/conftest.py index 296736f..0d6a3f1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,10 @@ +import asyncio import logging import os import uuid from typing import Any, AsyncGenerator +import httpx import pytest from httpx import HTTPStatusError from mcp import ClientSession @@ -13,19 +15,71 @@ from nextcloud_mcp_server.client import NextcloudClient logger = logging.getLogger(__name__) +async def wait_for_nextcloud( + host: str, max_attempts: int = 30, delay: float = 2.0 +) -> bool: + """ + Wait for Nextcloud server to be ready by checking the status endpoint. + + Args: + host: Nextcloud host URL + max_attempts: Maximum number of connection attempts + delay: Delay between attempts in seconds + + Returns: + True if server is ready, False otherwise + """ + logger.info(f"Waiting for Nextcloud server at {host} to be ready...") + + async with httpx.AsyncClient(timeout=5.0) as client: + for attempt in range(1, max_attempts + 1): + try: + # Try to hit the status endpoint + response = await client.get(f"{host}/status.php") + if response.status_code == 200: + data = response.json() + if data.get("installed"): + logger.info( + f"Nextcloud server is ready (version: {data.get('versionstring', 'unknown')})" + ) + return True + except (httpx.RequestError, httpx.TimeoutException) as e: + logger.debug(f"Attempt {attempt}/{max_attempts}: {e}") + + if attempt < max_attempts: + logger.info( + f"Nextcloud not ready yet, waiting {delay}s... (attempt {attempt}/{max_attempts})" + ) + await asyncio.sleep(delay) + + logger.error( + f"Nextcloud server at {host} did not become ready after {max_attempts} attempts" + ) + return False + + @pytest.fixture(scope="session") async def nc_client() -> AsyncGenerator[NextcloudClient, Any]: """ Fixture to create a NextcloudClient instance for integration tests. Uses environment variables for configuration. + Waits for Nextcloud to be ready before proceeding. """ assert os.getenv("NEXTCLOUD_HOST"), "NEXTCLOUD_HOST env var not set" assert os.getenv("NEXTCLOUD_USERNAME"), "NEXTCLOUD_USERNAME env var not set" assert os.getenv("NEXTCLOUD_PASSWORD"), "NEXTCLOUD_PASSWORD env var not set" + + host = os.getenv("NEXTCLOUD_HOST") + + # Wait for Nextcloud to be ready + if not await wait_for_nextcloud(host): + pytest.fail(f"Nextcloud server at {host} is not ready") + logger.info("Creating session-scoped NextcloudClient from environment variables.") client = NextcloudClient.from_env() - # Optional: Perform a quick check like getting capabilities to ensure connection works + + # Perform a quick check to ensure connection works try: await client.capabilities() logger.info( @@ -396,3 +450,183 @@ async def temporary_board_with_card( ) except Exception as e: 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 Resource Owner Password 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 password grant + # Note: This requires the OIDC app to support Resource Owner Password flow + token_response = await http_client.post( + token_endpoint, + data={ + "grant_type": "password", + "client_id": client_info.client_id, + "client_secret": client_info.client_secret, + "username": username, + "password": password, + "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(oauth_token: str) -> AsyncGenerator[NextcloudClient, Any]: + """ + Fixture to create a NextcloudClient instance using OAuth authentication. + Uses the oauth_token fixture to get an access token. + """ + nextcloud_host = os.getenv("NEXTCLOUD_HOST") + username = os.getenv("NEXTCLOUD_USERNAME") + + if not all([nextcloud_host, username]): + pytest.skip("OAuth client fixture requires NEXTCLOUD_HOST and USERNAME") + + logger.info(f"Creating OAuth NextcloudClient for user: {username}") + client = NextcloudClient.from_token( + base_url=nextcloud_host, + token=oauth_token, + username=username, + ) + + # Verify the OAuth client works + try: + await client.capabilities() + logger.info("OAuth NextcloudClient initialized and capabilities checked.") + yield client + except Exception as e: + logger.error(f"Failed to initialize OAuth NextcloudClient: {e}") + pytest.fail(f"Failed to connect to Nextcloud with OAuth token: {e}") + finally: + await client.close() + + +@pytest.fixture(scope="session") +async def nc_mcp_oauth_client() -> AsyncGenerator[ClientSession, Any]: + """ + Fixture to create an MCP client session for OAuth integration tests. + Connects to the OAuth-enabled MCP server on port 8001. + """ + logger.info("Creating Streamable HTTP client for OAuth MCP server") + streamable_context = streamablehttp_client("http://127.0.0.1:8001/mcp") + session_context = None + + try: + read_stream, write_stream, _ = await streamable_context.__aenter__() + session_context = ClientSession(read_stream, write_stream) + session = await session_context.__aenter__() + await session.initialize() + logger.info("OAuth MCP client session initialized successfully") + + yield session + + finally: + # Clean up in reverse order, ignoring task scope issues + if session_context is not None: + try: + await session_context.__aexit__(None, None, None) + except RuntimeError as e: + if "cancel scope" in str(e): + logger.debug(f"Ignoring cancel scope teardown issue: {e}") + else: + logger.warning(f"Error closing OAuth session: {e}") + except Exception as e: + logger.warning(f"Error closing OAuth session: {e}") + + try: + await streamable_context.__aexit__(None, None, None) + except RuntimeError as e: + if "cancel scope" in str(e): + logger.debug(f"Ignoring cancel scope teardown issue: {e}") + else: + logger.warning(f"Error closing OAuth streamable HTTP client: {e}") + except Exception as e: + logger.warning(f"Error closing OAuth streamable HTTP client: {e}") diff --git a/tests/integration/test_oauth.py b/tests/integration/test_oauth.py new file mode 100644 index 0000000..0cc35a0 --- /dev/null +++ b/tests/integration/test_oauth.py @@ -0,0 +1,126 @@ +"""Integration tests for OAuth authentication.""" + +import logging + +import pytest + +from nextcloud_mcp_server.client import NextcloudClient + +logger = logging.getLogger(__name__) + +pytestmark = pytest.mark.integration + + +class TestOAuthClient: + """Test OAuth-authenticated NextcloudClient.""" + + async def test_oauth_client_capabilities(self, nc_oauth_client: NextcloudClient): + """Test that OAuth client can fetch capabilities.""" + capabilities = await nc_oauth_client.capabilities() + + assert capabilities is not None + assert "version" in capabilities + logger.info( + f"OAuth client successfully fetched capabilities: {capabilities.get('version')}" + ) + + async def test_oauth_client_notes_list(self, nc_oauth_client: NextcloudClient): + """Test that OAuth client can list notes.""" + notes = await nc_oauth_client.notes.get_notes() + + assert isinstance(notes, list) + logger.info(f"OAuth client successfully listed {len(notes)} notes") + + async def test_oauth_client_create_note(self, nc_oauth_client: NextcloudClient): + """Test that OAuth client can create and delete a note.""" + # Create note + note_title = "OAuth Test Note" + note_content = "This note was created with OAuth authentication" + + created_note = await nc_oauth_client.notes.create_note( + title=note_title, content=note_content + ) + + assert created_note is not None + assert created_note.get("title") == note_title + note_id = created_note.get("id") + assert note_id is not None + + logger.info(f"OAuth client successfully created note with ID: {note_id}") + + # Clean up - delete the note + try: + await nc_oauth_client.notes.delete_note(note_id=note_id) + logger.info(f"OAuth client successfully deleted note {note_id}") + except Exception as e: + logger.error(f"Failed to clean up test note {note_id}: {e}") + raise + + +class TestOAuthTokenValidation: + """Test OAuth token validation and bearer auth.""" + + async def test_token_in_request_headers( + self, nc_oauth_client: NextcloudClient, oauth_token: str + ): + """Verify that bearer token is being used in requests.""" + # The client should be using BearerAuth + assert nc_oauth_client._auth is not None + + # Make a request and verify it works + capabilities = await nc_oauth_client.capabilities() + assert capabilities is not None + + logger.info("OAuth bearer token is correctly included in requests") + + async def test_invalid_token_fails(self): + """Test that an invalid token results in authentication failure.""" + import os + + from nextcloud_mcp_server.auth import BearerAuth + + nextcloud_host = os.getenv("NEXTCLOUD_HOST") + if not nextcloud_host: + pytest.skip("NEXTCLOUD_HOST not set") + + # Create client with invalid token using BearerAuth + invalid_client = NextcloudClient( + base_url=nextcloud_host, + username="testuser", + auth=BearerAuth("invalid_token_12345"), + ) + + # Attempt to use the client should fail with 401 + from httpx import HTTPStatusError + + with pytest.raises(HTTPStatusError) as exc_info: + await invalid_client.capabilities() + + assert exc_info.value.response.status_code == 401 + + await invalid_client.close() + logger.info("Invalid OAuth token correctly rejected") + + +class TestOAuthMCPIntegration: + """Test OAuth integration with MCP server.""" + + @pytest.mark.skip( + reason="OAuth MCP server integration requires full OAuth flow implementation" + ) + async def test_mcp_oauth_server_connection(self, nc_mcp_oauth_client): + """Test connection to OAuth-enabled MCP server.""" + # This test is currently skipped because the OAuth MCP server + # requires the full OAuth authorization flow to be implemented + # in the MCP SDK and app.py + + # Once implemented, this test should: + # 1. Connect to the OAuth MCP server + # 2. Verify tools are available + # 3. Call a tool and verify it works with OAuth auth + + result = await nc_mcp_oauth_client.list_tools() + assert result is not None + assert len(result.tools) > 0 + + logger.info(f"OAuth MCP server has {len(result.tools)} tools available") From 33b962a7fc41e78562a73091812bedadfcb371b9 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 13 Oct 2025 18:07:47 +0200 Subject: [PATCH 063/102] test: Setup interactive browser test --- .gitignore | 1 + nextcloud_mcp_server/app.py | 20 +-- .../auth/client_registration.py | 2 +- nextcloud_mcp_server/auth/context_helper.py | 1 + nextcloud_mcp_server/client/__init__.py | 2 +- pyproject.toml | 3 +- tests/conftest.py | 135 +++++++++++++----- tests/integration/test_oauth.py | 29 ++-- tests/integration/test_oauth_interactive.py | 32 +++++ 9 files changed, 162 insertions(+), 63 deletions(-) create mode 100644 tests/integration/test_oauth_interactive.py diff --git a/.gitignore b/.gitignore index 85bf658..fcc442a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__/ *.env .env.local .env.*.local +.nextcloud_oauth_test_client.json diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index f63ad08..c694bef 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -274,18 +274,20 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): # Determine authentication mode oauth_enabled = is_oauth_mode() - # WARNING: This is a synchronous function but OAuth setup requires async - # For now, OAuth configuration will be handled differently - # We'll need to restructure this or use a factory pattern - if oauth_enabled: logger.info("Configuring MCP server for OAuth mode") - logger.warning( - "OAuth mode requires async initialization - use factory pattern or separate setup" + # Asynchronously get the OAuth configuration + import asyncio + + nextcloud_host, token_verifier, auth_settings = asyncio.run( + setup_oauth_config() + ) + mcp = FastMCP( + "Nextcloud MCP", + lifespan=app_lifespan_oauth, + token_verifier=token_verifier, + auth=auth_settings, ) - # For now, fall back to a simplified OAuth setup - # TODO: This needs to be restructured to support async initialization - mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_oauth) else: logger.info("Configuring MCP server for BasicAuth mode") mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_basic) diff --git a/nextcloud_mcp_server/auth/client_registration.py b/nextcloud_mcp_server/auth/client_registration.py index 7ae9d28..2e2943d 100644 --- a/nextcloud_mcp_server/auth/client_registration.py +++ b/nextcloud_mcp_server/auth/client_registration.py @@ -211,7 +211,7 @@ async def load_or_register_client( storage_path: str | Path, client_name: str = "Nextcloud MCP Server", redirect_uris: list[str] | None = None, - force_register: bool = False, + force_register: bool = True, ) -> ClientInfo: """ Load client from storage or register a new one if not found/expired. diff --git a/nextcloud_mcp_server/auth/context_helper.py b/nextcloud_mcp_server/auth/context_helper.py index 1c160ce..c081f84 100644 --- a/nextcloud_mcp_server/auth/context_helper.py +++ b/nextcloud_mcp_server/auth/context_helper.py @@ -30,6 +30,7 @@ def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient: ValueError: If username cannot be extracted from token """ try: + logger.info(f"Inspecting session object: {dir(ctx.request_context.session)}") # Get AccessToken from MCP session (set by TokenVerifier) access_token: AccessToken = ctx.request_context.session.access_token diff --git a/nextcloud_mcp_server/client/__init__.py b/nextcloud_mcp_server/client/__init__.py index 621a379..27c1de1 100644 --- a/nextcloud_mcp_server/client/__init__.py +++ b/nextcloud_mcp_server/client/__init__.py @@ -104,7 +104,7 @@ class NextcloudClient: async def capabilities(self): response = await self._client.get( - "/ocs/v2.php/cloud/capabilities", + "/ocs/v2.php/apps/notifications/api/v2/notifications", headers={"OCS-APIRequest": "true", "Accept": "application/json"}, ) response.raise_for_status() diff --git a/pyproject.toml b/pyproject.toml index 3cc71d2..bc6e08c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,8 @@ log_cli = 1 log_cli_level = "INFO" log_level = "INFO" markers = [ - "integration: marks tests as slow (deselect with '-m \"not slow\"')" + "integration: marks tests as slow (deselect with '-m \"not slow\"')", + "interactive: marks tests as interactive (deselect with '-m \"not interactive\"')" ] [tool.commitizen] diff --git a/tests/conftest.py b/tests/conftest.py index 0d6a3f1..9a9b294 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -454,7 +454,7 @@ async def temporary_board_with_card( async def get_oauth_token(nextcloud_url: str, username: str, password: str) -> str: """ - Get an OAuth access token from Nextcloud OIDC using Resource Owner Password flow. + 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. @@ -501,16 +501,13 @@ async def get_oauth_token(nextcloud_url: str, username: str, password: str) -> s redirect_uris=["http://localhost:8000/oauth/callback"], ) - # Use client credentials to get a token via password grant - # Note: This requires the OIDC app to support Resource Owner Password flow + # Use client credentials to get a token via client_credentials grant token_response = await http_client.post( token_endpoint, data={ - "grant_type": "password", + "grant_type": "client_credentials", "client_id": client_info.client_id, "client_secret": client_info.client_secret, - "username": username, - "password": password, "scope": "openid profile email", }, ) @@ -590,43 +587,103 @@ async def nc_oauth_client(oauth_token: str) -> AsyncGenerator[NextcloudClient, A @pytest.fixture(scope="session") -async def nc_mcp_oauth_client() -> AsyncGenerator[ClientSession, Any]: +async def nc_mcp_oauth_client_interactive() -> AsyncGenerator[ClientSession, Any]: """ - Fixture to create an MCP client session for OAuth integration tests. - Connects to the OAuth-enabled MCP server on port 8001. + Fixture to create an MCP client session for interactive OAuth integration tests. + Performs an interactive OAuth flow to obtain an access token. """ - logger.info("Creating Streamable HTTP client for OAuth MCP server") - streamable_context = streamablehttp_client("http://127.0.0.1:8001/mcp") - session_context = None + import webbrowser + from http.server import BaseHTTPRequestHandler, HTTPServer + import threading + from urllib.parse import urlparse, parse_qs - try: - read_stream, write_stream, _ = await streamable_context.__aenter__() - session_context = ClientSession(read_stream, write_stream) - session = await session_context.__aenter__() - await session.initialize() - logger.info("OAuth MCP client session initialized successfully") + import time - yield session + auth_code = None - finally: - # Clean up in reverse order, ignoring task scope issues - if session_context is not None: - try: - await session_context.__aexit__(None, None, None) - except RuntimeError as e: - if "cancel scope" in str(e): - logger.debug(f"Ignoring cancel scope teardown issue: {e}") - else: - logger.warning(f"Error closing OAuth session: {e}") - except Exception as e: - logger.warning(f"Error closing OAuth session: {e}") + class OAuthCallbackHandler(BaseHTTPRequestHandler): + def do_GET(self): + nonlocal auth_code + if self.path.startswith("/shutdown"): + self.send_response(200) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write( + b"

Server shutting down...

" + ) + threading.Thread(target=httpd.shutdown).start() + return + parsed_path = urlparse(self.path) + query = parse_qs(parsed_path.query) + auth_code = query.get("code", [None])[0] + self.send_response(200) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write( + b"

Authentication successful!

You can close this window.

" + ) + + httpd = HTTPServer(("localhost", 8081), OAuthCallbackHandler) + server_thread = threading.Thread(target=httpd.serve_forever) + server_thread.daemon = True + server_thread.start() + + from nextcloud_mcp_server.auth.client_registration import load_or_register_client + + nextcloud_host = os.getenv("NEXTCLOUD_HOST") + async with httpx.AsyncClient() as http_client: + discovery_url = f"{nextcloud_host}/.well-known/openid-configuration" + discovery_response = await http_client.get(discovery_url) + oidc_config = discovery_response.json() + token_endpoint = oidc_config.get("token_endpoint") + registration_endpoint = oidc_config.get("registration_endpoint") + authorization_endpoint = oidc_config.get("authorization_endpoint") + + client_info = await load_or_register_client( + nextcloud_url=nextcloud_host, + registration_endpoint=registration_endpoint, + storage_path=".nextcloud_oauth_test_client.json", + redirect_uris=["http://localhost:8081"], + force_register=True, + ) + + auth_url = f"{authorization_endpoint}?response_type=code&client_id={client_info.client_id}&redirect_uri=http://localhost:8081&scope=openid%20profile%20email" + webbrowser.open(auth_url) + + while not auth_code: + logger.info("Sleeping until auth_code available") + time.sleep(1) + + token_response = await http_client.post( + token_endpoint, + data={ + "grant_type": "authorization_code", + "code": auth_code, + "redirect_uri": "http://localhost:8081", + "client_id": client_info.client_id, + "client_secret": client_info.client_secret, + }, + ) + + logger.info(f"Token response: {token_response.text}") + + # Shut down the server + token_data = token_response.json() + logger.info(f"Token data: {token_data}") + access_token = token_data.get("access_token") + + headers = {"Authorization": f"Bearer {access_token}"} + logger.info(f"Headers: {headers}") + async with streamablehttp_client("http://127.0.0.1:8001/mcp", headers=headers) as ( + read_stream, + write_stream, + _, + ): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() try: - await streamable_context.__aexit__(None, None, None) - except RuntimeError as e: - if "cancel scope" in str(e): - logger.debug(f"Ignoring cancel scope teardown issue: {e}") - else: - logger.warning(f"Error closing OAuth streamable HTTP client: {e}") - except Exception as e: - logger.warning(f"Error closing OAuth streamable HTTP client: {e}") + yield session + finally: + # Shut down the server + await http_client.get("http://localhost:8081/shutdown") diff --git a/tests/integration/test_oauth.py b/tests/integration/test_oauth.py index 0cc35a0..fc8bbd6 100644 --- a/tests/integration/test_oauth.py +++ b/tests/integration/test_oauth.py @@ -105,22 +105,27 @@ class TestOAuthTokenValidation: class TestOAuthMCPIntegration: """Test OAuth integration with MCP server.""" - @pytest.mark.skip( - reason="OAuth MCP server integration requires full OAuth flow implementation" - ) async def test_mcp_oauth_server_connection(self, nc_mcp_oauth_client): """Test connection to OAuth-enabled MCP server.""" - # This test is currently skipped because the OAuth MCP server - # requires the full OAuth authorization flow to be implemented - # in the MCP SDK and app.py - - # Once implemented, this test should: - # 1. Connect to the OAuth MCP server - # 2. Verify tools are available - # 3. Call a tool and verify it works with OAuth auth - result = await nc_mcp_oauth_client.list_tools() assert result is not None assert len(result.tools) > 0 logger.info(f"OAuth MCP server has {len(result.tools)} tools available") + + async def test_mcp_oauth_tool_execution(self, nc_mcp_oauth_client): + """Test executing a tool on the OAuth-enabled MCP server.""" + import json + + # Example: Execute the 'nc_tables_list_tables' tool + result = await nc_mcp_oauth_client.call_tool("nc_tables_list_tables") + + assert result.isError is False, f"Tool execution failed: {result.content}" + assert result.content is not None + notes_list = json.loads(result.content[0].text) + + assert isinstance(notes_list, list) + + logger.info( + f"Successfully executed 'nc_tables_list_tables' tool on OAuth MCP server and got {len(notes_list)} notes." + ) diff --git a/tests/integration/test_oauth_interactive.py b/tests/integration/test_oauth_interactive.py new file mode 100644 index 0000000..1701993 --- /dev/null +++ b/tests/integration/test_oauth_interactive.py @@ -0,0 +1,32 @@ +"""Interactive integration tests for OAuth authentication.""" + +import logging + +import pytest + +logger = logging.getLogger(__name__) + +pytestmark = [pytest.mark.integration, pytest.mark.interactive] + + +class TestOAuthInteractive: + """Test interactive OAuth authentication.""" + + async def test_mcp_oauth_tool_execution_interactive( + self, nc_mcp_oauth_client_interactive + ): + """Test executing a tool on the OAuth-enabled MCP server with an interactive token.""" + # Example: Execute the 'nc_notes_list' tool + result = await nc_mcp_oauth_client_interactive.call_tool("nc_tables_list") + + assert result.isError is False, f"Tool execution failed: {result.content}" + assert result.content is not None + import json + + notes_list = json.loads(result.content[0].text) + + assert isinstance(notes_list, list) + + logger.info( + f"Successfully executed 'nc_notes_list' tool on OAuth MCP server and got {len(notes_list)} notes." + ) From 2b11718c438953fcb27c67395e75571dedd8dc6a Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 13 Oct 2025 18:07:48 +0200 Subject: [PATCH 064/102] test: continue working on oauth client --- nextcloud_mcp_server/client/__init__.py | 2 +- tests/conftest.py | 43 +++++++++------------ tests/integration/test_oauth_interactive.py | 39 ++++++++++++------- 3 files changed, 44 insertions(+), 40 deletions(-) diff --git a/nextcloud_mcp_server/client/__init__.py b/nextcloud_mcp_server/client/__init__.py index 27c1de1..621a379 100644 --- a/nextcloud_mcp_server/client/__init__.py +++ b/nextcloud_mcp_server/client/__init__.py @@ -104,7 +104,7 @@ class NextcloudClient: async def capabilities(self): response = await self._client.get( - "/ocs/v2.php/apps/notifications/api/v2/notifications", + "/ocs/v2.php/cloud/capabilities", headers={"OCS-APIRequest": "true", "Accept": "application/json"}, ) response.raise_for_status() diff --git a/tests/conftest.py b/tests/conftest.py index 9a9b294..745c033 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -556,7 +556,9 @@ async def oauth_token() -> str: @pytest.fixture(scope="session") -async def nc_oauth_client(oauth_token: str) -> AsyncGenerator[NextcloudClient, Any]: +async def nc_oauth_client( + interactive_oauth_token: str, +) -> AsyncGenerator[NextcloudClient, Any]: """ Fixture to create a NextcloudClient instance using OAuth authentication. Uses the oauth_token fixture to get an access token. @@ -570,7 +572,7 @@ async def nc_oauth_client(oauth_token: str) -> AsyncGenerator[NextcloudClient, A logger.info(f"Creating OAuth NextcloudClient for user: {username}") client = NextcloudClient.from_token( base_url=nextcloud_host, - token=oauth_token, + token=interactive_oauth_token, username=username, ) @@ -587,19 +589,22 @@ async def nc_oauth_client(oauth_token: str) -> AsyncGenerator[NextcloudClient, A @pytest.fixture(scope="session") -async def nc_mcp_oauth_client_interactive() -> AsyncGenerator[ClientSession, Any]: +async def interactive_oauth_token() -> str: """ - Fixture to create an MCP client session for interactive OAuth integration tests. - Performs an interactive OAuth flow to obtain an access token. + Fixture to obtain an OAuth access token for integration tests. + + This uses the interactive OAuth flow to get a token. """ + import webbrowser from http.server import BaseHTTPRequestHandler, HTTPServer import threading from urllib.parse import urlparse, parse_qs - import time auth_code = None + httpd = None + server_thread = None class OAuthCallbackHandler(BaseHTTPRequestHandler): def do_GET(self): @@ -639,7 +644,6 @@ async def nc_mcp_oauth_client_interactive() -> AsyncGenerator[ClientSession, Any token_endpoint = oidc_config.get("token_endpoint") registration_endpoint = oidc_config.get("registration_endpoint") authorization_endpoint = oidc_config.get("authorization_endpoint") - client_info = await load_or_register_client( nextcloud_url=nextcloud_host, registration_endpoint=registration_endpoint, @@ -650,7 +654,6 @@ async def nc_mcp_oauth_client_interactive() -> AsyncGenerator[ClientSession, Any auth_url = f"{authorization_endpoint}?response_type=code&client_id={client_info.client_id}&redirect_uri=http://localhost:8081&scope=openid%20profile%20email" webbrowser.open(auth_url) - while not auth_code: logger.info("Sleeping until auth_code available") time.sleep(1) @@ -667,23 +670,15 @@ async def nc_mcp_oauth_client_interactive() -> AsyncGenerator[ClientSession, Any ) logger.info(f"Token response: {token_response.text}") - - # Shut down the server token_data = token_response.json() logger.info(f"Token data: {token_data}") access_token = token_data.get("access_token") - headers = {"Authorization": f"Bearer {access_token}"} - logger.info(f"Headers: {headers}") - async with streamablehttp_client("http://127.0.0.1:8001/mcp", headers=headers) as ( - read_stream, - write_stream, - _, - ): - async with ClientSession(read_stream, write_stream) as session: - await session.initialize() - try: - yield session - finally: - # Shut down the server - await http_client.get("http://localhost:8081/shutdown") + # Shut down the server + + await http_client.get("http://localhost:8081/shutdown") + if httpd: + httpd.server_close() + if server_thread: + server_thread.join(timeout=1) + return access_token diff --git a/tests/integration/test_oauth_interactive.py b/tests/integration/test_oauth_interactive.py index 1701993..09f991a 100644 --- a/tests/integration/test_oauth_interactive.py +++ b/tests/integration/test_oauth_interactive.py @@ -12,21 +12,30 @@ pytestmark = [pytest.mark.integration, pytest.mark.interactive] class TestOAuthInteractive: """Test interactive OAuth authentication.""" - async def test_mcp_oauth_tool_execution_interactive( - self, nc_mcp_oauth_client_interactive - ): - """Test executing a tool on the OAuth-enabled MCP server with an interactive token.""" - # Example: Execute the 'nc_notes_list' tool - result = await nc_mcp_oauth_client_interactive.call_tool("nc_tables_list") - - assert result.isError is False, f"Tool execution failed: {result.content}" - assert result.content is not None - import json - - notes_list = json.loads(result.content[0].text) - - assert isinstance(notes_list, list) + async def test_oauth_client_with_interactive_flow(self, nc_oauth_client): + """Test that OAuth client created via interactive flow can access Nextcloud APIs.""" + # Test 1: Check capabilities + capabilities = await nc_oauth_client.capabilities() + assert capabilities is not None + logger.info("OAuth client (interactive) successfully fetched capabilities") + # Test 2: List notes + notes = await nc_oauth_client.notes.get_all_notes() + assert isinstance(notes, list) logger.info( - f"Successfully executed 'nc_notes_list' tool on OAuth MCP server and got {len(notes_list)} notes." + f"OAuth client (interactive) successfully listed {len(notes)} notes" ) + + # Test 3: Create and delete a note + test_note = await nc_oauth_client.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.notes.delete_note(note_id=note_id) + logger.info(f"OAuth client (interactive) successfully deleted note {note_id}") From 7d8ba394346cdafe7934fd9e25ceefdb669cae87 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 13 Oct 2025 18:07:49 +0200 Subject: [PATCH 065/102] test: update app install scripts --- .../post-installation/install-calendar-app.sh | 2 +- .../post-installation/install-contacts-app.sh | 2 ++ .../post-installation/install-deck-app.sh | 2 ++ .../post-installation/install-notes-app.sh | 2 ++ .../post-installation/install-oidc-app.sh | 20 +++++++++++++------ .../post-installation/install-tables-app.sh | 2 ++ 6 files changed, 23 insertions(+), 7 deletions(-) diff --git a/app-hooks/post-installation/install-calendar-app.sh b/app-hooks/post-installation/install-calendar-app.sh index 2fe4f1f..465ba12 100755 --- a/app-hooks/post-installation/install-calendar-app.sh +++ b/app-hooks/post-installation/install-calendar-app.sh @@ -1,6 +1,6 @@ #!/bin/bash -set -e # Exit on any error +set -euox pipefail echo "Installing and configuring Calendar app..." diff --git a/app-hooks/post-installation/install-contacts-app.sh b/app-hooks/post-installation/install-contacts-app.sh index 7a97d68..1cf27d5 100755 --- a/app-hooks/post-installation/install-contacts-app.sh +++ b/app-hooks/post-installation/install-contacts-app.sh @@ -1,3 +1,5 @@ #!/bin/bash +set -euox pipefail + php /var/www/html/occ app:enable contacts diff --git a/app-hooks/post-installation/install-deck-app.sh b/app-hooks/post-installation/install-deck-app.sh index 8594e3b..75944e6 100755 --- a/app-hooks/post-installation/install-deck-app.sh +++ b/app-hooks/post-installation/install-deck-app.sh @@ -1,3 +1,5 @@ #!/bin/bash +set -euox pipefail + php /var/www/html/occ app:enable deck diff --git a/app-hooks/post-installation/install-notes-app.sh b/app-hooks/post-installation/install-notes-app.sh index f32392e..8704e39 100755 --- a/app-hooks/post-installation/install-notes-app.sh +++ b/app-hooks/post-installation/install-notes-app.sh @@ -1,3 +1,5 @@ #!/bin/bash +set -euox pipefail + php /var/www/html/occ app:enable notes diff --git a/app-hooks/post-installation/install-oidc-app.sh b/app-hooks/post-installation/install-oidc-app.sh index a09f708..3c18998 100755 --- a/app-hooks/post-installation/install-oidc-app.sh +++ b/app-hooks/post-installation/install-oidc-app.sh @@ -1,13 +1,21 @@ #!/bin/bash -set -e -echo "Installing and configuring OIDC app for testing..." +set -euox pipefail -# Enable the OIDC app +echo "Installing and configuring OIDC apps for testing..." + +# Enable the OIDC Identity Provider app +php /var/www/html/occ app:install oidc || true php /var/www/html/occ app:enable oidc -# Configure OIDC for testing with dynamic client registration enabled -# Note: The correct config key is 'dynamic_client_registration', not 'allow_dynamic_client_registration' +# Enable the user_oidc app (OIDC client for bearer token validation) +php /var/www/html/occ app:install user_oidc || true +php /var/www/html/occ app:enable user_oidc + +# Configure OIDC Identity Provider with dynamic client registration enabled php /var/www/html/occ config:app:set oidc dynamic_client_registration --value='true' -echo "OIDC app installed and configured successfully" +# Configure user_oidc to validate bearer tokens from the OIDC Identity Provider +php /var/www/html/occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean + +echo "OIDC apps installed and configured successfully" diff --git a/app-hooks/post-installation/install-tables-app.sh b/app-hooks/post-installation/install-tables-app.sh index 53c8583..21dbe5a 100755 --- a/app-hooks/post-installation/install-tables-app.sh +++ b/app-hooks/post-installation/install-tables-app.sh @@ -1,3 +1,5 @@ #!/bin/bash +set -euox pipefail + php /var/www/html/occ app:enable tables From 17979accb67ae42296e9daf71e805c2e78a30ad1 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 13 Oct 2025 18:07:50 +0200 Subject: [PATCH 066/102] test: Add patch for user_oidc app and update docs --- ...-authentication-causing-session-logo.patch | 69 +++++++++++++ .../post-installation/install-oidc-app.sh | 6 +- docs/oauth2-bearer-token-session-issue.md | 97 +++++++++++++++++++ docs/user_oidc-pr-description.md | 96 ++++++++++++++++++ tests/conftest.py | 39 +++++++- 5 files changed, 300 insertions(+), 7 deletions(-) create mode 100644 app-hooks/post-installation/0001-Fix-Bearer-token-authentication-causing-session-logo.patch create mode 100644 docs/oauth2-bearer-token-session-issue.md create mode 100644 docs/user_oidc-pr-description.md diff --git a/app-hooks/post-installation/0001-Fix-Bearer-token-authentication-causing-session-logo.patch b/app-hooks/post-installation/0001-Fix-Bearer-token-authentication-causing-session-logo.patch new file mode 100644 index 0000000..c578441 --- /dev/null +++ b/app-hooks/post-installation/0001-Fix-Bearer-token-authentication-causing-session-logo.patch @@ -0,0 +1,69 @@ +From deab2dac3d73d25f20a95c18103f327ab48f837a Mon Sep 17 00:00:00 2001 +From: Chris Coutinho +Date: Sun, 12 Oct 2025 21:09:29 +0200 +Subject: [PATCH 1/1] Fix Bearer token authentication causing session logout + +When using Bearer token authentication with OIDC, API requests to +endpoints with @CORS annotations (like Notes API) were failing with +401 Unauthorized errors. This occurred because: + +1. Bearer token validation successfully authenticated the user +2. A session was created for the authenticated user +3. Nextcloud's CORSMiddleware detected the logged-in session but no + CSRF token, causing it to call session->logout() +4. The logout invalidated the session, breaking the API request + +This fix sets the 'app_api' session flag during Bearer token +authentication, which instructs CORSMiddleware to skip the CSRF check +and logout logic. This is the same mechanism used by Nextcloud's +AppAPI framework for external application authentication. + +The flag is set at all successful Bearer token authentication points: +- Line 243: After OIDC Identity Provider validation +- Line 310: After auto-provisioning with bearer provisioning +- Line 315: After existing user authentication +- Line 337: After LDAP user sync + +Fixes: Bearer token authentication for all Nextcloud APIs +Tested-with: nextcloud-mcp-server integration tests +Signed-off-by: Chris Coutinho +--- + lib/User/Backend.php | 4 ++++ + 1 file changed, 4 insertions(+) + +diff --git a/lib/User/Backend.php b/lib/User/Backend.php +index 23cfb18..65665cc 100644 +--- a/lib/User/Backend.php ++++ b/lib/User/Backend.php +@@ -240,6 +240,7 @@ class Backend extends ABackend implements IPasswordConfirmationBackend, IGetDisp + $this->eventDispatcher->dispatchTyped($validationEvent); + $oidcProviderUserId = $validationEvent->getUserId(); + if ($oidcProviderUserId !== null) { ++ $this->session->set('app_api', true); + return $oidcProviderUserId; + } else { + $this->logger->debug('[NextcloudOidcProviderValidator] The bearer token validation has failed'); +@@ -306,10 +307,12 @@ class Backend extends ABackend implements IPasswordConfirmationBackend, IGetDisp + } + + $this->session->set('last-password-confirm', strtotime('+4 year', time())); ++ $this->session->set('app_api', true); + return $userId; + } elseif ($this->userExists($tokenUserId)) { + $this->checkFirstLogin($tokenUserId); + $this->session->set('last-password-confirm', strtotime('+4 year', time())); ++ $this->session->set('app_api', true); + return $tokenUserId; + } else { + // check if the user exists locally +@@ -331,6 +334,7 @@ class Backend extends ABackend implements IPasswordConfirmationBackend, IGetDisp + } + $this->checkFirstLogin($tokenUserId); + $this->session->set('last-password-confirm', strtotime('+4 year', time())); ++ $this->session->set('app_api', true); + return $tokenUserId; + } + } +-- +2.51.0 + diff --git a/app-hooks/post-installation/install-oidc-app.sh b/app-hooks/post-installation/install-oidc-app.sh index 3c18998..656f72f 100755 --- a/app-hooks/post-installation/install-oidc-app.sh +++ b/app-hooks/post-installation/install-oidc-app.sh @@ -5,13 +5,15 @@ set -euox pipefail echo "Installing and configuring OIDC apps for testing..." # Enable the OIDC Identity Provider app -php /var/www/html/occ app:install oidc || true +#php /var/www/html/occ app:install oidc || true php /var/www/html/occ app:enable oidc # Enable the user_oidc app (OIDC client for bearer token validation) -php /var/www/html/occ app:install user_oidc || true +#php /var/www/html/occ app:install user_oidc || true php /var/www/html/occ app:enable user_oidc +patch -u /var/www/html/custom_apps/user_oidc/lib/User/Backend.php -i /docker-entrypoint-hooks.d/post-installation/0001-Fix-Bearer-token-authentication-causing-session-logo.patch + # Configure OIDC Identity Provider with dynamic client registration enabled php /var/www/html/occ config:app:set oidc dynamic_client_registration --value='true' diff --git a/docs/oauth2-bearer-token-session-issue.md b/docs/oauth2-bearer-token-session-issue.md new file mode 100644 index 0000000..797c101 --- /dev/null +++ b/docs/oauth2-bearer-token-session-issue.md @@ -0,0 +1,97 @@ +# Root Cause Analysis: OAuth2 Bearer Token Session Invalidation + +## Problem +Bearer token authentication fails for app-specific APIs (like Notes) with 401 Unauthorized, even though it works for OCS APIs (capabilities). + +## Root Cause +The CORSMiddleware in Nextcloud server is logging out the session created by Bearer token authentication: + +``` +/home/chris/Software/server/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php:84 +$this->session->logout(); +``` + +### Why Session is Logged Out +1. Notes API has @CORS annotation +2. Bearer auth via user_oidc creates a logged-in session +3. Request has NO CSRF token +4. Request has NO AppAPI auth flag +5. Request has NO PHP_AUTH_USER/PHP_AUTH_PW (basic auth) +6. Therefore CORSMiddleware calls logout() + +### Log Evidence +``` +{"message":"[TokenInvalidatedListener] Could not find the OIDC session related with an invalidated token"} +``` + +Token validated successfully, then immediately invalidated by session logout. + +## Token Type Investigation (Opaque vs JWT) +- **Finding**: Token type (opaque vs JWT) does NOT affect the issue +- **Reason**: Session invalidation happens AFTER successful token validation +- Both opaque and JWT tokens validate correctly via TokenValidationRequestEvent +- The logout happens in CORSMiddleware, not in token validation + +## ✅ SOLUTION (Tested & Working) + +### Option A: Set AppAPI Flag for Bearer Auth ✅ +**Status**: Successfully tested and verified working + +Modified user_oidc `Backend.php` `getCurrentUserId()` method to set the `app_api` session flag before returning the user ID: + +```php +$this->session->set('app_api', true); +``` + +This bypasses CORS middleware's logout logic at line 81-82 by setting the same flag used by Nextcloud's AppAPI framework. + +### Implementation +The flag is added before all successful Bearer token authentication return statements in `/var/www/html/custom_apps/user_oidc/lib/User/Backend.php`: + +- Line ~243: After OIDC provider validation +- Line ~310: After auto-provisioning with bearer provisioning +- Line ~315: After existing user authentication +- Line ~337: After LDAP user sync + +### Test Results +All OAuth Bearer token operations now work correctly: + +✅ **Capabilities endpoint** (OCS API) - 200 OK +✅ **Notes API listing** - 200 OK +✅ **Notes API create** - 200 OK (created note 112) +✅ **Notes API delete** - 200 OK (deleted note 112) + +No session invalidation occurs, and all API operations complete successfully. + +### Patch File +See `patches/user_oidc-bearer-auth-app-api-flag.patch` for the exact changes. + +## Alternative Solutions (Not Tested) + +### Option B: Avoid Creating Full Session for Bearer Auth +Bearer token auth should not create a full session that triggers CORS middleware checks. This would require deeper architectural changes. + +### Option C: Add CSRF Exemption +Modify CORSMiddleware to exempt Bearer token authenticated requests from CSRF check. This would require changes to Nextcloud core. + +### Option D: Use Basic Auth Headers +Set PHP_AUTH_USER/PHP_AUTH_PW server variables during Bearer auth so CORSMiddleware can re-authenticate. This could have security implications. + +## Recommendations + +### Short-term (Current Implementation) +The `app_api` flag solution works correctly and follows Nextcloud's existing pattern for API authentication. This is the recommended approach for immediate use. + +### Long-term (Upstream Contribution) +Consider submitting this fix to the upstream user_oidc project as it enables proper Bearer token authentication for all Nextcloud APIs, not just OCS endpoints. + +## Files Involved +- `/home/chris/Software/user_oidc/lib/User/Backend.php` (getCurrentUserId) - **MODIFIED** +- `/home/chris/Software/server/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php` (logout logic) +- `/home/chris/Software/user_oidc/lib/Listener/TokenInvalidatedListener.php` (cleanup handler) + +## Testing +Run the OAuth interactive test to verify: +```bash +uv run pytest tests/integration/test_oauth_interactive.py -v +``` diff --git a/docs/user_oidc-pr-description.md b/docs/user_oidc-pr-description.md new file mode 100644 index 0000000..d8829b2 --- /dev/null +++ b/docs/user_oidc-pr-description.md @@ -0,0 +1,96 @@ +# Fix Bearer Token Authentication Causing Session Logout + +## Problem + +Bearer token authentication with OIDC fails for app-specific APIs (like Notes, Calendar, etc.) with `401 Unauthorized` errors, even though the same Bearer token works fine for OCS APIs (like `/ocs/v2.php/cloud/capabilities`). + +### Root Cause + +When using Bearer token authentication: + +1. ✅ Bearer token validation successfully authenticates the user +2. ✅ A session is created for the authenticated user +3. ❌ **Nextcloud's `CORSMiddleware` detects the logged-in session but no CSRF token** +4. ❌ **`CORSMiddleware` calls `$this->session->logout()` to prevent CSRF attacks** +5. ❌ The logout invalidates the session, breaking the API request with 401 Unauthorized + +This occurs because app-specific APIs (Notes, Calendar, etc.) use the `@CORS` annotation, which triggers the `CORSMiddleware` security checks. The OCS APIs don't have this annotation, which is why they work correctly. + +### Error Logs + +``` +[TokenInvalidatedListener] Could not find the OIDC session related with an invalidated token +Session token invalidated before logout +Logging out +``` + +## Solution + +Set the `app_api` session flag during Bearer token authentication. This instructs `CORSMiddleware` to skip the CSRF check and logout logic, as the authentication is API-based rather than session-based. + +This is the same mechanism used by Nextcloud's [AppAPI framework](https://github.com/cloud-py-api/app_api) for external application authentication. + +### Changes + +The fix adds `$this->session->set('app_api', true);` before all successful Bearer token authentication return statements in `lib/User/Backend.php`: + +- **Line 243**: After OIDC Identity Provider validation +- **Line 310**: After auto-provisioning with bearer provisioning +- **Line 315**: After existing user authentication +- **Line 337**: After LDAP user sync + +## Testing + +Tested with the [nextcloud-mcp-server](https://github.com/cccs-nik/nextcloud-mcp-server) project's integration tests: + +### Before Fix +``` +✅ Capabilities endpoint (OCS API) - 200 OK +❌ Notes API listing - 401 Unauthorized +❌ Notes API create - 401 Unauthorized +``` + +### After Fix +``` +✅ Capabilities endpoint (OCS API) - 200 OK +✅ Notes API listing - 200 OK +✅ Notes API create - 200 OK +✅ Notes API delete - 200 OK +``` + +All OAuth Bearer token operations now work correctly across all Nextcloud APIs without session invalidation. + +## Configuration + +This fix works with the standard Bearer token validation configuration: + +```php +// config.php +'user_oidc' => [ + 'oidc_provider_bearer_validation' => true, +], +``` + +And in the OIDC Identity Provider app: +```bash +php occ config:app:set oidc dynamic_client_registration --value='true' +``` + +## Impact + +This fix enables proper Bearer token authentication for: +- All Nextcloud app APIs (Notes, Calendar, Contacts, etc.) +- External applications using OAuth 2.0 / OpenID Connect +- MCP servers and other API integrations +- Any application using the `Authorization: Bearer` header + +## Related Files + +- `lib/User/Backend.php` - Modified to set `app_api` flag +- `/server/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php` - Contains the CSRF/logout logic that this bypasses + +## References + +- [Nextcloud CORS Middleware](https://github.com/nextcloud/server/blob/master/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php) +- [Nextcloud AppAPI](https://github.com/cloud-py-api/app_api) +- [OpenID Connect Bearer Token Usage](https://openid.net/specs/openid-connect-core-1_0.html#TokenUsage) diff --git a/tests/conftest.py b/tests/conftest.py index 745c033..f5fcdb7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -602,13 +602,17 @@ async def interactive_oauth_token() -> str: from urllib.parse import urlparse, parse_qs import time - auth_code = None + # Use a mutable container to share state across threads + auth_state = {"code": None} httpd = None server_thread = None class OAuthCallbackHandler(BaseHTTPRequestHandler): + def log_message(self, format, *args): + # Suppress default HTTP logging + pass + def do_GET(self): - nonlocal auth_code if self.path.startswith("/shutdown"): self.send_response(200) self.send_header("Content-type", "text/html") @@ -621,7 +625,11 @@ async def interactive_oauth_token() -> str: parsed_path = urlparse(self.path) query = parse_qs(parsed_path.query) - auth_code = query.get("code", [None])[0] + code = query.get("code", [None])[0] + auth_state["code"] = code + logger.info( + f"OAuth callback received. Code: {code[:20] if code else 'None'}..." + ) self.send_response(200) self.send_header("Content-type", "text/html") self.end_headers() @@ -652,12 +660,33 @@ async def interactive_oauth_token() -> str: force_register=True, ) + # First, open Nextcloud login page to establish session + login_url = f"{nextcloud_host}/login" + logger.info(f"Please log in to Nextcloud at: {login_url}") + logger.info( + "After logging in, the OAuth authorization will proceed automatically" + ) + + # Construct authorization URL auth_url = f"{authorization_endpoint}?response_type=code&client_id={client_info.client_id}&redirect_uri=http://localhost:8081&scope=openid%20profile%20email" + + # Open login page first, then auth URL + # webbrowser.open(login_url) + # time.sleep(2) # Give browser time to load login page webbrowser.open(auth_url) - while not auth_code: - logger.info("Sleeping until auth_code available") + + # Wait for auth code with timeout + timeout = 120 # 2 minutes + start_time = time.time() + while not auth_state["code"]: + if time.time() - start_time > timeout: + raise TimeoutError("OAuth authorization timed out after 2 minutes") + logger.info("Waiting for OAuth authorization...") time.sleep(1) + auth_code = auth_state["code"] + logger.info("Received authorization code, exchanging for token...") + token_response = await http_client.post( token_endpoint, data={ From 605c8afacd570025962a65188d5399fc9279114a Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 13 Oct 2025 18:07:51 +0200 Subject: [PATCH 067/102] test: Disable interactive tests for ci --- .github/workflows/test.yml | 2 +- scripts/test_oauth_tools.py | 94 ------------------------------------- 2 files changed, 1 insertion(+), 95 deletions(-) delete mode 100644 scripts/test_oauth_tools.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2543d69..18dcfd6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -56,4 +56,4 @@ jobs: NEXTCLOUD_USERNAME: "admin" NEXTCLOUD_PASSWORD: "admin" run: | - uv run --frozen python -m pytest + uv run --frozen python -m pytest -m 'not interactive' diff --git a/scripts/test_oauth_tools.py b/scripts/test_oauth_tools.py deleted file mode 100644 index 994cd52..0000000 --- a/scripts/test_oauth_tools.py +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env python3 -"""Test script to verify OAuth MCP tools work correctly. - -This script connects to the OAuth MCP server and tests tool execution. -Note: This currently requires a valid OAuth token, which must be obtained -through the browser-based OAuth flow. -""" - -import asyncio -import sys - -from mcp import ClientSession -from mcp.client.streamable_http import streamablehttp_client - - -async def test_oauth_mcp_tools(): - """Test OAuth MCP server tools.""" - print("Connecting to OAuth MCP server on port 8001...") - - streamable_context = streamablehttp_client("http://127.0.0.1:8001/mcp") - session_context = None - - try: - read_stream, write_stream, _ = await streamable_context.__aenter__() - session_context = ClientSession(read_stream, write_stream) - session = await session_context.__aenter__() - - print("Initializing session...") - await session.initialize() - print("✓ Session initialized successfully") - - # List available tools - print("\nListing available tools...") - result = await session.list_tools() - print(f"✓ Found {len(result.tools)} tools") - - for tool in result.tools[:5]: # Show first 5 - print(f" - {tool.name}: {tool.description}") - - if len(result.tools) > 5: - print(f" ... and {len(result.tools) - 5} more") - - # Try to call a simple tool - print("\nTesting tool execution...") - print("Note: Tool execution will fail without a valid OAuth token") - print(" (OAuth token must be obtained through browser flow)") - - try: - # Try to list tables (this will fail without OAuth token) - response = await session.call_tool("nc_tables_list_tables", {}) - print(f"✓ Tool executed successfully: {response}") - except Exception as e: - print(f"✗ Tool execution failed (expected without OAuth token): {e}") - print("\nTo use OAuth tools, you need to:") - print(" 1. Implement the browser-based OAuth authorization flow") - print(" 2. Obtain an access token from Nextcloud OIDC") - print(" 3. Include the token in the Authorization header") - - return True - - except Exception as e: - print(f"✗ Error: {e}") - import traceback - - traceback.print_exc() - return False - - finally: - # Clean up - if session_context is not None: - try: - await session_context.__aexit__(None, None, None) - except Exception: - pass - - try: - await streamable_context.__aexit__(None, None, None) - except Exception: - pass - - -if __name__ == "__main__": - print("OAuth MCP Server Tool Test") - print("=" * 50) - - success = asyncio.run(test_oauth_mcp_tools()) - - print("\n" + "=" * 50) - if success: - print("✓ Test completed (tools accessible)") - sys.exit(0) - else: - print("✗ Test failed") - sys.exit(1) From 0c5d9a46bd93bfdffcf667b7da3937a5d4406da9 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 13 Oct 2025 18:07:52 +0200 Subject: [PATCH 068/102] test: fix typo --- tests/integration/test_oauth.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/test_oauth.py b/tests/integration/test_oauth.py index fc8bbd6..7f9d1a7 100644 --- a/tests/integration/test_oauth.py +++ b/tests/integration/test_oauth.py @@ -19,9 +19,9 @@ class TestOAuthClient: capabilities = await nc_oauth_client.capabilities() assert capabilities is not None - assert "version" in capabilities + assert "ocs" in capabilities logger.info( - f"OAuth client successfully fetched capabilities: {capabilities.get('version')}" + f"OAuth client successfully fetched capabilities: {capabilities.get('ocs').get('meta')}" ) async def test_oauth_client_notes_list(self, nc_oauth_client: NextcloudClient): From 879cd58db15569f41c5952e7933760b1126cef51 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 13 Oct 2025 18:07:53 +0200 Subject: [PATCH 069/102] test: rename interactive mark to oauth --- pyproject.toml | 2 +- tests/integration/test_oauth.py | 4 ++-- tests/integration/test_oauth_interactive.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bc6e08c..cb79bb8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ log_cli_level = "INFO" log_level = "INFO" markers = [ "integration: marks tests as slow (deselect with '-m \"not slow\"')", - "interactive: marks tests as interactive (deselect with '-m \"not interactive\"')" + "oauth: marks tests as oauth (deselect with '-m \"not oauth\"')" ] [tool.commitizen] diff --git a/tests/integration/test_oauth.py b/tests/integration/test_oauth.py index 7f9d1a7..c66308d 100644 --- a/tests/integration/test_oauth.py +++ b/tests/integration/test_oauth.py @@ -8,7 +8,7 @@ from nextcloud_mcp_server.client import NextcloudClient logger = logging.getLogger(__name__) -pytestmark = pytest.mark.integration +pytestmark = [pytest.mark.integration, pytest.mark.oauth] class TestOAuthClient: @@ -26,7 +26,7 @@ class TestOAuthClient: async def test_oauth_client_notes_list(self, nc_oauth_client: NextcloudClient): """Test that OAuth client can list notes.""" - notes = await nc_oauth_client.notes.get_notes() + notes = await nc_oauth_client.notes.get_all_notes() assert isinstance(notes, list) logger.info(f"OAuth client successfully listed {len(notes)} notes") diff --git a/tests/integration/test_oauth_interactive.py b/tests/integration/test_oauth_interactive.py index 09f991a..76e93cb 100644 --- a/tests/integration/test_oauth_interactive.py +++ b/tests/integration/test_oauth_interactive.py @@ -6,7 +6,7 @@ import pytest logger = logging.getLogger(__name__) -pytestmark = [pytest.mark.integration, pytest.mark.interactive] +pytestmark = [pytest.mark.integration, pytest.mark.oauth] class TestOAuthInteractive: From b7b83880c0d0019705af4406685b460e948c7738 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 13 Oct 2025 18:07:54 +0200 Subject: [PATCH 070/102] chore: comments --- app-hooks/post-installation/install-oidc-app.sh | 2 -- 1 file changed, 2 deletions(-) diff --git a/app-hooks/post-installation/install-oidc-app.sh b/app-hooks/post-installation/install-oidc-app.sh index 656f72f..8858f52 100755 --- a/app-hooks/post-installation/install-oidc-app.sh +++ b/app-hooks/post-installation/install-oidc-app.sh @@ -5,11 +5,9 @@ set -euox pipefail echo "Installing and configuring OIDC apps for testing..." # Enable the OIDC Identity Provider app -#php /var/www/html/occ app:install oidc || true php /var/www/html/occ app:enable oidc # Enable the user_oidc app (OIDC client for bearer token validation) -#php /var/www/html/occ app:install user_oidc || true php /var/www/html/occ app:enable user_oidc patch -u /var/www/html/custom_apps/user_oidc/lib/User/Backend.php -i /docker-entrypoint-hooks.d/post-installation/0001-Fix-Bearer-token-authentication-causing-session-logo.patch From 4fae78a0907efbff9552278be74b8bfdd4e77bf9 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 13 Oct 2025 18:07:55 +0200 Subject: [PATCH 071/102] test: disable oauth in ci --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 18dcfd6..e55b329 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -56,4 +56,4 @@ jobs: NEXTCLOUD_USERNAME: "admin" NEXTCLOUD_PASSWORD: "admin" run: | - uv run --frozen python -m pytest -m 'not interactive' + uv run --frozen python -m pytest -m 'not oauth' From e42cabb6ed4c958f62d9eac0252791c05f8f5fe2 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 13 Oct 2025 18:07:56 +0200 Subject: [PATCH 072/102] chore: logging --- scripts/verify_oidc.py | 290 ----------------------------------------- tests/conftest.py | 4 +- 2 files changed, 2 insertions(+), 292 deletions(-) delete mode 100755 scripts/verify_oidc.py diff --git a/scripts/verify_oidc.py b/scripts/verify_oidc.py deleted file mode 100755 index fff4c5e..0000000 --- a/scripts/verify_oidc.py +++ /dev/null @@ -1,290 +0,0 @@ -#!/usr/bin/env python3 -""" -Verification script for Nextcloud OIDC implementation. - -This script tests the OIDC endpoints to understand token format and capabilities. -Usage: python scripts/verify_oidc.py -""" - -import asyncio -import json -import sys - -import httpx - - -class NextcloudOIDCVerifier: - """Verify Nextcloud OIDC implementation details.""" - - def __init__(self, base_url: str): - self.base_url = base_url.rstrip("/") - self.client = httpx.AsyncClient(follow_redirects=True, timeout=30.0) - - async def close(self): - await self.client.aclose() - - async def get_discovery(self) -> dict: - """Fetch OIDC discovery document.""" - print(f"\n{'=' * 60}") - print("1. OIDC Discovery Endpoint") - print(f"{'=' * 60}") - - url = f"{self.base_url}/.well-known/openid-configuration" - print(f"URL: {url}") - - try: - response = await self.client.get(url) - response.raise_for_status() - discovery = response.json() - - print("\n✓ Discovery endpoint successful") - print(f"\nIssuer: {discovery.get('issuer')}") - print(f"Authorization endpoint: {discovery.get('authorization_endpoint')}") - print(f"Token endpoint: {discovery.get('token_endpoint')}") - print(f"Userinfo endpoint: {discovery.get('userinfo_endpoint')}") - print(f"JWKS URI: {discovery.get('jwks_uri')}") - print( - f"Registration endpoint: {discovery.get('registration_endpoint', 'NOT AVAILABLE')}" - ) - - print( - f"\nSupported scopes: {', '.join(discovery.get('scopes_supported', []))}" - ) - print( - f"Response types: {', '.join(discovery.get('response_types_supported', []))}" - ) - print( - f"Grant types: {', '.join(discovery.get('grant_types_supported', []))}" - ) - - return discovery - - except httpx.HTTPStatusError as e: - print(f"\n✗ Discovery failed: HTTP {e.response.status_code}") - print(f"Response: {e.response.text}") - sys.exit(1) - except Exception as e: - print(f"\n✗ Discovery failed: {e}") - sys.exit(1) - - async def get_jwks(self, jwks_uri: str) -> dict: - """Fetch JWKS to check if JWT tokens are supported.""" - print(f"\n{'=' * 60}") - print("2. JWKS Endpoint (JWT Support)") - print(f"{'=' * 60}") - - print(f"URL: {jwks_uri}") - - try: - response = await self.client.get(jwks_uri) - response.raise_for_status() - jwks = response.json() - - print("\n✓ JWKS endpoint successful") - print(f"Number of keys: {len(jwks.get('keys', []))}") - - for idx, key in enumerate(jwks.get("keys", []), 1): - print(f"\nKey {idx}:") - print(f" - Key type: {key.get('kty')}") - print(f" - Algorithm: {key.get('alg')}") - print(f" - Use: {key.get('use', 'N/A')}") - print(f" - Key ID: {key.get('kid', 'N/A')}") - - return jwks - - except Exception as e: - print(f"\n✗ JWKS failed: {e}") - return {} - - async def test_dynamic_registration( - self, registration_endpoint: str | None - ) -> dict | None: - """Test dynamic client registration.""" - print(f"\n{'=' * 60}") - print("3. Dynamic Client Registration") - print(f"{'=' * 60}") - - if not registration_endpoint: - print("✗ Dynamic registration not available (not in discovery)") - return None - - print(f"URL: {registration_endpoint}") - - client_metadata = { - "client_name": "Nextcloud MCP Server Test", - "redirect_uris": ["http://localhost:8000/oauth/callback"], - "token_endpoint_auth_method": "client_secret_post", - "grant_types": ["authorization_code", "refresh_token"], - "response_types": ["code"], - "scope": "openid profile email roles groups", - } - - print("\nRegistration payload:") - print(json.dumps(client_metadata, indent=2)) - - try: - response = await self.client.post( - registration_endpoint, - json=client_metadata, - headers={"Content-Type": "application/json"}, - ) - response.raise_for_status() - client_info = response.json() - - print("\n✓ Dynamic registration successful") - print(f"\nClient ID: {client_info.get('client_id')}") - print(f"Client Secret: {client_info.get('client_secret', 'N/A')[:20]}...") - print( - f"Client ID issued at: {client_info.get('client_id_issued_at', 'N/A')}" - ) - print( - f"Client secret expires at: {client_info.get('client_secret_expires_at', 'Never')}" - ) - - # Save for later use - with open("/tmp/nextcloud_oidc_client.json", "w") as f: - json.dump(client_info, f, indent=2) - print("\n✓ Client credentials saved to /tmp/nextcloud_oidc_client.json") - - return client_info - - except httpx.HTTPStatusError as e: - print(f"\n✗ Dynamic registration failed: HTTP {e.response.status_code}") - print(f"Response: {e.response.text}") - return None - except Exception as e: - print(f"\n✗ Dynamic registration failed: {e}") - return None - - async def check_introspection_endpoint(self, discovery: dict) -> bool: - """Check if token introspection endpoint exists.""" - print(f"\n{'=' * 60}") - print("4. Token Introspection Endpoint") - print(f"{'=' * 60}") - - introspection_endpoint = discovery.get("introspection_endpoint") - - if introspection_endpoint: - print(f"URL: {introspection_endpoint}") - print("✓ Introspection endpoint available") - return True - else: - print("✗ Introspection endpoint NOT available") - print("Note: Will need to use userinfo endpoint for token validation") - return False - - def print_summary( - self, discovery: dict, jwks_available: bool, registration_available: bool - ): - """Print implementation summary.""" - print(f"\n{'=' * 60}") - print("IMPLEMENTATION SUMMARY") - print(f"{'=' * 60}") - - print("\n📋 Nextcloud OIDC Capabilities:") - print(" ✓ Discovery endpoint: Available") - print( - f" {'✓' if jwks_available else '✗'} JWKS endpoint: {'Available' if jwks_available else 'Not Available'}" - ) - print( - f" {'✓' if registration_available else '✗'} Dynamic registration: {'Available' if registration_available else 'Not Available'}" - ) - print(f" {'✗'} Token introspection: Not Available (use userinfo)") - - print("\n🔑 Token Format:") - if jwks_available: - print(" ✓ JWT access tokens: SUPPORTED (RFC 9068)") - print(" - Must be enabled per-client in OIDC settings") - print(" - Default: Opaque tokens") - else: - print(" - Opaque tokens only") - - print("\n🔐 Authentication Strategy:") - print(" Primary: Userinfo endpoint validation") - print(" Alternative: JWT validation (if enabled per-client)") - - print("\n📦 Required Scopes:") - scopes = discovery.get("scopes_supported", []) - print(f" Available: {', '.join(scopes)}") - print(" Recommended for MCP: openid profile email") - - print("\n👤 User Context Extraction:") - print(" - Username: 'sub' or 'preferred_username' claim") - print(" - From: JWT claims OR userinfo endpoint") - print(" - Groups: Available via 'roles' or 'groups' scope") - - print("\n⚙️ Configuration Requirements:") - if registration_available: - print(" ✓ Dynamic registration enabled - zero-config deployment possible") - print(" - Clients expire after 3600s (1 hour)") - print(" - Max 100 dynamic clients per instance") - print(" - BruteForce protection enabled") - else: - print(" ✗ Dynamic registration disabled - manual client setup required") - print(" Admin must create client via: occ oidc:create") - - print("\n📝 Endpoints:") - print(f" Authorization: {discovery.get('authorization_endpoint')}") - print(f" Token: {discovery.get('token_endpoint')}") - print(f" Userinfo: {discovery.get('userinfo_endpoint')}") - print(f" JWKS: {discovery.get('jwks_uri')}") - - -async def main(): - """Run verification tests.""" - print("=" * 60) - print("Nextcloud OIDC Verification Script") - print("=" * 60) - - # Get Nextcloud URL - nextcloud_url = input( - "\nEnter Nextcloud URL (e.g., https://cloud.coutinho.io): " - ).strip() - if not nextcloud_url: - nextcloud_url = "https://cloud.coutinho.io" - - verifier = NextcloudOIDCVerifier(nextcloud_url) - - try: - # 1. Get discovery document - discovery = await verifier.get_discovery() - - # 2. Check JWKS - jwks_uri = discovery.get("jwks_uri") - jwks_available = False - if jwks_uri: - jwks = await verifier.get_jwks(jwks_uri) - jwks_available = len(jwks.get("keys", [])) > 0 - - # 3. Test dynamic registration - registration_endpoint = discovery.get("registration_endpoint") - if registration_endpoint: - print("\nTest dynamic registration? (y/n): ", end="") - test_reg = input().strip().lower() - if test_reg == "y": - client_info = await verifier.test_dynamic_registration( - registration_endpoint - ) - registration_available = client_info is not None - else: - registration_available = True - print("Skipping dynamic registration test") - else: - registration_available = False - - # 4. Check introspection - await verifier.check_introspection_endpoint(discovery) - - # 5. Print summary - verifier.print_summary(discovery, jwks_available, registration_available) - - print(f"\n{'=' * 60}") - print("Verification complete!") - print(f"{'=' * 60}\n") - - finally: - await verifier.close() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/tests/conftest.py b/tests/conftest.py index f5fcdb7..3775d2e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -698,9 +698,9 @@ async def interactive_oauth_token() -> str: }, ) - logger.info(f"Token response: {token_response.text}") + logger.debug(f"Token response: {token_response.text}") token_data = token_response.json() - logger.info(f"Token data: {token_data}") + logger.debug(f"Token data: {token_data}") access_token = token_data.get("access_token") # Shut down the server From b26ff4f9bc7e447385edd0dde7785538ce436d10 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 13 Oct 2025 18:07:57 +0200 Subject: [PATCH 073/102] test: Fix oauth interactive browser tests --- tests/conftest.py | 81 +++++++++--- tests/integration/test_oauth.py | 214 ++++++++++++++++---------------- 2 files changed, 174 insertions(+), 121 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 3775d2e..115b386 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -135,6 +135,49 @@ async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]: logger.warning(f"Error closing streamable HTTP client: {e}") +@pytest.fixture(scope="session") +async def nc_mcp_oauth_client() -> AsyncGenerator[ClientSession, Any]: + """ + Fixture to create an MCP client session for OAuth integration tests using streamable-http. + Connects to the OAuth-enabled MCP server on port 8001. + """ + logger.info("Creating Streamable HTTP client for OAuth MCP server") + streamable_context = streamablehttp_client("http://127.0.0.1:8001/mcp") + session_context = None + + try: + read_stream, write_stream, _ = await streamable_context.__aenter__() + session_context = ClientSession(read_stream, write_stream) + session = await session_context.__aenter__() + await session.initialize() + logger.info("OAuth MCP client session initialized successfully") + + yield session + + finally: + # Clean up in reverse order, ignoring task scope issues + if session_context is not None: + try: + await session_context.__aexit__(None, None, None) + except RuntimeError as e: + if "cancel scope" in str(e): + logger.debug(f"Ignoring cancel scope teardown issue: {e}") + else: + logger.warning(f"Error closing OAuth session: {e}") + except Exception as e: + logger.warning(f"Error closing OAuth session: {e}") + + try: + await streamable_context.__aexit__(None, None, None) + except RuntimeError as e: + if "cancel scope" in str(e): + logger.debug(f"Ignoring cancel scope teardown issue: {e}") + else: + logger.warning(f"Error closing OAuth streamable HTTP client: {e}") + except Exception as e: + logger.warning(f"Error closing OAuth streamable HTTP client: {e}") + + @pytest.fixture async def temporary_note(nc_client: NextcloudClient): """ @@ -613,29 +656,37 @@ async def interactive_oauth_token() -> str: pass def do_GET(self): - if self.path.startswith("/shutdown"): + # Ignore subsequent requests if we already have a code + # (this is a session-scoped fixture, so only process the first auth code) + if auth_state["code"] is not None: self.send_response(200) self.send_header("Content-type", "text/html") self.end_headers() self.wfile.write( - b"

Server shutting down...

" + b"

Authentication already completed

" ) - threading.Thread(target=httpd.shutdown).start() return + # Parse the callback request parsed_path = urlparse(self.path) query = parse_qs(parsed_path.query) code = query.get("code", [None])[0] - auth_state["code"] = code - logger.info( - f"OAuth callback received. Code: {code[:20] if code else 'None'}..." - ) - self.send_response(200) - self.send_header("Content-type", "text/html") - self.end_headers() - self.wfile.write( - b"

Authentication successful!

You can close this window.

" - ) + + # Only process if we have a valid code + if code: + auth_state["code"] = code + logger.info(f"OAuth callback received. Code: {code[:20]}...") + self.send_response(200) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write( + b"

Authentication successful!

You can close this window.

" + ) + else: + # Ignore requests without a code (e.g., favicon requests) + logger.debug(f"Ignoring request without auth code: {self.path}") + self.send_response(404) + self.end_headers() httpd = HTTPServer(("localhost", 8081), OAuthCallbackHandler) server_thread = threading.Thread(target=httpd.serve_forever) @@ -704,9 +755,9 @@ async def interactive_oauth_token() -> str: access_token = token_data.get("access_token") # Shut down the server - - await http_client.get("http://localhost:8081/shutdown") + # Call shutdown directly instead of via HTTP to avoid race conditions if httpd: + httpd.shutdown() httpd.server_close() if server_thread: server_thread.join(timeout=1) diff --git a/tests/integration/test_oauth.py b/tests/integration/test_oauth.py index c66308d..5974013 100644 --- a/tests/integration/test_oauth.py +++ b/tests/integration/test_oauth.py @@ -1,9 +1,12 @@ """Integration tests for OAuth authentication.""" import logging +import os import pytest +from httpx import HTTPStatusError +from nextcloud_mcp_server.auth import BearerAuth from nextcloud_mcp_server.client import NextcloudClient logger = logging.getLogger(__name__) @@ -11,121 +14,120 @@ logger = logging.getLogger(__name__) pytestmark = [pytest.mark.integration, pytest.mark.oauth] -class TestOAuthClient: - """Test OAuth-authenticated NextcloudClient.""" - - async def test_oauth_client_capabilities(self, nc_oauth_client: NextcloudClient): - """Test that OAuth client can fetch capabilities.""" - capabilities = await nc_oauth_client.capabilities() - - assert capabilities is not None - assert "ocs" in capabilities - logger.info( - f"OAuth client successfully fetched capabilities: {capabilities.get('ocs').get('meta')}" - ) - - async def test_oauth_client_notes_list(self, nc_oauth_client: NextcloudClient): - """Test that OAuth client can list notes.""" - notes = await nc_oauth_client.notes.get_all_notes() - - assert isinstance(notes, list) - logger.info(f"OAuth client successfully listed {len(notes)} notes") - - async def test_oauth_client_create_note(self, nc_oauth_client: NextcloudClient): - """Test that OAuth client can create and delete a note.""" - # Create note - note_title = "OAuth Test Note" - note_content = "This note was created with OAuth authentication" - - created_note = await nc_oauth_client.notes.create_note( - title=note_title, content=note_content - ) - - assert created_note is not None - assert created_note.get("title") == note_title - note_id = created_note.get("id") - assert note_id is not None - - logger.info(f"OAuth client successfully created note with ID: {note_id}") - - # Clean up - delete the note - try: - await nc_oauth_client.notes.delete_note(note_id=note_id) - logger.info(f"OAuth client successfully deleted note {note_id}") - except Exception as e: - logger.error(f"Failed to clean up test note {note_id}: {e}") - raise +# OAuth Client Tests -class TestOAuthTokenValidation: - """Test OAuth token validation and bearer auth.""" +async def test_oauth_client_capabilities(nc_oauth_client: NextcloudClient): + """Test that OAuth client can fetch capabilities.""" + capabilities = await nc_oauth_client.capabilities() - async def test_token_in_request_headers( - self, nc_oauth_client: NextcloudClient, oauth_token: str - ): - """Verify that bearer token is being used in requests.""" - # The client should be using BearerAuth - assert nc_oauth_client._auth is not None - - # Make a request and verify it works - capabilities = await nc_oauth_client.capabilities() - assert capabilities is not None - - logger.info("OAuth bearer token is correctly included in requests") - - async def test_invalid_token_fails(self): - """Test that an invalid token results in authentication failure.""" - import os - - from nextcloud_mcp_server.auth import BearerAuth - - nextcloud_host = os.getenv("NEXTCLOUD_HOST") - if not nextcloud_host: - pytest.skip("NEXTCLOUD_HOST not set") - - # Create client with invalid token using BearerAuth - invalid_client = NextcloudClient( - base_url=nextcloud_host, - username="testuser", - auth=BearerAuth("invalid_token_12345"), - ) - - # Attempt to use the client should fail with 401 - from httpx import HTTPStatusError - - with pytest.raises(HTTPStatusError) as exc_info: - await invalid_client.capabilities() - - assert exc_info.value.response.status_code == 401 - - await invalid_client.close() - logger.info("Invalid OAuth token correctly rejected") + assert capabilities is not None + assert "ocs" in capabilities + logger.info( + f"OAuth client successfully fetched capabilities: {capabilities.get('ocs').get('meta')}" + ) -class TestOAuthMCPIntegration: - """Test OAuth integration with MCP server.""" +async def test_oauth_client_notes_list(nc_oauth_client: NextcloudClient): + """Test that OAuth client can list notes.""" + notes = await nc_oauth_client.notes.get_all_notes() - async def test_mcp_oauth_server_connection(self, nc_mcp_oauth_client): - """Test connection to OAuth-enabled MCP server.""" - result = await nc_mcp_oauth_client.list_tools() - assert result is not None - assert len(result.tools) > 0 + assert isinstance(notes, list) + logger.info(f"OAuth client successfully listed {len(notes)} notes") - logger.info(f"OAuth MCP server has {len(result.tools)} tools available") - async def test_mcp_oauth_tool_execution(self, nc_mcp_oauth_client): - """Test executing a tool on the OAuth-enabled MCP server.""" - import json +async def test_oauth_client_create_note(nc_oauth_client: NextcloudClient): + """Test that OAuth client can create and delete a note.""" + # Create note + note_title = "OAuth Test Note" + note_content = "This note was created with OAuth authentication" - # Example: Execute the 'nc_tables_list_tables' tool - result = await nc_mcp_oauth_client.call_tool("nc_tables_list_tables") + created_note = await nc_oauth_client.notes.create_note( + title=note_title, content=note_content + ) - assert result.isError is False, f"Tool execution failed: {result.content}" - assert result.content is not None - notes_list = json.loads(result.content[0].text) + assert created_note is not None + assert created_note.get("title") == note_title + note_id = created_note.get("id") + assert note_id is not None - assert isinstance(notes_list, list) + logger.info(f"OAuth client successfully created note with ID: {note_id}") - logger.info( - f"Successfully executed 'nc_tables_list_tables' tool on OAuth MCP server and got {len(notes_list)} notes." - ) + # Clean up - delete the note + try: + await nc_oauth_client.notes.delete_note(note_id=note_id) + logger.info(f"OAuth client successfully deleted note {note_id}") + except Exception as e: + logger.error(f"Failed to clean up test note {note_id}: {e}") + raise + + +# OAuth Token Validation Tests + + +async def test_token_in_request_headers( + nc_oauth_client: NextcloudClient, interactive_oauth_token: str +): + """Verify that bearer token is being used in requests.""" + # The client should be using BearerAuth + assert nc_oauth_client._client.auth is not None + + # Make a request and verify it works + capabilities = await nc_oauth_client.capabilities() + assert capabilities is not None + + logger.info("OAuth bearer token is correctly included in requests") + + +async def test_invalid_token_fails(): + """Test that an invalid token results in authentication failure.""" + nextcloud_host = os.getenv("NEXTCLOUD_HOST") + if not nextcloud_host: + pytest.skip("NEXTCLOUD_HOST not set") + + # Create client with invalid token using BearerAuth + invalid_client = NextcloudClient( + base_url=nextcloud_host, + username="testuser", + auth=BearerAuth("invalid_token_12345"), + ) + + # Attempt to use a protected endpoint - should fail with 401 + # Note: capabilities endpoint is public and doesn't require auth + with pytest.raises(HTTPStatusError) as exc_info: + await invalid_client.notes.get_all_notes() + + assert exc_info.value.response.status_code == 401 + + await invalid_client.close() + logger.info("Invalid OAuth token correctly rejected") + + +# OAuth MCP Integration Tests + + +async def test_mcp_oauth_server_connection(nc_mcp_oauth_client): + """Test connection to OAuth-enabled MCP server.""" + result = await nc_mcp_oauth_client.list_tools() + assert result is not None + assert len(result.tools) > 0 + + logger.info(f"OAuth MCP server has {len(result.tools)} tools available") + + +async def test_mcp_oauth_tool_execution(nc_mcp_oauth_client): + """Test executing a tool on the OAuth-enabled MCP server.""" + import json + + # Example: Execute the 'nc_tables_list_tables' tool + result = await nc_mcp_oauth_client.call_tool("nc_tables_list_tables") + + assert result.isError is False, f"Tool execution failed: {result.content}" + assert result.content is not None + notes_list = json.loads(result.content[0].text) + + assert isinstance(notes_list, list) + + logger.info( + f"Successfully executed 'nc_tables_list_tables' tool on OAuth MCP server and got {len(notes_list)} notes." + ) From b3b7c90bd0d118858dea5729447c365a0cc0caeb Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 13 Oct 2025 18:07:57 +0200 Subject: [PATCH 074/102] chore: Move httpd server to separate fixture --- pyproject.toml | 3 ++ tests/conftest.py | 71 ++++++++++++++++++++++++++++++++--------------- 2 files changed, 51 insertions(+), 23 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cb79bb8..0e9d007 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,9 @@ markers = [ "integration: marks tests as slow (deselect with '-m \"not slow\"')", "oauth: marks tests as oauth (deselect with '-m \"not oauth\"')" ] +testpaths = [ + "tests", +] [tool.commitizen] name = "cz_conventional_commits" diff --git a/tests/conftest.py b/tests/conftest.py index 115b386..ab067fa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -632,18 +632,19 @@ async def nc_oauth_client( @pytest.fixture(scope="session") -async def interactive_oauth_token() -> str: +def oauth_callback_server(): """ - Fixture to obtain an OAuth access token for integration tests. + Fixture to create an HTTP server for OAuth callback handling. - This uses the interactive OAuth flow to get a token. + Yields a tuple of (auth_state, server_url) where: + - auth_state: A dict with {"code": None} that will be populated with the auth code + - server_url: The callback URL for the server (e.g., "http://localhost:8081") + + The server automatically shuts down when the fixture is torn down. """ - - import webbrowser from http.server import BaseHTTPRequestHandler, HTTPServer import threading from urllib.parse import urlparse, parse_qs - import time # Use a mutable container to share state across threads auth_state = {"code": None} @@ -688,13 +689,46 @@ async def interactive_oauth_token() -> str: self.send_response(404) self.end_headers() - httpd = HTTPServer(("localhost", 8081), OAuthCallbackHandler) - server_thread = threading.Thread(target=httpd.serve_forever) - server_thread.daemon = True - server_thread.start() + try: + # Start the HTTP server + httpd = HTTPServer(("localhost", 8081), OAuthCallbackHandler) + server_thread = threading.Thread(target=httpd.serve_forever) + server_thread.daemon = True + server_thread.start() + logger.info("OAuth callback server started on http://localhost:8081") + + # Yield the auth state and server URL + yield auth_state, "http://localhost:8081" + + finally: + # Clean up the server + if httpd: + logger.info("Shutting down OAuth callback server...") + shutdown_thread = threading.Thread(target=httpd.shutdown) + shutdown_thread.start() + shutdown_thread.join(timeout=2) # Wait up to 2 seconds for shutdown + httpd.server_close() + logger.info("OAuth callback server shut down successfully") + if server_thread: + server_thread.join(timeout=1) + + +@pytest.fixture(scope="session") +async def interactive_oauth_token(oauth_callback_server) -> str: + """ + Fixture to obtain an OAuth access token for integration tests. + + This uses the interactive OAuth flow to get a token. + Depends on oauth_callback_server fixture for HTTP callback handling. + """ + import webbrowser + import time from nextcloud_mcp_server.auth.client_registration import load_or_register_client + # Unpack the server fixture + auth_state, callback_url = oauth_callback_server + nextcloud_host = os.getenv("NEXTCLOUD_HOST") async with httpx.AsyncClient() as http_client: discovery_url = f"{nextcloud_host}/.well-known/openid-configuration" @@ -707,7 +741,7 @@ async def interactive_oauth_token() -> str: nextcloud_url=nextcloud_host, registration_endpoint=registration_endpoint, storage_path=".nextcloud_oauth_test_client.json", - redirect_uris=["http://localhost:8081"], + redirect_uris=[callback_url], force_register=True, ) @@ -719,11 +753,9 @@ async def interactive_oauth_token() -> str: ) # Construct authorization URL - auth_url = f"{authorization_endpoint}?response_type=code&client_id={client_info.client_id}&redirect_uri=http://localhost:8081&scope=openid%20profile%20email" + auth_url = f"{authorization_endpoint}?response_type=code&client_id={client_info.client_id}&redirect_uri={callback_url}&scope=openid%20profile%20email" - # Open login page first, then auth URL - # webbrowser.open(login_url) - # time.sleep(2) # Give browser time to load login page + # Open authorization URL in browser webbrowser.open(auth_url) # Wait for auth code with timeout @@ -743,7 +775,7 @@ async def interactive_oauth_token() -> str: data={ "grant_type": "authorization_code", "code": auth_code, - "redirect_uri": "http://localhost:8081", + "redirect_uri": callback_url, "client_id": client_info.client_id, "client_secret": client_info.client_secret, }, @@ -754,11 +786,4 @@ async def interactive_oauth_token() -> str: logger.debug(f"Token data: {token_data}") access_token = token_data.get("access_token") - # Shut down the server - # Call shutdown directly instead of via HTTP to avoid race conditions - if httpd: - httpd.shutdown() - httpd.server_close() - if server_thread: - server_thread.join(timeout=1) return access_token From f58a9883a695e4de3a6a9fde1009ec88de2d5a51 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 13 Oct 2025 18:07:58 +0200 Subject: [PATCH 075/102] test: Fix oauth2 token extract from starlette requests --- nextcloud_mcp_server/auth/context_helper.py | 16 +++++++++++++--- tests/conftest.py | 13 ++++++++++--- tests/integration/test_oauth.py | 14 +++++++++----- 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/nextcloud_mcp_server/auth/context_helper.py b/nextcloud_mcp_server/auth/context_helper.py index c081f84..6e0c0f2 100644 --- a/nextcloud_mcp_server/auth/context_helper.py +++ b/nextcloud_mcp_server/auth/context_helper.py @@ -30,9 +30,19 @@ def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient: ValueError: If username cannot be extracted from token """ try: - logger.info(f"Inspecting session object: {dir(ctx.request_context.session)}") - # Get AccessToken from MCP session (set by TokenVerifier) - access_token: AccessToken = ctx.request_context.session.access_token + # In Starlette with FastMCP OAuth, the authenticated user info is stored in request.user + # The FastMCP auth middleware sets request.user to an AuthenticatedUser object + # which contains the access_token + if hasattr(ctx.request_context.request, "user") and hasattr( + ctx.request_context.request.user, "access_token" + ): + access_token: AccessToken = ctx.request_context.request.user.access_token + logger.debug("Retrieved access token from request.user for OAuth request") + else: + logger.error( + "OAuth authentication failed: No access token found in request" + ) + raise AttributeError("No access token found in OAuth request context") # Extract username from resource field (RFC 8707) # We stored the username here during token verification diff --git a/tests/conftest.py b/tests/conftest.py index ab067fa..f3a85d7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -136,13 +136,20 @@ async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]: @pytest.fixture(scope="session") -async def nc_mcp_oauth_client() -> AsyncGenerator[ClientSession, Any]: +async def nc_mcp_oauth_client( + interactive_oauth_token: str, +) -> AsyncGenerator[ClientSession, Any]: """ Fixture to create an MCP client session for OAuth integration tests using streamable-http. - Connects to the OAuth-enabled MCP server on port 8001. + Connects to the OAuth-enabled MCP server on port 8001 with OAuth authentication. """ logger.info("Creating Streamable HTTP client for OAuth MCP server") - streamable_context = streamablehttp_client("http://127.0.0.1:8001/mcp") + + # Pass OAuth token as Bearer token in headers + headers = {"Authorization": f"Bearer {interactive_oauth_token}"} + streamable_context = streamablehttp_client( + "http://127.0.0.1:8001/mcp", headers=headers + ) session_context = None try: diff --git a/tests/integration/test_oauth.py b/tests/integration/test_oauth.py index 5974013..8c4866f 100644 --- a/tests/integration/test_oauth.py +++ b/tests/integration/test_oauth.py @@ -119,15 +119,19 @@ async def test_mcp_oauth_tool_execution(nc_mcp_oauth_client): """Test executing a tool on the OAuth-enabled MCP server.""" import json - # Example: Execute the 'nc_tables_list_tables' tool - result = await nc_mcp_oauth_client.call_tool("nc_tables_list_tables") + # Example: Execute the 'nc_notes_search_notes' tool + result = await nc_mcp_oauth_client.call_tool( + "nc_notes_search_notes", arguments={"query": ""} + ) assert result.isError is False, f"Tool execution failed: {result.content}" assert result.content is not None - notes_list = json.loads(result.content[0].text) + response_data = json.loads(result.content[0].text) - assert isinstance(notes_list, list) + # The search response should have a 'results' field containing the list + assert "results" in response_data + assert isinstance(response_data["results"], list) logger.info( - f"Successfully executed 'nc_tables_list_tables' tool on OAuth MCP server and got {len(notes_list)} notes." + f"Successfully executed 'nc_notes_search_notes' tool on OAuth MCP server and got {len(response_data['results'])} notes." ) From a4a7fb48d697abe0ac73f07d5435d642ca023895 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 13 Oct 2025 18:07:59 +0200 Subject: [PATCH 076/102] chore: Update --help --- README.md | 60 +++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 45 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index ab0f419..0c34eb4 100644 --- a/README.md +++ b/README.md @@ -60,33 +60,63 @@ Resources provide read-only access to data for browsing and discovery. Unlike to ### Local Installation 1. Clone the repository (if running from source): - ```bash + ```shell git clone https://github.com/cbcoutinho/nextcloud-mcp-server.git cd nextcloud-mcp-server ``` 2. Install the package dependencies (if running via CLI): - ```bash + ```shell uv sync ``` 3. Run the CLI --help command to see all available options - ```bash - $ uv run python -m nextcloud_mcp_server.app --help - Usage: python -m nextcloud_mcp_server.app [OPTIONS] + ```shell + $ uv run nextcloud-mcp-server --help + Usage: nextcloud-mcp-server [OPTIONS] + + Run the Nextcloud MCP server. + + Authentication Modes: + - BasicAuth: Set NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD + - OAuth: Leave USERNAME/PASSWORD unset (requires OIDC app enabled) + + Examples: + # BasicAuth mode (legacy) + $ nextcloud-mcp-server --host 0.0.0.0 --port 8000 + + # OAuth mode with auto-registration $ nextcloud-mcp-server --oauth + + # OAuth mode with pre-configured client $ nextcloud-mcp-server + --oauth --oauth-client-id=xxx --oauth-client-secret=yyy Options: - -h, --host TEXT [default: 127.0.0.1] - -p, --port INTEGER [default: 8000] - -w, --workers INTEGER - -r, --reload + -h, --host TEXT Server host [default: 127.0.0.1] + -p, --port INTEGER Server port [default: 8000] + -w, --workers INTEGER Number of worker processes + -r, --reload Enable auto-reload -l, --log-level [critical|error|warning|info|debug|trace] - [default: info] - -t, --transport [sse|streamable-http] - [default: sse] + Logging level [default: info] + -t, --transport [sse|streamable-http|http] + MCP transport protocol [default: sse] -e, --enable-app [notes|tables|webdav|calendar|contacts|deck] - Enable specific Nextcloud app APIs. Can be - specified multiple times. If not specified, - all apps are enabled. + Enable specific Nextcloud app APIs. Can + be specified multiple times. If not + specified, all apps are enabled. + --oauth / --no-oauth Force OAuth mode (if enabled) or + BasicAuth mode (if disabled). By default, + auto-detected based on environment + variables. + --oauth-client-id TEXT OAuth client ID (can also use + NEXTCLOUD_OIDC_CLIENT_ID env var) + --oauth-client-secret TEXT OAuth client secret (can also use + NEXTCLOUD_OIDC_CLIENT_SECRET env var) + --oauth-storage-path TEXT Path to store OAuth client credentials + (can also use + NEXTCLOUD_OIDC_CLIENT_STORAGE env var) + [default: .nextcloud_oauth_client.json] + --mcp-server-url TEXT MCP server URL for OAuth callbacks (can + also use NEXTCLOUD_MCP_SERVER_URL env + var) [default: http://localhost:8000] --help Show this message and exit. ``` From 2489a714b88fcd434bcc545b30c6aed943056746 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 13 Oct 2025 18:08:00 +0200 Subject: [PATCH 077/102] docs: Update README and docs --- README.md | 384 +++++++++++++++++++++++++++++++++++++++-- docs/authentication.md | 88 ++++++++++ docs/configuration.md | 243 ++++++++++++++++++++++++++ docs/installation.md | 256 +++++++++++++++++++++++++++ docs/oauth-setup.md | 225 ++++++++++++++++++++++++ 5 files changed, 1183 insertions(+), 13 deletions(-) create mode 100644 docs/authentication.md create mode 100644 docs/configuration.md create mode 100644 docs/installation.md create mode 100644 docs/oauth-setup.md diff --git a/README.md b/README.md index 0c34eb4..f23f087 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,39 @@ The Nextcloud MCP (Model Context Protocol) server allows Large Language Models ( The server provides integration with multiple Nextcloud apps, enabling LLMs to interact with your Nextcloud data through a rich set of tools and resources. +## Authentication Modes + +The Nextcloud MCP server supports two authentication modes: + +| Mode | Status | Security | Use Case | +|------|--------|----------|----------| +| **OAuth2/OIDC** | ✅ Recommended | 🔒 High | Production deployments, multi-user scenarios | +| **Basic Auth** | ⚠️ Legacy | ⚠️ Lower | Development, backward compatibility | + +### OAuth2/OIDC (Recommended) +- **Zero-config deployment** via dynamic client registration +- **No credential storage** in environment variables +- **Per-user authentication** with access tokens +- **Automatic token validation** via Nextcloud OIDC +- **Secure by design** following OAuth 2.0 standards + +> [!IMPORTANT] +> **Current Implementation Limitations:** +> - Only tested with Nextcloud `user_oidc` and `oidc` apps (Nextcloud as identity provider) +> - Requires a patch for Bearer token support on non-OCS endpoints (see [docs/oauth2-bearer-token-session-issue.md](docs/oauth2-bearer-token-session-issue.md)) +> - External identity providers (Azure AD, Keycloak, etc.) have not been tested +> - Dynamic client registration credentials expire (default: 1 hour) - use pre-configured clients for production + +### Basic Authentication (Legacy) +- **Simple setup** with username/password +- **Single-user** server instances +- **Credentials in environment** (less secure) +- **Maintained for compatibility** - will be deprecated in future versions + +**How it works:** The server automatically detects the authentication mode: +- **OAuth mode**: When `NEXTCLOUD_USERNAME` and `NEXTCLOUD_PASSWORD` are NOT set +- **BasicAuth mode**: When both username and password are provided + ## Supported Nextcloud Apps | App | Support Status | Description | @@ -126,18 +159,156 @@ A pre-built Docker image is available: `ghcr.io/cbcoutinho/nextcloud-mcp-server` ## Configuration -The server requires credentials to connect to your Nextcloud instance. Create a file named `.env` (or any name you prefer) in the directory where you'll run the server, based on the `env.sample` file: +The server requires configuration to connect to your Nextcloud instance. Create a file named `.env` (or any name you prefer) in the directory where you'll run the server, based on the `env.sample` file. + +### Option 1: OAuth2/OIDC Configuration (Recommended) ```dotenv -# .env +# .env file for OAuth mode +NEXTCLOUD_HOST=https://your.nextcloud.instance.com + +# OAuth Configuration (Optional - auto-registers if not provided) +NEXTCLOUD_OIDC_CLIENT_ID= +NEXTCLOUD_OIDC_CLIENT_SECRET= +NEXTCLOUD_OIDC_CLIENT_STORAGE=.nextcloud_oauth_client.json +NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000 + +# Leave these EMPTY for OAuth mode +NEXTCLOUD_USERNAME= +NEXTCLOUD_PASSWORD= +``` + +**Environment Variables:** + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `NEXTCLOUD_HOST` | ✅ Yes | - | Full URL of your Nextcloud instance | +| `NEXTCLOUD_OIDC_CLIENT_ID` | ⚠️ Optional | - | Pre-configured OAuth client ID (auto-registers if empty) | +| `NEXTCLOUD_OIDC_CLIENT_SECRET` | ⚠️ Optional | - | Pre-configured OAuth client secret | +| `NEXTCLOUD_OIDC_CLIENT_STORAGE` | ⚠️ Optional | `.nextcloud_oauth_client.json` | Path to store auto-registered client credentials | +| `NEXTCLOUD_MCP_SERVER_URL` | ⚠️ Optional | `http://localhost:8000` | MCP server URL for OAuth callbacks | + +**Prerequisites:** +- Nextcloud OIDC app installed and enabled +- Dynamic Client Registration enabled (for auto-registration) +- See [OAuth Setup Guide](#oauth-setup-guide) below for detailed instructions + +### Option 2: Basic Authentication (Legacy) + +> [!WARNING] +> **Security Notice:** Basic Authentication stores credentials in environment variables and is less secure than OAuth. It's maintained for backward compatibility only and may be deprecated in future versions. Use OAuth for production deployments. + +```dotenv +# .env file for BasicAuth mode NEXTCLOUD_HOST=https://your.nextcloud.instance.com NEXTCLOUD_USERNAME=your_nextcloud_username -NEXTCLOUD_PASSWORD=your_nextcloud_app_password_or_login_password +NEXTCLOUD_PASSWORD=your_app_password_or_password ``` +**Environment Variables:** + * `NEXTCLOUD_HOST`: The full URL of your Nextcloud instance. * `NEXTCLOUD_USERNAME`: Your Nextcloud username. -* `NEXTCLOUD_PASSWORD`: **Important:** It is highly recommended to use a dedicated Nextcloud App Password for security. You can generate one in your Nextcloud Security settings. Alternatively, you can use your regular login password, but this is less secure. +* `NEXTCLOUD_PASSWORD`: **Important:** Use a dedicated Nextcloud App Password for security. Generate one in your Nextcloud Security settings. Alternatively, use your login password (less secure). + +## OAuth Setup Guide + +This guide walks you through setting up OAuth2/OIDC authentication for the Nextcloud MCP server. + +### Step 1: Install Nextcloud OIDC App + +1. Open your Nextcloud instance as an administrator +2. Navigate to **Apps** → **Security** +3. Find and install the **OpenID Connect user backend** app +4. Enable the app + +### Step 2: Enable Dynamic Client Registration + +1. Navigate to **Settings** → **OIDC** (in Administration settings) +2. Find the **Dynamic Client Registration** section +3. Enable **"Allow dynamic client registration"** +4. (Optional) Configure client expiration time: + ```bash + # Via Nextcloud CLI (occ) - optional, default is 3600 seconds (1 hour) + php occ config:app:set oidc expire_time --value "86400" # 24 hours + ``` + +### Step 3: Configure MCP Server + +Choose one of two approaches: + +#### Approach A: Automatic Registration (Zero-config) + +**Best for:** Development, testing, short-lived deployments + +1. Create your `.env` file with only the host: + ```dotenv + NEXTCLOUD_HOST=https://your.nextcloud.instance.com + ``` + +2. Start the MCP server: + ```bash + export $(grep -v '^#' .env | xargs) + uv run nextcloud-mcp-server --oauth + ``` + +3. The server will automatically: + - Register a new OAuth client with Nextcloud + - Save credentials to `.nextcloud_oauth_client.json` + - Display registration confirmation in logs + +**Note:** Dynamically registered clients expire after 1 hour by default. The server checks credentials at startup and re-registers if expired. For long-running deployments, consider Approach B. + +#### Approach B: Pre-configured Client (Production) + +**Best for:** Production, long-running deployments + +1. Register a client via Nextcloud CLI: + ```bash + # SSH into your Nextcloud server + php occ oidc:create \ + --name="Nextcloud MCP Server" \ + --type=confidential \ + --redirect-uri="http://localhost:8000/oauth/callback" + + # Note the client_id and client_secret from output + ``` + +2. Add credentials to your `.env` file: + ```dotenv + NEXTCLOUD_HOST=https://your.nextcloud.instance.com + NEXTCLOUD_OIDC_CLIENT_ID=your-client-id-here + NEXTCLOUD_OIDC_CLIENT_SECRET=your-client-secret-here + ``` + +3. Start the server - it will use the pre-configured credentials + +**Benefits:** Pre-configured clients don't expire automatically and are more stable for production use. + +### Step 4: Verify OAuth Configuration + +Start the server and look for these log messages: + +``` +INFO OAuth mode detected (NEXTCLOUD_USERNAME/PASSWORD not set) +INFO Configuring MCP server for OAuth mode +INFO Performing OIDC discovery: https://your.nextcloud.instance.com/.well-known/openid-configuration +INFO OIDC discovery successful +INFO OAuth client ready: ... +INFO OAuth initialization complete +``` + +### Step 5: Test Authentication + +The MCP server is now configured for OAuth. When clients connect: + +1. Client receives OAuth authorization URL from the MCP server +2. User authenticates via browser to Nextcloud +3. Nextcloud redirects back with authorization code +4. Client exchanges code for access token +5. Client uses token to access MCP server + +All API requests to Nextcloud use the user's OAuth token, ensuring proper permissions and audit trails. ## Transport Types @@ -179,19 +350,45 @@ docker run -p 127.0.0.1:8000:8000 --env-file .env --rm ghcr.io/cbcoutinho/nextcl Ensure your environment variables are loaded, then run the server. You have several options: -#### Option 1: Using `nextcloud_mcp_server` cli (recommended) +#### Option 1: Using `nextcloud-mcp-server` CLI (recommended) + +**OAuth Mode (Recommended):** ```bash # Load environment variables from your .env file export $(grep -v '^#' .env | xargs) -# Run the app module directly with custom options -uv run python -m nextcloud_mcp_server.app --host 0.0.0.0 --port 8080 --log-level info +# Start with OAuth (auto-detected when USERNAME/PASSWORD not set) +uv run nextcloud-mcp-server --host 0.0.0.0 --port 8000 + +# Explicitly force OAuth mode +uv run nextcloud-mcp-server --oauth + +# OAuth with custom configuration +uv run nextcloud-mcp-server --oauth \ + --oauth-client-id=your-client-id \ + --oauth-client-secret=your-client-secret + +# OAuth with specific apps enabled +uv run nextcloud-mcp-server --oauth \ + --enable-app notes --enable-app calendar +``` + +**BasicAuth Mode (Legacy):** +```bash +# Load environment variables from your .env file (with USERNAME/PASSWORD set) +export $(grep -v '^#' .env | xargs) + +# Start with BasicAuth (auto-detected when USERNAME/PASSWORD are set) +uv run nextcloud-mcp-server --host 0.0.0.0 --port 8000 + +# Explicitly force BasicAuth mode +uv run nextcloud-mcp-server --no-oauth # Enable only specific Nextcloud app APIs -uv run python -m nextcloud_mcp_server.app --enable-app notes --enable-app calendar +uv run nextcloud-mcp-server --enable-app notes --enable-app calendar # Enable only WebDAV for file operations -uv run python -m nextcloud_mcp_server.app --enable-app webdav +uv run nextcloud-mcp-server --enable-app webdav ``` #### Option 2: Using `uvicorn` @@ -245,21 +442,44 @@ This can be useful for: Mount your environment file when running the container: +**OAuth Mode:** ```bash -# Run with all apps enabled (default) -docker run -p 127.0.0.1:8000:8000 --env-file .env --rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest +# Run with OAuth (auto-detected when USERNAME/PASSWORD not in .env) +docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \ + ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth + +# OAuth with persistent client storage +docker run -p 127.0.0.1:8000:8000 --env-file .env \ + -v $(pwd)/.oauth:/app/.oauth \ + --rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth + +# OAuth with specific apps enabled +docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \ + ghcr.io/cbcoutinho/nextcloud-mcp-server:latest \ + --oauth --enable-app notes --enable-app calendar +``` + +**BasicAuth Mode (Legacy):** +```bash +# Run with BasicAuth (auto-detected when USERNAME/PASSWORD in .env) +docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \ + ghcr.io/cbcoutinho/nextcloud-mcp-server:latest # Run with only specific apps enabled -docker run -p 127.0.0.1:8000:8000 --env-file .env --rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest \ +docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \ + ghcr.io/cbcoutinho/nextcloud-mcp-server:latest \ --enable-app notes --enable-app calendar # Run with only WebDAV -docker run -p 127.0.0.1:8000:8000 --env-file .env --rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest \ +docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \ + ghcr.io/cbcoutinho/nextcloud-mcp-server:latest \ --enable-app webdav ``` This will start the server and expose it on port 8000 of your local machine. +**Note for OAuth:** When using OAuth with Docker, ensure the `NEXTCLOUD_MCP_SERVER_URL` in your `.env` file matches the accessible URL of the container (e.g., `http://localhost:8000` for local development). + ## Usage Once the server is running, you can connect to it using an MCP client like `MCP Inspector`. Once your MCP server is running, launch MCP Inspector as follows: @@ -270,6 +490,144 @@ uv run mcp dev You can then connect to and interact with the server's tools and resources through your browser. +## Troubleshooting OAuth + +### Issue: "OAuth mode requires NEXTCLOUD_HOST environment variable" + +**Cause:** The `NEXTCLOUD_HOST` environment variable is not set or empty. + +**Solution:** +```bash +# Ensure NEXTCLOUD_HOST is set in your .env file +echo "NEXTCLOUD_HOST=https://your.nextcloud.instance.com" >> .env + +# Load environment variables +export $(grep -v '^#' .env | xargs) +``` + +### Issue: "OAuth mode requires either client credentials OR dynamic client registration" + +**Cause:** The Nextcloud OIDC app either: +1. Is not installed +2. Doesn't have dynamic client registration enabled +3. Isn't providing a registration endpoint + +**Solution:** +1. Verify OIDC app is installed: Navigate to Nextcloud **Apps** → **Security** +2. Enable dynamic client registration: + - Go to **Settings** → **OIDC** (Administration) + - Enable "Allow dynamic client registration" +3. Or provide pre-configured credentials: + ```dotenv + NEXTCLOUD_OIDC_CLIENT_ID=your-client-id + NEXTCLOUD_OIDC_CLIENT_SECRET=your-client-secret + ``` + +### Issue: "Stored client has expired" + +**Cause:** Dynamically registered OAuth clients expire (default: 1 hour). + +**Solution:** + +**Option 1:** Restart the server - it will automatically re-register +```bash +# Server checks credentials at startup and re-registers if expired +uv run nextcloud-mcp-server --oauth +``` + +**Option 2:** Use pre-configured credentials (recommended for production) +```bash +# Register permanent client via Nextcloud CLI +php occ oidc:create \ + --name="Nextcloud MCP Server" \ + --type=confidential \ + --redirect-uri="http://localhost:8000/oauth/callback" + +# Add to .env +NEXTCLOUD_OIDC_CLIENT_ID= +NEXTCLOUD_OIDC_CLIENT_SECRET= +``` + +**Option 3:** Increase expiration time +```bash +# Via Nextcloud occ command +php occ config:app:set oidc expire_time --value "86400" # 24 hours +``` + +### Issue: "HTTP 401 Unauthorized" when calling Nextcloud APIs + +**Cause:** OAuth tokens may not work with certain Nextcloud endpoints due to CORS middleware session handling. + +**Solution:** This is a known issue with the Nextcloud OIDC app. See [docs/oauth2-bearer-token-session-issue.md](docs/oauth2-bearer-token-session-issue.md) for details and workarounds. + +The issue affects app-specific APIs (like Notes) but not OCS APIs. A patch for the `user_oidc` app is available in the documentation. + +### Issue: "Permission denied" when reading/writing client credentials file + +**Cause:** The server cannot access the OAuth client storage file. + +**Solution:** +```bash +# Check file permissions +ls -la .nextcloud_oauth_client.json + +# Fix permissions (should be 0600) +chmod 600 .nextcloud_oauth_client.json + +# Ensure the directory is writable +chmod 755 $(dirname .nextcloud_oauth_client.json) +``` + +### Issue: Switching Between OAuth and BasicAuth + +**To switch from BasicAuth to OAuth:** +```bash +# Remove or comment out USERNAME/PASSWORD in .env +# Keep only NEXTCLOUD_HOST +sed -i 's/^NEXTCLOUD_USERNAME/#NEXTCLOUD_USERNAME/' .env +sed -i 's/^NEXTCLOUD_PASSWORD/#NEXTCLOUD_PASSWORD/' .env + +# Restart server with --oauth flag +uv run nextcloud-mcp-server --oauth +``` + +**To switch from OAuth to BasicAuth:** +```bash +# Add USERNAME/PASSWORD to .env +echo "NEXTCLOUD_USERNAME=your-username" >> .env +echo "NEXTCLOUD_PASSWORD=your-password" >> .env + +# Restart server with --no-oauth flag (or let auto-detection work) +uv run nextcloud-mcp-server --no-oauth +``` + +### Getting Help + +If you continue to experience issues: + +1. **Check logs:** Run with `--log-level debug` for detailed output + ```bash + uv run nextcloud-mcp-server --oauth --log-level debug + ``` + +2. **Verify OIDC discovery:** Check if the discovery endpoint is accessible + ```bash + curl https://your.nextcloud.instance.com/.well-known/openid-configuration + ``` + +3. **Check dynamic registration:** Verify the endpoint exists in the discovery response + ```json + { + "registration_endpoint": "https://your.nextcloud.instance.com/apps/oidc/register" + } + ``` + +4. **Open an issue:** If problems persist, open an issue on the [GitHub repository](https://github.com/cbcoutinho/nextcloud-mcp-server/issues) with: + - Server logs (with `--log-level debug`) + - Nextcloud version + - OIDC app version + - Error messages + ## References: - https://github.com/modelcontextprotocol/python-sdk diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 0000000..aabf0fd --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,88 @@ +# Authentication + +The Nextcloud MCP server supports two authentication modes for connecting to your Nextcloud instance. + +## Authentication Modes Comparison + +| Mode | Status | Security | Use Case | +|------|--------|----------|----------| +| **OAuth2/OIDC** | ✅ Recommended | 🔒 High | Production deployments, multi-user scenarios | +| **Basic Auth** | ⚠️ Legacy | ⚠️ Lower | Development, backward compatibility | + +## OAuth2/OIDC (Recommended) + +OAuth2/OIDC authentication provides secure, token-based authentication following modern security standards. + +### Benefits +- **Zero-config deployment** via dynamic client registration +- **No credential storage** in environment variables +- **Per-user authentication** with access tokens +- **Automatic token validation** via Nextcloud OIDC +- **Secure by design** following OAuth 2.0 standards + +### Current Implementation Limitations + +> [!IMPORTANT] +> - Only tested with Nextcloud `user_oidc` and `oidc` apps (Nextcloud as identity provider) +> - Requires a patch for Bearer token support on non-OCS endpoints (see [oauth2-bearer-token-session-issue.md](oauth2-bearer-token-session-issue.md)) +> - External identity providers (Azure AD, Keycloak, etc.) have not been tested +> - Dynamic client registration credentials expire (default: 1 hour) - use pre-configured clients for production + +### How OAuth Works + +When a client connects to the MCP server with OAuth enabled: + +1. Client receives OAuth authorization URL from the MCP server +2. User authenticates via browser to Nextcloud +3. Nextcloud redirects back with authorization code +4. Client exchanges code for access token +5. Client uses token to access MCP server + +All API requests to Nextcloud use the user's OAuth token, ensuring proper permissions and audit trails. + +### See Also +- [OAuth Setup Guide](oauth-setup.md) - Step-by-step setup instructions +- [Configuration](configuration.md) - Environment variables +- [Troubleshooting](troubleshooting.md) - Common OAuth issues + +## Basic Authentication (Legacy) + +Basic Authentication uses username and password credentials directly. + +### Benefits +- **Simple setup** with username/password +- **Single-user** server instances +- **Quick for development** and testing + +### Limitations +- **Credentials in environment** (less secure) +- **Single user only** - all requests use the same account +- **No audit trail** - all actions appear from the same user +- **Maintained for compatibility** - will be deprecated in future versions + +> [!WARNING] +> **Security Notice:** Basic Authentication stores credentials in environment variables and is less secure than OAuth. It's maintained for backward compatibility only and may be deprecated in future versions. Use OAuth for production deployments. + +### See Also +- [Configuration](configuration.md#basic-authentication-legacy) - BasicAuth environment variables +- [Running the Server](running.md#basicauth-mode-legacy) - BasicAuth examples + +## Mode Detection + +The server automatically detects the authentication mode: + +- **OAuth mode**: When `NEXTCLOUD_USERNAME` and `NEXTCLOUD_PASSWORD` are NOT set +- **BasicAuth mode**: When both username and password are provided + +You can also force a specific mode using CLI flags: +```bash +# Force OAuth mode +uv run nextcloud-mcp-server --oauth + +# Force BasicAuth mode +uv run nextcloud-mcp-server --no-oauth +``` + +## Switching Between Modes + +See [Troubleshooting: Switching Between OAuth and BasicAuth](troubleshooting.md#switching-between-oauth-and-basicauth) for instructions. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..f1e881a --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,243 @@ +# Configuration + +The Nextcloud MCP server requires configuration to connect to your Nextcloud instance. Configuration is provided through environment variables, typically stored in a `.env` file. + +## Quick Start + +Create a `.env` file based on `env.sample`: + +```bash +cp env.sample .env +# Edit .env with your Nextcloud details +``` + +Then choose your authentication mode: + +- [OAuth2/OIDC Configuration](#oauth2oidc-configuration) (Recommended) +- [Basic Authentication Configuration](#basic-authentication-legacy) + +--- + +## OAuth2/OIDC Configuration + +OAuth2/OIDC is the recommended authentication mode for production deployments. + +### Minimal Configuration (Auto-registration) + +```dotenv +# .env file for OAuth with auto-registration +NEXTCLOUD_HOST=https://your.nextcloud.instance.com + +# Leave these EMPTY for OAuth mode +NEXTCLOUD_USERNAME= +NEXTCLOUD_PASSWORD= +``` + +This minimal configuration uses dynamic client registration to automatically register an OAuth client at startup. + +### Full Configuration (Pre-configured Client) + +```dotenv +# .env file for OAuth with pre-configured client +NEXTCLOUD_HOST=https://your.nextcloud.instance.com + +# OAuth Client Credentials (optional - auto-registers if not provided) +NEXTCLOUD_OIDC_CLIENT_ID=your-client-id +NEXTCLOUD_OIDC_CLIENT_SECRET=your-client-secret + +# OAuth Storage and Callback Settings (optional) +NEXTCLOUD_OIDC_CLIENT_STORAGE=.nextcloud_oauth_client.json +NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000 + +# Leave these EMPTY for OAuth mode +NEXTCLOUD_USERNAME= +NEXTCLOUD_PASSWORD= +``` + +### Environment Variables Reference + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `NEXTCLOUD_HOST` | ✅ Yes | - | Full URL of your Nextcloud instance (e.g., `https://cloud.example.com`) | +| `NEXTCLOUD_OIDC_CLIENT_ID` | ⚠️ Optional | - | OAuth client ID (auto-registers if empty) | +| `NEXTCLOUD_OIDC_CLIENT_SECRET` | ⚠️ Optional | - | OAuth client secret (auto-registers if empty) | +| `NEXTCLOUD_OIDC_CLIENT_STORAGE` | ⚠️ Optional | `.nextcloud_oauth_client.json` | Path to store auto-registered client credentials | +| `NEXTCLOUD_MCP_SERVER_URL` | ⚠️ Optional | `http://localhost:8000` | MCP server URL for OAuth callbacks | +| `NEXTCLOUD_USERNAME` | ❌ Must be empty | - | Leave empty to enable OAuth mode | +| `NEXTCLOUD_PASSWORD` | ❌ Must be empty | - | Leave empty to enable OAuth mode | + +### Prerequisites + +Before using OAuth configuration: + +1. **Install Nextcloud OIDC app** - Navigate to Apps → Security in your Nextcloud instance +2. **Enable dynamic client registration** (if using auto-registration) - Settings → OIDC +3. **Apply Bearer token patch** (if using non-OCS endpoints) - See [oauth2-bearer-token-session-issue.md](oauth2-bearer-token-session-issue.md) + +See the [OAuth Setup Guide](oauth-setup.md) for detailed instructions. + +--- + +## Basic Authentication (Legacy) + +Basic Authentication is maintained for backward compatibility. It uses username and password credentials. + +> [!WARNING] +> **Security Notice:** Basic Authentication stores credentials in environment variables and is less secure than OAuth. Use OAuth for production deployments. + +### Configuration + +```dotenv +# .env file for BasicAuth mode +NEXTCLOUD_HOST=https://your.nextcloud.instance.com +NEXTCLOUD_USERNAME=your_nextcloud_username +NEXTCLOUD_PASSWORD=your_app_password_or_password +``` + +### Environment Variables Reference + +| Variable | Required | Description | +|----------|----------|-------------| +| `NEXTCLOUD_HOST` | ✅ Yes | Full URL of your Nextcloud instance | +| `NEXTCLOUD_USERNAME` | ✅ Yes | Your Nextcloud username | +| `NEXTCLOUD_PASSWORD` | ✅ Yes | **Recommended:** Use a dedicated [Nextcloud App Password](https://docs.nextcloud.com/server/latest/user_manual/en/session_management.html#managing-devices). Generate one in Nextcloud Security settings. Alternatively, use your login password (less secure). | + +--- + +## Loading Environment Variables + +After creating your `.env` file, load the environment variables: + +### On Linux/macOS + +```bash +# Load all variables from .env +export $(grep -v '^#' .env | xargs) +``` + +### On Windows (PowerShell) + +```powershell +# Load variables from .env +Get-Content .env | ForEach-Object { + if ($_ -match '^\s*([^#][^=]*)\s*=\s*(.*)$') { + [Environment]::SetEnvironmentVariable($matches[1].Trim(), $matches[2].Trim(), "Process") + } +} +``` + +### Via Docker + +```bash +# Docker automatically loads .env when using --env-file +docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \ + ghcr.io/cbcoutinho/nextcloud-mcp-server:latest +``` + +--- + +## CLI Configuration + +Some configuration options can also be provided via CLI arguments. CLI arguments take precedence over environment variables. + +### OAuth-related CLI Options + +```bash +uv run nextcloud-mcp-server --help + +Options: + --oauth / --no-oauth Force OAuth mode (if enabled) or + BasicAuth mode (if disabled). By default, + auto-detected based on environment + variables. + --oauth-client-id TEXT OAuth client ID (can also use + NEXTCLOUD_OIDC_CLIENT_ID env var) + --oauth-client-secret TEXT OAuth client secret (can also use + NEXTCLOUD_OIDC_CLIENT_SECRET env var) + --oauth-storage-path TEXT Path to store OAuth client credentials + (can also use + NEXTCLOUD_OIDC_CLIENT_STORAGE env var) + [default: .nextcloud_oauth_client.json] + --mcp-server-url TEXT MCP server URL for OAuth callbacks (can + also use NEXTCLOUD_MCP_SERVER_URL env + var) [default: http://localhost:8000] +``` + +### Server Options + +```bash +Options: + -h, --host TEXT Server host [default: 127.0.0.1] + -p, --port INTEGER Server port [default: 8000] + -w, --workers INTEGER Number of worker processes + -r, --reload Enable auto-reload + -l, --log-level [critical|error|warning|info|debug|trace] + Logging level [default: info] + -t, --transport [sse|streamable-http|http] + MCP transport protocol [default: sse] +``` + +### App Selection + +```bash +Options: + -e, --enable-app [notes|tables|webdav|calendar|contacts|deck] + Enable specific Nextcloud app APIs. Can + be specified multiple times. If not + specified, all apps are enabled. +``` + +### Example CLI Usage + +```bash +# OAuth mode with custom client and port +uv run nextcloud-mcp-server --oauth \ + --oauth-client-id abc123 \ + --oauth-client-secret xyz789 \ + --port 8080 + +# BasicAuth mode with specific apps only +uv run nextcloud-mcp-server --no-oauth \ + --enable-app notes \ + --enable-app calendar +``` + +--- + +## Configuration Best Practices + +### For Development + +- Use BasicAuth for quick setup and testing +- Or use OAuth with auto-registration (dynamic client registration) +- Store `.env` file in your project directory +- Add `.env` to `.gitignore` + +### For Production + +- **Always use OAuth2/OIDC** with pre-configured clients +- Store OAuth client credentials securely +- Use environment variables from your deployment platform (Docker secrets, Kubernetes ConfigMaps, etc.) +- Never commit credentials to version control +- Set appropriate file permissions on credential storage: + ```bash + chmod 600 .nextcloud_oauth_client.json + ``` + +### For Docker + +- Mount OAuth client storage as a volume for persistence: + ```bash + docker run -v $(pwd)/.oauth:/app/.oauth --env-file .env \ + ghcr.io/cbcoutinho/nextcloud-mcp-server:latest + ``` +- Use Docker secrets for sensitive values in production + +--- + +## See Also + +- [OAuth Setup Guide](oauth-setup.md) - Step-by-step OAuth configuration +- [Authentication](authentication.md) - Authentication modes comparison +- [Running the Server](running.md) - Starting the server with different configurations +- [Troubleshooting](troubleshooting.md) - Common configuration issues diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..9080b66 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,256 @@ +# Installation + +This guide covers installing the Nextcloud MCP server on your system. + +## Prerequisites + +- **Python 3.11+** - Check with `python3 --version` +- **Access to a Nextcloud instance** - Self-hosted or cloud-hosted +- **Administrator access** (for OAuth setup) - Required to install OIDC app + +## Installation Methods + +Choose one of the following installation methods: + +- [Using uv (Recommended)](#using-uv-recommended) +- [Using pip](#using-pip) +- [Using Docker](#using-docker) +- [From Source](#from-source) + +--- + +## Using uv (Recommended) + +[uv](https://github.com/astral-sh/uv) is a fast Python package installer and resolver. + +### Install uv + +```bash +# On macOS/Linux +curl -LsSf https://astral.sh/uv/install.sh | sh + +# On Windows +powershell -c "irm https://astral.sh/uv/install.ps1 | iex" +``` + +### Install Nextcloud MCP Server + +```bash +# Install from PyPI +uv pip install nextcloud-mcp-server + +# Or install directly using uvx +uvx nextcloud-mcp-server --help +``` + +### Verify Installation + +```bash +uv run nextcloud-mcp-server --help +``` + +--- + +## Using pip + +Standard installation using pip: + +```bash +# Create a virtual environment (recommended) +python3 -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate + +# Install from PyPI +pip install nextcloud-mcp-server + +# Verify installation +nextcloud-mcp-server --help +``` + +--- + +## Using Docker + +A pre-built Docker image is available for easy deployment. + +### Pull the Image + +```bash +docker pull ghcr.io/cbcoutinho/nextcloud-mcp-server:latest +``` + +### Run the Container + +```bash +# Prepare your .env file first (see Configuration guide) + +# Run with environment file +docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \ + ghcr.io/cbcoutinho/nextcloud-mcp-server:latest +``` + +### Docker Compose + +Create a `docker-compose.yml`: + +```yaml +version: '3.8' + +services: + mcp: + image: ghcr.io/cbcoutinho/nextcloud-mcp-server:latest + ports: + - "127.0.0.1:8000:8000" + env_file: + - .env + volumes: + # For persistent OAuth client storage + - ./oauth-storage:/app/.oauth + restart: unless-stopped +``` + +Start the service: + +```bash +docker-compose up -d +``` + +--- + +## From Source + +Install from the GitHub repository: + +### Clone the Repository + +```bash +git clone https://github.com/cbcoutinho/nextcloud-mcp-server.git +cd nextcloud-mcp-server +``` + +### Install Dependencies + +#### Using uv (Recommended) + +```bash +# Install dependencies +uv sync + +# Install development dependencies (optional) +uv sync --group dev +``` + +#### Using pip + +```bash +# Create virtual environment +python3 -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate + +# Install in development mode +pip install -e . + +# Install development dependencies (optional) +pip install -e ".[dev]" +``` + +### Verify Installation + +```bash +# With uv +uv run nextcloud-mcp-server --help + +# With pip +nextcloud-mcp-server --help +``` + +--- + +## Next Steps + +After installation: + +1. **Configure the server** - See [Configuration Guide](configuration.md) +2. **Set up authentication** - See [OAuth Setup Guide](oauth-setup.md) or [Authentication](authentication.md) +3. **Run the server** - See [Running the Server](running.md) + +## Updating + +### Update with uv + +```bash +uv pip install --upgrade nextcloud-mcp-server +``` + +### Update with pip + +```bash +pip install --upgrade nextcloud-mcp-server +``` + +### Update Docker Image + +```bash +docker pull ghcr.io/cbcoutinho/nextcloud-mcp-server:latest +docker-compose up -d # Restart with new image +``` + +### Update from Source + +```bash +cd nextcloud-mcp-server +git pull origin master +uv sync # or: pip install -e . +``` + +## Troubleshooting Installation + +### Issue: "Python version too old" + +**Cause:** Python 3.11+ is required. + +**Solution:** +```bash +# Check your Python version +python3 --version + +# Install Python 3.11+ from: +# - https://www.python.org/downloads/ +# - Or use your system package manager (apt, brew, etc.) +``` + +### Issue: "Command not found: nextcloud-mcp-server" + +**Cause:** The package is not in your PATH. + +**Solution:** +```bash +# Ensure your virtual environment is activated +source venv/bin/activate + +# Or use uv run +uv run nextcloud-mcp-server --help + +# Or use python -m +python -m nextcloud_mcp_server.app --help +``` + +### Issue: Docker permission denied + +**Cause:** Docker requires elevated permissions. + +**Solution:** +```bash +# Add your user to the docker group (Linux) +sudo usermod -aG docker $USER +# Log out and back in + +# Or use sudo +sudo docker run ... +``` + +## See Also + +- [Configuration Guide](configuration.md) - Environment variables and settings +- [OAuth Setup Guide](oauth-setup.md) - OAuth authentication setup +- [Running the Server](running.md) - Starting and managing the server diff --git a/docs/oauth-setup.md b/docs/oauth-setup.md new file mode 100644 index 0000000..29343b1 --- /dev/null +++ b/docs/oauth-setup.md @@ -0,0 +1,225 @@ +# OAuth Setup Guide + +This guide walks you through setting up OAuth2/OIDC authentication for the Nextcloud MCP server. + +## Prerequisites + +- Nextcloud instance with administrator access +- Python 3.11+ installed +- Nextcloud MCP server installed (see [Installation Guide](installation.md)) + +## Step 1: Install Nextcloud OIDC App + +1. Open your Nextcloud instance as an administrator +2. Navigate to **Apps** → **Security** +3. Find and install the **OpenID Connect user backend** app +4. Enable the app + +## Step 2: Enable Dynamic Client Registration + +1. Navigate to **Settings** → **OIDC** (in Administration settings) +2. Find the **Dynamic Client Registration** section +3. Enable **"Allow dynamic client registration"** +4. (Optional) Configure client expiration time: + ```bash + # Via Nextcloud CLI (occ) - optional, default is 3600 seconds (1 hour) + php occ config:app:set oidc expire_time --value "86400" # 24 hours + ``` + +## Step 3: Choose Your Setup Approach + +You have two options for configuring OAuth clients: + +### Approach A: Automatic Registration (Zero-config) + +**Best for:** Development, testing, short-lived deployments + +**How it works:** The MCP server automatically registers a new OAuth client with Nextcloud at startup using dynamic client registration. + +**Pros:** +- Zero configuration required +- Quick to set up +- No manual client management + +**Cons:** +- Clients expire (default: 1 hour) +- Server must re-register on restart if expired +- Not recommended for long-running production deployments + +[Jump to Approach A setup →](#approach-a-automatic-registration) + +### Approach B: Pre-configured Client (Production) + +**Best for:** Production, long-running deployments + +**How it works:** You manually create an OAuth client via Nextcloud CLI and provide credentials to the MCP server. + +**Pros:** +- Credentials don't expire +- Stable for production use +- More control over client configuration + +**Cons:** +- Requires manual setup +- Needs access to Nextcloud server CLI + +[Jump to Approach B setup →](#approach-b-pre-configured-client) + +--- + +## Approach A: Automatic Registration + +### 1. Configure Environment + +Create your `.env` file with only the Nextcloud host: + +```dotenv +# .env file +NEXTCLOUD_HOST=https://your.nextcloud.instance.com + +# Leave these EMPTY for OAuth mode +NEXTCLOUD_USERNAME= +NEXTCLOUD_PASSWORD= +``` + +### 2. Start the MCP Server + +```bash +# Load environment variables +export $(grep -v '^#' .env | xargs) + +# Start server with OAuth enabled +uv run nextcloud-mcp-server --oauth +``` + +### 3. Verify Registration + +The server will automatically register a new OAuth client. Look for these log messages: + +``` +INFO OAuth mode detected (NEXTCLOUD_USERNAME/PASSWORD not set) +INFO Configuring MCP server for OAuth mode +INFO Performing OIDC discovery: https://your.nextcloud.instance.com/.well-known/openid-configuration +INFO OIDC discovery successful +INFO Attempting dynamic client registration... +INFO Dynamic client registration successful +INFO OAuth client ready: ... +INFO Saved OAuth client credentials to .nextcloud_oauth_client.json +INFO OAuth initialization complete +``` + +### 4. Client Credential Storage + +Registered client credentials are saved to `.nextcloud_oauth_client.json` by default. The server will: +- Load existing credentials on startup +- Check if they've expired +- Re-register automatically if expired or missing + +**Note:** Since dynamically registered clients expire (default: 1 hour), the server checks credentials at startup. For long-running deployments, consider using Approach B (pre-configured clients) instead. + +--- + +## Approach B: Pre-configured Client + +### 1. Register Client via Nextcloud CLI + +SSH into your Nextcloud server and run: + +```bash +# Create OAuth client +php occ oidc:create \ + --name="Nextcloud MCP Server" \ + --type=confidential \ + --redirect-uri="http://localhost:8000/oauth/callback" + +# Example output: +# Client ID: abc123xyz +# Client Secret: secret456def +``` + +**Note:** Adjust the `--redirect-uri` to match your MCP server URL if different from `http://localhost:8000`. + +### 2. Configure Environment + +Add the client credentials to your `.env` file: + +```dotenv +# .env file +NEXTCLOUD_HOST=https://your.nextcloud.instance.com + +# OAuth Client Credentials +NEXTCLOUD_OIDC_CLIENT_ID=abc123xyz +NEXTCLOUD_OIDC_CLIENT_SECRET=secret456def + +# Optional: Custom OAuth configuration +NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000 +NEXTCLOUD_OIDC_CLIENT_STORAGE=.nextcloud_oauth_client.json + +# Leave these EMPTY for OAuth mode +NEXTCLOUD_USERNAME= +NEXTCLOUD_PASSWORD= +``` + +See [Configuration Guide](configuration.md#oauth2oidc-configuration) for all available options. + +### 3. Start the MCP Server + +```bash +# Load environment variables +export $(grep -v '^#' .env | xargs) + +# Start server - it will use pre-configured credentials +uv run nextcloud-mcp-server --oauth +``` + +### 4. Verify Configuration + +Look for these log messages: + +``` +INFO OAuth mode detected (NEXTCLOUD_USERNAME/PASSWORD not set) +INFO Configuring MCP server for OAuth mode +INFO Performing OIDC discovery: https://your.nextcloud.instance.com/.well-known/openid-configuration +INFO OIDC discovery successful +INFO Using pre-configured OAuth client: abc123xyz +INFO OAuth initialization complete +``` + +**Benefits:** Pre-configured clients don't expire automatically and are more stable for production use. + +--- + +## Step 4: Test Authentication + +The MCP server is now configured for OAuth. When clients connect: + +1. Client connects to MCP server +2. Server provides OAuth authorization URL +3. User opens URL in browser and authenticates to Nextcloud +4. Nextcloud redirects back with authorization code +5. Client exchanges code for access token +6. Client uses Bearer token to access MCP server +7. All Nextcloud API requests use the user's OAuth token + +### Test with MCP Inspector + +```bash +# Start MCP Inspector +uv run mcp dev + +# In the browser UI: +# 1. Enter your MCP server URL (e.g., http://localhost:8000) +# 2. Complete OAuth flow in browser +# 3. Test tools and resources +``` + +## Next Steps + +- [Running the Server](running.md) - Additional server options +- [Configuration](configuration.md) - All environment variables +- [Troubleshooting](troubleshooting.md) - Common OAuth issues + +## See Also + +- [Authentication Overview](authentication.md) - OAuth vs BasicAuth comparison +- [OAuth Bearer Token Issue](oauth2-bearer-token-session-issue.md) - Required patch for non-OCS endpoints From 9ef9fff2b0c3f1a44b62bf1a246795dfc710530a Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 13 Oct 2025 18:08:01 +0200 Subject: [PATCH 078/102] docs: Update Docs --- docs/running.md | 440 +++++++++++++++++++++++++++++++++ docs/troubleshooting.md | 531 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 971 insertions(+) create mode 100644 docs/running.md create mode 100644 docs/troubleshooting.md diff --git a/docs/running.md b/docs/running.md new file mode 100644 index 0000000..5c91b50 --- /dev/null +++ b/docs/running.md @@ -0,0 +1,440 @@ +# Running the Server + +This guide covers different ways to start and run the Nextcloud MCP server. + +## Prerequisites + +Before running the server: + +1. **Install the server** - See [Installation Guide](installation.md) +2. **Configure environment** - See [Configuration Guide](configuration.md) +3. **Set up authentication** - See [OAuth Setup](oauth-setup.md) or [Authentication](authentication.md) + +--- + +## Quick Start + +Load your environment variables and start the server: + +```bash +# Load environment variables from .env +export $(grep -v '^#' .env | xargs) + +# Start the server +uv run nextcloud-mcp-server +``` + +The server will start on `http://127.0.0.1:8000` by default. + +--- + +## Running Locally + +### Method 1: Using nextcloud-mcp-server CLI (Recommended) + +The CLI provides a simple interface with built-in defaults: + +#### OAuth Mode + +```bash +# Auto-detected when NEXTCLOUD_USERNAME/PASSWORD not set +uv run nextcloud-mcp-server + +# Explicitly force OAuth mode +uv run nextcloud-mcp-server --oauth + +# OAuth with custom host and port +uv run nextcloud-mcp-server --oauth --host 0.0.0.0 --port 8080 + +# OAuth with pre-configured client +uv run nextcloud-mcp-server --oauth \ + --oauth-client-id abc123 \ + --oauth-client-secret xyz789 + +# OAuth with specific apps only +uv run nextcloud-mcp-server --oauth \ + --enable-app notes \ + --enable-app calendar +``` + +#### BasicAuth Mode (Legacy) + +```bash +# Auto-detected when NEXTCLOUD_USERNAME/PASSWORD are set +uv run nextcloud-mcp-server + +# Explicitly force BasicAuth mode +uv run nextcloud-mcp-server --no-oauth + +# BasicAuth with specific apps +uv run nextcloud-mcp-server --no-oauth \ + --enable-app notes \ + --enable-app webdav +``` + +### Method 2: Using uvicorn + +For more control over server options (workers, reload, etc.): + +```bash +# Load environment variables +export $(grep -v '^#' .env | xargs) + +# Run with uvicorn +uv run uvicorn nextcloud_mcp_server.app:get_app \ + --factory \ + --host 127.0.0.1 \ + --port 8000 \ + --reload # Enable auto-reload for development +``` + +See all uvicorn options at [https://www.uvicorn.org/settings/](https://www.uvicorn.org/settings/) + +### Method 3: Using Python Module + +```bash +# Load environment variables +export $(grep -v '^#' .env | xargs) + +# Run as Python module +python -m nextcloud_mcp_server.app --oauth --port 8000 +``` + +--- + +## Running with Docker + +### Basic Docker Run + +```bash +# OAuth mode +docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \ + ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth + +# BasicAuth mode +docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \ + ghcr.io/cbcoutinho/nextcloud-mcp-server:latest +``` + +### Docker with Persistent OAuth Storage + +```bash +docker run -p 127.0.0.1:8000:8000 --env-file .env \ + -v $(pwd)/.oauth:/app/.oauth \ + --rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth +``` + +### Docker Compose + +Create `docker-compose.yml`: + +```yaml +version: '3.8' + +services: + mcp: + image: ghcr.io/cbcoutinho/nextcloud-mcp-server:latest + command: --oauth --enable-app notes --enable-app calendar + ports: + - "127.0.0.1:8000:8000" + env_file: + - .env + volumes: + - ./oauth-storage:/app/.oauth + restart: unless-stopped +``` + +Start the service: + +```bash +# Start in foreground +docker-compose up + +# Start in background +docker-compose up -d + +# View logs +docker-compose logs -f + +# Stop the service +docker-compose down +``` + +--- + +## Server Options + +### Host and Port + +```bash +# Bind to all interfaces (accessible from network) +uv run nextcloud-mcp-server --host 0.0.0.0 --port 8000 + +# Bind to localhost only (default, more secure) +uv run nextcloud-mcp-server --host 127.0.0.1 --port 8000 + +# Use a different port +uv run nextcloud-mcp-server --port 8080 +``` + +**Security Note:** Using `--host 0.0.0.0` exposes the server to your network. Only use this if you understand the security implications. + +### Transport Protocols + +The server supports multiple MCP transport protocols: + +```bash +# Streamable HTTP (recommended) +uv run nextcloud-mcp-server --transport streamable-http + +# SSE - Server-Sent Events (default, deprecated) +uv run nextcloud-mcp-server --transport sse + +# HTTP +uv run nextcloud-mcp-server --transport http +``` + +> [!WARNING] +> SSE transport is deprecated and will be removed in a future version of the MCP spec. Please migrate to `streamable-http`. + +### Logging + +```bash +# Set log level (critical, error, warning, info, debug, trace) +uv run nextcloud-mcp-server --log-level debug + +# Production: use warning or error +uv run nextcloud-mcp-server --log-level warning +``` + +### Selective App Enablement + +By default, all supported Nextcloud apps are enabled. You can enable specific apps only: + +```bash +# Available apps: notes, tables, webdav, calendar, contacts, deck + +# Enable all apps (default) +uv run nextcloud-mcp-server + +# Enable only Notes +uv run nextcloud-mcp-server --enable-app notes + +# Enable multiple apps +uv run nextcloud-mcp-server \ + --enable-app notes \ + --enable-app calendar \ + --enable-app contacts + +# Enable only WebDAV for file operations +uv run nextcloud-mcp-server --enable-app webdav +``` + +**Use cases:** +- Reduce memory usage and startup time +- Limit functionality for security/organizational reasons +- Test specific app integrations +- Run lightweight instances with only needed features + +--- + +## Development Mode + +For active development with auto-reload: + +```bash +# Using uvicorn with reload +uv run uvicorn nextcloud_mcp_server.app:get_app \ + --factory \ + --reload \ + --host 127.0.0.1 \ + --port 8000 \ + --log-level debug +``` + +Or use the CLI with reload flag: + +```bash +uv run nextcloud-mcp-server --reload --log-level debug +``` + +--- + +## Connecting to the Server + +### Using MCP Inspector + +MCP Inspector is a browser-based tool for testing MCP servers: + +```bash +# Start MCP Inspector +uv run mcp dev + +# In the browser: +# 1. Enter server URL: http://localhost:8000 +# 2. Complete OAuth flow (if using OAuth) +# 3. Explore tools and resources +``` + +### Using MCP Clients + +MCP clients (like Claude Desktop, LLM IDEs) can connect to your server: + +1. Configure the client with your server URL +2. Complete OAuth authentication (if enabled) +3. Start interacting with Nextcloud through the LLM + +--- + +## Verifying Server Status + +### Check Server Health + +```bash +# Test if server is responding +curl http://localhost:8000/health + +# Expected response: HTTP 200 OK +``` + +### Check OAuth Configuration + +Look for these log messages on startup: + +**OAuth mode:** +``` +INFO OAuth mode detected (NEXTCLOUD_USERNAME/PASSWORD not set) +INFO Configuring MCP server for OAuth mode +INFO OIDC discovery successful +INFO OAuth client ready: ... +INFO OAuth initialization complete +``` + +**BasicAuth mode:** +``` +INFO BasicAuth mode detected (NEXTCLOUD_USERNAME/PASSWORD set) +INFO Initializing Nextcloud client with BasicAuth +``` + +--- + +## Process Management + +### Running as a Background Service + +#### Using systemd (Linux) + +Create `/etc/systemd/system/nextcloud-mcp.service`: + +```ini +[Unit] +Description=Nextcloud MCP Server +After=network.target + +[Service] +Type=simple +User=your-user +WorkingDirectory=/path/to/nextcloud-mcp-server +EnvironmentFile=/path/to/.env +ExecStart=/path/to/uv run nextcloud-mcp-server --oauth +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=multi-user.target +``` + +Enable and start: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable nextcloud-mcp +sudo systemctl start nextcloud-mcp +sudo systemctl status nextcloud-mcp +``` + +#### Using Docker Compose + +See [Docker Compose section](#docker-compose) above - includes `restart: unless-stopped`. + +### Monitoring Logs + +```bash +# Local installation with systemd +sudo journalctl -u nextcloud-mcp -f + +# Docker +docker logs -f + +# Docker Compose +docker-compose logs -f mcp +``` + +--- + +## Performance Tuning + +### Multiple Workers + +For production deployments with higher load: + +```bash +# Using CLI (if supported) +uv run nextcloud-mcp-server --workers 4 + +# Using uvicorn +uv run uvicorn nextcloud_mcp_server.app:get_app \ + --factory \ + --workers 4 \ + --host 0.0.0.0 \ + --port 8000 +``` + +### Production Settings + +```bash +# Recommended production configuration +uv run nextcloud-mcp-server \ + --oauth \ + --host 127.0.0.1 \ + --port 8000 \ + --log-level warning \ + --transport streamable-http \ + --workers 2 +``` + +--- + +## Troubleshooting + +### Server won't start + +Check logs for errors: +```bash +uv run nextcloud-mcp-server --log-level debug +``` + +Common issues: +- Environment variables not loaded - See [Configuration](configuration.md#loading-environment-variables) +- Port already in use - Try a different port with `--port` +- OAuth configuration errors - See [Troubleshooting](troubleshooting.md) + +### Can't connect to server + +1. Verify server is running: `curl http://localhost:8000/health` +2. Check firewall settings +3. Verify host binding (use `0.0.0.0` to allow network access) +4. Check OAuth authentication if enabled + +### OAuth authentication fails + +See [Troubleshooting OAuth](troubleshooting.md) for detailed OAuth troubleshooting. + +--- + +## See Also + +- [Configuration Guide](configuration.md) - Environment variables +- [OAuth Setup](oauth-setup.md) - OAuth authentication setup +- [Troubleshooting](troubleshooting.md) - Common issues and solutions +- [Installation](installation.md) - Installing the server diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..31ffb96 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,531 @@ +# Troubleshooting + +This guide covers common issues and solutions for the Nextcloud MCP server. + +## OAuth Issues + +### Issue: "OAuth mode requires NEXTCLOUD_HOST environment variable" + +**Cause:** The `NEXTCLOUD_HOST` environment variable is not set or empty. + +**Solution:** + +```bash +# Ensure NEXTCLOUD_HOST is set in your .env file +echo "NEXTCLOUD_HOST=https://your.nextcloud.instance.com" >> .env + +# Load environment variables +export $(grep -v '^#' .env | xargs) + +# Verify it's set +echo $NEXTCLOUD_HOST +``` + +--- + +### Issue: "OAuth mode requires either client credentials OR dynamic client registration" + +**Cause:** The Nextcloud OIDC app either: +1. Is not installed +2. Doesn't have dynamic client registration enabled +3. Isn't providing a registration endpoint + +**Solution:** + +**Option 1: Enable dynamic client registration** + +1. Verify OIDC app is installed: + - Navigate to Nextcloud **Apps** → **Security** + - Install "OpenID Connect user backend" if not present + +2. Enable dynamic client registration: + - Go to **Settings** → **OIDC** (Administration) + - Enable "Allow dynamic client registration" + +3. Verify the registration endpoint exists: + ```bash + curl https://your.nextcloud.instance.com/.well-known/openid-configuration | jq '.registration_endpoint' + # Should output: "https://your.nextcloud.instance.com/apps/oidc/register" + ``` + +**Option 2: Provide pre-configured credentials** + +Register a client and add credentials to `.env`: + +```bash +# On your Nextcloud server +php occ oidc:create \ + --name="Nextcloud MCP Server" \ + --type=confidential \ + --redirect-uri="http://localhost:8000/oauth/callback" + +# Add to .env +echo "NEXTCLOUD_OIDC_CLIENT_ID=" >> .env +echo "NEXTCLOUD_OIDC_CLIENT_SECRET=" >> .env +``` + +See [OAuth Setup Guide](oauth-setup.md) for detailed instructions. + +--- + +### Issue: "Stored client has expired" + +**Cause:** Dynamically registered OAuth clients expire (default: 1 hour). + +**Solution:** + +**Option 1: Restart the server** (automatic re-registration) + +```bash +# Server checks credentials at startup and re-registers if expired +uv run nextcloud-mcp-server --oauth +``` + +**Option 2: Use pre-configured credentials** (recommended for production) + +```bash +# Register permanent client via Nextcloud CLI +php occ oidc:create \ + --name="Nextcloud MCP Server" \ + --type=confidential \ + --redirect-uri="http://localhost:8000/oauth/callback" + +# Add to .env +NEXTCLOUD_OIDC_CLIENT_ID= +NEXTCLOUD_OIDC_CLIENT_SECRET= +``` + +**Option 3: Increase expiration time** + +```bash +# Via Nextcloud occ command (default: 3600 seconds) +php occ config:app:set oidc expire_time --value "86400" # 24 hours +``` + +--- + +### Issue: "HTTP 401 Unauthorized" when calling Nextcloud APIs + +**Cause:** OAuth Bearer tokens may not work with certain Nextcloud endpoints due to session handling in the CORS middleware. + +**Background:** The `user_oidc` app's CORS middleware interferes with Bearer token authentication for non-OCS endpoints (like Notes API). This affects app-specific APIs but not OCS APIs. + +**Solution:** + +A patch for the `user_oidc` app is required to fix Bearer token support. See [oauth2-bearer-token-session-issue.md](oauth2-bearer-token-session-issue.md) for: +- Detailed explanation of the issue +- Patch to apply to the `user_oidc` app +- Link to upstream pull request + +**Affected endpoints:** +- Notes API (`/apps/notes/api/`) +- Other app-specific endpoints + +**Unaffected endpoints:** +- OCS APIs (`/ocs/v2.php/`) +- Capabilities endpoint + +--- + +### Issue: "Permission denied" when reading/writing OAuth client credentials file + +**Cause:** The server cannot access the OAuth client storage file (default: `.nextcloud_oauth_client.json`). + +**Solution:** + +```bash +# Check file permissions +ls -la .nextcloud_oauth_client.json + +# Fix file permissions (should be 0600 - owner read/write only) +chmod 600 .nextcloud_oauth_client.json + +# Ensure the directory is writable +chmod 755 $(dirname .nextcloud_oauth_client.json) + +# If the file doesn't exist, ensure the directory is writable so it can be created +mkdir -p $(dirname .nextcloud_oauth_client.json) +``` + +--- + +### Issue: "OIDC discovery failed" or "Cannot reach OIDC discovery endpoint" + +**Cause:** The server cannot reach the Nextcloud OIDC discovery endpoint. + +**Solution:** + +1. Verify the Nextcloud URL is correct: + ```bash + echo $NEXTCLOUD_HOST + # Should be the full URL: https://your.nextcloud.instance.com + ``` + +2. Test the discovery endpoint manually: + ```bash + curl https://your.nextcloud.instance.com/.well-known/openid-configuration + # Should return JSON with OIDC configuration + ``` + +3. Check network connectivity: + ```bash + ping your.nextcloud.instance.com + ``` + +4. Verify OIDC app is installed and enabled in Nextcloud + +5. Check firewall rules if using Docker + +--- + +### Switching Between OAuth and BasicAuth + +#### To switch from BasicAuth to OAuth: + +```bash +# 1. Remove or comment out USERNAME/PASSWORD in .env +sed -i 's/^NEXTCLOUD_USERNAME/#NEXTCLOUD_USERNAME/' .env +sed -i 's/^NEXTCLOUD_PASSWORD/#NEXTCLOUD_PASSWORD/' .env + +# 2. Ensure NEXTCLOUD_HOST is set +grep NEXTCLOUD_HOST .env + +# 3. Restart server with OAuth +export $(grep -v '^#' .env | xargs) +uv run nextcloud-mcp-server --oauth +``` + +#### To switch from OAuth to BasicAuth: + +```bash +# 1. Add USERNAME/PASSWORD to .env +echo "NEXTCLOUD_USERNAME=your-username" >> .env +echo "NEXTCLOUD_PASSWORD=your-password" >> .env + +# 2. Restart server (BasicAuth auto-detected, or use --no-oauth) +export $(grep -v '^#' .env | xargs) +uv run nextcloud-mcp-server --no-oauth +``` + +--- + +## Configuration Issues + +### Issue: Environment variables not loaded + +**Cause:** Environment variables from `.env` file are not loaded into the shell. + +**Solution:** + +**On Linux/macOS:** +```bash +# Load all variables from .env +export $(grep -v '^#' .env | xargs) + +# Verify variables are set +env | grep NEXTCLOUD +``` + +**On Windows (PowerShell):** +```powershell +# Load variables from .env +Get-Content .env | ForEach-Object { + if ($_ -match '^\s*([^#][^=]*)\s*=\s*(.*)$') { + [Environment]::SetEnvironmentVariable($matches[1].Trim(), $matches[2].Trim(), "Process") + } +} + +# Verify variables are set +Get-ChildItem Env:NEXTCLOUD* +``` + +**With Docker:** +```bash +# Docker automatically loads .env when using --env-file +docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \ + ghcr.io/cbcoutinho/nextcloud-mcp-server:latest +``` + +--- + +### Issue: ".env file not found" + +**Cause:** The `.env` file doesn't exist or is in the wrong location. + +**Solution:** + +```bash +# Create .env from sample +cp env.sample .env + +# Edit with your Nextcloud details +nano .env # or vim, code, etc. + +# Ensure you're in the correct directory when running commands +pwd # Should be in the project directory containing .env +``` + +--- + +### Issue: "Invalid Nextcloud credentials" + +**Cause:** BasicAuth credentials are incorrect or the app password has been revoked. + +**Solution:** + +1. **Verify username:** + ```bash + # Username should match your Nextcloud login + echo $NEXTCLOUD_USERNAME + ``` + +2. **Generate a new app password:** + - Log in to Nextcloud + - Go to **Settings** → **Security** + - Under "Devices & sessions", create a new app password + - Update `.env` with the new password + +3. **Test credentials manually:** + ```bash + curl -u "$NEXTCLOUD_USERNAME:$NEXTCLOUD_PASSWORD" \ + "$NEXTCLOUD_HOST/ocs/v2.php/cloud/capabilities" \ + -H "OCS-APIRequest: true" + # Should return XML with capabilities + ``` + +--- + +## Server Issues + +### Issue: "Address already in use" / Port conflict + +**Cause:** Another process is using port 8000. + +**Solution:** + +**Option 1: Use a different port** +```bash +uv run nextcloud-mcp-server --port 8080 +``` + +**Option 2: Find and kill the process using the port** +```bash +# On Linux/macOS +lsof -ti:8000 | xargs kill -9 + +# On Windows +netstat -ano | findstr :8000 +taskkill /PID /F +``` + +**Option 3: Stop other MCP server instances** +```bash +# Check for running instances +ps aux | grep nextcloud-mcp-server + +# Kill specific process +kill +``` + +--- + +### Issue: Server starts but can't connect + +**Cause:** Server is bound to localhost only, or firewall is blocking connections. + +**Solution:** + +1. **Check server binding:** + ```bash + # Bind to all interfaces to allow network access + uv run nextcloud-mcp-server --host 0.0.0.0 --port 8000 + ``` + +2. **Test connectivity:** + ```bash + # Test from same machine + curl http://localhost:8000/health + + # Test from network (if using --host 0.0.0.0) + curl http://:8000/health + ``` + +3. **Check firewall:** + ```bash + # Linux (ufw) + sudo ufw allow 8000/tcp + + # Linux (firewalld) + sudo firewall-cmd --add-port=8000/tcp --permanent + sudo firewall-cmd --reload + ``` + +--- + +### Issue: Server crashes or restarts frequently + +**Cause:** Various issues including memory limits, uncaught exceptions, or OAuth token expiration. + +**Solution:** + +1. **Check logs with debug level:** + ```bash + uv run nextcloud-mcp-server --log-level debug + ``` + +2. **Monitor resource usage:** + ```bash + # Check memory and CPU + top -p $(pgrep -f nextcloud-mcp-server) + ``` + +3. **Use process manager for automatic restart:** + ```bash + # With systemd (see Running guide for full config) + sudo systemctl restart nextcloud-mcp + + # With Docker Compose (includes restart: unless-stopped) + docker-compose up -d + ``` + +4. **Check for OAuth credential expiration** (if using dynamic registration): + - See ["Stored client has expired"](#issue-stored-client-has-expired) above + +--- + +## Connection Issues + +### Issue: MCP client can't authenticate + +**Cause:** OAuth flow failing or credentials invalid. + +**Solution:** + +**For OAuth:** +1. Verify OAuth is configured correctly: + ```bash + uv run nextcloud-mcp-server --oauth --log-level debug + # Look for "OAuth initialization complete" + ``` + +2. Check that OIDC app is accessible: + ```bash + curl https://your.nextcloud.instance.com/.well-known/openid-configuration + ``` + +3. Verify MCP_SERVER_URL matches your setup: + ```bash + echo $NEXTCLOUD_MCP_SERVER_URL + # Should match the URL clients use to connect + ``` + +**For BasicAuth:** +1. Verify credentials work: + ```bash + curl -u "$NEXTCLOUD_USERNAME:$NEXTCLOUD_PASSWORD" \ + "$NEXTCLOUD_HOST/ocs/v2.php/cloud/capabilities" \ + -H "OCS-APIRequest: true" + ``` + +--- + +### Issue: Tools return errors or don't work + +**Cause:** Missing Nextcloud apps, incorrect permissions, or API issues. + +**Solution:** + +1. **Verify required Nextcloud apps are installed:** + - Notes: Install "Notes" app + - Calendar: Ensure CalDAV is enabled + - Contacts: Ensure CardDAV is enabled + - Deck: Install "Deck" app + +2. **Check user permissions:** + - Ensure the authenticated user has access to the resources + - Check sharing permissions for shared resources + +3. **Test API directly:** + ```bash + # Test Notes API + curl -u "$NEXTCLOUD_USERNAME:$NEXTCLOUD_PASSWORD" \ + "$NEXTCLOUD_HOST/apps/notes/api/v1/notes" + + # Test with OAuth Bearer token + curl -H "Authorization: Bearer $TOKEN" \ + "$NEXTCLOUD_HOST/apps/notes/api/v1/notes" + ``` + +4. **Check server logs for specific errors:** + ```bash + uv run nextcloud-mcp-server --log-level debug + ``` + +--- + +## Getting Help + +If you continue to experience issues: + +### 1. Enable Debug Logging + +```bash +uv run nextcloud-mcp-server --log-level debug +``` + +Review the logs for specific error messages. + +### 2. Verify OIDC Configuration (OAuth mode) + +```bash +# Check OIDC discovery +curl https://your.nextcloud.instance.com/.well-known/openid-configuration + +# Check registration endpoint exists +curl https://your.nextcloud.instance.com/.well-known/openid-configuration | jq '.registration_endpoint' +``` + +### 3. Test Nextcloud API Access + +```bash +# Test OCS API (should work with OAuth) +curl -H "Authorization: Bearer $TOKEN" \ + "$NEXTCLOUD_HOST/ocs/v2.php/cloud/capabilities?format=json" \ + -H "OCS-APIRequest: true" + +# Test app API (may need patch - see oauth2-bearer-token-session-issue.md) +curl -H "Authorization: Bearer $TOKEN" \ + "$NEXTCLOUD_HOST/apps/notes/api/v1/notes" +``` + +### 4. Check Versions + +```bash +# MCP Server version +uv run nextcloud-mcp-server --version + +# Python version +python3 --version + +# Nextcloud version (check in admin panel) +``` + +### 5. Open an Issue + +If problems persist, open an issue on the [GitHub repository](https://github.com/cbcoutinho/nextcloud-mcp-server/issues) with: + +- **Server logs** (with `--log-level debug`) +- **Nextcloud version** +- **OIDC app version** (if using OAuth) +- **Error messages** +- **Steps to reproduce** +- **Environment details** (OS, Python version, Docker vs local) + +--- + +## See Also + +- [OAuth Setup Guide](oauth-setup.md) - OAuth configuration +- [Configuration](configuration.md) - Environment variables +- [Running the Server](running.md) - Server options +- [OAuth Bearer Token Issue](oauth2-bearer-token-session-issue.md) - Required patch From bcf8daaa5d9b79b11a4f8562aef5845dd4a0375a Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 13 Oct 2025 18:08:02 +0200 Subject: [PATCH 079/102] docs: Update README --- README.md | 716 ++++++++++++------------------------------------------ 1 file changed, 149 insertions(+), 567 deletions(-) diff --git a/README.md b/README.md index f23f087..4fa0eaa 100644 --- a/README.md +++ b/README.md @@ -2,646 +2,228 @@ [![Docker Image](https://img.shields.io/badge/docker-ghcr.io/cbcoutinho/nextcloud--mcp--server-blue)](https://github.com/cbcoutinho/nextcloud-mcp-server/pkgs/container/nextcloud-mcp-server) -The Nextcloud MCP (Model Context Protocol) server allows Large Language Models (LLMs) like OpenAI's GPT, Google's Gemini, or Anthropic's Claude to interact with your Nextcloud instance. This enables automation of various Nextcloud actions, starting with the Notes API. +**Enable AI assistants to interact with your Nextcloud instance.** + +The Nextcloud MCP (Model Context Protocol) server allows Large Language Models like Claude, GPT, and Gemini to interact with your Nextcloud data through a secure API. Create notes, manage calendars, organize contacts, work with files, and more - all through natural language. ## Features -The server provides integration with multiple Nextcloud apps, enabling LLMs to interact with your Nextcloud data through a rich set of tools and resources. +### Supported Nextcloud Apps -## Authentication Modes +| App | Support | Features | +|-----|---------|----------| +| **Notes** | ✅ Full | Create, read, update, delete, search notes. Handle attachments. | +| **Calendar** | ✅ Full | Manage events, recurring events, reminders, attendees via CalDAV. | +| **Contacts** | ✅ Full | CRUD operations for contacts and address books via CardDAV. | +| **Files (WebDAV)** | ✅ Full | Complete file system access - browse, read, write, organize files. | +| **Deck** | ✅ Full | Project management - boards, stacks, cards, labels, assignments. | +| **Tables** | ⚠️ Partial | Row-level operations. Table management not yet supported. | +| **Tasks** | ❌ Planned | [Issue #73](https://github.com/cbcoutinho/nextcloud-mcp-server/issues/73) | -The Nextcloud MCP server supports two authentication modes: +Want to see another Nextcloud app supported? [Open an issue](https://github.com/cbcoutinho/nextcloud-mcp-server/issues) or contribute a pull request! -| Mode | Status | Security | Use Case | -|------|--------|----------|----------| -| **OAuth2/OIDC** | ✅ Recommended | 🔒 High | Production deployments, multi-user scenarios | -| **Basic Auth** | ⚠️ Legacy | ⚠️ Lower | Development, backward compatibility | +### Authentication -### OAuth2/OIDC (Recommended) -- **Zero-config deployment** via dynamic client registration -- **No credential storage** in environment variables -- **Per-user authentication** with access tokens -- **Automatic token validation** via Nextcloud OIDC -- **Secure by design** following OAuth 2.0 standards +| Mode | Security | Best For | +|------|----------|----------| +| **OAuth2/OIDC** ✅ | 🔒 High | Production, multi-user deployments | +| **Basic Auth** ⚠️ | Lower | Development, testing | -> [!IMPORTANT] -> **Current Implementation Limitations:** -> - Only tested with Nextcloud `user_oidc` and `oidc` apps (Nextcloud as identity provider) -> - Requires a patch for Bearer token support on non-OCS endpoints (see [docs/oauth2-bearer-token-session-issue.md](docs/oauth2-bearer-token-session-issue.md)) -> - External identity providers (Azure AD, Keycloak, etc.) have not been tested -> - Dynamic client registration credentials expire (default: 1 hour) - use pre-configured clients for production +OAuth2/OIDC provides secure, per-user authentication with access tokens. See [Authentication Guide](docs/authentication.md) for details. -### Basic Authentication (Legacy) -- **Simple setup** with username/password -- **Single-user** server instances -- **Credentials in environment** (less secure) -- **Maintained for compatibility** - will be deprecated in future versions +## Quick Start -**How it works:** The server automatically detects the authentication mode: -- **OAuth mode**: When `NEXTCLOUD_USERNAME` and `NEXTCLOUD_PASSWORD` are NOT set -- **BasicAuth mode**: When both username and password are provided +### 1. Install -## Supported Nextcloud Apps +```bash +# Using uv (recommended) +uv pip install nextcloud-mcp-server -| App | Support Status | Description | -|-----|----------------|-------------| -| **Notes** | ✅ Full Support | Create, read, update, delete, and search notes. Handle attachments via WebDAV. | -| **Calendar** | ✅ Full Support | Complete calendar integration - create, update, delete events. Support for recurring events, reminders, attendees, and all-day events via CalDAV. | -| **Tables** | ⚠️ Row Operations | Read table schemas and perform CRUD operations on table rows. Table management not yet supported. | -| **Files (WebDAV)** | ✅ Full Support | Complete file system access - browse directories, read/write files, create/delete resources. | -| **Contacts** | ✅ Full Support | Create, read, update, and delete contacts and address books via CardDAV. | -| **Deck** | ✅ Full Support | Complete project management - boards, stacks, cards, labels, user assignments. Full CRUD operations and advanced features. | -| **Tasks** | ❌ [Not Started](https://github.com/cbcoutinho/nextcloud-mcp-server/issues/73) | TBD | +# Or using pip +pip install nextcloud-mcp-server -Is there a Nextcloud app not present in this list that you'd like to be -included? Feel free to open an issue, or contribute via a pull-request. +# Or using Docker +docker pull ghcr.io/cbcoutinho/nextcloud-mcp-server:latest +``` -## Available Tools & Resources +See [Installation Guide](docs/installation.md) for detailed instructions. -Resources provide read-only access to data for browsing and discovery. Unlike tools, resources are automatically listed by MCP clients and enable LLMs to explore your Nextcloud data structure. +### 2. Configure -### Core Resources -| Resource | Description | -|----------|-------------| -| `nc://capabilities` | Access Nextcloud server capabilities | -| `notes://settings` | Access Notes app settings | -| `nc://Notes/{note_id}/attachments/{attachment_filename}` | Access attachments for notes | +Create a `.env` file: +```bash +# Copy the sample +cp env.sample .env +``` -### Tools vs Resources - -**Tools** are for actions and operations: -- Create, update, delete operations -- Structured responses with validation -- Error handling and business logic -- Examples: `deck_create_card`, `deck_update_stack` - -**Resources** are for data browsing and discovery: -- Read-only access to existing data -- Automatic listing by MCP clients -- Raw data format for exploration -- Examples: `nc://Deck/boards/{board_id}`, `nc://Deck/boards/{board_id}/stacks` - - -## Installation - -### Prerequisites - -* Python 3.11+ -* Access to a Nextcloud instance - -### Local Installation - -1. Clone the repository (if running from source): - ```shell - git clone https://github.com/cbcoutinho/nextcloud-mcp-server.git - cd nextcloud-mcp-server - ``` -2. Install the package dependencies (if running via CLI): - ```shell - uv sync - ``` - -3. Run the CLI --help command to see all available options - ```shell - $ uv run nextcloud-mcp-server --help - Usage: nextcloud-mcp-server [OPTIONS] - - Run the Nextcloud MCP server. - - Authentication Modes: - - BasicAuth: Set NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD - - OAuth: Leave USERNAME/PASSWORD unset (requires OIDC app enabled) - - Examples: - # BasicAuth mode (legacy) - $ nextcloud-mcp-server --host 0.0.0.0 --port 8000 - - # OAuth mode with auto-registration $ nextcloud-mcp-server --oauth - - # OAuth mode with pre-configured client $ nextcloud-mcp-server - --oauth --oauth-client-id=xxx --oauth-client-secret=yyy - - Options: - -h, --host TEXT Server host [default: 127.0.0.1] - -p, --port INTEGER Server port [default: 8000] - -w, --workers INTEGER Number of worker processes - -r, --reload Enable auto-reload - -l, --log-level [critical|error|warning|info|debug|trace] - Logging level [default: info] - -t, --transport [sse|streamable-http|http] - MCP transport protocol [default: sse] - -e, --enable-app [notes|tables|webdav|calendar|contacts|deck] - Enable specific Nextcloud app APIs. Can - be specified multiple times. If not - specified, all apps are enabled. - --oauth / --no-oauth Force OAuth mode (if enabled) or - BasicAuth mode (if disabled). By default, - auto-detected based on environment - variables. - --oauth-client-id TEXT OAuth client ID (can also use - NEXTCLOUD_OIDC_CLIENT_ID env var) - --oauth-client-secret TEXT OAuth client secret (can also use - NEXTCLOUD_OIDC_CLIENT_SECRET env var) - --oauth-storage-path TEXT Path to store OAuth client credentials - (can also use - NEXTCLOUD_OIDC_CLIENT_STORAGE env var) - [default: .nextcloud_oauth_client.json] - --mcp-server-url TEXT MCP server URL for OAuth callbacks (can - also use NEXTCLOUD_MCP_SERVER_URL env - var) [default: http://localhost:8000] - --help Show this message and exit. - ``` - -### Docker - -A pre-built Docker image is available: `ghcr.io/cbcoutinho/nextcloud-mcp-server` - -## Configuration - -The server requires configuration to connect to your Nextcloud instance. Create a file named `.env` (or any name you prefer) in the directory where you'll run the server, based on the `env.sample` file. - -### Option 1: OAuth2/OIDC Configuration (Recommended) - +**For OAuth (recommended):** ```dotenv -# .env file for OAuth mode NEXTCLOUD_HOST=https://your.nextcloud.instance.com - -# OAuth Configuration (Optional - auto-registers if not provided) -NEXTCLOUD_OIDC_CLIENT_ID= -NEXTCLOUD_OIDC_CLIENT_SECRET= -NEXTCLOUD_OIDC_CLIENT_STORAGE=.nextcloud_oauth_client.json -NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000 - -# Leave these EMPTY for OAuth mode -NEXTCLOUD_USERNAME= -NEXTCLOUD_PASSWORD= ``` -**Environment Variables:** - -| Variable | Required | Default | Description | -|----------|----------|---------|-------------| -| `NEXTCLOUD_HOST` | ✅ Yes | - | Full URL of your Nextcloud instance | -| `NEXTCLOUD_OIDC_CLIENT_ID` | ⚠️ Optional | - | Pre-configured OAuth client ID (auto-registers if empty) | -| `NEXTCLOUD_OIDC_CLIENT_SECRET` | ⚠️ Optional | - | Pre-configured OAuth client secret | -| `NEXTCLOUD_OIDC_CLIENT_STORAGE` | ⚠️ Optional | `.nextcloud_oauth_client.json` | Path to store auto-registered client credentials | -| `NEXTCLOUD_MCP_SERVER_URL` | ⚠️ Optional | `http://localhost:8000` | MCP server URL for OAuth callbacks | - -**Prerequisites:** -- Nextcloud OIDC app installed and enabled -- Dynamic Client Registration enabled (for auto-registration) -- See [OAuth Setup Guide](#oauth-setup-guide) below for detailed instructions - -### Option 2: Basic Authentication (Legacy) - -> [!WARNING] -> **Security Notice:** Basic Authentication stores credentials in environment variables and is less secure than OAuth. It's maintained for backward compatibility only and may be deprecated in future versions. Use OAuth for production deployments. - +**For Basic Auth:** ```dotenv -# .env file for BasicAuth mode NEXTCLOUD_HOST=https://your.nextcloud.instance.com -NEXTCLOUD_USERNAME=your_nextcloud_username -NEXTCLOUD_PASSWORD=your_app_password_or_password +NEXTCLOUD_USERNAME=your_username +NEXTCLOUD_PASSWORD=your_app_password ``` -**Environment Variables:** +See [Configuration Guide](docs/configuration.md) for all options. -* `NEXTCLOUD_HOST`: The full URL of your Nextcloud instance. -* `NEXTCLOUD_USERNAME`: Your Nextcloud username. -* `NEXTCLOUD_PASSWORD`: **Important:** Use a dedicated Nextcloud App Password for security. Generate one in your Nextcloud Security settings. Alternatively, use your login password (less secure). +### 3. Set Up Authentication -## OAuth Setup Guide +**OAuth Setup (recommended):** +1. Install Nextcloud OIDC app +2. Enable dynamic client registration +3. Start the server -This guide walks you through setting up OAuth2/OIDC authentication for the Nextcloud MCP server. +See [OAuth Setup Guide](docs/oauth-setup.md) for step-by-step instructions. -### Step 1: Install Nextcloud OIDC App - -1. Open your Nextcloud instance as an administrator -2. Navigate to **Apps** → **Security** -3. Find and install the **OpenID Connect user backend** app -4. Enable the app - -### Step 2: Enable Dynamic Client Registration - -1. Navigate to **Settings** → **OIDC** (in Administration settings) -2. Find the **Dynamic Client Registration** section -3. Enable **"Allow dynamic client registration"** -4. (Optional) Configure client expiration time: - ```bash - # Via Nextcloud CLI (occ) - optional, default is 3600 seconds (1 hour) - php occ config:app:set oidc expire_time --value "86400" # 24 hours - ``` - -### Step 3: Configure MCP Server - -Choose one of two approaches: - -#### Approach A: Automatic Registration (Zero-config) - -**Best for:** Development, testing, short-lived deployments - -1. Create your `.env` file with only the host: - ```dotenv - NEXTCLOUD_HOST=https://your.nextcloud.instance.com - ``` - -2. Start the MCP server: - ```bash - export $(grep -v '^#' .env | xargs) - uv run nextcloud-mcp-server --oauth - ``` - -3. The server will automatically: - - Register a new OAuth client with Nextcloud - - Save credentials to `.nextcloud_oauth_client.json` - - Display registration confirmation in logs - -**Note:** Dynamically registered clients expire after 1 hour by default. The server checks credentials at startup and re-registers if expired. For long-running deployments, consider Approach B. - -#### Approach B: Pre-configured Client (Production) - -**Best for:** Production, long-running deployments - -1. Register a client via Nextcloud CLI: - ```bash - # SSH into your Nextcloud server - php occ oidc:create \ - --name="Nextcloud MCP Server" \ - --type=confidential \ - --redirect-uri="http://localhost:8000/oauth/callback" - - # Note the client_id and client_secret from output - ``` - -2. Add credentials to your `.env` file: - ```dotenv - NEXTCLOUD_HOST=https://your.nextcloud.instance.com - NEXTCLOUD_OIDC_CLIENT_ID=your-client-id-here - NEXTCLOUD_OIDC_CLIENT_SECRET=your-client-secret-here - ``` - -3. Start the server - it will use the pre-configured credentials - -**Benefits:** Pre-configured clients don't expire automatically and are more stable for production use. - -### Step 4: Verify OAuth Configuration - -Start the server and look for these log messages: - -``` -INFO OAuth mode detected (NEXTCLOUD_USERNAME/PASSWORD not set) -INFO Configuring MCP server for OAuth mode -INFO Performing OIDC discovery: https://your.nextcloud.instance.com/.well-known/openid-configuration -INFO OIDC discovery successful -INFO OAuth client ready: ... -INFO OAuth initialization complete -``` - -### Step 5: Test Authentication - -The MCP server is now configured for OAuth. When clients connect: - -1. Client receives OAuth authorization URL from the MCP server -2. User authenticates via browser to Nextcloud -3. Nextcloud redirects back with authorization code -4. Client exchanges code for access token -5. Client uses token to access MCP server - -All API requests to Nextcloud use the user's OAuth token, ensuring proper permissions and audit trails. - -## Transport Types - -The server supports two transport types for MCP communication: - -### Streamable HTTP (Recommended) -The `streamable-http` transport is the recommended and modern transport type that provides improved streaming capabilities: +### 4. Run the Server ```bash -# Use streamable-http transport (recommended) -uv run python -m nextcloud_mcp_server.app --transport streamable-http -``` - -### SSE (Server-Sent Events) - Deprecated -> [!WARNING] -> ⚠️ **Deprecated**: SSE transport is deprecated and will be removed in a future version of the MCP spec. SSE will be supported for the foreseable future, but users are encouraged to switch to the new transport type. Please migrate to `streamable-http`. - -```bash -# SSE transport (deprecated - for backwards compatibility only) -uv run python -m nextcloud_mcp_server.app --transport sse -``` - -#### Docker Usage with Transports - -```bash -# Using SSE transport (default - deprecated) -docker run -p 127.0.0.1:8000:8000 --env-file .env --rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest - -# Using streamable-http transport (recommended) -docker run -p 127.0.0.1:8000:8000 --env-file .env --rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest \ - --transport streamable-http -``` - -**Note:** When using MCP clients, ensure your client supports the transport type you've configured on the server. Most modern MCP clients support streamable-http. - -## Running the Server - -### Locally - -Ensure your environment variables are loaded, then run the server. You have several options: - -#### Option 1: Using `nextcloud-mcp-server` CLI (recommended) - -**OAuth Mode (Recommended):** -```bash -# Load environment variables from your .env file +# Load environment variables export $(grep -v '^#' .env | xargs) -# Start with OAuth (auto-detected when USERNAME/PASSWORD not set) -uv run nextcloud-mcp-server --host 0.0.0.0 --port 8000 - -# Explicitly force OAuth mode +# Start the server uv run nextcloud-mcp-server --oauth -# OAuth with custom configuration -uv run nextcloud-mcp-server --oauth \ - --oauth-client-id=your-client-id \ - --oauth-client-secret=your-client-secret - -# OAuth with specific apps enabled -uv run nextcloud-mcp-server --oauth \ - --enable-app notes --enable-app calendar -``` - -**BasicAuth Mode (Legacy):** -```bash -# Load environment variables from your .env file (with USERNAME/PASSWORD set) -export $(grep -v '^#' .env | xargs) - -# Start with BasicAuth (auto-detected when USERNAME/PASSWORD are set) -uv run nextcloud-mcp-server --host 0.0.0.0 --port 8000 - -# Explicitly force BasicAuth mode -uv run nextcloud-mcp-server --no-oauth - -# Enable only specific Nextcloud app APIs -uv run nextcloud-mcp-server --enable-app notes --enable-app calendar - -# Enable only WebDAV for file operations -uv run nextcloud-mcp-server --enable-app webdav -``` - -#### Option 2: Using `uvicorn` - -You can also run the MCP server with `uvicorn` directly, which enables support -for all uvicorn arguments (e.g. `--reload`, `--workers`). - -```bash -# Load environment variables from your .env file -export $(grep -v '^#' .env | xargs) - -# Run with uvicorn using the --factory option -uv run uvicorn nextcloud_mcp_server.app:get_app --factory --reload --host 127.0.0.1 --port 8000 -``` - -The server will start, typically listening on `http://127.0.0.1:8000`. - -**Host binding options:** -- Use `--host 0.0.0.0` to bind to all interfaces -- Use `--host 127.0.0.1` to bind only to localhost (default) - -See the full list of available `uvicorn` options and how to set them at [https://www.uvicorn.org/settings/]() - -### Selective App Enablement - -By default, all supported Nextcloud app APIs are enabled. You can selectively enable only specific apps using the `--enable-app` option: - -```bash -# Available apps: notes, tables, webdav, calendar, contacts, deck - -# Enable all apps (default behavior) -uv run python -m nextcloud_mcp_server.app - -# Enable only Notes and Calendar -uv run python -m nextcloud_mcp_server.app --enable-app notes --enable-app calendar - -# Enable only WebDAV for file operations -uv run python -m nextcloud_mcp_server.app --enable-app webdav - -# Enable multiple apps by repeating the option -uv run python -m nextcloud_mcp_server.app --enable-app notes --enable-app tables --enable-app contacts -``` - -This can be useful for: -- Reducing memory usage and startup time -- Limiting available functionality for security or organizational reasons -- Testing specific app integrations -- Running lightweight instances with only needed features - -### Using Docker - -Mount your environment file when running the container: - -**OAuth Mode:** -```bash -# Run with OAuth (auto-detected when USERNAME/PASSWORD not in .env) +# Or with Docker docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \ ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth - -# OAuth with persistent client storage -docker run -p 127.0.0.1:8000:8000 --env-file .env \ - -v $(pwd)/.oauth:/app/.oauth \ - --rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth - -# OAuth with specific apps enabled -docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \ - ghcr.io/cbcoutinho/nextcloud-mcp-server:latest \ - --oauth --enable-app notes --enable-app calendar ``` -**BasicAuth Mode (Legacy):** -```bash -# Run with BasicAuth (auto-detected when USERNAME/PASSWORD in .env) -docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \ - ghcr.io/cbcoutinho/nextcloud-mcp-server:latest +The server starts on `http://127.0.0.1:8000` by default. -# Run with only specific apps enabled -docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \ - ghcr.io/cbcoutinho/nextcloud-mcp-server:latest \ - --enable-app notes --enable-app calendar +See [Running the Server](docs/running.md) for more options. -# Run with only WebDAV -docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \ - ghcr.io/cbcoutinho/nextcloud-mcp-server:latest \ - --enable-app webdav -``` +### 5. Connect an MCP Client -This will start the server and expose it on port 8000 of your local machine. - -**Note for OAuth:** When using OAuth with Docker, ensure the `NEXTCLOUD_MCP_SERVER_URL` in your `.env` file matches the accessible URL of the container (e.g., `http://localhost:8000` for local development). - -## Usage - -Once the server is running, you can connect to it using an MCP client like `MCP Inspector`. Once your MCP server is running, launch MCP Inspector as follows: +Test with MCP Inspector: ```bash uv run mcp dev ``` -You can then connect to and interact with the server's tools and resources through your browser. +Or connect from: +- Claude Desktop +- Any MCP-compatible client -## Troubleshooting OAuth +## Documentation -### Issue: "OAuth mode requires NEXTCLOUD_HOST environment variable" +### Getting Started +- **[Installation](docs/installation.md)** - Install the server +- **[Configuration](docs/configuration.md)** - Environment variables and settings +- **[Authentication](docs/authentication.md)** - OAuth vs BasicAuth +- **[OAuth Setup Guide](docs/oauth-setup.md)** - Step-by-step OAuth configuration +- **[Running the Server](docs/running.md)** - Start and manage the server -**Cause:** The `NEXTCLOUD_HOST` environment variable is not set or empty. +### Reference +- **[Troubleshooting](docs/troubleshooting.md)** - Common issues and solutions +- **[OAuth Bearer Token Issue](docs/oauth2-bearer-token-session-issue.md)** - Required patch for non-OCS endpoints -**Solution:** -```bash -# Ensure NEXTCLOUD_HOST is set in your .env file -echo "NEXTCLOUD_HOST=https://your.nextcloud.instance.com" >> .env +### App-Specific Documentation +- [Notes API](docs/notes.md) +- [Calendar (CalDAV)](docs/calendar.md) +- [Contacts (CardDAV)](docs/contacts.md) +- [Deck](docs/deck.md) +- [Tables](docs/table.md) +- [WebDAV](docs/webdav.md) -# Load environment variables -export $(grep -v '^#' .env | xargs) +## MCP Tools & Resources + +The server exposes Nextcloud functionality through MCP tools (for actions) and resources (for data browsing). + +### Tools +Tools enable AI assistants to perform actions: +- `nc_notes_create_note` - Create a new note +- `deck_create_card` - Create a Deck card +- `nc_calendar_create_event` - Create a calendar event +- `nc_contacts_create_contact` - Create a contact +- And many more... + +### Resources +Resources provide read-only access to Nextcloud data: +- `nc://capabilities` - Server capabilities +- `nc://Deck/boards/{board_id}` - Deck board data +- `notes://settings` - Notes app settings +- And more... + +Run `uv run nextcloud-mcp-server --help` to see all available options. + +## Examples + +### Create a Note +``` +AI: "Create a note called 'Meeting Notes' with today's agenda" +→ Uses nc_notes_create_note tool ``` -### Issue: "OAuth mode requires either client credentials OR dynamic client registration" - -**Cause:** The Nextcloud OIDC app either: -1. Is not installed -2. Doesn't have dynamic client registration enabled -3. Isn't providing a registration endpoint - -**Solution:** -1. Verify OIDC app is installed: Navigate to Nextcloud **Apps** → **Security** -2. Enable dynamic client registration: - - Go to **Settings** → **OIDC** (Administration) - - Enable "Allow dynamic client registration" -3. Or provide pre-configured credentials: - ```dotenv - NEXTCLOUD_OIDC_CLIENT_ID=your-client-id - NEXTCLOUD_OIDC_CLIENT_SECRET=your-client-secret - ``` - -### Issue: "Stored client has expired" - -**Cause:** Dynamically registered OAuth clients expire (default: 1 hour). - -**Solution:** - -**Option 1:** Restart the server - it will automatically re-register -```bash -# Server checks credentials at startup and re-registers if expired -uv run nextcloud-mcp-server --oauth +### Manage Calendar +``` +AI: "Schedule a team meeting for next Tuesday at 2pm" +→ Uses nc_calendar_create_event tool ``` -**Option 2:** Use pre-configured credentials (recommended for production) -```bash -# Register permanent client via Nextcloud CLI -php occ oidc:create \ - --name="Nextcloud MCP Server" \ - --type=confidential \ - --redirect-uri="http://localhost:8000/oauth/callback" - -# Add to .env -NEXTCLOUD_OIDC_CLIENT_ID= -NEXTCLOUD_OIDC_CLIENT_SECRET= +### Organize Files +``` +AI: "Create a folder called 'Project X' and move all PDFs there" +→ Uses WebDAV tools (nc_webdav_create_directory, nc_webdav_move) ``` -**Option 3:** Increase expiration time -```bash -# Via Nextcloud occ command -php occ config:app:set oidc expire_time --value "86400" # 24 hours +### Project Management +``` +AI: "Create a new Deck board for Q1 planning with Todo, In Progress, and Done stacks" +→ Uses deck_create_board and deck_create_stack tools ``` -### Issue: "HTTP 401 Unauthorized" when calling Nextcloud APIs +## Transport Protocols -**Cause:** OAuth tokens may not work with certain Nextcloud endpoints due to CORS middleware session handling. +The server supports multiple MCP transport protocols: -**Solution:** This is a known issue with the Nextcloud OIDC app. See [docs/oauth2-bearer-token-session-issue.md](docs/oauth2-bearer-token-session-issue.md) for details and workarounds. +- **streamable-http** (recommended) - Modern streaming protocol +- **sse** (default, deprecated) - Server-Sent Events for backward compatibility +- **http** - Standard HTTP protocol -The issue affects app-specific APIs (like Notes) but not OCS APIs. A patch for the `user_oidc` app is available in the documentation. - -### Issue: "Permission denied" when reading/writing client credentials file - -**Cause:** The server cannot access the OAuth client storage file. - -**Solution:** ```bash -# Check file permissions -ls -la .nextcloud_oauth_client.json - -# Fix permissions (should be 0600) -chmod 600 .nextcloud_oauth_client.json - -# Ensure the directory is writable -chmod 755 $(dirname .nextcloud_oauth_client.json) +# Use streamable-http (recommended) +uv run nextcloud-mcp-server --transport streamable-http ``` -### Issue: Switching Between OAuth and BasicAuth - -**To switch from BasicAuth to OAuth:** -```bash -# Remove or comment out USERNAME/PASSWORD in .env -# Keep only NEXTCLOUD_HOST -sed -i 's/^NEXTCLOUD_USERNAME/#NEXTCLOUD_USERNAME/' .env -sed -i 's/^NEXTCLOUD_PASSWORD/#NEXTCLOUD_PASSWORD/' .env - -# Restart server with --oauth flag -uv run nextcloud-mcp-server --oauth -``` - -**To switch from OAuth to BasicAuth:** -```bash -# Add USERNAME/PASSWORD to .env -echo "NEXTCLOUD_USERNAME=your-username" >> .env -echo "NEXTCLOUD_PASSWORD=your-password" >> .env - -# Restart server with --no-oauth flag (or let auto-detection work) -uv run nextcloud-mcp-server --no-oauth -``` - -### Getting Help - -If you continue to experience issues: - -1. **Check logs:** Run with `--log-level debug` for detailed output - ```bash - uv run nextcloud-mcp-server --oauth --log-level debug - ``` - -2. **Verify OIDC discovery:** Check if the discovery endpoint is accessible - ```bash - curl https://your.nextcloud.instance.com/.well-known/openid-configuration - ``` - -3. **Check dynamic registration:** Verify the endpoint exists in the discovery response - ```json - { - "registration_endpoint": "https://your.nextcloud.instance.com/apps/oidc/register" - } - ``` - -4. **Open an issue:** If problems persist, open an issue on the [GitHub repository](https://github.com/cbcoutinho/nextcloud-mcp-server/issues) with: - - Server logs (with `--log-level debug`) - - Nextcloud version - - OIDC app version - - Error messages - -## References: - -- https://github.com/modelcontextprotocol/python-sdk +> [!WARNING] +> SSE transport is deprecated and will be removed in a future MCP specification version. Please migrate to `streamable-http`. ## Contributing -Contributions are welcome! Please feel free to submit issues or pull requests on the [GitHub repository](https://github.com/cbcoutinho/nextcloud-mcp-server). +Contributions are welcome! + +- Report bugs or request features: [GitHub Issues](https://github.com/cbcoutinho/nextcloud-mcp-server/issues) +- Submit improvements: [Pull Requests](https://github.com/cbcoutinho/nextcloud-mcp-server/pulls) +- Read [CLAUDE.md](CLAUDE.md) for development guidelines + +## Security + +[![MseeP.ai Security Assessment](https://mseep.net/pr/cbcoutinho-nextcloud-mcp-server-badge.png)](https://mseep.ai/app/cbcoutinho-nextcloud-mcp-server) + +This project takes security seriously: +- OAuth2/OIDC support for secure authentication +- No credential storage with OAuth mode +- Per-user access tokens +- Regular security assessments + +Found a security issue? Please report it privately to the maintainers. + +## License + +This project is licensed under the AGPL-3.0 License. See [LICENSE](./LICENSE) for details. ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=cbcoutinho/nextcloud-mcp-server&type=Date)](https://www.star-history.com/#cbcoutinho/nextcloud-mcp-server&Date) -## License +## References -This project is licensed under the AGPL-3.0 License. See the [LICENSE](./LICENSE) file for details. - -[![MseeP.ai Security Assessment Badge](https://mseep.net/pr/cbcoutinho-nextcloud-mcp-server-badge.png)](https://mseep.ai/app/cbcoutinho-nextcloud-mcp-server) +- [Model Context Protocol](https://github.com/modelcontextprotocol) +- [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk) +- [Nextcloud](https://nextcloud.com/) From ea468889ce34facef3dcf9985a5a7e8d43dba67f Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 13 Oct 2025 18:08:03 +0200 Subject: [PATCH 080/102] docs: Remove pip --- README.md | 9 +-- docs/installation.md | 137 +++++++++++++++---------------------------- 2 files changed, 53 insertions(+), 93 deletions(-) diff --git a/README.md b/README.md index 4fa0eaa..0ddf4e7 100644 --- a/README.md +++ b/README.md @@ -36,11 +36,12 @@ OAuth2/OIDC provides secure, per-user authentication with access tokens. See [Au ### 1. Install ```bash -# Using uv (recommended) -uv pip install nextcloud-mcp-server +# Clone the repository +git clone https://github.com/cbcoutinho/nextcloud-mcp-server.git +cd nextcloud-mcp-server -# Or using pip -pip install nextcloud-mcp-server +# Install with uv (recommended) +uv sync # Or using Docker docker pull ghcr.io/cbcoutinho/nextcloud-mcp-server:latest diff --git a/docs/installation.md b/docs/installation.md index 9080b66..13d2af7 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -12,20 +12,21 @@ This guide covers installing the Nextcloud MCP server on your system. Choose one of the following installation methods: -- [Using uv (Recommended)](#using-uv-recommended) -- [Using pip](#using-pip) +- [From Source (Recommended)](#from-source-recommended) - [Using Docker](#using-docker) -- [From Source](#from-source) --- -## Using uv (Recommended) +## From Source (Recommended) -[uv](https://github.com/astral-sh/uv) is a fast Python package installer and resolver. +Install from the GitHub repository using uv or pip. -### Install uv +### Prerequisites + +Install [uv](https://github.com/astral-sh/uv) (recommended) or ensure pip is available: ```bash +# Install uv (recommended) # On macOS/Linux curl -LsSf https://astral.sh/uv/install.sh | sh @@ -33,37 +34,46 @@ curl -LsSf https://astral.sh/uv/install.sh | sh powershell -c "irm https://astral.sh/uv/install.ps1 | iex" ``` -### Install Nextcloud MCP Server +### Clone the Repository ```bash -# Install from PyPI -uv pip install nextcloud-mcp-server +git clone https://github.com/cbcoutinho/nextcloud-mcp-server.git +cd nextcloud-mcp-server +``` -# Or install directly using uvx -uvx nextcloud-mcp-server --help +### Install Dependencies + +#### Using uv (Recommended) + +```bash +# Install dependencies +uv sync + +# Install development dependencies (optional) +uv sync --group dev +``` + +#### Using pip + +```bash +# Create virtual environment +python3 -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate + +# Install in development mode +pip install -e . + +# Install development dependencies (optional) +pip install -e ".[dev]" ``` ### Verify Installation ```bash +# With uv uv run nextcloud-mcp-server --help -``` ---- - -## Using pip - -Standard installation using pip: - -```bash -# Create a virtual environment (recommended) -python3 -m venv venv -source venv/bin/activate # On Windows: venv\Scripts\activate - -# Install from PyPI -pip install nextcloud-mcp-server - -# Verify installation +# With pip/venv nextcloud-mcp-server --help ``` @@ -117,55 +127,6 @@ docker-compose up -d --- -## From Source - -Install from the GitHub repository: - -### Clone the Repository - -```bash -git clone https://github.com/cbcoutinho/nextcloud-mcp-server.git -cd nextcloud-mcp-server -``` - -### Install Dependencies - -#### Using uv (Recommended) - -```bash -# Install dependencies -uv sync - -# Install development dependencies (optional) -uv sync --group dev -``` - -#### Using pip - -```bash -# Create virtual environment -python3 -m venv venv -source venv/bin/activate # On Windows: venv\Scripts\activate - -# Install in development mode -pip install -e . - -# Install development dependencies (optional) -pip install -e ".[dev]" -``` - -### Verify Installation - -```bash -# With uv -uv run nextcloud-mcp-server --help - -# With pip -nextcloud-mcp-server --help -``` - ---- - ## Next Steps After installation: @@ -176,31 +137,29 @@ After installation: ## Updating -### Update with uv +### Update from Source ```bash -uv pip install --upgrade nextcloud-mcp-server -``` +cd nextcloud-mcp-server +git pull origin master -### Update with pip +# Using uv +uv sync -```bash -pip install --upgrade nextcloud-mcp-server +# Or using pip +pip install -e . ``` ### Update Docker Image ```bash docker pull ghcr.io/cbcoutinho/nextcloud-mcp-server:latest + +# If using docker-compose docker-compose up -d # Restart with new image -``` -### Update from Source - -```bash -cd nextcloud-mcp-server -git pull origin master -uv sync # or: pip install -e . +# If using docker run +# Stop the old container and start a new one with the updated image ``` ## Troubleshooting Installation From 4b19964817dfdafcd84d6b811a9c9da96c60625c Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 13 Oct 2025 18:08:04 +0200 Subject: [PATCH 081/102] docs: Update docs --- docs/authentication.md | 43 +++++++++++++++++++++++++++++++++++++---- docs/configuration.md | 12 +++++++++--- docs/oauth-setup.md | 36 +++++++++++++++++++++++++++++++--- docs/troubleshooting.md | 23 +++++++++++++++------- 4 files changed, 97 insertions(+), 17 deletions(-) diff --git a/docs/authentication.md b/docs/authentication.md index aabf0fd..9db7785 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -13,6 +13,34 @@ The Nextcloud MCP server supports two authentication modes for connecting to you OAuth2/OIDC authentication provides secure, token-based authentication following modern security standards. +### Required Nextcloud Apps + +OAuth authentication requires **two Nextcloud apps** to work together: + +#### 1. `oidc` - OIDC Identity Provider +**Purpose:** Makes Nextcloud an OAuth2/OIDC authorization server + +**Provides:** +- OAuth2 authorization endpoint (`/apps/oidc/authorize`) +- Token endpoint (`/apps/oidc/token`) +- User info endpoint (`/apps/oidc/userinfo`) +- JWKS endpoint for token validation (`/apps/oidc/jwks`) +- Dynamic client registration endpoint (`/apps/oidc/register`) + +**Installation:** Available in Nextcloud App Store under "Security" + +#### 2. `user_oidc` - OpenID Connect User Backend +**Purpose:** Authenticates users and validates Bearer tokens + +**Provides:** +- Bearer token validation against the OIDC provider +- User authentication via OIDC +- Session management for authenticated users + +**Installation:** Available in Nextcloud App Store under "Security" + +**Important:** The `user_oidc` app requires a patch for Bearer token support on non-OCS endpoints (like Notes API). See [oauth2-bearer-token-session-issue.md](oauth2-bearer-token-session-issue.md) for details. + ### Benefits - **Zero-config deployment** via dynamic client registration - **No credential storage** in environment variables @@ -23,10 +51,17 @@ OAuth2/OIDC authentication provides secure, token-based authentication following ### Current Implementation Limitations > [!IMPORTANT] -> - Only tested with Nextcloud `user_oidc` and `oidc` apps (Nextcloud as identity provider) -> - Requires a patch for Bearer token support on non-OCS endpoints (see [oauth2-bearer-token-session-issue.md](oauth2-bearer-token-session-issue.md)) -> - External identity providers (Azure AD, Keycloak, etc.) have not been tested -> - Dynamic client registration credentials expire (default: 1 hour) - use pre-configured clients for production +> **Tested Configuration:** +> - ✅ Nextcloud `oidc` app (OIDC Identity Provider) + `user_oidc` app (OIDC User Backend) +> - ✅ Nextcloud acting as its own identity provider (self-hosted OIDC) +> +> **Not Tested:** +> - ❌ External identity providers (Azure AD, Keycloak, Okta, etc.) +> - ❌ Using `user_oidc` with external OIDC providers +> +> **Known Requirements:** +> - 🔧 The `user_oidc` app requires a patch for Bearer token support on non-OCS endpoints (see [oauth2-bearer-token-session-issue.md](oauth2-bearer-token-session-issue.md)) +> - ⏱️ Dynamic client registration credentials expire (default: 1 hour) - use pre-configured clients for production ### How OAuth Works diff --git a/docs/configuration.md b/docs/configuration.md index f1e881a..3742b1f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -70,9 +70,15 @@ NEXTCLOUD_PASSWORD= Before using OAuth configuration: -1. **Install Nextcloud OIDC app** - Navigate to Apps → Security in your Nextcloud instance -2. **Enable dynamic client registration** (if using auto-registration) - Settings → OIDC -3. **Apply Bearer token patch** (if using non-OCS endpoints) - See [oauth2-bearer-token-session-issue.md](oauth2-bearer-token-session-issue.md) +1. **Install required Nextcloud apps** (both are required): + - **`oidc`** - OIDC Identity Provider (Apps → Security) + - **`user_oidc`** - OpenID Connect user backend (Apps → Security) + +2. **Configure the apps**: + - Enable dynamic client registration (if using auto-registration) - Settings → OIDC + - Enable Bearer token validation: `php occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean` + +3. **Apply Bearer token patch** - The `user_oidc` app requires a patch for non-OCS endpoints - See [oauth2-bearer-token-session-issue.md](oauth2-bearer-token-session-issue.md) See the [OAuth Setup Guide](oauth-setup.md) for detailed instructions. diff --git a/docs/oauth-setup.md b/docs/oauth-setup.md index 29343b1..aa7c692 100644 --- a/docs/oauth-setup.md +++ b/docs/oauth-setup.md @@ -8,14 +8,33 @@ This guide walks you through setting up OAuth2/OIDC authentication for the Nextc - Python 3.11+ installed - Nextcloud MCP server installed (see [Installation Guide](installation.md)) -## Step 1: Install Nextcloud OIDC App +## Step 1: Install Required Nextcloud Apps + +OAuth authentication requires **two apps** to work together: + +### Install the OIDC Identity Provider App 1. Open your Nextcloud instance as an administrator 2. Navigate to **Apps** → **Security** -3. Find and install the **OpenID Connect user backend** app +3. Find and install the **OIDC** app (full name: "OIDC Identity Provider") 4. Enable the app -## Step 2: Enable Dynamic Client Registration +This app makes Nextcloud an OAuth2/OIDC authorization server. + +### Install the OpenID Connect User Backend App + +1. In **Apps** → **Security** +2. Find and install the **OpenID Connect user backend** app (app ID: `user_oidc`) +3. Enable the app + +This app handles Bearer token validation and user authentication. + +> [!IMPORTANT] +> **Required Patch:** The `user_oidc` app needs a patch for Bearer token authentication to work with non-OCS endpoints (like Notes API). See [oauth2-bearer-token-session-issue.md](oauth2-bearer-token-session-issue.md) for the patch and installation instructions. + +## Step 2: Configure OIDC Apps + +### Enable Dynamic Client Registration (for `oidc` app) 1. Navigate to **Settings** → **OIDC** (in Administration settings) 2. Find the **Dynamic Client Registration** section @@ -26,6 +45,17 @@ This guide walks you through setting up OAuth2/OIDC authentication for the Nextc php occ config:app:set oidc expire_time --value "86400" # 24 hours ``` +### Enable Bearer Token Validation (for `user_oidc` app) + +Configure the `user_oidc` app to validate bearer tokens from the `oidc` Identity Provider: + +```bash +# Via Nextcloud CLI (occ) - required for Bearer token authentication +php occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean +``` + +This tells the `user_oidc` app to validate Bearer tokens against Nextcloud's own OIDC Identity Provider. + ## Step 3: Choose Your Setup Approach You have two options for configuring OAuth clients: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 31ffb96..d75a5a8 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -25,23 +25,30 @@ echo $NEXTCLOUD_HOST ### Issue: "OAuth mode requires either client credentials OR dynamic client registration" -**Cause:** The Nextcloud OIDC app either: -1. Is not installed -2. Doesn't have dynamic client registration enabled -3. Isn't providing a registration endpoint +**Cause:** The required Nextcloud OIDC apps are either: +1. Not installed (both `oidc` and `user_oidc` apps are required) +2. Don't have dynamic client registration enabled +3. Aren't providing a registration endpoint **Solution:** **Option 1: Enable dynamic client registration** -1. Verify OIDC app is installed: +1. Verify **both** OIDC apps are installed: - Navigate to Nextcloud **Apps** → **Security** - - Install "OpenID Connect user backend" if not present + - Install **"OIDC"** (OIDC Identity Provider app) if not present + - Install **"OpenID Connect user backend"** (user_oidc app) if not present 2. Enable dynamic client registration: - Go to **Settings** → **OIDC** (Administration) - Enable "Allow dynamic client registration" +3. Configure Bearer token validation: + ```bash + # Required for user_oidc app to validate tokens + php occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean + ``` + 3. Verify the registration endpoint exists: ```bash curl https://your.nextcloud.instance.com/.well-known/openid-configuration | jq '.registration_endpoint' @@ -172,7 +179,9 @@ mkdir -p $(dirname .nextcloud_oauth_client.json) ping your.nextcloud.instance.com ``` -4. Verify OIDC app is installed and enabled in Nextcloud +4. Verify **both** OIDC apps are installed and enabled in Nextcloud: + - `oidc` - OIDC Identity Provider + - `user_oidc` - OpenID Connect user backend 5. Check firewall rules if using Docker From 37b0577bfde6ac3acbf5b58bd6d8998a20b3c5e1 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 13 Oct 2025 19:13:16 +0200 Subject: [PATCH 082/102] test: Add asyncio tests using Playwright --- .github/workflows/test.yml | 4 +- CLAUDE.md | 40 ++- pyproject.toml | 2 + tests/conftest.py | 304 +++++++++++++++++++++ tests/integration/test_oauth_playwright.py | 59 ++++ uv.lock | 151 ++++++++++ 6 files changed, 558 insertions(+), 2 deletions(-) create mode 100644 tests/integration/test_oauth_playwright.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e55b329..8977a0f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,6 +30,7 @@ jobs: uses: hoverkraft-tech/compose-action@3846bcd61da338e9eaaf83e7ed0234a12b099b72 # v2.4.1 with: compose-file: "./docker-compose.yml" + - name: Install the latest version of uv uses: astral-sh/setup-uv@3259c6206f993105e3a61b142c2d97bf4b9ef83d # v7.1.0 @@ -56,4 +57,5 @@ jobs: NEXTCLOUD_USERNAME: "admin" NEXTCLOUD_PASSWORD: "admin" run: | - uv run --frozen python -m pytest -m 'not oauth' + uv run playwright install --with-deps + uv run --frozen python -m pytest diff --git a/CLAUDE.md b/CLAUDE.md index 0ffc880..578090f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,7 +107,7 @@ Each Nextcloud app has a corresponding server module that: - If tests require modifications to pass, ask for permission before proceeding - Use `docker-compose up --build -d mcp` to rebuild MCP container after code changes - **Use existing fixtures** from `tests/conftest.py` to avoid duplicate setup work: - - `nc_mcp_client` - MCP client session for tool/resource testing + - `nc_mcp_client` - MCP client session for tool/resource testing - `nc_client` - Direct NextcloudClient for setup/cleanup operations - `temporary_note` - Creates and cleans up test notes automatically - `temporary_addressbook` - Creates and cleans up test address books @@ -117,6 +117,44 @@ Each Nextcloud app has a corresponding server module that: - For specific API changes: `uv run pytest tests/integration/test_notes_api.py -v` - **Avoid creating standalone test scripts** - use pytest with proper fixtures instead +#### OAuth/OIDC Testing +OAuth integration tests support both **interactive** and **automated** (Playwright) authentication flows: + +**Automated Testing (Recommended for CI/CD):** +- Uses Playwright headless browser automation to complete OAuth flow programmatically +- Fixtures: `playwright_oauth_token`, `nc_oauth_client_playwright`, `nc_mcp_oauth_client_playwright` +- Requires: `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD` environment variables +- Uses `pytest-playwright-asyncio` for async Playwright fixtures +- Playwright configuration: Use pytest CLI args like `--browser firefox --headed` to customize +- Install browsers: `uv run playwright install firefox` (or `chromium`, `webkit`) +- Example: + ```bash + # Run OAuth tests with automated Playwright flow using Firefox + uv run pytest tests/integration/test_oauth_playwright.py --browser firefox -v + + # Run with visible browser for debugging + uv run pytest tests/integration/test_oauth_playwright.py --browser firefox --headed -v + + # Run with Chromium (default) + uv run pytest tests/integration/test_oauth_playwright.py -v + ``` + +**Interactive Testing (Manual browser login):** +- Opens system browser and waits for manual login/authorization +- Fixtures: `interactive_oauth_token`, `nc_oauth_client`, `nc_mcp_oauth_client` +- Requires: User to complete browser-based login when prompted +- Useful for: Debugging OAuth flows, testing with 2FA, local development +- Example: + ```bash + # Run OAuth tests with interactive flow (will open browser) + uv run pytest tests/integration/test_oauth_interactive.py -v + ``` + +**Test Environment Setup:** +- Start OAuth MCP server: `docker-compose up --build -d mcp-oauth` +- OAuth server runs on port 8001 (regular MCP on 8000) +- Both flows register OAuth clients dynamically using Nextcloud's OIDC provider + ### Configuration Files - **`pyproject.toml`** - Python project configuration using uv for dependency management diff --git a/pyproject.toml b/pyproject.toml index 0e9d007..3da27c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,9 +48,11 @@ build-backend = "poetry.core.masonry.api" dev = [ "commitizen>=4.8.2", "ipython>=9.2.0", + "playwright>=1.49.1", "pytest>=8.3.5", "pytest-asyncio>=1.0.0", "pytest-cov>=6.1.1", + "pytest-playwright-asyncio>=0.7.1", "ruff>=0.11.13", ] diff --git a/tests/conftest.py b/tests/conftest.py index f3a85d7..b53916d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -794,3 +794,307 @@ async def interactive_oauth_token(oauth_callback_server) -> str: access_token = token_data.get("access_token") return access_token + + +@pytest.fixture(scope="session") +async def playwright_oauth_token(browser) -> str: + """ + Fixture to obtain an OAuth access token using Playwright headless browser automation. + + This fully automates the OAuth flow by: + 1. Discovering OIDC endpoints + 2. Registering an OAuth client + 3. Navigating to authorization URL in headless browser + 4. Programmatically filling in login form + 5. Handling OAuth consent + 6. Extracting auth code from redirect + 7. Exchanging code for access token + + Environment variables required: + - NEXTCLOUD_HOST: Nextcloud instance URL + - NEXTCLOUD_USERNAME: Username for login + - NEXTCLOUD_PASSWORD: Password for login + + Playwright Configuration: + - Configure browser via pytest CLI args: --browser firefox --headed + - Browser fixture provided by pytest-playwright-asyncio + - See: https://playwright.dev/python/docs/test-runners + """ + from urllib.parse import urlparse, parse_qs + + 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( + "Playwright OAuth requires NEXTCLOUD_HOST, NEXTCLOUD_USERNAME, and NEXTCLOUD_PASSWORD" + ) + + logger.info("Starting Playwright-based OAuth flow...") + + # Use async httpx for all HTTP operations + async with httpx.AsyncClient(timeout=30.0) as http_client: + # OIDC Discovery + discovery_url = f"{nextcloud_host}/.well-known/openid-configuration" + logger.debug(f"Fetching OIDC discovery from: {discovery_url}") + + discovery_response = await http_client.get(discovery_url) + discovery_response.raise_for_status() + oidc_config = discovery_response.json() + + token_endpoint = oidc_config.get("token_endpoint") + registration_endpoint = oidc_config.get("registration_endpoint") + authorization_endpoint = oidc_config.get("authorization_endpoint") + + if not all([token_endpoint, registration_endpoint, authorization_endpoint]): + raise ValueError("OIDC discovery missing required endpoints") + + logger.debug(f"Authorization endpoint: {authorization_endpoint}") + logger.debug(f"Token endpoint: {token_endpoint}") + + # Register OAuth client with a callback that won't actually be used + # (we'll extract the code from the browser URL instead) + callback_url = "http://localhost:9999/oauth/callback" + + # Register client asynchronously + client_metadata = { + "client_name": "Nextcloud MCP Server - Playwright Tests", + "redirect_uris": [callback_url], + "token_endpoint_auth_method": "client_secret_post", + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "scope": "openid profile email", + } + + reg_response = await http_client.post( + registration_endpoint, + json=client_metadata, + headers={"Content-Type": "application/json"}, + ) + reg_response.raise_for_status() + client_info_dict = reg_response.json() + + client_id = client_info_dict["client_id"] + client_secret = client_info_dict["client_secret"] + + # Construct authorization URL + auth_url = ( + f"{authorization_endpoint}?" + f"response_type=code&" + f"client_id={client_id}&" + f"redirect_uri={callback_url}&" + f"scope=openid%20profile%20email" + ) + + logger.info("Opening browser for OAuth authorization...") + + # Async browser automation using pytest-playwright's browser fixture + context = await browser.new_context(ignore_https_errors=True) + page = await context.new_page() + + try: + # Navigate to authorization URL + logger.debug(f"Navigating to: {auth_url}") + await page.goto(auth_url, wait_until="networkidle", timeout=30000) + + # Check if we need to login first + current_url = page.url + logger.debug(f"Current URL after navigation: {current_url}") + + # If we're on a login page, fill in credentials + if "/login" in current_url or "/index.php/login" in current_url: + logger.info("Login page detected, filling in credentials...") + + # Wait for login form + await page.wait_for_selector('input[name="user"]', timeout=10000) + + # Fill in username and password + await page.fill('input[name="user"]', username) + await page.fill('input[name="password"]', password) + + logger.debug("Credentials filled, submitting login form...") + + # Submit the form + await page.click('button[type="submit"]') + + # Wait for navigation after login + await page.wait_for_load_state("networkidle", timeout=30000) + current_url = page.url + logger.info(f"After login, current URL: {current_url}") + + # Now we should be on the OAuth authorization/consent page or already redirected + # Check if there's an authorize button to click + try: + # Look for common authorization button patterns + authorize_button = await page.query_selector( + 'button:has-text("Authorize"), button:has-text("Allow"), input[type="submit"][value*="uthoriz"]' + ) + + if authorize_button: + logger.info( + "Authorization consent page detected, clicking authorize..." + ) + await authorize_button.click() + await page.wait_for_load_state("networkidle", timeout=10000) + current_url = page.url + logger.debug(f"After authorization, current_url: {current_url}") + except Exception as e: + logger.debug( + f"No authorization button found or already authorized: {e}" + ) + + # Wait for redirect to callback URL (which will fail to load, but we just need the URL) + try: + # The redirect might fail since localhost:9999 isn't actually running + # But we can still extract the code from the URL + await page.wait_for_url(f"{callback_url}*", timeout=10000) + except Exception as e: + # Expected - the callback URL won't load, but we should have the URL + logger.debug(f"Callback redirect (expected to fail): {e}") + + # Extract auth code from URL + final_url = page.url + logger.debug(f"Final URL: {final_url}") + + parsed_url = urlparse(final_url) + query_params = parse_qs(parsed_url.query) + auth_code = query_params.get("code", [None])[0] + + if not auth_code: + # Take a screenshot for debugging + screenshot_path = "/tmp/playwright_oauth_error.png" + await page.screenshot(path=screenshot_path) + logger.error(f"Screenshot saved to {screenshot_path}") + raise ValueError( + f"No authorization code found in redirect URL: {final_url}" + ) + + logger.info( + f"Successfully extracted authorization code: {auth_code[:20]}..." + ) + + finally: + await context.close() + + # Exchange authorization code for access token + logger.info("Exchanging authorization code for access token...") + token_response = await http_client.post( + token_endpoint, + data={ + "grant_type": "authorization_code", + "code": auth_code, + "redirect_uri": callback_url, + "client_id": client_id, + "client_secret": client_secret, + }, + ) + + token_response.raise_for_status() + token_data = token_response.json() + access_token = token_data.get("access_token") + + if not access_token: + raise ValueError(f"No access_token in response: {token_data}") + + logger.info("Successfully obtained OAuth access token via Playwright") + return access_token + + +# Alternative fixtures using Playwright token (for automated/CI testing) + + +@pytest.fixture(scope="session") +async def nc_oauth_client_playwright( + playwright_oauth_token: str, +) -> AsyncGenerator[NextcloudClient, Any]: + """ + Fixture to create a NextcloudClient instance using automated Playwright OAuth authentication. + This fixture uses headless browser automation and is suitable for CI/CD pipelines. + + For interactive testing, use nc_oauth_client fixture instead. + """ + nextcloud_host = os.getenv("NEXTCLOUD_HOST") + username = os.getenv("NEXTCLOUD_USERNAME") + + if not all([nextcloud_host, username]): + pytest.skip( + "Playwright OAuth client fixture requires NEXTCLOUD_HOST and USERNAME" + ) + + logger.info(f"Creating OAuth NextcloudClient (Playwright) for user: {username}") + client = NextcloudClient.from_token( + base_url=nextcloud_host, + token=playwright_oauth_token, + username=username, + ) + + # Verify the OAuth client works + try: + await client.capabilities() + logger.info( + "OAuth NextcloudClient (Playwright) initialized and capabilities checked." + ) + yield client + except Exception as e: + logger.error(f"Failed to initialize Playwright OAuth NextcloudClient: {e}") + pytest.fail(f"Failed to connect to Nextcloud with Playwright OAuth token: {e}") + finally: + await client.close() + + +@pytest.fixture(scope="session") +async def nc_mcp_oauth_client_playwright( + playwright_oauth_token: str, +) -> AsyncGenerator[ClientSession, Any]: + """ + Fixture to create an MCP client session for OAuth integration tests using Playwright automation. + Connects to the OAuth-enabled MCP server on port 8001 with OAuth authentication. + + This fixture uses headless browser automation and is suitable for CI/CD pipelines. + For interactive testing, use nc_mcp_oauth_client fixture instead. + """ + logger.info("Creating Streamable HTTP client for OAuth MCP server (Playwright)") + + # Pass OAuth token as Bearer token in headers + headers = {"Authorization": f"Bearer {playwright_oauth_token}"} + streamable_context = streamablehttp_client( + "http://127.0.0.1:8001/mcp", headers=headers + ) + session_context = None + + try: + read_stream, write_stream, _ = await streamable_context.__aenter__() + session_context = ClientSession(read_stream, write_stream) + session = await session_context.__aenter__() + await session.initialize() + logger.info("OAuth MCP client session (Playwright) initialized successfully") + + yield session + + finally: + # Clean up in reverse order, ignoring task scope issues + if session_context is not None: + try: + await session_context.__aexit__(None, None, None) + except RuntimeError as e: + if "cancel scope" in str(e): + logger.debug(f"Ignoring cancel scope teardown issue: {e}") + else: + logger.warning(f"Error closing Playwright OAuth session: {e}") + except Exception as e: + logger.warning(f"Error closing Playwright OAuth session: {e}") + + try: + await streamable_context.__aexit__(None, None, None) + except RuntimeError as e: + if "cancel scope" in str(e): + logger.debug(f"Ignoring cancel scope teardown issue: {e}") + else: + logger.warning( + f"Error closing Playwright OAuth streamable HTTP client: {e}" + ) + except Exception as e: + logger.warning( + f"Error closing Playwright OAuth streamable HTTP client: {e}" + ) diff --git a/tests/integration/test_oauth_playwright.py b/tests/integration/test_oauth_playwright.py new file mode 100644 index 0000000..2a6fc6c --- /dev/null +++ b/tests/integration/test_oauth_playwright.py @@ -0,0 +1,59 @@ +"""Integration tests for Playwright-based OAuth authentication.""" + +import logging + +import pytest + +logger = logging.getLogger(__name__) + +pytestmark = [pytest.mark.integration, pytest.mark.oauth] + + +class TestOAuthPlaywright: + """Test automated Playwright OAuth authentication.""" + + async def test_playwright_oauth_token_acquisition( + self, playwright_oauth_token: str + ): + """Test that Playwright can acquire an OAuth token automatically.""" + assert playwright_oauth_token is not None + assert isinstance(playwright_oauth_token, str) + assert len(playwright_oauth_token) > 0 + logger.info( + f"Successfully acquired OAuth token via Playwright: {playwright_oauth_token[:20]}..." + ) + + async def test_oauth_client_with_playwright_flow(self, nc_oauth_client_playwright): + """Test that OAuth client created via Playwright flow can access Nextcloud APIs.""" + # Test 1: Check capabilities + capabilities = await nc_oauth_client_playwright.capabilities() + assert capabilities is not None + logger.info("OAuth client (Playwright) successfully fetched capabilities") + + # Test 2: List notes + notes = await nc_oauth_client_playwright.notes.get_all_notes() + assert isinstance(notes, list) + logger.info(f"OAuth client (Playwright) successfully listed {len(notes)} notes") + + async def test_mcp_oauth_client_with_playwright( + self, nc_mcp_oauth_client_playwright + ): + """Test that MCP OAuth client via Playwright can execute tools.""" + import json + + # Test: Execute the 'nc_notes_search_notes' tool + result = await nc_mcp_oauth_client_playwright.call_tool( + "nc_notes_search_notes", arguments={"query": ""} + ) + + assert result.isError is False, f"Tool execution failed: {result.content}" + assert result.content is not None + response_data = json.loads(result.content[0].text) + + # The search response should have a 'results' field containing the list + assert "results" in response_data + assert isinstance(response_data["results"], list) + + logger.info( + f"Successfully executed 'nc_notes_search_notes' tool on Playwright OAuth MCP server and got {len(response_data['results'])} notes." + ) diff --git a/uv.lock b/uv.lock index 48451ba..9d564a6 100644 --- a/uv.lock +++ b/uv.lock @@ -289,6 +289,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, ] +[[package]] +name = "greenlet" +version = "3.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/de/f28ced0a67749cac23fecb02b694f6473f47686dff6afaa211d186e2ef9c/greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2", size = 272305, upload-time = "2025-08-07T13:15:41.288Z" }, + { url = "https://files.pythonhosted.org/packages/09/16/2c3792cba130000bf2a31c5272999113f4764fd9d874fb257ff588ac779a/greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246", size = 632472, upload-time = "2025-08-07T13:42:55.044Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/95d48d7e3d433e6dae5b1682e4292242a53f22df82e6d3dda81b1701a960/greenlet-3.2.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3", size = 644646, upload-time = "2025-08-07T13:45:26.523Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5e/405965351aef8c76b8ef7ad370e5da58d57ef6068df197548b015464001a/greenlet-3.2.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633", size = 640519, upload-time = "2025-08-07T13:53:13.928Z" }, + { url = "https://files.pythonhosted.org/packages/25/5d/382753b52006ce0218297ec1b628e048c4e64b155379331f25a7316eb749/greenlet-3.2.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079", size = 639707, upload-time = "2025-08-07T13:18:27.146Z" }, + { url = "https://files.pythonhosted.org/packages/1f/8e/abdd3f14d735b2929290a018ecf133c901be4874b858dd1c604b9319f064/greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8", size = 587684, upload-time = "2025-08-07T13:18:25.164Z" }, + { url = "https://files.pythonhosted.org/packages/5d/65/deb2a69c3e5996439b0176f6651e0052542bb6c8f8ec2e3fba97c9768805/greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52", size = 1116647, upload-time = "2025-08-07T13:42:38.655Z" }, + { url = "https://files.pythonhosted.org/packages/3f/cc/b07000438a29ac5cfb2194bfc128151d52f333cee74dd7dfe3fb733fc16c/greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa", size = 1142073, upload-time = "2025-08-07T13:18:21.737Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0f/30aef242fcab550b0b3520b8e3561156857c94288f0332a79928c31a52cf/greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9", size = 299100, upload-time = "2025-08-07T13:44:12.287Z" }, + { url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" }, + { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" }, + { url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185, upload-time = "2025-08-07T13:45:27.624Z" }, + { url = "https://files.pythonhosted.org/packages/31/da/0386695eef69ffae1ad726881571dfe28b41970173947e7c558d9998de0f/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", size = 649926, upload-time = "2025-08-07T13:53:15.251Z" }, + { url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839, upload-time = "2025-08-07T13:18:30.281Z" }, + { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" }, + { url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, + { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" }, + { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, + { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, + { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" }, + { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" }, + { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, + { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, + { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, + { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -604,9 +646,11 @@ dependencies = [ dev = [ { name = "commitizen" }, { name = "ipython" }, + { name = "playwright" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, + { name = "pytest-playwright-asyncio" }, { name = "ruff" }, ] @@ -625,9 +669,11 @@ requires-dist = [ dev = [ { name = "commitizen", specifier = ">=4.8.2" }, { name = "ipython", specifier = ">=9.2.0" }, + { name = "playwright", specifier = ">=1.49.1" }, { name = "pytest", specifier = ">=8.3.5" }, { name = "pytest-asyncio", specifier = ">=1.0.0" }, { name = "pytest-cov", specifier = ">=6.1.1" }, + { name = "pytest-playwright-asyncio", specifier = ">=0.7.1" }, { name = "ruff", specifier = ">=0.11.13" }, ] @@ -745,6 +791,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/34/e7/ae39f538fd6844e982063c3a5e4598b8ced43b9633baa3a85ef33af8c05c/pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8", size = 6984598, upload-time = "2025-07-01T09:16:27.732Z" }, ] +[[package]] +name = "playwright" +version = "1.55.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/3a/c81ff76df266c62e24f19718df9c168f49af93cabdbc4608ae29656a9986/playwright-1.55.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:d7da108a95001e412effca4f7610de79da1637ccdf670b1ae3fdc08b9694c034", size = 40428109, upload-time = "2025-08-28T15:46:20.357Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f5/bdb61553b20e907196a38d864602a9b4a461660c3a111c67a35179b636fa/playwright-1.55.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8290cf27a5d542e2682ac274da423941f879d07b001f6575a5a3a257b1d4ba1c", size = 38687254, upload-time = "2025-08-28T15:46:23.925Z" }, + { url = "https://files.pythonhosted.org/packages/4a/64/48b2837ef396487807e5ab53c76465747e34c7143fac4a084ef349c293a8/playwright-1.55.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:25b0d6b3fd991c315cca33c802cf617d52980108ab8431e3e1d37b5de755c10e", size = 40428108, upload-time = "2025-08-28T15:46:27.119Z" }, + { url = "https://files.pythonhosted.org/packages/08/33/858312628aa16a6de97839adc2ca28031ebc5391f96b6fb8fdf1fcb15d6c/playwright-1.55.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c6d4d8f6f8c66c483b0835569c7f0caa03230820af8e500c181c93509c92d831", size = 45905643, upload-time = "2025-08-28T15:46:30.312Z" }, + { url = "https://files.pythonhosted.org/packages/83/83/b8d06a5b5721931aa6d5916b83168e28bd891f38ff56fe92af7bdee9860f/playwright-1.55.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29a0777c4ce1273acf90c87e4ae2fe0130182100d99bcd2ae5bf486093044838", size = 45296647, upload-time = "2025-08-28T15:46:33.221Z" }, + { url = "https://files.pythonhosted.org/packages/06/2e/9db64518aebcb3d6ef6cd6d4d01da741aff912c3f0314dadb61226c6a96a/playwright-1.55.0-py3-none-win32.whl", hash = "sha256:29e6d1558ad9d5b5c19cbec0a72f6a2e35e6353cd9f262e22148685b86759f90", size = 35476046, upload-time = "2025-08-28T15:46:36.184Z" }, + { url = "https://files.pythonhosted.org/packages/46/4f/9ba607fa94bb9cee3d4beb1c7b32c16efbfc9d69d5037fa85d10cafc618b/playwright-1.55.0-py3-none-win_amd64.whl", hash = "sha256:7eb5956473ca1951abb51537e6a0da55257bb2e25fc37c2b75af094a5c93736c", size = 35476048, upload-time = "2025-08-28T15:46:38.867Z" }, + { url = "https://files.pythonhosted.org/packages/21/98/5ca173c8ec906abde26c28e1ecb34887343fd71cc4136261b90036841323/playwright-1.55.0-py3-none-win_arm64.whl", hash = "sha256:012dc89ccdcbd774cdde8aeee14c08e0dd52ddb9135bf10e9db040527386bd76", size = 31225543, upload-time = "2025-08-28T15:46:41.613Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -878,6 +943,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/83/d6/887a1ff844e64aa823fb4905978d882a633cfe295c32eacad582b78a7d8b/pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c", size = 48608, upload-time = "2025-09-24T14:19:10.015Z" }, ] +[[package]] +name = "pyee" +version = "13.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/03/1fd98d5841cd7964a27d729ccf2199602fe05eb7a405c1462eb7277945ed/pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37", size = 31250, upload-time = "2025-03-17T18:53:15.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/4d/b9add7c84060d4c1906abe9a7e5359f2a60f7a9a4f67268b2766673427d8/pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", size = 15730, upload-time = "2025-03-17T18:53:14.532Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -916,6 +993,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, ] +[[package]] +name = "pytest-base-url" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/1a/b64ac368de6b993135cb70ca4e5d958a5c268094a3a2a4cac6f0021b6c4f/pytest_base_url-2.1.0.tar.gz", hash = "sha256:02748589a54f9e63fcbe62301d6b0496da0d10231b753e950c63e03aee745d45", size = 6702, upload-time = "2024-01-31T22:43:00.81Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/1c/b00940ab9eb8ede7897443b771987f2f4a76f06be02f1b3f01eb7567e24a/pytest_base_url-2.1.0-py3-none-any.whl", hash = "sha256:3ad15611778764d451927b2a53240c1a7a591b521ea44cebfe45849d2d2812e6", size = 5302, upload-time = "2024-01-31T22:42:58.897Z" }, +] + [[package]] name = "pytest-cov" version = "7.0.0" @@ -930,6 +1020,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, ] +[[package]] +name = "pytest-playwright-asyncio" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "playwright" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-base-url" }, + { name = "python-slugify" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/14/bdabbbcceea6acdcab21d5e920671ce27268d505d1800228c61b14fc0a47/pytest_playwright_asyncio-0.7.1.tar.gz", hash = "sha256:696896e27d8d6b0029f9d324d9b1ae64cfb239c378c13440ea06af4df68ccfae", size = 16836, upload-time = "2025-09-08T08:10:54.877Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1e/f71a3131bb03a57631d77a47cebba93b694033759f69f08a6f06c375fc30/pytest_playwright_asyncio-0.7.1-py3-none-any.whl", hash = "sha256:1cc25aed49879161cc1b1aa0f9e1a3d36d9ebdde412b6e5074440d71dc0d87e3", size = 16963, upload-time = "2025-09-08T08:10:56.788Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -960,6 +1066,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, ] +[[package]] +name = "python-slugify" +version = "8.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "text-unidecode" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921, upload-time = "2024-02-08T18:32:45.488Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" }, +] + [[package]] name = "pythonvcard4" version = "0.2.0" @@ -1069,6 +1187,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" }, ] +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + [[package]] name = "rich" version = "14.1.0" @@ -1291,6 +1424,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/bd/de8d508070629b6d84a30d01d57e4a65c69aa7f5abe7560b8fad3b50ea59/termcolor-3.1.0-py3-none-any.whl", hash = "sha256:591dd26b5c2ce03b9e43f391264626557873ce1d379019786f99b0c2bee140aa", size = 7684, upload-time = "2025-04-30T11:37:52.382Z" }, ] +[[package]] +name = "text-unidecode" +version = "1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885, upload-time = "2019-08-30T21:36:45.405Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" }, +] + [[package]] name = "tomli" version = "2.2.1" @@ -1393,6 +1535,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, ] +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + [[package]] name = "uvicorn" version = "0.37.0" From 6ce411094c1e43d62cdc4c4d023184fab549c69a Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 13 Oct 2025 19:22:59 +0200 Subject: [PATCH 083/102] test: Enable tests via playwright, disable interactive in CI --- CLAUDE.md | 19 +-- tests/conftest.py | 156 ++++++++++++++++++-- tests/integration/test_oauth.py | 2 +- tests/integration/test_oauth_interactive.py | 14 +- 4 files changed, 161 insertions(+), 30 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 578090f..2448dca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -118,35 +118,36 @@ Each Nextcloud app has a corresponding server module that: - **Avoid creating standalone test scripts** - use pytest with proper fixtures instead #### OAuth/OIDC Testing -OAuth integration tests support both **interactive** and **automated** (Playwright) authentication flows: +OAuth integration tests support both **automated** (Playwright) and **interactive** authentication flows: -**Automated Testing (Recommended for CI/CD):** +**Automated Testing (Default - Recommended for CI/CD):** +- **Default fixtures**: `nc_oauth_client`, `nc_mcp_oauth_client` now use Playwright automation by default - Uses Playwright headless browser automation to complete OAuth flow programmatically -- Fixtures: `playwright_oauth_token`, `nc_oauth_client_playwright`, `nc_mcp_oauth_client_playwright` +- All Playwright fixtures: `playwright_oauth_token`, `nc_oauth_client`, `nc_mcp_oauth_client`, `nc_oauth_client_playwright`, `nc_mcp_oauth_client_playwright` - Requires: `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD` environment variables - Uses `pytest-playwright-asyncio` for async Playwright fixtures - Playwright configuration: Use pytest CLI args like `--browser firefox --headed` to customize - Install browsers: `uv run playwright install firefox` (or `chromium`, `webkit`) - Example: ```bash - # Run OAuth tests with automated Playwright flow using Firefox - uv run pytest tests/integration/test_oauth_playwright.py --browser firefox -v + # Run all OAuth tests with automated Playwright flow using Firefox + uv run pytest tests/integration/test_oauth*.py --browser firefox -v - # Run with visible browser for debugging + # Run specific Playwright tests with visible browser for debugging uv run pytest tests/integration/test_oauth_playwright.py --browser firefox --headed -v # Run with Chromium (default) - uv run pytest tests/integration/test_oauth_playwright.py -v + uv run pytest tests/integration/test_oauth.py -v ``` **Interactive Testing (Manual browser login):** - Opens system browser and waits for manual login/authorization -- Fixtures: `interactive_oauth_token`, `nc_oauth_client`, `nc_mcp_oauth_client` +- Fixtures: `interactive_oauth_token`, `nc_oauth_client_interactive`, `nc_mcp_oauth_client_interactive` - Requires: User to complete browser-based login when prompted - Useful for: Debugging OAuth flows, testing with 2FA, local development - Example: ```bash - # Run OAuth tests with interactive flow (will open browser) + # Run OAuth tests with interactive flow (will open browser and wait for manual login) uv run pytest tests/integration/test_oauth_interactive.py -v ``` diff --git a/tests/conftest.py b/tests/conftest.py index b53916d..1a5dbe2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -136,14 +136,23 @@ async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]: @pytest.fixture(scope="session") -async def nc_mcp_oauth_client( +async def nc_mcp_oauth_client_interactive( interactive_oauth_token: str, ) -> AsyncGenerator[ClientSession, Any]: """ - Fixture to create an MCP client session for OAuth integration tests using streamable-http. + Fixture to create an MCP client session for OAuth integration tests using interactive authentication. Connects to the OAuth-enabled MCP server on port 8001 with OAuth authentication. + Requires manual browser login. + + For automated testing, use nc_mcp_oauth_client fixture instead. + + Automatically skips when running in GitHub Actions CI. """ - logger.info("Creating Streamable HTTP client for OAuth MCP server") + # Skip interactive tests in CI environments + if os.getenv("GITHUB_ACTIONS"): + pytest.skip("Skipping interactive OAuth tests in GitHub Actions CI") + + logger.info("Creating Streamable HTTP client for OAuth MCP server (Interactive)") # Pass OAuth token as Bearer token in headers headers = {"Authorization": f"Bearer {interactive_oauth_token}"} @@ -157,7 +166,7 @@ async def nc_mcp_oauth_client( session_context = ClientSession(read_stream, write_stream) session = await session_context.__aenter__() await session.initialize() - logger.info("OAuth MCP client session initialized successfully") + logger.info("OAuth MCP client session (Interactive) initialized successfully") yield session @@ -170,9 +179,9 @@ async def nc_mcp_oauth_client( if "cancel scope" in str(e): logger.debug(f"Ignoring cancel scope teardown issue: {e}") else: - logger.warning(f"Error closing OAuth session: {e}") + logger.warning(f"Error closing OAuth session (Interactive): {e}") except Exception as e: - logger.warning(f"Error closing OAuth session: {e}") + logger.warning(f"Error closing OAuth session (Interactive): {e}") try: await streamable_context.__aexit__(None, None, None) @@ -180,9 +189,70 @@ async def nc_mcp_oauth_client( if "cancel scope" in str(e): logger.debug(f"Ignoring cancel scope teardown issue: {e}") else: - logger.warning(f"Error closing OAuth streamable HTTP client: {e}") + logger.warning( + f"Error closing OAuth streamable HTTP client (Interactive): {e}" + ) except Exception as e: - logger.warning(f"Error closing OAuth streamable HTTP client: {e}") + logger.warning( + f"Error closing OAuth streamable HTTP client (Interactive): {e}" + ) + + +@pytest.fixture(scope="session") +async def nc_mcp_oauth_client( + playwright_oauth_token: str, +) -> AsyncGenerator[ClientSession, Any]: + """ + Fixture to create an MCP client session for OAuth integration tests using Playwright automation. + Connects to the OAuth-enabled MCP server on port 8001 with OAuth authentication. + + This is the default OAuth MCP fixture using headless browser automation suitable for CI/CD. + For interactive testing with manual browser login, use nc_mcp_oauth_client_interactive instead. + """ + logger.info("Creating Streamable HTTP client for OAuth MCP server (Playwright)") + + # Pass OAuth token as Bearer token in headers + headers = {"Authorization": f"Bearer {playwright_oauth_token}"} + streamable_context = streamablehttp_client( + "http://127.0.0.1:8001/mcp", headers=headers + ) + session_context = None + + try: + read_stream, write_stream, _ = await streamable_context.__aenter__() + session_context = ClientSession(read_stream, write_stream) + session = await session_context.__aenter__() + await session.initialize() + logger.info("OAuth MCP client session (Playwright) initialized successfully") + + yield session + + finally: + # Clean up in reverse order, ignoring task scope issues + if session_context is not None: + try: + await session_context.__aexit__(None, None, None) + except RuntimeError as e: + if "cancel scope" in str(e): + logger.debug(f"Ignoring cancel scope teardown issue: {e}") + else: + logger.warning(f"Error closing Playwright OAuth session: {e}") + except Exception as e: + logger.warning(f"Error closing Playwright OAuth session: {e}") + + try: + await streamable_context.__aexit__(None, None, None) + except RuntimeError as e: + if "cancel scope" in str(e): + logger.debug(f"Ignoring cancel scope teardown issue: {e}") + else: + logger.warning( + f"Error closing Playwright OAuth streamable HTTP client: {e}" + ) + except Exception as e: + logger.warning( + f"Error closing Playwright OAuth streamable HTTP client: {e}" + ) @pytest.fixture @@ -606,20 +676,28 @@ async def oauth_token() -> str: @pytest.fixture(scope="session") -async def nc_oauth_client( +async def nc_oauth_client_interactive( interactive_oauth_token: str, ) -> AsyncGenerator[NextcloudClient, Any]: """ - Fixture to create a NextcloudClient instance using OAuth authentication. - Uses the oauth_token fixture to get an access token. + Fixture to create a NextcloudClient instance using interactive OAuth authentication. + Uses the interactive_oauth_token fixture which requires manual browser login. + + For automated testing, use nc_oauth_client fixture instead. + + 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") + nextcloud_host = os.getenv("NEXTCLOUD_HOST") username = os.getenv("NEXTCLOUD_USERNAME") if not all([nextcloud_host, username]): pytest.skip("OAuth client fixture requires NEXTCLOUD_HOST and USERNAME") - logger.info(f"Creating OAuth NextcloudClient for user: {username}") + logger.info(f"Creating OAuth NextcloudClient (Interactive) for user: {username}") client = NextcloudClient.from_token( base_url=nextcloud_host, token=interactive_oauth_token, @@ -629,15 +707,54 @@ async def nc_oauth_client( # Verify the OAuth client works try: await client.capabilities() - logger.info("OAuth NextcloudClient initialized and capabilities checked.") + logger.info( + "OAuth NextcloudClient (Interactive) initialized and capabilities checked." + ) yield client except Exception as e: - logger.error(f"Failed to initialize OAuth NextcloudClient: {e}") + logger.error(f"Failed to initialize OAuth NextcloudClient (Interactive): {e}") pytest.fail(f"Failed to connect to Nextcloud with OAuth token: {e}") finally: await client.close() +@pytest.fixture(scope="session") +async def nc_oauth_client( + playwright_oauth_token: str, +) -> AsyncGenerator[NextcloudClient, Any]: + """ + Fixture to create a NextcloudClient instance using automated Playwright OAuth authentication. + This is the default OAuth fixture using headless browser automation suitable for CI/CD. + + For interactive testing with manual browser login, use nc_oauth_client_interactive instead. + """ + nextcloud_host = os.getenv("NEXTCLOUD_HOST") + username = os.getenv("NEXTCLOUD_USERNAME") + + if not all([nextcloud_host, username]): + pytest.skip("OAuth client fixture requires NEXTCLOUD_HOST and USERNAME") + + logger.info(f"Creating OAuth NextcloudClient (Playwright) for user: {username}") + client = NextcloudClient.from_token( + base_url=nextcloud_host, + token=playwright_oauth_token, + username=username, + ) + + # Verify the OAuth client works + try: + await client.capabilities() + logger.info( + "OAuth NextcloudClient (Playwright) initialized and capabilities checked." + ) + yield client + except Exception as e: + logger.error(f"Failed to initialize OAuth NextcloudClient (Playwright): {e}") + pytest.fail(f"Failed to connect to Nextcloud with Playwright OAuth token: {e}") + finally: + await client.close() + + @pytest.fixture(scope="session") def oauth_callback_server(): """ @@ -648,7 +765,12 @@ def oauth_callback_server(): - server_url: The callback URL for the server (e.g., "http://localhost:8081") The server automatically shuts down when the fixture is torn down. + + 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") from http.server import BaseHTTPRequestHandler, HTTPServer import threading from urllib.parse import urlparse, parse_qs @@ -727,7 +849,13 @@ async def interactive_oauth_token(oauth_callback_server) -> str: This uses the interactive OAuth flow to get a token. Depends on oauth_callback_server fixture for HTTP callback handling. + + 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 diff --git a/tests/integration/test_oauth.py b/tests/integration/test_oauth.py index 8c4866f..88257e7 100644 --- a/tests/integration/test_oauth.py +++ b/tests/integration/test_oauth.py @@ -66,7 +66,7 @@ async def test_oauth_client_create_note(nc_oauth_client: NextcloudClient): async def test_token_in_request_headers( - nc_oauth_client: NextcloudClient, interactive_oauth_token: str + nc_oauth_client: NextcloudClient, playwright_oauth_token: str ): """Verify that bearer token is being used in requests.""" # The client should be using BearerAuth diff --git a/tests/integration/test_oauth_interactive.py b/tests/integration/test_oauth_interactive.py index 76e93cb..27a947a 100644 --- a/tests/integration/test_oauth_interactive.py +++ b/tests/integration/test_oauth_interactive.py @@ -10,24 +10,26 @@ pytestmark = [pytest.mark.integration, pytest.mark.oauth] class TestOAuthInteractive: - """Test interactive OAuth authentication.""" + """Test interactive OAuth authentication with manual browser login.""" - async def test_oauth_client_with_interactive_flow(self, nc_oauth_client): + 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.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.notes.get_all_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.notes.create_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", ) @@ -37,5 +39,5 @@ class TestOAuthInteractive: logger.info(f"OAuth client (interactive) successfully created note {note_id}") # Clean up - await nc_oauth_client.notes.delete_note(note_id=note_id) + await nc_oauth_client_interactive.notes.delete_note(note_id=note_id) logger.info(f"OAuth client (interactive) successfully deleted note {note_id}") From 949d383606c9ed3391916ee1c49e583620f29050 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 13 Oct 2025 19:31:13 +0200 Subject: [PATCH 084/102] test: Install deps before wait, use firefox --- .github/workflows/test.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8977a0f..9af9e9e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,6 +34,10 @@ jobs: - name: Install the latest version of uv uses: astral-sh/setup-uv@3259c6206f993105e3a61b142c2d97bf4b9ef83d # v7.1.0 + - name: Install Playwright dependencies + run: | + uv run playwright install --with-deps + - name: Wait for service to be ready run: | echo "Waiting for service at http://localhost:8080/ocs/v2.php/apps/serverinfo/api/v1/info to return 401..." @@ -57,5 +61,4 @@ jobs: NEXTCLOUD_USERNAME: "admin" NEXTCLOUD_PASSWORD: "admin" run: | - uv run playwright install --with-deps - uv run --frozen python -m pytest + uv run pytest -v --browser firefox From 23cffc606bd4436025d2c8fa0a1104ddce14b60c Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 13 Oct 2025 19:43:14 +0200 Subject: [PATCH 085/102] test: Add --build flag to docker compose up --- .github/workflows/test.yml | 3 ++- CLAUDE.md | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9af9e9e..526620d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,13 +30,14 @@ jobs: uses: hoverkraft-tech/compose-action@3846bcd61da338e9eaaf83e7ed0234a12b099b72 # v2.4.1 with: compose-file: "./docker-compose.yml" + up-flags: "--build" - name: Install the latest version of uv uses: astral-sh/setup-uv@3259c6206f993105e3a61b142c2d97bf4b9ef83d # v7.1.0 - name: Install Playwright dependencies run: | - uv run playwright install --with-deps + uv run playwright install firefox --with-deps - name: Wait for service to be ready run: | diff --git a/CLAUDE.md b/CLAUDE.md index 2448dca..342d294 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -145,6 +145,7 @@ OAuth integration tests support both **automated** (Playwright) and **interactiv - Fixtures: `interactive_oauth_token`, `nc_oauth_client_interactive`, `nc_mcp_oauth_client_interactive` - Requires: User to complete browser-based login when prompted - Useful for: Debugging OAuth flows, testing with 2FA, local development +- **Automatically skipped in GitHub Actions CI** - Interactive fixtures check for `GITHUB_ACTIONS` environment variable - Example: ```bash # Run OAuth tests with interactive flow (will open browser and wait for manual login) @@ -156,6 +157,11 @@ OAuth integration tests support both **automated** (Playwright) and **interactiv - OAuth server runs on port 8001 (regular MCP on 8000) - Both flows register OAuth clients dynamically using Nextcloud's OIDC provider +**CI/CD Considerations:** +- Interactive OAuth tests are automatically skipped when `GITHUB_ACTIONS` environment variable is set +- Automated Playwright tests will run in CI/CD environments +- Use Firefox browser in CI: `--browser firefox` (Chromium may have issues with localhost redirects) + ### Configuration Files - **`pyproject.toml`** - Python project configuration using uv for dependency management From 558f5ab6a4ae579daa2f63d01a1d492132998b07 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 13 Oct 2025 19:45:25 +0200 Subject: [PATCH 086/102] test: oauth --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 526620d..0e5eea9 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 + uv run pytest -v --browser firefox -k oauth From f48d3714d2aba4d922cc9c849bcbdd406469f45d Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 13 Oct 2025 20:03:30 +0200 Subject: [PATCH 087/102] test: Add `restart` to mcp containers in docker-compose.yml --- docker-compose.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 966d13b..69bd71d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,6 +47,7 @@ services: mcp: build: . command: ["--transport", "streamable-http"] + restart: always depends_on: - app ports: @@ -61,6 +62,7 @@ services: mcp-oauth: build: . command: ["--transport", "streamable-http", "--oauth", "--port", "8001"] + restart: always depends_on: - app ports: From 13e4915e38679c26e03468ddfea4029b1a204467 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 13 Oct 2025 20:20:45 +0200 Subject: [PATCH 088/102] test: Remove unused pytest fixtures --- .github/workflows/test.yml | 2 +- nextcloud_mcp_server/app.py | 21 ++-- nextcloud_mcp_server/auth/context_helper.py | 2 +- nextcloud_mcp_server/client/__init__.py | 4 +- nextcloud_mcp_server/client/base.py | 8 +- nextcloud_mcp_server/client/contacts.py | 4 +- nextcloud_mcp_server/client/deck.py | 12 +- nextcloud_mcp_server/models/__init__.py | 76 ++++++------ nextcloud_mcp_server/models/deck.py | 2 +- nextcloud_mcp_server/server/__init__.py | 4 +- nextcloud_mcp_server/server/calendar.py | 5 +- nextcloud_mcp_server/server/deck.py | 14 +-- nextcloud_mcp_server/server/notes.py | 16 +-- tests/conftest.py | 118 ++----------------- tests/integration/test_deck_api.py | 2 +- tests/integration/test_error_propagation.py | 2 +- tests/integration/test_field_preservation.py | 3 +- tests/integration/test_oauth_interactive.py | 51 ++++---- tests/test_models.py | 2 +- 19 files changed, 115 insertions(+), 233 deletions(-) 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 From 23688f3f85bdd7037fe8a33f8bd2f6014719a0e0 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 13 Oct 2025 20:43:28 +0200 Subject: [PATCH 089/102] chore: Remove comments --- tests/conftest.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 2f42fe5..3f88452 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -148,9 +148,6 @@ async def nc_mcp_oauth_client_interactive( 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") logger.info("Creating Streamable HTTP client for OAuth MCP server (Interactive)") @@ -584,9 +581,6 @@ async def nc_oauth_client_interactive( 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") nextcloud_host = os.getenv("NEXTCLOUD_HOST") username = os.getenv("NEXTCLOUD_USERNAME") @@ -665,9 +659,6 @@ def oauth_callback_server(): 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 threading from http.server import BaseHTTPRequestHandler, HTTPServer from urllib.parse import parse_qs, urlparse From e886eff4eda7813b88906a0f7383c450696ee08e Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 13 Oct 2025 20:51:58 +0200 Subject: [PATCH 090/102] test: Fix typo in skipif condition --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 3f88452..43d2245 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -732,7 +732,7 @@ def oauth_callback_server(): @pytest.fixture(scope="session") @pytest.mark.skipif( - "GITHUB_ACTIONS" in os.environ, + "GITHUB_ACTION" in os.environ, reason="Unable to access interactive browser in GitHub Actions", ) async def interactive_oauth_token(oauth_callback_server) -> str: From 2ae3c423e946f6e27f1a1187e9eb9974b79f86d2 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 13 Oct 2025 20:54:21 +0200 Subject: [PATCH 091/102] test: Skip interactive tests if GITHUB_ACTIONS is defined --- tests/conftest.py | 4 ---- tests/integration/test_oauth_interactive.py | 5 +++++ 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 43d2245..8a55fa8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -731,10 +731,6 @@ def oauth_callback_server(): @pytest.fixture(scope="session") -@pytest.mark.skipif( - "GITHUB_ACTION" 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. diff --git a/tests/integration/test_oauth_interactive.py b/tests/integration/test_oauth_interactive.py index 3652769..fdd4402 100644 --- a/tests/integration/test_oauth_interactive.py +++ b/tests/integration/test_oauth_interactive.py @@ -1,5 +1,6 @@ """Interactive integration tests for OAuth authentication.""" +import os import logging import pytest @@ -9,6 +10,10 @@ logger = logging.getLogger(__name__) pytestmark = [pytest.mark.integration, pytest.mark.oauth] +@pytest.mark.skipif( + "GITHUB_ACTIONS" in os.environ, + reason="Unable to access interactive browser in GitHub Actions", +) 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 From d879904540ed86aba7a56a39e9e3b5d82aae2b39 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 13 Oct 2025 21:00:20 +0200 Subject: [PATCH 092/102] test: Skip for GITHUB_ACTIONS inside fixture --- tests/conftest.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 8a55fa8..bbf2fe7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -741,6 +741,13 @@ async def interactive_oauth_token(oauth_callback_server) -> str: Automatically skips when running in GitHub Actions CI. """ + # Skip if GITHUB_ACTIONS env var available, meaning that no interactive + # browser is available + if "GITHUB_ACTIONS" in os.environ: + pytest.skip( + reason="Running in GitHub Action, skipping due to lack of interactive browser" + ) + import time import webbrowser From a4ca3e00a0b67c42612e1ce57dd55d578e1a1b59 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 13 Oct 2025 21:02:52 +0200 Subject: [PATCH 093/102] Revert "test: Skip for GITHUB_ACTIONS inside fixture" This reverts commit 4d65e6952cc164fe0212faa807d1f659df3d2792. --- tests/conftest.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index bbf2fe7..8a55fa8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -741,13 +741,6 @@ async def interactive_oauth_token(oauth_callback_server) -> str: Automatically skips when running in GitHub Actions CI. """ - # Skip if GITHUB_ACTIONS env var available, meaning that no interactive - # browser is available - if "GITHUB_ACTIONS" in os.environ: - pytest.skip( - reason="Running in GitHub Action, skipping due to lack of interactive browser" - ) - import time import webbrowser From 3c4535da754144cda4633852deb5d9c281ac8166 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 13 Oct 2025 21:10:03 +0200 Subject: [PATCH 094/102] test: Replace unittest class with simple tests --- tests/integration/test_oauth_interactive.py | 2 +- tests/integration/test_oauth_playwright.py | 75 ++++++++++----------- 2 files changed, 36 insertions(+), 41 deletions(-) diff --git a/tests/integration/test_oauth_interactive.py b/tests/integration/test_oauth_interactive.py index fdd4402..e107b10 100644 --- a/tests/integration/test_oauth_interactive.py +++ b/tests/integration/test_oauth_interactive.py @@ -1,7 +1,7 @@ """Interactive integration tests for OAuth authentication.""" -import os import logging +import os import pytest diff --git a/tests/integration/test_oauth_playwright.py b/tests/integration/test_oauth_playwright.py index 2a6fc6c..9b5ccb7 100644 --- a/tests/integration/test_oauth_playwright.py +++ b/tests/integration/test_oauth_playwright.py @@ -9,51 +9,46 @@ logger = logging.getLogger(__name__) pytestmark = [pytest.mark.integration, pytest.mark.oauth] -class TestOAuthPlaywright: - """Test automated Playwright OAuth authentication.""" +async def test_playwright_oauth_token_acquisition(playwright_oauth_token: str): + """Test that Playwright can acquire an OAuth token automatically.""" + assert playwright_oauth_token is not None + assert isinstance(playwright_oauth_token, str) + assert len(playwright_oauth_token) > 0 + logger.info( + f"Successfully acquired OAuth token via Playwright: {playwright_oauth_token[:20]}..." + ) - async def test_playwright_oauth_token_acquisition( - self, playwright_oauth_token: str - ): - """Test that Playwright can acquire an OAuth token automatically.""" - assert playwright_oauth_token is not None - assert isinstance(playwright_oauth_token, str) - assert len(playwright_oauth_token) > 0 - logger.info( - f"Successfully acquired OAuth token via Playwright: {playwright_oauth_token[:20]}..." - ) - async def test_oauth_client_with_playwright_flow(self, nc_oauth_client_playwright): - """Test that OAuth client created via Playwright flow can access Nextcloud APIs.""" - # Test 1: Check capabilities - capabilities = await nc_oauth_client_playwright.capabilities() - assert capabilities is not None - logger.info("OAuth client (Playwright) successfully fetched capabilities") +async def test_oauth_client_with_playwright_flow(nc_oauth_client_playwright): + """Test that OAuth client created via Playwright flow can access Nextcloud APIs.""" + # Test 1: Check capabilities + capabilities = await nc_oauth_client_playwright.capabilities() + assert capabilities is not None + logger.info("OAuth client (Playwright) successfully fetched capabilities") - # Test 2: List notes - notes = await nc_oauth_client_playwright.notes.get_all_notes() - assert isinstance(notes, list) - logger.info(f"OAuth client (Playwright) successfully listed {len(notes)} notes") + # Test 2: List notes + notes = await nc_oauth_client_playwright.notes.get_all_notes() + assert isinstance(notes, list) + logger.info(f"OAuth client (Playwright) successfully listed {len(notes)} notes") - async def test_mcp_oauth_client_with_playwright( - self, nc_mcp_oauth_client_playwright - ): - """Test that MCP OAuth client via Playwright can execute tools.""" - import json - # Test: Execute the 'nc_notes_search_notes' tool - result = await nc_mcp_oauth_client_playwright.call_tool( - "nc_notes_search_notes", arguments={"query": ""} - ) +async def test_mcp_oauth_client_with_playwright(nc_mcp_oauth_client_playwright): + """Test that MCP OAuth client via Playwright can execute tools.""" + import json - assert result.isError is False, f"Tool execution failed: {result.content}" - assert result.content is not None - response_data = json.loads(result.content[0].text) + # Test: Execute the 'nc_notes_search_notes' tool + result = await nc_mcp_oauth_client_playwright.call_tool( + "nc_notes_search_notes", arguments={"query": ""} + ) - # The search response should have a 'results' field containing the list - assert "results" in response_data - assert isinstance(response_data["results"], list) + assert result.isError is False, f"Tool execution failed: {result.content}" + assert result.content is not None + response_data = json.loads(result.content[0].text) - logger.info( - f"Successfully executed 'nc_notes_search_notes' tool on Playwright OAuth MCP server and got {len(response_data['results'])} notes." - ) + # The search response should have a 'results' field containing the list + assert "results" in response_data + assert isinstance(response_data["results"], list) + + logger.info( + f"Successfully executed 'nc_notes_search_notes' tool on Playwright OAuth MCP server and got {len(response_data['results'])} notes." + ) From 057e25b6538b634d53010457e42f6cc5eca46af7 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 13 Oct 2025 23:34:49 +0200 Subject: [PATCH 095/102] chore: Add support for overriding public issuer URL test: Add patch for PKCE support --- ...-challenge-methods-to-discovery-documen.patch | 16 ++++++++++++++++ app-hooks/post-installation/install-oidc-app.sh | 2 ++ docker-compose.yml | 6 +++++- nextcloud_mcp_server/app.py | 9 +++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 app-hooks/post-installation/0002-Add-PKCE-code-challenge-methods-to-discovery-documen.patch diff --git a/app-hooks/post-installation/0002-Add-PKCE-code-challenge-methods-to-discovery-documen.patch b/app-hooks/post-installation/0002-Add-PKCE-code-challenge-methods-to-discovery-documen.patch new file mode 100644 index 0000000..99f70f4 --- /dev/null +++ b/app-hooks/post-installation/0002-Add-PKCE-code-challenge-methods-to-discovery-documen.patch @@ -0,0 +1,16 @@ +diff --git a/lib/Util/DiscoveryGenerator.php b/lib/Util/DiscoveryGenerator.php +index ee3cd57..6429f94 100644 +--- a/lib/Util/DiscoveryGenerator.php ++++ b/lib/Util/DiscoveryGenerator.php +@@ -171,6 +171,11 @@ class DiscoveryGenerator + $discoveryPayload['registration_endpoint'] = $host . $this->urlGenerator->linkToRoute('oidc.DynamicRegistration.registerClient', []); + } + ++ // Add PKCE support if enabled ++ if ($this->appConfig->getAppValueBool('proof_key_for_code_exchange', false)) { ++ $discoveryPayload['code_challenge_methods_supported'] = ['S256']; ++ } ++ + $this->logger->info('Request to Discovery Endpoint.'); + + $response = new JSONResponse($discoveryPayload); diff --git a/app-hooks/post-installation/install-oidc-app.sh b/app-hooks/post-installation/install-oidc-app.sh index 8858f52..50c59ab 100755 --- a/app-hooks/post-installation/install-oidc-app.sh +++ b/app-hooks/post-installation/install-oidc-app.sh @@ -11,9 +11,11 @@ php /var/www/html/occ app:enable oidc php /var/www/html/occ app:enable user_oidc patch -u /var/www/html/custom_apps/user_oidc/lib/User/Backend.php -i /docker-entrypoint-hooks.d/post-installation/0001-Fix-Bearer-token-authentication-causing-session-logo.patch +patch -u /var/www/html/custom_apps/oidc/lib/Util/DiscoveryGenerator.php -i /docker-entrypoint-hooks.d/post-installation/0002-Add-PKCE-code-challenge-methods-to-discovery-documen.patch # Configure OIDC Identity Provider with dynamic client registration enabled php /var/www/html/occ config:app:set oidc dynamic_client_registration --value='true' +php /var/www/html/occ config:app:set oidc proof_key_for_code_exchange --value=true --type=boolean # Configure user_oidc to validate bearer tokens from the OIDC Identity Provider php /var/www/html/occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean diff --git a/docker-compose.yml b/docker-compose.yml index 69bd71d..1421b57 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,7 +28,7 @@ services: #- command: chown -R www-data:www-data /var/www/html && while ! nc -z db 3306; do sleep 1; echo sleeping; done #user: root ports: - - 127.0.0.1:8080:80 + - 0.0.0.0:8080:80 depends_on: - redis - db @@ -67,8 +67,12 @@ services: - app ports: - 127.0.0.1:8001:8001 + #extra_hosts: + #- "host.docker.internal:host-gateway" environment: - NEXTCLOUD_HOST=http://app:80 + - NEXTCLOUD_MCP_SERVER_URL=http://127.0.01:8001 + - NEXTCLOUD_PUBLIC_ISSUER_URL=http://127.0.0.1:8080 # No USERNAME/PASSWORD - will use OAuth volumes: - oauth-client-storage:/app/.oauth diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index 91afaa6..c99c57e 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -214,6 +214,15 @@ async def setup_oauth_config(): userinfo_uri = discovery["userinfo_endpoint"] registration_endpoint = discovery.get("registration_endpoint") + # Allow override of public issuer URL for clients + # (useful when MCP server accesses Nextcloud via internal URL + # but needs to advertise a different URL to clients) + public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL") + if public_issuer: + public_issuer = public_issuer.rstrip("/") + logger.info(f"Using public issuer URL for clients: {public_issuer}") + issuer = public_issuer + # Handle client registration client_id = os.getenv("NEXTCLOUD_OIDC_CLIENT_ID") client_secret = os.getenv("NEXTCLOUD_OIDC_CLIENT_SECRET") From afc82ce3dc8ede12180ed0da0e3a6620843869f0 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Tue, 14 Oct 2025 00:07:57 +0200 Subject: [PATCH 096/102] chore: Validate auth server support for PKCE on startup --- Dockerfile | 2 ++ nextcloud_mcp_server/app.py | 67 +++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/Dockerfile b/Dockerfile index f435026..4d0ec71 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,4 +6,6 @@ COPY . . RUN uv sync --locked --no-dev +ENV PYTHONUNBUFFERED=1 + ENTRYPOINT ["/app/.venv/bin/nextcloud-mcp-server", "--host", "0.0.0.0"] diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index c99c57e..955efac 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -28,6 +28,70 @@ from nextcloud_mcp_server.server import ( logger = logging.getLogger(__name__) +def validate_pkce_support(discovery: dict, discovery_url: str) -> None: + """ + Validate that the OIDC provider properly advertises PKCE support. + + According to RFC 8414, if code_challenge_methods_supported is absent, + it means the authorization server does not support PKCE. + + MCP clients require PKCE with S256 and will refuse to connect if this + field is missing or doesn't include S256. + """ + + code_challenge_methods = discovery.get("code_challenge_methods_supported") + + if code_challenge_methods is None: + click.echo("=" * 80, err=True) + click.echo( + "ERROR: OIDC CONFIGURATION ERROR - Missing PKCE Support Advertisement", + err=True, + ) + click.echo("=" * 80, err=True) + click.echo(f"Discovery URL: {discovery_url}", err=True) + click.echo("", err=True) + click.echo( + "The OIDC discovery document is missing 'code_challenge_methods_supported'.", + err=True, + ) + click.echo( + "According to RFC 8414, this means the server does NOT support PKCE.", + err=True, + ) + click.echo("", err=True) + click.echo("⚠️ MCP clients (like Claude Code) WILL REJECT this provider!") + click.echo("", err=True) + click.echo("How to fix:", err=True) + click.echo( + " 1. Ensure PKCE is enabled in Nextcloud OIDC app settings", err=True + ) + click.echo( + " 2. Update the OIDC app to advertise PKCE support in discovery", err=True + ) + click.echo(" 3. See: RFC 8414 Section 2 (Authorization Server Metadata)") + click.echo("=" * 80, err=True) + click.echo("", err=True) + return + + if "S256" not in code_challenge_methods: + click.echo("=" * 80, err=True) + click.echo( + "WARNING: OIDC CONFIGURATION WARNING - S256 Challenge Method Not Advertised", + err=True, + ) + click.echo("=" * 80, err=True) + click.echo(f"Discovery URL: {discovery_url}", err=True) + click.echo(f"Advertised methods: {code_challenge_methods}", err=True) + click.echo("", err=True) + click.echo("MCP specification requires S256 code challenge method.", err=True) + click.echo("Some clients may reject this provider.", err=True) + click.echo("=" * 80, err=True) + click.echo("", err=True) + return + + click.echo(f"✓ PKCE support validated: {code_challenge_methods}") + + @dataclass class AppContext: """Application context for BasicAuth mode.""" @@ -209,6 +273,9 @@ async def setup_oauth_config(): logger.info("OIDC discovery successful") + # Validate PKCE support + validate_pkce_support(discovery, discovery_url) + # Extract endpoints issuer = discovery["issuer"] userinfo_uri = discovery["userinfo_endpoint"] From 1023a7d9c79dc1a6eac270f0847bd87cadddd6a9 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Tue, 14 Oct 2025 00:17:28 +0200 Subject: [PATCH 097/102] chore: Remove comments --- docker-compose.yml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 1421b57..2cffd7e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,11 +22,7 @@ services: app: image: docker.io/library/nextcloud:32.0.0@sha256:3e70e4dfe882ef44738fdc30d9896fb07c12febb27c4a1177e3d63dc0004a0b4 - #user: www-data:www-data restart: always - #post_start: - #- command: chown -R www-data:www-data /var/www/html && while ! nc -z db 3306; do sleep 1; echo sleeping; done - #user: root ports: - 0.0.0.0:8080:80 depends_on: @@ -56,8 +52,6 @@ services: - NEXTCLOUD_HOST=http://app:80 - NEXTCLOUD_USERNAME=admin - NEXTCLOUD_PASSWORD=admin - #volumes: - #- ./nextcloud_mcp_server:/app/nextcloud_mcp_server:ro mcp-oauth: build: . @@ -67,8 +61,6 @@ services: - app ports: - 127.0.0.1:8001:8001 - #extra_hosts: - #- "host.docker.internal:host-gateway" environment: - NEXTCLOUD_HOST=http://app:80 - NEXTCLOUD_MCP_SERVER_URL=http://127.0.01:8001 @@ -76,8 +68,6 @@ services: # No USERNAME/PASSWORD - will use OAuth volumes: - oauth-client-storage:/app/.oauth - #volumes: - #- ./nextcloud_mcp_server:/app/nextcloud_mcp_server:ro volumes: nextcloud: From 3ed24bd5e3c9801c5aa45b63fcd9b40515bc13a3 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Tue, 14 Oct 2025 01:15:32 +0200 Subject: [PATCH 098/102] docs: restructure documentation --- README.md | 16 +- docs/authentication.md | 64 ++- docs/configuration.md | 10 +- docs/oauth-architecture.md | 322 ++++++++++++ docs/oauth-setup.md | 604 ++++++++++++++++------ docs/oauth-troubleshooting.md | 554 ++++++++++++++++++++ docs/oauth-upstream-status.md | 226 ++++++++ docs/oauth2-bearer-token-session-issue.md | 97 ---- docs/quickstart-oauth.md | 163 ++++++ docs/troubleshooting.md | 20 +- docs/user_oidc-pr-description.md | 96 ---- 11 files changed, 1799 insertions(+), 373 deletions(-) create mode 100644 docs/oauth-architecture.md create mode 100644 docs/oauth-troubleshooting.md create mode 100644 docs/oauth-upstream-status.md delete mode 100644 docs/oauth2-bearer-token-session-issue.md create mode 100644 docs/quickstart-oauth.md delete mode 100644 docs/user_oidc-pr-description.md diff --git a/README.md b/README.md index 0ddf4e7..08fc445 100644 --- a/README.md +++ b/README.md @@ -75,11 +75,12 @@ See [Configuration Guide](docs/configuration.md) for all options. ### 3. Set Up Authentication **OAuth Setup (recommended):** -1. Install Nextcloud OIDC app +1. Install Nextcloud OIDC apps (`oidc` + `user_oidc`) 2. Enable dynamic client registration -3. Start the server +3. Configure Bearer token validation +4. Start the server -See [OAuth Setup Guide](docs/oauth-setup.md) for step-by-step instructions. +See [OAuth Quick Start](docs/quickstart-oauth.md) for 5-minute setup or [OAuth Setup Guide](docs/oauth-setup.md) for production deployment. ### 4. Run the Server @@ -117,12 +118,17 @@ Or connect from: - **[Installation](docs/installation.md)** - Install the server - **[Configuration](docs/configuration.md)** - Environment variables and settings - **[Authentication](docs/authentication.md)** - OAuth vs BasicAuth -- **[OAuth Setup Guide](docs/oauth-setup.md)** - Step-by-step OAuth configuration - **[Running the Server](docs/running.md)** - Start and manage the server +### OAuth Documentation +- **[OAuth Quick Start](docs/quickstart-oauth.md)** - 5-minute setup guide +- **[OAuth Setup Guide](docs/oauth-setup.md)** - Production deployment +- **[OAuth Architecture](docs/oauth-architecture.md)** - How OAuth works +- **[OAuth Troubleshooting](docs/oauth-troubleshooting.md)** - OAuth-specific issues +- **[Upstream Status](docs/oauth-upstream-status.md)** - Required patches and PRs + ### Reference - **[Troubleshooting](docs/troubleshooting.md)** - Common issues and solutions -- **[OAuth Bearer Token Issue](docs/oauth2-bearer-token-session-issue.md)** - Required patch for non-OCS endpoints ### App-Specific Documentation - [Notes API](docs/notes.md) diff --git a/docs/authentication.md b/docs/authentication.md index 9db7785..cf6b9d4 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -13,6 +13,23 @@ The Nextcloud MCP server supports two authentication modes for connecting to you OAuth2/OIDC authentication provides secure, token-based authentication following modern security standards. +### Architecture + +The Nextcloud MCP Server acts as an **OAuth 2.0 Resource Server**, protecting access to Nextcloud resources: + +``` +MCP Client ←→ MCP Server (Resource Server) ←→ Nextcloud (Authorization Server + APIs) + OAuth Flow with PKCE Bearer Token Auth +``` + +**Key Components**: +- **MCP Server**: OAuth Resource Server (validates tokens, provides MCP tools) +- **Nextcloud `oidc` app**: OAuth Authorization Server (issues tokens) +- **Nextcloud `user_oidc` app**: Token validation middleware +- **MCP Client**: Any MCP-compatible client (Claude, custom clients) + +For detailed architecture, see [OAuth Architecture](oauth-architecture.md). + ### Required Nextcloud Apps OAuth authentication requires **two Nextcloud apps** to work together: @@ -39,14 +56,17 @@ OAuth authentication requires **two Nextcloud apps** to work together: **Installation:** Available in Nextcloud App Store under "Security" -**Important:** The `user_oidc` app requires a patch for Bearer token support on non-OCS endpoints (like Notes API). See [oauth2-bearer-token-session-issue.md](oauth2-bearer-token-session-issue.md) for details. +**Important:** The `user_oidc` app requires a patch for Bearer token support on non-OCS endpoints (like Notes API). See [Upstream Status](oauth-upstream-status.md) for details. ### Benefits - **Zero-config deployment** via dynamic client registration - **No credential storage** in environment variables - **Per-user authentication** with access tokens -- **Automatic token validation** via Nextcloud OIDC -- **Secure by design** following OAuth 2.0 standards +- **Per-user permissions** - each user has their own Nextcloud client +- **Automatic token validation** via Nextcloud OIDC userinfo endpoint +- **Token caching** for performance (default: 1 hour TTL) +- **PKCE required** for enhanced security (S256 code challenge) +- **Secure by design** following OAuth 2.0 and OpenID Connect standards ### Current Implementation Limitations @@ -54,31 +74,49 @@ OAuth authentication requires **two Nextcloud apps** to work together: > **Tested Configuration:** > - ✅ Nextcloud `oidc` app (OIDC Identity Provider) + `user_oidc` app (OIDC User Backend) > - ✅ Nextcloud acting as its own identity provider (self-hosted OIDC) +> - ✅ MCP server as OAuth Resource Server +> - ✅ PKCE with S256 code challenge method > > **Not Tested:** > - ❌ External identity providers (Azure AD, Keycloak, Okta, etc.) > - ❌ Using `user_oidc` with external OIDC providers > > **Known Requirements:** -> - 🔧 The `user_oidc` app requires a patch for Bearer token support on non-OCS endpoints (see [oauth2-bearer-token-session-issue.md](oauth2-bearer-token-session-issue.md)) +> - 🔧 The `user_oidc` app requires a patch for Bearer token support on non-OCS endpoints (see [Upstream Status](oauth-upstream-status.md)) > - ⏱️ Dynamic client registration credentials expire (default: 1 hour) - use pre-configured clients for production +> - 🔐 PKCE must be advertised in OIDC discovery (see [Upstream Status](oauth-upstream-status.md)) ### How OAuth Works -When a client connects to the MCP server with OAuth enabled: +The MCP server implements the OAuth 2.0 Resource Server pattern: -1. Client receives OAuth authorization URL from the MCP server -2. User authenticates via browser to Nextcloud -3. Nextcloud redirects back with authorization code -4. Client exchanges code for access token -5. Client uses token to access MCP server +**Phase 1: Authorization (OAuth Flow with PKCE)** +1. MCP client connects and receives OAuth settings (issuer URL, scopes) +2. Client initiates OAuth flow with PKCE (Proof Key for Code Exchange) +3. User authenticates via browser to Nextcloud +4. Nextcloud redirects back with authorization code +5. Client exchanges code + code_verifier for access token -All API requests to Nextcloud use the user's OAuth token, ensuring proper permissions and audit trails. +**Phase 2: API Access (Bearer Token Validation)** +6. Client sends MCP requests with `Authorization: Bearer ` header +7. MCP server validates token by calling Nextcloud's userinfo endpoint +8. Server creates per-user NextcloudClient instance with the token +9. All Nextcloud API requests use the user's Bearer token +10. User-specific permissions and audit trails apply + +This ensures: +- Each user has their own authenticated session +- Actions appear from the correct user in Nextcloud logs +- Proper permission boundaries are maintained +- No shared credentials between users ### See Also -- [OAuth Setup Guide](oauth-setup.md) - Step-by-step setup instructions +- [OAuth Quick Start](quickstart-oauth.md) - 5-minute setup for development +- [OAuth Setup Guide](oauth-setup.md) - Detailed production setup +- [OAuth Architecture](oauth-architecture.md) - Technical details +- [Upstream Status](oauth-upstream-status.md) - Required patches and PR status +- [OAuth Troubleshooting](oauth-troubleshooting.md) - OAuth-specific issues - [Configuration](configuration.md) - Environment variables -- [Troubleshooting](troubleshooting.md) - Common OAuth issues ## Basic Authentication (Legacy) diff --git a/docs/configuration.md b/docs/configuration.md index 3742b1f..ab2bd30 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -78,9 +78,9 @@ Before using OAuth configuration: - Enable dynamic client registration (if using auto-registration) - Settings → OIDC - Enable Bearer token validation: `php occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean` -3. **Apply Bearer token patch** - The `user_oidc` app requires a patch for non-OCS endpoints - See [oauth2-bearer-token-session-issue.md](oauth2-bearer-token-session-issue.md) +3. **Apply Bearer token patch** - The `user_oidc` app requires a patch for non-OCS endpoints - See [Upstream Status](oauth-upstream-status.md) for details -See the [OAuth Setup Guide](oauth-setup.md) for detailed instructions. +See the [OAuth Setup Guide](oauth-setup.md) for detailed step-by-step instructions, or [OAuth Quick Start](quickstart-oauth.md) for a 5-minute setup. --- @@ -243,7 +243,11 @@ uv run nextcloud-mcp-server --no-oauth \ ## See Also -- [OAuth Setup Guide](oauth-setup.md) - Step-by-step OAuth configuration +- [OAuth Quick Start](quickstart-oauth.md) - 5-minute OAuth setup for development +- [OAuth Setup Guide](oauth-setup.md) - Detailed OAuth configuration for production +- [OAuth Architecture](oauth-architecture.md) - How OAuth works in the MCP server +- [Upstream Status](oauth-upstream-status.md) - Required patches and upstream PRs - [Authentication](authentication.md) - Authentication modes comparison - [Running the Server](running.md) - Starting the server with different configurations - [Troubleshooting](troubleshooting.md) - Common configuration issues +- [OAuth Troubleshooting](oauth-troubleshooting.md) - OAuth-specific troubleshooting diff --git a/docs/oauth-architecture.md b/docs/oauth-architecture.md new file mode 100644 index 0000000..dbf9864 --- /dev/null +++ b/docs/oauth-architecture.md @@ -0,0 +1,322 @@ +# OAuth Architecture + +This document explains how OAuth2/OIDC authentication works in the Nextcloud MCP Server implementation. + +## Overview + +The Nextcloud MCP Server acts as an **OAuth 2.0 Resource Server**, protecting access to Nextcloud resources. It relies on Nextcloud's OIDC Identity Provider for user authentication and token validation. + +## Architecture Diagram + +``` +┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐ +│ │ │ │ │ │ +│ MCP Client │ │ MCP Server │ │ Nextcloud │ +│ (Claude, │ │ (Resource │ │ Instance │ +│ etc.) │ │ Server) │ │ │ +│ │ │ │ │ │ +└──────┬──────┘ └────────┬─────────┘ └────────┬────────┘ + │ │ │ + │ │ │ + │ 1. Connect to MCP │ │ + ├─────────────────────────────────>│ │ + │ │ │ + │ 2. Return auth settings │ │ + │ (issuer_url, scopes) │ │ + │<─────────────────────────────────┤ │ + │ │ │ + │ │ │ + │ 3. Start OAuth flow (with PKCE) │ │ + ├──────────────────────────────────┼────────────────────────────────────>│ + │ │ /apps/oidc/authorize │ + │ │ │ + │ 4. User authenticates in browser│ │ + │<─────────────────────────────────┼─────────────────────────────────────┤ + │ │ │ + │ 5. Authorization code (redirect)│ │ + │<─────────────────────────────────┤ │ + │ │ │ + │ 6. Exchange code for token │ │ + ├──────────────────────────────────┼────────────────────────────────────>│ + │ │ /apps/oidc/token │ + │ │ │ + │ 7. Access token │ │ + │<─────────────────────────────────┼─────────────────────────────────────┤ + │ │ │ + │ │ │ + │ 8. API request with Bearer token│ │ + ├─────────────────────────────────>│ │ + │ Authorization: Bearer xxx │ │ + │ │ │ + │ │ 9. Validate token via userinfo │ + │ ├────────────────────────────────────>│ + │ │ /apps/oidc/userinfo │ + │ │ │ + │ │ 10. User info (token valid) │ + │ │<────────────────────────────────────┤ + │ │ │ + │ │ 11. Nextcloud API request │ + │ ├────────────────────────────────────>│ + │ │ Authorization: Bearer xxx │ + │ │ (Notes, Calendar, etc.) │ + │ │ │ + │ │ 12. API response │ + │ │<────────────────────────────────────┤ + │ │ │ + │ 13. MCP tool response │ │ + │<─────────────────────────────────┤ │ + │ │ │ +``` + +## Components + +### 1. MCP Client +- Any MCP-compatible client (Claude Desktop, Claude Code, custom clients) +- Initiates OAuth flow with PKCE (Proof Key for Code Exchange) +- Stores and sends access token with each request +- **Example**: Claude Desktop, Claude Code + +### 2. MCP Server (Resource Server) +- **Role**: OAuth 2.0 Resource Server +- **Location**: This Nextcloud MCP Server implementation +- **Responsibilities**: + - Validates Bearer tokens by calling Nextcloud's userinfo endpoint + - Caches validated tokens (default: 1 hour TTL) + - Creates authenticated Nextcloud client instances per-user + - Enforces PKCE requirements (S256 code challenge method) + - Exposes Nextcloud functionality via MCP tools + +**Key Files**: +- [`app.py`](../nextcloud_mcp_server/app.py) - OAuth mode detection and configuration +- [`auth/token_verifier.py`](../nextcloud_mcp_server/auth/token_verifier.py) - Token validation logic +- [`auth/context_helper.py`](../nextcloud_mcp_server/auth/context_helper.py) - Per-user client creation + +### 3. Nextcloud OIDC Apps + +#### a) `oidc` - OIDC Identity Provider +- **Role**: OAuth 2.0 Authorization Server +- **Location**: Nextcloud app (`apps/oidc`) +- **Endpoints**: + - `/.well-known/openid-configuration` - Discovery endpoint + - `/apps/oidc/authorize` - Authorization endpoint + - `/apps/oidc/token` - Token endpoint + - `/apps/oidc/userinfo` - User info endpoint (token validation) + - `/apps/oidc/jwks` - JSON Web Key Set + - `/apps/oidc/register` - Dynamic client registration + +**Configuration**: +```bash +# Enable dynamic client registration (optional) +# Settings → OIDC → "Allow dynamic client registration" +``` + +#### b) `user_oidc` - OpenID Connect User Backend +- **Role**: Bearer token validation middleware +- **Location**: Nextcloud app (`apps/user_oidc`) +- **Responsibilities**: + - Validates Bearer tokens for Nextcloud API requests + - Creates user sessions from valid Bearer tokens + - Integrates with Nextcloud's authentication system + +**Configuration**: +```bash +# Enable Bearer token validation (required) +php occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean +``` + +> [!IMPORTANT] +> The `user_oidc` app requires a patch to properly support Bearer token authentication for non-OCS endpoints. See [Upstream Status](oauth-upstream-status.md) for details. + +### 4. Nextcloud Instance +- **Role**: Resource Owner / API Provider +- **Provides**: Notes, Calendar, Contacts, Deck, Files, etc. + +## Authentication Flow + +### Phase 1: OAuth Authorization (Steps 1-7) + +1. **Client Connects**: MCP client connects to MCP server +2. **Auth Settings**: MCP server returns OAuth settings: + ```json + { + "issuer_url": "https://nextcloud.example.com", + "resource_server_url": "http://localhost:8000", + "required_scopes": ["openid", "profile"] + } + ``` +3. **OAuth Flow**: Client initiates OAuth flow with PKCE + - Generates `code_verifier` (random string) + - Calculates `code_challenge` = SHA256(code_verifier) + - Redirects user to `/apps/oidc/authorize` with `code_challenge` +4. **User Authentication**: User logs in to Nextcloud via browser +5. **Authorization Code**: Nextcloud redirects back with authorization code +6. **Token Exchange**: Client exchanges code for access token + - Sends `code` + `code_verifier` to `/apps/oidc/token` + - OIDC app validates PKCE challenge +7. **Access Token**: Client receives access token (JWT or opaque) + +### Phase 2: API Access (Steps 8-13) + +8. **API Request**: Client sends MCP request with Bearer token +9. **Token Validation**: MCP server validates token: + - Checks cache (1-hour TTL by default) + - If not cached, calls `/apps/oidc/userinfo` with Bearer token + - Extracts username from `sub` or `preferred_username` claim +10. **User Info**: Nextcloud returns user info if token is valid +11. **Nextcloud API Call**: MCP server calls Nextcloud API on behalf of user + - Creates `NextcloudClient` instance with Bearer token + - User-specific permissions apply +12. **API Response**: Nextcloud returns data +13. **MCP Response**: MCP server returns formatted response to client + +## Token Validation + +The MCP server validates tokens using the **userinfo endpoint approach**: + +### Why Userinfo (vs JWT Validation)? + +**Advantages**: +- Works with both JWT and opaque tokens +- No need to manage JWKS rotation +- Always up-to-date (respects token revocation) +- Simpler implementation + +**Caching Strategy**: +- Validated tokens cached for 1 hour (configurable) +- Cache keyed by token string +- Expired tokens re-validated automatically + +**Implementation**: See [`NextcloudTokenVerifier`](../nextcloud_mcp_server/auth/token_verifier.py) + +## PKCE Requirement + +The MCP server **requires** PKCE with S256 code challenge method: + +1. Server validates OIDC discovery advertises PKCE support +2. Checks for `code_challenge_methods_supported` field +3. Verifies `S256` is included in supported methods +4. Logs error if PKCE not properly advertised + +**Why PKCE?**: +- Required by MCP specification +- Protects against authorization code interception +- Essential for public clients (desktop apps, CLI tools) + +**Implementation**: See [`validate_pkce_support()`](../nextcloud_mcp_server/app.py#L31-L93) + +## Client Registration + +The MCP server supports two client registration modes: + +### Automatic Registration (Dynamic Client Registration) + +```bash +# No client credentials needed +NEXTCLOUD_HOST=https://nextcloud.example.com +``` + +**How it works**: +1. Server checks `/.well-known/openid-configuration` for `registration_endpoint` +2. Calls `/apps/oidc/register` to register new client +3. Saves credentials to `.nextcloud_oauth_client.json` +4. Re-registers if credentials expire + +**Best for**: Development, testing, short-lived deployments + +### Pre-configured Client + +```bash +# Manual client registration via CLI +php occ oidc:create --name="MCP Server" --type=confidential --redirect-uri="http://localhost:8000/oauth/callback" + +# Configure MCP server +NEXTCLOUD_HOST=https://nextcloud.example.com +NEXTCLOUD_OIDC_CLIENT_ID=abc123 +NEXTCLOUD_OIDC_CLIENT_SECRET=xyz789 +``` + +**Best for**: Production, long-running deployments + +## Per-User Client Instances + +Each authenticated user gets their own `NextcloudClient` instance: + +```python +# From MCP context (contains validated token) +client = get_client_from_context(ctx) + +# Creates NextcloudClient with: +# - username: from token's 'sub' or 'preferred_username' claim +# - auth: BearerAuth(token) +``` + +**Benefits**: +- User-specific permissions +- Audit trail (actions appear from correct user) +- No shared credentials +- Multi-user support + +**Implementation**: See [`get_client_from_context()`](../nextcloud_mcp_server/auth/context_helper.py) + +## Security Considerations + +### Token Storage +- MCP client stores access token +- MCP server does NOT store tokens (validates per-request) +- Token validation results cached in-memory only + +### PKCE Protection +- Server validates PKCE is advertised +- Client MUST use PKCE with S256 +- Protects against authorization code interception + +### Scopes +- Required scopes: `openid`, `profile` +- Additional scopes inferred from userinfo response + +### Token Validation +- Every MCP request validates Bearer token +- Cached for performance (1-hour default) +- Calls userinfo endpoint for validation + +## Configuration + +See [Configuration Guide](configuration.md) for all OAuth environment variables: + +| Variable | Purpose | +|----------|---------| +| `NEXTCLOUD_HOST` | Nextcloud instance URL | +| `NEXTCLOUD_OIDC_CLIENT_ID` | Pre-configured client ID (optional) | +| `NEXTCLOUD_OIDC_CLIENT_SECRET` | Pre-configured client secret (optional) | +| `NEXTCLOUD_MCP_SERVER_URL` | MCP server URL for OAuth callbacks | +| `NEXTCLOUD_OIDC_CLIENT_STORAGE` | Path for auto-registered credentials | + +## Testing + +The integration test suite includes comprehensive OAuth testing: + +- **Automated tests** (Playwright): [`tests/integration/test_oauth_playwright.py`](../tests/integration/test_oauth_playwright.py) +- **Interactive tests**: [`tests/integration/test_oauth_interactive.py`](../tests/integration/test_oauth_interactive.py) +- **Fixtures**: [`tests/conftest.py`](../tests/conftest.py) + +Run OAuth tests: +```bash +# Start OAuth-enabled MCP server +docker-compose up --build -d mcp-oauth + +# Run automated tests +uv run pytest tests/integration/test_oauth_playwright.py --browser firefox -v + +# Run interactive tests (manual login) +uv run pytest tests/integration/test_oauth_interactive.py -v +``` + +## See Also + +- [OAuth Setup Guide](oauth-setup.md) - Configuration steps +- [OAuth Quick Start](quickstart-oauth.md) - Get started quickly +- [Upstream Status](oauth-upstream-status.md) - Required upstream patches +- [OAuth Troubleshooting](oauth-troubleshooting.md) - Common issues +- [RFC 6749](https://www.rfc-editor.org/rfc/rfc6749) - OAuth 2.0 Authorization Framework +- [RFC 7636](https://www.rfc-editor.org/rfc/rfc7636) - PKCE +- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html) diff --git a/docs/oauth-setup.md b/docs/oauth-setup.md index aa7c692..3024f3f 100644 --- a/docs/oauth-setup.md +++ b/docs/oauth-setup.md @@ -1,255 +1,545 @@ # OAuth Setup Guide -This guide walks you through setting up OAuth2/OIDC authentication for the Nextcloud MCP server. +This guide walks you through setting up OAuth2/OIDC authentication for the Nextcloud MCP server in production. + +> **Quick Start?** If you want a 5-minute setup for development, see [OAuth Quick Start](quickstart-oauth.md). + +## Table of Contents + +- [Prerequisites](#prerequisites) +- [Architecture Overview](#architecture-overview) +- [Step 1: Install Nextcloud Apps](#step-1-install-nextcloud-apps) +- [Step 2: Configure OIDC Apps](#step-2-configure-oidc-apps) +- [Step 3: Choose Deployment Mode](#step-3-choose-deployment-mode) +- [Step 4: Configure MCP Server](#step-4-configure-mcp-server) +- [Step 5: Start and Verify](#step-5-start-and-verify) +- [Testing Authentication](#testing-authentication) +- [Production Recommendations](#production-recommendations) ## Prerequisites -- Nextcloud instance with administrator access -- Python 3.11+ installed -- Nextcloud MCP server installed (see [Installation Guide](installation.md)) +Before beginning, ensure you have: -## Step 1: Install Required Nextcloud Apps +- **Nextcloud instance** with administrator access +- **Nextcloud version** 28 or later +- **SSH/CLI access** to Nextcloud server (for `occ` commands) +- **Python 3.11+** installed on MCP server host +- **MCP server installed** (see [Installation Guide](installation.md)) -OAuth authentication requires **two apps** to work together: +## Architecture Overview -### Install the OIDC Identity Provider App +The OAuth implementation uses the following components: -1. Open your Nextcloud instance as an administrator +``` +MCP Client ←→ MCP Server (Resource Server) ←→ Nextcloud (Authorization Server + APIs) + OAuth Flow Bearer Token Auth +``` + +**Key Roles**: +- **MCP Server**: OAuth Resource Server (validates tokens, provides MCP tools) +- **Nextcloud `oidc` app**: OAuth Authorization Server (issues tokens) +- **Nextcloud `user_oidc` app**: Token validation middleware + +For detailed architecture, see [OAuth Architecture](oauth-architecture.md). + +## Step 1: Install Nextcloud Apps + +OAuth authentication requires **two Nextcloud apps** to work together. + +### Required Apps + +#### 1. `oidc` - OIDC Identity Provider + +**Purpose**: Makes Nextcloud an OAuth2/OIDC authorization server + +**Installation**: +1. Open Nextcloud as administrator 2. Navigate to **Apps** → **Security** -3. Find and install the **OIDC** app (full name: "OIDC Identity Provider") -4. Enable the app +3. Find **"OIDC"** (full name: "OIDC Identity Provider") +4. Click **Enable** or **Download and enable** -This app makes Nextcloud an OAuth2/OIDC authorization server. +**Provides**: +- OAuth2 authorization endpoint +- Token endpoint +- User info endpoint +- JWKS endpoint +- Dynamic client registration endpoint (optional) -### Install the OpenID Connect User Backend App +#### 2. `user_oidc` - OpenID Connect User Backend +**Purpose**: Authenticates users and validates Bearer tokens + +**Installation**: 1. In **Apps** → **Security** -2. Find and install the **OpenID Connect user backend** app (app ID: `user_oidc`) -3. Enable the app +2. Find **"OpenID Connect user backend"** (app ID: `user_oidc`) +3. Click **Enable** or **Download and enable** -This app handles Bearer token validation and user authentication. +**Provides**: +- Bearer token validation against OIDC provider +- User authentication via OIDC +- Session management for authenticated users > [!IMPORTANT] -> **Required Patch:** The `user_oidc` app needs a patch for Bearer token authentication to work with non-OCS endpoints (like Notes API). See [oauth2-bearer-token-session-issue.md](oauth2-bearer-token-session-issue.md) for the patch and installation instructions. +> **Upstream Patch Required**: The `user_oidc` app needs a patch for Bearer token support with app-specific APIs (Notes, Calendar, etc.). The patch is pending upstream review. +> +> **Status**: See [Upstream Status](oauth-upstream-status.md) for current PR status and workarounds. +> +> **Impact**: OCS APIs work without patch, but app-specific APIs require the patch. + +### Verify Installation + +```bash +# Check both apps are installed and enabled +php occ app:list | grep -E "oidc|user_oidc" + +# Expected output: +# - oidc: enabled +# - user_oidc: enabled +``` ## Step 2: Configure OIDC Apps -### Enable Dynamic Client Registration (for `oidc` app) +### Configure `oidc` App (Identity Provider) -1. Navigate to **Settings** → **OIDC** (in Administration settings) -2. Find the **Dynamic Client Registration** section -3. Enable **"Allow dynamic client registration"** -4. (Optional) Configure client expiration time: +#### Option A: Dynamic Client Registration (Development) + +**Best for**: Development, testing, auto-registration + +1. Navigate to **Settings** → **OIDC** (Administration settings) +2. Enable **"Allow dynamic client registration"** +3. (Optional) Configure client expiration: ```bash - # Via Nextcloud CLI (occ) - optional, default is 3600 seconds (1 hour) + # Default: 3600 seconds (1 hour) php occ config:app:set oidc expire_time --value "86400" # 24 hours ``` -### Enable Bearer Token Validation (for `user_oidc` app) +#### Option B: Pre-configured Clients (Production) -Configure the `user_oidc` app to validate bearer tokens from the `oidc` Identity Provider: +**Best for**: Production, long-running deployments + +Skip the dynamic registration setting. You'll manually register clients via CLI in Step 3. + +### Configure `user_oidc` App (Token Validation) + +**Required**: Enable Bearer token validation: ```bash -# Via Nextcloud CLI (occ) - required for Bearer token authentication +# SSH into Nextcloud server php occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean ``` -This tells the `user_oidc` app to validate Bearer tokens against Nextcloud's own OIDC Identity Provider. +This tells `user_oidc` to validate Bearer tokens against Nextcloud's OIDC Identity Provider. -## Step 3: Choose Your Setup Approach +### Verify OIDC Discovery -You have two options for configuring OAuth clients: +Test that OIDC discovery endpoint is accessible: -### Approach A: Automatic Registration (Zero-config) +```bash +curl https://your.nextcloud.instance.com/.well-known/openid-configuration | jq +``` -**Best for:** Development, testing, short-lived deployments +Expected response: +```json +{ + "issuer": "https://your.nextcloud.instance.com", + "authorization_endpoint": "https://your.nextcloud.instance.com/apps/oidc/authorize", + "token_endpoint": "https://your.nextcloud.instance.com/apps/oidc/token", + "userinfo_endpoint": "https://your.nextcloud.instance.com/apps/oidc/userinfo", + "jwks_uri": "https://your.nextcloud.instance.com/apps/oidc/jwks", + "registration_endpoint": "https://your.nextcloud.instance.com/apps/oidc/register", + ... +} +``` -**How it works:** The MCP server automatically registers a new OAuth client with Nextcloud at startup using dynamic client registration. +### PKCE Support -**Pros:** +The MCP server **requires PKCE** (Proof Key for Code Exchange) with S256 code challenge method. + +**Validation**: The MCP server automatically validates PKCE support at startup by checking the discovery response for `code_challenge_methods_supported`. + +**Note**: If PKCE is not advertised in discovery metadata, the server logs a warning but continues (PKCE still works, it's just not advertised). See [Upstream Status](oauth-upstream-status.md) for tracking. + +## Step 3: Choose Deployment Mode + +You have two options for managing OAuth clients: + +### Mode A: Automatic Registration (Dynamic Client Registration) + +**Best for**: Development, testing, short-lived deployments + +**How it works**: +- MCP server automatically registers OAuth client at startup +- Uses Nextcloud's dynamic client registration endpoint +- Saves credentials to `.nextcloud_oauth_client.json` +- Re-registers automatically if credentials expire + +**Pros**: - Zero configuration required -- Quick to set up +- Quick setup - No manual client management -**Cons:** -- Clients expire (default: 1 hour) -- Server must re-register on restart if expired -- Not recommended for long-running production deployments +**Cons**: +- Clients expire (default: 1 hour, configurable) +- Must re-register on restart if expired +- Not ideal for long-running production -[Jump to Approach A setup →](#approach-a-automatic-registration) +**Configuration**: Skip to [Step 4](#step-4-configure-mcp-server) with minimal config. -### Approach B: Pre-configured Client (Production) +--- -**Best for:** Production, long-running deployments +### Mode B: Pre-configured Client (Production) -**How it works:** You manually create an OAuth client via Nextcloud CLI and provide credentials to the MCP server. +**Best for**: Production, long-running deployments, stable environments -**Pros:** +**How it works**: +- You manually register OAuth client via Nextcloud CLI +- Provide client credentials to MCP server - Credentials don't expire -- Stable for production use + +**Pros**: +- Credentials don't expire +- Stable for production - More control over client configuration +- Better for audit trails -**Cons:** +**Cons**: - Requires manual setup -- Needs access to Nextcloud server CLI +- Needs SSH/CLI access to Nextcloud server -[Jump to Approach B setup →](#approach-b-pre-configured-client) - ---- - -## Approach A: Automatic Registration - -### 1. Configure Environment - -Create your `.env` file with only the Nextcloud host: - -```dotenv -# .env file -NEXTCLOUD_HOST=https://your.nextcloud.instance.com - -# Leave these EMPTY for OAuth mode -NEXTCLOUD_USERNAME= -NEXTCLOUD_PASSWORD= -``` - -### 2. Start the MCP Server +**Setup**: Register a client via CLI: ```bash -# Load environment variables -export $(grep -v '^#' .env | xargs) - -# Start server with OAuth enabled -uv run nextcloud-mcp-server --oauth -``` - -### 3. Verify Registration - -The server will automatically register a new OAuth client. Look for these log messages: - -``` -INFO OAuth mode detected (NEXTCLOUD_USERNAME/PASSWORD not set) -INFO Configuring MCP server for OAuth mode -INFO Performing OIDC discovery: https://your.nextcloud.instance.com/.well-known/openid-configuration -INFO OIDC discovery successful -INFO Attempting dynamic client registration... -INFO Dynamic client registration successful -INFO OAuth client ready: ... -INFO Saved OAuth client credentials to .nextcloud_oauth_client.json -INFO OAuth initialization complete -``` - -### 4. Client Credential Storage - -Registered client credentials are saved to `.nextcloud_oauth_client.json` by default. The server will: -- Load existing credentials on startup -- Check if they've expired -- Re-register automatically if expired or missing - -**Note:** Since dynamically registered clients expire (default: 1 hour), the server checks credentials at startup. For long-running deployments, consider using Approach B (pre-configured clients) instead. - ---- - -## Approach B: Pre-configured Client - -### 1. Register Client via Nextcloud CLI - -SSH into your Nextcloud server and run: - -```bash -# Create OAuth client +# SSH into Nextcloud server php occ oidc:create \ --name="Nextcloud MCP Server" \ --type=confidential \ --redirect-uri="http://localhost:8000/oauth/callback" # Example output: -# Client ID: abc123xyz -# Client Secret: secret456def +# Client ID: abc123xyz789 +# Client Secret: secret456def012 + +# Save these credentials for Step 4 ``` -**Note:** Adjust the `--redirect-uri` to match your MCP server URL if different from `http://localhost:8000`. +**Important**: Adjust `--redirect-uri` to match your MCP server URL: +- Local: `http://localhost:8000/oauth/callback` +- Remote: `http://your-server:8000/oauth/callback` +- Custom port: `http://your-server:PORT/oauth/callback` -### 2. Configure Environment - -Add the client credentials to your `.env` file: - -```dotenv -# .env file -NEXTCLOUD_HOST=https://your.nextcloud.instance.com - -# OAuth Client Credentials -NEXTCLOUD_OIDC_CLIENT_ID=abc123xyz -NEXTCLOUD_OIDC_CLIENT_SECRET=secret456def - -# Optional: Custom OAuth configuration -NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000 -NEXTCLOUD_OIDC_CLIENT_STORAGE=.nextcloud_oauth_client.json - -# Leave these EMPTY for OAuth mode -NEXTCLOUD_USERNAME= -NEXTCLOUD_PASSWORD= +The redirect URI **must** be: +``` +{NEXTCLOUD_MCP_SERVER_URL}/oauth/callback ``` -See [Configuration Guide](configuration.md#oauth2oidc-configuration) for all available options. +## Step 4: Configure MCP Server -### 3. Start the MCP Server +Create or update your `.env` file with OAuth configuration. + +### For Mode A (Automatic Registration) ```bash -# Load environment variables -export $(grep -v '^#' .env | xargs) +# Copy sample if needed +cp env.sample .env -# Start server - it will use pre-configured credentials -uv run nextcloud-mcp-server --oauth +# Edit .env +cat > .env << 'EOF' +# Nextcloud Instance +NEXTCLOUD_HOST=https://your.nextcloud.instance.com + +# Leave EMPTY for OAuth mode (do not set USERNAME/PASSWORD) +NEXTCLOUD_USERNAME= +NEXTCLOUD_PASSWORD= + +# Optional: MCP server URL (for OAuth callbacks) +NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000 + +# Optional: Client storage path +NEXTCLOUD_OIDC_CLIENT_STORAGE=.nextcloud_oauth_client.json +EOF ``` -### 4. Verify Configuration +### For Mode B (Pre-configured Client) -Look for these log messages: +```bash +# Copy sample if needed +cp env.sample .env +# Edit .env +cat > .env << 'EOF' +# Nextcloud Instance +NEXTCLOUD_HOST=https://your.nextcloud.instance.com + +# OAuth Client Credentials (from Step 3) +NEXTCLOUD_OIDC_CLIENT_ID=abc123xyz789 +NEXTCLOUD_OIDC_CLIENT_SECRET=secret456def012 + +# MCP server URL (must match redirect URI) +NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000 + +# Leave EMPTY for OAuth mode +NEXTCLOUD_USERNAME= +NEXTCLOUD_PASSWORD= +EOF +``` + +### Environment Variables Reference + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `NEXTCLOUD_HOST` | ✅ Yes | - | Full URL of Nextcloud instance | +| `NEXTCLOUD_OIDC_CLIENT_ID` | ⚠️ Mode B only | - | OAuth client ID | +| `NEXTCLOUD_OIDC_CLIENT_SECRET` | ⚠️ Mode B only | - | OAuth client secret | +| `NEXTCLOUD_MCP_SERVER_URL` | ⚠️ Optional | `http://localhost:8000` | MCP server URL for callbacks | +| `NEXTCLOUD_OIDC_CLIENT_STORAGE` | ⚠️ Optional | `.nextcloud_oauth_client.json` | Client credentials storage path | +| `NEXTCLOUD_USERNAME` | ❌ Must be empty | - | Leave empty for OAuth | +| `NEXTCLOUD_PASSWORD` | ❌ Must be empty | - | Leave empty for OAuth | + +See [Configuration Guide](configuration.md) for all options. + +## Step 5: Start and Verify + +### Load Environment Variables + +```bash +# Load from .env file +export $(grep -v '^#' .env | xargs) + +# Verify key variables are set +echo "NEXTCLOUD_HOST: $NEXTCLOUD_HOST" +echo "NEXTCLOUD_MCP_SERVER_URL: $NEXTCLOUD_MCP_SERVER_URL" +``` + +### Start MCP Server + +```bash +# Start with OAuth mode +uv run nextcloud-mcp-server --oauth + +# Or with custom options +uv run nextcloud-mcp-server --oauth --port 8000 --log-level info +``` + +### Verify Startup + +Look for these success messages: + +**For Mode A (Auto-registration)**: ``` INFO OAuth mode detected (NEXTCLOUD_USERNAME/PASSWORD not set) INFO Configuring MCP server for OAuth mode INFO Performing OIDC discovery: https://your.nextcloud.instance.com/.well-known/openid-configuration +✓ PKCE support validated: ['S256'] INFO OIDC discovery successful -INFO Using pre-configured OAuth client: abc123xyz +INFO Attempting dynamic client registration... +INFO Dynamic client registration successful +INFO OAuth client ready: ... +INFO Saved OAuth client credentials to .nextcloud_oauth_client.json INFO OAuth initialization complete +INFO MCP server ready at http://127.0.0.1:8000 ``` -**Benefits:** Pre-configured clients don't expire automatically and are more stable for production use. +**For Mode B (Pre-configured)**: +``` +INFO OAuth mode detected (NEXTCLOUD_USERNAME/PASSWORD not set) +INFO Configuring MCP server for OAuth mode +INFO Performing OIDC discovery: https://your.nextcloud.instance.com/.well-known/openid-configuration +✓ PKCE support validated: ['S256'] +INFO OIDC discovery successful +INFO Using pre-configured OAuth client: abc123xyz789 +INFO OAuth initialization complete +INFO MCP server ready at http://127.0.0.1:8000 +``` ---- +### Common Startup Issues -## Step 4: Test Authentication +| Issue | Solution | +|-------|----------| +| "OAuth mode requires NEXTCLOUD_HOST" | Set `NEXTCLOUD_HOST` in `.env` | +| "OIDC discovery failed" | Verify Nextcloud URL and network connectivity | +| "Dynamic registration failed" | Enable dynamic registration in OIDC app settings | +| "PKCE validation failed" | See [Upstream Status](oauth-upstream-status.md) | -The MCP server is now configured for OAuth. When clients connect: +See [OAuth Troubleshooting](oauth-troubleshooting.md) for detailed solutions. -1. Client connects to MCP server -2. Server provides OAuth authorization URL -3. User opens URL in browser and authenticates to Nextcloud -4. Nextcloud redirects back with authorization code -5. Client exchanges code for access token -6. Client uses Bearer token to access MCP server -7. All Nextcloud API requests use the user's OAuth token +## Testing Authentication ### Test with MCP Inspector +The MCP Inspector provides a web UI for testing: + ```bash -# Start MCP Inspector +# In a new terminal uv run mcp dev -# In the browser UI: -# 1. Enter your MCP server URL (e.g., http://localhost:8000) -# 2. Complete OAuth flow in browser -# 3. Test tools and resources +# Opens browser at http://localhost:6272 ``` +In the MCP Inspector UI: +1. Enter server URL: `http://localhost:8000/mcp` +2. Click **Connect** +3. Complete OAuth flow in browser popup: + - Login to Nextcloud + - Authorize MCP server access + - Redirected back to MCP Inspector +4. Test tools: + - Try `nc_notes_create_note` + - Try `nc_notes_search_notes` + - Try `nc_calendar_list_events` + +### Test from Command Line + +```bash +# Get an OAuth token (you'll need to implement client flow or extract from browser) +TOKEN="your_access_token_here" + +# Test OCS API (should work) +curl -H "Authorization: Bearer $TOKEN" \ + "$NEXTCLOUD_HOST/ocs/v2.php/cloud/capabilities?format=json" \ + -H "OCS-APIRequest: true" + +# Test Notes API (requires upstream patch) +curl -H "Authorization: Bearer $TOKEN" \ + "$NEXTCLOUD_HOST/apps/notes/api/v1/notes" +``` + +### Verify Token Validation + +Check MCP server logs for token validation: + +```bash +# Start server with debug logging +uv run nextcloud-mcp-server --oauth --log-level debug + +# Look for: +# DEBUG Token validation via userinfo endpoint +# DEBUG Token validated successfully for user: username +``` + +## Production Recommendations + +### Security Best Practices + +1. **Use Pre-configured Clients** (Mode B) + - More stable + - Better audit trails + - No expiration issues + +2. **Secure Credential Storage** + ```bash + # Set restrictive permissions + chmod 600 .nextcloud_oauth_client.json + chmod 600 .env + ``` + +3. **Use HTTPS for MCP Server** + - Especially important for remote access + - Use reverse proxy (nginx, Apache) with SSL + +4. **Restrict Redirect URIs** + - Only register necessary redirect URIs + - Use specific URLs (not wildcards) + +### Deployment Considerations + +1. **MCP Server URL** + - Must be accessible to OAuth clients + - Must match redirect URI registered with Nextcloud + - For Docker: expose port and use correct host + +2. **Network Configuration** + - MCP server must reach Nextcloud (OIDC endpoints) + - OAuth clients must reach MCP server (callbacks) + - OAuth clients must reach Nextcloud (authorization flow) + +3. **Process Management** + - Use systemd, supervisord, or Docker for MCP server + - Ensure automatic restart on failure + - Monitor logs for OAuth errors + +### Example Production Configs + +#### Docker Compose + +```yaml +version: '3' +services: + nextcloud-mcp: + image: ghcr.io/cbcoutinho/nextcloud-mcp-server:latest + ports: + - "127.0.0.1:8000:8000" + environment: + NEXTCLOUD_HOST: https://your.nextcloud.instance.com + NEXTCLOUD_OIDC_CLIENT_ID: ${NEXTCLOUD_OIDC_CLIENT_ID} + NEXTCLOUD_OIDC_CLIENT_SECRET: ${NEXTCLOUD_OIDC_CLIENT_SECRET} + NEXTCLOUD_MCP_SERVER_URL: http://your-server:8000 + volumes: + - ./oauth_client.json:/app/.nextcloud_oauth_client.json + command: ["--oauth", "--transport", "streamable-http"] + restart: unless-stopped +``` + +#### Systemd Service + +```ini +[Unit] +Description=Nextcloud MCP Server (OAuth) +After=network.target + +[Service] +Type=simple +User=mcp +WorkingDirectory=/opt/nextcloud-mcp-server +Environment="NEXTCLOUD_HOST=https://your.nextcloud.instance.com" +Environment="NEXTCLOUD_OIDC_CLIENT_ID=abc123xyz789" +Environment="NEXTCLOUD_OIDC_CLIENT_SECRET=secret456def012" +Environment="NEXTCLOUD_MCP_SERVER_URL=http://your-server:8000" +ExecStart=/opt/nextcloud-mcp-server/.venv/bin/nextcloud-mcp-server --oauth +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=multi-user.target +``` + +### Monitoring and Maintenance + +1. **Log Monitoring** + ```bash + # Watch for OAuth errors + tail -f /var/log/nextcloud-mcp/server.log | grep -i "oauth\|token" + ``` + +2. **Token Expiration** (Mode A only) + - Monitor for "Stored client has expired" messages + - Consider increasing expiration or switching to Mode B + +3. **Upstream Patches** + - Subscribe to [Upstream Status](oauth-upstream-status.md) + - Plan to update when patches are merged + +## Troubleshooting + +For OAuth-specific issues, see [OAuth Troubleshooting](oauth-troubleshooting.md). + +Common issues: +- [OIDC discovery failed](oauth-troubleshooting.md#oidc-discovery-failed) +- [Bearer token auth fails](oauth-troubleshooting.md#bearer-token-authentication-fails) +- [Client expired](oauth-troubleshooting.md#client-expired) +- [PKCE errors](oauth-troubleshooting.md#pkce-not-advertised) + ## Next Steps -- [Running the Server](running.md) - Additional server options +- [OAuth Architecture](oauth-architecture.md) - Understand how OAuth works +- [OAuth Troubleshooting](oauth-troubleshooting.md) - Solve common issues +- [Upstream Status](oauth-upstream-status.md) - Track required patches - [Configuration](configuration.md) - All environment variables -- [Troubleshooting](troubleshooting.md) - Common OAuth issues +- [Running the Server](running.md) - Additional server options ## See Also - [Authentication Overview](authentication.md) - OAuth vs BasicAuth comparison -- [OAuth Bearer Token Issue](oauth2-bearer-token-session-issue.md) - Required patch for non-OCS endpoints +- [Quick Start Guide](quickstart-oauth.md) - 5-minute setup for development +- [MCP Specification](https://spec.modelcontextprotocol.io/) - MCP protocol details +- [RFC 6749](https://www.rfc-editor.org/rfc/rfc6749) - OAuth 2.0 Framework +- [RFC 7636](https://www.rfc-editor.org/rfc/rfc7636) - PKCE Extension diff --git a/docs/oauth-troubleshooting.md b/docs/oauth-troubleshooting.md new file mode 100644 index 0000000..08e7197 --- /dev/null +++ b/docs/oauth-troubleshooting.md @@ -0,0 +1,554 @@ +# OAuth Troubleshooting + +This guide covers OAuth-specific issues and solutions for the Nextcloud MCP server. + +For general troubleshooting, see [Troubleshooting Guide](troubleshooting.md). + +## Quick Diagnosis + +Start here to identify your issue: + +| Symptom | Likely Cause | Quick Fix Link | +|---------|--------------|----------------| +| "OAuth mode requires NEXTCLOUD_HOST" | Missing environment variable | [Missing NEXTCLOUD_HOST](#missing-nextcloud_host) | +| "OAuth mode requires client credentials OR dynamic registration" | OIDC apps not configured | [Missing OIDC Apps](#missing-or-misconfigured-oidc-apps) | +| "PKCE support validation failed" | OIDC app doesn't advertise PKCE | [PKCE Not Advertised](#pkce-not-advertised) | +| "Stored client has expired" | Dynamic client expired | [Client Expired](#client-expired) | +| HTTP 401 for Notes API | Bearer token patch missing | [Bearer Token Auth Fails](#bearer-token-authentication-fails) | +| "OIDC discovery failed" | Network or configuration issue | [Discovery Failed](#oidc-discovery-failed) | +| "Permission denied" on .nextcloud_oauth_client.json | File permissions issue | [File Permission Error](#file-permission-error) | + +## Configuration Issues + +### Missing NEXTCLOUD_HOST + +**Error Message**: +``` +OAuth mode requires NEXTCLOUD_HOST environment variable +``` + +**Cause**: The `NEXTCLOUD_HOST` environment variable is not set or empty. + +**Solution**: + +1. Add to your `.env` file: + ```bash + NEXTCLOUD_HOST=https://your.nextcloud.instance.com + ``` + +2. Reload environment variables: + ```bash + export $(grep -v '^#' .env | xargs) + ``` + +3. Verify it's set: + ```bash + echo $NEXTCLOUD_HOST + # Should output: https://your.nextcloud.instance.com + ``` + +--- + +### Missing or Misconfigured OIDC Apps + +**Error Message**: +``` +OAuth mode requires either client credentials OR dynamic client registration +``` + +**Cause**: The required Nextcloud OIDC apps are either: +- Not installed +- Not enabled +- Missing configuration + +**Solution**: + +**Step 1**: Verify both apps are installed: + +```bash +# Check installed apps +php occ app:list | grep -E "oidc|user_oidc" + +# Should show: +# - oidc: enabled +# - user_oidc: enabled +``` + +If not installed: +1. Open Nextcloud as administrator +2. Navigate to **Apps** → **Security** +3. Install **"OIDC"** (OIDC Identity Provider) +4. Install **"OpenID Connect user backend"** (user_oidc) +5. Enable both apps + +**Step 2**: Enable dynamic client registration: + +1. Go to **Settings** → **OIDC** (Administration) +2. Enable **"Allow dynamic client registration"** + +**Step 3**: Configure Bearer token validation: + +```bash +php occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean +``` + +**Step 4**: Verify discovery endpoint: + +```bash +curl https://your.nextcloud.instance.com/.well-known/openid-configuration | jq '.registration_endpoint' + +# Should output: +# "https://your.nextcloud.instance.com/apps/oidc/register" +``` + +**Alternative**: Use pre-configured client credentials: + +```bash +# Register client via CLI +php occ oidc:create \ + --name="Nextcloud MCP Server" \ + --type=confidential \ + --redirect-uri="http://localhost:8000/oauth/callback" + +# Add to .env +echo "NEXTCLOUD_OIDC_CLIENT_ID=" >> .env +echo "NEXTCLOUD_OIDC_CLIENT_SECRET=" >> .env +``` + +--- + +### Client Expired + +**Error Message**: +``` +Stored client has expired +``` + +**Cause**: Dynamically registered OAuth clients expire (default: 1 hour). + +**Solution**: + +**Option 1: Restart the Server** (Automatic re-registration) + +```bash +uv run nextcloud-mcp-server --oauth +# Server automatically re-registers if credentials expired +``` + +**Option 2: Use Pre-configured Credentials** (Recommended for production) + +```bash +# Register permanent client via Nextcloud CLI +php occ oidc:create \ + --name="Nextcloud MCP Server" \ + --type=confidential \ + --redirect-uri="http://localhost:8000/oauth/callback" + +# Add to .env +NEXTCLOUD_OIDC_CLIENT_ID= +NEXTCLOUD_OIDC_CLIENT_SECRET= +``` + +Pre-configured clients don't expire. + +**Option 3: Increase Expiration Time** + +```bash +# Via Nextcloud CLI (default: 3600 seconds = 1 hour) +php occ config:app:set oidc expire_time --value "86400" # 24 hours +``` + +--- + +### File Permission Error + +**Error Message**: +``` +Permission denied when reading/writing .nextcloud_oauth_client.json +``` + +**Cause**: The server cannot access the OAuth client storage file. + +**Solution**: + +```bash +# Check file permissions +ls -la .nextcloud_oauth_client.json + +# Fix file permissions (owner read/write only) +chmod 600 .nextcloud_oauth_client.json + +# Ensure directory is writable +chmod 755 $(dirname .nextcloud_oauth_client.json) + +# If file doesn't exist, ensure directory is writable +mkdir -p $(dirname .nextcloud_oauth_client.json) +``` + +For custom storage paths: +```bash +# Set custom path in .env +NEXTCLOUD_OIDC_CLIENT_STORAGE=/path/to/custom/oauth_client.json + +# Ensure directory exists and is writable +mkdir -p $(dirname /path/to/custom/oauth_client.json) +chmod 755 $(dirname /path/to/custom/oauth_client.json) +``` + +--- + +## Discovery and Connection Issues + +### OIDC Discovery Failed + +**Error Message**: +``` +OIDC discovery failed +Cannot reach OIDC discovery endpoint +``` + +**Cause**: The server cannot reach the Nextcloud OIDC discovery endpoint. + +**Solution**: + +**Step 1**: Verify Nextcloud URL is correct: + +```bash +echo $NEXTCLOUD_HOST +# Should be full URL: https://your.nextcloud.instance.com +``` + +**Step 2**: Test discovery endpoint manually: + +```bash +curl https://your.nextcloud.instance.com/.well-known/openid-configuration + +# Should return JSON with OIDC configuration +# { +# "issuer": "https://your.nextcloud.instance.com", +# "authorization_endpoint": "https://your.nextcloud.instance.com/apps/oidc/authorize", +# ... +# } +``` + +**Step 3**: Check network connectivity: + +```bash +# Test basic connectivity +ping your.nextcloud.instance.com + +# Test HTTPS +curl -I https://your.nextcloud.instance.com +``` + +**Step 4**: Verify both OIDC apps are enabled: + +```bash +php occ app:list | grep -E "oidc|user_oidc" +``` + +**Step 5**: Check firewall rules (if using Docker): + +```bash +# Check if MCP server can reach Nextcloud +docker exec nextcloud-mcp-server curl https://your.nextcloud.instance.com/.well-known/openid-configuration +``` + +--- + +## Authentication Issues + +### Bearer Token Authentication Fails + +**Error Message**: +``` +HTTP 401 Unauthorized when calling Nextcloud APIs +``` + +**Symptoms**: +- OCS APIs work (`/ocs/v2.php/cloud/capabilities`) +- App APIs fail (`/apps/notes/api/`, `/apps/calendar/`, etc.) + +**Cause**: The `user_oidc` app's CORS middleware interferes with Bearer token authentication for non-OCS endpoints. + +**Solution**: Apply the Bearer token patch to `user_oidc` app. + +See [Upstream Status](oauth-upstream-status.md#1-bearer-token-support-for-non-ocs-endpoints) for details. + +**Quick Patch**: + +```bash +# SSH into Nextcloud server +cd /path/to/nextcloud/apps/user_oidc + +# Edit lib/User/Backend.php +# Add this line before each return statement in getCurrentUserId() method: +$this->session->set('app_api', true); + +# Lines to modify: ~243, ~310, ~315, ~337 +``` + +**Test the fix**: + +```bash +# Get an OAuth token (from MCP client or test) +TOKEN="your_access_token" + +# Test Notes API +curl -H "Authorization: Bearer $TOKEN" \ + https://your.nextcloud.instance.com/apps/notes/api/v1/notes + +# Should return notes JSON (not 401) +``` + +--- + +### PKCE Not Advertised + +**Error Message**: +``` +ERROR: OIDC CONFIGURATION ERROR - Missing PKCE Support Advertisement +⚠️ MCP clients (like Claude Code) WILL REJECT this provider! +``` + +**Cause**: The OIDC discovery endpoint doesn't include `code_challenge_methods_supported` field. + +**Impact**: +- Some MCP clients may refuse to connect +- Standards compliance issue (RFC 8414) +- **Functionality still works** (PKCE is accepted, just not advertised) + +**Solution**: + +**Short-term**: The MCP server logs a warning but continues. OAuth flow still works. + +**Long-term**: Update the `oidc` app to advertise PKCE support. + +See [Upstream Status](oauth-upstream-status.md#2-pkce-support-advertisement-in-discovery) for tracking. + +**Verify**: + +```bash +curl https://your.nextcloud.instance.com/.well-known/openid-configuration | jq '.code_challenge_methods_supported' + +# Should return: +# ["S256", "plain"] + +# If null, PKCE isn't advertised (but still works) +``` + +--- + +## Runtime Issues + +### MCP Client Can't Authenticate + +**Symptoms**: +- Client connects but OAuth flow fails +- Authorization redirects don't work +- Token exchange fails + +**Diagnosis**: + +**Step 1**: Verify OAuth is configured correctly: + +```bash +uv run nextcloud-mcp-server --oauth --log-level debug +``` + +Look for: +``` +INFO OAuth initialization complete +INFO MCP server ready at http://127.0.0.1:8000 +``` + +**Step 2**: Check OIDC discovery: + +```bash +curl https://your.nextcloud.instance.com/.well-known/openid-configuration +``` + +**Step 3**: Verify MCP server URL matches client expectations: + +```bash +echo $NEXTCLOUD_MCP_SERVER_URL +# Should match the URL clients use to connect +# Default: http://localhost:8000 +``` + +If MCP server is on a different host/port, update: +```bash +NEXTCLOUD_MCP_SERVER_URL=http://actual-host:actual-port +``` + +**Step 4**: Check redirect URI configuration: + +For pre-configured clients, ensure redirect URI matches: +```bash +# Client redirect URI should be: +http://your-mcp-server-url/oauth/callback + +# Example for local server: +http://localhost:8000/oauth/callback +``` + +--- + +### Tools Return 401 Errors + +**Symptoms**: +- OAuth flow completes successfully +- Token is valid +- MCP tools return 401 errors + +**Cause**: Bearer token not working with Nextcloud APIs. + +**Solution**: See [Bearer Token Authentication Fails](#bearer-token-authentication-fails) above. + +--- + +## Switching Authentication Modes + +### From BasicAuth to OAuth + +```bash +# 1. Remove or comment out USERNAME/PASSWORD in .env +sed -i 's/^NEXTCLOUD_USERNAME/#NEXTCLOUD_USERNAME/' .env +sed -i 's/^NEXTCLOUD_PASSWORD/#NEXTCLOUD_PASSWORD/' .env + +# 2. Ensure NEXTCLOUD_HOST is set +grep NEXTCLOUD_HOST .env + +# 3. Restart server with OAuth +export $(grep -v '^#' .env | xargs) +uv run nextcloud-mcp-server --oauth +``` + +### From OAuth to BasicAuth + +```bash +# 1. Add USERNAME/PASSWORD to .env +echo "NEXTCLOUD_USERNAME=your-username" >> .env +echo "NEXTCLOUD_PASSWORD=your-password" >> .env + +# 2. Restart server (BasicAuth auto-detected) +export $(grep -v '^#' .env | xargs) +uv run nextcloud-mcp-server --no-oauth +``` + +--- + +## Advanced Debugging + +### Enable Debug Logging + +```bash +uv run nextcloud-mcp-server --oauth --log-level debug +``` + +Look for: +- OIDC discovery details +- Client registration attempts +- Token validation logs +- API request/response details + +### Test Discovery Endpoint + +```bash +# Full discovery response +curl https://your.nextcloud.instance.com/.well-known/openid-configuration | jq + +# Check specific fields +curl https://your.nextcloud.instance.com/.well-known/openid-configuration | jq '{ + issuer, + authorization_endpoint, + token_endpoint, + userinfo_endpoint, + registration_endpoint, + code_challenge_methods_supported +}' +``` + +### Test Token Validation + +```bash +# Get userinfo with token +curl -H "Authorization: Bearer $TOKEN" \ + https://your.nextcloud.instance.com/apps/oidc/userinfo + +# Should return user info: +# { +# "sub": "username", +# "preferred_username": "username", +# "name": "Display Name", +# ... +# } +``` + +### Test Nextcloud API Access + +```bash +# Test OCS API (should work) +curl -H "Authorization: Bearer $TOKEN" \ + "$NEXTCLOUD_HOST/ocs/v2.php/cloud/capabilities?format=json" \ + -H "OCS-APIRequest: true" + +# Test app API (requires patch) +curl -H "Authorization: Bearer $TOKEN" \ + "$NEXTCLOUD_HOST/apps/notes/api/v1/notes" +``` + +--- + +## Getting Help + +If you continue to experience issues: + +### 1. Collect Diagnostic Information + +```bash +# Server version +uv run nextcloud-mcp-server --version + +# Python version +python3 --version + +# Server logs with debug +uv run nextcloud-mcp-server --oauth --log-level debug 2>&1 | tee mcp-server.log + +# OIDC discovery +curl https://your.nextcloud.instance.com/.well-known/openid-configuration > oidc-discovery.json + +# Nextcloud version +# Check in Nextcloud admin panel or: +php occ -V +``` + +### 2. Check Documentation + +- [OAuth Architecture](oauth-architecture.md) - How OAuth works +- [OAuth Setup Guide](oauth-setup.md) - Configuration steps +- [Upstream Status](oauth-upstream-status.md) - Required patches +- [Configuration](configuration.md) - Environment variables + +### 3. Open an Issue + +If problems persist, [open an issue](https://github.com/cbcoutinho/nextcloud-mcp-server/issues) with: + +- **Error messages** (full text) +- **Server logs** (with `--log-level debug`) +- **OIDC discovery response** (from curl command above) +- **Nextcloud version** +- **OIDC app versions** (`oidc` and `user_oidc`) +- **Steps to reproduce** +- **Environment details** (OS, Python version, Docker vs local) + +--- + +## See Also + +- [OAuth Quick Start](quickstart-oauth.md) - Get started quickly +- [OAuth Setup Guide](oauth-setup.md) - Detailed configuration +- [OAuth Architecture](oauth-architecture.md) - Technical details +- [Upstream Status](oauth-upstream-status.md) - Required patches +- [General Troubleshooting](troubleshooting.md) - Non-OAuth issues diff --git a/docs/oauth-upstream-status.md b/docs/oauth-upstream-status.md new file mode 100644 index 0000000..bdfc593 --- /dev/null +++ b/docs/oauth-upstream-status.md @@ -0,0 +1,226 @@ +# OAuth Upstream Status + +This document tracks the status of upstream patches and pull requests required for full OAuth functionality. + +## Overview + +The Nextcloud MCP Server's OAuth implementation relies on two Nextcloud apps: +- **`oidc`** - OIDC Identity Provider (Authorization Server) +- **`user_oidc`** - OpenID Connect user backend (Token validation) + +While the core OAuth flow works, there are **pending upstream improvements** that enhance functionality and standards compliance. + +## Required Patches + +### 1. Bearer Token Support for Non-OCS Endpoints + +**Status**: 🟡 **Patch Required** (Pending Upstream) + +**Affected Component**: `user_oidc` app + +**Issue**: Bearer token authentication fails for app-specific APIs (Notes, Calendar, etc.) with `401 Unauthorized` errors, even though OCS APIs work correctly. + +**Root Cause**: The `CORSMiddleware` in Nextcloud logs out sessions created by Bearer token authentication when CSRF tokens are missing, which breaks API requests. + +**Solution**: Set the `app_api` session flag during Bearer token authentication to bypass CSRF checks. + +**Upstream PR**: [nextcloud/user_oidc#1221](https://github.com/nextcloud/user_oidc/issues/1221) + +**Workaround**: Manually apply the patch to `lib/User/Backend.php` in the `user_oidc` app + +**Impact**: +- ✅ **Works**: OCS APIs (`/ocs/v2.php/cloud/capabilities`) +- ❌ **Requires Patch**: App APIs (`/apps/notes/api/`, `/apps/calendar/`, etc.) + +**Files Modified**: `lib/User/Backend.php` in `user_oidc` app + +**Patch Summary**: +```php +// Add before successful Bearer token authentication returns +$this->session->set('app_api', true); +``` + +This is added at lines ~243, ~310, ~315, and ~337 in `Backend.php`. + +--- + +### 2. PKCE Support Advertisement in Discovery + +**Status**: 🟢 **PR Submitted** (Pending Review) + +**Affected Component**: `oidc` app + +**Issue**: The OIDC discovery endpoint (`/.well-known/openid-configuration`) does not advertise PKCE support in the `code_challenge_methods_supported` field. + +**Why It Matters**: +- MCP specification requires PKCE with S256 code challenge method +- RFC 8414 states that absence of `code_challenge_methods_supported` means PKCE is **not supported** +- Some MCP clients may reject providers without proper PKCE advertisement + +**Current Behavior**: +- PKCE **functionally works** (the OIDC app accepts and validates PKCE) +- PKCE just isn't **advertised** in discovery metadata + +**Recommended Fix**: Update `oidc` app to include: +```json +{ + "code_challenge_methods_supported": ["S256"] +} +``` + +**Workaround**: The MCP server implements PKCE validation and logs a warning if not advertised. Functionality still works. + +**Upstream PR**: [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) - Submitted 2025-10-13 +- **Changes**: Adds `code_challenge_methods_supported: ["S256"]` to discovery document when PKCE is enabled +- **Size**: +5 lines added, 0 deleted +- **Status**: Open, awaiting review + +--- + +## Upstream PRs Status + +| PR/Issue | Component | Status | Priority | Notes | +|----------|-----------|--------|----------|-------| +| [user_oidc#1221](https://github.com/nextcloud/user_oidc/issues/1221) | `user_oidc` | 🟡 Open | High | Required for app-specific APIs | +| [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) | `oidc` | 🟢 PR Open | Medium | PKCE advertisement for standards compliance | + +## What Works Without Patches + +The following functionality works **out of the box** without any patches: + +✅ **OAuth Flow**: +- OIDC discovery +- Dynamic client registration +- Authorization code flow with PKCE +- Token exchange +- Userinfo endpoint + +✅ **MCP Server as Resource Server**: +- Token validation via userinfo +- Per-user client instances +- Token caching + +✅ **Nextcloud OCS APIs**: +- Capabilities endpoint +- All OCS-based APIs + +## What Requires Patches + +The following functionality requires upstream patches: + +🟡 **App-Specific APIs** (Requires user_oidc#1221): +- Notes API (`/apps/notes/api/`) +- Calendar API (CalDAV) +- Contacts API (CardDAV) +- Deck API +- Tables API +- Custom app APIs + +🟡 **Standards Compliance** (PKCE advertisement): +- Full RFC 8414 compliance +- MCP client compatibility guarantee + +## Installation Instructions + +### For Development/Testing + +If the upstream PRs are not yet merged, you can apply patches manually: + +#### 1. Apply Bearer Token Patch + +```bash +# SSH into Nextcloud server +cd /path/to/nextcloud/apps/user_oidc + +# Download and apply patch +# (Patch file to be created once PR is ready) +wget https://github.com/nextcloud/user_oidc/pull/XXXX.patch +git apply XXXX.patch + +# Or manually edit lib/User/Backend.php +# Add this line before each return statement in getCurrentUserId(): +# $this->session->set('app_api', true); +``` + +#### 2. Verify Installation + +```bash +# Test with OAuth token +curl -H "Authorization: Bearer YOUR_TOKEN" \ + https://your.nextcloud.com/apps/notes/api/v1/notes + +# Should return notes JSON (not 401) +``` + +### For Production + +**Recommendation**: Wait for upstream PRs to be merged and included in official Nextcloud releases before deploying OAuth in production. + +**Alternative**: Use a patched version of `user_oidc` app in your deployment: +1. Fork the `user_oidc` app +2. Apply the required patches +3. Install your patched version +4. Document the changes for your team + +## Testing + +The integration test suite validates OAuth functionality: + +```bash +# Start OAuth-enabled MCP server +docker-compose up --build -d mcp-oauth + +# Run comprehensive OAuth tests +uv run pytest tests/integration/test_oauth_playwright.py --browser firefox -v + +# Tests verify: +# - OAuth flow completion +# - Token validation +# - MCP tool calls with Bearer tokens +# - Notes API access (requires patch) +``` + +## Monitoring Upstream Progress + +To track progress on these issues: + +1. **Watch the upstream repositories**: + - [nextcloud/user_oidc](https://github.com/nextcloud/user_oidc) + - [nextcloud/oidc](https://github.com/nextcloud/oidc) + +2. **Subscribe to specific issues**: + - [user_oidc#1221](https://github.com/nextcloud/user_oidc/issues/1221) - Bearer token support + +3. **Check Nextcloud release notes** for mentions of: + - Bearer token authentication improvements + - OIDC/OAuth enhancements + - AppAPI compatibility + +## Contributing + +Want to help get these patches merged? + +1. **Test the patches**: Run the integration tests and report results +2. **Review PRs**: Provide feedback on upstream pull requests +3. **Document issues**: Report any problems or edge cases +4. **Contribute code**: Submit improvements or fixes to upstream + +## Timeline Expectations + +**Best Case**: PRs merged in next Nextcloud minor release (est. 3-6 months) + +**Realistic**: PRs reviewed and merged within 6-12 months + +**Meanwhile**: Use the workarounds documented in this guide + +## See Also + +- [OAuth Architecture](oauth-architecture.md) - How OAuth works in this implementation +- [OAuth Troubleshooting](oauth-troubleshooting.md) - Common issues and solutions +- [OAuth Setup Guide](oauth-setup.md) - Configuration instructions + +--- + +**Last Updated**: 2025-10-14 + +**Next Review**: When PR #584 or issue #1221 has activity diff --git a/docs/oauth2-bearer-token-session-issue.md b/docs/oauth2-bearer-token-session-issue.md deleted file mode 100644 index 797c101..0000000 --- a/docs/oauth2-bearer-token-session-issue.md +++ /dev/null @@ -1,97 +0,0 @@ -# Root Cause Analysis: OAuth2 Bearer Token Session Invalidation - -## Problem -Bearer token authentication fails for app-specific APIs (like Notes) with 401 Unauthorized, even though it works for OCS APIs (capabilities). - -## Root Cause -The CORSMiddleware in Nextcloud server is logging out the session created by Bearer token authentication: - -``` -/home/chris/Software/server/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php:84 -$this->session->logout(); -``` - -### Why Session is Logged Out -1. Notes API has @CORS annotation -2. Bearer auth via user_oidc creates a logged-in session -3. Request has NO CSRF token -4. Request has NO AppAPI auth flag -5. Request has NO PHP_AUTH_USER/PHP_AUTH_PW (basic auth) -6. Therefore CORSMiddleware calls logout() - -### Log Evidence -``` -{"message":"[TokenInvalidatedListener] Could not find the OIDC session related with an invalidated token"} -``` - -Token validated successfully, then immediately invalidated by session logout. - -## Token Type Investigation (Opaque vs JWT) -- **Finding**: Token type (opaque vs JWT) does NOT affect the issue -- **Reason**: Session invalidation happens AFTER successful token validation -- Both opaque and JWT tokens validate correctly via TokenValidationRequestEvent -- The logout happens in CORSMiddleware, not in token validation - -## ✅ SOLUTION (Tested & Working) - -### Option A: Set AppAPI Flag for Bearer Auth ✅ -**Status**: Successfully tested and verified working - -Modified user_oidc `Backend.php` `getCurrentUserId()` method to set the `app_api` session flag before returning the user ID: - -```php -$this->session->set('app_api', true); -``` - -This bypasses CORS middleware's logout logic at line 81-82 by setting the same flag used by Nextcloud's AppAPI framework. - -### Implementation -The flag is added before all successful Bearer token authentication return statements in `/var/www/html/custom_apps/user_oidc/lib/User/Backend.php`: - -- Line ~243: After OIDC provider validation -- Line ~310: After auto-provisioning with bearer provisioning -- Line ~315: After existing user authentication -- Line ~337: After LDAP user sync - -### Test Results -All OAuth Bearer token operations now work correctly: - -✅ **Capabilities endpoint** (OCS API) - 200 OK -✅ **Notes API listing** - 200 OK -✅ **Notes API create** - 200 OK (created note 112) -✅ **Notes API delete** - 200 OK (deleted note 112) - -No session invalidation occurs, and all API operations complete successfully. - -### Patch File -See `patches/user_oidc-bearer-auth-app-api-flag.patch` for the exact changes. - -## Alternative Solutions (Not Tested) - -### Option B: Avoid Creating Full Session for Bearer Auth -Bearer token auth should not create a full session that triggers CORS middleware checks. This would require deeper architectural changes. - -### Option C: Add CSRF Exemption -Modify CORSMiddleware to exempt Bearer token authenticated requests from CSRF check. This would require changes to Nextcloud core. - -### Option D: Use Basic Auth Headers -Set PHP_AUTH_USER/PHP_AUTH_PW server variables during Bearer auth so CORSMiddleware can re-authenticate. This could have security implications. - -## Recommendations - -### Short-term (Current Implementation) -The `app_api` flag solution works correctly and follows Nextcloud's existing pattern for API authentication. This is the recommended approach for immediate use. - -### Long-term (Upstream Contribution) -Consider submitting this fix to the upstream user_oidc project as it enables proper Bearer token authentication for all Nextcloud APIs, not just OCS endpoints. - -## Files Involved -- `/home/chris/Software/user_oidc/lib/User/Backend.php` (getCurrentUserId) - **MODIFIED** -- `/home/chris/Software/server/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php` (logout logic) -- `/home/chris/Software/user_oidc/lib/Listener/TokenInvalidatedListener.php` (cleanup handler) - -## Testing -Run the OAuth interactive test to verify: -```bash -uv run pytest tests/integration/test_oauth_interactive.py -v -``` diff --git a/docs/quickstart-oauth.md b/docs/quickstart-oauth.md new file mode 100644 index 0000000..47f8fae --- /dev/null +++ b/docs/quickstart-oauth.md @@ -0,0 +1,163 @@ +# OAuth Quick Start Guide + +Get up and running with OAuth authentication in 5 minutes. + +## Prerequisites Checklist + +Before you begin, ensure you have: + +- [ ] Nextcloud instance with **administrator access** +- [ ] Nextcloud version 28 or later +- [ ] Python 3.11+ installed +- [ ] `uv` package manager installed ([installation instructions](https://docs.astral.sh/uv/getting-started/installation/)) + +## Step 1: Install Nextcloud Apps + +Install **both** required apps in your Nextcloud instance: + +1. Open Nextcloud as administrator +2. Navigate to **Apps** → **Security** +3. Install: + - **OIDC** (OIDC Identity Provider app) + - **OpenID Connect user backend** (user_oidc app) +4. Enable both apps + +> [!IMPORTANT] +> The `user_oidc` app requires an upstream patch for Bearer token support. See [Upstream Status](oauth-upstream-status.md) for details. The functionality works, but the PR is pending. + +## Step 2: Configure Nextcloud OIDC + +Enable dynamic client registration and Bearer token validation: + +### Via Web UI + +1. Go to **Settings** → **OIDC** (Administration settings) +2. Enable **"Allow dynamic client registration"** + +### Via CLI (Required) + +SSH into your Nextcloud server and run: + +```bash +# Enable Bearer token validation +php occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean +``` + +## Step 3: Install MCP Server + +Clone and install the MCP server: + +```bash +# Clone repository +git clone https://github.com/cbcoutinho/nextcloud-mcp-server.git +cd nextcloud-mcp-server + +# Install dependencies +uv sync +``` + +## Step 4: Configure Environment + +Create a `.env` file with minimal configuration: + +```bash +# Copy sample +cp env.sample .env + +# Edit .env and set: +NEXTCLOUD_HOST=https://your.nextcloud.instance.com + +# IMPORTANT: Leave these EMPTY for OAuth mode +NEXTCLOUD_USERNAME= +NEXTCLOUD_PASSWORD= +``` + +## Step 5: Start the Server + +Load environment variables and start the server: + +```bash +# Load environment +export $(grep -v '^#' .env | xargs) + +# Start server with OAuth +uv run nextcloud-mcp-server --oauth +``` + +Look for this success message: + +``` +✓ PKCE support validated: ['S256'] +INFO OAuth initialization complete +INFO MCP server ready at http://127.0.0.1:8000 +``` + +## Step 6: Test with MCP Inspector + +Open a new terminal and test the connection: + +```bash +# Start MCP Inspector +uv run mcp dev +``` + +This opens your browser. In the MCP Inspector UI: + +1. Enter server URL: `http://127.0.0.1:8000/mcp` +2. Click **Connect** +3. Complete the OAuth flow in the browser popup +4. After authorization, you'll see available tools and resources + +Test a tool by trying: +- **Tool**: `nc_notes_create_note` +- **Title**: "Test Note" +- **Content**: "Hello from MCP!" +- **Category**: "Notes" + +## Troubleshooting Quick Fixes + +### PKCE Error + +If you see: +``` +ERROR: OIDC CONFIGURATION ERROR - Missing PKCE Support Advertisement +``` + +**Fix**: The Nextcloud OIDC app needs to be updated to advertise PKCE support. See [Upstream Status](oauth-upstream-status.md) for the required PR. + +### 401 Unauthorized for Notes API + +If OAuth works but Notes API returns 401: + +**Fix**: The `user_oidc` app needs the Bearer token patch. See [Upstream Status](oauth-upstream-status.md) for details. + +### Can't Reach OIDC Discovery Endpoint + +**Fix**: Verify your Nextcloud URL is correct and accessible: + +```bash +curl https://your.nextcloud.instance.com/.well-known/openid-configuration +``` + +## Next Steps + +- [OAuth Setup Guide](oauth-setup.md) - Detailed configuration options +- [OAuth Architecture](oauth-architecture.md) - How it works under the hood +- [OAuth Troubleshooting](oauth-troubleshooting.md) - Common issues and solutions +- [Configuration](configuration.md) - All environment variables + +## Development vs Production + +This quick start uses **automatic client registration** which is perfect for: +- Development +- Testing +- Short-lived deployments + +For **production deployments**, you should: +1. Pre-register OAuth clients manually +2. Use dedicated client credentials +3. See [OAuth Setup Guide](oauth-setup.md) for production configuration + +--- + +**Need help?** Check [OAuth Troubleshooting](oauth-troubleshooting.md) or [open an issue](https://github.com/cbcoutinho/nextcloud-mcp-server/issues). diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index d75a5a8..e5037bb 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2,7 +2,9 @@ This guide covers common issues and solutions for the Nextcloud MCP server. -## OAuth Issues +> **OAuth-specific issues?** See the dedicated [OAuth Troubleshooting Guide](oauth-troubleshooting.md) for OAuth authentication problems, OIDC discovery issues, token validation failures, and more. + +## OAuth Issues (Quick Reference) ### Issue: "OAuth mode requires NEXTCLOUD_HOST environment variable" @@ -218,6 +220,18 @@ uv run nextcloud-mcp-server --no-oauth --- +### For More OAuth Help + +See the dedicated **[OAuth Troubleshooting Guide](oauth-troubleshooting.md)** for: +- Bearer token authentication failures +- PKCE validation errors +- Token validation issues +- Client registration problems +- Advanced OAuth debugging +- And much more... + +--- + ## Configuration Issues ### Issue: Environment variables not loaded @@ -534,7 +548,9 @@ If problems persist, open an issue on the [GitHub repository](https://github.com ## See Also +- **[OAuth Troubleshooting](oauth-troubleshooting.md)** - Dedicated OAuth troubleshooting guide - [OAuth Setup Guide](oauth-setup.md) - OAuth configuration +- [OAuth Architecture](oauth-architecture.md) - How OAuth works +- [Upstream Status](oauth-upstream-status.md) - Required patches and upstream PRs - [Configuration](configuration.md) - Environment variables - [Running the Server](running.md) - Server options -- [OAuth Bearer Token Issue](oauth2-bearer-token-session-issue.md) - Required patch diff --git a/docs/user_oidc-pr-description.md b/docs/user_oidc-pr-description.md deleted file mode 100644 index d8829b2..0000000 --- a/docs/user_oidc-pr-description.md +++ /dev/null @@ -1,96 +0,0 @@ -# Fix Bearer Token Authentication Causing Session Logout - -## Problem - -Bearer token authentication with OIDC fails for app-specific APIs (like Notes, Calendar, etc.) with `401 Unauthorized` errors, even though the same Bearer token works fine for OCS APIs (like `/ocs/v2.php/cloud/capabilities`). - -### Root Cause - -When using Bearer token authentication: - -1. ✅ Bearer token validation successfully authenticates the user -2. ✅ A session is created for the authenticated user -3. ❌ **Nextcloud's `CORSMiddleware` detects the logged-in session but no CSRF token** -4. ❌ **`CORSMiddleware` calls `$this->session->logout()` to prevent CSRF attacks** -5. ❌ The logout invalidates the session, breaking the API request with 401 Unauthorized - -This occurs because app-specific APIs (Notes, Calendar, etc.) use the `@CORS` annotation, which triggers the `CORSMiddleware` security checks. The OCS APIs don't have this annotation, which is why they work correctly. - -### Error Logs - -``` -[TokenInvalidatedListener] Could not find the OIDC session related with an invalidated token -Session token invalidated before logout -Logging out -``` - -## Solution - -Set the `app_api` session flag during Bearer token authentication. This instructs `CORSMiddleware` to skip the CSRF check and logout logic, as the authentication is API-based rather than session-based. - -This is the same mechanism used by Nextcloud's [AppAPI framework](https://github.com/cloud-py-api/app_api) for external application authentication. - -### Changes - -The fix adds `$this->session->set('app_api', true);` before all successful Bearer token authentication return statements in `lib/User/Backend.php`: - -- **Line 243**: After OIDC Identity Provider validation -- **Line 310**: After auto-provisioning with bearer provisioning -- **Line 315**: After existing user authentication -- **Line 337**: After LDAP user sync - -## Testing - -Tested with the [nextcloud-mcp-server](https://github.com/cccs-nik/nextcloud-mcp-server) project's integration tests: - -### Before Fix -``` -✅ Capabilities endpoint (OCS API) - 200 OK -❌ Notes API listing - 401 Unauthorized -❌ Notes API create - 401 Unauthorized -``` - -### After Fix -``` -✅ Capabilities endpoint (OCS API) - 200 OK -✅ Notes API listing - 200 OK -✅ Notes API create - 200 OK -✅ Notes API delete - 200 OK -``` - -All OAuth Bearer token operations now work correctly across all Nextcloud APIs without session invalidation. - -## Configuration - -This fix works with the standard Bearer token validation configuration: - -```php -// config.php -'user_oidc' => [ - 'oidc_provider_bearer_validation' => true, -], -``` - -And in the OIDC Identity Provider app: -```bash -php occ config:app:set oidc dynamic_client_registration --value='true' -``` - -## Impact - -This fix enables proper Bearer token authentication for: -- All Nextcloud app APIs (Notes, Calendar, Contacts, etc.) -- External applications using OAuth 2.0 / OpenID Connect -- MCP servers and other API integrations -- Any application using the `Authorization: Bearer` header - -## Related Files - -- `lib/User/Backend.php` - Modified to set `app_api` flag -- `/server/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php` - Contains the CSRF/logout logic that this bypasses - -## References - -- [Nextcloud CORS Middleware](https://github.com/nextcloud/server/blob/master/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php) -- [Nextcloud AppAPI](https://github.com/cloud-py-api/app_api) -- [OpenID Connect Bearer Token Usage](https://openid.net/specs/openid-connect-core-1_0.html#TokenUsage) From 52044ef053721d23b3e39ebb0a4a2f7a22407ead Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 13 Oct 2025 23:30:55 +0000 Subject: [PATCH 099/102] =?UTF-8?q?bump:=20version=200.12.6=20=E2=86=92=20?= =?UTF-8?q?0.13.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a00de2e..ffb5cee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## v0.13.0 (2025-10-13) + +### Feat + +- **server**: Experimental support for OAuth2/OIDC authentication + ## v0.12.6 (2025-10-11) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 3da27c9..5c1df3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nextcloud-mcp-server" -version = "0.12.6" +version = "0.13.0" description = "" authors = [ {name = "Chris Coutinho",email = "chris@coutinho.io"} diff --git a/uv.lock b/uv.lock index 9d564a6..4a8f6b7 100644 --- a/uv.lock +++ b/uv.lock @@ -630,7 +630,7 @@ wheels = [ [[package]] name = "nextcloud-mcp-server" -version = "0.12.6" +version = "0.13.0" source = { editable = "." } dependencies = [ { name = "click" }, From ab4012781176df28b68e5bc28f95227a5bdd214f Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Tue, 14 Oct 2025 01:32:30 +0200 Subject: [PATCH 100/102] ci: [skip ci] Remove --- OAUTH_IMPLEMENTATION_PLAN.md | 742 ----------------------------------- OAUTH_TESTING.md | 121 ------ 2 files changed, 863 deletions(-) delete mode 100644 OAUTH_IMPLEMENTATION_PLAN.md delete mode 100644 OAUTH_TESTING.md diff --git a/OAUTH_IMPLEMENTATION_PLAN.md b/OAUTH_IMPLEMENTATION_PLAN.md deleted file mode 100644 index e6c82b4..0000000 --- a/OAUTH_IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,742 +0,0 @@ -# OAuth2/OIDC Implementation Plan for Nextcloud MCP Server - -## Executive Summary -Upgrade the Nextcloud MCP server to support OAuth2/OIDC authentication using Nextcloud's OIDC app as the Authorization Server, eliminating the need for baked-in credentials in server deployment. - -**Status**: ✅ Research Complete - Implementation Ready - -## Research Findings Summary - -### ✅ Verified Nextcloud OIDC Capabilities -- **Token Format**: Opaque tokens by default, **RFC 9068 JWT access tokens available** (must be enabled per-client) -- **Discovery**: Full OpenID Connect discovery available at `/.well-known/openid-configuration` -- **JWKS**: Available at `/apps/oidc/jwks` for JWT signature validation -- **Dynamic Registration**: Supported via `/apps/oidc/register` (must be enabled by admin) -- **Introspection**: ❌ NOT available - must use **userinfo endpoint** for token validation -- **Userinfo**: Available at `/apps/oidc/userinfo` - validates token and returns user claims -- **Scopes**: `openid`, `profile`, `email`, `roles`, `groups` -- **User Claims**: `sub`, `preferred_username` (both contain Nextcloud username) - -### 🔑 Key Implementation Decisions -1. **Primary Token Validation**: Use **userinfo endpoint** (not introspection) -2. **JWT Support**: Optional - enables local validation if client configured for RFC 9068 -3. **User Context**: Extract username from `sub` or `preferred_username` claim via userinfo -4. **Dynamic Registration**: Primary deployment method (zero-config) -5. **Token Lifetime**: Access tokens default to 3600s, clients default to 3600s (both configurable) - -## Architecture Overview - -### Server Role: Resource Server (RS) - RFC 9728 -The MCP server acts as a **Resource Server** that: -- Validates OAuth tokens issued by Nextcloud OIDC app (Authorization Server) -- Protects MCP tools/resources with OAuth authentication -- Uses validated tokens to make Nextcloud API calls on behalf of authenticated users - -### Authentication Flow -``` -1. Client connects to MCP Server (RS) -2. MCP Server provides RFC 9728 metadata pointing to Nextcloud OIDC (AS) -3. Client performs OAuth flow with Nextcloud OIDC -4. Client presents access token to MCP Server -5. MCP Server validates token via userinfo endpoint (or JWT if configured) -6. MCP Server extracts username from claims -7. MCP Server uses token to call Nextcloud APIs with user context -``` - -## Key Design Decisions - -### 1. Dynamic Client Registration (PRIMARY APPROACH) -**Use Nextcloud OIDC's Dynamic Client Registration for zero-config deployment** - -**Benefits:** -- No manual client setup required -- MCP server auto-registers on first startup -- Automatic credential generation -- Self-healing if client expires -- Better developer/deployment experience - -**Implementation:** -```python -# Startup sequence: -1. Check for existing client credentials (file/env) -2. If none found, POST to /apps/oidc/register -3. Store client_id and client_secret persistently -4. Use credentials for OAuth flow -5. Auto re-register if client expires (3600s default) -``` - -**Nextcloud OIDC Requirements:** -- Admin must enable "Dynamic Client Registration" in OIDC app settings -- Rate limiting via BruteForce protection -- Max 100 dynamic clients per instance -- Clients expire after 1 hour (configurable via occ) - -### 2. Token Validation Strategy: Userinfo Endpoint (PRIMARY) - -**✅ VERIFIED IMPLEMENTATION: Userinfo Endpoint Validation** - -Nextcloud OIDC **does NOT provide** a token introspection endpoint. Token validation must use: - -**Primary: Userinfo Endpoint Validation** -- Call `/apps/oidc/userinfo` with Bearer token -- Nextcloud validates token internally (checks expiration, client, etc.) -- Returns user claims if valid: `sub`, `preferred_username`, `email`, `roles`, `groups` -- HTTP 400/401 if token invalid -- Cache results with TTL matching token expiration (3600s default) - -**Implementation Pattern**: -```python -async def verify_token(self, token: str) -> AccessToken | None: - # Call userinfo endpoint - response = await client.get( - f"{nextcloud_host}/apps/oidc/userinfo", - headers={"Authorization": f"Bearer {token}"} - ) - - if response.status_code == 200: - claims = response.json() - return AccessToken( - token=token, - client_id="", # Not available from userinfo - scopes=["openid", "profile"], # From original request - expires_at=calculate_expiry() # 3600s from now - ) - return None # Invalid token -``` - -**Optional: JWT Validation (Performance Optimization)** -- Available if client configured with "JWT Access Tokens (RFC 9068)" enabled -- Fetch JWKS from `/apps/oidc/jwks` -- Validate JWT signatures locally (no network call) -- Cache JWKS with refresh mechanism -- Falls back to userinfo if JWT validation fails - -**Trade-offs**: -- Userinfo: Simpler, always works, network call per validation -- JWT: Faster, no network call, requires per-client configuration - -### 3. Dual-Mode Authentication (Backward Compatibility) -Support both authentication modes: - -**Mode 1: OAuth2/OIDC (NEW)** -- Environment: `NEXTCLOUD_HOST` + optional `NEXTCLOUD_OIDC_CLIENT_ID/SECRET` -- Auto-registers if no client credentials provided -- Per-request client creation with bearer token - -**Mode 2: Basic Auth (LEGACY)** -- Environment: `NEXTCLOUD_HOST` + `NEXTCLOUD_USERNAME` + `NEXTCLOUD_PASSWORD` -- Current implementation preserved -- Single client in lifespan context - -### 4. HTTP Client Architecture - -**✅ REVISED: Context-aware Client Retrieval** - -Instead of per-request client creation, use a helper that extracts user context: - -```python -# Helper function to get client from MCP context -async def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient: - """Extract authenticated user context and create NextcloudClient.""" - # MCP SDK provides AccessToken from TokenVerifier - access_token: AccessToken = ctx.request_context.session.access_token - - # Extract username from cached userinfo claims - # (stored during token verification) - username = access_token.scopes[0] # Or from custom metadata - - # Create client with bearer token - return NextcloudClient.from_token( - base_url=base_url, - token=access_token.token, - username=username - ) - -# In tool implementations: -@mcp.tool() -async def nc_notes_create(title: str, content: str): - ctx = mcp.get_context() - - if oauth_mode: - client = await get_client_from_context(ctx, nextcloud_host) - else: - # Legacy: use lifespan client - client = ctx.request_context.lifespan_context.client - - return await client.notes.create_note(title, content) -``` - -**Key Pattern**: -- Token verification caches userinfo claims -- Helper retrieves username from cached data (no additional API call) -- Client uses bearer token for Nextcloud API calls - -### 5. User Context Extraction - -**✅ VERIFIED: Userinfo Endpoint Response** - -From Nextcloud OIDC userinfo endpoint response: -- **Username**: `sub` AND `preferred_username` (both contain Nextcloud username) -- **Scopes**: Determined by scopes requested during OAuth flow -- **Groups/Roles**: Available via `roles` or `groups` scope -- **Profile**: `name`, `email`, `picture`, etc. (if `profile` scope requested) - -**Implementation**: -```python -# During token verification: -userinfo = await fetch_userinfo(token) -# { -# "sub": "username", -# "preferred_username": "username", -# "email": "user@example.com", -# "roles": ["group1", "group2"], # if 'roles' scope -# "groups": ["group1", "group2"] # if 'groups' scope -# } - -username = userinfo["sub"] # or userinfo["preferred_username"] -``` - -**Storage Strategy**: -- Cache userinfo in AccessToken metadata -- Use MCP SDK's built-in token caching -- TTL matches access token expiration (3600s default) - -## Implementation Components - -### New Modules - -#### 1. `nextcloud_mcp_server/auth/__init__.py` -Exports: `NextcloudTokenVerifier`, `BearerAuth`, `register_client` - -#### 2. `nextcloud_mcp_server/auth/token_verifier.py` -```python -class NextcloudTokenVerifier(TokenVerifier): - """ - Validates access tokens using Nextcloud OIDC userinfo endpoint. - - Primary method: Userinfo endpoint validation (always works) - Optional: JWT validation if client configured for RFC 9068 - """ - - def __init__( - self, - nextcloud_host: str, - userinfo_uri: str, - jwks_uri: str | None = None, - enable_jwt_validation: bool = False - ): - self.nextcloud_host = nextcloud_host - self.userinfo_uri = userinfo_uri - self.jwks_uri = jwks_uri - self.enable_jwt_validation = enable_jwt_validation - - # Cache for validated tokens: token -> (userinfo, expiry) - self._token_cache: dict[str, tuple[dict, float]] = {} - - # JWKS cache (if JWT validation enabled) - self._jwks: dict | None = None - self._jwks_expires: float = 0 - - self._client = httpx.AsyncClient() - - async def verify_token(self, token: str) -> AccessToken | None: - """ - Verify token using userinfo endpoint (primary) or JWT validation (optional). - - Returns AccessToken with userinfo cached in metadata. - """ - # Check cache first - if token in self._token_cache: - userinfo, expiry = self._token_cache[token] - if time.time() < expiry: - return self._create_access_token(token, userinfo) - - # Try JWT validation first if enabled - if self.enable_jwt_validation and self.jwks_uri: - access_token = await self._verify_jwt(token) - if access_token: - return access_token - - # Fall back to (or use primary) userinfo validation - return await self._verify_via_userinfo(token) - - async def _verify_via_userinfo(self, token: str) -> AccessToken | None: - """Validate token by calling userinfo endpoint.""" - try: - response = await self._client.get( - self.userinfo_uri, - headers={"Authorization": f"Bearer {token}"}, - timeout=5.0 - ) - - if response.status_code == 200: - userinfo = response.json() - - # Cache for 3600s (default token lifetime) - # TODO: Get actual expiry from token if JWT - expiry = time.time() + 3600 - self._token_cache[token] = (userinfo, expiry) - - return self._create_access_token(token, userinfo) - - except Exception as e: - logger.warning(f"Userinfo validation failed: {e}") - - return None - - async def _verify_jwt(self, token: str) -> AccessToken | None: - """Validate JWT token locally using JWKS (optional optimization).""" - try: - # Fetch JWKS if not cached - if not self._jwks or time.time() > self._jwks_expires: - await self._refresh_jwks() - - # Decode and validate JWT - claims = jwt.decode( - token, - self._jwks, - algorithms=["RS256", "HS256"], - issuer=self.nextcloud_host, - options={"verify_aud": False} # Nextcloud may not include aud - ) - - # Extract userinfo from JWT claims - userinfo = { - "sub": claims.get("sub"), - "preferred_username": claims.get("preferred_username"), - "email": claims.get("email"), - "roles": claims.get("roles", []), - "groups": claims.get("groups", []) - } - - # Cache - expiry = claims.get("exp", time.time() + 3600) - self._token_cache[token] = (userinfo, expiry) - - return self._create_access_token(token, userinfo) - - except Exception as e: - logger.debug(f"JWT validation failed, falling back to userinfo: {e}") - return None - - def _create_access_token(self, token: str, userinfo: dict) -> AccessToken: - """Create AccessToken with userinfo in metadata.""" - username = userinfo.get("sub") or userinfo.get("preferred_username") - - return AccessToken( - token=token, - client_id="", # Not available from userinfo - scopes=["openid", "profile", "email"], # TODO: Track actual scopes - expires_at=int(time.time() + 3600), # TODO: Get from JWT exp claim - # Store username in scopes[0] as workaround for MCP SDK limitation - # Or use custom AccessToken subclass with username field - ) - - async def _refresh_jwks(self): - """Fetch JWKS from Nextcloud OIDC.""" - response = await self._client.get(self.jwks_uri) - response.raise_for_status() - self._jwks = response.json() - self._jwks_expires = time.time() + 3600 # Cache for 1 hour - - async def close(self): - """Cleanup resources.""" - await self._client.aclose() -``` - -#### 3. `nextcloud_mcp_server/auth/client_registration.py` -```python -async def register_client( - nextcloud_url: str, - client_name: str = "Nextcloud MCP Server", - redirect_uris: list[str] = None -) -> dict: - """Register MCP server as OAuth client with Nextcloud OIDC""" - # POST to /apps/oidc/register - # Return client_id, client_secret, expires_at - -async def load_or_register_client(storage_path: str) -> dict: - """Load existing client or register new one""" - # Check storage file - # Validate expiration - # Re-register if expired - # Persist credentials -``` - -#### 4. `nextcloud_mcp_server/auth/bearer_auth.py` -```python -class BearerAuth(httpx.Auth): - """Bearer token authentication for httpx""" - - def __init__(self, token: str): - self.token = token - - def auth_flow(self, request): - request.headers["Authorization"] = f"Bearer {self.token}" - yield request -``` - -### Modified Files - -#### 1. `nextcloud_mcp_server/app.py` -```python -# Add OAuth configuration -from nextcloud_mcp_server.auth import NextcloudTokenVerifier, register_client - -# In get_app(): -if oauth_enabled: - # Load or register client - client_info = await load_or_register_client(storage_path) - - # Create token verifier - token_verifier = NextcloudTokenVerifier( - jwks_uri=f"{nextcloud_host}/apps/oidc/jwks", - issuer=f"{nextcloud_host}" - ) - - # Configure FastMCP with OAuth - mcp = FastMCP( - "Nextcloud MCP", - token_verifier=token_verifier, - auth=AuthSettings( - issuer_url=nextcloud_host, - resource_server_url=mcp_server_url, - required_scopes=["openid", "profile"] - ), - lifespan=app_lifespan_oauth # Don't create client in lifespan - ) -else: - # Legacy BasicAuth mode - mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_basic) -``` - -#### 2. `nextcloud_mcp_server/client/__init__.py` -```python -class NextcloudClient: - def __init__(self, base_url: str, username: str, auth: Auth | None = None): - # Accept either BasicAuth or BearerAuth - self._client = AsyncClient(base_url=base_url, auth=auth, ...) - - @classmethod - def from_env(cls): - """Legacy: Create from username/password env vars""" - return cls(base_url, username, auth=BasicAuth(username, password)) - - @classmethod - def from_token(cls, base_url: str, token: str, username: str): - """OAuth: Create from bearer token""" - return cls(base_url, username, auth=BearerAuth(token)) -``` - -#### 3. `nextcloud_mcp_server/server/notes.py` (and other tool modules) -```python -from nextcloud_mcp_server.auth import get_client_from_context - -@mcp.tool() -async def nc_notes_create(title: str, content: str): - ctx: Context = mcp.get_context() - - # OAuth mode: Get client from request context - if oauth_enabled: - client = get_client_from_context(ctx) - else: - # Legacy mode: Use lifespan client - client = ctx.request_context.lifespan_context.client - - return await client.notes.create_note(...) -``` - -#### 4. `nextcloud_mcp_server/config.py` -```python -class NextcloudConfig: - # Common - host: str - - # OAuth mode - oauth_enabled: bool = False - oidc_client_id: str | None = None - oidc_client_secret: str | None = None - client_storage_path: str = ".nextcloud_oauth_client.json" - mcp_server_url: str = "http://localhost:8000/mcp" - required_scopes: list[str] = ["openid", "profile", "email"] - - # Legacy mode - username: str | None = None - password: str | None = None - - @classmethod - def from_env(cls): - oauth_enabled = not ( - os.getenv("NEXTCLOUD_USERNAME") and - os.getenv("NEXTCLOUD_PASSWORD") - ) - return cls(oauth_enabled=oauth_enabled, ...) -``` - -### Configuration Files - -#### Updated `env.sample` -```bash -# Nextcloud Instance -NEXTCLOUD_HOST=https://nextcloud.example.com - -# ===== AUTHENTICATION MODE ===== -# Choose ONE of the following: - -# Option 1: OAuth2/OIDC (RECOMMENDED) -# - Requires Nextcloud OIDC app installed -# - Enable "Dynamic Client Registration" in OIDC app settings -# - Leave NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD empty -# - Optional: Pre-register client and provide credentials -NEXTCLOUD_OIDC_CLIENT_ID= -NEXTCLOUD_OIDC_CLIENT_SECRET= -NEXTCLOUD_OIDC_CLIENT_STORAGE=.nextcloud_oauth_client.json -NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000/mcp - -# Option 2: Basic Authentication (LEGACY - Will be deprecated) -# - Requires username and password -# - Less secure - credentials stored in environment -# - Use only for backward compatibility -NEXTCLOUD_USERNAME= -NEXTCLOUD_PASSWORD= -``` - -## Dependencies - -### New Python Dependencies -```toml -# pyproject.toml additions: -dependencies = [ - # ... existing ... - "PyJWT[crypto]>=2.8.0", # JWT validation - "cryptography>=41.0.0", # JWKS key handling (if not present) -] -``` - -## Nextcloud OIDC Setup - -### Administrator Setup (One-time) -1. Install Nextcloud OIDC app from App Store -2. Navigate to Settings → OIDC -3. Enable "Dynamic Client Registration" -4. (Optional) Configure token expiration times via CLI: - ```bash - php occ config:app:set oidc expire_time --value "3600" - php occ config:app:set oidc refresh_expire_time --value "86400" - ``` - -### MCP Server Deployment (Zero-config) -1. Set `NEXTCLOUD_HOST` environment variable -2. Set `NEXTCLOUD_MCP_SERVER_URL` (if not localhost:8000) -3. Start MCP server → Auto-registers on first run -4. Client credentials stored in `.nextcloud_oauth_client.json` - -### Alternative: Pre-registered Client -```bash -# Create client via CLI -php occ oidc:create \ - --name="Nextcloud MCP Server" \ - --type=confidential \ - --redirect-uri="http://localhost:8000/oauth/callback" - -# Set credentials in environment -NEXTCLOUD_OIDC_CLIENT_ID= -NEXTCLOUD_OIDC_CLIENT_SECRET= -``` - -## Testing Strategy - -### Unit Tests -- Token validation with mocked JWKS -- JWT claim extraction -- Client registration flow -- Bearer auth implementation - -### Integration Tests -- Dynamic client registration against test Nextcloud -- OAuth flow end-to-end -- Token-based API calls -- Client expiration and re-registration -- Dual-mode authentication (OAuth + BasicAuth) - -### Test Fixtures -```python -# tests/conftest.py additions: -@pytest.fixture -def mock_oidc_server(): - """Mock Nextcloud OIDC endpoints""" - # Mock /apps/oidc/openid-configuration - # Mock /apps/oidc/jwks - # Mock /apps/oidc/register - # Mock /apps/oidc/token - -@pytest.fixture -async def oauth_nc_client(mock_oidc_server): - """NextcloudClient with OAuth token""" - token = generate_test_jwt() - return NextcloudClient.from_token(base_url, token, "testuser") -``` - -## Migration Path - -### Phase 1: Implementation (Week 1-2) -- [ ] Implement token verifier with JWT validation -- [ ] Implement dynamic client registration -- [ ] Add BearerAuth for httpx -- [ ] Modify NextcloudClient for dual-mode auth -- [ ] Update app.py with OAuth configuration -- [ ] Add configuration management - -### Phase 2: Testing (Week 2-3) -- [ ] Unit tests for all auth components -- [ ] Integration tests with test Nextcloud instance -- [ ] End-to-end OAuth flow testing -- [ ] Backward compatibility testing - -### Phase 3: Documentation (Week 3) -- [ ] Update README.md with OAuth setup -- [ ] Update CLAUDE.md with architecture changes -- [ ] Add OAuth troubleshooting guide -- [ ] Document OIDC app configuration -- [ ] Add migration guide for existing deployments - -### Phase 4: Deployment (Week 4) -- [ ] Release with both modes supported -- [ ] Monitor for issues -- [ ] Deprecation notice for BasicAuth -- [ ] Plan BasicAuth removal timeline (6+ months) - -## Security Considerations - -### Token Security -- Store client secrets securely (file permissions, secret managers) -- Validate JWT signatures against trusted JWKS -- Verify token claims (issuer, audience, expiration) -- Implement token refresh logic -- Rate limit token validation failures - -### Client Registration Security -- Nextcloud OIDC provides BruteForce protection -- Dynamic clients limited to 100 per instance -- Clients expire after 1 hour (configurable) -- Admin must explicitly enable dynamic registration - -### API Security -- Bearer tokens used for Nextcloud API calls -- Token scopes control access levels -- User context preserved in all API operations -- No credential storage in MCP server - -## Performance Considerations - -### JWT Validation Performance -- JWKS caching with TTL (e.g., 1 hour) -- Key rotation handling via JWKS refresh -- Local validation (no network call per request) -- Async validation to avoid blocking - -### Client Creation -- OAuth mode: Per-request client creation (lightweight) -- BasicAuth mode: Single client in lifespan (current) -- Connection pooling maintained in both modes - -## Future Enhancements - -### Scope-based Authorization -- Define custom Nextcloud scopes for MCP operations -- Map MCP tools to required scopes -- Fine-grained permission control - -### Multi-tenant Support -- Support multiple Nextcloud instances -- Per-user client registration -- Tenant isolation - -### Token Introspection Fallback -- Implement RFC 7662 introspection -- Use if JWT validation fails -- Support for opaque tokens - -### Admin Controls -- MCP server admin UI for OAuth config -- Client credential rotation -- Usage monitoring and logging - -## Decisions Made (Post-Research) - -1. **✅ Token Validation Method**: Userinfo endpoint (primary), JWT optional - - Nextcloud OIDC does NOT provide introspection endpoint - - Userinfo endpoint validates token AND returns user claims - - JWT validation available as performance optimization if client configured - -2. **✅ Client expiration handling**: Auto re-register with logging - - Clients expire after 3600s by default - - Check expiry on startup and periodically - - Auto-register with backoff on failure - -3. **✅ Scope requirements**: `["openid", "profile", "email"]` - - Sufficient for basic user identification - - Optional: Add `"roles"` or `"groups"` for group-based authorization - -4. **✅ Token caching**: In-memory with 3600s TTL - - Cache userinfo response (includes all needed claims) - - Use token string as cache key - - TTL matches default access token lifetime - -5. **✅ Client storage**: JSON file with 0600 permissions - - Default: `.nextcloud_oauth_client.json` - - Configurable via env var - - Contains: client_id, client_secret, issued_at - -6. **✅ Username extraction**: From `sub` or `preferred_username` claim - - Both contain Nextcloud username (verified) - - Retrieved during token validation - - Cached with token - -7. **✅ BasicAuth deprecation**: 12 months after OAuth stable release - - Phase 1: OAuth + BasicAuth (6 months) - - Phase 2: OAuth only, deprecation warnings (6 months) - - Phase 3: Remove BasicAuth - -## Key Changes from Original Plan - -### 1. Token Validation -**Original**: JWT validation with JWKS (primary), introspection (fallback) -**Updated**: Userinfo endpoint (primary), JWT validation (optional optimization) -- Reason: Nextcloud OIDC has no introspection endpoint - -### 2. User Context Extraction -**Original**: Extract username from JWT claims -**Updated**: Fetch from userinfo endpoint during validation -- Reason: Opaque tokens by default, userinfo always works - -### 3. Token Caching Strategy -**Original**: MCP SDK handles all caching -**Updated**: Custom cache in TokenVerifier for userinfo responses -- Reason: Need to cache username separately from AccessToken - -### 4. JWT Support -**Original**: Required for all deployments -**Updated**: Optional performance optimization -- Reason: Requires per-client configuration in Nextcloud OIDC -- Default: Opaque tokens validated via userinfo - -## References - -- [MCP Python SDK OAuth Documentation](https://github.com/modelcontextprotocol/python-sdk) -- [MCP RFC 9728 Protected Resource Metadata](https://www.rfc-editor.org/rfc/rfc9728.html) -- [Nextcloud OIDC App Repository](https://github.com/H2CK/oidc) -- [OpenID Connect Dynamic Client Registration](https://openid.net/specs/openid-connect-registration-1_0.html) -- [RFC 9068 JWT Access Tokens](https://www.rfc-editor.org/rfc/rfc9068.html) -- [MCP Simple Auth Example](~/Software/python-sdk/examples/servers/simple-auth/) - -## Success Criteria - -✅ MCP server can authenticate via Nextcloud OIDC with zero manual client setup -✅ Dynamic client registration works automatically on first run -✅ JWT tokens validated locally without per-request network calls -✅ Backward compatibility maintained with BasicAuth mode -✅ All existing tests pass in both auth modes -✅ Documentation complete for OAuth setup and migration -✅ Security review passed (token handling, credential storage) -✅ Performance benchmarks meet targets (< 10ms token validation overhead) diff --git a/OAUTH_TESTING.md b/OAUTH_TESTING.md deleted file mode 100644 index d601866..0000000 --- a/OAUTH_TESTING.md +++ /dev/null @@ -1,121 +0,0 @@ -# OAuth Testing Setup - -This document describes the automated OAuth testing infrastructure for the Nextcloud MCP server. - -## Overview - -We've created a comprehensive testing setup that includes: - -1. **OIDC App Configuration** - Nextcloud OIDC app automatically installed and configured with dynamic client registration -2. **Dual MCP Services** - Two MCP server instances running in Docker: - - `mcp` (port 8000) - BasicAuth mode (username/password) - - `mcp-oauth` (port 8001) - OAuth mode (dynamic client registration) -3. **Test Fixtures** - Pytest fixtures for OAuth client testing -4. **Integration Tests** - OAuth-specific integration tests - -## Docker Compose Setup - -The `docker-compose.yml` includes: - -```yaml -services: - app: # Nextcloud with OIDC app enabled - mcp: # BasicAuth MCP server (port 8000) - mcp-oauth: # OAuth MCP server (port 8001) -``` - -## OIDC Configuration - -The OIDC app is configured automatically via `app-hooks/post-installation/install-oidc-app.sh`: - -- **Dynamic Client Registration**: Enabled -- **Config Key**: `dynamic_client_registration` (not `allow_dynamic_client_registration`) -- **Registration Endpoint**: `http://localhost:8080/apps/oidc/register` - -### Important: Config Key Fix - -The correct OIDC config key is `dynamic_client_registration`. The initial implementation used `allow_dynamic_client_registration` which was incorrect and caused the registration endpoint to not appear in the OIDC discovery document. - -## Test Fixtures - -Located in `tests/conftest.py`: - -### `oauth_token` -Session-scoped fixture that obtains an OAuth access token. - -**Current Limitation**: Nextcloud OIDC only supports `authorization_code` and `refresh_token` grant types, not the `password` grant type. This means we cannot automatically obtain tokens for testing without implementing a full browser-based OAuth flow. - -### `nc_oauth_client` -Session-scoped NextcloudClient configured with OAuth bearer token authentication. - -**Status**: Implemented but currently skipped due to token acquisition limitation. - -### `nc_mcp_oauth_client` -Session-scoped MCP client that connects to the OAuth-enabled MCP server on port 8001. - -**Status**: Implemented but marked as skip - requires full OAuth authorization flow implementation in MCP SDK. - -## Current Test Status - -### ✅ Working -- OIDC app installation and configuration -- Dynamic client registration -- OAuth infrastructure (BearerAuth, TokenVerifier, client registration) -- Docker compose dual-mode setup - -### ⚠️ Limitations -- **No automated token acquisition**: Nextcloud OIDC doesn't support the Resource Owner Password Credentials grant, which means we cannot programmatically get tokens for testing without browser interaction -- **Manual testing only**: OAuth functionality must be tested manually using a browser-based OAuth flow -- **MCP OAuth server untested**: The OAuth MCP server requires the full OAuth authorization flow to be implemented in the MCP Python SDK - -## Manual Testing OAuth - -To manually test OAuth functionality: - -1. Start the docker-compose environment: - ```bash - docker-compose up -d - ``` - -2. The OAuth MCP server runs on port 8001 and will: - - Automatically register a client via dynamic registration - - Store client credentials in `/app/.oauth/` volume - - Display OAuth configuration on startup - -3. To test OAuth with a real client: - - Use the authorization endpoint: `http://localhost:8080/apps/oidc/authorize` - - Implement the authorization code flow - - Exchange code for token at: `http://localhost:8080/apps/oidc/token` - -## Future Work - -To enable automated OAuth testing, one of these approaches is needed: - -1. **Mock OIDC Server**: Create a test OIDC server that supports password grant -2. **Browser Automation**: Use Selenium/Playwright to automate the OAuth flow -3. **Test-Only Password Grant**: Patch Nextcloud OIDC to support password grant in test mode -4. **Pre-generated Tokens**: Manually generate long-lived tokens and use them in tests - -## Running Tests - -```bash -# Run all tests (OAuth tests will be skipped) -uv run pytest tests/integration/test_oauth.py -v - -# Run only the invalid token test (this one works) -uv run pytest tests/integration/test_oauth.py::TestOAuthTokenValidation::test_invalid_token_fails -v -``` - -## Files Modified - -- `tests/conftest.py` - Added OAuth fixtures and token acquisition logic -- `tests/integration/test_oauth.py` - OAuth-specific integration tests -- `docker-compose.yml` - Added `mcp-oauth` service -- `app-hooks/post-installation/install-oidc-app.sh` - OIDC installation and configuration -- `nextcloud_mcp_server/client/__init__.py` - Added `from_token()` classmethod - -## Notes - -- The `from_token()` method was added to NextcloudClient to support OAuth authentication -- All OAuth infrastructure is in place and functional -- The main limitation is automated token acquisition for testing, not the OAuth implementation itself From 72ace9da9e9d3103fa31ab7976c77cf5afed7fcf Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Tue, 14 Oct 2025 02:08:45 +0200 Subject: [PATCH 101/102] ci: [skip ci] Move tests to subdirs --- .../calendar}/test_calendar_operations.py | 0 .../calendar}/test_field_preservation.py | 0 .../contacts}/test_contacts_operations.py | 0 tests/{integration => client/deck}/test_deck_api.py | 0 .../{integration => client/notes}/test_attachments.py | 0 .../notes}/test_embedded_images.py | 0 tests/{integration => client/notes}/test_notes_api.py | 0 .../{integration => client/tables}/test_tables_api.py | 0 tests/{integration => client}/test_oauth.py | 0 .../{integration => client}/test_oauth_interactive.py | 0 .../{integration => client}/test_oauth_playwright.py | 0 .../webdav}/test_webdav_cleanup.py | 0 .../webdav}/test_webdav_operations.py | 0 tests/{integration => server}/test_contacts_mcp.py | 0 tests/{integration => server}/test_deck_mcp.py | 0 .../{integration => server}/test_error_propagation.py | 11 +++-------- tests/{integration => server}/test_mcp.py | 0 17 files changed, 3 insertions(+), 8 deletions(-) rename tests/{integration => client/calendar}/test_calendar_operations.py (100%) rename tests/{integration => client/calendar}/test_field_preservation.py (100%) rename tests/{integration => client/contacts}/test_contacts_operations.py (100%) rename tests/{integration => client/deck}/test_deck_api.py (100%) rename tests/{integration => client/notes}/test_attachments.py (100%) rename tests/{integration => client/notes}/test_embedded_images.py (100%) rename tests/{integration => client/notes}/test_notes_api.py (100%) rename tests/{integration => client/tables}/test_tables_api.py (100%) rename tests/{integration => client}/test_oauth.py (100%) rename tests/{integration => client}/test_oauth_interactive.py (100%) rename tests/{integration => client}/test_oauth_playwright.py (100%) rename tests/{integration => client/webdav}/test_webdav_cleanup.py (100%) rename tests/{integration => client/webdav}/test_webdav_operations.py (100%) rename tests/{integration => server}/test_contacts_mcp.py (100%) rename tests/{integration => server}/test_deck_mcp.py (100%) rename tests/{integration => server}/test_error_propagation.py (96%) rename tests/{integration => server}/test_mcp.py (100%) diff --git a/tests/integration/test_calendar_operations.py b/tests/client/calendar/test_calendar_operations.py similarity index 100% rename from tests/integration/test_calendar_operations.py rename to tests/client/calendar/test_calendar_operations.py diff --git a/tests/integration/test_field_preservation.py b/tests/client/calendar/test_field_preservation.py similarity index 100% rename from tests/integration/test_field_preservation.py rename to tests/client/calendar/test_field_preservation.py diff --git a/tests/integration/test_contacts_operations.py b/tests/client/contacts/test_contacts_operations.py similarity index 100% rename from tests/integration/test_contacts_operations.py rename to tests/client/contacts/test_contacts_operations.py diff --git a/tests/integration/test_deck_api.py b/tests/client/deck/test_deck_api.py similarity index 100% rename from tests/integration/test_deck_api.py rename to tests/client/deck/test_deck_api.py diff --git a/tests/integration/test_attachments.py b/tests/client/notes/test_attachments.py similarity index 100% rename from tests/integration/test_attachments.py rename to tests/client/notes/test_attachments.py diff --git a/tests/integration/test_embedded_images.py b/tests/client/notes/test_embedded_images.py similarity index 100% rename from tests/integration/test_embedded_images.py rename to tests/client/notes/test_embedded_images.py diff --git a/tests/integration/test_notes_api.py b/tests/client/notes/test_notes_api.py similarity index 100% rename from tests/integration/test_notes_api.py rename to tests/client/notes/test_notes_api.py diff --git a/tests/integration/test_tables_api.py b/tests/client/tables/test_tables_api.py similarity index 100% rename from tests/integration/test_tables_api.py rename to tests/client/tables/test_tables_api.py diff --git a/tests/integration/test_oauth.py b/tests/client/test_oauth.py similarity index 100% rename from tests/integration/test_oauth.py rename to tests/client/test_oauth.py diff --git a/tests/integration/test_oauth_interactive.py b/tests/client/test_oauth_interactive.py similarity index 100% rename from tests/integration/test_oauth_interactive.py rename to tests/client/test_oauth_interactive.py diff --git a/tests/integration/test_oauth_playwright.py b/tests/client/test_oauth_playwright.py similarity index 100% rename from tests/integration/test_oauth_playwright.py rename to tests/client/test_oauth_playwright.py diff --git a/tests/integration/test_webdav_cleanup.py b/tests/client/webdav/test_webdav_cleanup.py similarity index 100% rename from tests/integration/test_webdav_cleanup.py rename to tests/client/webdav/test_webdav_cleanup.py diff --git a/tests/integration/test_webdav_operations.py b/tests/client/webdav/test_webdav_operations.py similarity index 100% rename from tests/integration/test_webdav_operations.py rename to tests/client/webdav/test_webdav_operations.py diff --git a/tests/integration/test_contacts_mcp.py b/tests/server/test_contacts_mcp.py similarity index 100% rename from tests/integration/test_contacts_mcp.py rename to tests/server/test_contacts_mcp.py diff --git a/tests/integration/test_deck_mcp.py b/tests/server/test_deck_mcp.py similarity index 100% rename from tests/integration/test_deck_mcp.py rename to tests/server/test_deck_mcp.py diff --git a/tests/integration/test_error_propagation.py b/tests/server/test_error_propagation.py similarity index 96% rename from tests/integration/test_error_propagation.py rename to tests/server/test_error_propagation.py index 4812538..cc9b48d 100644 --- a/tests/integration/test_error_propagation.py +++ b/tests/server/test_error_propagation.py @@ -7,8 +7,10 @@ from mcp import ClientSession logger = logging.getLogger(__name__) +# Mark all tests in this module as integration tests +pytestmark = pytest.mark.integration + -@pytest.mark.integration async def test_missing_note_tool_error(nc_mcp_client: ClientSession): """Test that accessing a non-existent note via tool returns proper error.""" # Try to get a non-existent note via tool - should return error response @@ -20,7 +22,6 @@ async def test_missing_note_tool_error(nc_mcp_client: ClientSession): assert "Note 999999 not found" in response.content[0].text -@pytest.mark.integration async def test_delete_missing_note_tool_error(nc_mcp_client: ClientSession): """Test that deleting a non-existent note returns proper error.""" # Try to delete a non-existent note - should return error response @@ -34,7 +35,6 @@ async def test_delete_missing_note_tool_error(nc_mcp_client: ClientSession): assert "Note 999999 not found" in response.content[0].text -@pytest.mark.integration async def test_search_with_empty_query(nc_mcp_client: ClientSession): """Test search behavior with empty query.""" # Search with empty query @@ -47,7 +47,6 @@ async def test_search_with_empty_query(nc_mcp_client: ClientSession): assert response.isError is False -@pytest.mark.integration async def test_tool_missing_required_parameters(nc_mcp_client: ClientSession): """Test calling a tool with missing required parameters.""" # Try to create note with missing parameters @@ -66,7 +65,6 @@ async def test_tool_missing_required_parameters(nc_mcp_client: ClientSession): ) -@pytest.mark.integration async def test_update_note_with_invalid_etag(nc_mcp_client: ClientSession, nc_client): """Test updating a note with invalid ETag.""" # First create a note @@ -98,7 +96,6 @@ async def test_update_note_with_invalid_etag(nc_mcp_client: ClientSession, nc_cl await nc_client.notes.delete_note(note_id) -@pytest.mark.integration async def test_calendar_missing_calendar_error(nc_mcp_client: ClientSession): """Test calendar operations with non-existent calendar.""" # Try to create event in non-existent calendar @@ -127,7 +124,6 @@ async def test_calendar_missing_calendar_error(nc_mcp_client: ClientSession): assert response.isError is True -@pytest.mark.integration async def test_webdav_read_missing_file_error(nc_mcp_client: ClientSession): """Test WebDAV operations with non-existent file.""" # Try to read a non-existent file @@ -151,7 +147,6 @@ async def test_webdav_read_missing_file_error(nc_mcp_client: ClientSession): assert response.isError is True -@pytest.mark.integration async def test_tables_missing_table_error(nc_mcp_client: ClientSession): """Test Tables operations with non-existent table.""" # Try to get schema of non-existent table diff --git a/tests/integration/test_mcp.py b/tests/server/test_mcp.py similarity index 100% rename from tests/integration/test_mcp.py rename to tests/server/test_mcp.py From 865268446608b54dbc5c73422763c0d25e8b8efa Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Tue, 14 Oct 2025 12:03:03 +0200 Subject: [PATCH 102/102] ci: [skip ci] Move oauth mcp tests to server subdir --- tests/client/test_oauth.py | 34 --------------- tests/client/test_oauth_playwright.py | 22 ---------- tests/server/test_mcp_oauth.py | 59 +++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 56 deletions(-) create mode 100644 tests/server/test_mcp_oauth.py diff --git a/tests/client/test_oauth.py b/tests/client/test_oauth.py index 88257e7..debf0f4 100644 --- a/tests/client/test_oauth.py +++ b/tests/client/test_oauth.py @@ -101,37 +101,3 @@ async def test_invalid_token_fails(): await invalid_client.close() logger.info("Invalid OAuth token correctly rejected") - - -# OAuth MCP Integration Tests - - -async def test_mcp_oauth_server_connection(nc_mcp_oauth_client): - """Test connection to OAuth-enabled MCP server.""" - result = await nc_mcp_oauth_client.list_tools() - assert result is not None - assert len(result.tools) > 0 - - logger.info(f"OAuth MCP server has {len(result.tools)} tools available") - - -async def test_mcp_oauth_tool_execution(nc_mcp_oauth_client): - """Test executing a tool on the OAuth-enabled MCP server.""" - import json - - # Example: Execute the 'nc_notes_search_notes' tool - result = await nc_mcp_oauth_client.call_tool( - "nc_notes_search_notes", arguments={"query": ""} - ) - - assert result.isError is False, f"Tool execution failed: {result.content}" - assert result.content is not None - response_data = json.loads(result.content[0].text) - - # The search response should have a 'results' field containing the list - assert "results" in response_data - assert isinstance(response_data["results"], list) - - logger.info( - f"Successfully executed 'nc_notes_search_notes' tool on OAuth MCP server and got {len(response_data['results'])} notes." - ) diff --git a/tests/client/test_oauth_playwright.py b/tests/client/test_oauth_playwright.py index 9b5ccb7..989f325 100644 --- a/tests/client/test_oauth_playwright.py +++ b/tests/client/test_oauth_playwright.py @@ -30,25 +30,3 @@ async def test_oauth_client_with_playwright_flow(nc_oauth_client_playwright): notes = await nc_oauth_client_playwright.notes.get_all_notes() assert isinstance(notes, list) logger.info(f"OAuth client (Playwright) successfully listed {len(notes)} notes") - - -async def test_mcp_oauth_client_with_playwright(nc_mcp_oauth_client_playwright): - """Test that MCP OAuth client via Playwright can execute tools.""" - import json - - # Test: Execute the 'nc_notes_search_notes' tool - result = await nc_mcp_oauth_client_playwright.call_tool( - "nc_notes_search_notes", arguments={"query": ""} - ) - - assert result.isError is False, f"Tool execution failed: {result.content}" - assert result.content is not None - response_data = json.loads(result.content[0].text) - - # The search response should have a 'results' field containing the list - assert "results" in response_data - assert isinstance(response_data["results"], list) - - logger.info( - f"Successfully executed 'nc_notes_search_notes' tool on Playwright OAuth MCP server and got {len(response_data['results'])} notes." - ) diff --git a/tests/server/test_mcp_oauth.py b/tests/server/test_mcp_oauth.py new file mode 100644 index 0000000..839e098 --- /dev/null +++ b/tests/server/test_mcp_oauth.py @@ -0,0 +1,59 @@ +import json +import logging +import pytest + +logger = logging.getLogger(__name__) + +pytestmark = [pytest.mark.integration, pytest.mark.oauth] + + +async def test_mcp_oauth_server_connection(nc_mcp_oauth_client): + """Test connection to OAuth-enabled MCP server.""" + result = await nc_mcp_oauth_client.list_tools() + assert result is not None + assert len(result.tools) > 0 + + logger.info(f"OAuth MCP server has {len(result.tools)} tools available") + + +async def test_mcp_oauth_tool_execution(nc_mcp_oauth_client): + """Test executing a tool on the OAuth-enabled MCP server.""" + import json + + # Example: Execute the 'nc_notes_search_notes' tool + result = await nc_mcp_oauth_client.call_tool( + "nc_notes_search_notes", arguments={"query": ""} + ) + + assert result.isError is False, f"Tool execution failed: {result.content}" + assert result.content is not None + response_data = json.loads(result.content[0].text) + + # The search response should have a 'results' field containing the list + assert "results" in response_data + assert isinstance(response_data["results"], list) + + logger.info( + f"Successfully executed 'nc_notes_search_notes' tool on OAuth MCP server and got {len(response_data['results'])} notes." + ) + + +async def test_mcp_oauth_client_with_playwright(nc_mcp_oauth_client_playwright): + """Test that MCP OAuth client via Playwright can execute tools.""" + + # Test: Execute the 'nc_notes_search_notes' tool + result = await nc_mcp_oauth_client_playwright.call_tool( + "nc_notes_search_notes", arguments={"query": ""} + ) + + assert result.isError is False, f"Tool execution failed: {result.content}" + assert result.content is not None + response_data = json.loads(result.content[0].text) + + # The search response should have a 'results' field containing the list + assert "results" in response_data + assert isinstance(response_data["results"], list) + + logger.info( + f"Successfully executed 'nc_notes_search_notes' tool on Playwright OAuth MCP server and got {len(response_data['results'])} notes." + )