From aa0b6dc5dd3c39dd067bbe130801697e2ea3b6ec Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 20 Oct 2025 19:10:23 +0200 Subject: [PATCH 1/7] docs: Update docs --- README.md | 52 ++++++++++++++++----------- docs/oauth-upstream-status.md | 68 +++++++++++++++++++++-------------- pyproject.toml | 4 +-- 3 files changed, 76 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index db53b38..9da7d65 100644 --- a/README.md +++ b/README.md @@ -7,22 +7,33 @@ 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. > [!NOTE] -> **Nextcloud has two ways to enable AI access:** Nextcloud provides [Context Agent](https://github.com/nextcloud/context_agent), an AI agent backend that powers the [Assistant](https://github.com/nextcloud/assistant) app and allows AI to interact with Nextcloud apps like Calendar, Talk, and Contacts. Context Agent runs as an ExApp inside Nextcloud and also exposes an MCP server endpoint for external LLMs. This project (Nextcloud MCP Server) is a **dedicated standalone MCP server** designed specifically for external MCP clients like Claude Code and IDEs, with deep CRUD operations and OAuth support. See our [detailed comparison](docs/comparison-context-agent.md) to understand which approach fits your use case. +> **Nextcloud has two ways to enable AI access:** Nextcloud provides [Context Agent](https://github.com/nextcloud/context_agent), an AI agent backend that powers the [Assistant](https://github.com/nextcloud/assistant) app and allows AI to interact with Nextcloud apps like Calendar, Talk, and Contacts. Context Agent runs as an ExApp inside Nextcloud and also exposes an MCP server endpoint for external LLMs. This project (Nextcloud MCP Server) is a **dedicated standalone MCP server** designed specifically for external MCP clients like Claude Code and IDEs, with deep CRUD operations and OAuth support. -## Features +### High-level Comparison: Nextcloud MCP Server vs. Nextcloud AI Stack -### Supported Nextcloud Apps +| Aspect | **Nextcloud MCP Server**
(This Project) | **Nextcloud AI Stack**
(Assistant + Context Agent) | +|--------|---------------------------------------------|--------------------------------------------------------| +| **Purpose** | External MCP client access to Nextcloud | AI assistance within Nextcloud UI | +| **Deployment** | Standalone (Docker, VM, K8s) | Inside Nextcloud (ExApp via AppAPI) | +| **Primary Users** | Claude Code, IDEs, external developers | Nextcloud end users via Assistant app | +| **Authentication** | OAuth2/OIDC or Basic Auth | Session-based (integrated) | +| **Notes Support** | ✅ Full CRUD + search (7 tools) | ❌ Not implemented | +| **Calendar** | ✅ Full CalDAV + tasks (20+ tools) | ✅ Events, free/busy, tasks (4 tools) | +| **Contacts** | ✅ Full CardDAV (8 tools) | ✅ Find person, current user (2 tools) | +| **Files (WebDAV)** | ✅ Full filesystem access (12 tools) | ✅ Read, folder tree, sharing (3 tools) | +| **Deck** | ✅ Full project management (15 tools) | ✅ Basic board/card ops (2 tools) | +| **Tables** | ✅ Row operations (5 tools) | ❌ Not implemented | +| **Cookbook** | ✅ Full recipe management (13 tools) | ❌ Not implemented | +| **Talk** | ❌ Not implemented | ✅ Messages, conversations (4 tools) | +| **Mail** | ❌ Not implemented | ✅ Send email (2 tools) | +| **AI Features** | ❌ Not implemented | ✅ Image gen, transcription, doc gen (4 tools) | +| **Web/Maps** | ❌ Not implemented | ✅ Search, weather, transit (5 tools) | +| **MCP Resources** | ✅ Structured data URIs | ❌ Not supported | +| **External MCP** | ❌ Pure server | ✅ Consumes external MCP servers | +| **Safety Model** | Client-controlled | Built-in safe/dangerous distinction | +| **Best For** | • Deep CRUD operations
• External integrations
• OAuth security
• IDE/editor integration | • AI-driven actions in Nextcloud UI
• Multi-service orchestration
• User task automation
• MCP aggregation hub | -| 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. | -| **Cookbook** | ✅ Full | Manage recipes with schema.org metadata. Import from URLs, search, categorize. | -| **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) | +See our [detailed comparison](docs/comparison-context-agent.md) for architecture diagrams, workflow examples, and guidance on when to use each approach. Want to see another Nextcloud app supported? [Open an issue](https://github.com/cbcoutinho/nextcloud-mcp-server/issues) or contribute a pull request! @@ -30,14 +41,15 @@ Want to see another Nextcloud app supported? [Open an issue](https://github.com/ | Mode | Security | Best For | |------|----------|----------| -| **OAuth2/OIDC** ⚠️ **Experimental** | 🔒 High | Testing, evaluation (requires patches) | +| **OAuth2/OIDC** ⚠️ **Experimental** | 🔒 High | Testing, evaluation (requires patch for app-specific APIs) | | **Basic Auth** ✅ | Lower | Development, testing, production | > [!IMPORTANT] -> **OAuth is experimental** and requires manual patches to upstream Nextcloud apps. Specifically: +> **OAuth is experimental** and requires a manual patch to the `user_oidc` app for full functionality: > - **Required patch**: `user_oidc` app needs modifications for Bearer token support ([issue #1221](https://github.com/nextcloud/user_oidc/issues/1221)) > - **Impact**: Without the patch, most app-specific APIs (Notes, Calendar, Contacts, Deck, etc.) will fail with 401 errors -> - **Production use**: Wait for upstream patches to be merged into official releases +> - **What works without patches**: OAuth flow, PKCE support (with `oidc` v1.10.0+), OCS APIs +> - **Production use**: Wait for upstream patch to be merged into official releases > > See [OAuth Upstream Status](docs/oauth-upstream-status.md) for detailed information on required patches and workarounds. @@ -92,10 +104,10 @@ See [Configuration Guide](docs/configuration.md) for all options. 3. Start the server **OAuth Setup (experimental):** -1. Install Nextcloud OIDC apps (`oidc` + `user_oidc`) -2. **Apply required patches** to `user_oidc` app (see [OAuth Upstream Status](docs/oauth-upstream-status.md)) -3. Enable dynamic client registration -4. Configure Bearer token validation +1. Install Nextcloud OIDC apps (`oidc` v1.10.0+ + `user_oidc`) +2. **Apply required patch** to `user_oidc` app for Bearer token support (see [OAuth Upstream Status](docs/oauth-upstream-status.md)) +3. Enable dynamic client registration or create an OIDC client with id & secret +4. Configure Bearer token validation in `user_oidc` 5. Start the server See [OAuth Quick Start](docs/quickstart-oauth.md) for 5-minute setup or [OAuth Setup Guide](docs/oauth-setup.md) for detailed instructions. diff --git a/docs/oauth-upstream-status.md b/docs/oauth-upstream-status.md index 2d9b729..998b76c 100644 --- a/docs/oauth-upstream-status.md +++ b/docs/oauth-upstream-status.md @@ -44,36 +44,52 @@ This is added at lines ~243, ~310, ~315, and ~337 in `Backend.php`. --- -### 2. PKCE Support Advertisement in Discovery +### 2. PKCE Support (RFC 7636) -**Status**: 🟢 **PR Submitted** (Pending Review) +**Status**: ✅ **Complete** (Merged Upstream) **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. +**Issue**: The OIDC app lacked PKCE (Proof Key for Code Exchange) implementation per RFC 7636. -**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 +**Resolution**: Full PKCE support has been implemented and merged upstream into the `oidc` app: -**Current Behavior**: -- PKCE **functionally works** (the OIDC app accepts and validates PKCE) -- PKCE just isn't **advertised** in discovery metadata +**Authorization Endpoint** (`/authorize`): +- Accepts `code_challenge` and `code_challenge_method` parameters +- Validates code_challenge format (43-128 characters, unreserved chars only) +- Supports both `S256` (SHA-256) and `plain` challenge methods +- Stores challenge and method in database for later verification -**Recommended Fix**: Update `oidc` app to include: +**Token Endpoint** (`/token`): +- Accepts `code_verifier` parameter +- Verifies code_verifier against stored code_challenge using proper algorithm +- Uses constant-time comparison to prevent timing attacks +- Enforces code_verifier requirement when PKCE was used in authorization + +**Discovery Document**: ```json { - "code_challenge_methods_supported": ["S256"] + "code_challenge_methods_supported": ["S256", "plain"] } ``` -**Workaround**: The MCP server implements PKCE validation and logs a warning if not advertised. Functionality still works. +**Database**: +- New columns: `code_challenge` and `code_challenge_method` in `oc_oauth2_access_tokens` +- Migration included for existing installations -**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 +**Why It Mattered**: +- MCP specification requires PKCE with S256 code challenge method +- RFC 7636 PKCE provides security for public clients (no client secret) +- RFC 8414 states that absence of `code_challenge_methods_supported` means PKCE is **not supported** +- Prevents authorization code interception attacks + +**Upstream PR**: [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) - ✅ **Merged 2025-10-20** +- **Changes**: Complete PKCE implementation (+194 lines) + - Authorization flow with code_challenge validation + - Token exchange with code_verifier verification + - Database schema updates + - Discovery document updates +- **Status**: Merged and available in v1.10.0+ of the `oidc` app --- @@ -82,17 +98,17 @@ This is added at lines ~243, ~310, ~315, and ~337 in `Backend.php`. | 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 | +| [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) | `oidc` | ✅ Merged | ~~Medium~~ | ✅ PKCE advertisement complete (v1.10.0+) | ## What Works Without Patches The following functionality works **out of the box** without any patches: ✅ **OAuth Flow**: -- OIDC discovery +- OIDC discovery with full PKCE support (requires `oidc` app v1.10.0+) - Dynamic client registration -- Authorization code flow with PKCE -- Token exchange +- Authorization code flow with PKCE (S256 and plain methods) +- Token exchange with code_verifier verification - Userinfo endpoint ✅ **MCP Server as Resource Server**: @@ -116,9 +132,9 @@ The following functionality requires upstream patches: - Tables API - Custom app APIs -🟡 **Standards Compliance** (PKCE advertisement): -- Full RFC 8414 compliance -- MCP client compatibility guarantee +✅ **Standards Compliance**: Now complete with `oidc` app v1.10.0+ +- ✅ Full RFC 8414 compliance (PKCE advertisement) +- ✅ MCP client compatibility guarantee ## Installation Instructions @@ -221,6 +237,6 @@ Want to help get these patches merged? --- -**Last Updated**: 2025-10-14 +**Last Updated**: 2025-10-20 -**Next Review**: When PR #584 or issue #1221 has activity +**Next Review**: When issue #1221 (Bearer token support) has activity diff --git a/pyproject.toml b/pyproject.toml index ee9a5b6..16eaac4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,8 +22,8 @@ dependencies = [ anyio_mode = "auto" addopts = "-p no:asyncio" # Disable pytest-asyncio plugin, use only anyio log_cli = 1 -log_cli_level = "WARN" -log_level = "WARN" +log_cli_level = "ERROR" +log_level = "ERROR" markers = [ "integration: marks tests as slow (deselect with '-m \"not slow\"')", "oauth: marks tests as oauth (deselect with '-m \"not oauth\"')" From 989b6de3c0112473b7d0dd4bde1541c04f72254c Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 20 Oct 2025 19:59:41 +0200 Subject: [PATCH 2/7] build: Switch to uv build backend --- .github/workflows/release.yml | 30 +++++++++++++++++++++++++++ pyproject.toml | 38 +++++++++++++++++++++++++++++++---- 2 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c92cbef --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,30 @@ +name: Release + +on: + push: + tags: + # Publish on any tag starting with a `v`, e.g., v1.2.3 + - v* + +jobs: + pypi: + name: Publish to PyPI + runs-on: ubuntu-latest + # Environment and permissions trusted publishing. + environment: + # Create this environment in the GitHub repository under Settings -> Environments + name: pypi + permissions: + id-token: write + contents: read + steps: + - name: Checkout + uses: actions/checkout@v5 + - name: Install uv + uses: astral-sh/setup-uv@v6 + - name: Install Python 3.11 + run: uv python install 3.11 + - name: Build + run: uv build + - name: Publish + run: uv publish diff --git a/pyproject.toml b/pyproject.toml index 16eaac4..45c0f4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,14 @@ [project] name = "nextcloud-mcp-server" version = "0.17.0" -description = "" +description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data" authors = [ - {name = "Chris Coutinho",email = "chris@coutinho.io"} + {name = "Chris Coutinho", email = "chris@coutinho.io"} ] readme = "README.md" +license = {text = "AGPL-3.0-only"} requires-python = ">=3.11" +keywords = ["nextcloud", "mcp", "model-context-protocol", "llm", "ai", "claude", "webdav", "caldav", "carddav"] dependencies = [ "mcp[cli] (>=1.18,<1.19)", "httpx (>=0.28.1,<0.29.0)", @@ -17,6 +19,24 @@ dependencies = [ "click>=8.1.8", "caldav", ] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU Affero General Public License v3", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: Communications", + "Topic :: Internet :: WWW/HTTP", +] + +[project.urls] +Homepage = "https://github.com/cbcoutinho/nextcloud-mcp-server" +Documentation = "https://github.com/cbcoutinho/nextcloud-mcp-server#readme" +Repository = "https://github.com/cbcoutinho/nextcloud-mcp-server" +"Bug Tracker" = "https://github.com/cbcoutinho/nextcloud-mcp-server/issues" +Changelog = "https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/CHANGELOG.md" [tool.pytest.ini_options] anyio_mode = "auto" @@ -50,8 +70,12 @@ extend-select = ["I"] caldav = { git = "https://github.com/cbcoutinho/caldav", branch = "feature/httpx" } [build-system] -requires = ["poetry-core>=2.0.0,<3.0.0"] -build-backend = "poetry.core.masonry.api" +requires = ["uv_build>=0.9.4,<0.10.0"] +build-backend = "uv_build" + +[tool.uv.build-backend] +module-name = "nextcloud_mcp_server" +module-root = "" [dependency-groups] dev = [ @@ -67,3 +91,9 @@ dev = [ [project.scripts] nextcloud-mcp-server = "nextcloud_mcp_server.app:run" + +[[tool.uv.index]] +name = "testpypi" +url = "https://test.pypi.org/simple/" +publish-url = "https://test.pypi.org/legacy/" +explicit = true From 460e2e190c5d895bee0e9565776544496f27ba14 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 20 Oct 2025 20:22:07 +0200 Subject: [PATCH 3/7] ci: set workflow to be on workflow_dispatch --- .github/workflows/release.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c92cbef..6c82b8a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,10 +1,11 @@ name: Release -on: - push: - tags: - # Publish on any tag starting with a `v`, e.g., v1.2.3 - - v* +on: workflow_dispatch +# Uncomment and release to PyPI once caldav feature/httpx branch is merged +#on: + #push: + #tags: + #- v* jobs: pypi: From fde68dac55aed2f3c72a2ce2f428641cfdd2b8f9 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 20 Oct 2025 20:27:01 +0200 Subject: [PATCH 4/7] ci: Enable publish to test pypi --- .github/workflows/release.yml | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c82b8a..c755998 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,11 +1,9 @@ name: Release -on: workflow_dispatch -# Uncomment and release to PyPI once caldav feature/httpx branch is merged -#on: - #push: - #tags: - #- v* +on: + push: + tags: + - v* jobs: pypi: @@ -28,4 +26,4 @@ jobs: - name: Build run: uv build - name: Publish - run: uv publish + run: uv publish --index testpypi From e8f1340133fd65850eb0a923ff2ae667251045d0 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 20 Oct 2025 21:26:32 +0200 Subject: [PATCH 5/7] fix(caldav): Fix caldav search() due to missing todos --- nextcloud_mcp_server/client/calendar.py | 12 +++++++----- uv.lock | 4 ++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/nextcloud_mcp_server/client/calendar.py b/nextcloud_mcp_server/client/calendar.py index ec19974..dc79c84 100644 --- a/nextcloud_mcp_server/client/calendar.py +++ b/nextcloud_mcp_server/client/calendar.py @@ -260,7 +260,7 @@ class CalendarClient: result = [] for event in events: - await event.load() + await event.load(only_if_unloaded=True) event_dict = self._parse_ical_event(event.data) if event_dict: event_dict["href"] = str(event.url) @@ -311,7 +311,7 @@ class CalendarClient: # Find the event by UID using caldav library event = await calendar.event_by_uid(event_uid) - await event.load() + await event.load(only_if_unloaded=True) # Merge updates into existing iCal data updated_ical = self._merge_ical_properties(event.data, event_data, event_uid) @@ -347,7 +347,7 @@ class CalendarClient: calendar = self._get_calendar(calendar_name) event = await calendar.event_by_uid(event_uid) - await event.load() + await event.load(only_if_unloaded=True) event_data = self._parse_ical_event(event.data) if not event_data: @@ -413,7 +413,9 @@ class CalendarClient: result = [] for todo in todos: - await todo.load() + # Only load if data not already present from REPORT response + # This avoids 404 errors for virtual calendars (e.g., Deck boards) + await todo.load(only_if_unloaded=True) todo_dict = self._parse_ical_todo(todo.data) if todo_dict: todo_dict["href"] = str(todo.url) @@ -465,7 +467,7 @@ class CalendarClient: try: # Find the todo by UID todo = await calendar.todo_by_uid(todo_uid) - await todo.load() + await todo.load(only_if_unloaded=True) logger.debug( f"Loaded todo {todo_uid}, current data length: {len(todo.data)}" diff --git a/uv.lock b/uv.lock index cb251dc..33e8edb 100644 --- a/uv.lock +++ b/uv.lock @@ -54,8 +54,8 @@ wheels = [ [[package]] name = "caldav" -version = "2.0.2.dev37+g543d3829b" -source = { git = "https://github.com/cbcoutinho/caldav?branch=feature%2Fhttpx#543d3829b3caedadd9d3d52b91c01fd9f73cce02" } +version = "2.0.2.dev38+g1aa2be35e" +source = { git = "https://github.com/cbcoutinho/caldav?branch=feature%2Fhttpx#1aa2be35e94883b44efd42f1cd82d281f8f58e60" } dependencies = [ { name = "httpx", extra = ["http2"] }, { name = "icalendar" }, From 63b898c0e393ff4c5c1d644feea852e5a55c7d1b Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 20 Oct 2025 22:57:18 +0200 Subject: [PATCH 6/7] chore: Update logs --- nextcloud_mcp_server/auth/client_registration.py | 6 +++++- tests/conftest.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/nextcloud_mcp_server/auth/client_registration.py b/nextcloud_mcp_server/auth/client_registration.py index b8b7340..d99c21d 100644 --- a/nextcloud_mcp_server/auth/client_registration.py +++ b/nextcloud_mcp_server/auth/client_registration.py @@ -1,5 +1,6 @@ """Dynamic client registration for Nextcloud OIDC.""" +import datetime as dt import json import logging import os @@ -113,8 +114,11 @@ async def register_client( logger.info( f"Successfully registered client: {client_info.get('client_id')}" ) + expires_at = dt.datetime.fromtimestamp( + client_info.get("client_secret_expires_at") + ) logger.info( - f"Client expires at: {client_info.get('client_secret_expires_at')} " + f"Client expires at: {expires_at} " f"(in {client_info.get('client_secret_expires_at', 0) - int(time.time())} seconds)" ) diff --git a/tests/conftest.py b/tests/conftest.py index 1d7a11c..b843d1a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -829,7 +829,7 @@ async def shared_oauth_client_credentials(anyio_backend, oauth_callback_server): nextcloud_url=nextcloud_host, registration_endpoint=registration_endpoint, storage_path=".nextcloud_oauth_shared_test_client.json", - client_name="Nextcloud MCP Server - Shared Test Client", + client_name="Pytest - Shared Test Client", redirect_uris=[callback_url], ) From 48744e8a6cd9bc4a7eb1516fbaa0be2d9bce27ab Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 20 Oct 2025 23:14:12 +0200 Subject: [PATCH 7/7] ci: Publish to PyPI --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c755998..0dbc9d2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,4 +26,4 @@ jobs: - name: Build run: uv build - name: Publish - run: uv publish --index testpypi + run: uv publish