Compare commits

..

94 Commits

Author SHA1 Message Date
Chris Coutinho 7498b501eb chore: Remove remaining tools 2025-09-11 09:31:13 +02:00
Chris Coutinho 652c58d1fb chore: fix test 2025-09-11 00:40:16 +02:00
Chris Coutinho e7a5caa0d6 Merge remote-tracking branch 'origin/master' into feature/deck 2025-09-11 00:37:58 +02:00
Chris Coutinho d2d413afcd feat(deck): Add support for stack, cards, labels 2025-09-11 00:35:02 +02:00
github-actions[bot] 3c3df0d3a5 bump: version 0.9.0 → 0.10.0 2025-09-10 22:13:45 +00:00
Chris Coutinho c59bcca053 Merge pull request #154 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.17
2025-09-11 00:13:16 +02:00
Chris Coutinho 18973e061a Merge pull request #150 from pedrxd/task/mr-move-webdav
feat: Add WebDAV resource move/rename/copy functionality
2025-09-11 00:12:23 +02:00
Chris Coutinho 167053578d feat(deck): Initialize Deck app client/server 2025-09-11 00:10:25 +02:00
renovate-bot-cbcoutinho[bot] 2633b63a04 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.17 2025-09-10 22:05:55 +00:00
Pedro Ruiz 5d4902a73e feat: Add WebDAV resource copy functionality 2025-09-10 22:15:16 +02:00
Pedro Ruiz b55b9640c6 feat: Add WebDAV resource move/rename functionality 2025-09-10 22:12:17 +02:00
github-actions[bot] b1eb4d2497 bump: version 0.8.3 → 0.9.0 2025-09-10 15:24:13 +00:00
Chris Coutinho 6c580fec01 Merge pull request #148 from cbcoutinho/feature/uvicorn
Replace mcp run with uvicorn
2025-09-10 17:23:48 +02:00
Chris Coutinho bbd8d1cf63 feat(cli): Replace mcp run with click CLI and runtime options
BREAKING CHANGE: FASTMCP_-prefixed env vars have been replaced by CLI
arguments. Refer to the README for updated usage.

Usage: python -m nextcloud_mcp_server.app [OPTIONS]

Options:
  -h, --host TEXT
  -p, --port INTEGER
  -w, --workers INTEGER
  -r, --reload
  --log-level [critical|error|warning|info|debug|trace]
  -t, --transport [sse|streamable-http]
  -e, --enable-app [notes|tables|webdav|calendar|contacts]
                                  Enable specific Nextcloud app APIs. Can be
                                  specified multiple times. If not specified,
                                  all apps are enabled.
  --help                          Show this message and exit.
2025-09-10 17:19:12 +02:00
Chris Coutinho d01c6ee0d0 Merge pull request #152 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.16
2025-09-10 09:10:13 +02:00
renovate-bot-cbcoutinho[bot] d48b93e8fc chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.16 2025-09-10 04:04:38 +00:00
Chris Coutinho 7b663c5476 Merge pull request #151 from cbcoutinho/renovate/nextcloud-31.0.8
chore(deps): update nextcloud:31.0.8 docker digest to c3329db
2025-09-09 23:47:13 +02:00
renovate-bot-cbcoutinho[bot] 73257e749f chore(deps): update nextcloud:31.0.8 docker digest to c3329db 2025-09-09 21:44:22 +00:00
Chris Coutinho d66faa9533 Merge pull request #149 from cbcoutinho/renovate/nextcloud-31.0.8
chore(deps): update nextcloud:31.0.8 docker digest to f26bb78
2025-09-09 13:24:14 +02:00
renovate-bot-cbcoutinho[bot] 58fd0283ea chore(deps): update nextcloud:31.0.8 docker digest to f26bb78 2025-09-09 10:04:26 +00:00
Chris Coutinho 3feac952da Merge pull request #147 from cbcoutinho/renovate/nextcloud-31.0.8
chore(deps): update nextcloud:31.0.8 docker digest to 6205056
2025-09-09 06:55:12 +02:00
renovate-bot-cbcoutinho[bot] 6a2ed9815a chore(deps): update nextcloud:31.0.8 docker digest to 6205056 2025-09-09 04:04:52 +00:00
Chris Coutinho c1c01196a4 Merge pull request #146 from cbcoutinho/renovate/pin-dependencies
chore(deps): pin softprops/action-gh-release action to 6cbd405
2025-09-08 07:55:49 +02:00
renovate-bot-cbcoutinho[bot] 930bb280fe chore(deps): pin softprops/action-gh-release action to 6cbd405 2025-09-08 04:07:08 +00:00
Chris Coutinho e36d020f6b chore: Update README 2025-09-08 00:31:27 +02:00
Chris Coutinho c13240819a Merge pull request #145 from cbcoutinho/renovate/softprops-action-gh-release-digest
chore(deps): update softprops/action-gh-release digest to 6cbd405
2025-09-08 00:22:19 +02:00
Chris Coutinho c2c2a71c4b ci: bump dep 2025-09-08 00:11:13 +02:00
renovate-bot-cbcoutinho[bot] 21f6164e07 chore(deps): update softprops/action-gh-release digest to 6cbd405 2025-09-07 10:04:29 +00:00
Chris Coutinho 420fa9173d Merge pull request #144 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.8.15-python3.11-alpine
chore(deps): update ghcr.io/astral-sh/uv:0.8.15-python3.11-alpine docker digest to e471ce4
2025-09-03 18:19:54 +02:00
renovate-bot-cbcoutinho[bot] da4d48c493 chore(deps): update ghcr.io/astral-sh/uv:0.8.15-python3.11-alpine docker digest to e471ce4 2025-09-03 16:05:56 +00:00
Chris Coutinho 404abe8695 Merge pull request #143 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.15
2025-09-03 07:42:55 +02:00
renovate-bot-cbcoutinho[bot] 28dd24510d chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.15 2025-09-03 04:04:52 +00:00
Chris Coutinho f72bb7e996 Merge pull request #142 from cbcoutinho/renovate/mariadb-lts
chore(deps): update mariadb:lts docker digest to ec5d50f
2025-09-02 10:33:34 +02:00
renovate-bot-cbcoutinho[bot] 9c0a0e9bf3 chore(deps): update mariadb:lts docker digest to ec5d50f 2025-09-02 04:05:18 +00:00
Chris Coutinho 78b96177bd Merge pull request #141 from cbcoutinho/renovate/astral-sh-setup-uv-digest
chore(deps): update astral-sh/setup-uv digest to 557e51d
2025-09-01 18:28:23 +02:00
renovate-bot-cbcoutinho[bot] 70b0754a19 chore(deps): update astral-sh/setup-uv digest to 557e51d 2025-09-01 16:06:39 +00:00
github-actions[bot] f034012101 bump: version 0.8.2 → 0.8.3 2025-08-31 19:22:11 +00:00
Chris Coutinho 7c4c0284f3 Merge pull request #140 from cbcoutinho/feature/etag
fix(notes): Include ETags in responses to avoid accidently updates
2025-08-31 21:21:50 +02:00
Chris Coutinho 892340fb66 chore: Remove unused model SuccessResponse 2025-08-31 21:15:43 +02:00
Chris Coutinho f79b957644 test: Update tests with McpError 2025-08-31 21:08:04 +02:00
Chris Coutinho ef1fb9e9aa fix(server): Replace ErrorResponses with standard McpErrors 2025-08-31 20:58:12 +02:00
Chris Coutinho d712b5487c test(notes): Modify tests with updated error handling 2025-08-31 19:32:39 +02:00
Chris Coutinho 892a8d2d23 fix(notes): Include ETags in responses to avoid accidently updates 2025-08-31 19:20:51 +02:00
github-actions[bot] daeb95f3c3 bump: version 0.8.1 → 0.8.2 2025-08-31 10:36:56 +00:00
Chris Coutinho 36d44d1781 Merge pull request #139 from cbcoutinho/feature/notes-no-return-content
fix(notes): Remove note contents from responses to reduce token usage
2025-08-31 12:36:30 +02:00
Chris Coutinho 949fb7124b fix(notes): Remove note contents from responses to reduce token usage 2025-08-31 11:55:15 +02:00
github-actions[bot] 6c4f071d2b bump: version 0.8.0 → 0.8.1 2025-08-30 20:38:13 +00:00
Chris Coutinho 53b11f7fbb fix(model): Serialize timestamps in RFC3339 format 2025-08-30 22:37:16 +02:00
Chris Coutinho 336bc45637 Merge pull request #138 from cbcoutinho/renovate/nextcloud-31.0.8
chore(deps): update nextcloud:31.0.8 docker digest to fcf6370
2025-08-30 20:29:17 +02:00
renovate-bot-cbcoutinho[bot] 6c587bb265 chore(deps): update nextcloud:31.0.8 docker digest to fcf6370 2025-08-30 18:19:45 +00:00
github-actions[bot] 6b1f5c12c8 bump: version 0.7.2 → 0.8.0 2025-08-30 17:28:57 +00:00
Chris Coutinho f8dc1f060b Merge pull request #137 from cbcoutinho/feature/claude-code
Feature/claude code
2025-08-30 19:28:33 +02:00
Chris Coutinho 4cf5f2a95a feat(client): Preserve fields when modifying contacts/calendar resources 2025-08-30 19:19:20 +02:00
Chris Coutinho 1cc65f0160 chore: Remove unused model 2025-08-30 18:31:45 +02:00
Chris Coutinho 9b00530e8e feat(server): Add structured output to all tool/resource output
BREAKING CHANGE
2025-08-30 18:27:32 +02:00
Chris Coutinho 938376425b chore: Update CLAUDE.md 2025-08-30 14:34:25 +02:00
Chris Coutinho 0484167a22 refactor: Use _make_request where available 2025-08-30 14:27:53 +02:00
Chris Coutinho 84ad1958af chore: Remove unnecessary logging
Migrate pre-commit tasks to local
2025-08-30 14:25:16 +02:00
Chris Coutinho fa002296ff chore(claude): Initialize CLAUDE.md 2025-08-30 13:23:34 +02:00
github-actions[bot] 464ff2c8b2 bump: version 0.7.1 → 0.7.2 2025-08-30 10:15:06 +00:00
Chris Coutinho 0804ff8d17 Merge pull request #136 from rnivet/fix/get-all-notes-paging
fix(client): Use paging to fetch all notes
2025-08-30 12:14:45 +02:00
Rémi Nivet 4f7023a16e fix(client): Use paging to fetch all notes 2025-08-29 23:46:58 +02:00
Chris Coutinho 8f6656c546 Merge pull request #134 from cbcoutinho/renovate/nextcloud-31.0.8
chore(deps): update nextcloud:31.0.8 docker digest to 3eaddb0
2025-08-29 12:53:52 +02:00
Chris Coutinho 741c58d9a3 Merge pull request #135 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.14
2025-08-29 12:53:42 +02:00
renovate-bot-cbcoutinho[bot] e7b79d0316 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.14 2025-08-29 10:25:25 +00:00
renovate-bot-cbcoutinho[bot] 0e4cc8e56f chore(deps): update nextcloud:31.0.8 docker digest to 3eaddb0 2025-08-29 10:25:20 +00:00
Chris Coutinho 16da7a9a76 Merge pull request #133 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.13
2025-08-22 13:06:28 +02:00
renovate-bot-cbcoutinho[bot] 520e515f2b chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.13 2025-08-21 22:14:57 +00:00
Chris Coutinho fd6ce7b294 Merge pull request #132 from cbcoutinho/renovate/astral-sh-setup-uv-digest
chore(deps): update astral-sh/setup-uv digest to 4959332
2025-08-21 12:45:28 +02:00
renovate-bot-cbcoutinho[bot] 8063059f5f chore(deps): update astral-sh/setup-uv digest to 4959332 2025-08-21 10:04:51 +00:00
Chris Coutinho 20c5046b20 Merge pull request #130 from cbcoutinho/renovate/redis-alpine
chore(deps): update redis:alpine docker digest to 987c376
2025-08-19 11:50:51 +02:00
Chris Coutinho 68126640d8 Merge pull request #131 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.12
2025-08-19 11:50:10 +02:00
renovate-bot-cbcoutinho[bot] af617e3869 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.12 2025-08-19 04:04:58 +00:00
renovate-bot-cbcoutinho[bot] 04e5f7beca chore(deps): update redis:alpine docker digest to 987c376 2025-08-19 04:04:54 +00:00
Chris Coutinho 6ed1efab24 Merge pull request #129 from cbcoutinho/renovate/nextcloud-31.0.8
chore(deps): update nextcloud:31.0.8 docker digest to 72abe18
2025-08-17 23:30:34 +02:00
renovate-bot-cbcoutinho[bot] cffa002364 chore(deps): update nextcloud:31.0.8 docker digest to 72abe18 2025-08-17 16:04:16 +00:00
Chris Coutinho 951a7095b2 Merge pull request #127 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.11
2025-08-16 20:04:50 +02:00
Chris Coutinho ee31f33038 Merge pull request #128 from cbcoutinho/renovate/nextcloud-31.x
chore(deps): update nextcloud docker tag to v31.0.8
2025-08-15 14:18:22 +02:00
renovate-bot-cbcoutinho[bot] 0fdbfae198 chore(deps): update nextcloud docker tag to v31.0.8 2025-08-15 04:08:58 +00:00
renovate-bot-cbcoutinho[bot] 315f918d88 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.11 2025-08-14 22:11:23 +00:00
Chris Coutinho 96a8491a4c Merge pull request #123 from cbcoutinho/renovate/astral-sh-setup-uv-digest
chore(deps): update astral-sh/setup-uv digest to d9e0f98
2025-08-13 10:00:32 +02:00
Chris Coutinho 0a311766f2 Merge pull request #124 from cbcoutinho/renovate/mariadb-lts
chore(deps): update mariadb:lts docker digest to 272084c
2025-08-13 09:59:56 +02:00
Chris Coutinho d28c249f8d Merge pull request #125 from cbcoutinho/renovate/nextcloud-31.0.7
chore(deps): update nextcloud:31.0.7 docker digest to b255a97
2025-08-13 09:59:47 +02:00
renovate-bot-cbcoutinho[bot] ab6cac8799 chore(deps): update nextcloud:31.0.7 docker digest to b255a97 2025-08-13 04:05:37 +00:00
renovate-bot-cbcoutinho[bot] 7127b9953f chore(deps): update mariadb:lts docker digest to 272084c 2025-08-13 04:05:33 +00:00
renovate-bot-cbcoutinho[bot] 49c9af3c76 chore(deps): update astral-sh/setup-uv digest to d9e0f98 2025-08-12 22:08:22 +00:00
Chris Coutinho 823151f42e Merge pull request #122 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.9
2025-08-12 13:31:53 +02:00
renovate-bot-cbcoutinho[bot] 2bbd56e1cd chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.9 2025-08-12 04:05:16 +00:00
Chris Coutinho 8a36a120a7 Merge pull request #121 from cbcoutinho/renovate/actions-checkout-5.x
chore(deps): update actions/checkout action to v5
2025-08-11 22:39:16 +02:00
renovate-bot-cbcoutinho[bot] 9df8cc937d chore(deps): update actions/checkout action to v5 2025-08-11 16:07:14 +00:00
Chris Coutinho 325dcdf654 Merge pull request #118 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.8
2025-08-09 09:09:45 +02:00
renovate-bot-cbcoutinho[bot] 945eb1eb4e chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.8 2025-08-09 04:04:39 +00:00
Chris Coutinho 088343d003 Merge pull request #117 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.7
2025-08-09 01:14:56 +02:00
renovate-bot-cbcoutinho[bot] 94d553985f chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.7 2025-08-08 22:07:52 +00:00
41 changed files with 5247 additions and 239 deletions
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
packages: write
steps:
- name: Check out
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
fetch-depth: 0
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
@@ -25,7 +25,7 @@ jobs:
github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
changelog_increment_filename: body.md
- name: Release
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2
uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3
with:
body_path: "body.md"
tag_name: v${{ env.REVISION }}
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Docker meta
id: meta
+4 -4
View File
@@ -9,9 +9,9 @@ jobs:
linting:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Install the latest version of uv
uses: astral-sh/setup-uv@e92bafb6253dcd438e0484186d7669ea7a8ca1cc # v6
uses: astral-sh/setup-uv@557e51de59eb14aaaba2ed9621916900a91d50c6 # v6
- name: Check format
run: |
uv run --frozen ruff format --diff
@@ -24,14 +24,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Run docker compose
uses: hoverkraft-tech/compose-action@40041ff1b97dbf152cd2361138c2b03fa29139df # v2.3.0
with:
compose-file: "./docker-compose.yml"
- name: Install the latest version of uv
uses: astral-sh/setup-uv@e92bafb6253dcd438e0484186d7669ea7a8ca1cc # v6
uses: astral-sh/setup-uv@557e51de59eb14aaaba2ed9621916900a91d50c6 # v6
- name: Wait for service to be ready
run: |
+10 -3
View File
@@ -1,13 +1,20 @@
repos:
- repo: https://github.com/commitizen-tools/commitizen
rev: v4.8.3
rev: v4.9.0
hooks:
- id: commitizen
- id: commitizen-branch
stages:
- pre-push
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.5
- repo: local
hooks:
- id: ruff-check
name: ruff-check
entry: uv run ruff check
language: system
types: [python]
- id: ruff-format
name: ruff-format
entry: uv run ruff format
language: system
types: [python]
+54
View File
@@ -1,3 +1,57 @@
## v0.10.0 (2025-09-10)
### Feat
- Add WebDAV resource copy functionality
- Add WebDAV resource move/rename functionality
## v0.9.0 (2025-09-10)
### BREAKING CHANGE
- FASTMCP_-prefixed env vars have been replaced by CLI
arguments. Refer to the README for updated usage.
### Feat
- **cli**: Replace `mcp run` with click CLI and runtime options
## v0.8.3 (2025-08-31)
### Fix
- **server**: Replace ErrorResponses with standard McpErrors
- **notes**: Include ETags in responses to avoid accidently updates
## v0.8.2 (2025-08-31)
### Fix
- **notes**: Remove note contents from responses to reduce token usage
## v0.8.1 (2025-08-30)
### Fix
- **model**: Serialize timestamps in RFC3339 format
## v0.8.0 (2025-08-30)
### Feat
- **client**: Preserve fields when modifying contacts/calendar resources
- **server**: Add structured output to all tool/resource output
### Refactor
- Use _make_request where available
## v0.7.2 (2025-08-30)
### Fix
- **client**: Use paging to fetch all notes
## v0.7.1 (2025-08-08)
### Fix
+124
View File
@@ -0,0 +1,124 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Development Commands
### Testing
```bash
# Run all tests
uv run pytest
# Run integration tests only
uv run pytest -m integration
# Run tests with coverage
uv run pytest --cov
# Skip integration tests
uv run pytest -m "not integration"
```
### Code Quality
```bash
# Format and lint code
uv run ruff check
uv run ruff format
# Type checking
# No explicit type checker configured - this is a Python project using ruff for linting
```
### Running the Server
```bash
# Local development - load environment variables and run
export $(grep -v '^#' .env | xargs)
mcp run --transport sse nextcloud_mcp_server.app:mcp
# Docker development environment with Nextcloud instance
docker-compose up
# After code changes, rebuild and restart only the MCP server container
docker-compose up --build -d mcp
# Build Docker image
docker build -t nextcloud-mcp-server .
```
### Environment Setup
```bash
# Install dependencies
uv sync
# Install development dependencies
uv sync --group dev
```
## Architecture Overview
This is a Python MCP (Model Context Protocol) server that provides LLM integration with Nextcloud. The architecture follows a layered pattern:
### Core Components
- **`nextcloud_mcp_server/app.py`** - Main MCP server entry point using FastMCP framework
- **`nextcloud_mcp_server/client/`** - HTTP client implementations for different Nextcloud APIs
- **`nextcloud_mcp_server/server/`** - MCP tool/resource definitions that expose client functionality
- **`nextcloud_mcp_server/controllers/`** - Business logic controllers (e.g., notes search)
### Client Architecture
- **`NextcloudClient`** - Main orchestrating client that manages all app-specific clients
- **`BaseNextcloudClient`** - Abstract base class providing common HTTP functionality and retry logic
- **App-specific clients**: `NotesClient`, `CalendarClient`, `ContactsClient`, `TablesClient`, `WebDAVClient`
### Server Integration
Each Nextcloud app has a corresponding server module that:
1. Defines MCP tools using `@mcp.tool()` decorators
2. Defines MCP resources using `@mcp.resource()` decorators
3. Uses the context pattern to access the `NextcloudClient` instance
### Supported Nextcloud Apps
- **Notes** - Full CRUD operations and search
- **Calendar** - CalDAV integration with events, recurring events, attendees
- **Contacts** - CardDAV integration with address book operations
- **Tables** - Row-level operations on Nextcloud Tables
- **WebDAV** - Complete file system access
### Key Patterns
1. **Environment-based configuration** - Uses `NextcloudClient.from_env()` to load credentials from environment variables
2. **Async/await throughout** - All operations are async using httpx
3. **Retry logic** - `@retry_on_429` decorator handles rate limiting
4. **Context injection** - MCP context provides access to the authenticated client instance
5. **Modular design** - Each Nextcloud app is isolated in its own client/server pair
### Testing Structure
- **Integration tests** in `tests/integration/` - Test real Nextcloud API interactions
- **Fixtures** in `tests/conftest.py` - Shared test setup and utilities
- Tests are marked with `@pytest.mark.integration` for selective running
- **Important**: Integration tests run against live Docker containers. After making code changes to the MCP server, rebuild only the MCP container with `docker-compose up --build -d mcp` before running tests
#### Testing Best Practices
- **MANDATORY: Always run tests after implementing features or fixing bugs**
- Run tests to completion before considering any task complete
- 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_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
- `temporary_contact` - Creates and cleans up test contacts
- **Test specific functionality** after changes:
- For Notes changes: `uv run pytest tests/integration/test_mcp.py -k "notes" -v`
- 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
### Configuration Files
- **`pyproject.toml`** - Python project configuration using uv for dependency management
- **`.env`** (from `env.sample`) - Environment variables for Nextcloud connection
- **`docker-compose.yml`** - Complete development environment with Nextcloud + database
+2 -2
View File
@@ -1,4 +1,4 @@
FROM ghcr.io/astral-sh/uv:0.8.6-python3.11-alpine@sha256:15e7f7ee738f1b8a0e1ed95da7cf821c58b77d3d15275bf8f4605fbbf36679f4
FROM ghcr.io/astral-sh/uv:0.8.17-python3.11-alpine@sha256:2a2cae80b7d3b3b3c7f94ec3ed91e9b3ca2524a7a429824fbbadd9954fa5d6b6
WORKDIR /app
@@ -6,4 +6,4 @@ COPY . .
RUN uv sync --locked --no-dev
CMD ["/app/.venv/bin/mcp", "run", "--transport", "sse", "/app/nextcloud_mcp_server/app.py:mcp"]
ENTRYPOINT ["/app/.venv/bin/python", "-m", "nextcloud_mcp_server.app"]
+220 -12
View File
@@ -17,6 +17,11 @@ The server provides integration with multiple Nextcloud apps, enabling LLMs to i
| **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 |
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
@@ -58,6 +63,30 @@ The server provides integration with multiple Nextcloud apps, enabling LLMs to i
| `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_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 |
| `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 |
@@ -78,15 +107,46 @@ The server provides integration with multiple Nextcloud apps, enabling LLMs to i
| `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
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.
### 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 |
### 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
**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`
### WebDAV File System Access
The server provides complete file system access to your NextCloud instance, enabling you to:
@@ -116,6 +176,95 @@ await nc_webdav_write_file("NewProject/docs/notes.md", "# My Notes\n\nContent he
# 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
@@ -232,9 +381,9 @@ This server supports adding and retrieving note attachments via WebDAV. Please n
git clone https://github.com/cbcoutinho/nextcloud-mcp-server.git
cd nextcloud-mcp-server
```
2. Install the package (if running as a library):
2. Install the package dependencies (if running via CLI):
```bash
poetry install
uv sync
```
### Docker
@@ -255,45 +404,104 @@ NEXTCLOUD_PASSWORD=your_nextcloud_app_password_or_login_password
* `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.
* `FASTMCP_HOST`: _Optional:_ By default FastMCP binds to localhost. Use this variable to set a different binding address (e.g. `0.0.0.0`)
## Running the Server
### Locally
Ensure your environment variables are loaded, then run the server using `mcp run`:
Ensure your environment variables are loaded, then run the server. You have several options:
#### Option 1: Using `nextcloud_mcp_server` cli (recommended)
```bash
# 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
# Enable only specific Nextcloud app APIs
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
```
#### 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 the server
mcp run --transport sse nextcloud_mcp_server.app:mcp
# 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://localhost:8000`.
The server will start, typically listening on `http://127.0.0.1:8000`.
> NOTE: To make the server bind to a different address, use the FASTMCP_HOST environmental variable
**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:
```bash
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
# 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
# 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
# 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
```
This will start the server and expose it on port 8000 of your local machine.
## Usage
Once the server is running, you can connect to it using an MCP client like `uvx`. Add the server to your `uvx` configuration:
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:
```bash
uvx mcp add nextcloud-mcp http://localhost:8000 --default-transport sse
uv run mcp dev
```
You can then interact with the server's tools and resources through your LLM interface connected to `uvx`.
You can then connect to and interact with the server's tools and resources through your browser.
## References:
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
php /var/www/html/occ app:enable deck
+6 -4
View File
@@ -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:2bcbaec92bd9d4f6591bc8103d3a8e6d0512ee2235506e47a2e129d190444405
image: mariadb:lts@sha256:ec5d50f32359ff020b93cce6834f9bf89147c34aea0e90c952ccf556c94a4fb8
restart: always
command: --transaction-isolation=READ-COMMITTED
volumes:
@@ -17,11 +17,11 @@ 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:7521abdff715d396aa482183942f3fe643344287c29ccb66eee16ac08a92190f
image: redis:alpine@sha256:987c376c727652f99625c7d205a1cba3cb2c53b92b0b62aade2bd48ee1593232
restart: always
app:
image: nextcloud:31.0.7@sha256:a834f43b159890322d471862a3d8ab48602d4a619499aa7bc94b662cf4463e66
image: nextcloud:31.0.8@sha256:c3329db9d0d0d79b1fe6433b54b81c28acaefecfe96a400be202b7da80f6b8ca
#user: www-data:www-data
restart: always
#post_start:
@@ -46,13 +46,15 @@ services:
mcp:
build: .
command: ["--host", "0.0.0.0"]
ports:
- 8000:8000
environment:
- NEXTCLOUD_HOST=http://app:80
- NEXTCLOUD_USERNAME=admin
- NEXTCLOUD_PASSWORD=admin
- FASTMCP_HOST=0.0.0.0
#volumes:
#- ./nextcloud_mcp_server:/app/nextcloud_mcp_server:ro
volumes:
nextcloud:
+100 -22
View File
@@ -1,21 +1,28 @@
import click
import logging
import uvicorn
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from starlette.applications import Starlette
from starlette.routing import Mount
from mcp.server.fastmcp import Context, FastMCP
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import setup_logging
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.server import (
configure_calendar_tools,
configure_contacts_tools,
configure_notes_tools,
configure_tables_tools,
configure_webdav_tools,
configure_deck_tools,
)
setup_logging()
logger = logging.getLogger(__name__)
@dataclass
@@ -37,28 +44,99 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
await client.close()
# Create an MCP server
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan)
def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
setup_logging()
logger = logging.getLogger(__name__)
# Create an MCP server
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan)
@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
return await client.capabilities()
# Define available apps and their configuration functions
available_apps = {
"notes": configure_notes_tools,
"tables": configure_tables_tools,
"webdav": configure_webdav_tools,
"calendar": configure_calendar_tools,
"contacts": configure_contacts_tools,
"deck": configure_deck_tools,
}
# If no specific apps are specified, enable all
if enabled_apps is None:
enabled_apps = list(available_apps.keys())
# Configure only the enabled apps
for app_name in enabled_apps:
if app_name in available_apps:
logger.info(f"Configuring {app_name} tools")
available_apps[app_name](mcp)
else:
logger.warning(
f"Unknown app: {app_name}. Available apps: {list(available_apps.keys())}"
)
mcp_app = mcp.sse_app() if transport == "sse" else mcp.streamable_http_app()
app = Starlette(routes=[Mount("/", app=mcp_app)])
return app
@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
return await client.capabilities()
@click.command()
@click.option("--host", "-h", default="127.0.0.1")
@click.option("--port", "-p", type=int, default=8000)
@click.option("--workers", "-w", type=int, default=None)
@click.option("--reload", "-r", is_flag=True)
@click.option(
"--log-level",
type=click.Choice(["critical", "error", "warning", "info", "debug", "trace"]),
)
@click.option(
"--transport", "-t", default="sse", type=click.Choice(["sse", "streamable-http"])
)
@click.option(
"--enable-app",
"-e",
multiple=True,
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.",
)
def run(
host: str,
port: int,
workers: int,
reload: bool,
log_level: str,
transport: str,
enable_app: tuple[str, ...],
):
enabled_apps = list(enable_app) if enable_app else None
if reload or workers:
app = "nextcloud_mcp_server.app:get_app"
factory = True
else:
app = get_app(transport=transport, enabled_apps=enabled_apps)
factory = False
uvicorn.run(
app=app,
factory=factory,
host=host,
port=port,
reload=reload,
workers=workers,
log_level=log_level,
)
configure_notes_tools(mcp)
configure_tables_tools(mcp)
configure_webdav_tools(mcp)
configure_calendar_tools(mcp)
configure_contacts_tools(mcp)
def run():
mcp.run()
if __name__ == "__main__":
run()
+2
View File
@@ -14,6 +14,7 @@ from httpx import (
from ..controllers.notes_search import NotesSearchController
from .calendar import CalendarClient
from .contacts import ContactsClient
from .deck import DeckClient
from .notes import NotesClient
from .tables import TablesClient
from .webdav import WebDAVClient
@@ -69,6 +70,7 @@ class NextcloudClient:
self.tables = TablesClient(self._client, username)
self.calendar = CalendarClient(self._client, username)
self.contacts = ContactsClient(self._client, username)
self.deck = DeckClient(self._client, username)
# Initialize controllers
self._notes_search = NotesSearchController()
+7
View File
@@ -39,6 +39,13 @@ def retry_on_429(func):
f"429 Client Error: Too Many Requests, Number of attempts: {retries}"
)
time.sleep(5)
elif e.response.status_code == 404:
# 404 errors are often expected (e.g., checking if attachments exist)
# Log as debug instead of warning
logger.debug(
f"HTTPStatusError {e.response.status_code}: {e}, Number of attempts: {retries}"
)
raise
else:
logger.warning(
f"HTTPStatusError {e.response.status_code}: {e}, Number of attempts: {retries}"
+136 -11
View File
@@ -238,27 +238,33 @@ class CalendarClient(BaseNextcloudClient):
event_data: Dict[str, Any],
etag: str = "",
) -> Dict[str, Any]:
"""Update an existing calendar event."""
"""Update an existing calendar event while preserving all existing properties."""
event_filename = f"{event_uid}.ics"
event_path = f"{self._get_caldav_base_path()}/{calendar_name}/{event_filename}"
# Get existing event data to merge with updates
existing_event_data = {}
# Get raw iCal content to preserve all properties including extended ones
raw_ical_content = ""
if not etag:
try:
existing_event_data, current_etag = await self.get_event(
raw_ical_content, current_etag = await self._get_raw_ical(
calendar_name, event_uid
)
etag = current_etag
except Exception:
# Continue without etag if we can't get it
pass
# Fall back to creating new iCal if we can't get existing
logger.warning(
f"Could not fetch existing iCal for {event_uid}, creating new"
)
raw_ical_content = ""
# Merge existing data with new data (new data takes precedence)
merged_data = {**existing_event_data, **event_data}
# Create updated iCalendar event
ical_content = self._create_ical_event(merged_data, event_uid)
# Create updated iCalendar event preserving existing properties
if raw_ical_content:
ical_content = self._merge_ical_properties(
raw_ical_content, event_data, event_uid
)
else:
# Fallback to creating new iCal if we couldn't get existing
ical_content = self._create_ical_event(event_data, event_uid)
headers = {
"Content-Type": "text/calendar; charset=utf-8",
@@ -949,3 +955,122 @@ class CalendarClient(BaseNextcloudClient):
except Exception as e:
logger.error(f"Error deleting calendar {calendar_name}: {e}")
raise
async def _get_raw_ical(
self, calendar_name: str, event_uid: str
) -> Tuple[str, str]:
"""Get raw iCal content for an event without parsing."""
event_filename = f"{event_uid}.ics"
event_path = f"{self._get_caldav_base_path()}/{calendar_name}/{event_filename}"
headers = {"Accept": "text/calendar"}
try:
response = await self._make_request("GET", event_path, headers=headers)
etag = response.headers.get("etag", "")
return response.text, etag
except Exception as e:
logger.error(f"Error getting raw iCal for {event_uid}: {e}")
raise
def _merge_ical_properties(
self, raw_ical: str, event_data: Dict[str, Any], event_uid: str
) -> str:
"""Merge new event data into existing raw iCal while preserving all properties."""
try:
# Parse existing iCal
cal = Calendar.from_ical(raw_ical)
# Find the VEVENT component
for component in cal.walk():
if component.name == "VEVENT":
# Update only the properties that were provided in event_data
if "title" in event_data:
component["SUMMARY"] = event_data["title"]
if "description" in event_data:
component["DESCRIPTION"] = event_data["description"]
if "location" in event_data:
component["LOCATION"] = event_data["location"]
if "status" in event_data:
component["STATUS"] = event_data["status"].upper()
if "priority" in event_data:
component["PRIORITY"] = event_data["priority"]
if "privacy" in event_data:
component["CLASS"] = event_data["privacy"].upper()
if "url" in event_data:
component["URL"] = event_data["url"]
# Handle dates
if "start_datetime" in event_data:
start_str = event_data["start_datetime"]
all_day = event_data.get("all_day", False)
if all_day:
start_date = dt.datetime.fromisoformat(
start_str.split("T")[0]
).date()
component["DTSTART"] = start_date
else:
start_dt = dt.datetime.fromisoformat(
start_str.replace("Z", "+00:00")
)
component["DTSTART"] = start_dt
if "end_datetime" in event_data:
end_str = event_data["end_datetime"]
all_day = event_data.get("all_day", False)
if all_day:
end_date = dt.datetime.fromisoformat(
end_str.split("T")[0]
).date()
component["DTEND"] = end_date
else:
end_dt = dt.datetime.fromisoformat(
end_str.replace("Z", "+00:00")
)
component["DTEND"] = end_dt
# Handle categories
if "categories" in event_data:
categories = event_data["categories"]
if categories:
component["CATEGORIES"] = categories.split(",")
# Handle recurrence
if "recurring" in event_data:
if event_data["recurring"] and "recurrence_rule" in event_data:
recurrence_rule = event_data["recurrence_rule"]
if recurrence_rule:
component["RRULE"] = vRecur.from_ical(recurrence_rule)
elif not event_data["recurring"]:
# Remove recurrence if set to False
if "RRULE" in component:
del component["RRULE"]
# Handle attendees
if "attendees" in event_data:
attendees = event_data["attendees"]
# Remove existing attendees
component.pop("ATTENDEE", None)
if attendees:
for email in attendees.split(","):
if email.strip():
component.add("ATTENDEE", f"mailto:{email.strip()}")
# Update timestamps in proper iCal format
from icalendar import vDDDTypes
now = dt.datetime.now(dt.UTC)
component["LAST-MODIFIED"] = vDDDTypes(now)
component["DTSTAMP"] = vDDDTypes(now)
# Preserve all other existing properties (X-*, ORGANIZER, COMMENT, GEO, etc.)
# by not touching them - they remain in the component
break
return cal.to_ical().decode("utf-8")
except Exception as e:
logger.error(f"Error merging iCal properties: {e}")
# Fallback to creating new iCal
return self._create_ical_event(event_data, event_uid)
+201
View File
@@ -143,6 +143,50 @@ class ContactsClient(BaseNextcloudClient):
url = f"{carddav_path}/{addressbook}/{uid}.vcf"
await self._make_request("DELETE", url)
async def update_contact(
self, *, addressbook: str, uid: str, contact_data: dict, etag: str = ""
):
"""Update an existing contact while preserving all existing properties."""
carddav_path = self._get_carddav_base_path()
url = f"{carddav_path}/{addressbook}/{uid}.vcf"
# Get raw vCard content to preserve all properties including extended ones
raw_vcard_content = ""
if not etag:
try:
raw_vcard_content, current_etag = await self._get_raw_vcard(
addressbook, uid
)
etag = current_etag
except Exception:
# Fall back to creating new vCard if we can't get existing
logger.warning(
f"Could not fetch existing vCard for {uid}, creating new"
)
raw_vcard_content = ""
# Create updated vCard preserving existing properties
if raw_vcard_content:
vcard_content = self._merge_vcard_properties(
raw_vcard_content, contact_data, uid
)
else:
# Fallback to creating new vCard if we couldn't get existing
contact = Contact(fn=contact_data.get("fn"), uid=uid)
if "email" in contact_data:
contact.email = [{"value": contact_data["email"], "type": ["HOME"]}]
if "tel" in contact_data:
contact.tel = [{"value": contact_data["tel"], "type": ["HOME"]}]
vcard_content = contact.to_vcard()
headers = {
"Content-Type": "text/vcard; charset=utf-8",
}
if etag:
headers["If-Match"] = etag
await self._make_request("PUT", url, content=vcard_content, headers=headers)
async def list_contacts(self, *, addressbook: str):
"""List all available contacts for addressbook."""
@@ -233,3 +277,160 @@ class ContactsClient(BaseNextcloudClient):
logger.debug(f"Found {len(contacts)} contacts")
return contacts
async def _get_raw_vcard(self, addressbook: str, uid: str) -> tuple[str, str]:
"""Get raw vCard content for a contact without parsing."""
carddav_path = self._get_carddav_base_path()
url = f"{carddav_path}/{addressbook}/{uid}.vcf"
try:
response = await self._make_request("GET", url)
etag = response.headers.get("etag", "")
return response.text, etag
except Exception as e:
logger.error(f"Error getting raw vCard for {uid}: {e}")
raise
def _merge_vcard_properties(
self, raw_vcard: str, contact_data: dict, uid: str
) -> str:
"""Merge new contact data into existing raw vCard while preserving all properties."""
try:
# Instead of using pythonvCard4 which has formatting issues,
# let's do a simple text-based merge to preserve exact formatting
# Start with the original vCard
lines = raw_vcard.strip().split("\n")
updated_lines = []
# Track what we've updated to avoid duplicates
updated_properties = set()
for line in lines:
line = line.strip()
if not line:
continue
# Skip the END:VCARD line for now
if line == "END:VCARD":
continue
property_name = line.split(":")[0].split(";")[0]
# Handle updates for specific properties
if property_name == "FN" and "fn" in contact_data:
updated_lines.append(f"FN:{contact_data['fn']}")
updated_properties.add("fn")
elif property_name == "EMAIL" and "email" in contact_data:
# Replace first email with new one, preserve others
if "email" not in updated_properties:
if isinstance(contact_data["email"], str):
# Try to preserve the original format as much as possible
if ";TYPE=" in line:
type_part = line.split(";TYPE=")[1].split(":")[0]
updated_lines.append(
f"EMAIL;TYPE={type_part}:{contact_data['email']}"
)
else:
updated_lines.append(f"EMAIL:{contact_data['email']}")
updated_properties.add("email")
else:
# Keep additional emails unchanged
updated_lines.append(line)
elif property_name == "TEL" and "tel" in contact_data:
# Similar handling for phone numbers
if "tel" not in updated_properties:
if isinstance(contact_data["tel"], str):
if ";TYPE=" in line:
type_part = line.split(";TYPE=")[1].split(":")[0]
updated_lines.append(
f"TEL;TYPE={type_part}:{contact_data['tel']}"
)
else:
updated_lines.append(f"TEL:{contact_data['tel']}")
updated_properties.add("tel")
else:
# Keep additional phone numbers unchanged
updated_lines.append(line)
elif property_name == "NOTE" and "note" in contact_data:
updated_lines.append(f"NOTE:{contact_data['note']}")
updated_properties.add("note")
elif property_name == "NICKNAME" and "nickname" in contact_data:
nickname_value = contact_data["nickname"]
if isinstance(nickname_value, list):
nickname_value = ",".join(nickname_value)
updated_lines.append(f"NICKNAME:{nickname_value}")
updated_properties.add("nickname")
elif property_name == "BDAY" and "bday" in contact_data:
updated_lines.append(f"BDAY:{contact_data['bday']}")
updated_properties.add("bday")
elif property_name == "CATEGORIES" and "categories" in contact_data:
categories_value = contact_data["categories"]
if isinstance(categories_value, list):
categories_value = ",".join(categories_value)
updated_lines.append(f"CATEGORIES:{categories_value}")
updated_properties.add("categories")
elif property_name == "ORG" and (
"org" in contact_data or "organization" in contact_data
):
org_value = contact_data.get("org") or contact_data.get(
"organization"
)
updated_lines.append(f"ORG:{org_value}")
updated_properties.add("org")
elif property_name == "TITLE" and "title" in contact_data:
updated_lines.append(f"TITLE:{contact_data['title']}")
updated_properties.add("title")
else:
# Keep all other properties unchanged (preserves all extended/custom fields)
updated_lines.append(line)
# Add any new properties that weren't in the original vCard
for key, value in contact_data.items():
if key not in updated_properties:
if key == "fn":
updated_lines.append(f"FN:{value}")
elif key == "email" and isinstance(value, str):
updated_lines.append(f"EMAIL:{value}")
elif key == "tel" and isinstance(value, str):
updated_lines.append(f"TEL:{value}")
elif key == "note":
updated_lines.append(f"NOTE:{value}")
elif key == "nickname":
nickname_value = (
value if isinstance(value, str) else ",".join(value)
)
updated_lines.append(f"NICKNAME:{nickname_value}")
elif key == "bday":
updated_lines.append(f"BDAY:{value}")
elif key == "categories":
categories_value = (
value if isinstance(value, str) else ",".join(value)
)
updated_lines.append(f"CATEGORIES:{categories_value}")
elif key in ["org", "organization"]:
updated_lines.append(f"ORG:{value}")
elif key == "title":
updated_lines.append(f"TITLE:{value}")
# Add the END:VCARD line
updated_lines.append("END:VCARD")
# Join all lines
return "\n".join(updated_lines)
except Exception as e:
logger.error(f"Error merging vCard properties: {e}")
# Fallback to creating basic vCard matching Nextcloud format
basic_vcard = f"""BEGIN:VCARD
VERSION:3.0
UID:{uid}
FN:{contact_data.get("fn", "Unknown")}"""
if "email" in contact_data:
basic_vcard += f"\nEMAIL:{contact_data['email']}"
if "tel" in contact_data:
basic_vcard += f"\nTEL:{contact_data['tel']}"
basic_vcard += "\nEND:VCARD"
return basic_vcard
+602
View File
@@ -0,0 +1,602 @@
from typing import List, Optional, Dict, Any
from nextcloud_mcp_server.client.base import BaseNextcloudClient
from nextcloud_mcp_server.models.deck import (
DeckBoard,
DeckStack,
DeckCard,
DeckLabel,
DeckACL,
DeckAttachment,
DeckComment,
DeckSession,
DeckConfig,
)
class DeckClient(BaseNextcloudClient):
"""Client for Nextcloud Deck app operations."""
def _get_deck_headers(
self, additional_headers: Optional[Dict[str, str]] = None
) -> Dict[str, str]:
"""Get standard headers required for Deck API calls."""
headers = {"OCS-APIRequest": "true", "Content-Type": "application/json"}
if additional_headers:
headers.update(additional_headers)
return headers
# Boards
async def get_boards(
self, details: bool = False, if_modified_since: Optional[str] = None
) -> List[DeckBoard]:
additional_headers = {}
if if_modified_since:
additional_headers["If-Modified-Since"] = if_modified_since
headers = self._get_deck_headers(additional_headers)
params = {"details": "true"} if details else {}
response = await self._make_request(
"GET", "/apps/deck/api/v1.0/boards", headers=headers, params=params
)
return [DeckBoard(**board) for board in response.json()]
async def create_board(self, title: str, color: str) -> DeckBoard:
json_data = {"title": title, "color": color}
headers = self._get_deck_headers()
response = await self._make_request(
"POST", "/apps/deck/api/v1.0/boards", json=json_data, headers=headers
)
return DeckBoard(**response.json())
async def get_board(self, board_id: int) -> DeckBoard:
headers = self._get_deck_headers()
response = await self._make_request(
"GET", f"/apps/deck/api/v1.0/boards/{board_id}", headers=headers
)
return DeckBoard(**response.json())
async def update_board(
self,
board_id: int,
title: Optional[str] = None,
color: Optional[str] = None,
archived: Optional[bool] = None,
) -> None:
json_data = {}
if title is not None:
json_data["title"] = title
if color is not None:
json_data["color"] = color
if archived is not None:
json_data["archived"] = archived
headers = self._get_deck_headers()
await self._make_request(
"PUT",
f"/apps/deck/api/v1.0/boards/{board_id}",
json=json_data,
headers=headers,
)
async def delete_board(self, board_id: int) -> None:
headers = self._get_deck_headers()
await self._make_request(
"DELETE", f"/apps/deck/api/v1.0/boards/{board_id}", headers=headers
)
async def undo_delete_board(self, board_id: int) -> None:
headers = self._get_deck_headers()
await self._make_request(
"POST",
f"/apps/deck/api/v1.0/boards/{board_id}/undo_delete",
headers=headers,
)
async def add_acl_rule(
self,
board_id: int,
type: int,
participant: str,
permission_edit: bool,
permission_share: bool,
permission_manage: bool,
) -> List[DeckACL]:
json_data = {
"type": type,
"participant": participant,
"permissionEdit": permission_edit,
"permissionShare": permission_share,
"permissionManage": permission_manage,
}
response = await self._make_request(
"POST", f"/apps/deck/api/v1.0/boards/{board_id}/acl", json=json_data
)
return [DeckACL(**acl) for acl in response.json()]
async def update_acl_rule(
self,
board_id: int,
acl_id: int,
permission_edit: Optional[bool] = None,
permission_share: Optional[bool] = None,
permission_manage: Optional[bool] = None,
) -> None:
json_data = {}
if permission_edit is not None:
json_data["permissionEdit"] = permission_edit
if permission_share is not None:
json_data["permissionShare"] = permission_share
if permission_manage is not None:
json_data["permissionManage"] = permission_manage
await self._make_request(
"PUT", f"/apps/deck/api/v1.0/boards/{board_id}/acl/{acl_id}", json=json_data
)
async def delete_acl_rule(self, board_id: int, acl_id: int) -> None:
await self._make_request(
"DELETE", f"/apps/deck/api/v1.0/boards/{board_id}/acl/{acl_id}"
)
async def clone_board(
self,
board_id: int,
with_cards: bool = False,
with_assignments: bool = False,
with_labels: bool = False,
with_due_date: bool = False,
move_cards_to_left_stack: bool = False,
restore_archived_cards: bool = False,
) -> DeckBoard:
json_data = {
"withCards": with_cards,
"withAssignments": with_assignments,
"withLabels": with_labels,
"withDueDate": with_due_date,
"moveCardsToLeftStack": move_cards_to_left_stack,
"restoreArchivedCards": restore_archived_cards,
}
response = await self._make_request(
"POST", f"/apps/deck/api/v1.0/boards/{board_id}/clone", json=json_data
)
return DeckBoard(**response.json())
# Stacks
async def get_stacks(
self, board_id: int, if_modified_since: Optional[str] = None
) -> List[DeckStack]:
additional_headers = {}
if if_modified_since:
additional_headers["If-Modified-Since"] = if_modified_since
headers = self._get_deck_headers(additional_headers)
response = await self._make_request(
"GET", f"/apps/deck/api/v1.0/boards/{board_id}/stacks", headers=headers
)
return [DeckStack(**stack) for stack in response.json()]
async def get_archived_stacks(self, board_id: int) -> List[DeckStack]:
response = await self._make_request(
"GET", f"/apps/deck/api/v1.0/boards/{board_id}/stacks/archived"
)
return [DeckStack(**stack) for stack in response.json()]
async def get_stack(self, board_id: int, stack_id: int) -> DeckStack:
response = await self._make_request(
"GET", f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}"
)
return DeckStack(**response.json())
async def create_stack(self, board_id: int, title: str, order: int) -> DeckStack:
json_data = {"title": title, "order": order}
headers = self._get_deck_headers()
response = await self._make_request(
"POST",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks",
json=json_data,
headers=headers,
)
return DeckStack(**response.json())
async def update_stack(
self,
board_id: int,
stack_id: int,
title: Optional[str] = None,
order: Optional[int] = None,
) -> None:
json_data = {}
if title is not None:
json_data["title"] = title
if order is not None:
json_data["order"] = order
headers = self._get_deck_headers()
await self._make_request(
"PUT",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}",
json=json_data,
headers=headers,
)
async def delete_stack(self, board_id: int, stack_id: int) -> None:
await self._make_request(
"DELETE", f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}"
)
# Cards
async def get_card(self, board_id: int, stack_id: int, card_id: int) -> DeckCard:
headers = self._get_deck_headers()
response = await self._make_request(
"GET",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}",
headers=headers,
)
return DeckCard(**response.json())
async def create_card(
self,
board_id: int,
stack_id: int,
title: str,
type: str = "plain",
order: int = 999,
description: Optional[str] = None,
duedate: Optional[str] = None,
) -> DeckCard:
json_data = {
"title": title,
"type": type,
"order": order,
}
if description is not None:
json_data["description"] = description
if duedate is not None:
json_data["duedate"] = duedate
headers = self._get_deck_headers()
response = await self._make_request(
"POST",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards",
json=json_data,
headers=headers,
)
return DeckCard(**response.json())
async def update_card(
self,
board_id: int,
stack_id: int,
card_id: int,
title: Optional[str] = None,
description: Optional[str] = None,
type: Optional[str] = None,
owner: Optional[str] = None,
order: Optional[int] = None,
duedate: Optional[str] = None,
archived: Optional[bool] = None,
done: Optional[str] = None,
) -> None:
# First, get the current card to use existing values for required fields
current_card = await self.get_card(board_id, stack_id, card_id)
json_data = {}
if title is not None:
json_data["title"] = title
if description is not None:
json_data["description"] = description
# Type is required by the API, use provided or keep current
json_data["type"] = type if type is not None else current_card.type
# Owner is required by the API, use provided or keep current
json_data["owner"] = (
owner
if owner is not None
else (
current_card.owner
if isinstance(current_card.owner, str)
else current_card.owner.uid
if hasattr(current_card.owner, "uid")
else current_card.owner.primaryKey
)
)
if order is not None:
json_data["order"] = order
if duedate is not None:
json_data["duedate"] = duedate
if archived is not None:
json_data["archived"] = archived
if done is not None:
json_data["done"] = done
headers = self._get_deck_headers()
await self._make_request(
"PUT",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}",
json=json_data,
headers=headers,
)
async def delete_card(self, board_id: int, stack_id: int, card_id: int) -> None:
headers = self._get_deck_headers()
await self._make_request(
"DELETE",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}",
headers=headers,
)
async def archive_card(self, board_id: int, stack_id: int, card_id: int) -> None:
await self._make_request(
"PUT",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/archive",
)
async def unarchive_card(self, board_id: int, stack_id: int, card_id: int) -> None:
await self._make_request(
"PUT",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/unarchive",
)
async def assign_label_to_card(
self, board_id: int, stack_id: int, card_id: int, label_id: int
) -> None:
json_data = {"labelId": label_id}
await self._make_request(
"PUT",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/assignLabel",
json=json_data,
)
async def remove_label_from_card(
self, board_id: int, stack_id: int, card_id: int, label_id: int
) -> None:
json_data = {"labelId": label_id}
await self._make_request(
"PUT",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/removeLabel",
json=json_data,
)
async def assign_user_to_card(
self, board_id: int, stack_id: int, card_id: int, user_id: str
) -> None:
json_data = {"userId": user_id}
await self._make_request(
"PUT",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/assignUser",
json=json_data,
)
async def unassign_user_from_card(
self, board_id: int, stack_id: int, card_id: int, user_id: str
) -> None:
json_data = {"userId": user_id}
await self._make_request(
"PUT",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/unassignUser",
json=json_data,
)
async def reorder_card(
self,
board_id: int,
stack_id: int,
card_id: int,
order: int,
target_stack_id: int,
) -> None:
json_data = {"order": order, "stackId": target_stack_id}
await self._make_request(
"PUT",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/reorder",
json=json_data,
)
# Labels
async def get_label(self, board_id: int, label_id: int) -> DeckLabel:
headers = self._get_deck_headers()
response = await self._make_request(
"GET",
f"/apps/deck/api/v1.0/boards/{board_id}/labels/{label_id}",
headers=headers,
)
return DeckLabel(**response.json())
async def create_label(self, board_id: int, title: str, color: str) -> DeckLabel:
json_data = {"title": title, "color": color}
headers = self._get_deck_headers()
response = await self._make_request(
"POST",
f"/apps/deck/api/v1.0/boards/{board_id}/labels",
json=json_data,
headers=headers,
)
return DeckLabel(**response.json())
async def update_label(
self,
board_id: int,
label_id: int,
title: Optional[str] = None,
color: Optional[str] = None,
) -> None:
json_data = {}
if title is not None:
json_data["title"] = title
if color is not None:
json_data["color"] = color
await self._make_request(
"PUT",
f"/apps/deck/api/v1.0/boards/{board_id}/labels/{label_id}",
json=json_data,
)
async def delete_label(self, board_id: int, label_id: int) -> None:
await self._make_request(
"DELETE", f"/apps/deck/api/v1.0/boards/{board_id}/labels/{label_id}"
)
# Attachments
async def get_attachments(
self, board_id: int, stack_id: int, card_id: int
) -> List[DeckAttachment]:
response = await self._make_request(
"GET",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/attachments",
)
return [DeckAttachment(**attachment) for attachment in response.json()]
async def get_attachment_file(
self, board_id: int, stack_id: int, card_id: int, attachment_id: int
) -> Any:
# This endpoint returns the raw file, so we return the raw response content
response = await self._make_request(
"GET",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/attachments/{attachment_id}",
)
return response.content
async def upload_attachment(
self,
board_id: int,
stack_id: int,
card_id: int,
file_data: bytes,
file_type: str = "file",
) -> DeckAttachment:
# The API expects binary data directly, not JSON
headers = {"Content-Type": "application/octet-stream"}
params = {"type": file_type}
response = await self._make_request(
"POST",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/attachments",
headers=headers,
params=params,
data=file_data,
)
return DeckAttachment(**response.json())
async def update_attachment(
self,
board_id: int,
stack_id: int,
card_id: int,
attachment_id: int,
file_data: bytes,
file_type: str = "deck_file",
) -> DeckAttachment:
headers = {"Content-Type": "application/octet-stream"}
params = {"type": file_type}
response = await self._make_request(
"PUT",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/attachments/{attachment_id}",
headers=headers,
params=params,
data=file_data,
)
return DeckAttachment(**response.json())
async def delete_attachment(
self, board_id: int, stack_id: int, card_id: int, attachment_id: int
) -> None:
await self._make_request(
"DELETE",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/attachments/{attachment_id}",
)
async def restore_attachment(
self, board_id: int, stack_id: int, card_id: int, attachment_id: int
) -> None:
await self._make_request(
"PUT",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/attachments/{attachment_id}/restore",
)
# OCS API Endpoints (Config, Comments, Sessions)
async def get_config(self) -> DeckConfig:
headers = {"OCS-APIRequest": "true", "Accept": "application/json"}
response = await self._make_request(
"GET", "/ocs/v2.php/apps/deck/api/v1.0/config", headers=headers
)
return DeckConfig(**response.json()["ocs"]["data"])
async def set_config_value(
self, key: str, value: Any, board_id: Optional[int] = None
) -> Any:
path = f"/ocs/v2.php/apps/deck/api/v1.0/config/{key}"
if board_id:
path = f"/ocs/v2.php/apps/deck/api/v1.0/config/board:{board_id}:{key}"
json_data = {"value": value}
response = await self._make_request(
"POST",
path,
json=json_data,
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
return response.json()["ocs"]["data"]
async def get_comments(
self, card_id: int, limit: int = 20, offset: int = 0
) -> List[DeckComment]:
params = {"limit": limit, "offset": offset}
response = await self._make_request(
"GET",
f"/ocs/v2.php/apps/deck/api/v1.0/cards/{card_id}/comments",
params=params,
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
return [DeckComment(**comment) for comment in response.json()["ocs"]["data"]]
async def create_comment(
self, card_id: int, message: str, parent_id: Optional[int] = None
) -> DeckComment:
json_data = {"message": message}
if parent_id is not None:
json_data["parentId"] = parent_id
response = await self._make_request(
"POST",
f"/ocs/v2.php/apps/deck/api/v1.0/cards/{card_id}/comments",
json=json_data,
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
return DeckComment(**response.json()["ocs"]["data"])
async def update_comment(
self, card_id: int, comment_id: int, message: str
) -> DeckComment:
json_data = {"message": message}
response = await self._make_request(
"PUT",
f"/ocs/v2.php/apps/deck/api/v1.0/cards/{card_id}/comments/{comment_id}",
json=json_data,
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
return DeckComment(**response.json()["ocs"]["data"])
async def delete_comment(self, card_id: int, comment_id: int) -> None:
await self._make_request(
"DELETE",
f"/ocs/v2.php/apps/deck/api/v1.0/cards/{card_id}/comments/{comment_id}",
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
async def create_session(self, board_id: int) -> DeckSession:
json_data = {"boardId": board_id}
response = await self._make_request(
"PUT",
"/ocs/v2.php/apps/deck/api/v1.0/session/create",
json=json_data,
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
return DeckSession(**response.json()["ocs"]["data"])
async def sync_session(self, board_id: int, token: str) -> None:
json_data = {"boardId": board_id, "token": token}
await self._make_request(
"POST",
"/ocs/v2.php/apps/deck/api/v1.0/session/sync",
json=json_data,
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
async def close_session(self, board_id: int, token: str) -> None:
json_data = {"boardId": board_id, "token": token}
await self._make_request(
"POST",
"/ocs/v2.php/apps/deck/api/v1.0/session/close",
json=json_data,
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
+15 -2
View File
@@ -18,8 +18,21 @@ class NotesClient(BaseNextcloudClient):
async def get_all_notes(self) -> List[Dict[str, Any]]:
"""Get all notes."""
response = await self._make_request("GET", "/apps/notes/api/v1/notes")
return response.json()
notes = []
cursor = ""
while True:
response = await self._make_request(
"GET",
"/apps/notes/api/v1/notes",
params={"chunkSize": 50, "chunkCursor": cursor},
)
notes.extend(response.json())
if "X-Notes-Chunk-Cursor" not in response.headers:
break
cursor = response.headers["X-Notes-Chunk-Cursor"]
return notes
async def get_note(self, note_id: int) -> Dict[str, Any]:
"""Get a specific note by ID."""
+167 -13
View File
@@ -31,7 +31,7 @@ class WebDAVClient(BaseNextcloudClient):
# First try a PROPFIND to verify resource exists
propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
try:
propfind_resp = await self._client.request(
propfind_resp = await self._make_request(
"PROPFIND", webdav_path, headers=propfind_headers
)
logger.debug(
@@ -44,8 +44,7 @@ class WebDAVClient(BaseNextcloudClient):
# For other errors, continue with deletion attempt
# Proceed with deletion
response = await self._client.delete(webdav_path, headers=headers)
response.raise_for_status()
response = await self._make_request("DELETE", webdav_path, headers=headers)
logger.debug(f"Successfully deleted WebDAV resource '{path}'")
return {"status_code": response.status_code}
@@ -127,7 +126,7 @@ class WebDAVClient(BaseNextcloudClient):
# First check if we can access WebDAV at all
notes_dir_path = f"{webdav_base}/Notes"
propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
notes_dir_response = await self._client.request(
notes_dir_response = await self._make_request(
"PROPFIND", notes_dir_path, headers=propfind_headers
)
@@ -146,7 +145,7 @@ class WebDAVClient(BaseNextcloudClient):
# Ensure the parent directory exists using MKCOL
mkcol_headers = {"OCS-APIRequest": "true"}
mkcol_response = await self._client.request(
mkcol_response = await self._make_request(
"MKCOL", parent_dir_path, headers=mkcol_headers
)
@@ -158,8 +157,8 @@ class WebDAVClient(BaseNextcloudClient):
mkcol_response.raise_for_status()
# Proceed with the PUT request
response = await self._client.put(
attachment_path, content=content, headers=headers
response = await self._make_request(
"PUT", attachment_path, content=content, headers=headers
)
response.raise_for_status()
logger.debug(
@@ -190,7 +189,7 @@ class WebDAVClient(BaseNextcloudClient):
logger.debug(f"Fetching attachment '{filename}' for note {note_id}")
try:
response = await self._client.get(attachment_path)
response = await self._make_request("GET", attachment_path)
response.raise_for_status()
content = response.content
@@ -237,7 +236,7 @@ class WebDAVClient(BaseNextcloudClient):
headers = {"Depth": "1", "Content-Type": "text/xml", "OCS-APIRequest": "true"}
try:
response = await self._client.request(
response = await self._make_request(
"PROPFIND", webdav_path, content=propfind_body, headers=headers
)
response.raise_for_status()
@@ -320,7 +319,7 @@ class WebDAVClient(BaseNextcloudClient):
logger.debug(f"Reading file: {path}")
try:
response = await self._client.get(webdav_path)
response = await self._make_request("GET", webdav_path)
response.raise_for_status()
content = response.content
@@ -354,8 +353,8 @@ class WebDAVClient(BaseNextcloudClient):
headers = {"Content-Type": content_type, "OCS-APIRequest": "true"}
try:
response = await self._client.put(
webdav_path, content=content, headers=headers
response = await self._make_request(
"PUT", webdav_path, content=content, headers=headers
)
response.raise_for_status()
@@ -382,7 +381,7 @@ class WebDAVClient(BaseNextcloudClient):
headers = {"OCS-APIRequest": "true"}
try:
response = await self._client.request("MKCOL", webdav_path, headers=headers)
response = await self._make_request("MKCOL", webdav_path, headers=headers)
response.raise_for_status()
logger.debug(f"Successfully created directory '{path}'")
@@ -416,3 +415,158 @@ class WebDAVClient(BaseNextcloudClient):
except Exception as e:
logger.error(f"Unexpected error creating directory '{path}': {e}")
raise e
async def move_resource(
self, source_path: str, destination_path: str, overwrite: bool = False
) -> Dict[str, Any]:
"""Move or rename a resource (file or directory) via WebDAV MOVE.
Args:
source_path: The path of the file or directory to move
destination_path: The new path for the file or directory
overwrite: Whether to overwrite the destination if it exists
Returns:
Dict with status_code and optional message
"""
source_webdav_path = f"{self._get_webdav_base_path()}/{source_path.lstrip('/')}"
destination_webdav_path = (
f"{self._get_webdav_base_path()}/{destination_path.lstrip('/')}"
)
# Ensure paths have consistent trailing slashes for directories
if source_path.endswith("/") and not destination_path.endswith("/"):
destination_webdav_path += "/"
elif not source_path.endswith("/") and destination_path.endswith("/"):
source_webdav_path += "/"
logger.debug(f"Moving resource from '{source_path}' to '{destination_path}'")
headers = {
"OCS-APIRequest": "true",
"Destination": destination_webdav_path,
"Overwrite": "T" if overwrite else "F",
}
try:
response = await self._make_request(
"MOVE", source_webdav_path, headers=headers
)
response.raise_for_status()
logger.debug(
f"Successfully moved resource from '{source_path}' to '{destination_path}'"
)
return {"status_code": response.status_code}
except HTTPStatusError as e:
if e.response.status_code == 404:
logger.debug(f"Source resource '{source_path}' not found")
return {"status_code": 404, "message": "Source resource not found"}
elif e.response.status_code == 412:
logger.debug(
f"Destination '{destination_path}' already exists and overwrite is false"
)
return {
"status_code": 412,
"message": "Destination already exists and overwrite is false",
}
elif e.response.status_code == 409:
logger.debug(
f"Parent directory of destination '{destination_path}' doesn't exist"
)
return {
"status_code": 409,
"message": "Parent directory of destination doesn't exist",
}
logger.debug(
f"Parent directory of destination '{destination_path}' doesn't exist"
)
return {
"status_code": 409,
"message": "Parent directory of destination doesn't exist",
}
else:
logger.error(
f"HTTP error moving resource from '{source_path}' to '{destination_path}': {e}"
)
raise e
except Exception as e:
logger.error(
f"Unexpected error moving resource from '{source_path}' to '{destination_path}': {e}"
)
raise e
async def copy_resource(
self, source_path: str, destination_path: str, overwrite: bool = False
) -> Dict[str, Any]:
"""Copy a resource (file or directory) via WebDAV COPY.
Args:
source_path: The path of the file or directory to copy
destination_path: The destination path for the copy
overwrite: Whether to overwrite the destination if it exists
Returns:
Dict with status_code and optional message
"""
source_webdav_path = f"{self._get_webdav_base_path()}/{source_path.lstrip('/')}"
destination_webdav_path = (
f"{self._get_webdav_base_path()}/{destination_path.lstrip('/')}"
)
# Ensure paths have consistent trailing slashes for directories
if source_path.endswith("/") and not destination_path.endswith("/"):
destination_webdav_path += "/"
elif not source_path.endswith("/") and destination_path.endswith("/"):
source_webdav_path += "/"
logger.debug(f"Copying resource from '{source_path}' to '{destination_path}'")
headers = {
"OCS-APIRequest": "true",
"Destination": destination_webdav_path,
"Overwrite": "T" if overwrite else "F",
}
try:
response = await self._make_request(
"COPY", source_webdav_path, headers=headers
)
response.raise_for_status()
logger.debug(
f"Successfully copied resource from '{source_path}' to '{destination_path}'"
)
return {"status_code": response.status_code}
except HTTPStatusError as e:
if e.response.status_code == 404:
logger.debug(f"Source resource '{source_path}' not found")
return {"status_code": 404, "message": "Source resource not found"}
elif e.response.status_code == 412:
logger.debug(
f"Destination '{destination_path}' already exists and overwrite is false"
)
return {
"status_code": 412,
"message": "Destination already exists and overwrite is false",
}
elif e.response.status_code == 409:
logger.debug(
f"Parent directory of destination '{destination_path}' doesn't exist"
)
return {
"status_code": 409,
"message": "Parent directory of destination doesn't exist",
}
else:
logger.error(
f"HTTP error copying resource from '{source_path}' to '{destination_path}': {e}"
)
raise e
except Exception as e:
logger.error(
f"Unexpected error copying resource from '{source_path}' to '{destination_path}': {e}"
)
raise e
+140
View File
@@ -0,0 +1,140 @@
"""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,
)
# Calendar models
from .calendar import (
Calendar,
CalendarEvent,
CalendarEventSummary,
CreateEventResponse,
UpdateEventResponse,
DeleteEventResponse,
ListEventsResponse,
ListCalendarsResponse,
AvailabilitySlot,
FindAvailabilityResponse,
BulkOperationResult,
BulkOperationResponse,
CreateMeetingResponse,
UpcomingEventsResponse,
ManageCalendarResponse,
)
# Contacts models
from .contacts import (
AddressBook,
Contact,
ContactField,
ListAddressBooksResponse,
ListContactsResponse,
CreateContactResponse,
UpdateContactResponse,
DeleteContactResponse,
CreateAddressBookResponse,
DeleteAddressBookResponse,
)
# Tables models
from .tables import (
Table,
TableColumn,
TableRow,
TableView,
TableSchema,
ListTablesResponse,
GetSchemaResponse,
ReadTableResponse,
CreateRowResponse,
UpdateRowResponse,
DeleteRowResponse,
)
# WebDAV models
from .webdav import (
FileInfo,
DirectoryListing,
ReadFileResponse,
WriteFileResponse,
CreateDirectoryResponse,
DeleteResourceResponse,
)
__all__ = [
# Base models
"BaseResponse",
"IdResponse",
"StatusResponse",
# Notes models
"Note",
"NoteSearchResult",
"NotesSettings",
"CreateNoteResponse",
"UpdateNoteResponse",
"DeleteNoteResponse",
"AppendContentResponse",
"SearchNotesResponse",
# Calendar models
"Calendar",
"CalendarEvent",
"CalendarEventSummary",
"CreateEventResponse",
"UpdateEventResponse",
"DeleteEventResponse",
"ListEventsResponse",
"ListCalendarsResponse",
"AvailabilitySlot",
"FindAvailabilityResponse",
"BulkOperationResult",
"BulkOperationResponse",
"CreateMeetingResponse",
"UpcomingEventsResponse",
"ManageCalendarResponse",
# Contacts models
"AddressBook",
"Contact",
"ContactField",
"ListAddressBooksResponse",
"ListContactsResponse",
"CreateContactResponse",
"UpdateContactResponse",
"DeleteContactResponse",
"CreateAddressBookResponse",
"DeleteAddressBookResponse",
# Tables models
"Table",
"TableColumn",
"TableRow",
"TableView",
"TableSchema",
"ListTablesResponse",
"GetSchemaResponse",
"ReadTableResponse",
"CreateRowResponse",
"UpdateRowResponse",
"DeleteRowResponse",
# WebDAV models
"FileInfo",
"DirectoryListing",
"ReadFileResponse",
"WriteFileResponse",
"CreateDirectoryResponse",
"DeleteResourceResponse",
]
+48
View File
@@ -0,0 +1,48 @@
"""Base Pydantic models for common response patterns."""
from datetime import datetime, timezone
from typing import Optional, Union
from pydantic import BaseModel, Field, field_serializer
def _utc_now() -> datetime:
"""Generate UTC timestamp for responses."""
return datetime.now(timezone.utc)
class BaseResponse(BaseModel):
"""Base response model for all MCP tool responses."""
success: bool = Field(
default=True, description="Whether the operation was successful"
)
timestamp: datetime = Field(
default_factory=_utc_now, description="Response timestamp"
)
@field_serializer("timestamp")
def serialize_timestamp(self, timestamp: datetime) -> str:
"""Serialize timestamp to RFC3339 format for MCP compliance."""
if timestamp.tzinfo is None:
# If somehow we get a naive datetime, assume UTC
timestamp = timestamp.replace(tzinfo=timezone.utc)
# Use isoformat() which produces RFC3339 compliant format
# For UTC times, replace '+00:00' with 'Z' as preferred by many systems
iso_string = timestamp.isoformat()
if iso_string.endswith("+00:00"):
return iso_string[:-6] + "Z"
return iso_string
class IdResponse(BaseResponse):
"""Response model for operations that return a new ID."""
id: Union[int, str] = Field(description="ID of the created or affected resource")
class StatusResponse(BaseResponse):
"""Response model for operations that return just a status."""
status_code: Optional[int] = Field(None, description="HTTP status code")
message: Optional[str] = Field(None, description="Status message")
+182
View File
@@ -0,0 +1,182 @@
"""Pydantic models for Calendar app responses."""
from typing import List, Optional
from pydantic import BaseModel, Field
from .base import BaseResponse, StatusResponse
class Calendar(BaseModel):
"""Model for a Nextcloud calendar."""
name: str = Field(description="Calendar name/ID")
display_name: str = Field(description="Calendar display name")
description: Optional[str] = Field(None, description="Calendar description")
color: Optional[str] = Field(None, description="Calendar color")
href: Optional[str] = Field(None, description="Calendar DAV href")
timezone: Optional[str] = Field(None, description="Calendar timezone")
enabled: bool = Field(default=True, description="Whether calendar is enabled")
ctag: Optional[str] = Field(None, description="Calendar tag for synchronization")
class CalendarEventSummary(BaseModel):
"""Model for calendar event summary (for lists)."""
uid: str = Field(description="Event UID")
summary: str = Field(description="Event summary/title")
start: str = Field(description="Event start datetime (ISO format)")
end: Optional[str] = Field(None, description="Event end datetime (ISO format)")
all_day: bool = Field(default=False, description="Whether event is all-day")
location: Optional[str] = Field(None, description="Event location")
description: Optional[str] = Field(None, description="Event description")
categories: List[str] = Field(default_factory=list, description="Event categories")
status: Optional[str] = Field(
None, description="Event status (CONFIRMED, TENTATIVE, CANCELLED)"
)
class CalendarEvent(CalendarEventSummary):
"""Model for a complete calendar event."""
created: Optional[str] = Field(None, description="Event creation datetime")
last_modified: Optional[str] = Field(None, description="Last modification datetime")
recurring: bool = Field(default=False, description="Whether event is recurring")
recurrence_rule: Optional[str] = Field(None, description="RFC5545 recurrence rule")
recurrence_end: Optional[str] = Field(None, description="Recurrence end date")
attendees: List[str] = Field(
default_factory=list, description="List of attendee email addresses"
)
organizer: Optional[str] = Field(None, description="Event organizer")
priority: Optional[int] = Field(None, description="Event priority (1-9)")
privacy: Optional[str] = Field(None, description="Event privacy level")
url: Optional[str] = Field(None, description="Event URL")
duration_minutes: Optional[int] = Field(
None, description="Event duration in minutes"
)
reminder_minutes: Optional[int] = Field(
None, description="Reminder time in minutes before event"
)
reminder_email: bool = Field(
default=False, description="Whether to send email reminder"
)
color: Optional[str] = Field(None, description="Event color")
etag: Optional[str] = Field(None, description="ETag for versioning")
class CreateEventResponse(BaseResponse):
"""Response model for event creation."""
event: CalendarEvent = Field(description="The created event")
calendar_name: str = Field(
description="Name of the calendar the event was created in"
)
class UpdateEventResponse(BaseResponse):
"""Response model for event updates."""
event: CalendarEvent = Field(description="The updated event")
calendar_name: str = Field(description="Name of the calendar the event belongs to")
class DeleteEventResponse(StatusResponse):
"""Response model for event deletion."""
deleted_uid: str = Field(description="UID of the deleted event")
calendar_name: str = Field(
description="Name of the calendar the event was deleted from"
)
class ListEventsResponse(BaseResponse):
"""Response model for listing events."""
events: List[CalendarEventSummary] = Field(description="List of events")
calendar_name: Optional[str] = Field(
None, description="Calendar name (if filtered to one calendar)"
)
start_date: Optional[str] = Field(None, description="Start date filter applied")
end_date: Optional[str] = Field(None, description="End date filter applied")
total_found: int = Field(description="Total number of events found")
class ListCalendarsResponse(BaseResponse):
"""Response model for listing calendars."""
calendars: List[Calendar] = Field(description="List of available calendars")
total_count: int = Field(description="Total number of calendars")
class AvailabilitySlot(BaseModel):
"""Model for an available time slot."""
start: str = Field(description="Slot start datetime (ISO format)")
end: str = Field(description="Slot end datetime (ISO format)")
duration_minutes: int = Field(description="Slot duration in minutes")
date: str = Field(description="Date of the slot (YYYY-MM-DD)")
class FindAvailabilityResponse(BaseResponse):
"""Response model for finding availability."""
available_slots: List[AvailabilitySlot] = Field(
description="List of available time slots"
)
duration_requested: int = Field(description="Requested duration in minutes")
date_range_start: str = Field(description="Start date of search range")
date_range_end: str = Field(description="End date of search range")
attendees_checked: List[str] = Field(
default_factory=list, description="Attendees checked for availability"
)
business_hours_only: bool = Field(
description="Whether search was limited to business hours"
)
class BulkOperationResult(BaseModel):
"""Model for bulk operation results."""
operation: str = Field(description="Operation performed (update, delete, move)")
events_processed: int = Field(description="Number of events processed")
events_successful: int = Field(
description="Number of events successfully processed"
)
events_failed: int = Field(description="Number of events that failed processing")
failed_events: List[str] = Field(
default_factory=list, description="UIDs of events that failed"
)
errors: List[str] = Field(default_factory=list, description="Error messages")
class BulkOperationResponse(BaseResponse):
"""Response model for bulk operations."""
result: BulkOperationResult = Field(description="Bulk operation result")
class CreateMeetingResponse(CreateEventResponse):
"""Response model for meeting creation (same as event creation)."""
pass
class UpcomingEventsResponse(BaseResponse):
"""Response model for upcoming events."""
events: List[CalendarEventSummary] = Field(description="List of upcoming events")
days_ahead: int = Field(description="Number of days ahead searched")
calendar_name: Optional[str] = Field(
None, description="Calendar name (if filtered to one calendar)"
)
class ManageCalendarResponse(BaseResponse):
"""Response model for calendar management operations."""
action: str = Field(description="Action performed (create, delete, update, list)")
calendar: Optional[Calendar] = Field(None, description="Calendar that was affected")
calendars: Optional[List[Calendar]] = Field(
None, description="List of calendars (for list action)"
)
message: str = Field(description="Success message")
+130
View File
@@ -0,0 +1,130 @@
"""Pydantic models for Contacts app responses."""
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
from .base import BaseResponse, StatusResponse
class AddressBook(BaseModel):
"""Model for a Nextcloud address book."""
uri: str = Field(description="Address book URI")
displayname: str = Field(description="Address book display name")
description: Optional[str] = Field(None, description="Address book description")
ctag: Optional[str] = Field(
None, description="Address book tag for synchronization"
)
class ContactField(BaseModel):
"""Model for a contact field (email, phone, etc.)."""
type: str = Field(description="Field type (e.g., 'email', 'phone', 'address')")
value: str = Field(description="Field value")
label: Optional[str] = Field(None, description="Field label (e.g., 'work', 'home')")
preferred: bool = Field(
default=False, description="Whether this is the preferred field of this type"
)
class Contact(BaseModel):
"""Model for a Nextcloud contact."""
uid: str = Field(description="Contact UID")
fn: str = Field(description="Full name (formatted name)")
given_name: Optional[str] = Field(None, description="Given name")
family_name: Optional[str] = Field(None, description="Family name")
organization: Optional[str] = Field(None, description="Organization")
title: Optional[str] = Field(None, description="Job title")
emails: List[ContactField] = Field(
default_factory=list, description="Email addresses"
)
phones: List[ContactField] = Field(
default_factory=list, description="Phone numbers"
)
addresses: List[ContactField] = Field(default_factory=list, description="Addresses")
urls: List[ContactField] = Field(default_factory=list, description="URLs")
note: Optional[str] = Field(None, description="Notes")
photo: Optional[str] = Field(None, description="Photo URL or base64 data")
birthday: Optional[str] = Field(None, description="Birthday (ISO date format)")
categories: List[str] = Field(
default_factory=list, description="Contact categories"
)
custom_fields: Dict[str, Any] = Field(
default_factory=dict, description="Custom fields"
)
etag: Optional[str] = Field(None, description="ETag for versioning")
@property
def primary_email(self) -> Optional[str]:
"""Get the primary email address."""
if not self.emails:
return None
# Return preferred email if available, otherwise first email
preferred = next(
(email.value for email in self.emails if email.preferred), None
)
return preferred or self.emails[0].value
@property
def primary_phone(self) -> Optional[str]:
"""Get the primary phone number."""
if not self.phones:
return None
# Return preferred phone if available, otherwise first phone
preferred = next(
(phone.value for phone in self.phones if phone.preferred), None
)
return preferred or self.phones[0].value
class ListAddressBooksResponse(BaseResponse):
"""Response model for listing address books."""
addressbooks: List[AddressBook] = Field(
description="List of available address books"
)
total_count: int = Field(description="Total number of address books")
class ListContactsResponse(BaseResponse):
"""Response model for listing contacts."""
contacts: List[Contact] = Field(description="List of contacts")
addressbook: str = Field(description="Address book name")
total_count: int = Field(description="Total number of contacts")
class CreateContactResponse(BaseResponse):
"""Response model for contact creation."""
contact: Contact = Field(description="The created contact")
addressbook: str = Field(description="Address book the contact was created in")
class UpdateContactResponse(BaseResponse):
"""Response model for contact updates."""
contact: Contact = Field(description="The updated contact")
addressbook: str = Field(description="Address book the contact belongs to")
class DeleteContactResponse(StatusResponse):
"""Response model for contact deletion."""
deleted_uid: str = Field(description="UID of the deleted contact")
addressbook: str = Field(description="Address book the contact was deleted from")
class CreateAddressBookResponse(BaseResponse):
"""Response model for address book creation."""
addressbook: AddressBook = Field(description="The created address book")
class DeleteAddressBookResponse(StatusResponse):
"""Response model for address book deletion."""
deleted_name: str = Field(description="Name of the deleted address book")
+268
View File
@@ -0,0 +1,268 @@
from datetime import datetime
from typing import List, Optional, Dict, Any, Union
from pydantic import BaseModel, Field, field_validator
from .base import BaseResponse, StatusResponse
class DeckUser(BaseModel):
primaryKey: str
uid: str
displayname: str
class DeckPermissions(BaseModel):
PERMISSION_READ: bool
PERMISSION_EDIT: bool
PERMISSION_MANAGE: bool
PERMISSION_SHARE: bool
class DeckLabel(BaseModel):
id: int
title: str
color: str
boardId: Optional[int] = None
cardId: Optional[int] = None
class DeckACL(BaseModel):
id: int
participant: DeckUser
type: int
boardId: int
permissionEdit: bool
permissionShare: bool
permissionManage: bool
owner: bool
class DeckBoardSettings(BaseModel):
calendar: bool
cardDetailsInModal: Optional[bool] = Field(default=None, alias="cardDetailsInModal")
cardIdBadge: Optional[bool] = Field(default=None, alias="cardIdBadge")
groupLimit: Optional[List[Dict[str, str]]] = Field(default=None, alias="groupLimit")
notify_due: Optional[str] = Field(default=None, alias="notify-due")
class DeckBoard(BaseModel):
id: int
title: str
owner: DeckUser
color: str
archived: bool
labels: List[DeckLabel]
acl: List[DeckACL]
permissions: DeckPermissions
users: List[DeckUser]
deletedAt: int
lastModified: Optional[int] = None
settings: Optional[DeckBoardSettings] = None
etag: Optional[str] = Field(default=None, alias="ETag")
@field_validator("settings", mode="before")
@classmethod
def validate_settings(cls, v):
# Handle case where API returns empty array instead of dict/null
if isinstance(v, list) and len(v) == 0:
return None
return v
class DeckAssignedUser(BaseModel):
id: int
participant: DeckUser
cardId: int
type: int
class DeckCard(BaseModel):
id: int
title: str
stackId: int
type: str
order: int
archived: bool
owner: Union[str, DeckUser] # Can be either string or user object
description: Optional[str] = None
duedate: Optional[datetime] = None
done: Optional[datetime] = None
lastModified: Optional[int] = None
createdAt: Optional[int] = None
labels: Optional[List[DeckLabel]] = None
assignedUsers: Optional[List[Union[DeckUser, DeckAssignedUser]]] = None
attachments: Optional[List[Any]] = None # Define a proper Attachment model later
attachmentCount: Optional[int] = None
deletedAt: Optional[int] = None
commentsUnread: Optional[int] = None
overdue: Optional[int] = None
etag: Optional[str] = Field(default=None, alias="ETag")
@field_validator("owner", mode="before")
@classmethod
def validate_owner(cls, v):
# Handle case where API returns user object instead of string
if isinstance(v, dict):
return v.get("uid", v.get("primaryKey", str(v)))
return v
@field_validator("assignedUsers", mode="before")
@classmethod
def validate_assigned_users(cls, v):
# Handle different formats of assigned users from the API
if not v:
return v
validated_users = []
for user in v:
if isinstance(user, dict):
# Check if it's an assignment object with participant
if "participant" in user:
validated_users.append(user)
# Check if it's a direct user object
elif "uid" in user or "primaryKey" in user:
validated_users.append(user)
else:
validated_users.append(user)
return validated_users
class DeckStack(BaseModel):
id: int
title: str
boardId: int
order: int
deletedAt: int
lastModified: Optional[int] = None
cards: Optional[List[DeckCard]] = None
etag: Optional[str] = Field(default=None, alias="ETag")
class DeckAttachmentExtendedData(BaseModel):
filesize: int
mimetype: str
info: Dict[str, str]
class DeckAttachment(BaseModel):
id: int
cardId: int
type: str
data: str
lastModified: int
createdAt: int
createdBy: str
deletedAt: int
extendedData: DeckAttachmentExtendedData
class DeckComment(BaseModel):
id: int
objectId: int
message: str
actorId: str
actorType: str
actorDisplayName: str
creationDateTime: datetime
mentions: List[Dict[str, str]]
replyTo: Optional[Any] = None # Self-referencing, handle later if needed
class DeckSession(BaseModel):
token: str
class DeckConfig(BaseModel):
calendar: bool
cardDetailsInModal: bool
cardIdBadge: bool
groupLimit: Optional[List[Dict[str, str]]] = None
# Response Models for MCP Tools
class ListBoardsResponse(BaseResponse):
"""Response model for listing deck boards."""
boards: List[DeckBoard] = Field(description="List of deck boards")
total: int = Field(description="Total number of boards")
class CreateBoardResponse(BaseResponse):
"""Response model for board creation."""
id: int = Field(description="The created board ID")
title: str = Field(description="The created board title")
color: str = Field(description="The created board color")
class BoardOperationResponse(StatusResponse):
"""Response model for board operations like update/delete."""
board_id: int = Field(description="ID of the affected board")
# Stack Response Models
class ListStacksResponse(BaseResponse):
"""Response model for listing deck stacks."""
stacks: List[DeckStack] = Field(description="List of deck stacks")
total: int = Field(description="Total number of stacks")
class CreateStackResponse(BaseResponse):
"""Response model for stack creation."""
id: int = Field(description="The created stack ID")
title: str = Field(description="The created stack title")
order: int = Field(description="The created stack order")
class StackOperationResponse(StatusResponse):
"""Response model for stack operations like update/delete."""
stack_id: int = Field(description="ID of the affected stack")
board_id: int = Field(description="ID of the board containing the stack")
# Card Response Models
class CreateCardResponse(BaseResponse):
"""Response model for card creation."""
id: int = Field(description="The created card ID")
title: str = Field(description="The created card title")
description: Optional[str] = Field(description="The created card description")
stackId: int = Field(description="The stack ID the card belongs to")
class CardOperationResponse(StatusResponse):
"""Response model for card operations like update/delete."""
card_id: int = Field(description="ID of the affected card")
stack_id: int = Field(description="ID of the stack containing the card")
board_id: int = Field(description="ID of the board containing the card")
# Label Response Models
class CreateLabelResponse(BaseResponse):
"""Response model for label creation."""
id: int = Field(description="The created label ID")
title: str = Field(description="The created label title")
color: str = Field(description="The created label color")
class LabelOperationResponse(StatusResponse):
"""Response model for label operations like update/delete."""
label_id: int = Field(description="ID of the affected label")
board_id: int = Field(description="ID of the board containing the label")
+85
View File
@@ -0,0 +1,85 @@
"""Pydantic models for Notes app responses."""
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
from .base import BaseResponse, IdResponse, StatusResponse
class Note(BaseModel):
"""Model for a Nextcloud note."""
id: int = Field(description="Note ID")
title: str = Field(description="Note title")
content: str = Field(description="Note content in markdown")
category: str = Field(default="", description="Note category")
modified: int = Field(description="Unix timestamp of last modification")
favorite: bool = Field(
default=False, description="Whether note is marked as favorite"
)
etag: str = Field(description="ETag for versioning")
readonly: bool = Field(default=False, description="Whether note is read-only")
@property
def modified_datetime(self) -> datetime:
"""Convert Unix timestamp to datetime."""
return datetime.fromtimestamp(self.modified)
class NoteSearchResult(BaseModel):
"""Model for note search results (limited fields)."""
id: int = Field(description="Note ID")
title: str = Field(description="Note title")
category: str = Field(default="", description="Note category")
score: Optional[float] = Field(None, description="Search relevance score")
class NotesSettings(BaseModel):
"""Model for Notes app settings."""
notesPath: str = Field(description="Path to notes directory")
fileSuffix: str = Field(description="File suffix for notes")
noteMode: str = Field(description="Note mode setting")
class CreateNoteResponse(IdResponse):
"""Response model for note creation."""
title: str = Field(description="The created note title")
category: str = Field(description="The created note category")
etag: str = Field(description="Current ETag for the created note")
class UpdateNoteResponse(BaseResponse):
"""Response model for note updates."""
id: int = Field(description="The updated note ID")
title: str = Field(description="The updated note title")
category: str = Field(description="The updated note category")
etag: str = Field(description="Current ETag for the updated note")
class DeleteNoteResponse(StatusResponse):
"""Response model for note deletion."""
deleted_id: int = Field(description="ID of the deleted note")
class AppendContentResponse(BaseResponse):
"""Response model for appending content to a note."""
id: int = Field(description="The updated note ID")
title: str = Field(description="The updated note title")
category: str = Field(description="The updated note category")
etag: str = Field(description="Current ETag for the updated note")
class SearchNotesResponse(BaseResponse):
"""Response model for note search."""
results: List[NoteSearchResult] = Field(description="Search results")
query: str = Field(description="The search query used")
total_found: int = Field(description="Total number of notes found")
+142
View File
@@ -0,0 +1,142 @@
"""Pydantic models for Tables app responses."""
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
from .base import BaseResponse, IdResponse, StatusResponse
class TableColumn(BaseModel):
"""Model for a table column definition."""
id: int = Field(description="Column ID")
title: str = Field(description="Column title")
type: str = Field(description="Column type (text, number, datetime, etc.)")
subtype: Optional[str] = Field(None, description="Column subtype")
mandatory: bool = Field(default=False, description="Whether column is mandatory")
description: Optional[str] = Field(None, description="Column description")
text_default: Optional[str] = Field(None, description="Default text value")
text_allowed_pattern: Optional[str] = Field(
None, description="Allowed text pattern"
)
text_max_length: Optional[int] = Field(None, description="Maximum text length")
number_default: Optional[float] = Field(None, description="Default number value")
number_min: Optional[float] = Field(None, description="Minimum number value")
number_max: Optional[float] = Field(None, description="Maximum number value")
number_decimals: Optional[int] = Field(None, description="Number of decimal places")
datetime_default: Optional[str] = Field(None, description="Default datetime value")
selection_options: List[str] = Field(
default_factory=list, description="Selection options"
)
selection_default: Optional[str] = Field(
None, description="Default selection value"
)
class TableRow(BaseModel):
"""Model for a table row."""
id: int = Field(description="Row ID")
created_by: Optional[str] = Field(None, description="User who created the row")
created_at: Optional[str] = Field(None, description="Row creation timestamp")
last_edit_by: Optional[str] = Field(
None, description="User who last edited the row"
)
last_edit_at: Optional[str] = Field(None, description="Last edit timestamp")
data: Dict[int, Any] = Field(description="Row data keyed by column ID")
class TableView(BaseModel):
"""Model for a table view."""
id: int = Field(description="View ID")
title: str = Field(description="View title")
emoji: Optional[str] = Field(None, description="View emoji")
description: Optional[str] = Field(None, description="View description")
columns: List[int] = Field(
default_factory=list, description="List of column IDs in this view"
)
sort: List[Dict[str, Any]] = Field(
default_factory=list, description="Sort configuration"
)
filter: List[Dict[str, Any]] = Field(
default_factory=list, description="Filter configuration"
)
class Table(BaseModel):
"""Model for a Nextcloud table."""
id: int = Field(description="Table ID")
title: str = Field(description="Table title")
emoji: Optional[str] = Field(None, description="Table emoji")
ownership: str = Field(description="Table ownership")
owner_display_name: str = Field(description="Display name of table owner")
created_by: Optional[str] = Field(None, description="User who created the table")
created_at: Optional[str] = Field(None, description="Table creation timestamp")
last_edit_by: Optional[str] = Field(
None, description="User who last edited the table"
)
last_edit_at: Optional[str] = Field(None, description="Last edit timestamp")
row_count: int = Field(default=0, description="Number of rows in the table")
has_shares: bool = Field(default=False, description="Whether table is shared")
archived: bool = Field(default=False, description="Whether table is archived")
is_shared: bool = Field(
default=False, description="Whether table is shared with current user"
)
on_share_permissions: Optional[Dict[str, Any]] = Field(
None, description="Share permissions"
)
class TableSchema(BaseModel):
"""Model for complete table schema including columns and views."""
table: Table = Field(description="Table information")
columns: List[TableColumn] = Field(description="Table columns")
views: List[TableView] = Field(description="Table views")
class ListTablesResponse(BaseResponse):
"""Response model for listing tables."""
tables: List[Table] = Field(description="List of available tables")
total_count: int = Field(description="Total number of tables")
class GetSchemaResponse(BaseResponse):
"""Response model for getting table schema."""
table_schema: TableSchema = Field(description="Table schema information")
class ReadTableResponse(BaseResponse):
"""Response model for reading table rows."""
rows: List[TableRow] = Field(description="Table rows")
table_id: int = Field(description="Table ID")
total_count: Optional[int] = Field(
None, description="Total number of rows (if known)"
)
offset: Optional[int] = Field(None, description="Offset used for pagination")
limit: Optional[int] = Field(None, description="Limit used for pagination")
class CreateRowResponse(IdResponse):
"""Response model for row creation."""
row: TableRow = Field(description="The created row")
table_id: int = Field(description="Table ID the row was created in")
class UpdateRowResponse(BaseResponse):
"""Response model for row updates."""
row: TableRow = Field(description="The updated row")
class DeleteRowResponse(StatusResponse):
"""Response model for row deletion."""
deleted_id: int = Field(description="ID of the deleted row")
+108
View File
@@ -0,0 +1,108 @@
"""Pydantic models for WebDAV responses."""
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
from .base import BaseResponse, StatusResponse
class FileInfo(BaseModel):
"""Model for file/directory information."""
name: str = Field(description="File/directory name")
path: str = Field(description="Full path")
is_directory: bool = Field(description="Whether this is a directory")
size: Optional[int] = Field(
None, description="File size in bytes (None for directories)"
)
content_type: Optional[str] = Field(None, description="MIME content type")
last_modified: Optional[str] = Field(
None, description="Last modification time (ISO format)"
)
etag: Optional[str] = Field(None, description="ETag for versioning")
@property
def last_modified_datetime(self) -> Optional[datetime]:
"""Convert last modified string to datetime."""
if not self.last_modified:
return None
try:
return datetime.fromisoformat(self.last_modified.replace("Z", "+00:00"))
except (ValueError, AttributeError):
return None
class DirectoryListing(BaseResponse):
"""Response model for directory listings."""
path: str = Field(description="Directory path")
items: List[FileInfo] = Field(description="Files and directories in the path")
total_count: int = Field(description="Total number of items")
directories_count: int = Field(description="Number of directories")
files_count: int = Field(description="Number of files")
total_size: int = Field(default=0, description="Total size of all files in bytes")
class ReadFileResponse(BaseResponse):
"""Response model for reading file contents."""
path: str = Field(description="File path")
content: str = Field(description="File content (text or base64 for binary)")
content_type: str = Field(description="MIME content type")
size: int = Field(description="File size in bytes")
encoding: Optional[str] = Field(
None, description="Encoding used (e.g., 'base64' for binary files)"
)
etag: Optional[str] = Field(None, description="ETag for versioning")
last_modified: Optional[str] = Field(None, description="Last modification time")
class WriteFileResponse(StatusResponse):
"""Response model for writing files."""
path: str = Field(description="File path that was written")
size: Optional[int] = Field(None, description="Size of the written file")
created: bool = Field(description="Whether a new file was created (vs overwritten)")
class CreateDirectoryResponse(StatusResponse):
"""Response model for directory creation."""
path: str = Field(description="Directory path that was created")
created: bool = Field(
description="Whether directory was created or already existed"
)
class DeleteResourceResponse(StatusResponse):
"""Response model for resource deletion."""
path: str = Field(description="Path that was deleted")
was_directory: bool = Field(
description="Whether the deleted resource was a directory"
)
items_deleted: Optional[int] = Field(
None, description="Number of items deleted (for directories)"
)
class MoveResourceResponse(StatusResponse):
"""Response model for resource move/rename operations."""
source_path: str = Field(description="Original path of the resource")
destination_path: str = Field(description="New path of the resource")
overwrite: bool = Field(
description="Whether the destination was overwritten if it existed"
)
class CopyResourceResponse(StatusResponse):
"""Response model for resource copy operations."""
source_path: str = Field(description="Original path of the resource")
destination_path: str = Field(description="Destination path for the copy")
overwrite: bool = Field(
description="Whether the destination was overwritten if it existed"
)
+3 -1
View File
@@ -3,11 +3,13 @@ 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",
"configure_contacts_tools",
"configure_deck_tools",
"configure_notes_tools",
"configure_tables_tools",
"configure_webdav_tools",
"configure_contacts_tools",
]
+9 -2
View File
@@ -5,6 +5,10 @@ from typing import Optional
from mcp.server.fastmcp import Context, FastMCP
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.models.calendar import (
Calendar,
ListCalendarsResponse,
)
logger = logging.getLogger(__name__)
@@ -12,10 +16,13 @@ logger = logging.getLogger(__name__)
def configure_calendar_tools(mcp: FastMCP):
# Calendar tools
@mcp.tool()
async def nc_calendar_list_calendars(ctx: Context):
async def nc_calendar_list_calendars(ctx: Context) -> ListCalendarsResponse:
"""List all available calendars for the user"""
client: NextcloudClient = ctx.request_context.lifespan_context.client
return await client.calendar.list_calendars()
calendars_data = await client.calendar.list_calendars()
calendars = [Calendar(**cal_data) for cal_data in calendars_data]
return ListCalendarsResponse(calendars=calendars, total_count=len(calendars))
@mcp.tool()
async def nc_calendar_create_event(
+18 -1
View File
@@ -17,7 +17,7 @@ def configure_contacts_tools(mcp: FastMCP):
@mcp.tool()
async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str):
"""List all addressbooks for the user."""
"""List all contacts in the specified addressbook."""
client: NextcloudClient = ctx.request_context.lifespan_context.client
return await client.contacts.list_contacts(addressbook=addressbook)
@@ -63,3 +63,20 @@ def configure_contacts_tools(mcp: FastMCP):
"""Delete a contact."""
client: NextcloudClient = ctx.request_context.lifespan_context.client
return await client.contacts.delete_contact(addressbook=addressbook, uid=uid)
@mcp.tool()
async def nc_contacts_update_contact(
ctx: Context, *, addressbook: str, uid: str, contact_data: dict, etag: str = ""
):
"""Update an existing contact while preserving all existing properties.
Args:
addressbook: The name of the addressbook containing the contact.
uid: The unique ID of the contact to update.
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
return await client.contacts.update_contact(
addressbook=addressbook, uid=uid, contact_data=contact_data, etag=etag
)
+494
View File
@@ -0,0 +1,494 @@
import logging
from typing import Optional
from mcp.server.fastmcp import Context, FastMCP
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.models.deck import (
CreateBoardResponse,
CreateStackResponse,
StackOperationResponse,
CreateCardResponse,
CardOperationResponse,
CreateLabelResponse,
LabelOperationResponse,
)
logger = logging.getLogger(__name__)
def configure_deck_tools(mcp: FastMCP):
"""Configure Nextcloud Deck tools and resources for the MCP server."""
# Resources
@mcp.resource("nc://Deck/boards")
async def deck_boards_resource():
"""List all Nextcloud Deck boards"""
ctx: Context = mcp.get_context()
client: NextcloudClient = ctx.request_context.lifespan_context.client
boards = await client.deck.get_boards()
return [board.model_dump() for board in boards]
@mcp.resource("nc://Deck/boards/{board_id}")
async def deck_board_resource(board_id: int):
"""Get details of a specific Nextcloud Deck board"""
ctx: Context = mcp.get_context()
client: NextcloudClient = ctx.request_context.lifespan_context.client
board = await client.deck.get_board(board_id)
return board.model_dump()
@mcp.resource("nc://Deck/boards/{board_id}/stacks")
async def deck_stacks_resource(board_id: int):
"""List all stacks in a Nextcloud Deck board"""
ctx: Context = mcp.get_context()
client: NextcloudClient = ctx.request_context.lifespan_context.client
stacks = await client.deck.get_stacks(board_id)
return [stack.model_dump() for stack in stacks]
@mcp.resource("nc://Deck/boards/{board_id}/stacks/{stack_id}")
async def deck_stack_resource(board_id: int, stack_id: int):
"""Get details of a specific Nextcloud Deck stack"""
ctx: Context = mcp.get_context()
client: NextcloudClient = ctx.request_context.lifespan_context.client
stack = await client.deck.get_stack(board_id, stack_id)
return stack.model_dump()
@mcp.resource("nc://Deck/boards/{board_id}/stacks/{stack_id}/cards")
async def deck_cards_resource(board_id: int, stack_id: int):
"""List all cards in a Nextcloud Deck stack"""
ctx: Context = mcp.get_context()
client: NextcloudClient = ctx.request_context.lifespan_context.client
stack = await client.deck.get_stack(board_id, stack_id)
if stack.cards:
return [card.model_dump() for card in stack.cards]
return []
@mcp.resource("nc://Deck/boards/{board_id}/stacks/{stack_id}/cards/{card_id}")
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()
client: NextcloudClient = ctx.request_context.lifespan_context.client
card = await client.deck.get_card(board_id, stack_id, card_id)
return card.model_dump()
@mcp.resource("nc://Deck/boards/{board_id}/labels")
async def deck_labels_resource(board_id: int):
"""List all labels in a Nextcloud Deck board"""
ctx: Context = mcp.get_context()
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]
@mcp.resource("nc://Deck/boards/{board_id}/labels/{label_id}")
async def deck_label_resource(board_id: int, label_id: int):
"""Get details of a specific Nextcloud Deck label"""
ctx: Context = mcp.get_context()
client: NextcloudClient = ctx.request_context.lifespan_context.client
label = await client.deck.get_label(board_id, label_id)
return label.model_dump()
# Tools
@mcp.tool()
async def deck_create_board(
ctx: Context, title: str, color: str
) -> CreateBoardResponse:
"""Create a new Nextcloud Deck board
Args:
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
board = await client.deck.create_board(title, color)
return CreateBoardResponse(id=board.id, title=board.title, color=board.color)
# Stack Tools
@mcp.tool()
async def deck_create_stack(
ctx: Context, board_id: int, title: str, order: int
) -> CreateStackResponse:
"""Create a new stack in a Nextcloud Deck board
Args:
board_id: The ID of the board
title: The title of the new stack
order: Order for sorting the stacks
"""
client: NextcloudClient = ctx.request_context.lifespan_context.client
stack = await client.deck.create_stack(board_id, title, order)
return CreateStackResponse(id=stack.id, title=stack.title, order=stack.order)
@mcp.tool()
async def deck_update_stack(
ctx: Context,
board_id: int,
stack_id: int,
title: Optional[str] = None,
order: Optional[int] = None,
) -> StackOperationResponse:
"""Update a Nextcloud Deck stack
Args:
board_id: The ID of the board
stack_id: The ID of the stack
title: New title for the stack
order: New order for the stack
"""
client: NextcloudClient = ctx.request_context.lifespan_context.client
await client.deck.update_stack(board_id, stack_id, title, order)
return StackOperationResponse(
success=True,
message="Stack updated successfully",
stack_id=stack_id,
board_id=board_id,
)
@mcp.tool()
async def deck_delete_stack(
ctx: Context, board_id: int, stack_id: int
) -> StackOperationResponse:
"""Delete a Nextcloud Deck stack
Args:
board_id: The ID of the board
stack_id: The ID of the stack
"""
client: NextcloudClient = ctx.request_context.lifespan_context.client
await client.deck.delete_stack(board_id, stack_id)
return StackOperationResponse(
success=True,
message="Stack deleted successfully",
stack_id=stack_id,
board_id=board_id,
)
# Card Tools
@mcp.tool()
async def deck_create_card(
ctx: Context,
board_id: int,
stack_id: int,
title: str,
type: str = "plain",
order: int = 999,
description: Optional[str] = None,
duedate: Optional[str] = None,
) -> CreateCardResponse:
"""Create a new card in a Nextcloud Deck stack
Args:
board_id: The ID of the board
stack_id: The ID of the stack
title: The title of the new card
type: Type of the card (default: plain)
order: Order for sorting the cards
description: Description of the card
duedate: Due date of the card (ISO-8601 format)
"""
client: NextcloudClient = ctx.request_context.lifespan_context.client
card = await client.deck.create_card(
board_id, stack_id, title, type, order, description, duedate
)
return CreateCardResponse(
id=card.id,
title=card.title,
description=card.description,
stackId=card.stackId,
)
@mcp.tool()
async def deck_update_card(
ctx: Context,
board_id: int,
stack_id: int,
card_id: int,
title: Optional[str] = None,
description: Optional[str] = None,
type: Optional[str] = None,
owner: Optional[str] = None,
order: Optional[int] = None,
duedate: Optional[str] = None,
archived: Optional[bool] = None,
done: Optional[str] = None,
) -> CardOperationResponse:
"""Update a Nextcloud Deck card
Args:
board_id: The ID of the board
stack_id: The ID of the stack
card_id: The ID of the card
title: New title for the card
description: New description for the card
type: New type for the card
owner: New owner for the card
order: New order for the card
duedate: New due date for the card (ISO-8601 format)
archived: Whether the card should be archived
done: Completion date for the card (ISO-8601 format)
"""
client: NextcloudClient = ctx.request_context.lifespan_context.client
await client.deck.update_card(
board_id,
stack_id,
card_id,
title,
description,
type,
owner,
order,
duedate,
archived,
done,
)
return CardOperationResponse(
success=True,
message="Card updated successfully",
card_id=card_id,
stack_id=stack_id,
board_id=board_id,
)
@mcp.tool()
async def deck_delete_card(
ctx: Context, board_id: int, stack_id: int, card_id: int
) -> CardOperationResponse:
"""Delete a Nextcloud Deck card
Args:
board_id: The ID of the board
stack_id: The ID of the stack
card_id: The ID of the card
"""
client: NextcloudClient = ctx.request_context.lifespan_context.client
await client.deck.delete_card(board_id, stack_id, card_id)
return CardOperationResponse(
success=True,
message="Card deleted successfully",
card_id=card_id,
stack_id=stack_id,
board_id=board_id,
)
@mcp.tool()
async def deck_archive_card(
ctx: Context, board_id: int, stack_id: int, card_id: int
) -> CardOperationResponse:
"""Archive a Nextcloud Deck card
Args:
board_id: The ID of the board
stack_id: The ID of the stack
card_id: The ID of the card
"""
client: NextcloudClient = ctx.request_context.lifespan_context.client
await client.deck.archive_card(board_id, stack_id, card_id)
return CardOperationResponse(
success=True,
message="Card archived successfully",
card_id=card_id,
stack_id=stack_id,
board_id=board_id,
)
@mcp.tool()
async def deck_unarchive_card(
ctx: Context, board_id: int, stack_id: int, card_id: int
) -> CardOperationResponse:
"""Unarchive a Nextcloud Deck card
Args:
board_id: The ID of the board
stack_id: The ID of the stack
card_id: The ID of the card
"""
client: NextcloudClient = ctx.request_context.lifespan_context.client
await client.deck.unarchive_card(board_id, stack_id, card_id)
return CardOperationResponse(
success=True,
message="Card unarchived successfully",
card_id=card_id,
stack_id=stack_id,
board_id=board_id,
)
@mcp.tool()
async def deck_reorder_card(
ctx: Context,
board_id: int,
stack_id: int,
card_id: int,
order: int,
target_stack_id: int,
) -> CardOperationResponse:
"""Reorder/move a Nextcloud Deck card
Args:
board_id: The ID of the board
stack_id: The ID of the current stack
card_id: The ID of the card
order: New position in the target stack
target_stack_id: The ID of the target stack
"""
client: NextcloudClient = ctx.request_context.lifespan_context.client
await client.deck.reorder_card(
board_id, stack_id, card_id, order, target_stack_id
)
return CardOperationResponse(
success=True,
message="Card reordered successfully",
card_id=card_id,
stack_id=target_stack_id,
board_id=board_id,
)
# Label Tools
@mcp.tool()
async def deck_create_label(
ctx: Context, board_id: int, title: str, color: str
) -> CreateLabelResponse:
"""Create a new label in a Nextcloud Deck board
Args:
board_id: The ID of the board
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
label = await client.deck.create_label(board_id, title, color)
return CreateLabelResponse(id=label.id, title=label.title, color=label.color)
@mcp.tool()
async def deck_update_label(
ctx: Context,
board_id: int,
label_id: int,
title: Optional[str] = None,
color: Optional[str] = None,
) -> LabelOperationResponse:
"""Update a Nextcloud Deck label
Args:
board_id: The ID of the board
label_id: The ID of the label
title: New title for the label
color: New color for the label (hex format without #)
"""
client: NextcloudClient = ctx.request_context.lifespan_context.client
await client.deck.update_label(board_id, label_id, title, color)
return LabelOperationResponse(
success=True,
message="Label updated successfully",
label_id=label_id,
board_id=board_id,
)
@mcp.tool()
async def deck_delete_label(
ctx: Context, board_id: int, label_id: int
) -> LabelOperationResponse:
"""Delete a Nextcloud Deck label
Args:
board_id: The ID of the board
label_id: The ID of the label
"""
client: NextcloudClient = ctx.request_context.lifespan_context.client
await client.deck.delete_label(board_id, label_id)
return LabelOperationResponse(
success=True,
message="Label deleted successfully",
label_id=label_id,
board_id=board_id,
)
# Card-Label Assignment Tools
@mcp.tool()
async def deck_assign_label_to_card(
ctx: Context, board_id: int, stack_id: int, card_id: int, label_id: int
) -> CardOperationResponse:
"""Assign a label to a Nextcloud Deck card
Args:
board_id: The ID of the board
stack_id: The ID of the stack
card_id: The ID of the card
label_id: The ID of the label to assign
"""
client: NextcloudClient = ctx.request_context.lifespan_context.client
await client.deck.assign_label_to_card(board_id, stack_id, card_id, label_id)
return CardOperationResponse(
success=True,
message="Label assigned to card successfully",
card_id=card_id,
stack_id=stack_id,
board_id=board_id,
)
@mcp.tool()
async def deck_remove_label_from_card(
ctx: Context, board_id: int, stack_id: int, card_id: int, label_id: int
) -> CardOperationResponse:
"""Remove a label from a Nextcloud Deck card
Args:
board_id: The ID of the board
stack_id: The ID of the stack
card_id: The ID of the card
label_id: The ID of the label to remove
"""
client: NextcloudClient = ctx.request_context.lifespan_context.client
await client.deck.remove_label_from_card(board_id, stack_id, card_id, label_id)
return CardOperationResponse(
success=True,
message="Label removed from card successfully",
card_id=card_id,
stack_id=stack_id,
board_id=board_id,
)
# Card-User Assignment Tools
@mcp.tool()
async def deck_assign_user_to_card(
ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str
) -> CardOperationResponse:
"""Assign a user to a Nextcloud Deck card
Args:
board_id: The ID of the board
stack_id: The ID of the stack
card_id: The ID of the card
user_id: The user ID to assign
"""
client: NextcloudClient = ctx.request_context.lifespan_context.client
await client.deck.assign_user_to_card(board_id, stack_id, card_id, user_id)
return CardOperationResponse(
success=True,
message="User assigned to card successfully",
card_id=card_id,
stack_id=stack_id,
board_id=board_id,
)
@mcp.tool()
async def deck_unassign_user_from_card(
ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str
) -> CardOperationResponse:
"""Unassign a user from a Nextcloud Deck card
Args:
board_id: The ID of the board
stack_id: The ID of the stack
card_id: The ID of the card
user_id: The user ID to unassign
"""
client: NextcloudClient = ctx.request_context.lifespan_context.client
await client.deck.unassign_user_from_card(board_id, stack_id, card_id, user_id)
return CardOperationResponse(
success=True,
message="User unassigned from card successfully",
card_id=card_id,
stack_id=stack_id,
board_id=board_id,
)
+219 -25
View File
@@ -1,8 +1,21 @@
import logging
from httpx import HTTPStatusError
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData
from mcp.server.fastmcp import Context, FastMCP
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.models.notes import (
Note,
NotesSettings,
CreateNoteResponse,
UpdateNoteResponse,
DeleteNoteResponse,
AppendContentResponse,
SearchNotesResponse,
NoteSearchResult,
)
logger = logging.getLogger(__name__)
@@ -15,7 +28,8 @@ def configure_notes_tools(mcp: FastMCP):
mcp.get_context()
) # https://github.com/modelcontextprotocol/python-sdk/issues/244
client: NextcloudClient = ctx.request_context.lifespan_context.client
return await client.notes.get_settings()
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):
@@ -38,23 +52,70 @@ def configure_notes_tools(mcp: FastMCP):
]
}
@mcp.tool()
async def nc_get_note(note_id: int, ctx: Context):
@mcp.resource("nc://Notes/{note_id}")
async def nc_get_note(note_id: int):
"""Get user note using note id"""
ctx: Context = mcp.get_context()
client: NextcloudClient = ctx.request_context.lifespan_context.client
return await client.notes.get_note(note_id)
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_create_note(
title: str, content: str, category: str, ctx: Context
):
) -> CreateNoteResponse:
"""Create a new note"""
client: NextcloudClient = ctx.request_context.lifespan_context.client
return await client.notes.create_note(
title=title,
content=content,
category=category,
)
try:
note_data = await client.notes.create_note(
title=title,
content=content,
category=category,
)
note = Note(**note_data)
return CreateNoteResponse(
id=note.id, title=note.title, category=note.category, etag=note.etag
)
except HTTPStatusError as e:
if e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message="Access denied: insufficient permissions to create notes",
)
)
elif e.response.status_code == 413:
raise McpError(ErrorData(code=-1, message="Note content too large"))
elif e.response.status_code == 409:
raise McpError(
ErrorData(
code=-1,
message=f"A note with title '{title}' already exists in this category",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to create note: server error ({e.response.status_code})",
)
)
@mcp.tool()
async def nc_notes_update_note(
@@ -64,32 +125,165 @@ def configure_notes_tools(mcp: FastMCP):
content: str | None,
category: str | None,
ctx: Context,
):
) -> UpdateNoteResponse:
"""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.
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
return await client.notes.update(
note_id=note_id,
etag=etag,
title=title,
content=content,
category=category,
)
try:
note_data = await client.notes.update(
note_id=note_id,
etag=etag,
title=title,
content=content,
category=category,
)
note = Note(**note_data)
return UpdateNoteResponse(
id=note.id, title=note.title, category=note.category, etag=note.etag
)
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 == 412:
raise McpError(
ErrorData(
code=-1,
message=f"Note {note_id} has been modified by someone else. Please refresh and try again.",
)
)
elif e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message=f"Access denied: insufficient permissions to update note {note_id}",
)
)
elif e.response.status_code == 413:
raise McpError(
ErrorData(code=-1, message="Updated note content is too large")
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to update note {note_id}: server error ({e.response.status_code})",
)
)
@mcp.tool()
async def nc_notes_append_content(note_id: int, content: str, ctx: Context):
"""Append content to an existing note with a clear separator"""
async def nc_notes_append_content(
note_id: int, content: str, ctx: Context
) -> AppendContentResponse:
"""Append content to an existing note. The tool adds a `\n---\n`
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
return await client.notes.append_content(note_id=note_id, content=content)
try:
note_data = await client.notes.append_content(
note_id=note_id, content=content
)
note = Note(**note_data)
return AppendContentResponse(
id=note.id, title=note.title, category=note.category, etag=note.etag
)
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: insufficient permissions to modify note {note_id}",
)
)
elif e.response.status_code == 413:
raise McpError(
ErrorData(
code=-1,
message="Content to append would make the note too large",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to append content to note {note_id}: server error ({e.response.status_code})",
)
)
@mcp.tool()
async def nc_notes_search_notes(query: str, ctx: Context):
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
return await client.notes_search_notes(query=query)
try:
search_results_raw = await client.notes_search_notes(query=query)
# Convert to NoteSearchResult models, including the _score field
results = [
NoteSearchResult(
id=result["id"],
title=result["title"],
category=result["category"],
score=result.get("_score"), # Include search score if available
)
for result in search_results_raw
]
return SearchNotesResponse(
results=results, query=query, total_found=len(results)
)
except HTTPStatusError as e:
if e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message="Access denied: insufficient permissions to search notes",
)
)
elif e.response.status_code == 400:
raise McpError(
ErrorData(code=-1, message="Invalid search query format")
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Search failed: server error ({e.response.status_code})",
)
)
@mcp.tool()
async def nc_notes_delete_note(note_id: int, ctx: Context):
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
return await client.notes.delete_note(note_id)
try:
await client.notes.delete_note(note_id)
return DeleteNoteResponse(
status_code=200,
message=f"Note {note_id} deleted successfully",
deleted_id=note_id,
)
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: insufficient permissions to delete note {note_id}",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to delete note {note_id}: server error ({e.response.status_code})",
)
)
+66 -2
View File
@@ -43,11 +43,11 @@ def configure_webdav_tools(mcp: FastMCP):
Examples:
# Read a text file
result = await nc_webdav_read_file("Documents/readme.txt")
print(result['content']) # Decoded text content
logger.info(result['content']) # Decoded text content
# Read a binary file
result = await nc_webdav_read_file("Images/photo.jpg")
print(result['encoding']) # 'base64'
logger.info(result['encoding']) # 'base64'
"""
client: NextcloudClient = ctx.request_context.lifespan_context.client
content, content_type = await client.webdav.read_file(path)
@@ -149,3 +149,67 @@ def configure_webdav_tools(mcp: FastMCP):
"""
client: NextcloudClient = ctx.request_context.lifespan_context.client
return await client.webdav.delete_resource(path)
@mcp.tool()
async def nc_webdav_move_resource(
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
):
"""Move or rename a file or directory in NextCloud.
Args:
source_path: Full path of the file or directory to move
destination_path: New path for the file or directory
overwrite: Whether to overwrite the destination if it exists (default: False)
Returns:
Dict with status_code indicating result (404 if source not found, 412 if destination exists and overwrite is False)
Examples:
# 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")
# 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
return await client.webdav.move_resource(
source_path, destination_path, overwrite
)
@mcp.tool()
async def nc_webdav_copy_resource(
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
):
"""Copy a file or directory in NextCloud.
Args:
source_path: Full path of the file or directory to copy
destination_path: Destination path for the copy
overwrite: Whether to overwrite the destination if it exists (default: False)
Returns:
Dict with status_code indicating result (404 if source not found, 412 if destination exists and overwrite is False)
Examples:
# 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")
# 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
return await client.webdav.copy_resource(
source_path, destination_path, overwrite
)
+6 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "nextcloud-mcp-server"
version = "0.7.1"
version = "0.10.0"
description = ""
authors = [
{name = "Chris Coutinho",email = "chris@coutinho.io"}
@@ -13,6 +13,8 @@ dependencies = [
"pillow (>=11.2.1,<12.0.0)",
"icalendar (>=6.0.0,<7.0.0)",
"pythonvcard4>=0.2.0",
"pydantic>=2.11.4",
"click>=8.1.8",
]
[tool.pytest.ini_options]
@@ -47,3 +49,6 @@ dev = [
"pytest-cov>=6.1.1",
"ruff>=0.11.13",
]
[project.scripts]
nextcloud-mcp-server = "nextcloud_mcp_server.app:run"
+146
View File
@@ -250,3 +250,149 @@ async def temporary_contact(nc_client: NextcloudClient, temporary_addressbook: s
logger.error(
f"Unexpected error deleting temporary contact {contact_uid}: {e}"
)
@pytest.fixture
async def temporary_board(nc_client: NextcloudClient):
"""
Fixture to create a temporary deck board for tests and ensure its deletion afterward.
Yields the created board data dict.
"""
board_id = None
unique_suffix = uuid.uuid4().hex[:8]
board_title = f"Temporary Test Board {unique_suffix}"
board_color = "FF0000" # Red color
created_board_data = None
logger.info(f"Creating temporary deck board: {board_title}")
try:
created_board = await nc_client.deck.create_board(board_title, board_color)
board_id = created_board.id
created_board_data = {
"id": board_id,
"title": created_board.title,
"color": created_board.color,
"archived": getattr(created_board, "archived", False),
}
logger.info(f"Temporary board created with ID: {board_id}")
yield created_board_data
finally:
if board_id:
logger.info(f"Cleaning up temporary board ID: {board_id}")
try:
await nc_client.deck.delete_board(board_id)
logger.info(f"Successfully deleted temporary board ID: {board_id}")
except HTTPStatusError as e:
# Ignore 404 if board was already deleted by the test itself
if e.response.status_code not in [404, 403]:
logger.error(f"HTTP error deleting temporary board {board_id}: {e}")
else:
logger.warning(
f"Temporary board {board_id} already deleted or access denied ({e.response.status_code})."
)
except Exception as e:
logger.error(
f"Unexpected error deleting temporary board {board_id}: {e}"
)
@pytest.fixture
async def temporary_board_with_stack(nc_client: NextcloudClient, temporary_board: dict):
"""
Fixture to create a temporary stack in a temporary board.
Yields a tuple: (board_data, stack_data).
Depends on the temporary_board fixture.
"""
board_data = temporary_board
board_id = board_data["id"]
unique_suffix = uuid.uuid4().hex[:8]
stack_title = f"Test Stack {unique_suffix}"
stack_order = 1
stack = None
logger.info(f"Creating temporary stack in board ID: {board_id}")
try:
stack = await nc_client.deck.create_stack(board_id, stack_title, stack_order)
stack_data = {
"id": stack.id,
"title": stack.title,
"order": stack.order,
"boardId": board_id,
}
logger.info(f"Temporary stack created with ID: {stack.id}")
yield (board_data, stack_data)
finally:
# Clean up - delete stack
if stack and hasattr(stack, "id"):
logger.info(f"Cleaning up temporary stack ID: {stack.id}")
try:
await nc_client.deck.delete_stack(board_id, stack.id)
logger.info(f"Successfully deleted temporary stack ID: {stack.id}")
except HTTPStatusError as e:
if e.response.status_code not in [404, 403]:
logger.error(f"HTTP error deleting temporary stack {stack.id}: {e}")
else:
logger.warning(
f"Temporary stack {stack.id} already deleted or access denied ({e.response.status_code})."
)
except Exception as e:
logger.error(
f"Unexpected error deleting temporary stack {stack.id}: {e}"
)
@pytest.fixture
async def temporary_board_with_card(
nc_client: NextcloudClient, temporary_board_with_stack: tuple
):
"""
Fixture to create a temporary card in a temporary stack within a temporary board.
Yields a tuple: (board_data, stack_data, card_data).
Depends on the temporary_board_with_stack fixture.
"""
board_data, stack_data = temporary_board_with_stack
board_id = board_data["id"]
stack_id = stack_data["id"]
unique_suffix = uuid.uuid4().hex[:8]
card_title = f"Test Card {unique_suffix}"
card_description = f"Test description for card {unique_suffix}"
card = None
logger.info(
f"Creating temporary card in stack ID: {stack_id}, board ID: {board_id}"
)
try:
card = await nc_client.deck.create_card(
board_id, stack_id, card_title, description=card_description
)
card_data = {
"id": card.id,
"title": card.title,
"description": card.description,
"stackId": stack_id,
"boardId": board_id,
}
logger.info(f"Temporary card created with ID: {card.id}")
yield (board_data, stack_data, card_data)
finally:
# Clean up - delete card
if card and hasattr(card, "id"):
logger.info(f"Cleaning up temporary card ID: {card.id}")
try:
await nc_client.deck.delete_card(board_id, stack_id, card.id)
logger.info(f"Successfully deleted temporary card ID: {card.id}")
except HTTPStatusError as e:
if e.response.status_code not in [404, 403]:
logger.error(f"HTTP error deleting temporary card {card.id}: {e}")
else:
logger.warning(
f"Temporary card {card.id} already deleted or access denied ({e.response.status_code})."
)
except Exception as e:
logger.error(f"Unexpected error deleting temporary card {card.id}: {e}")
+327
View File
@@ -0,0 +1,327 @@
import logging
import uuid
import pytest
from httpx import HTTPStatusError
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.models.deck import DeckStack, DeckCard, DeckLabel
logger = logging.getLogger(__name__)
pytestmark = pytest.mark.integration
# Board CRUD Tests
async def test_deck_board_crud_workflow(
nc_client: NextcloudClient, temporary_board: dict
):
"""
Test complete board CRUD workflow using the temporary_board fixture.
"""
board_data = temporary_board
board_id = board_data["id"]
original_title = board_data["title"]
original_color = board_data["color"]
logger.info(f"Testing CRUD operations on board ID: {board_id}")
# Read the board
read_board = await nc_client.deck.get_board(board_id)
assert read_board.id == board_id
assert read_board.title == original_title
assert read_board.color == original_color
logger.info(f"Successfully read board ID: {board_id}")
# Update the board
updated_title = f"Updated {original_title}"
updated_color = "00FF00" # Green color
await nc_client.deck.update_board(
board_id, title=updated_title, color=updated_color
)
# Verify the update
updated_board = await nc_client.deck.get_board(board_id)
assert updated_board.title == updated_title
assert updated_board.color == updated_color
logger.info(f"Successfully updated board ID: {board_id}")
async def test_deck_list_boards(nc_client: NextcloudClient):
"""
Test listing all boards with different options.
"""
# Test basic listing
boards = await nc_client.deck.get_boards()
assert isinstance(boards, list)
logger.info(f"Found {len(boards)} boards")
# Test with details
detailed_boards = await nc_client.deck.get_boards(details=True)
assert isinstance(detailed_boards, list)
logger.info(f"Found {len(detailed_boards)} boards with details")
async def test_deck_board_operations_nonexistent(nc_client: NextcloudClient):
"""
Test operations on non-existent board return appropriate errors.
"""
non_existent_id = 999999999
# Test get non-existent board
with pytest.raises(HTTPStatusError) as excinfo:
await nc_client.deck.get_board(non_existent_id)
assert excinfo.value.response.status_code in [
404,
403,
] # 403 might be returned for access denied
logger.info(
f"Get non-existent board correctly failed with {excinfo.value.response.status_code}"
)
# Test update non-existent board
with pytest.raises(HTTPStatusError) as excinfo:
await nc_client.deck.update_board(non_existent_id, title="Should Fail")
assert excinfo.value.response.status_code in [
404,
403,
400,
] # 400 for bad request on invalid board ID
logger.info(
f"Update non-existent board correctly failed with {excinfo.value.response.status_code}"
)
# Stack CRUD Tests
async def test_deck_stack_crud_workflow(
nc_client: NextcloudClient, temporary_board: dict
):
"""
Test complete stack CRUD workflow.
"""
board_id = temporary_board["id"]
stack_title = f"Test Stack {uuid.uuid4().hex[:8]}"
stack_order = 1
stack = None
try:
# Create stack
stack = await nc_client.deck.create_stack(board_id, stack_title, stack_order)
assert isinstance(stack, DeckStack)
assert stack.title == stack_title
assert stack.order == stack_order
stack_id = stack.id
logger.info(f"Created stack ID: {stack_id}")
# Read stack
read_stack = await nc_client.deck.get_stack(board_id, stack_id)
assert read_stack.id == stack_id
assert read_stack.title == stack_title
logger.info(f"Successfully read stack ID: {stack_id}")
# Update stack
updated_title = f"Updated {stack_title}"
updated_order = 2
await nc_client.deck.update_stack(
board_id, stack_id, title=updated_title, order=updated_order
)
# Verify update
updated_stack = await nc_client.deck.get_stack(board_id, stack_id)
assert updated_stack.title == updated_title
assert updated_stack.order == updated_order
logger.info(f"Successfully updated stack ID: {stack_id}")
# List stacks
stacks = await nc_client.deck.get_stacks(board_id)
assert isinstance(stacks, list)
assert any(s.id == stack_id for s in stacks)
logger.info(f"Found stack ID: {stack_id} in board stacks list")
finally:
# Clean up - delete stack
if stack and hasattr(stack, "id"):
try:
await nc_client.deck.delete_stack(board_id, stack.id)
logger.info(f"Cleaned up stack ID: {stack.id}")
except Exception as e:
logger.warning(f"Failed to clean up stack ID: {stack.id}: {e}")
# Card CRUD Tests
async def test_deck_card_crud_workflow(
nc_client: NextcloudClient, temporary_board_with_stack: tuple
):
"""
Test complete card CRUD workflow.
"""
board_data, stack_data = temporary_board_with_stack
board_id = board_data["id"]
stack_id = stack_data["id"]
card_title = f"Test Card {uuid.uuid4().hex[:8]}"
card_description = f"Test description for card {uuid.uuid4().hex[:8]}"
card = None
try:
# Create card
card = await nc_client.deck.create_card(
board_id, stack_id, card_title, description=card_description
)
assert isinstance(card, DeckCard)
assert card.title == card_title
assert card.description == card_description
card_id = card.id
logger.info(f"Created card ID: {card_id}")
# Read card
read_card = await nc_client.deck.get_card(board_id, stack_id, card_id)
assert read_card.id == card_id
assert read_card.title == card_title
logger.info(f"Successfully read card ID: {card_id}")
# Update card
updated_title = f"Updated {card_title}"
updated_description = f"Updated description for {card_title}"
await nc_client.deck.update_card(
board_id,
stack_id,
card_id,
title=updated_title,
description=updated_description,
)
# Verify update
updated_card = await nc_client.deck.get_card(board_id, stack_id, card_id)
assert updated_card.title == updated_title
assert updated_card.description == updated_description
logger.info(f"Successfully updated card ID: {card_id}")
# Archive and unarchive card
await nc_client.deck.archive_card(board_id, stack_id, card_id)
logger.info(f"Archived card ID: {card_id}")
await nc_client.deck.unarchive_card(board_id, stack_id, card_id)
logger.info(f"Unarchived card ID: {card_id}")
finally:
# Clean up - delete card
if card and hasattr(card, "id"):
try:
await nc_client.deck.delete_card(board_id, stack_id, card.id)
logger.info(f"Cleaned up card ID: {card.id}")
except Exception as e:
logger.warning(f"Failed to clean up card ID: {card.id}: {e}")
# Label CRUD Tests
async def test_deck_label_crud_workflow(
nc_client: NextcloudClient, temporary_board: dict
):
"""
Test complete label CRUD workflow.
"""
board_id = temporary_board["id"]
label_title = f"Test Label {uuid.uuid4().hex[:8]}"
label_color = "FF0000" # Red
label = None
try:
# Create label
label = await nc_client.deck.create_label(board_id, label_title, label_color)
assert isinstance(label, DeckLabel)
assert label.title == label_title
assert label.color == label_color
label_id = label.id
logger.info(f"Created label ID: {label_id}")
# Read label
read_label = await nc_client.deck.get_label(board_id, label_id)
assert read_label.id == label_id
assert read_label.title == label_title
logger.info(f"Successfully read label ID: {label_id}")
# Update label
updated_title = f"Updated {label_title}"
updated_color = "00FF00" # Green
await nc_client.deck.update_label(
board_id, label_id, title=updated_title, color=updated_color
)
# Verify update
updated_label = await nc_client.deck.get_label(board_id, label_id)
assert updated_label.title == updated_title
assert updated_label.color == updated_color
logger.info(f"Successfully updated label ID: {label_id}")
finally:
# Clean up - delete label
if label and hasattr(label, "id"):
try:
await nc_client.deck.delete_label(board_id, label.id)
logger.info(f"Cleaned up label ID: {label.id}")
except Exception as e:
logger.warning(f"Failed to clean up label ID: {label.id}: {e}")
# Configuration and Comments Tests
async def test_deck_config_operations(nc_client: NextcloudClient):
"""
Test deck configuration operations.
"""
# Get config
config = await nc_client.deck.get_config()
assert config is not None
logger.info(f"Retrieved deck config: {config}")
async def test_deck_comments_workflow(
nc_client: NextcloudClient, temporary_board_with_card: tuple
):
"""
Test comment operations on a card.
"""
board_data, stack_data, card_data = temporary_board_with_card
card_id = card_data["id"]
comment_message = f"Test comment {uuid.uuid4().hex[:8]}"
comment = None
try:
# Create comment
comment = await nc_client.deck.create_comment(card_id, comment_message)
assert comment.message == comment_message
comment_id = comment.id
logger.info(f"Created comment ID: {comment_id}")
# List comments
comments = await nc_client.deck.get_comments(card_id)
assert isinstance(comments, list)
assert any(c.id == comment_id for c in comments)
logger.info(f"Found comment ID: {comment_id} in card comments")
# Update comment
updated_message = f"Updated {comment_message}"
updated_comment = await nc_client.deck.update_comment(
card_id, comment_id, updated_message
)
assert updated_comment.message == updated_message
logger.info(f"Successfully updated comment ID: {comment_id}")
finally:
# Clean up - delete comment
if comment and hasattr(comment, "id"):
try:
await nc_client.deck.delete_comment(card_id, comment.id)
logger.info(f"Cleaned up comment ID: {comment.id}")
except Exception as e:
logger.warning(f"Failed to clean up comment ID: {comment.id}: {e}")
+223
View File
@@ -0,0 +1,223 @@
import json
import logging
import uuid
import pytest
from mcp import ClientSession
from nextcloud_mcp_server.client import NextcloudClient
logger = logging.getLogger(__name__)
pytestmark = pytest.mark.integration
async def test_deck_mcp_connectivity(nc_mcp_client: ClientSession):
"""Test deck MCP tools are available and functional."""
# List available tools
tools = await nc_mcp_client.list_tools()
tool_names = [tool.name for tool in tools.tools]
# Verify expected deck tools are present
expected_deck_tools = ["deck_create_board"]
for expected_tool in expected_deck_tools:
assert expected_tool in tool_names, (
f"Expected deck tool '{expected_tool}' not found in available tools"
)
logger.info(f"Found expected deck tool: {expected_tool}")
# List available resource templates
templates = await nc_mcp_client.list_resource_templates()
template_uris = [template.uriTemplate for template in templates.resourceTemplates]
# Verify expected deck resource templates
expected_deck_templates = [
"nc://Deck/boards/{board_id}",
]
for expected_template in expected_deck_templates:
assert expected_template in template_uris, (
f"Expected deck template '{expected_template}' not found"
)
logger.info(f"Found expected deck resource template: {expected_template}")
# List available resources
resources = await nc_mcp_client.list_resources()
resource_uris = [str(resource.uri) for resource in resources.resources]
# Verify expected deck resources
expected_deck_resources = [
"nc://Deck/boards",
]
for expected_resource in expected_deck_resources:
assert expected_resource in resource_uris, (
f"Expected deck resource '{expected_resource}' not found"
)
logger.info(f"Found expected deck resource: {expected_resource}")
async def test_deck_board_crud_workflow_mcp(
nc_mcp_client: ClientSession, nc_client: NextcloudClient
):
"""Test complete Deck board CRUD workflow via MCP tools with verification via NextcloudClient."""
unique_suffix = uuid.uuid4().hex[:8]
board_title = f"MCP Test Board {unique_suffix}"
board_color = "0000FF" # Blue
# 1. Create board via MCP
logger.info(f"Creating board via MCP: {board_title}")
create_result = await nc_mcp_client.call_tool(
"deck_create_board",
{"title": board_title, "color": board_color},
)
assert create_result.isError is False, (
f"MCP board creation failed: {create_result.content}"
)
created_board_json = create_result.content[0].text
created_board_response = json.loads(created_board_json)
board_id = created_board_response["id"]
logger.info(f"Board created via MCP with ID: {board_id}")
assert created_board_response["title"] == board_title
assert created_board_response["color"] == board_color
# 2. Verify creation via direct NextcloudClient
direct_board = await nc_client.deck.get_board(board_id)
assert direct_board.title == board_title, (
f"Title mismatch: {direct_board.title} != {board_title}"
)
assert direct_board.color == board_color, "Color mismatch"
logger.info("Board creation verified via direct client")
# 3. Read board via MCP resource
logger.info(f"Reading board via MCP resource: {board_id}")
read_result = await nc_mcp_client.read_resource(f"nc://Deck/boards/{board_id}")
assert len(read_result.contents) == 1, "Expected exactly one content item"
read_board_data = json.loads(read_result.contents[0].text)
assert read_board_data["title"] == board_title
assert read_board_data["color"] == board_color
logger.info("Board read via MCP resource successfully")
# 4. Verify board via direct read of resource
logger.info(f"Verifying board via resource read: {board_id}")
# This was already done in step 3, so we'll just log confirmation
logger.info("Board structure verified successfully")
# 5. Read boards list via MCP resource
logger.info("Reading boards list via MCP resource")
boards_resource_result = await nc_mcp_client.read_resource("nc://Deck/boards")
assert len(boards_resource_result.contents) == 1, (
"Expected exactly one content item"
)
boards_resource_data = json.loads(boards_resource_result.contents[0].text)
assert isinstance(boards_resource_data, list) # Resources return raw lists
# Verify our board is in the resource list
resource_board_ids = [board["id"] for board in boards_resource_data]
assert board_id in resource_board_ids, "Created board not found in resource list"
logger.info("Board found in boards resource list")
# Clean up - delete board
await nc_client.deck.delete_board(board_id)
logger.info(f"Cleaned up board ID: {board_id}")
async def test_deck_board_operations_error_handling_mcp(nc_mcp_client: ClientSession):
"""Test MCP deck tools handle errors appropriately."""
non_existent_id = 999999999
# Test create board with invalid parameters via MCP tool
logger.info("Testing board creation with invalid parameters via MCP")
create_result = await nc_mcp_client.call_tool(
"deck_create_board",
{"title": "", "color": "FF0000"},
)
assert create_result.isError is True, "Expected error for invalid board creation"
logger.info("Invalid board creation correctly failed via MCP tool")
# Test read non-existent board via MCP resource
logger.info(f"Testing read non-existent board via MCP resource: {non_existent_id}")
try:
read_result = await nc_mcp_client.read_resource(
f"nc://Deck/boards/{non_existent_id}"
)
# If no error is thrown, check if the result indicates an error
assert len(read_result.contents) == 0, (
"Expected empty content for non-existent board"
)
except Exception as e:
logger.info(f"Read non-existent board correctly failed via MCP resource: {e}")
async def test_deck_board_creation_validation_mcp(nc_mcp_client: ClientSession):
"""Test deck board creation validation via MCP tools."""
# Test creating board with empty title should fail
logger.info("Testing board creation with empty title via MCP")
create_result = await nc_mcp_client.call_tool(
"deck_create_board",
{"title": "", "color": "FF0000"},
)
assert create_result.isError is True, "Expected error for empty board title"
logger.info("Empty title board creation correctly failed via MCP")
async def test_deck_board_creation_success_mcp(
nc_mcp_client: ClientSession, nc_client: NextcloudClient
):
"""Test deck board creation with valid parameters via MCP tools."""
# Test creating board with valid parameters
logger.info("Testing board creation with valid parameters via MCP")
create_result = await nc_mcp_client.call_tool(
"deck_create_board",
{"title": f"Valid Board {uuid.uuid4().hex[:8]}", "color": "00FF00"},
)
assert create_result.isError is False, "Valid board creation should succeed"
created_board = json.loads(create_result.content[0].text)
board_id = created_board["id"]
logger.info(f"Valid board created successfully with ID: {board_id}")
# Clean up - delete board
await nc_client.deck.delete_board(board_id)
logger.info(f"Cleaned up board ID: {board_id}")
async def test_deck_workflow_integration_mcp(
nc_mcp_client: ClientSession, temporary_board_with_card: tuple
):
"""Test a complete deck workflow using MCP tools with temporary resources."""
board_data, stack_data, card_data = temporary_board_with_card
board_id = board_data["id"]
board_title = board_data["title"]
# 1. Read board via MCP to verify the structure
logger.info(f"Reading board via MCP resource: {board_id}")
read_result = await nc_mcp_client.read_resource(f"nc://Deck/boards/{board_id}")
board_mcp_data = json.loads(read_result.contents[0].text)
assert board_mcp_data["title"] == board_title
logger.info("Board structure verified via MCP resource")
# 2. List boards via MCP resource and verify our board is there
logger.info("Listing boards via MCP resource")
list_result = await nc_mcp_client.read_resource("nc://Deck/boards")
boards_data = json.loads(list_result.contents[0].text)
board_found = any(board["id"] == board_id for board in boards_data)
assert board_found, "Board not found in boards list"
logger.info("Board found in boards list")
# 3. Verify board data matches via resource (already done in step 1)
logger.info(f"Board data verification completed for board: {board_id}")
logger.info("Board structure and data verified successfully")
+172
View File
@@ -0,0 +1,172 @@
"""Test error propagation in the MCP server for various error scenarios."""
import logging
from mcp import ClientSession
from mcp.shared.exceptions import McpError
import pytest
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")
@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
response = await nc_mcp_client.call_tool(
"nc_notes_delete_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
async def test_search_with_empty_query(nc_mcp_client: ClientSession):
"""Test search behavior with empty query."""
# Search with empty query
response = await nc_mcp_client.call_tool("nc_notes_search_notes", {"query": ""})
logger.info(f"Empty search query response: {response}")
# Should return successful response with empty or valid results
assert response is not None
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
response = await nc_mcp_client.call_tool(
"nc_notes_create_note",
{"title": "Test"}, # Missing content and category
)
logger.info(f"Missing params response: {response}")
# Should return error response for missing required parameters
assert response is not None
assert response.isError is True
assert (
"required" in response.content[0].text.lower()
or "missing" in response.content[0].text.lower()
)
@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
note_data = await nc_client.notes.create_note(
title="Test Note for ETag", content="Test content", category=""
)
note_id = note_data["id"]
try:
# Try to update with invalid ETag - should return error response
response = await nc_mcp_client.call_tool(
"nc_notes_update_note",
{
"note_id": note_id,
"etag": "invalid-etag",
"title": "Updated Title",
"content": None,
"category": None,
},
)
# Should return error response (not raise exception) for tools
assert response is not None
assert response.isError is True
assert "modified by someone else" in response.content[0].text
finally:
# Clean up
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
response = await nc_mcp_client.call_tool(
"nc_calendar_create_event",
{
"calendar_name": "non-existent-calendar",
"title": "Test Event",
"start_datetime": "2025-01-15T14:00:00",
},
)
logger.info(f"Non-existent calendar response: {response}")
# Should return structured error response
assert response is not None
# Note: Some modules may not have improved error handling yet
# Check if we have structured content with success=false or isError=true
if (
hasattr(response, "structuredContent")
and response.structuredContent
and "result" in response.structuredContent
):
assert response.structuredContent["result"]["success"] is False
else:
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
response = await nc_mcp_client.call_tool(
"nc_webdav_read_file", {"path": "non-existent-file.txt"}
)
logger.info(f"Missing file response: {response}")
# Should return structured error response
assert response is not None
# Note: Some modules may not have improved error handling yet
# Check if we have structured content with success=false or isError=true
if (
hasattr(response, "structuredContent")
and response.structuredContent
and "result" in response.structuredContent
):
assert response.structuredContent["result"]["success"] is False
else:
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
response = await nc_mcp_client.call_tool(
"nc_tables_get_schema", {"table_id": 999999}
)
logger.info(f"Missing table response: {response}")
# Should return structured error response
assert response is not None
# Note: Some modules may not have improved error handling yet
# Check if we have structured content with success=false or isError=true
if (
hasattr(response, "structuredContent")
and response.structuredContent
and "result" in response.structuredContent
):
assert response.structuredContent["result"]["success"] is False
else:
assert response.isError is True
@@ -0,0 +1,436 @@
"""Integration tests for CalDAV and CardDAV field preservation.
This test module demonstrates data loss issues when non-supported fields
are present in calendar events and contacts during round-trip operations.
"""
import logging
import pytest
import uuid
from datetime import datetime, timedelta
logger = logging.getLogger(__name__)
@pytest.mark.integration
async def test_calendar_event_custom_fields_preservation(nc_client):
"""Test that demonstrates loss of non-supported iCal fields during round-trip operations."""
calendar_name = "personal"
# Create an event with standard fields
event_data = {
"title": "Test Event with Custom Fields",
"description": "Event to test custom field preservation",
"start_datetime": (datetime.now() + timedelta(days=1)).isoformat(),
"end_datetime": (datetime.now() + timedelta(days=1, hours=1)).isoformat(),
"location": "Test Location",
}
# Create the event
result = await nc_client.calendar.create_event(calendar_name, event_data)
event_uid = result["uid"]
try:
# Now manually inject a custom iCal property by creating a new version with raw iCal
# This simulates what would happen if the event was created by another CalDAV client
# with extended properties
custom_ical = f"""BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test Client//EN
BEGIN:VEVENT
UID:{event_uid}
DTSTART:{(datetime.now() + timedelta(days=1)).strftime("%Y%m%dT%H%M%SZ")}
DTEND:{(datetime.now() + timedelta(days=1, hours=1)).strftime("%Y%m%dT%H%M%SZ")}
SUMMARY:Test Event with Custom Fields
DESCRIPTION:Event to test custom field preservation
LOCATION:Test Location
X-CUSTOM-FIELD:This is a custom field that should be preserved
X-VENDOR-SPECIFIC:Vendor specific data
CATEGORIES:work,testing
STATUS:CONFIRMED
PRIORITY:5
CLASS:PUBLIC
CREATED:{datetime.now().strftime("%Y%m%dT%H%M%SZ")}
DTSTAMP:{datetime.now().strftime("%Y%m%dT%H%M%SZ")}
LAST-MODIFIED:{datetime.now().strftime("%Y%m%dT%H%M%SZ")}
END:VEVENT
END:VCALENDAR"""
# Direct CalDAV PUT to inject the custom iCal
event_path = f"/remote.php/dav/calendars/{nc_client.calendar.username}/{calendar_name}/{event_uid}.ics"
await nc_client.calendar._make_request(
"PUT",
event_path,
content=custom_ical,
headers={"Content-Type": "text/calendar; charset=utf-8"},
)
logger.info(f"Injected custom iCal properties into event {event_uid}")
# Retrieve the event to confirm custom fields are present in raw iCal
response = await nc_client.calendar._make_request(
"GET", event_path, headers={"Accept": "text/calendar"}
)
raw_ical_before = response.text
logger.info("Raw iCal before update:")
logger.info(raw_ical_before)
# Verify custom fields exist in raw iCal
assert (
"X-CUSTOM-FIELD:This is a custom field that should be preserved"
in raw_ical_before
)
assert "X-VENDOR-SPECIFIC:Vendor specific data" in raw_ical_before
# Now update the event through the MCP client (simulating normal usage)
update_data = {
"title": "Updated Test Event with Custom Fields",
"description": "Updated description - custom fields should be preserved",
}
await nc_client.calendar.update_event(calendar_name, event_uid, update_data)
logger.info(f"Updated event {event_uid} through MCP client")
# Retrieve the event again to see if custom fields survived
response_after = await nc_client.calendar._make_request(
"GET", event_path, headers={"Accept": "text/calendar"}
)
raw_ical_after = response_after.text
logger.info("Raw iCal after update:")
logger.info(raw_ical_after)
# THIS IS THE TEST THAT SHOULD FAIL - custom fields should be preserved but won't be
try:
assert (
"X-CUSTOM-FIELD:This is a custom field that should be preserved"
in raw_ical_after
), "Custom field X-CUSTOM-FIELD was lost during round-trip update"
assert "X-VENDOR-SPECIFIC:Vendor specific data" in raw_ical_after, (
"Custom field X-VENDOR-SPECIFIC was lost during round-trip update"
)
logger.info(
"✓ Custom fields were preserved (unexpected - this should fail with current implementation)"
)
except AssertionError as e:
logger.error(f"✗ Custom fields were lost during round-trip update: {e}")
# Re-raise to show the test failure
raise
finally:
# Cleanup
try:
await nc_client.calendar.delete_event(calendar_name, event_uid)
except Exception as cleanup_error:
logger.warning(f"Failed to cleanup event {event_uid}: {cleanup_error}")
@pytest.mark.integration
async def test_contact_extended_fields_preservation(nc_client):
"""Test that demonstrates loss of extended vCard fields during round-trip operations."""
addressbook_name = f"test_preservation_{uuid.uuid4().hex[:8]}"
# Create a temporary addressbook
await nc_client.contacts.create_addressbook(
name=addressbook_name, display_name="Test Preservation Addressbook"
)
try:
contact_uid = str(uuid.uuid4())
# Create a contact with minimal data first
basic_contact_data = {
"fn": "John Extended Doe",
"email": "john.extended@example.com",
}
await nc_client.contacts.create_contact(
addressbook=addressbook_name,
uid=contact_uid,
contact_data=basic_contact_data,
)
logger.info(f"Created basic contact {contact_uid}")
# Now inject a rich vCard with extended fields directly via CardDAV
extended_vcard = f"""BEGIN:VCARD
VERSION:4.0
UID:{contact_uid}
FN:John Extended Doe
N:Doe;John;Extended;;
NICKNAME:Johnny,JD
EMAIL;TYPE=work:john.work@company.com
EMAIL;TYPE=home:john.extended@example.com
TEL;TYPE=cell:+1-555-123-4567
TEL;TYPE=work:+1-555-987-6543
ADR;TYPE=home:;;123 Main St;Hometown;ST;12345;USA
ADR;TYPE=work:;;456 Work Ave;Worktown;ST;54321;USA
ORG:Example Corporation
TITLE:Senior Developer
URL;TYPE=work:https://company.com/john
URL;TYPE=personal:https://johndoe.dev
BDAY:1985-06-15
NOTE:This is a note with important information that should be preserved.
CATEGORIES:colleagues,developers,friends
X-CUSTOM-FIELD:This should be preserved
X-SKYPE:john.doe.skype
X-LINKEDIN:https://linkedin.com/in/johndoe
REV:{datetime.now().strftime("%Y%m%dT%H%M%SZ")}
END:VCARD"""
# Direct CardDAV PUT to inject the extended vCard
contact_path = f"/remote.php/dav/addressbooks/users/{nc_client.contacts.username}/{addressbook_name}/{contact_uid}.vcf"
await nc_client.contacts._make_request(
"PUT",
contact_path,
content=extended_vcard,
headers={"Content-Type": "text/vcard; charset=utf-8"},
)
logger.info(f"Injected extended vCard for contact {contact_uid}")
# Retrieve the contact to confirm extended fields are present in raw vCard
response = await nc_client.contacts._make_request("GET", contact_path)
raw_vcard_before = response.text
logger.info("Raw vCard before any operations:")
logger.info(raw_vcard_before)
# Verify extended fields exist in raw vCard
assert "TEL;TYPE=cell:+1-555-123-4567" in raw_vcard_before
assert "ADR;TYPE=home:;;123 Main St;Hometown;ST;12345;USA" in raw_vcard_before
assert "ORG:Example Corporation" in raw_vcard_before
assert "TITLE:Senior Developer" in raw_vcard_before
assert "X-CUSTOM-FIELD:This should be preserved" in raw_vcard_before
assert "X-LINKEDIN:https://linkedin.com/in/johndoe" in raw_vcard_before
assert "NOTE:This is a note with important information" in raw_vcard_before
# List contacts through the MCP client (this will parse and return limited fields)
contacts = await nc_client.contacts.list_contacts(addressbook=addressbook_name)
our_contact = next((c for c in contacts if c["vcard_id"] == contact_uid), None)
assert our_contact is not None
logger.info("Contact as parsed by MCP client:")
logger.info(our_contact)
# Check what fields are accessible through the parsed contact
parsed_contact = our_contact["contact"]
# These should be available (basic fields that are parsed)
assert parsed_contact["fullname"] == "John Extended Doe"
assert parsed_contact["email"] is not None # Some email should be present
# The raw vCard should still be available in addressdata
raw_addressdata = our_contact["addressdata"]
assert "X-CUSTOM-FIELD:This should be preserved" in raw_addressdata
assert "ORG:Example Corporation" in raw_addressdata
# The key test: Can we update this contact without losing extended field data?
logger.info("Testing contact update preservation...")
# Update the contact through the MCP client with a simple change
try:
await nc_client.contacts.update_contact(
addressbook=addressbook_name,
uid=contact_uid,
contact_data={"email": "john.updated@example.com"},
)
logger.info("✓ Contact updated successfully")
except Exception as e:
logger.error(f"✗ Failed to update contact: {e}")
raise
# Retrieve the contact again to see if extended fields survived
contacts_after = await nc_client.contacts.list_contacts(
addressbook=addressbook_name
)
updated_contact = next(
(c for c in contacts_after if c["vcard_id"] == contact_uid), None
)
assert updated_contact is not None, "Contact not found after update"
updated_addressdata = updated_contact["addressdata"]
logger.info("Raw vCard after contact update:")
logger.info(updated_addressdata)
# THIS IS THE CRITICAL TEST - extended fields should be preserved during updates
extended_field_checks = [
("ORG:Example Corporation", "organization field"),
("TITLE:Senior Developer", "title field"),
("TEL;TYPE=cell:+1-555-123-4567", "cell phone"),
("TEL;TYPE=work:+1-555-987-6543", "work phone"),
("ADR;TYPE=home:;;123 Main St;Hometown;ST;12345;USA", "home address"),
("ADR;TYPE=work:;;456 Work Ave;Worktown;ST;54321;USA", "work address"),
("URL;TYPE=work;VALUE=URI:https://company.com/john", "work URL"),
("NOTE:This is a note with important information", "note field"),
("CATEGORIES:colleagues,developers,friends", "categories"),
("X-CUSTOM-FIELD:This should be preserved", "custom field"),
("X-LINKEDIN:https://linkedin.com/in/johndoe", "LinkedIn custom field"),
("john.updated@example.com", "updated email"),
]
all_preserved = True
for field_pattern, field_name in extended_field_checks:
if field_pattern in updated_addressdata:
logger.info(f"{field_name} preserved")
else:
logger.error(f"{field_name} was lost during update")
all_preserved = False
# The test should PASS - field preservation should work
assert all_preserved, (
"Contact update lost extended field data - this indicates the preservation mechanism failed"
)
logger.info("🎉 SUCCESS: All extended fields preserved during contact update!")
finally:
# Cleanup
try:
await nc_client.contacts.delete_addressbook(name=addressbook_name)
except Exception as cleanup_error:
logger.warning(
f"Failed to cleanup addressbook {addressbook_name}: {cleanup_error}"
)
@pytest.mark.integration
async def test_calendar_event_roundtrip_data_loss_demonstration(nc_client):
"""Demonstrates specific data loss scenarios in calendar events."""
calendar_name = "personal"
event_data = {
"title": "Roundtrip Test Event",
"description": "Testing data preservation",
"start_datetime": (datetime.now() + timedelta(days=2)).isoformat(),
"end_datetime": (datetime.now() + timedelta(days=2, hours=1)).isoformat(),
}
result = await nc_client.calendar.create_event(calendar_name, event_data)
event_uid = result["uid"]
try:
# Inject additional iCal properties that are valid but not supported by our parser
extended_ical = f"""BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Extended Client//EN
BEGIN:VEVENT
UID:{event_uid}
DTSTART:{(datetime.now() + timedelta(days=2)).strftime("%Y%m%dT%H%M%SZ")}
DTEND:{(datetime.now() + timedelta(days=2, hours=1)).strftime("%Y%m%dT%H%M%SZ")}
SUMMARY:Roundtrip Test Event
DESCRIPTION:Testing data preservation
STATUS:CONFIRMED
PRIORITY:5
CLASS:PUBLIC
SEQUENCE:1
X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
X-MICROSOFT-CDO-IMPORTANCE:1
X-CUSTOM-MEETING-ID:12345-67890
X-ZOOM-MEETING-URL:https://zoom.us/j/1234567890
ORGANIZER;CN=Test Organizer:mailto:organizer@example.com
COMMENT:This is a comment that should be preserved
LOCATION:Conference Room A
GEO:40.7128;-74.0060
TRANSP:OPAQUE
CREATED:{datetime.now().strftime("%Y%m%dT%H%M%SZ")}
DTSTAMP:{datetime.now().strftime("%Y%m%dT%H%M%SZ")}
LAST-MODIFIED:{datetime.now().strftime("%Y%m%dT%H%M%SZ")}
END:VEVENT
END:VCALENDAR"""
# Inject the extended iCal
event_path = f"/remote.php/dav/calendars/{nc_client.calendar.username}/{calendar_name}/{event_uid}.ics"
await nc_client.calendar._make_request(
"PUT",
event_path,
content=extended_ical,
headers={"Content-Type": "text/calendar; charset=utf-8"},
)
# Verify extended properties are present
response = await nc_client.calendar._make_request(
"GET", event_path, headers={"Accept": "text/calendar"}
)
original_ical = response.text
# Confirm extended properties exist
extended_properties = [
"SEQUENCE:1",
"X-MICROSOFT-CDO-ALLDAYEVENT:FALSE",
"X-CUSTOM-MEETING-ID:12345-67890",
"X-ZOOM-MEETING-URL:https://zoom.us/j/1234567890",
"ORGANIZER;CN=Test Organizer:mailto:organizer@example.com",
"COMMENT:This is a comment that should be preserved",
"GEO:40.7128;-74.0060",
"TRANSP:OPAQUE",
]
# More flexible patterns for properties that might be reformatted
flexible_patterns = {
"ORGANIZER;CN=Test Organizer:mailto:organizer@example.com": [
"ORGANIZER;CN=Test Organizer:mailto:organizer@example.com",
'ORGANIZER;CN="Test Organizer":mailto:organizer@example.com',
],
"GEO:40.7128;-74.0060": [
"GEO:40.7128;-74.0060",
"GEO:40.7128;-74.006", # May lose trailing zero
],
}
for prop in extended_properties:
assert prop in original_ical, (
f"Extended property {prop} not found in original iCal"
)
logger.info("✓ All extended properties confirmed in original iCal")
# Now perform a simple update through MCP
update_data = {"location": "Conference Room B"} # Simple location change
await nc_client.calendar.update_event(calendar_name, event_uid, update_data)
# Check what survived the round-trip
response_after = await nc_client.calendar._make_request(
"GET", event_path, headers={"Accept": "text/calendar"}
)
updated_ical = response_after.text
logger.info("Checking which properties survived the update...")
# Check which extended properties survived
survived = []
lost = []
for prop in extended_properties:
# Check if this property has flexible patterns
if prop in flexible_patterns:
# Check if any of the flexible patterns match
found = any(
pattern in updated_ical for pattern in flexible_patterns[prop]
)
if found:
survived.append(prop)
else:
lost.append(prop)
else:
# Standard exact match
if prop in updated_ical:
survived.append(prop)
else:
lost.append(prop)
logger.info(f"Properties that SURVIVED: {survived}")
logger.error(f"Properties that were LOST: {lost}")
# This test should fail - we expect data loss
assert len(lost) == 0, (
f"Round-trip update lost {len(lost)} extended properties: {lost}"
)
finally:
try:
await nc_client.calendar.delete_event(calendar_name, event_uid)
except Exception as cleanup_error:
logger.warning(f"Failed to cleanup event {event_uid}: {cleanup_error}")
+143 -27
View File
@@ -24,7 +24,6 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession):
# Verify expected tools are present
expected_tools = [
"nc_get_note",
"nc_notes_create_note",
"nc_notes_update_note",
"nc_notes_append_content",
@@ -52,6 +51,7 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession):
"nc_calendar_find_availability",
"nc_calendar_bulk_operations",
"nc_calendar_manage_calendar",
"deck_create_board",
]
for expected_tool in expected_tools:
@@ -84,7 +84,7 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession):
resource_uris.append(str(resource.uri)) # Convert to string for comparison
# Verify expected resources
expected_resources = ["nc://capabilities", "notes://settings"]
expected_resources = ["nc://capabilities", "notes://settings", "nc://Deck/boards"]
for expected_resource in expected_resources:
assert expected_resource in resource_uris, (
@@ -124,8 +124,11 @@ async def test_mcp_notes_crud_workflow(
created_note = create_result.content[0].text
note_data = json.loads(created_note) # Parse the returned JSON
note_id = note_data["id"]
create_etag = note_data["etag"] # Verify create response includes ETag
logger.info(f"Note created via MCP with ID: {note_id}")
logger.info(f"Note created via MCP with ID: {note_id}, ETag: {create_etag}")
assert "etag" in note_data, "Create response should include ETag"
assert create_etag, "Create ETag should not be empty"
# 2. Verify creation via direct NextcloudClient
direct_note = await nc_client.notes.get_note(note_id)
@@ -137,11 +140,9 @@ 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.call_tool("nc_get_note", {"note_id": note_id})
assert read_result.isError is False, (
f"MCP note read failed: {read_result.content}"
)
read_note_data = json.loads(read_result.content[0].text)
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)
assert read_note_data["title"] == test_title
assert read_note_data["content"] == test_content
@@ -168,6 +169,15 @@ async def test_mcp_notes_crud_workflow(
f"MCP note update failed: {update_result.content}"
)
# Verify update response includes new ETag
updated_note_data = json.loads(update_result.content[0].text)
update_etag = updated_note_data["etag"]
logger.info(f"Note updated via MCP, new ETag: {update_etag}")
assert "etag" in updated_note_data, "Update response should include ETag"
assert update_etag, "Update ETag should not be empty"
assert update_etag != etag, "ETag should change after update"
# 5. Verify update via direct NextcloudClient
updated_direct_note = await nc_client.notes.get_note(note_id)
assert updated_direct_note["title"] == updated_title
@@ -184,6 +194,15 @@ async def test_mcp_notes_crud_workflow(
f"MCP note append failed: {append_result.content}"
)
# Verify append response includes new ETag
appended_note_data = json.loads(append_result.content[0].text)
append_etag = appended_note_data["etag"]
logger.info(f"Content appended via MCP, new ETag: {append_etag}")
assert "etag" in appended_note_data, "Append response should include ETag"
assert append_etag, "Append ETag should not be empty"
assert append_etag != update_etag, "ETag should change after append"
# 7. Verify append via direct NextcloudClient
appended_direct_note = await nc_client.notes.get_note(note_id)
assert append_content in appended_direct_note["content"]
@@ -199,14 +218,23 @@ async def test_mcp_notes_crud_workflow(
)
search_notes_text = search_result.content[0].text
logger.info(f"Search result text: {search_notes_text}")
search_notes = json.loads(search_notes_text)
search_response = json.loads(search_notes_text)
# Ensure search_notes is a list
if not isinstance(search_notes, list):
logger.warning(
f"Expected search results to be a list, got: {type(search_notes)}"
)
search_notes = [search_notes] if search_notes else []
# Expect structured response with Pydantic format
assert isinstance(search_response, dict), (
f"Expected search response to be a dict with structured format, got: {type(search_response)}"
)
assert "results" in search_response, (
f"Expected 'results' field in search response, got keys: {list(search_response.keys())}"
)
assert "success" in search_response and search_response["success"], (
f"Expected successful search response, got: {search_response}"
)
search_notes = search_response["results"]
assert isinstance(search_notes, list), (
f"Expected results to be a list, got: {type(search_notes)}"
)
# Find our note in search results
found_note = None
@@ -216,7 +244,7 @@ async def test_mcp_notes_crud_workflow(
break
assert found_note is not None, (
f"Created note not found in search results. Search returned: {search_notes}"
f"Created note not found in search results. Search returned: {search_response}"
)
assert found_note["title"] == updated_title
@@ -250,6 +278,82 @@ async def test_mcp_notes_crud_workflow(
logger.warning(f"Failed to cleanup note: {e}")
async def test_mcp_notes_etag_conflict(
nc_mcp_client: ClientSession, nc_client: NextcloudClient
):
"""Test that MCP note updates fail when using stale ETags."""
unique_suffix = uuid.uuid4().hex[:8]
test_title = f"ETag Test Note {unique_suffix}"
test_content = f"This is test content for ETag testing {unique_suffix}"
test_category = "ETTesting"
created_note = None
try:
# 1. Create note via MCP
logger.info(f"Creating note for ETag conflict test: {test_title}")
create_result = await nc_mcp_client.call_tool(
"nc_notes_create_note",
{"title": test_title, "content": test_content, "category": test_category},
)
assert create_result.isError is False
note_data = json.loads(create_result.content[0].text)
note_id = note_data["id"]
original_etag = note_data["etag"]
created_note = note_data
# 2. Update note to change ETag
first_update_result = await nc_mcp_client.call_tool(
"nc_notes_update_note",
{
"note_id": note_id,
"etag": original_etag,
"title": f"First Update {test_title}",
"content": test_content,
"category": test_category,
},
)
assert first_update_result.isError is False
updated_data = json.loads(first_update_result.content[0].text)
new_etag = updated_data["etag"]
assert new_etag != original_etag, "ETag should have changed after update"
# 3. Try to update with the stale (original) ETag - this should fail
logger.info(f"Attempting update with stale ETag: {original_etag}")
conflict_result = await nc_mcp_client.call_tool(
"nc_notes_update_note",
{
"note_id": note_id,
"etag": original_etag, # Use stale ETag
"title": "This should fail",
"content": "This update should be rejected",
"category": test_category,
},
)
# 4. Verify the update was rejected with a 412 error
# With McpError, tools now return proper error responses
assert conflict_result.isError is True, "Update with stale ETag should fail"
response_content = conflict_result.content[0].text
assert "modified by someone else" in response_content, (
f"Expected conflict error message, got: {response_content}"
)
logger.info("Successfully verified ETag conflict handling via MCP")
finally:
# Cleanup
if created_note is not None:
try:
await nc_client.notes.delete_note(created_note["id"])
logger.info(f"Cleaned up test note {created_note['id']}")
except Exception as e:
logger.warning(f"Failed to cleanup test note: {e}")
async def test_mcp_webdav_workflow(
nc_mcp_client: ClientSession, nc_client: NextcloudClient
):
@@ -431,21 +535,33 @@ async def test_mcp_calendar_workflow(
f"MCP calendar listing failed: {calendars_result.content}"
)
calendars_data = json.loads(calendars_result.content[0].text)
calendars_response = json.loads(calendars_result.content[0].text)
# Debug output to understand the structure
logger.info(f"calendars_data type: {type(calendars_data)}")
logger.info(f"calendars_data content: {calendars_data}")
logger.info(f"calendars_response type: {type(calendars_response)}")
logger.info(f"calendars_response content: {calendars_response}")
# Handle the case where MCP tool returns a single dict instead of a list
if isinstance(calendars_data, dict):
# Single calendar returned as dict instead of list
calendar_name = calendars_data["name"]
elif isinstance(calendars_data, list) and calendars_data:
# Normal case - list of calendars
calendar_name = calendars_data[0]["name"]
else:
# Expect structured response with Pydantic format
assert isinstance(calendars_response, dict), (
f"Expected calendar response to be a dict with structured format, got: {type(calendars_response)}"
)
assert "calendars" in calendars_response, (
f"Expected 'calendars' field in response, got keys: {list(calendars_response.keys())}"
)
assert "success" in calendars_response and calendars_response["success"], (
f"Expected successful calendar response, got: {calendars_response}"
)
calendars_list = calendars_response["calendars"]
assert isinstance(calendars_list, list), (
f"Expected calendars to be a list, got: {type(calendars_list)}"
)
if not calendars_list:
pytest.skip("No calendars available for testing")
# Use the first available calendar
calendar_name = calendars_list[0]["name"]
logger.info(f"Using calendar: {calendar_name}")
# 2. Create event via MCP
+84
View File
@@ -0,0 +1,84 @@
"""Unit tests for Pydantic models and serialization."""
from datetime import datetime, timezone
import json
import logging
import re
from nextcloud_mcp_server.models.base import BaseResponse
logger = logging.getLogger(__name__)
def test_timestamp_format_validation():
"""Test that timestamps in BaseResponse are RFC3339 compliant for MCP validation.
This test should initially fail, demonstrating the timestamp validation error
seen in MCP inspector. MCP expects RFC3339 format with timezone information.
"""
# Create a response object
response = BaseResponse()
# Serialize to JSON (mimics what MCP inspector sees)
json_str = response.model_dump_json()
data = json.loads(json_str)
timestamp_str = data["timestamp"]
# RFC3339 regex pattern (what MCP expects)
# Format: YYYY-MM-DDTHH:MM:SS[.ffffff][Z|±HH:MM]
rfc3339_pattern = (
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$"
)
# This assertion should FAIL with current implementation
assert re.match(rfc3339_pattern, timestamp_str), (
f"Timestamp '{timestamp_str}' is not RFC3339 compliant. "
f"MCP expects format like '2025-08-30T19:22:58.862377Z' or '2025-08-30T19:22:58.862377+00:00'"
)
def test_base_response_timestamp_is_utc():
"""Test that BaseResponse timestamps are in UTC timezone."""
response = BaseResponse()
# The timestamp should be timezone-aware and in UTC
assert response.timestamp.tzinfo is not None, (
"Timestamp should have timezone information"
)
assert response.timestamp.tzinfo == timezone.utc, (
"Timestamp should be in UTC timezone"
)
def test_serialized_timestamp_ends_with_z_or_offset():
"""Test that serialized timestamps have proper timezone suffix."""
response = BaseResponse()
json_str = response.model_dump_json()
data = json.loads(json_str)
timestamp_str = data["timestamp"]
# Should end with 'Z' (UTC) or timezone offset like '+00:00'
assert timestamp_str.endswith("Z") or re.search(
r"[+-]\d{2}:\d{2}$", timestamp_str
), (
f"Timestamp '{timestamp_str}' should end with 'Z' or timezone offset like '+00:00'"
)
def test_current_broken_format():
"""Test showing the current broken timestamp format that causes MCP validation errors."""
# This demonstrates what the current code produces
current_naive_dt = datetime.now()
current_format = current_naive_dt.isoformat()
# Show that current format lacks timezone info
assert "Z" not in current_format
assert "+" not in current_format
assert "-" not in current_format[-6:] # Check last 6 chars for timezone
logger.info(f"Current broken format: {current_format}")
logger.info(
"This format causes MCP validation errors because it lacks timezone information"
)
Generated
+142 -104
View File
@@ -1,5 +1,5 @@
version = 1
revision = 2
revision = 3
requires-python = ">=3.11"
[[package]]
@@ -272,11 +272,11 @@ wheels = [
[[package]]
name = "httpx-sse"
version = "0.4.0"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624, upload-time = "2023-12-22T08:01:21.083Z" }
sdist = { url = "https://files.pythonhosted.org/packages/6e/fa/66bd985dd0b7c109a3bcb89272ee0bfb7e2b4d06309ad7b38ff866734b2a/httpx_sse-0.4.1.tar.gz", hash = "sha256:8f44d34414bc7b21bf3602713005c5df4917884f76072479b21f68befa4ea26e", size = 12998, upload-time = "2025-06-24T13:21:05.71Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819, upload-time = "2023-12-22T08:01:19.89Z" },
{ url = "https://files.pythonhosted.org/packages/25/0a/6269e3473b09aed2dab8aa1a600c70f31f00ae1349bee30658f7e358a159/httpx_sse-0.4.1-py3-none-any.whl", hash = "sha256:cba42174344c3a5b06f255ce65b350880f962d99ead85e776f23c6618a377a37", size = 8054, upload-time = "2025-06-24T13:21:04.772Z" },
]
[[package]]
@@ -370,7 +370,7 @@ wheels = [
[[package]]
name = "jsonschema"
version = "4.24.0"
version = "4.25.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
@@ -378,33 +378,33 @@ dependencies = [
{ name = "referencing" },
{ name = "rpds-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bf/d3/1cf5326b923a53515d8f3a2cd442e6d7e94fcc444716e879ea70a0ce3177/jsonschema-4.24.0.tar.gz", hash = "sha256:0b4e8069eb12aedfa881333004bccaec24ecef5a8a6a4b6df142b2cc9599d196", size = 353480, upload-time = "2025-05-26T18:48:10.459Z" }
sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/3d/023389198f69c722d039351050738d6755376c8fd343e91dc493ea485905/jsonschema-4.24.0-py3-none-any.whl", hash = "sha256:a462455f19f5faf404a7902952b6f0e3ce868f3ee09a359b05eca6673bd8412d", size = 88709, upload-time = "2025-05-26T18:48:08.417Z" },
{ url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" },
]
[[package]]
name = "jsonschema-specifications"
version = "2025.4.1"
version = "2025.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "referencing" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bf/ce/46fbd9c8119cfc3581ee5643ea49464d168028cfb5caff5fc0596d0cf914/jsonschema_specifications-2025.4.1.tar.gz", hash = "sha256:630159c9f4dbea161a6a2205c3011cc4f18ff381b189fff48bb39b9bf26ae608", size = 15513, upload-time = "2025-04-23T12:34:07.418Z" }
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/01/0e/b27cdbaccf30b890c40ed1da9fd4a3593a5cf94dae54fb34f8a4b74fcd3f/jsonschema_specifications-2025.4.1-py3-none-any.whl", hash = "sha256:4653bffbd6584f7de83a67e0d620ef16900b390ddc7939d56684d6c81e33f1af", size = 18437, upload-time = "2025-04-23T12:34:05.422Z" },
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
]
[[package]]
name = "markdown-it-py"
version = "3.0.0"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" },
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[[package]]
@@ -505,13 +505,15 @@ wheels = [
[[package]]
name = "nextcloud-mcp-server"
version = "0.7.1"
version = "0.10.0"
source = { editable = "." }
dependencies = [
{ name = "click" },
{ name = "httpx" },
{ name = "icalendar" },
{ name = "mcp", extra = ["cli"] },
{ name = "pillow" },
{ name = "pydantic" },
{ name = "pythonvcard4" },
]
@@ -527,10 +529,12 @@ dev = [
[package.metadata]
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 = "pillow", specifier = ">=11.2.1,<12.0.0" },
{ name = "pydantic", specifier = ">=2.11.4" },
{ name = "pythonvcard4", specifier = ">=0.2.0" },
]
@@ -754,16 +758,16 @@ wheels = [
[[package]]
name = "pydantic-settings"
version = "2.9.1"
version = "2.10.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/67/1d/42628a2c33e93f8e9acbde0d5d735fa0850f3e6a2f8cb1eb6c40b9a732ac/pydantic_settings-2.9.1.tar.gz", hash = "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268", size = 163234, upload-time = "2025-04-18T16:44:48.265Z" }
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" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b6/5f/d6d641b490fd3ec2c4c13b4244d68deea3a1b970a97be64f34fb5504ff72/pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef", size = 44356, upload-time = "2025-04-18T16:44:46.617Z" },
{ 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" },
]
[[package]]
@@ -829,11 +833,11 @@ wheels = [
[[package]]
name = "python-dotenv"
version = "1.1.0"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920, upload-time = "2025-03-25T10:14:56.835Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256, upload-time = "2025-03-25T10:14:55.034Z" },
{ url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" },
]
[[package]]
@@ -917,89 +921,123 @@ wheels = [
[[package]]
name = "rich"
version = "14.0.0"
version = "14.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078, upload-time = "2025-03-30T14:15:14.23Z" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload-time = "2025-03-30T14:15:12.283Z" },
{ url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" },
]
[[package]]
name = "rpds-py"
version = "0.25.1"
version = "0.27.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/a6/60184b7fc00dd3ca80ac635dd5b8577d444c57e8e8742cecabfacb829921/rpds_py-0.25.1.tar.gz", hash = "sha256:8960b6dac09b62dac26e75d7e2c4a22efb835d827a7278c34f72b2b84fa160e3", size = 27304, upload-time = "2025-05-21T12:46:12.502Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e9/dd/2c0cbe774744272b0ae725f44032c77bdcab6e8bcf544bffa3b6e70c8dba/rpds_py-0.27.1.tar.gz", hash = "sha256:26a1c73171d10b7acccbded82bf6a586ab8203601e565badc74bbbf8bc5a10f8", size = 27479, upload-time = "2025-08-27T12:16:36.024Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/e1/df13fe3ddbbea43567e07437f097863b20c99318ae1f58a0fe389f763738/rpds_py-0.25.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:5f048bbf18b1f9120685c6d6bb70cc1a52c8cc11bdd04e643d28d3be0baf666d", size = 373341, upload-time = "2025-05-21T12:43:02.978Z" },
{ url = "https://files.pythonhosted.org/packages/7a/58/deef4d30fcbcbfef3b6d82d17c64490d5c94585a2310544ce8e2d3024f83/rpds_py-0.25.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fbb0dbba559959fcb5d0735a0f87cdbca9e95dac87982e9b95c0f8f7ad10255", size = 359111, upload-time = "2025-05-21T12:43:05.128Z" },
{ url = "https://files.pythonhosted.org/packages/bb/7e/39f1f4431b03e96ebaf159e29a0f82a77259d8f38b2dd474721eb3a8ac9b/rpds_py-0.25.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4ca54b9cf9d80b4016a67a0193ebe0bcf29f6b0a96f09db942087e294d3d4c2", size = 386112, upload-time = "2025-05-21T12:43:07.13Z" },
{ url = "https://files.pythonhosted.org/packages/db/e7/847068a48d63aec2ae695a1646089620b3b03f8ccf9f02c122ebaf778f3c/rpds_py-0.25.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ee3e26eb83d39b886d2cb6e06ea701bba82ef30a0de044d34626ede51ec98b0", size = 400362, upload-time = "2025-05-21T12:43:08.693Z" },
{ url = "https://files.pythonhosted.org/packages/3b/3d/9441d5db4343d0cee759a7ab4d67420a476cebb032081763de934719727b/rpds_py-0.25.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89706d0683c73a26f76a5315d893c051324d771196ae8b13e6ffa1ffaf5e574f", size = 522214, upload-time = "2025-05-21T12:43:10.694Z" },
{ url = "https://files.pythonhosted.org/packages/a2/ec/2cc5b30d95f9f1a432c79c7a2f65d85e52812a8f6cbf8768724571710786/rpds_py-0.25.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2013ee878c76269c7b557a9a9c042335d732e89d482606990b70a839635feb7", size = 411491, upload-time = "2025-05-21T12:43:12.739Z" },
{ url = "https://files.pythonhosted.org/packages/dc/6c/44695c1f035077a017dd472b6a3253553780837af2fac9b6ac25f6a5cb4d/rpds_py-0.25.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45e484db65e5380804afbec784522de84fa95e6bb92ef1bd3325d33d13efaebd", size = 386978, upload-time = "2025-05-21T12:43:14.25Z" },
{ url = "https://files.pythonhosted.org/packages/b1/74/b4357090bb1096db5392157b4e7ed8bb2417dc7799200fcbaee633a032c9/rpds_py-0.25.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:48d64155d02127c249695abb87d39f0faf410733428d499867606be138161d65", size = 420662, upload-time = "2025-05-21T12:43:15.8Z" },
{ url = "https://files.pythonhosted.org/packages/26/dd/8cadbebf47b96e59dfe8b35868e5c38a42272699324e95ed522da09d3a40/rpds_py-0.25.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:048893e902132fd6548a2e661fb38bf4896a89eea95ac5816cf443524a85556f", size = 563385, upload-time = "2025-05-21T12:43:17.78Z" },
{ url = "https://files.pythonhosted.org/packages/c3/ea/92960bb7f0e7a57a5ab233662f12152085c7dc0d5468534c65991a3d48c9/rpds_py-0.25.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0317177b1e8691ab5879f4f33f4b6dc55ad3b344399e23df2e499de7b10a548d", size = 592047, upload-time = "2025-05-21T12:43:19.457Z" },
{ url = "https://files.pythonhosted.org/packages/61/ad/71aabc93df0d05dabcb4b0c749277881f8e74548582d96aa1bf24379493a/rpds_py-0.25.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bffcf57826d77a4151962bf1701374e0fc87f536e56ec46f1abdd6a903354042", size = 557863, upload-time = "2025-05-21T12:43:21.69Z" },
{ url = "https://files.pythonhosted.org/packages/93/0f/89df0067c41f122b90b76f3660028a466eb287cbe38efec3ea70e637ca78/rpds_py-0.25.1-cp311-cp311-win32.whl", hash = "sha256:cda776f1967cb304816173b30994faaf2fd5bcb37e73118a47964a02c348e1bc", size = 219627, upload-time = "2025-05-21T12:43:23.311Z" },
{ url = "https://files.pythonhosted.org/packages/7c/8d/93b1a4c1baa903d0229374d9e7aa3466d751f1d65e268c52e6039c6e338e/rpds_py-0.25.1-cp311-cp311-win_amd64.whl", hash = "sha256:dc3c1ff0abc91444cd20ec643d0f805df9a3661fcacf9c95000329f3ddf268a4", size = 231603, upload-time = "2025-05-21T12:43:25.145Z" },
{ url = "https://files.pythonhosted.org/packages/cb/11/392605e5247bead2f23e6888e77229fbd714ac241ebbebb39a1e822c8815/rpds_py-0.25.1-cp311-cp311-win_arm64.whl", hash = "sha256:5a3ddb74b0985c4387719fc536faced33cadf2172769540c62e2a94b7b9be1c4", size = 223967, upload-time = "2025-05-21T12:43:26.566Z" },
{ url = "https://files.pythonhosted.org/packages/7f/81/28ab0408391b1dc57393653b6a0cf2014cc282cc2909e4615e63e58262be/rpds_py-0.25.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b5ffe453cde61f73fea9430223c81d29e2fbf412a6073951102146c84e19e34c", size = 364647, upload-time = "2025-05-21T12:43:28.559Z" },
{ url = "https://files.pythonhosted.org/packages/2c/9a/7797f04cad0d5e56310e1238434f71fc6939d0bc517192a18bb99a72a95f/rpds_py-0.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:115874ae5e2fdcfc16b2aedc95b5eef4aebe91b28e7e21951eda8a5dc0d3461b", size = 350454, upload-time = "2025-05-21T12:43:30.615Z" },
{ url = "https://files.pythonhosted.org/packages/69/3c/93d2ef941b04898011e5d6eaa56a1acf46a3b4c9f4b3ad1bbcbafa0bee1f/rpds_py-0.25.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a714bf6e5e81b0e570d01f56e0c89c6375101b8463999ead3a93a5d2a4af91fa", size = 389665, upload-time = "2025-05-21T12:43:32.629Z" },
{ url = "https://files.pythonhosted.org/packages/c1/57/ad0e31e928751dde8903a11102559628d24173428a0f85e25e187defb2c1/rpds_py-0.25.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:35634369325906bcd01577da4c19e3b9541a15e99f31e91a02d010816b49bfda", size = 403873, upload-time = "2025-05-21T12:43:34.576Z" },
{ url = "https://files.pythonhosted.org/packages/16/ad/c0c652fa9bba778b4f54980a02962748479dc09632e1fd34e5282cf2556c/rpds_py-0.25.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4cb2b3ddc16710548801c6fcc0cfcdeeff9dafbc983f77265877793f2660309", size = 525866, upload-time = "2025-05-21T12:43:36.123Z" },
{ url = "https://files.pythonhosted.org/packages/2a/39/3e1839bc527e6fcf48d5fec4770070f872cdee6c6fbc9b259932f4e88a38/rpds_py-0.25.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9ceca1cf097ed77e1a51f1dbc8d174d10cb5931c188a4505ff9f3e119dfe519b", size = 416886, upload-time = "2025-05-21T12:43:38.034Z" },
{ url = "https://files.pythonhosted.org/packages/7a/95/dd6b91cd4560da41df9d7030a038298a67d24f8ca38e150562644c829c48/rpds_py-0.25.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c2cd1a4b0c2b8c5e31ffff50d09f39906fe351389ba143c195566056c13a7ea", size = 390666, upload-time = "2025-05-21T12:43:40.065Z" },
{ url = "https://files.pythonhosted.org/packages/64/48/1be88a820e7494ce0a15c2d390ccb7c52212370badabf128e6a7bb4cb802/rpds_py-0.25.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1de336a4b164c9188cb23f3703adb74a7623ab32d20090d0e9bf499a2203ad65", size = 425109, upload-time = "2025-05-21T12:43:42.263Z" },
{ url = "https://files.pythonhosted.org/packages/cf/07/3e2a17927ef6d7720b9949ec1b37d1e963b829ad0387f7af18d923d5cfa5/rpds_py-0.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9fca84a15333e925dd59ce01da0ffe2ffe0d6e5d29a9eeba2148916d1824948c", size = 567244, upload-time = "2025-05-21T12:43:43.846Z" },
{ url = "https://files.pythonhosted.org/packages/d2/e5/76cf010998deccc4f95305d827847e2eae9c568099c06b405cf96384762b/rpds_py-0.25.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:88ec04afe0c59fa64e2f6ea0dd9657e04fc83e38de90f6de201954b4d4eb59bd", size = 596023, upload-time = "2025-05-21T12:43:45.932Z" },
{ url = "https://files.pythonhosted.org/packages/52/9a/df55efd84403736ba37a5a6377b70aad0fd1cb469a9109ee8a1e21299a1c/rpds_py-0.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a8bd2f19e312ce3e1d2c635618e8a8d8132892bb746a7cf74780a489f0f6cdcb", size = 561634, upload-time = "2025-05-21T12:43:48.263Z" },
{ url = "https://files.pythonhosted.org/packages/ab/aa/dc3620dd8db84454aaf9374bd318f1aa02578bba5e567f5bf6b79492aca4/rpds_py-0.25.1-cp312-cp312-win32.whl", hash = "sha256:e5e2f7280d8d0d3ef06f3ec1b4fd598d386cc6f0721e54f09109a8132182fbfe", size = 222713, upload-time = "2025-05-21T12:43:49.897Z" },
{ url = "https://files.pythonhosted.org/packages/a3/7f/7cef485269a50ed5b4e9bae145f512d2a111ca638ae70cc101f661b4defd/rpds_py-0.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:db58483f71c5db67d643857404da360dce3573031586034b7d59f245144cc192", size = 235280, upload-time = "2025-05-21T12:43:51.893Z" },
{ url = "https://files.pythonhosted.org/packages/99/f2/c2d64f6564f32af913bf5f3f7ae41c7c263c5ae4c4e8f1a17af8af66cd46/rpds_py-0.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:6d50841c425d16faf3206ddbba44c21aa3310a0cebc3c1cdfc3e3f4f9f6f5728", size = 225399, upload-time = "2025-05-21T12:43:53.351Z" },
{ url = "https://files.pythonhosted.org/packages/2b/da/323848a2b62abe6a0fec16ebe199dc6889c5d0a332458da8985b2980dffe/rpds_py-0.25.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:659d87430a8c8c704d52d094f5ba6fa72ef13b4d385b7e542a08fc240cb4a559", size = 364498, upload-time = "2025-05-21T12:43:54.841Z" },
{ url = "https://files.pythonhosted.org/packages/1f/b4/4d3820f731c80fd0cd823b3e95b9963fec681ae45ba35b5281a42382c67d/rpds_py-0.25.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:68f6f060f0bbdfb0245267da014d3a6da9be127fe3e8cc4a68c6f833f8a23bb1", size = 350083, upload-time = "2025-05-21T12:43:56.428Z" },
{ url = "https://files.pythonhosted.org/packages/d5/b1/3a8ee1c9d480e8493619a437dec685d005f706b69253286f50f498cbdbcf/rpds_py-0.25.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:083a9513a33e0b92cf6e7a6366036c6bb43ea595332c1ab5c8ae329e4bcc0a9c", size = 389023, upload-time = "2025-05-21T12:43:57.995Z" },
{ url = "https://files.pythonhosted.org/packages/3b/31/17293edcfc934dc62c3bf74a0cb449ecd549531f956b72287203e6880b87/rpds_py-0.25.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:816568614ecb22b18a010c7a12559c19f6fe993526af88e95a76d5a60b8b75fb", size = 403283, upload-time = "2025-05-21T12:43:59.546Z" },
{ url = "https://files.pythonhosted.org/packages/d1/ca/e0f0bc1a75a8925024f343258c8ecbd8828f8997ea2ac71e02f67b6f5299/rpds_py-0.25.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c6564c0947a7f52e4792983f8e6cf9bac140438ebf81f527a21d944f2fd0a40", size = 524634, upload-time = "2025-05-21T12:44:01.087Z" },
{ url = "https://files.pythonhosted.org/packages/3e/03/5d0be919037178fff33a6672ffc0afa04ea1cfcb61afd4119d1b5280ff0f/rpds_py-0.25.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c4a128527fe415d73cf1f70a9a688d06130d5810be69f3b553bf7b45e8acf79", size = 416233, upload-time = "2025-05-21T12:44:02.604Z" },
{ url = "https://files.pythonhosted.org/packages/05/7c/8abb70f9017a231c6c961a8941403ed6557664c0913e1bf413cbdc039e75/rpds_py-0.25.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a49e1d7a4978ed554f095430b89ecc23f42014a50ac385eb0c4d163ce213c325", size = 390375, upload-time = "2025-05-21T12:44:04.162Z" },
{ url = "https://files.pythonhosted.org/packages/7a/ac/a87f339f0e066b9535074a9f403b9313fd3892d4a164d5d5f5875ac9f29f/rpds_py-0.25.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d74ec9bc0e2feb81d3f16946b005748119c0f52a153f6db6a29e8cd68636f295", size = 424537, upload-time = "2025-05-21T12:44:06.175Z" },
{ url = "https://files.pythonhosted.org/packages/1f/8f/8d5c1567eaf8c8afe98a838dd24de5013ce6e8f53a01bd47fe8bb06b5533/rpds_py-0.25.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3af5b4cc10fa41e5bc64e5c198a1b2d2864337f8fcbb9a67e747e34002ce812b", size = 566425, upload-time = "2025-05-21T12:44:08.242Z" },
{ url = "https://files.pythonhosted.org/packages/95/33/03016a6be5663b389c8ab0bbbcca68d9e96af14faeff0a04affcb587e776/rpds_py-0.25.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:79dc317a5f1c51fd9c6a0c4f48209c6b8526d0524a6904fc1076476e79b00f98", size = 595197, upload-time = "2025-05-21T12:44:10.449Z" },
{ url = "https://files.pythonhosted.org/packages/33/8d/da9f4d3e208c82fda311bff0cf0a19579afceb77cf456e46c559a1c075ba/rpds_py-0.25.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1521031351865e0181bc585147624d66b3b00a84109b57fcb7a779c3ec3772cd", size = 561244, upload-time = "2025-05-21T12:44:12.387Z" },
{ url = "https://files.pythonhosted.org/packages/e2/b3/39d5dcf7c5f742ecd6dbc88f6f84ae54184b92f5f387a4053be2107b17f1/rpds_py-0.25.1-cp313-cp313-win32.whl", hash = "sha256:5d473be2b13600b93a5675d78f59e63b51b1ba2d0476893415dfbb5477e65b31", size = 222254, upload-time = "2025-05-21T12:44:14.261Z" },
{ url = "https://files.pythonhosted.org/packages/5f/19/2d6772c8eeb8302c5f834e6d0dfd83935a884e7c5ce16340c7eaf89ce925/rpds_py-0.25.1-cp313-cp313-win_amd64.whl", hash = "sha256:a7b74e92a3b212390bdce1d93da9f6488c3878c1d434c5e751cbc202c5e09500", size = 234741, upload-time = "2025-05-21T12:44:16.236Z" },
{ url = "https://files.pythonhosted.org/packages/5b/5a/145ada26cfaf86018d0eb304fe55eafdd4f0b6b84530246bb4a7c4fb5c4b/rpds_py-0.25.1-cp313-cp313-win_arm64.whl", hash = "sha256:dd326a81afe332ede08eb39ab75b301d5676802cdffd3a8f287a5f0b694dc3f5", size = 224830, upload-time = "2025-05-21T12:44:17.749Z" },
{ url = "https://files.pythonhosted.org/packages/4b/ca/d435844829c384fd2c22754ff65889c5c556a675d2ed9eb0e148435c6690/rpds_py-0.25.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:a58d1ed49a94d4183483a3ce0af22f20318d4a1434acee255d683ad90bf78129", size = 359668, upload-time = "2025-05-21T12:44:19.322Z" },
{ url = "https://files.pythonhosted.org/packages/1f/01/b056f21db3a09f89410d493d2f6614d87bb162499f98b649d1dbd2a81988/rpds_py-0.25.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f251bf23deb8332823aef1da169d5d89fa84c89f67bdfb566c49dea1fccfd50d", size = 345649, upload-time = "2025-05-21T12:44:20.962Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0f/e0d00dc991e3d40e03ca36383b44995126c36b3eafa0ccbbd19664709c88/rpds_py-0.25.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8dbd586bfa270c1103ece2109314dd423df1fa3d9719928b5d09e4840cec0d72", size = 384776, upload-time = "2025-05-21T12:44:22.516Z" },
{ url = "https://files.pythonhosted.org/packages/9f/a2/59374837f105f2ca79bde3c3cd1065b2f8c01678900924949f6392eab66d/rpds_py-0.25.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6d273f136e912aa101a9274c3145dcbddbe4bac560e77e6d5b3c9f6e0ed06d34", size = 395131, upload-time = "2025-05-21T12:44:24.147Z" },
{ url = "https://files.pythonhosted.org/packages/9c/dc/48e8d84887627a0fe0bac53f0b4631e90976fd5d35fff8be66b8e4f3916b/rpds_py-0.25.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:666fa7b1bd0a3810a7f18f6d3a25ccd8866291fbbc3c9b912b917a6715874bb9", size = 520942, upload-time = "2025-05-21T12:44:25.915Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f5/ee056966aeae401913d37befeeab57a4a43a4f00099e0a20297f17b8f00c/rpds_py-0.25.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:921954d7fbf3fccc7de8f717799304b14b6d9a45bbeec5a8d7408ccbf531faf5", size = 411330, upload-time = "2025-05-21T12:44:27.638Z" },
{ url = "https://files.pythonhosted.org/packages/ab/74/b2cffb46a097cefe5d17f94ede7a174184b9d158a0aeb195f39f2c0361e8/rpds_py-0.25.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3d86373ff19ca0441ebeb696ef64cb58b8b5cbacffcda5a0ec2f3911732a194", size = 387339, upload-time = "2025-05-21T12:44:29.292Z" },
{ url = "https://files.pythonhosted.org/packages/7f/9a/0ff0b375dcb5161c2b7054e7d0b7575f1680127505945f5cabaac890bc07/rpds_py-0.25.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c8980cde3bb8575e7c956a530f2c217c1d6aac453474bf3ea0f9c89868b531b6", size = 418077, upload-time = "2025-05-21T12:44:30.877Z" },
{ url = "https://files.pythonhosted.org/packages/0d/a1/fda629bf20d6b698ae84c7c840cfb0e9e4200f664fc96e1f456f00e4ad6e/rpds_py-0.25.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8eb8c84ecea987a2523e057c0d950bcb3f789696c0499290b8d7b3107a719d78", size = 562441, upload-time = "2025-05-21T12:44:32.541Z" },
{ url = "https://files.pythonhosted.org/packages/20/15/ce4b5257f654132f326f4acd87268e1006cc071e2c59794c5bdf4bebbb51/rpds_py-0.25.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:e43a005671a9ed5a650f3bc39e4dbccd6d4326b24fb5ea8be5f3a43a6f576c72", size = 590750, upload-time = "2025-05-21T12:44:34.557Z" },
{ url = "https://files.pythonhosted.org/packages/fb/ab/e04bf58a8d375aeedb5268edcc835c6a660ebf79d4384d8e0889439448b0/rpds_py-0.25.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:58f77c60956501a4a627749a6dcb78dac522f249dd96b5c9f1c6af29bfacfb66", size = 558891, upload-time = "2025-05-21T12:44:37.358Z" },
{ url = "https://files.pythonhosted.org/packages/90/82/cb8c6028a6ef6cd2b7991e2e4ced01c854b6236ecf51e81b64b569c43d73/rpds_py-0.25.1-cp313-cp313t-win32.whl", hash = "sha256:2cb9e5b5e26fc02c8a4345048cd9998c2aca7c2712bd1b36da0c72ee969a3523", size = 218718, upload-time = "2025-05-21T12:44:38.969Z" },
{ url = "https://files.pythonhosted.org/packages/b6/97/5a4b59697111c89477d20ba8a44df9ca16b41e737fa569d5ae8bff99e650/rpds_py-0.25.1-cp313-cp313t-win_amd64.whl", hash = "sha256:401ca1c4a20cc0510d3435d89c069fe0a9ae2ee6495135ac46bdd49ec0495763", size = 232218, upload-time = "2025-05-21T12:44:40.512Z" },
{ url = "https://files.pythonhosted.org/packages/49/74/48f3df0715a585cbf5d34919c9c757a4c92c1a9eba059f2d334e72471f70/rpds_py-0.25.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ee86d81551ec68a5c25373c5643d343150cc54672b5e9a0cafc93c1870a53954", size = 374208, upload-time = "2025-05-21T12:45:26.306Z" },
{ url = "https://files.pythonhosted.org/packages/55/b0/9b01bb11ce01ec03d05e627249cc2c06039d6aa24ea5a22a39c312167c10/rpds_py-0.25.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89c24300cd4a8e4a51e55c31a8ff3918e6651b241ee8876a42cc2b2a078533ba", size = 359262, upload-time = "2025-05-21T12:45:28.322Z" },
{ url = "https://files.pythonhosted.org/packages/a9/eb/5395621618f723ebd5116c53282052943a726dba111b49cd2071f785b665/rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:771c16060ff4e79584dc48902a91ba79fd93eade3aa3a12d6d2a4aadaf7d542b", size = 387366, upload-time = "2025-05-21T12:45:30.42Z" },
{ url = "https://files.pythonhosted.org/packages/68/73/3d51442bdb246db619d75039a50ea1cf8b5b4ee250c3e5cd5c3af5981cd4/rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:785ffacd0ee61c3e60bdfde93baa6d7c10d86f15655bd706c89da08068dc5038", size = 400759, upload-time = "2025-05-21T12:45:32.516Z" },
{ url = "https://files.pythonhosted.org/packages/b7/4c/3a32d5955d7e6cb117314597bc0f2224efc798428318b13073efe306512a/rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2a40046a529cc15cef88ac5ab589f83f739e2d332cb4d7399072242400ed68c9", size = 523128, upload-time = "2025-05-21T12:45:34.396Z" },
{ url = "https://files.pythonhosted.org/packages/be/95/1ffccd3b0bb901ae60b1dd4b1be2ab98bb4eb834cd9b15199888f5702f7b/rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:85fc223d9c76cabe5d0bff82214459189720dc135db45f9f66aa7cffbf9ff6c1", size = 411597, upload-time = "2025-05-21T12:45:36.164Z" },
{ url = "https://files.pythonhosted.org/packages/ef/6d/6e6cd310180689db8b0d2de7f7d1eabf3fb013f239e156ae0d5a1a85c27f/rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0be9965f93c222fb9b4cc254235b3b2b215796c03ef5ee64f995b1b69af0762", size = 388053, upload-time = "2025-05-21T12:45:38.45Z" },
{ url = "https://files.pythonhosted.org/packages/4a/87/ec4186b1fe6365ced6fa470960e68fc7804bafbe7c0cf5a36237aa240efa/rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8378fa4a940f3fb509c081e06cb7f7f2adae8cf46ef258b0e0ed7519facd573e", size = 421821, upload-time = "2025-05-21T12:45:40.732Z" },
{ url = "https://files.pythonhosted.org/packages/7a/60/84f821f6bf4e0e710acc5039d91f8f594fae0d93fc368704920d8971680d/rpds_py-0.25.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:33358883a4490287e67a2c391dfaea4d9359860281db3292b6886bf0be3d8692", size = 564534, upload-time = "2025-05-21T12:45:42.672Z" },
{ url = "https://files.pythonhosted.org/packages/41/3a/bc654eb15d3b38f9330fe0f545016ba154d89cdabc6177b0295910cd0ebe/rpds_py-0.25.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1d1fadd539298e70cac2f2cb36f5b8a65f742b9b9f1014dd4ea1f7785e2470bf", size = 592674, upload-time = "2025-05-21T12:45:44.533Z" },
{ url = "https://files.pythonhosted.org/packages/2e/ba/31239736f29e4dfc7a58a45955c5db852864c306131fd6320aea214d5437/rpds_py-0.25.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:9a46c2fb2545e21181445515960006e85d22025bd2fe6db23e76daec6eb689fe", size = 558781, upload-time = "2025-05-21T12:45:46.281Z" },
{ url = "https://files.pythonhosted.org/packages/b5/c1/7907329fbef97cbd49db6f7303893bd1dd5a4a3eae415839ffdfb0762cae/rpds_py-0.27.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:be898f271f851f68b318872ce6ebebbc62f303b654e43bf72683dbdc25b7c881", size = 371063, upload-time = "2025-08-27T12:12:47.856Z" },
{ url = "https://files.pythonhosted.org/packages/11/94/2aab4bc86228bcf7c48760990273653a4900de89c7537ffe1b0d6097ed39/rpds_py-0.27.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:62ac3d4e3e07b58ee0ddecd71d6ce3b1637de2d373501412df395a0ec5f9beb5", size = 353210, upload-time = "2025-08-27T12:12:49.187Z" },
{ url = "https://files.pythonhosted.org/packages/3a/57/f5eb3ecf434342f4f1a46009530e93fd201a0b5b83379034ebdb1d7c1a58/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4708c5c0ceb2d034f9991623631d3d23cb16e65c83736ea020cdbe28d57c0a0e", size = 381636, upload-time = "2025-08-27T12:12:50.492Z" },
{ url = "https://files.pythonhosted.org/packages/ae/f4/ef95c5945e2ceb5119571b184dd5a1cc4b8541bbdf67461998cfeac9cb1e/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:abfa1171a9952d2e0002aba2ad3780820b00cc3d9c98c6630f2e93271501f66c", size = 394341, upload-time = "2025-08-27T12:12:52.024Z" },
{ url = "https://files.pythonhosted.org/packages/5a/7e/4bd610754bf492d398b61725eb9598ddd5eb86b07d7d9483dbcd810e20bc/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b507d19f817ebaca79574b16eb2ae412e5c0835542c93fe9983f1e432aca195", size = 523428, upload-time = "2025-08-27T12:12:53.779Z" },
{ url = "https://files.pythonhosted.org/packages/9f/e5/059b9f65a8c9149361a8b75094864ab83b94718344db511fd6117936ed2a/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:168b025f8fd8d8d10957405f3fdcef3dc20f5982d398f90851f4abc58c566c52", size = 402923, upload-time = "2025-08-27T12:12:55.15Z" },
{ url = "https://files.pythonhosted.org/packages/f5/48/64cabb7daced2968dd08e8a1b7988bf358d7bd5bcd5dc89a652f4668543c/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb56c6210ef77caa58e16e8c17d35c63fe3f5b60fd9ba9d424470c3400bcf9ed", size = 384094, upload-time = "2025-08-27T12:12:57.194Z" },
{ url = "https://files.pythonhosted.org/packages/ae/e1/dc9094d6ff566bff87add8a510c89b9e158ad2ecd97ee26e677da29a9e1b/rpds_py-0.27.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:d252f2d8ca0195faa707f8eb9368955760880b2b42a8ee16d382bf5dd807f89a", size = 401093, upload-time = "2025-08-27T12:12:58.985Z" },
{ url = "https://files.pythonhosted.org/packages/37/8e/ac8577e3ecdd5593e283d46907d7011618994e1d7ab992711ae0f78b9937/rpds_py-0.27.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6e5e54da1e74b91dbc7996b56640f79b195d5925c2b78efaa8c5d53e1d88edde", size = 417969, upload-time = "2025-08-27T12:13:00.367Z" },
{ url = "https://files.pythonhosted.org/packages/66/6d/87507430a8f74a93556fe55c6485ba9c259949a853ce407b1e23fea5ba31/rpds_py-0.27.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ffce0481cc6e95e5b3f0a47ee17ffbd234399e6d532f394c8dce320c3b089c21", size = 558302, upload-time = "2025-08-27T12:13:01.737Z" },
{ url = "https://files.pythonhosted.org/packages/3a/bb/1db4781ce1dda3eecc735e3152659a27b90a02ca62bfeea17aee45cc0fbc/rpds_py-0.27.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a205fdfe55c90c2cd8e540ca9ceba65cbe6629b443bc05db1f590a3db8189ff9", size = 589259, upload-time = "2025-08-27T12:13:03.127Z" },
{ url = "https://files.pythonhosted.org/packages/7b/0e/ae1c8943d11a814d01b482e1f8da903f88047a962dff9bbdadf3bd6e6fd1/rpds_py-0.27.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:689fb5200a749db0415b092972e8eba85847c23885c8543a8b0f5c009b1a5948", size = 554983, upload-time = "2025-08-27T12:13:04.516Z" },
{ url = "https://files.pythonhosted.org/packages/b2/d5/0b2a55415931db4f112bdab072443ff76131b5ac4f4dc98d10d2d357eb03/rpds_py-0.27.1-cp311-cp311-win32.whl", hash = "sha256:3182af66048c00a075010bc7f4860f33913528a4b6fc09094a6e7598e462fe39", size = 217154, upload-time = "2025-08-27T12:13:06.278Z" },
{ url = "https://files.pythonhosted.org/packages/24/75/3b7ffe0d50dc86a6a964af0d1cc3a4a2cdf437cb7b099a4747bbb96d1819/rpds_py-0.27.1-cp311-cp311-win_amd64.whl", hash = "sha256:b4938466c6b257b2f5c4ff98acd8128ec36b5059e5c8f8372d79316b1c36bb15", size = 228627, upload-time = "2025-08-27T12:13:07.625Z" },
{ url = "https://files.pythonhosted.org/packages/8d/3f/4fd04c32abc02c710f09a72a30c9a55ea3cc154ef8099078fd50a0596f8e/rpds_py-0.27.1-cp311-cp311-win_arm64.whl", hash = "sha256:2f57af9b4d0793e53266ee4325535a31ba48e2f875da81a9177c9926dfa60746", size = 220998, upload-time = "2025-08-27T12:13:08.972Z" },
{ url = "https://files.pythonhosted.org/packages/bd/fe/38de28dee5df58b8198c743fe2bea0c785c6d40941b9950bac4cdb71a014/rpds_py-0.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ae2775c1973e3c30316892737b91f9283f9908e3cc7625b9331271eaaed7dc90", size = 361887, upload-time = "2025-08-27T12:13:10.233Z" },
{ url = "https://files.pythonhosted.org/packages/7c/9a/4b6c7eedc7dd90986bf0fab6ea2a091ec11c01b15f8ba0a14d3f80450468/rpds_py-0.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2643400120f55c8a96f7c9d858f7be0c88d383cd4653ae2cf0d0c88f668073e5", size = 345795, upload-time = "2025-08-27T12:13:11.65Z" },
{ url = "https://files.pythonhosted.org/packages/6f/0e/e650e1b81922847a09cca820237b0edee69416a01268b7754d506ade11ad/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16323f674c089b0360674a4abd28d5042947d54ba620f72514d69be4ff64845e", size = 385121, upload-time = "2025-08-27T12:13:13.008Z" },
{ url = "https://files.pythonhosted.org/packages/1b/ea/b306067a712988e2bff00dcc7c8f31d26c29b6d5931b461aa4b60a013e33/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a1f4814b65eacac94a00fc9a526e3fdafd78e439469644032032d0d63de4881", size = 398976, upload-time = "2025-08-27T12:13:14.368Z" },
{ url = "https://files.pythonhosted.org/packages/2c/0a/26dc43c8840cb8fe239fe12dbc8d8de40f2365e838f3d395835dde72f0e5/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ba32c16b064267b22f1850a34051121d423b6f7338a12b9459550eb2096e7ec", size = 525953, upload-time = "2025-08-27T12:13:15.774Z" },
{ url = "https://files.pythonhosted.org/packages/22/14/c85e8127b573aaf3a0cbd7fbb8c9c99e735a4a02180c84da2a463b766e9e/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5c20f33fd10485b80f65e800bbe5f6785af510b9f4056c5a3c612ebc83ba6cb", size = 407915, upload-time = "2025-08-27T12:13:17.379Z" },
{ url = "https://files.pythonhosted.org/packages/ed/7b/8f4fee9ba1fb5ec856eb22d725a4efa3deb47f769597c809e03578b0f9d9/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:466bfe65bd932da36ff279ddd92de56b042f2266d752719beb97b08526268ec5", size = 386883, upload-time = "2025-08-27T12:13:18.704Z" },
{ url = "https://files.pythonhosted.org/packages/86/47/28fa6d60f8b74fcdceba81b272f8d9836ac0340570f68f5df6b41838547b/rpds_py-0.27.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:41e532bbdcb57c92ba3be62c42e9f096431b4cf478da9bc3bc6ce5c38ab7ba7a", size = 405699, upload-time = "2025-08-27T12:13:20.089Z" },
{ url = "https://files.pythonhosted.org/packages/d0/fd/c5987b5e054548df56953a21fe2ebed51fc1ec7c8f24fd41c067b68c4a0a/rpds_py-0.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f149826d742b406579466283769a8ea448eed82a789af0ed17b0cd5770433444", size = 423713, upload-time = "2025-08-27T12:13:21.436Z" },
{ url = "https://files.pythonhosted.org/packages/ac/ba/3c4978b54a73ed19a7d74531be37a8bcc542d917c770e14d372b8daea186/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80c60cfb5310677bd67cb1e85a1e8eb52e12529545441b43e6f14d90b878775a", size = 562324, upload-time = "2025-08-27T12:13:22.789Z" },
{ url = "https://files.pythonhosted.org/packages/b5/6c/6943a91768fec16db09a42b08644b960cff540c66aab89b74be6d4a144ba/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7ee6521b9baf06085f62ba9c7a3e5becffbc32480d2f1b351559c001c38ce4c1", size = 593646, upload-time = "2025-08-27T12:13:24.122Z" },
{ url = "https://files.pythonhosted.org/packages/11/73/9d7a8f4be5f4396f011a6bb7a19fe26303a0dac9064462f5651ced2f572f/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a512c8263249a9d68cac08b05dd59d2b3f2061d99b322813cbcc14c3c7421998", size = 558137, upload-time = "2025-08-27T12:13:25.557Z" },
{ url = "https://files.pythonhosted.org/packages/6e/96/6772cbfa0e2485bcceef8071de7821f81aeac8bb45fbfd5542a3e8108165/rpds_py-0.27.1-cp312-cp312-win32.whl", hash = "sha256:819064fa048ba01b6dadc5116f3ac48610435ac9a0058bbde98e569f9e785c39", size = 221343, upload-time = "2025-08-27T12:13:26.967Z" },
{ url = "https://files.pythonhosted.org/packages/67/b6/c82f0faa9af1c6a64669f73a17ee0eeef25aff30bb9a1c318509efe45d84/rpds_py-0.27.1-cp312-cp312-win_amd64.whl", hash = "sha256:d9199717881f13c32c4046a15f024971a3b78ad4ea029e8da6b86e5aa9cf4594", size = 232497, upload-time = "2025-08-27T12:13:28.326Z" },
{ url = "https://files.pythonhosted.org/packages/e1/96/2817b44bd2ed11aebacc9251da03689d56109b9aba5e311297b6902136e2/rpds_py-0.27.1-cp312-cp312-win_arm64.whl", hash = "sha256:33aa65b97826a0e885ef6e278fbd934e98cdcfed80b63946025f01e2f5b29502", size = 222790, upload-time = "2025-08-27T12:13:29.71Z" },
{ url = "https://files.pythonhosted.org/packages/cc/77/610aeee8d41e39080c7e14afa5387138e3c9fa9756ab893d09d99e7d8e98/rpds_py-0.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e4b9fcfbc021633863a37e92571d6f91851fa656f0180246e84cbd8b3f6b329b", size = 361741, upload-time = "2025-08-27T12:13:31.039Z" },
{ url = "https://files.pythonhosted.org/packages/3a/fc/c43765f201c6a1c60be2043cbdb664013def52460a4c7adace89d6682bf4/rpds_py-0.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1441811a96eadca93c517d08df75de45e5ffe68aa3089924f963c782c4b898cf", size = 345574, upload-time = "2025-08-27T12:13:32.902Z" },
{ url = "https://files.pythonhosted.org/packages/20/42/ee2b2ca114294cd9847d0ef9c26d2b0851b2e7e00bf14cc4c0b581df0fc3/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55266dafa22e672f5a4f65019015f90336ed31c6383bd53f5e7826d21a0e0b83", size = 385051, upload-time = "2025-08-27T12:13:34.228Z" },
{ url = "https://files.pythonhosted.org/packages/fd/e8/1e430fe311e4799e02e2d1af7c765f024e95e17d651612425b226705f910/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d78827d7ac08627ea2c8e02c9e5b41180ea5ea1f747e9db0915e3adf36b62dcf", size = 398395, upload-time = "2025-08-27T12:13:36.132Z" },
{ url = "https://files.pythonhosted.org/packages/82/95/9dc227d441ff2670651c27a739acb2535ccaf8b351a88d78c088965e5996/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae92443798a40a92dc5f0b01d8a7c93adde0c4dc965310a29ae7c64d72b9fad2", size = 524334, upload-time = "2025-08-27T12:13:37.562Z" },
{ url = "https://files.pythonhosted.org/packages/87/01/a670c232f401d9ad461d9a332aa4080cd3cb1d1df18213dbd0d2a6a7ab51/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c46c9dd2403b66a2a3b9720ec4b74d4ab49d4fabf9f03dfdce2d42af913fe8d0", size = 407691, upload-time = "2025-08-27T12:13:38.94Z" },
{ url = "https://files.pythonhosted.org/packages/03/36/0a14aebbaa26fe7fab4780c76f2239e76cc95a0090bdb25e31d95c492fcd/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2efe4eb1d01b7f5f1939f4ef30ecea6c6b3521eec451fb93191bf84b2a522418", size = 386868, upload-time = "2025-08-27T12:13:40.192Z" },
{ url = "https://files.pythonhosted.org/packages/3b/03/8c897fb8b5347ff6c1cc31239b9611c5bf79d78c984430887a353e1409a1/rpds_py-0.27.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:15d3b4d83582d10c601f481eca29c3f138d44c92187d197aff663a269197c02d", size = 405469, upload-time = "2025-08-27T12:13:41.496Z" },
{ url = "https://files.pythonhosted.org/packages/da/07/88c60edc2df74850d496d78a1fdcdc7b54360a7f610a4d50008309d41b94/rpds_py-0.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4ed2e16abbc982a169d30d1a420274a709949e2cbdef119fe2ec9d870b42f274", size = 422125, upload-time = "2025-08-27T12:13:42.802Z" },
{ url = "https://files.pythonhosted.org/packages/6b/86/5f4c707603e41b05f191a749984f390dabcbc467cf833769b47bf14ba04f/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a75f305c9b013289121ec0f1181931975df78738cdf650093e6b86d74aa7d8dd", size = 562341, upload-time = "2025-08-27T12:13:44.472Z" },
{ url = "https://files.pythonhosted.org/packages/b2/92/3c0cb2492094e3cd9baf9e49bbb7befeceb584ea0c1a8b5939dca4da12e5/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:67ce7620704745881a3d4b0ada80ab4d99df390838839921f99e63c474f82cf2", size = 592511, upload-time = "2025-08-27T12:13:45.898Z" },
{ url = "https://files.pythonhosted.org/packages/10/bb/82e64fbb0047c46a168faa28d0d45a7851cd0582f850b966811d30f67ad8/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d992ac10eb86d9b6f369647b6a3f412fc0075cfd5d799530e84d335e440a002", size = 557736, upload-time = "2025-08-27T12:13:47.408Z" },
{ url = "https://files.pythonhosted.org/packages/00/95/3c863973d409210da7fb41958172c6b7dbe7fc34e04d3cc1f10bb85e979f/rpds_py-0.27.1-cp313-cp313-win32.whl", hash = "sha256:4f75e4bd8ab8db624e02c8e2fc4063021b58becdbe6df793a8111d9343aec1e3", size = 221462, upload-time = "2025-08-27T12:13:48.742Z" },
{ url = "https://files.pythonhosted.org/packages/ce/2c/5867b14a81dc217b56d95a9f2a40fdbc56a1ab0181b80132beeecbd4b2d6/rpds_py-0.27.1-cp313-cp313-win_amd64.whl", hash = "sha256:f9025faafc62ed0b75a53e541895ca272815bec18abe2249ff6501c8f2e12b83", size = 232034, upload-time = "2025-08-27T12:13:50.11Z" },
{ url = "https://files.pythonhosted.org/packages/c7/78/3958f3f018c01923823f1e47f1cc338e398814b92d83cd278364446fac66/rpds_py-0.27.1-cp313-cp313-win_arm64.whl", hash = "sha256:ed10dc32829e7d222b7d3b93136d25a406ba9788f6a7ebf6809092da1f4d279d", size = 222392, upload-time = "2025-08-27T12:13:52.587Z" },
{ url = "https://files.pythonhosted.org/packages/01/76/1cdf1f91aed5c3a7bf2eba1f1c4e4d6f57832d73003919a20118870ea659/rpds_py-0.27.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:92022bbbad0d4426e616815b16bc4127f83c9a74940e1ccf3cfe0b387aba0228", size = 358355, upload-time = "2025-08-27T12:13:54.012Z" },
{ url = "https://files.pythonhosted.org/packages/c3/6f/bf142541229374287604caf3bb2a4ae17f0a580798fd72d3b009b532db4e/rpds_py-0.27.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:47162fdab9407ec3f160805ac3e154df042e577dd53341745fc7fb3f625e6d92", size = 342138, upload-time = "2025-08-27T12:13:55.791Z" },
{ url = "https://files.pythonhosted.org/packages/1a/77/355b1c041d6be40886c44ff5e798b4e2769e497b790f0f7fd1e78d17e9a8/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb89bec23fddc489e5d78b550a7b773557c9ab58b7946154a10a6f7a214a48b2", size = 380247, upload-time = "2025-08-27T12:13:57.683Z" },
{ url = "https://files.pythonhosted.org/packages/d6/a4/d9cef5c3946ea271ce2243c51481971cd6e34f21925af2783dd17b26e815/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e48af21883ded2b3e9eb48cb7880ad8598b31ab752ff3be6457001d78f416723", size = 390699, upload-time = "2025-08-27T12:13:59.137Z" },
{ url = "https://files.pythonhosted.org/packages/3a/06/005106a7b8c6c1a7e91b73169e49870f4af5256119d34a361ae5240a0c1d/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f5b7bd8e219ed50299e58551a410b64daafb5017d54bbe822e003856f06a802", size = 521852, upload-time = "2025-08-27T12:14:00.583Z" },
{ url = "https://files.pythonhosted.org/packages/e5/3e/50fb1dac0948e17a02eb05c24510a8fe12d5ce8561c6b7b7d1339ab7ab9c/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08f1e20bccf73b08d12d804d6e1c22ca5530e71659e6673bce31a6bb71c1e73f", size = 402582, upload-time = "2025-08-27T12:14:02.034Z" },
{ url = "https://files.pythonhosted.org/packages/cb/b0/f4e224090dc5b0ec15f31a02d746ab24101dd430847c4d99123798661bfc/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dc5dceeaefcc96dc192e3a80bbe1d6c410c469e97bdd47494a7d930987f18b2", size = 384126, upload-time = "2025-08-27T12:14:03.437Z" },
{ url = "https://files.pythonhosted.org/packages/54/77/ac339d5f82b6afff1df8f0fe0d2145cc827992cb5f8eeb90fc9f31ef7a63/rpds_py-0.27.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:d76f9cc8665acdc0c9177043746775aa7babbf479b5520b78ae4002d889f5c21", size = 399486, upload-time = "2025-08-27T12:14:05.443Z" },
{ url = "https://files.pythonhosted.org/packages/d6/29/3e1c255eee6ac358c056a57d6d6869baa00a62fa32eea5ee0632039c50a3/rpds_py-0.27.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:134fae0e36022edad8290a6661edf40c023562964efea0cc0ec7f5d392d2aaef", size = 414832, upload-time = "2025-08-27T12:14:06.902Z" },
{ url = "https://files.pythonhosted.org/packages/3f/db/6d498b844342deb3fa1d030598db93937a9964fcf5cb4da4feb5f17be34b/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb11a4f1b2b63337cfd3b4d110af778a59aae51c81d195768e353d8b52f88081", size = 557249, upload-time = "2025-08-27T12:14:08.37Z" },
{ url = "https://files.pythonhosted.org/packages/60/f3/690dd38e2310b6f68858a331399b4d6dbb9132c3e8ef8b4333b96caf403d/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:13e608ac9f50a0ed4faec0e90ece76ae33b34c0e8656e3dceb9a7db994c692cd", size = 587356, upload-time = "2025-08-27T12:14:10.034Z" },
{ url = "https://files.pythonhosted.org/packages/86/e3/84507781cccd0145f35b1dc32c72675200c5ce8d5b30f813e49424ef68fc/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dd2135527aa40f061350c3f8f89da2644de26cd73e4de458e79606384f4f68e7", size = 555300, upload-time = "2025-08-27T12:14:11.783Z" },
{ url = "https://files.pythonhosted.org/packages/e5/ee/375469849e6b429b3516206b4580a79e9ef3eb12920ddbd4492b56eaacbe/rpds_py-0.27.1-cp313-cp313t-win32.whl", hash = "sha256:3020724ade63fe320a972e2ffd93b5623227e684315adce194941167fee02688", size = 216714, upload-time = "2025-08-27T12:14:13.629Z" },
{ url = "https://files.pythonhosted.org/packages/21/87/3fc94e47c9bd0742660e84706c311a860dcae4374cf4a03c477e23ce605a/rpds_py-0.27.1-cp313-cp313t-win_amd64.whl", hash = "sha256:8ee50c3e41739886606388ba3ab3ee2aae9f35fb23f833091833255a31740797", size = 228943, upload-time = "2025-08-27T12:14:14.937Z" },
{ url = "https://files.pythonhosted.org/packages/70/36/b6e6066520a07cf029d385de869729a895917b411e777ab1cde878100a1d/rpds_py-0.27.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:acb9aafccaae278f449d9c713b64a9e68662e7799dbd5859e2c6b3c67b56d334", size = 362472, upload-time = "2025-08-27T12:14:16.333Z" },
{ url = "https://files.pythonhosted.org/packages/af/07/b4646032e0dcec0df9c73a3bd52f63bc6c5f9cda992f06bd0e73fe3fbebd/rpds_py-0.27.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b7fb801aa7f845ddf601c49630deeeccde7ce10065561d92729bfe81bd21fb33", size = 345676, upload-time = "2025-08-27T12:14:17.764Z" },
{ url = "https://files.pythonhosted.org/packages/b0/16/2f1003ee5d0af4bcb13c0cf894957984c32a6751ed7206db2aee7379a55e/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe0dd05afb46597b9a2e11c351e5e4283c741237e7f617ffb3252780cca9336a", size = 385313, upload-time = "2025-08-27T12:14:19.829Z" },
{ url = "https://files.pythonhosted.org/packages/05/cd/7eb6dd7b232e7f2654d03fa07f1414d7dfc980e82ba71e40a7c46fd95484/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b6dfb0e058adb12d8b1d1b25f686e94ffa65d9995a5157afe99743bf7369d62b", size = 399080, upload-time = "2025-08-27T12:14:21.531Z" },
{ url = "https://files.pythonhosted.org/packages/20/51/5829afd5000ec1cb60f304711f02572d619040aa3ec033d8226817d1e571/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed090ccd235f6fa8bb5861684567f0a83e04f52dfc2e5c05f2e4b1309fcf85e7", size = 523868, upload-time = "2025-08-27T12:14:23.485Z" },
{ url = "https://files.pythonhosted.org/packages/05/2c/30eebca20d5db95720ab4d2faec1b5e4c1025c473f703738c371241476a2/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf876e79763eecf3e7356f157540d6a093cef395b65514f17a356f62af6cc136", size = 408750, upload-time = "2025-08-27T12:14:24.924Z" },
{ url = "https://files.pythonhosted.org/packages/90/1a/cdb5083f043597c4d4276eae4e4c70c55ab5accec078da8611f24575a367/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12ed005216a51b1d6e2b02a7bd31885fe317e45897de81d86dcce7d74618ffff", size = 387688, upload-time = "2025-08-27T12:14:27.537Z" },
{ url = "https://files.pythonhosted.org/packages/7c/92/cf786a15320e173f945d205ab31585cc43969743bb1a48b6888f7a2b0a2d/rpds_py-0.27.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:ee4308f409a40e50593c7e3bb8cbe0b4d4c66d1674a316324f0c2f5383b486f9", size = 407225, upload-time = "2025-08-27T12:14:28.981Z" },
{ url = "https://files.pythonhosted.org/packages/33/5c/85ee16df5b65063ef26017bef33096557a4c83fbe56218ac7cd8c235f16d/rpds_py-0.27.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0b08d152555acf1f455154d498ca855618c1378ec810646fcd7c76416ac6dc60", size = 423361, upload-time = "2025-08-27T12:14:30.469Z" },
{ url = "https://files.pythonhosted.org/packages/4b/8e/1c2741307fcabd1a334ecf008e92c4f47bb6f848712cf15c923becfe82bb/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dce51c828941973a5684d458214d3a36fcd28da3e1875d659388f4f9f12cc33e", size = 562493, upload-time = "2025-08-27T12:14:31.987Z" },
{ url = "https://files.pythonhosted.org/packages/04/03/5159321baae9b2222442a70c1f988cbbd66b9be0675dd3936461269be360/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c1476d6f29eb81aa4151c9a31219b03f1f798dc43d8af1250a870735516a1212", size = 592623, upload-time = "2025-08-27T12:14:33.543Z" },
{ url = "https://files.pythonhosted.org/packages/ff/39/c09fd1ad28b85bc1d4554a8710233c9f4cefd03d7717a1b8fbfd171d1167/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3ce0cac322b0d69b63c9cdb895ee1b65805ec9ffad37639f291dd79467bee675", size = 558800, upload-time = "2025-08-27T12:14:35.436Z" },
{ url = "https://files.pythonhosted.org/packages/c5/d6/99228e6bbcf4baa764b18258f519a9035131d91b538d4e0e294313462a98/rpds_py-0.27.1-cp314-cp314-win32.whl", hash = "sha256:dfbfac137d2a3d0725758cd141f878bf4329ba25e34979797c89474a89a8a3a3", size = 221943, upload-time = "2025-08-27T12:14:36.898Z" },
{ url = "https://files.pythonhosted.org/packages/be/07/c802bc6b8e95be83b79bdf23d1aa61d68324cb1006e245d6c58e959e314d/rpds_py-0.27.1-cp314-cp314-win_amd64.whl", hash = "sha256:a6e57b0abfe7cc513450fcf529eb486b6e4d3f8aee83e92eb5f1ef848218d456", size = 233739, upload-time = "2025-08-27T12:14:38.386Z" },
{ url = "https://files.pythonhosted.org/packages/c8/89/3e1b1c16d4c2d547c5717377a8df99aee8099ff050f87c45cb4d5fa70891/rpds_py-0.27.1-cp314-cp314-win_arm64.whl", hash = "sha256:faf8d146f3d476abfee026c4ae3bdd9ca14236ae4e4c310cbd1cf75ba33d24a3", size = 223120, upload-time = "2025-08-27T12:14:39.82Z" },
{ url = "https://files.pythonhosted.org/packages/62/7e/dc7931dc2fa4a6e46b2a4fa744a9fe5c548efd70e0ba74f40b39fa4a8c10/rpds_py-0.27.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:ba81d2b56b6d4911ce735aad0a1d4495e808b8ee4dc58715998741a26874e7c2", size = 358944, upload-time = "2025-08-27T12:14:41.199Z" },
{ url = "https://files.pythonhosted.org/packages/e6/22/4af76ac4e9f336bfb1a5f240d18a33c6b2fcaadb7472ac7680576512b49a/rpds_py-0.27.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:84f7d509870098de0e864cad0102711c1e24e9b1a50ee713b65928adb22269e4", size = 342283, upload-time = "2025-08-27T12:14:42.699Z" },
{ url = "https://files.pythonhosted.org/packages/1c/15/2a7c619b3c2272ea9feb9ade67a45c40b3eeb500d503ad4c28c395dc51b4/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e960fc78fecd1100539f14132425e1d5fe44ecb9239f8f27f079962021523e", size = 380320, upload-time = "2025-08-27T12:14:44.157Z" },
{ url = "https://files.pythonhosted.org/packages/a2/7d/4c6d243ba4a3057e994bb5bedd01b5c963c12fe38dde707a52acdb3849e7/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62f85b665cedab1a503747617393573995dac4600ff51869d69ad2f39eb5e817", size = 391760, upload-time = "2025-08-27T12:14:45.845Z" },
{ url = "https://files.pythonhosted.org/packages/b4/71/b19401a909b83bcd67f90221330bc1ef11bc486fe4e04c24388d28a618ae/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fed467af29776f6556250c9ed85ea5a4dd121ab56a5f8b206e3e7a4c551e48ec", size = 522476, upload-time = "2025-08-27T12:14:47.364Z" },
{ url = "https://files.pythonhosted.org/packages/e4/44/1a3b9715c0455d2e2f0f6df5ee6d6f5afdc423d0773a8a682ed2b43c566c/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2729615f9d430af0ae6b36cf042cb55c0936408d543fb691e1a9e36648fd35a", size = 403418, upload-time = "2025-08-27T12:14:49.991Z" },
{ url = "https://files.pythonhosted.org/packages/1c/4b/fb6c4f14984eb56673bc868a66536f53417ddb13ed44b391998100a06a96/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b207d881a9aef7ba753d69c123a35d96ca7cb808056998f6b9e8747321f03b8", size = 384771, upload-time = "2025-08-27T12:14:52.159Z" },
{ url = "https://files.pythonhosted.org/packages/c0/56/d5265d2d28b7420d7b4d4d85cad8ef891760f5135102e60d5c970b976e41/rpds_py-0.27.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:639fd5efec029f99b79ae47e5d7e00ad8a773da899b6309f6786ecaf22948c48", size = 400022, upload-time = "2025-08-27T12:14:53.859Z" },
{ url = "https://files.pythonhosted.org/packages/8f/e9/9f5fc70164a569bdd6ed9046486c3568d6926e3a49bdefeeccfb18655875/rpds_py-0.27.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fecc80cb2a90e28af8a9b366edacf33d7a91cbfe4c2c4544ea1246e949cfebeb", size = 416787, upload-time = "2025-08-27T12:14:55.673Z" },
{ url = "https://files.pythonhosted.org/packages/d4/64/56dd03430ba491db943a81dcdef115a985aac5f44f565cd39a00c766d45c/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42a89282d711711d0a62d6f57d81aa43a1368686c45bc1c46b7f079d55692734", size = 557538, upload-time = "2025-08-27T12:14:57.245Z" },
{ url = "https://files.pythonhosted.org/packages/3f/36/92cc885a3129993b1d963a2a42ecf64e6a8e129d2c7cc980dbeba84e55fb/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:cf9931f14223de59551ab9d38ed18d92f14f055a5f78c1d8ad6493f735021bbb", size = 588512, upload-time = "2025-08-27T12:14:58.728Z" },
{ url = "https://files.pythonhosted.org/packages/dd/10/6b283707780a81919f71625351182b4f98932ac89a09023cb61865136244/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f39f58a27cc6e59f432b568ed8429c7e1641324fbe38131de852cd77b2d534b0", size = 555813, upload-time = "2025-08-27T12:15:00.334Z" },
{ url = "https://files.pythonhosted.org/packages/04/2e/30b5ea18c01379da6272a92825dd7e53dc9d15c88a19e97932d35d430ef7/rpds_py-0.27.1-cp314-cp314t-win32.whl", hash = "sha256:d5fa0ee122dc09e23607a28e6d7b150da16c662e66409bbe85230e4c85bb528a", size = 217385, upload-time = "2025-08-27T12:15:01.937Z" },
{ url = "https://files.pythonhosted.org/packages/32/7d/97119da51cb1dd3f2f3c0805f155a3aa4a95fa44fe7d78ae15e69edf4f34/rpds_py-0.27.1-cp314-cp314t-win_amd64.whl", hash = "sha256:6567d2bb951e21232c2f660c24cf3470bb96de56cdcb3f071a83feeaff8a2772", size = 230097, upload-time = "2025-08-27T12:15:03.961Z" },
{ url = "https://files.pythonhosted.org/packages/0c/ed/e1fba02de17f4f76318b834425257c8ea297e415e12c68b4361f63e8ae92/rpds_py-0.27.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cdfe4bb2f9fe7458b7453ad3c33e726d6d1c7c0a72960bcc23800d77384e42df", size = 371402, upload-time = "2025-08-27T12:15:51.561Z" },
{ url = "https://files.pythonhosted.org/packages/af/7c/e16b959b316048b55585a697e94add55a4ae0d984434d279ea83442e460d/rpds_py-0.27.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8fabb8fd848a5f75a2324e4a84501ee3a5e3c78d8603f83475441866e60b94a3", size = 354084, upload-time = "2025-08-27T12:15:53.219Z" },
{ url = "https://files.pythonhosted.org/packages/de/c1/ade645f55de76799fdd08682d51ae6724cb46f318573f18be49b1e040428/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eda8719d598f2f7f3e0f885cba8646644b55a187762bec091fa14a2b819746a9", size = 383090, upload-time = "2025-08-27T12:15:55.158Z" },
{ url = "https://files.pythonhosted.org/packages/1f/27/89070ca9b856e52960da1472efcb6c20ba27cfe902f4f23ed095b9cfc61d/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c64d07e95606ec402a0a1c511fe003873fa6af630bda59bac77fac8b4318ebc", size = 394519, upload-time = "2025-08-27T12:15:57.238Z" },
{ url = "https://files.pythonhosted.org/packages/b3/28/be120586874ef906aa5aeeae95ae8df4184bc757e5b6bd1c729ccff45ed5/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:93a2ed40de81bcff59aabebb626562d48332f3d028ca2036f1d23cbb52750be4", size = 523817, upload-time = "2025-08-27T12:15:59.237Z" },
{ url = "https://files.pythonhosted.org/packages/a8/ef/70cc197bc11cfcde02a86f36ac1eed15c56667c2ebddbdb76a47e90306da/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:387ce8c44ae94e0ec50532d9cb0edce17311024c9794eb196b90e1058aadeb66", size = 403240, upload-time = "2025-08-27T12:16:00.923Z" },
{ url = "https://files.pythonhosted.org/packages/cf/35/46936cca449f7f518f2f4996e0e8344db4b57e2081e752441154089d2a5f/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaf94f812c95b5e60ebaf8bfb1898a7d7cb9c1af5744d4a67fa47796e0465d4e", size = 385194, upload-time = "2025-08-27T12:16:02.802Z" },
{ url = "https://files.pythonhosted.org/packages/e1/62/29c0d3e5125c3270b51415af7cbff1ec587379c84f55a5761cc9efa8cd06/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:4848ca84d6ded9b58e474dfdbad4b8bfb450344c0551ddc8d958bf4b36aa837c", size = 402086, upload-time = "2025-08-27T12:16:04.806Z" },
{ url = "https://files.pythonhosted.org/packages/8f/66/03e1087679227785474466fdd04157fb793b3b76e3fcf01cbf4c693c1949/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2bde09cbcf2248b73c7c323be49b280180ff39fadcfe04e7b6f54a678d02a7cf", size = 419272, upload-time = "2025-08-27T12:16:06.471Z" },
{ url = "https://files.pythonhosted.org/packages/6a/24/e3e72d265121e00b063aef3e3501e5b2473cf1b23511d56e529531acf01e/rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:94c44ee01fd21c9058f124d2d4f0c9dc7634bec93cd4b38eefc385dabe71acbf", size = 560003, upload-time = "2025-08-27T12:16:08.06Z" },
{ url = "https://files.pythonhosted.org/packages/26/ca/f5a344c534214cc2d41118c0699fffbdc2c1bc7046f2a2b9609765ab9c92/rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:df8b74962e35c9249425d90144e721eed198e6555a0e22a563d29fe4486b51f6", size = 590482, upload-time = "2025-08-27T12:16:10.137Z" },
{ url = "https://files.pythonhosted.org/packages/ce/08/4349bdd5c64d9d193c360aa9db89adeee6f6682ab8825dca0a3f535f434f/rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:dc23e6820e3b40847e2f4a7726462ba0cf53089512abe9ee16318c366494c17a", size = 556523, upload-time = "2025-08-27T12:16:12.188Z" },
]
[[package]]
@@ -1056,15 +1094,14 @@ wheels = [
[[package]]
name = "sse-starlette"
version = "2.3.4"
version = "3.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "starlette" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/be/7e776a29b5f712b5bd13c571256a2470fcf345c562c7b2359f2ee15d9355/sse_starlette-2.3.4.tar.gz", hash = "sha256:0ffd6bed217cdbb74a84816437c609278003998b4991cd2e6872d0b35130e4d5", size = 17522, upload-time = "2025-05-04T19:28:51.44Z" }
sdist = { url = "https://files.pythonhosted.org/packages/42/6f/22ed6e33f8a9e76ca0a412405f31abb844b779d52c5f96660766edcd737c/sse_starlette-3.0.2.tar.gz", hash = "sha256:ccd60b5765ebb3584d0de2d7a6e4f745672581de4f5005ab31c3a25d10b52b3a", size = 20985, upload-time = "2025-07-27T09:07:44.565Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/43/a4/ee4a20f0b5ff34c391f3685eff7cdba1178a487766e31b04efb51bbddd87/sse_starlette-2.3.4-py3-none-any.whl", hash = "sha256:b8100694f3f892b133d0f7483acb7aacfcf6ed60f863b31947664b6dc74e529f", size = 10232, upload-time = "2025-05-04T19:28:50.199Z" },
{ url = "https://files.pythonhosted.org/packages/ef/10/c78f463b4ef22eef8491f218f692be838282cd65480f6e423d7730dfd1fb/sse_starlette-3.0.2-py3-none-any.whl", hash = "sha256:16b7cbfddbcd4eaca11f7b586f3b8a080f1afe952c15813455b162edea619e5a", size = 11297, upload-time = "2025-07-27T09:07:43.268Z" },
]
[[package]]
@@ -1083,14 +1120,15 @@ wheels = [
[[package]]
name = "starlette"
version = "0.46.2"
version = "0.47.3"
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/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload-time = "2025-04-13T13:56:17.942Z" }
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" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" },
{ 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" },
]
[[package]]
@@ -1161,7 +1199,7 @@ wheels = [
[[package]]
name = "typer"
version = "0.16.0"
version = "0.17.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
@@ -1169,9 +1207,9 @@ dependencies = [
{ name = "shellingham" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c5/8c/7d682431efca5fd290017663ea4588bf6f2c6aad085c7f108c5dbc316e70/typer-0.16.0.tar.gz", hash = "sha256:af377ffaee1dbe37ae9440cb4e8f11686ea5ce4e9bae01b84ae7c63b87f1dd3b", size = 102625, upload-time = "2025-05-26T14:30:31.824Z" }
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" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/42/3efaf858001d2c2913de7f354563e3a3a2f0decae3efe98427125a8f441e/typer-0.16.0-py3-none-any.whl", hash = "sha256:1f79bed11d4d02d4310e3c1b7ba594183bcedb0ac73b27a9e5f28f6fb5b98855", size = 46317, upload-time = "2025-05-26T14:30:30.523Z" },
{ 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" },
]
[[package]]
@@ -1206,15 +1244,15 @@ wheels = [
[[package]]
name = "uvicorn"
version = "0.34.2"
version = "0.35.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a6/ae/9bbb19b9e1c450cf9ecaef06463e40234d98d95bf572fab11b4f19ae5ded/uvicorn-0.34.2.tar.gz", hash = "sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328", size = 76815, upload-time = "2025-04-19T06:02:50.101Z" }
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" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b1/4b/4cef6ce21a2aaca9d852a6e84ef4f135d99fcd74fa75105e2fc0c8308acd/uvicorn-0.34.2-py3-none-any.whl", hash = "sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403", size = 62483, upload-time = "2025-04-19T06:02:48.42Z" },
{ 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" },
]
[[package]]